sort IPv6 subnets and addresses numerically instead of randomly

This commit is contained in:
Jarek Krochmalski committed 2026-08-23 10:21:00 +02:00
1 parent 2791e8c36d
commit 114f21385a
3 files changed
+147 -6

No files matched your search

+78 -2
View File
@@ -1,6 +1,8 @@
/**
* Convert IP address (with optional CIDR) to numeric value for sorting
* e.g., "192.168.1.0/24" -> 3232235776, "10.0.0.1" -> 167772161
* Convert an IPv4 address (with optional CIDR) to a numeric value for sorting.
* e.g. "192.168.1.0/24" -> 3232235776, "10.0.0.1" -> 167772161.
* Returns Infinity for empty values or anything that is not IPv4 (IPv6 included) -
* for mixed v4/v6 sorting use compareIps, which orders both families correctly.
*/
export function ipToNumber(ip: string | undefined | null): number {
if (!ip || ip === '-') return Infinity; // Push empty IPs to the end
@@ -13,3 +15,77 @@ export function ipToNumber(ip: string | undefined | null): number {
return isNaN(num) ? Infinity : (acc << 8) + num;
}, 0) >>> 0; // Convert to unsigned 32-bit
}
/**
* Parse an IPv6 address (CIDR stripped) to its 128-bit value, or null if it is not
* a valid IPv6 address. Handles `::` zero-compression and a trailing IPv4 tail
* (e.g. `::ffff:192.168.0.1`).
*/
export function ipv6ToBigInt(ip: string): bigint | null {
let s = ip.split('/')[0].trim();
if (s === '' || !s.includes(':')) return null;
// A trailing dotted-quad (IPv4-in-IPv6) becomes two hextets.
const lastColon = s.lastIndexOf(':');
const tail = s.slice(lastColon + 1);
if (tail.includes('.')) {
const o = tail.split('.');
if (o.length !== 4) return null;
const n = o.map((p) => parseInt(p, 10));
if (n.some((x) => isNaN(x) || x < 0 || x > 255)) return null;
const hi = ((n[0] << 8) | n[1]).toString(16);
const lo = ((n[2] << 8) | n[3]).toString(16);
s = s.slice(0, lastColon + 1) + hi + ':' + lo;
}
const halves = s.split('::');
if (halves.length > 2) return null; // more than one '::' is invalid
const parseGroups = (part: string): string[] | null => {
if (part === '') return [];
const g = part.split(':');
for (const h of g) {
if (h === '' || h.length > 4 || !/^[0-9a-fA-F]+$/.test(h)) return null;
}
return g;
};
const head = parseGroups(halves[0]);
const rest = parseGroups(halves.length === 2 ? halves[1] : '');
if (head === null || rest === null) return null;
let groups: string[];
if (halves.length === 2) {
const fill = 8 - head.length - rest.length;
if (fill < 0) return null;
groups = [...head, ...Array(fill).fill('0'), ...rest];
} else {
groups = head;
}
if (groups.length !== 8) return null;
let value = 0n;
for (const h of groups) value = (value << 16n) + BigInt(parseInt(h, 16));
return value;
}
/**
* Comparator for IP strings (subnet/gateway/address, with or without CIDR) that
* orders IPv4 and IPv6 correctly - IPv4 first, then IPv6, each numerically, with
* empty/invalid values last. Fixes the "random" IPv6 subnet ordering (#1453) that
* ipToNumber alone caused by collapsing every IPv6 value to Infinity.
*/
export function compareIps(a: string | undefined | null, b: string | undefined | null): number {
const rank = (ip: string | undefined | null): { fam: number; val: bigint } => {
if (!ip || ip === '-') return { fam: 2, val: 0n };
const v4 = ipToNumber(ip);
if (v4 !== Infinity) return { fam: 0, val: BigInt(v4) };
const v6 = ipv6ToBigInt(ip);
if (v6 !== null) return { fam: 1, val: v6 };
return { fam: 2, val: 0n }; // unparseable -> last
};
const ra = rank(a);
const rb = rank(b);
if (ra.fam !== rb.fam) return ra.fam - rb.fam;
return ra.val < rb.val ? -1 : ra.val > rb.val ? 1 : 0;
}
+3 -3
View File
@@ -24,7 +24,7 @@
import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state';
import PageHeader from '$lib/components/PageHeader.svelte';
import { DataGrid } from '$lib/components/data-grid';
import { ipToNumber } from '$lib/utils/ip';
import { compareIps } from '$lib/utils/ip';
import NetworkGraphModal from './NetworkGraphModal.svelte';
type SortField = 'name' | 'driver' | 'containers' | 'subnet' | 'gateway';
@@ -226,10 +226,10 @@
cmp = Object.keys(a.containers || {}).length - Object.keys(b.containers || {}).length;
break;
case 'subnet':
cmp = ipToNumber(getNetworkSubnet(a)) - ipToNumber(getNetworkSubnet(b));
cmp = compareIps(getNetworkSubnet(a), getNetworkSubnet(b));
break;
case 'gateway':
cmp = ipToNumber(getNetworkGateway(a)) - ipToNumber(getNetworkGateway(b));
cmp = compareIps(getNetworkGateway(a), getNetworkGateway(b));
break;
}
// Secondary sort by name for stability when primary values are equal
+66 -1
View File
@@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test';
import { ipToNumber } from '../src/lib/utils/ip';
import { ipToNumber, ipv6ToBigInt, compareIps } from '../src/lib/utils/ip';
describe('ipToNumber', () => {
test('converts standard IPv4 addresses', () => {
@@ -31,3 +31,68 @@ describe('ipToNumber', () => {
expect(sorted).toEqual(['10.0.0.1', '172.16.0.1', '192.168.1.2', '192.168.1.10']);
});
});
describe('ipv6ToBigInt', () => {
test('parses full and compressed forms to the same value', () => {
const full = ipv6ToBigInt('2001:0db8:0000:0000:0000:0000:0000:0001');
expect(ipv6ToBigInt('2001:db8::1')).toBe(full);
expect(ipv6ToBigInt('2001:db8::1/64')).toBe(full); // CIDR stripped
});
test('handles :: at the start and end', () => {
expect(ipv6ToBigInt('::1')).toBe(1n);
expect(ipv6ToBigInt('::')).toBe(0n);
expect(ipv6ToBigInt('fe80::')).toBe(ipv6ToBigInt('fe80:0:0:0:0:0:0:0'));
});
test('handles an IPv4 tail (IPv4-mapped)', () => {
expect(ipv6ToBigInt('::ffff:192.168.0.1')).toBe(ipv6ToBigInt('::ffff:c0a8:1'));
});
test('orders IPv6 numerically, not lexically', () => {
// The #1453 case: the last hextet differs; string sort mis-orders 0x9 vs 0x10.
expect(ipv6ToBigInt('2001:db8::9')! < ipv6ToBigInt('2001:db8::10')!).toBe(true);
expect(ipv6ToBigInt('2001:db8::2')! < ipv6ToBigInt('2001:db8::a')!).toBe(true);
});
test('rejects invalid input', () => {
expect(ipv6ToBigInt('192.168.0.1')).toBeNull(); // no colon
expect(ipv6ToBigInt('2001::db8::1')).toBeNull(); // two '::'
expect(ipv6ToBigInt('gggg::1')).toBeNull(); // non-hex
expect(ipv6ToBigInt('1:2:3:4:5:6:7:8:9')).toBeNull(); // too many groups
expect(ipv6ToBigInt('')).toBeNull();
});
});
describe('compareIps (#1453 mixed v4/v6 ordering)', () => {
test('orders IPv6 subnets numerically (the reported bug)', () => {
const subnets = ['2001:db8::10/64', '2001:db8::2/64', '2001:db8::a/64', '2001:db8::1/64'];
const sorted = [...subnets].sort(compareIps);
expect(sorted).toEqual([
'2001:db8::1/64',
'2001:db8::2/64',
'2001:db8::a/64',
'2001:db8::10/64'
]);
});
test('IPv4 sorts before IPv6, each family numerically', () => {
const mixed = ['fd00::1', '10.0.0.5', 'fd00::2', '10.0.0.1', '2001:db8::1'];
const sorted = [...mixed].sort(compareIps);
expect(sorted).toEqual(['10.0.0.1', '10.0.0.5', '2001:db8::1', 'fd00::1', 'fd00::2']);
});
test('empty / invalid values sort last', () => {
const list = ['fd00::1', '-', '10.0.0.1', undefined, null, 'garbage'];
const sorted = [...list].sort(compareIps);
expect(sorted.slice(0, 2)).toEqual(['10.0.0.1', 'fd00::1']);
// the three empty/invalid entries trail, order among them not significant
expect(new Set(sorted.slice(2))).toEqual(new Set(['-', undefined, null, 'garbage']));
});
test('is a stable transitive comparator (returns -1/0/1 semantics)', () => {
expect(compareIps('10.0.0.1', '10.0.0.1')).toBe(0);
expect(compareIps('10.0.0.1', '10.0.0.2')).toBeLessThan(0);
expect(compareIps('2001:db8::2', '2001:db8::1')).toBeGreaterThan(0);
});
});