Improve IP configuration UI (#22320)

* Split netmask from IP address

* handle ipv6 as well

* render fix

* improved UI for IP, mask & DNS

* remove ip detail dialog

* use `nothing`

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

* use ha-list-item instead of mwc

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
This commit is contained in:
Petar Petrov 2024-10-21 19:49:43 +03:00 committed by GitHub
parent e2a89a55b7
commit 202bc6440b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 330 additions and 265 deletions

View File

@ -4,7 +4,7 @@ import { hassioApiResultExtractor, HassioResponse } from "./common";
interface IpConfiguration {
address: string[];
gateway: string;
gateway: string | null;
method: "disabled" | "static" | "auto";
nameservers: string[];
}
@ -114,3 +114,65 @@ export const accesspointScan = async (
)
);
};
export const parseAddress = (address: string) => {
const [ip, cidr] = address.split("/");
return { ip, mask: cidrToNetmask(cidr, address.includes(":")) };
};
export const formatAddress = (ip: string, mask: string) =>
`${ip}/${netmaskToCidr(mask)}`;
// Helper functions
export const cidrToNetmask = (
cidr: string,
isIPv6: boolean = false
): string => {
const bits = parseInt(cidr, 10);
if (isIPv6) {
const fullMask = "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff";
const numGroups = Math.floor(bits / 16);
const remainingBits = bits % 16;
const lastGroup = remainingBits
? parseInt(
"1".repeat(remainingBits) + "0".repeat(16 - remainingBits),
2
).toString(16)
: "";
return fullMask
.split(":")
.slice(0, numGroups)
.concat(lastGroup)
.concat(Array(8 - numGroups - (lastGroup ? 1 : 0)).fill("0"))
.join(":");
}
/* eslint-disable no-bitwise */
const mask = ~(2 ** (32 - bits) - 1);
return [
(mask >>> 24) & 255,
(mask >>> 16) & 255,
(mask >>> 8) & 255,
mask & 255,
].join(".");
/* eslint-enable no-bitwise */
};
export const netmaskToCidr = (netmask: string): number => {
if (netmask.includes(":")) {
// IPv6
return netmask
.split(":")
.map((group) =>
group ? (parseInt(group, 16).toString(2).match(/1/g) || []).length : 0
)
.reduce((sum, val) => sum + val, 0);
}
// IPv4
return netmask
.split(".")
.reduce(
(count, octet) =>
count + (parseInt(octet, 10).toString(2).match(/1/g) || []).length,
0
);
};

View File

@ -1,146 +0,0 @@
import "@material/mwc-button/mwc-button";
import { CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { createCloseHeading } from "../../../components/ha-dialog";
import type { NetworkInterface } from "../../../data/hassio/network";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { IPDetailDialogParams } from "./show-ip-detail-dialog";
@customElement("dialog-ip-detail")
class DialogIPDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: IPDetailDialogParams;
@state() private _interface?: NetworkInterface;
public showDialog(params: IPDetailDialogParams): void {
this._params = params;
this._interface = this._params.interface;
}
public closeDialog() {
this._params = undefined;
this._interface = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._interface) {
return nothing;
}
const ipv4 = this._interface.ipv4;
const ipv6 = this._interface.ipv6;
return html`
<ha-dialog
open
@closed=${this.closeDialog}
scrimClickAction
escapeKeyAction
.heading=${createCloseHeading(
this.hass,
this.hass.localize("ui.dialogs.dialog-ip-detail.ip_information")
)}
>
${ipv4
? html`
<div>
<h3>
${this.hass.localize("ui.dialogs.dialog-ip-detail.ipv4")}
</h3>
${ipv4.address
? html`<div>
${this.hass.localize(
"ui.dialogs.dialog-ip-detail.ip_address",
{ address: ipv4.address?.join(", ") }
)}
</div>`
: ""}
${ipv4.gateway
? html`<div>
${this.hass.localize(
"ui.dialogs.dialog-ip-detail.gateway",
{ gateway: ipv4.gateway }
)}
</div>`
: ""}
${ipv4.method
? html`<div>
${this.hass.localize(
"ui.dialogs.dialog-ip-detail.method",
{ method: ipv4.method }
)}
</div>`
: ""}
${ipv4.nameservers?.length
? html`
<div>
${this.hass.localize(
"ui.dialogs.dialog-ip-detail.nameservers",
{ nameservers: ipv4.nameservers?.join(", ") }
)}
</div>
`
: ""}
</div>
`
: ""}
${ipv6
? html`
<div>
<h3>
${this.hass.localize("ui.dialogs.dialog-ip-detail.ipv6")}
</h3>
${ipv6.address
? html`<div>
${this.hass.localize(
"ui.dialogs.dialog-ip-detail.ip_address",
{ address: ipv6.address?.join(", ") }
)}
</div>`
: ""}
${ipv6.gateway
? html`<div>
${this.hass.localize(
"ui.dialogs.dialog-ip-detail.gateway",
{ gateway: ipv6.gateway }
)}
</div>`
: ""}
${ipv6.method
? html`<div>
${this.hass.localize(
"ui.dialogs.dialog-ip-detail.method",
{ method: ipv6.method }
)}
</div>`
: ""}
${ipv6.nameservers?.length
? html`
<div>
${this.hass.localize(
"ui.dialogs.dialog-ip-detail.nameservers",
{ nameservers: ipv6.nameservers?.join(", ") }
)}
</div>
`
: ""}
</div>
`
: ""}
</ha-dialog>
`;
}
static styles: CSSResultGroup = haStyleDialog;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-ip-detail": DialogIPDetail;
}
}

