From f47336392cef0bcfddebea55420e1d0579642b69 Mon Sep 17 00:00:00 2001 From: Bastian Date: Fri, 20 Jun 2025 14:01:02 +0200 Subject: [PATCH] Fix/dhcp config network sort (#25799) * Add ip sort method to compare helper * Add ip sort functionality to dhcp config panel datatable * Add type ip to DataTableColumnData * Change ip sorting to padStart method for better readablity * Rename ip compare method to clarify ipv4 * Enhance IP compare method to include ipv6 * Add compare IP test --- src/common/string/compare.ts | 53 +++++++++++++++++++ src/components/data-table/ha-data-table.ts | 1 + .../data-table/sort-filter-worker.ts | 4 +- .../dhcp/dhcp-config-panel.ts | 1 + test/common/string/compare_ip.test.ts | 34 ++++++++++++ 5 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 test/common/string/compare_ip.test.ts diff --git a/src/common/string/compare.ts b/src/common/string/compare.ts index 65952c2922..9d6aca76b8 100644 --- a/src/common/string/compare.ts +++ b/src/common/string/compare.ts @@ -1,4 +1,5 @@ import memoizeOne from "memoize-one"; +import { isIPAddress } from "./is_ip_address"; const collator = memoizeOne( (language: string | undefined) => new Intl.Collator(language) @@ -33,6 +34,19 @@ export const stringCompare = ( return fallbackStringCompare(a, b); }; +export const ipCompare = (a: string, b: string) => { + const aIsIpV4 = isIPAddress(a); + const bIsIpV4 = isIPAddress(b); + + if (aIsIpV4 && bIsIpV4) { + return ipv4Compare(a, b); + } + if (!aIsIpV4 && !bIsIpV4) { + return ipV6Compare(a, b); + } + return aIsIpV4 ? -1 : 1; +}; + export const caseInsensitiveStringCompare = ( a: string, b: string, @@ -64,3 +78,42 @@ export const orderCompare = (order: string[]) => (a: string, b: string) => { return idxA - idxB; }; + +function ipv4Compare(a: string, b: string) { + const num1 = Number( + a + .split(".") + .map((num) => num.padStart(3, "0")) + .join("") + ); + const num2 = Number( + b + .split(".") + .map((num) => num.padStart(3, "0")) + .join("") + ); + return num1 - num2; +} + +function ipV6Compare(a: string, b: string) { + const ipv6a = normalizeIPv6(a) + .split(":") + .map((part) => part.padStart(4, "0")) + .join(""); + const ipv6b = normalizeIPv6(b) + .split(":") + .map((part) => part.padStart(4, "0")) + .join(""); + + return ipv6a.localeCompare(ipv6b); +} + +function normalizeIPv6(ip) { + const parts = ip.split("::"); + const head = parts[0].split(":"); + const tail = parts[1] ? parts[1].split(":") : []; + const totalParts = 8; + const missing = totalParts - (head.length + tail.length); + const zeros = new Array(missing).fill("0"); + return [...head, ...zeros, ...tail].join(":"); +} diff --git a/src/components/data-table/ha-data-table.ts b/src/components/data-table/ha-data-table.ts index b0cf4bab88..0369758b29 100644 --- a/src/components/data-table/ha-data-table.ts +++ b/src/components/data-table/ha-data-table.ts @@ -72,6 +72,7 @@ export interface DataTableColumnData extends DataTableSortColumnData { label?: TemplateResult | string; type?: | "numeric" + | "ip" | "icon" | "icon-button" | "overflow" diff --git a/src/components/data-table/sort-filter-worker.ts b/src/components/data-table/sort-filter-worker.ts index b34ec899ff..a4da225cbd 100644 --- a/src/components/data-table/sort-filter-worker.ts +++ b/src/components/data-table/sort-filter-worker.ts @@ -1,5 +1,5 @@ import { expose } from "comlink"; -import { stringCompare } from "../../common/string/compare"; +import { stringCompare, ipCompare } from "../../common/string/compare"; import { stripDiacritics } from "../../common/string/strip-diacritics"; import type { ClonedDataTableColumnData, @@ -57,6 +57,8 @@ const sortData = ( if (column.type === "numeric") { valA = isNaN(valA) ? undefined : Number(valA); valB = isNaN(valB) ? undefined : Number(valB); + } else if (column.type === "ip") { + return sort * ipCompare(valA, valB); } else if (typeof valA === "string" && typeof valB === "string") { return sort * stringCompare(valA, valB, language); } diff --git a/src/panels/config/integrations/integration-panels/dhcp/dhcp-config-panel.ts b/src/panels/config/integrations/integration-panels/dhcp/dhcp-config-panel.ts index 683719ee43..fdf8768c6f 100644 --- a/src/panels/config/integrations/integration-panels/dhcp/dhcp-config-panel.ts +++ b/src/panels/config/integrations/integration-panels/dhcp/dhcp-config-panel.ts @@ -60,6 +60,7 @@ export class DHCPConfigPanel extends SubscribeMixin(LitElement) { title: localize("ui.panel.config.dhcp.ip_address"), filterable: true, sortable: true, + type: "ip", }, }; diff --git a/test/common/string/compare_ip.test.ts b/test/common/string/compare_ip.test.ts new file mode 100644 index 0000000000..ddb7e7146a --- /dev/null +++ b/test/common/string/compare_ip.test.ts @@ -0,0 +1,34 @@ +import { assert, describe, it } from "vitest"; +import { ipCompare } from "../../../src/common/string/compare"; +import { isIPAddress } from "../../../src/common/string/is_ip_address"; + +describe("compareIpAdresses", () => { + const ipAddresses: string[] = [ + "192.168.1.1", + "10.0.0.1", + "fe80::85d:e82c:9446:7995", + "192.168.0.1", + "fe80::85d:e82c:9446:7994", + "::ffff:192.168.1.1", + "1050:0000:0000:0000:0005:0600:300c:326b", + ]; + const expected: string[] = [ + "10.0.0.1", + "192.168.0.1", + "192.168.1.1", + "::ffff:192.168.1.1", + "1050:0000:0000:0000:0005:0600:300c:326b", + "fe80::85d:e82c:9446:7994", + "fe80::85d:e82c:9446:7995", + ]; + + const sorted = [...ipAddresses].sort(ipCompare); + + it("Detects ipv4 addresses", () => { + assert.isTrue(isIPAddress("192.168.0.1")); + }); + + it("Compares ipv4 and ipv6 addresses", () => { + assert.deepEqual(sorted, expected); + }); +});