Files
nxdns/admin/src/features/overview/clientScope.ts
T
mokhtar 85b8be50a0
Gates / frontend (push) Successful in 1m57s
Gates / test (push) Successful in 2m34s
Gates / test-aarch64 (push) Successful in 8m9s
Gates / package (push) Successful in 7m14s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 18m17s
Release / guard (push) Successful in 33s
Gates / test-aarch64 (push) Successful in 7m22s
Gates / container (push) Successful in 11s
Release / gates (push) Successful in 10m35s
Gates / frontend (push) Successful in 2m8s
Gates / test (push) Successful in 2m16s
Gates / package (push) Successful in 44s
Release / publish (push) Successful in 10m4s
admin: overview redesign, device scope, one formatting contract (milestone 39)
The Overview page takes the decided visual language (specs/ui-visual-redesign.md): four centred totals with their Activity links, a smoothed area chart of total and blocked queries with point hover and a tooltip centred beside the point, a stacked client chart in eight distinct hues plus one Other band that is always a series, and a card row with the cache hit rate, the query types as a single-hue ramp ring, and the upstream breakdown. The count axis grows its margin with the widest grouped tick and draws whole-number ticks only.

GET /api/overview takes a client parameter; the scoped read uses idx_query_log_ts and the cache keeps scoped slots. The device selector beside the period selector is URL state, so a scoped view is a link, and the tile links carry the scope into Activity. The route reduces a pasted IPv6 scope to the RFC 5952 spelling the logger stores, mapped addresses included, and drops anything that is not an address. A failed device list says so under the selector with a retry.

All measured quantities go through admin/src/lib/format.ts: grouped counts, two-decimal percentages, one-decimal rates, durations as the two largest nonzero units. Identifiers, configured values and preset labels render as written; the module header states that scope. A sweep test refuses toFixed, toLocaleString, Intl.NumberFormat and padStart anywhere else.

Chrome: one 4px radius from the metrics constants, shared Card with a prominent title and a one-line description on every panel, the settings form sections on the same card with a floated legend, the sidebar grouped into Monitoring and System with a status block (protection, queries per minute on Overview, uptime), keyboard-focusable table scroll wrappers, and the accent darkened to 5.43:1 on its wash.

Not built: the spec's ranked-list primitive, which has no consumer and no API rows. Codex reviewed sessions B to D over five rounds (thirty-three findings fixed, thirteen rejected as non-quantities); the owner skipped a sixth round.

Claude-Session: https://claude.ai/code/session_01VTgx3a1zz1R78o4K55kkwR
2026-09-07 23:11:50 +02:00

97 lines
4.0 KiB
TypeScript

/**
* The device Overview is scoped to, as URL state beside the period.
*
* The API takes one exact address (no list) and matches the text the logger
* stored, so that is what the route lets through: a value that is not an IPv4
* or IPv6 address is dropped from the URL rather than sent on to a 400, and an
* IPv6 address is reduced to the RFC 5952 spelling the logger writes, so an
* uppercase or expanded form in a pasted link still finds its client. The
* server writes hex groups only, so an IPv4 tail is folded into the two hextets
* it names and an IPv4-mapped address is reduced to its plain IPv4. An absent
* scope is the whole household.
*/
const IPV4 = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
const HEXTET = /^[0-9a-f]{1,4}$/i;
/** The two hextets an IPv4 tail names: `192.0.2.30` is `c000:21e`. */
function ipv4Hextets(dotted: string): string[] {
const octets = dotted.split(".").map(Number);
return [((octets[0] << 8) | octets[1]).toString(16), ((octets[2] << 8) | octets[3]).toString(16)];
}
/**
* One side of a `::` as hextets, or null. An IPv4 tail is folded into the two
* hextets it names — the server prints an IPv6 address as hex groups only,
* never with a dotted tail — and it is read only where the address ends, since
* the tail is the last 32 bits and nothing may follow it.
*/
function sideHextets(parts: string[], dottedAllowed: boolean): string[] | null {
const last = parts[parts.length - 1];
const hextets =
last !== undefined && last.includes(".")
? dottedAllowed && IPV4.test(last)
? [...parts.slice(0, -1), ...ipv4Hextets(last)]
: null
: parts;
if (hextets === null) return null;
return hextets.every((group) => HEXTET.test(group)) ? hextets : null;
}
/**
* RFC 4291 text — up to eight hextets, one `::` at most, an IPv4 tail allowed
* as the last 32 bits — parsed to its eight hextets, or null.
*/
function ipv6Groups(value: string): string[] | null {
const halves = value.split("::");
if (halves.length > 2) return null;
const split = halves.length === 2;
const head = sideHextets(halves[0] === "" ? [] : (halves[0] as string).split(":"), !split);
const tail = sideHextets(split && halves[1] !== "" ? (halves[1] as string).split(":") : [], split);
if (head === null || tail === null) return null;
const width = head.length + tail.length;
if (split ? width >= 8 : width !== 8) return null;
const zeros = Array.from({ length: 8 - width }, () => "0");
const expanded = split ? [...head, ...zeros, ...tail] : head;
return expanded.map((group) => group.replace(/^0+(?=.)/, "").toLowerCase());
}
/** RFC 5952: lowercase, no leading zeros, the longest run of two or more zero groups as `::` (the first on a tie). */
function compressIpv6(groups: string[]): string {
let best = { start: -1, length: 0 };
for (let i = 0; i < groups.length;) {
if (groups[i] !== "0") {
i += 1;
continue;
}
let j = i;
while (j < groups.length && groups[j] === "0") j += 1;
if (j - i >= 2 && j - i > best.length) best = { start: i, length: j - i };
i = j;
}
if (best.start < 0) return groups.join(":");
const head = groups.slice(0, best.start).join(":");
const tail = groups.slice(best.start + best.length).join(":");
return `${head}::${tail}`;
}
/**
* The dotted IPv4 an IPv4-mapped address stands for, or null. The server
* normalizes `::ffff:a.b.c.d` to the plain `a.b.c.d`, so a link that spells the
* mapped form has to be reduced the same way to find its client.
*/
function mappedIpv4(groups: string[]): string | null {
if (groups.slice(0, 5).some((group) => group !== "0") || groups[5] !== "ffff") return null;
const high = Number.parseInt(groups[6] as string, 16);
const low = Number.parseInt(groups[7] as string, 16);
return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
}
export function parseClient(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
if (IPV4.test(value)) return value;
const groups = ipv6Groups(value);
if (groups === null) return undefined;
return mappedIpv4(groups) ?? compressIpv6(groups);
}