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?",