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
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:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user