View File

@ -1,19 +0,0 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { NetworkInterface } from "../../../data/hassio/network";
export interface IPDetailDialogParams {
interface?: NetworkInterface;
}
export const loadIPDetailDialog = () => import("./dialog-ip-detail");
export const showIPDetailDialog = (
element: HTMLElement,
dialogParams: IPDetailDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-ip-detail",
dialogImport: loadIPDetailDialog,
dialogParams,
});
};

View File

@ -1,13 +1,11 @@
import "@material/mwc-button/mwc-button";
import { ActionDetail } from "@material/mwc-list/mwc-list";
import "@material/mwc-list/mwc-list-item";
import "@material/mwc-tab";
import "@material/mwc-tab-bar";
import { mdiDotsVertical } from "@mdi/js";
import { mdiDeleteOutline, mdiPlus, mdiMenuDown } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { cache } from "lit/directives/cache";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-button-menu";
import "../../../components/ha-card";
import "../../../components/ha-circular-progress";
@ -16,6 +14,7 @@ import "../../../components/ha-formfield";
import "../../../components/ha-icon-button";
import "../../../components/ha-password-field";
import "../../../components/ha-radio";
import "../../../components/ha-list-item";
import type { HaRadio } from "../../../components/ha-radio";
import "../../../components/ha-textfield";
import type { HaTextField } from "../../../components/ha-textfield";
@ -24,7 +23,9 @@ import {
AccessPoints,
accesspointScan,
fetchNetworkInfo,
formatAddress,
NetworkInterface,
parseAddress,
updateNetworkInterface,
WifiConfiguration,
} from "../../../data/hassio/network";
@ -33,10 +34,26 @@ import {
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
import { showIPDetailDialog } from "./show-ip-detail-dialog";
const IP_VERSIONS = ["ipv4", "ipv6"];
const PREDEFINED_DNS = {
ipv4: {
Cloudflare: ["1.1.1.1", "1.0.0.1"],
Google: ["8.8.8.8", "8.8.4.4"],
Quad9: ["9.9.9.9", "149.112.112.112"],
NextDNS: ["45.90.28.0", "45.90.30.0"],
AdGuard: ["94.140.14.140", "94.140.14.141"],
},
ipv6: {
Cloudflare: ["2606:4700:4700::1111", "2606:4700:4700::1001"],
Google: ["2001:4860:4860::8888", "2001:4860:4860::8844"],
Quad9: ["2620:fe::fe", "2620:fe::9"],
NextDNS: ["2a05:d014:a000::", "2a05:d014:a001::"],
AdGuard: ["94.140.14.140", "94.140.14.141"],
},
};
@customElement("supervisor-network")
export class HassioNetwork extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@ -57,6 +74,8 @@ export class HassioNetwork extends LitElement {
@state() private _wifiConfiguration?: WifiConfiguration;
@state() private _dnsMenuOpen = false;
protected firstUpdated() {
this._fetchNetworkInfo();
}
@ -121,7 +140,7 @@ export class HassioNetwork extends LitElement {
)}
</p>`
: ""}
<mwc-button
<ha-button
class="scan"
@click=${this._scanForAP}
.disabled=${this._scanning}
@ -132,7 +151,7 @@ export class HassioNetwork extends LitElement {
: this.hass.localize(
"ui.panel.config.network.supervisor.scan_ap"
)}
</mwc-button>
</ha-button>
${this._accessPoints &&
this._accessPoints.accesspoints &&
this._accessPoints.accesspoints.length !== 0
@ -142,7 +161,7 @@ export class HassioNetwork extends LitElement {
.filter((ap) => ap.ssid)
.map(
(ap) => html`
<mwc-list-item
<ha-list-item
twoline
@click=${this._selectAP}
.activated=${ap.ssid ===
@ -157,7 +176,7 @@ export class HassioNetwork extends LitElement {
)}:
${ap.signal}
</span>
</mwc-list-item>
</ha-list-item>
`
)}
</mwc-list>
@ -240,35 +259,15 @@ export class HassioNetwork extends LitElement {
: ""}
</div>
<div class="card-actions">
<mwc-button @click=${this._updateNetwork} .disabled=${!this._dirty}>
<ha-button @click=${this._updateNetwork} .disabled=${!this._dirty}>
${this._processing
? html`<ha-circular-progress indeterminate size="small">
</ha-circular-progress>`
: this.hass.localize("ui.common.save")}
</mwc-button>
<ha-button-menu @action=${this._handleAction}>
<ha-icon-button
slot="trigger"
.label=${"ui.common.menu"}
.path=${mdiDotsVertical}
></ha-icon-button>
<mwc-list-item
>${this.hass.localize(
"ui.panel.config.network.ip_information"
)}</mwc-list-item
>
</ha-button-menu>
</ha-button>
</div>`;
}
private _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
showIPDetailDialog(this, { interface: this._interface });
break;
}
}
private _selectAP(event) {
this._wifiConfiguration = event.currentTarget.ap;
this._dirty = true;
@ -295,6 +294,11 @@ export class HassioNetwork extends LitElement {
}
private _renderIPConfiguration(version: string) {
const nameservers = this._interface![version]?.nameservers || [];
if (nameservers.length === 0) {
nameservers.push(""); // always show input
}
const disableInputs = this._interface![version]?.method === "auto";
return html`
<ha-expansion-panel
.header=${`IPv${version.charAt(version.length - 1)}`}
@ -345,69 +349,146 @@ export class HassioNetwork extends LitElement {
</ha-radio>
</ha-formfield>
</div>
${this._interface![version].method === "static"
${["static", "auto"].includes(this._interface![version].method)
? html`
<ha-textfield
id="address"
.label=${this.hass.localize(
"ui.panel.config.network.supervisor.ip_netmask"
)}
.version=${version}
.value=${this._toString(this._interface![version].address)}
@change=${this._handleInputValueChanged}
>
</ha-textfield>
${this._interface![version].address.map(
(address: string, index: number) => {
const { ip, mask } = parseAddress(address);
return html`
<div class="address-row">
<ha-textfield
id="address"
.label=${this.hass.localize(
"ui.panel.config.network.supervisor.ip"
)}
.version=${version}
.value=${ip}
.index=${index}
@change=${this._handleInputValueChanged}
.disabled=${disableInputs}
>
</ha-textfield>
<ha-textfield
id="netmask"
.label=${this.hass.localize(
"ui.panel.config.network.supervisor.netmask"
)}
.version=${version}
.value=${mask}
.index=${index}
@change=${this._handleInputValueChanged}
.disabled=${disableInputs}
>
</ha-textfield>
${this._interface![version].address.length > 1 &&
!disableInputs
? html`
<ha-icon-button
.label=${this.hass.localize("ui.common.delete")}
.path=${mdiDeleteOutline}
.version=${version}
.index=${index}
@click=${this._removeAddress}
></ha-icon-button>
`
: nothing}
</div>
`;
}
)}
${!disableInputs
? html`
<ha-button
@click=${this._addAddress}
.version=${version}
class="add-address"
>
${this.hass.localize(
"ui.panel.config.network.supervisor.add_address"
)}
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-button>
`
: nothing}
<ha-textfield
id="gateway"
.label=${this.hass.localize(
"ui.panel.config.network.supervisor.gateway"
)}
.version=${version}
.value=${this._interface![version].gateway}
.value=${this._interface![version].gateway || ""}
@change=${this._handleInputValueChanged}
.disabled=${disableInputs}
>
</ha-textfield>
<ha-textfield
id="nameservers"
.label=${this.hass.localize(
"ui.panel.config.network.supervisor.dns_servers"
<div class="nameservers">
${nameservers.map(
(nameserver: string, index: number) => html`
<div class="address-row">
<ha-textfield
id="nameserver"
.label=${this.hass.localize(
"ui.panel.config.network.supervisor.dns_server"
)}
.version=${version}
.value=${nameserver}
.index=${index}
@change=${this._handleInputValueChanged}
>
</ha-textfield>
${this._interface![version].nameservers?.length > 1
? html`
<ha-icon-button
.label=${this.hass.localize("ui.common.delete")}
.path=${mdiDeleteOutline}
.version=${version}
.index=${index}
@click=${this._removeNameserver}
></ha-icon-button>
`
: nothing}
</div>
`
)}
</div>
<ha-button-menu
@opened=${this._handleDNSMenuOpened}
@closed=${this._handleDNSMenuClosed}
.version=${version}
.value=${this._toString(this._interface![version].nameservers)}
@change=${this._handleInputValueChanged}
class="add-nameserver"
>
</ha-textfield>
<ha-button slot="trigger">
${this.hass.localize(
"ui.panel.config.network.supervisor.add_dns_server"
)}
<ha-svg-icon
slot="icon"
.path=${this._dnsMenuOpen ? mdiMenuDown : mdiPlus}
></ha-svg-icon>
</ha-button>
${Object.entries(PREDEFINED_DNS[version]).map(
([name, addresses]) => html`
<ha-list-item
@click=${this._addPredefinedDNS}
.version=${version}
.addresses=${addresses}
>
${name}
</ha-list-item>
`
)}
<ha-list-item @click=${this._addCustomDNS} .version=${version}>
${this.hass.localize(
"ui.panel.config.network.supervisor.custom_dns"
)}
</ha-list-item>
</ha-button-menu>
`
: ""}
</ha-expansion-panel>
`;
}
_toArray(data: string | string[]): string[] {
if (Array.isArray(data)) {
if (data && typeof data[0] === "string") {
data = data[0];
}
}
if (!data) {
return [];
}
if (typeof data === "string") {
return data.replace(/ /g, "").split(",");
}
return data;
}
_toString(data: string | string[]): string {
if (!data) {
return "";
}
if (Array.isArray(data)) {
return data.join(", ");
}
return data;
}
private async _updateNetwork() {
this._processing = true;
let interfaceOptions: Partial<NetworkInterface> = {};
@ -419,9 +500,13 @@ export class HassioNetwork extends LitElement {
if (this._interface![version]?.method === "static") {
interfaceOptions[version] = {
...interfaceOptions[version],
address: this._toArray(this._interface![version]?.address),
address: this._interface![version]?.address?.filter(
(address: string) => address.trim()
),
gateway: this._interface![version]?.gateway,
nameservers: this._toArray(this._interface![version]?.nameservers),
nameservers: this._interface![version]?.nameservers?.filter(
(ns: string) => ns.trim()
),
};
}
});
@ -515,16 +600,28 @@ export class HassioNetwork extends LitElement {
const version = (ev.target as any).version as "ipv4" | "ipv6";
const id = source.id;
if (
!value ||
!this._interface ||
this._toString(this._interface[version]![id]) === this._toString(value)
) {
if (!value || !this._interface?.[version]) {
return;
}
this._dirty = true;
this._interface[version]![id] = value;
if (id === "address") {
const index = (ev.target as any).index as number;
const { mask } = parseAddress(value);
this._interface[version]!.address![index] = formatAddress(value, mask);
this.requestUpdate("_interface");
} else if (id === "netmask") {
const index = (ev.target as any).index as number;
const { ip } = parseAddress(this._interface![version]!.address![index]);
this._interface[version]!.address![index] = formatAddress(ip, value);
this.requestUpdate("_interface");
} else if (id === "nameserver") {
const index = (ev.target as any).index as number;
this._interface[version]!.nameservers![index] = value;
this.requestUpdate("_interface");
} else {
this._interface[version]![id] = value;
}
}
private _handleInputValueChangedWifi(ev: Event): void {
@ -543,6 +640,64 @@ export class HassioNetwork extends LitElement {
this._wifiConfiguration![id] = value;
}
private _addAddress(ev: Event): void {
const version = (ev.target as any).version as "ipv4" | "ipv6";
this._interface![version]!.address!.push(
version === "ipv4" ? "0.0.0.0/24" : "::/64"
);
this._dirty = true;
this.requestUpdate("_interface");
}
private _removeAddress(ev: Event): void {
const source = ev.target as any;
const index = source.index as number;
const version = source.version as "ipv4" | "ipv6";
this._interface![version]!.address!.splice(index, 1);
this._dirty = true;
this.requestUpdate("_interface");
}
private _handleDNSMenuOpened() {
this._dnsMenuOpen = true;
}
private _handleDNSMenuClosed() {
this._dnsMenuOpen = false;
}
private _addPredefinedDNS(ev: Event) {
const source = ev.target as any;
const version = source.version as "ipv4" | "ipv6";
const addresses = source.addresses as string[];
if (!this._interface![version]!.nameservers) {
this._interface![version]!.nameservers = [];
}
this._interface![version]!.nameservers!.push(...addresses);
this._dirty = true;
this.requestUpdate("_interface");
}
private _addCustomDNS(ev: Event) {
const source = ev.target as any;
const version = source.version as "ipv4" | "ipv6";
if (!this._interface![version]!.nameservers) {
this._interface![version]!.nameservers = [];
}
this._interface![version]!.nameservers!.push("");
this._dirty = true;
this.requestUpdate("_interface");
}
private _removeNameserver(ev: Event): void {
const source = ev.target as any;
const index = source.index as number;
const version = source.version as "ipv4" | "ipv6";
this._interface![version]!.nameservers!.splice(index, 1);
this._dirty = true;
this.requestUpdate("_interface");
}
static get styles(): CSSResultGroup {
return [
css`
@ -557,11 +712,11 @@ export class HassioNetwork extends LitElement {
padding: 20px 24px;
}
mwc-button.warning {
ha-button.warning {
--mdc-theme-primary: var(--error-color);
}
mwc-button.scan {
ha-button.scan {
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
@ -574,10 +729,24 @@ export class HassioNetwork extends LitElement {
display: block;
margin-top: 16px;
}
ha-expansion-panel ha-textfield:last-child {
margin-bottom: 16px;
.address-row {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
}
mwc-list-item {
.address-row ha-textfield {
flex: 1;
}
.address-row ha-icon-button {
--mdc-icon-button-size: 36px;
margin-top: 16px;
}
.add-address,
.add-nameserver {
margin-top: 16px;
}
ha-list-item {
--mdc-list-side-padding: 10px;
}
.card-actions {
@ -586,6 +755,9 @@ export class HassioNetwork extends LitElement {
justify-content: space-between;
align-items: center;
}
ha-expansion-panel > :last-child {
margin-bottom: 16px;
}
`,
];
}

View File

@ -1708,15 +1708,6 @@
"message_placeholder": "Enter a sentence to speak.",
"play": "Play"
},
"dialog-ip-detail": {
"ip_information": "[%key:ui::panel::config::network::ip_information%]",
"ipv4": "IPv4",
"ipv6": "IPv6",
"ip_address": "IP Address: {address}",
"gateway": "Gateway: {gateway}",
"method": "Method: {method}",
"nameservers": "Name Servers: {nameservers}"
},
"update_backup": {
"title": "Create backup?",
"text": "This will create a backup before installing.",
@ -5240,9 +5231,13 @@
"static": "Static",
"auto": "Automatic",
"disabled": "Disabled",
"ip_netmask": "IP address/Netmask",
"ip": "IP address",
"netmask": "Netmask",
"add_address": "Add address",
"gateway": "Gateway address",
"dns_servers": "DNS Servers",
"dns_server": "DNS Server",
"add_dns_server": "Add DNS Server",
"custom_dns": "Custom",
"unsaved": "You have unsaved changes, these will get lost if you change tabs, do you want to continue?",
"failed_to_change": "Failed to change network settings",
"hostname": {
@ -7726,6 +7721,7 @@
"auto": "Automatic",
"disabled": "Disabled",
"ip_netmask": "IP address/netmask",
"netmask": "Netmask",
"gateway": "Gateway address",
"dns_servers": "DNS servers",
"unsaved": "You have unsaved changes, these will get lost if you change tabs, do you want to continue?",