From 202bc6440b7012fa23ee71cc069d02da035e66fb Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Mon, 21 Oct 2024 19:49:43 +0300 Subject: [PATCH] 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 * use ha-list-item instead of mwc --------- Co-authored-by: Paul Bottein --- src/data/hassio/network.ts | 64 +++- src/panels/config/network/dialog-ip-detail.ts | 146 -------- .../config/network/show-ip-detail-dialog.ts | 19 - .../config/network/supervisor-network.ts | 348 +++++++++++++----- src/translations/en.json | 18 +- 5 files changed, 330 insertions(+), 265 deletions(-) delete mode 100644 src/panels/config/network/dialog-ip-detail.ts delete mode 100644 src/panels/config/network/show-ip-detail-dialog.ts diff --git a/src/data/hassio/network.ts b/src/data/hassio/network.ts index 7c3483cb6b..a0b501d429 100644 --- a/src/data/hassio/network.ts +++ b/src/data/hassio/network.ts @@ -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 + ); +}; diff --git a/src/panels/config/network/dialog-ip-detail.ts b/src/panels/config/network/dialog-ip-detail.ts deleted file mode 100644 index e998e9c059..0000000000 --- a/src/panels/config/network/dialog-ip-detail.ts +++ /dev/null @@ -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` - - ${ipv4 - ? html` -
-

- ${this.hass.localize("ui.dialogs.dialog-ip-detail.ipv4")} -

- ${ipv4.address - ? html`
- ${this.hass.localize( - "ui.dialogs.dialog-ip-detail.ip_address", - { address: ipv4.address?.join(", ") } - )} -
` - : ""} - ${ipv4.gateway - ? html`
- ${this.hass.localize( - "ui.dialogs.dialog-ip-detail.gateway", - { gateway: ipv4.gateway } - )} -
` - : ""} - ${ipv4.method - ? html`
- ${this.hass.localize( - "ui.dialogs.dialog-ip-detail.method", - { method: ipv4.method } - )} -
` - : ""} - ${ipv4.nameservers?.length - ? html` -
- ${this.hass.localize( - "ui.dialogs.dialog-ip-detail.nameservers", - { nameservers: ipv4.nameservers?.join(", ") } - )} -
- ` - : ""} -
- ` - : ""} - ${ipv6 - ? html` -
-

- ${this.hass.localize("ui.dialogs.dialog-ip-detail.ipv6")} -

- ${ipv6.address - ? html`
- ${this.hass.localize( - "ui.dialogs.dialog-ip-detail.ip_address", - { address: ipv6.address?.join(", ") } - )} -
` - : ""} - ${ipv6.gateway - ? html`
- ${this.hass.localize( - "ui.dialogs.dialog-ip-detail.gateway", - { gateway: ipv6.gateway } - )} -
` - : ""} - ${ipv6.method - ? html`
- ${this.hass.localize( - "ui.dialogs.dialog-ip-detail.method", - { method: ipv6.method } - )} -
` - : ""} - ${ipv6.nameservers?.length - ? html` -
- ${this.hass.localize( - "ui.dialogs.dialog-ip-detail.nameservers", - { nameservers: ipv6.nameservers?.join(", ") } - )} -
- ` - : ""} -
- ` - : ""} -
- `; - } - - static styles: CSSResultGroup = haStyleDialog; -} - -declare global { - interface HTMLElementTagNameMap { - "dialog-ip-detail": DialogIPDetail; - } -} diff --git a/src/panels/config/network/show-ip-detail-dialog.ts b/src/panels/config/network/show-ip-detail-dialog.ts deleted file mode 100644 index cc4a4fedfe..0000000000 --- a/src/panels/config/network/show-ip-detail-dialog.ts +++ /dev/null @@ -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, - }); -}; diff --git a/src/panels/config/network/supervisor-network.ts b/src/panels/config/network/supervisor-network.ts index 166b60b58f..2824d7eadf 100644 --- a/src/panels/config/network/supervisor-network.ts +++ b/src/panels/config/network/supervisor-network.ts @@ -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 { )}

` : ""} - + ${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` - - + ` )} @@ -240,35 +259,15 @@ export class HassioNetwork extends LitElement { : ""}
- + ${this._processing ? html` ` : this.hass.localize("ui.common.save")} - - - - ${this.hass.localize( - "ui.panel.config.network.ip_information" - )} - +
`; } - private _handleAction(ev: CustomEvent) { - 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` - ${this._interface![version].method === "static" + ${["static", "auto"].includes(this._interface![version].method) ? html` - - + ${this._interface![version].address.map( + (address: string, index: number) => { + const { ip, mask } = parseAddress(address); + return html` +
+ + + + + ${this._interface![version].address.length > 1 && + !disableInputs + ? html` + + ` + : nothing} +
+ `; + } + )} + ${!disableInputs + ? html` + + ${this.hass.localize( + "ui.panel.config.network.supervisor.add_address" + )} + + + ` + : nothing} - + ${nameservers.map( + (nameserver: string, index: number) => html` +
+ + + ${this._interface![version].nameservers?.length > 1 + ? html` + + ` + : nothing} +
+ ` )} + + -
+ + ${this.hass.localize( + "ui.panel.config.network.supervisor.add_dns_server" + )} + + + ${Object.entries(PREDEFINED_DNS[version]).map( + ([name, addresses]) => html` + + ${name} + + ` + )} + + ${this.hass.localize( + "ui.panel.config.network.supervisor.custom_dns" + )} + + ` : ""}
`; } - _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 = {}; @@ -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; + } `, ]; } diff --git a/src/translations/en.json b/src/translations/en.json index 63733d3edd..54b9a19b3b 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -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?",