admin: overview redesign, device scope, one formatting contract (milestone 39)
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

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
This commit is contained in:
2026-09-07 23:11:50 +02:00
parent e656670dd4
commit 85b8be50a0
77 changed files with 2869 additions and 1163 deletions
+78 -12
View File
@@ -31,6 +31,7 @@ const std = @import("std");
const coverage = @import("../coverage.zig");
const db = @import("../../storage/db.zig");
const http_util = @import("../http_util.zig");
const logger = @import("../../storage/logger.zig");
const queries_repo = @import("../../storage/repositories/queries_repo.zig");
const server = @import("../server.zig");
@@ -139,10 +140,12 @@ pub fn handle(
request: *http_util.Request,
) http_util.HandlerError!void {
const period = periodParam(request.query) catch return badPeriod(request);
var client_buf: [max_client_value_len]u8 = undefined;
const client = clientParam(request.query, &client_buf) catch return badClient(request);
const database = state.querylog_db orelse return unavailable(request);
const span = window(period, std.Io.Clock.real.now(io).toSeconds());
const body = cachedBody(state, io, database, request.arena, period, span) catch |err| {
const body = cachedBody(state, io, database, request.arena, period, span, client) catch |err| {
return internal(request, err);
};
return http_util.respondBytes(request, .ok, body, http_util.content_type_json, &.{});
@@ -165,6 +168,7 @@ fn cachedBody(
arena: std.mem.Allocator,
period: Period,
span: Window,
client: ?[]const u8,
) db.Error![]const u8 {
state.querylog_lock.lockUncancelable(io);
defer state.querylog_lock.unlock(io);
@@ -172,16 +176,16 @@ fn cachedBody(
const index = @intFromEnum(period);
const data_version = try database.queryInt("PRAGMA data_version");
if (state.overview_cache.get(index, span.until, data_version)) |cached| {
if (state.overview_cache.get(index, span.until, data_version, client)) |cached| {
// The copy is what makes a later rebuild's free-and-replace safe: the
// response is written after the lock is gone, and by then these bytes
// may belong to nobody.
return arena.dupe(u8, cached);
}
const body = try buildBody(state, io, database, arena, period, span);
const body = try buildBody(state, io, database, arena, period, span, client);
const owned = try state.gpa.dupe(u8, body);
state.overview_cache.put(state.gpa, index, span.until, data_version, owned);
try state.overview_cache.put(state.gpa, index, span.until, data_version, client, owned);
return body;
}
@@ -194,6 +198,7 @@ fn buildBody(
arena: std.mem.Allocator,
period: Period,
span: Window,
client: ?[]const u8,
) db.Error![]const u8 {
var scope = try server.QuerylogRead.openLocked(state, io, database);
errdefer scope.abort();
@@ -203,6 +208,7 @@ fn buildBody(
span.since,
span.bucket_seconds,
span.bucket_count,
client,
);
const window_coverage = try coverage.read(database, span.since);
try scope.commit();
@@ -245,6 +251,29 @@ fn badPeriod(request: *http_util.Request) http_util.HandlerError!void {
return http_util.respondError(request, .bad_request, "period must be one of 1h, 24h, 7d, 30d");
}
pub const ClientError = error{BadClient};
/// `queryValue` measures the value before it decodes it, and a browser
/// percent-encodes every colon of an IPv6 address: three bytes on the wire
/// per byte of address.
const max_client_value_len = 3 * logger.max_client_len;
/// One exact client address, or nothing. An absent or empty value is the
/// whole household, as the query-log filter reads it; the length bound is that
/// filter's too. A comma is a list, and the overview scopes to one device.
fn clientParam(query: []const u8, buf: *[max_client_value_len]u8) ClientError!?[]const u8 {
const found = http_util.queryValue(query, "client", buf) catch return error.BadClient;
const text = found orelse return null;
if (text.len == 0) return null;
if (text.len > logger.max_client_len) return error.BadClient;
if (std.mem.indexOfScalar(u8, text, ',') != null) return error.BadClient;
return text;
}
fn badClient(request: *http_util.Request) http_util.HandlerError!void {
return http_util.respondError(request, .bad_request, "client must be a single client address");
}
fn unavailable(request: *http_util.Request) http_util.HandlerError!void {
return http_util.respondError(request, .service_unavailable, "query log unavailable");
}
@@ -284,6 +313,23 @@ test "an absent period defaults and a bad one is rejected" {
try testing.expectError(error.BadPeriod, periodParam("period=1hhhhhhhhhh"));
}
test "an absent client is the whole household and a bad one is rejected" {
var buf: [max_client_value_len]u8 = undefined;
try testing.expectEqual(@as(?[]const u8, null), try clientParam("period=1h", &buf));
try testing.expectEqualStrings("192.0.2.10", (try clientParam("client=192.0.2.10", &buf)).?);
try testing.expectEqual(@as(?[]const u8, null), try clientParam("client=", &buf));
try testing.expectError(error.BadClient, clientParam("client=192.0.2.10,192.0.2.11", &buf));
try testing.expectError(error.BadClient, clientParam("client=%2", &buf));
// A browser encodes the colons; the decoded address is what the bound
// applies to.
try testing.expectEqualStrings(
"2001:db8:85a3:8d3:1319:8a2e:370:7348",
(try clientParam("client=2001%3Adb8%3A85a3%3A8d3%3A1319%3A8a2e%3A370%3A7348", &buf)).?,
);
// Longer than any address: rejected rather than truncated.
try testing.expectError(error.BadClient, clientParam("client=" ++ "1" ** 46, &buf));
}
test "each period spans its own bucket width times its count" {
for (std.enums.values(Period)) |period| {
const span = window(period, 1_700_000_000);
@@ -391,6 +437,7 @@ test "one overview answers totals and buckets that agree over the same window" {
span.since,
span.bucket_seconds,
span.bucket_count,
null,
);
try testing.expectEqual(@as(u64, 3), data.totals.queries);
@@ -428,6 +475,7 @@ test "an empty window reports zeros with a null mean" {
span.since,
span.bucket_seconds,
span.bucket_count,
null,
);
try testing.expectEqual(@as(u64, 0), data.totals.queries);
@@ -444,20 +492,37 @@ test "the cache serves one period's bytes and rebuilds when the key moves" {
var cache: server.OverviewCache = .{};
defer cache.deinit(gpa);
try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 100, 7));
try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 100, 7, null));
cache.put(gpa, 0, 100, 7, try gpa.dupe(u8, "first"));
try testing.expectEqualStrings("first", cache.get(0, 100, 7).?);
try cache.put(gpa, 0, 100, 7, null, try gpa.dupe(u8, "first"));
try testing.expectEqualStrings("first", cache.get(0, 100, 7, null).?);
// A different period, a rolled window and a bumped data version are three
// different keys, and none of them hits.
try testing.expectEqual(@as(?[]const u8, null), cache.get(1, 100, 7));
try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 101, 7));
try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 100, 8));
try testing.expectEqual(@as(?[]const u8, null), cache.get(1, 100, 7, null));
try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 101, 7, null));
try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 100, 8, null));
// A rebuild replaces the entry and frees the old body; the leak checker in
// `testing.allocator` is the assertion.
cache.put(gpa, 0, 100, 8, try gpa.dupe(u8, "second"));
try testing.expectEqualStrings("second", cache.get(0, 100, 8).?);
try cache.put(gpa, 0, 100, 8, null, try gpa.dupe(u8, "second"));
try testing.expectEqualStrings("second", cache.get(0, 100, 8, null).?);
}
test "a scoped body has its own slot, keyed on the client too" {
const gpa = testing.allocator;
var cache: server.OverviewCache = .{};
defer cache.deinit(gpa);
try cache.put(gpa, 0, 100, 7, null, try gpa.dupe(u8, "household"));
try cache.put(gpa, 0, 100, 7, "192.0.2.10", try gpa.dupe(u8, "one device"));
// Neither evicts the other.
try testing.expectEqualStrings("household", cache.get(0, 100, 7, null).?);
try testing.expectEqualStrings("one device", cache.get(0, 100, 7, "192.0.2.10").?);
// Another client is a miss, and its rebuild takes the scoped slot over.
try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 100, 7, "192.0.2.11"));
try cache.put(gpa, 0, 100, 7, "192.0.2.11", try gpa.dupe(u8, "another"));
try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 100, 7, "192.0.2.10"));
try testing.expectEqualStrings("household", cache.get(0, 100, 7, null).?);
}
/// Two connections onto one file, which is the only arrangement in which
@@ -528,6 +593,7 @@ const CacheFixture = struct {
self.arena_state.allocator(),
period,
span,
null,
);
}