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 beside the point, a stacked client chart in eight distinct hues plus one Other band, and a card row with the cache hit rate, the query types as a single-hue ramp ring, and the upstream breakdown. 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. All numbers go through admin/src/lib/format.ts: grouped counts, two-decimal percentages, one-decimal rates, durations as the two largest nonzero units. 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 description, the sidebar grouped into Monitoring and System with a status block (protection, queries per minute on Overview, uptime), 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 could not review sessions B to D, its endpoint returned 404 for every call on 2026-09-03; the owner accepted the smoke-server screenshots.
This commit is contained in:
@@ -1240,6 +1240,21 @@ const overview_raw_sql =
|
||||
\\ WHERE timestamp >= ?1 AND timestamp < ?2
|
||||
;
|
||||
|
||||
/// `INDEXED BY` because the planner, offered both indexes, walks
|
||||
/// `idx_query_log_client` — the device's whole retained history — and filters
|
||||
/// the window out of it afterwards. The window is the small side: a device is
|
||||
/// a slice of every row ever kept, and the window is a slice of hours.
|
||||
const overview_raw_client_sql =
|
||||
\\SELECT timestamp, client_ip, qtype, blocked, cache_hit, response_time_us,
|
||||
\\ route_kind,
|
||||
\\ CASE route_kind
|
||||
\\ WHEN 'upstream' THEN upstream
|
||||
\\ WHEN 'forward_zone' THEN forward_zone
|
||||
\\ END AS source
|
||||
\\ FROM query_log INDEXED BY idx_query_log_ts
|
||||
\\ WHERE timestamp >= ?1 AND timestamp < ?2 AND client_ip = ?3
|
||||
;
|
||||
|
||||
/// The whole Overview payload for `[since, since + bucket_seconds *
|
||||
/// bucket_count)`, from one already-open read transaction — the caller owns the
|
||||
/// transaction and the lock, as it does for every other read here.
|
||||
@@ -1250,6 +1265,13 @@ const overview_raw_sql =
|
||||
/// buckets) one pass over the raw rows in the window does the same work in Zig.
|
||||
/// Both produce the same contracts, which is what the equivalence test asserts.
|
||||
///
|
||||
/// A `client` scopes the whole payload to that address's rows (milestone 39).
|
||||
/// The projections aggregate across clients for everything but the per-client
|
||||
/// series, so a scoped read always takes the raw path, whatever the width: one
|
||||
/// scan of the window's rows over `idx_query_log_ts`, which household volume
|
||||
/// wears. Per-client projections would be a second copy of every table for one
|
||||
/// filter.
|
||||
///
|
||||
/// Preconditions are caller bugs, not runtime conditions: a zero width or count
|
||||
/// is `error.Misuse`, and so is a projection-path window that is not on the
|
||||
/// grid, because the projections cannot express it. The handler's `window()`
|
||||
@@ -1260,19 +1282,20 @@ pub fn overview(
|
||||
since: i64,
|
||||
bucket_seconds: u32,
|
||||
bucket_count: u32,
|
||||
client: ?[]const u8,
|
||||
) db.Error!Overview {
|
||||
if (bucket_seconds == 0 or bucket_count == 0) return error.Misuse;
|
||||
const width: i64 = bucket_seconds;
|
||||
const span = std.math.mul(i64, width, bucket_count) catch return error.Misuse;
|
||||
const until = std.math.add(i64, since, span) catch return error.Misuse;
|
||||
const from_projections = width >= grain;
|
||||
const from_projections = client == null and width >= grain;
|
||||
if (from_projections and (@mod(since, grain) != 0 or @rem(width, grain) != 0)) return error.Misuse;
|
||||
|
||||
var accum = try Accum.init(arena, since, width, bucket_count);
|
||||
if (from_projections) {
|
||||
try readProjections(database, &accum, since, until);
|
||||
} else {
|
||||
try readRaw(database, &accum, since, until);
|
||||
try readRaw(database, &accum, since, until, client);
|
||||
}
|
||||
return try accum.finish();
|
||||
}
|
||||
@@ -1327,11 +1350,12 @@ fn readProjections(database: *db.Db, accum: *Accum, since: i64, until: i64) db.E
|
||||
}
|
||||
}
|
||||
|
||||
fn readRaw(database: *db.Db, accum: *Accum, since: i64, until: i64) db.Error!void {
|
||||
var stmt = try database.prepare(overview_raw_sql);
|
||||
fn readRaw(database: *db.Db, accum: *Accum, since: i64, until: i64, client: ?[]const u8) db.Error!void {
|
||||
var stmt = try database.prepare(if (client == null) overview_raw_sql else overview_raw_client_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, since);
|
||||
try stmt.bindInt(2, until);
|
||||
if (client) |address| try stmt.bindText(3, address);
|
||||
|
||||
while (try stmt.step()) {
|
||||
const index = try accum.indexOf(stmt.columnInt(0));
|
||||
@@ -1371,6 +1395,21 @@ fn openLog() !db.Db {
|
||||
return database;
|
||||
}
|
||||
|
||||
test "a scoped overview read walks the timestamp index, not the client's history" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
var stmt = try database.prepare("EXPLAIN QUERY PLAN " ++ overview_raw_client_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, 0);
|
||||
try stmt.bindInt(2, 1);
|
||||
try stmt.bindText(3, "192.0.2.10");
|
||||
try testing.expect(try stmt.step());
|
||||
const detail = stmt.columnText(3);
|
||||
try testing.expect(std.mem.indexOf(u8, detail, "idx_query_log_ts") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, detail, "idx_query_log_client") == null);
|
||||
}
|
||||
|
||||
fn plainRow(timestamp: i64, domain: []const u8) Row {
|
||||
return .{
|
||||
.timestamp = timestamp,
|
||||
@@ -2309,7 +2348,7 @@ test "the type breakdown groups by qtype, keeps the null row and orders it last"
|
||||
aggRow(-1, "192.0.2.10", 255, .upstream, "9.9.9.9"),
|
||||
});
|
||||
|
||||
const rows = (try overview(&database, arena, agg_since, agg_width, agg_buckets)).types;
|
||||
const rows = (try overview(&database, arena, agg_since, agg_width, agg_buckets, null)).types;
|
||||
try testing.expectEqual(@as(usize, 4), rows.len);
|
||||
try testing.expectEqual(@as(?u16, 1), rows[0].qtype);
|
||||
try testing.expectEqual(@as(u64, 3), rows[0].count);
|
||||
@@ -2325,7 +2364,7 @@ test "an empty window has no type rows at all" {
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
const rows = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets)).types;
|
||||
const rows = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets, null)).types;
|
||||
try testing.expectEqual(@as(usize, 0), rows.len);
|
||||
}
|
||||
|
||||
@@ -2349,7 +2388,7 @@ test "the route breakdown keys on the answering resolver, not on blocklist prove
|
||||
aggRow(8, "192.0.2.10", 1, .rejected, null),
|
||||
});
|
||||
|
||||
const rows = (try overview(&database, arena, agg_since, agg_width, agg_buckets)).routes;
|
||||
const rows = (try overview(&database, arena, agg_since, agg_width, agg_buckets, null)).routes;
|
||||
// Two upstreams, one null-source upstream, one forward zone and four
|
||||
// source-less kinds. Every row carries the same `source_name`, so a
|
||||
// breakdown that grouped by it would collapse to one row.
|
||||
@@ -2381,7 +2420,7 @@ test "an empty window has no route rows at all" {
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
const rows = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets)).routes;
|
||||
const rows = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets, null)).routes;
|
||||
try testing.expectEqual(@as(usize, 0), rows.len);
|
||||
}
|
||||
|
||||
@@ -2391,7 +2430,7 @@ test "an empty window still has a zero-filled other series and no named clients"
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
const result = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets)).clients;
|
||||
const result = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets, null)).clients;
|
||||
try testing.expectEqual(@as(usize, 0), result.clients.len);
|
||||
try testing.expectEqual(@as(usize, agg_buckets), result.other.len);
|
||||
for (result.other) |count| try testing.expectEqual(@as(u64, 0), count);
|
||||
@@ -2413,7 +2452,7 @@ test "client series are bucket-aligned, zero-filled and ranked by in-window tota
|
||||
aggRow(agg_width * agg_buckets, "192.0.2.10", 1, .upstream, "9.9.9.9"),
|
||||
});
|
||||
|
||||
const result = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets)).clients;
|
||||
const result = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets, null)).clients;
|
||||
try testing.expectEqual(@as(usize, 2), result.clients.len);
|
||||
try testing.expectEqualStrings("192.0.2.20", result.clients[0].client);
|
||||
try testing.expectEqualStrings("192.0.2.10", result.clients[1].client);
|
||||
@@ -2445,7 +2484,7 @@ test "the ninth client folds into other and the cut is the same on every read" {
|
||||
}
|
||||
}
|
||||
|
||||
const result = (try overview(&database, arena, agg_since, agg_width, agg_buckets)).clients;
|
||||
const result = (try overview(&database, arena, agg_since, agg_width, agg_buckets, null)).clients;
|
||||
try testing.expectEqual(@as(usize, max_client_series), result.clients.len);
|
||||
// The busiest is 192.0.2.108 with nine rows; the lone folded client is
|
||||
// 192.0.2.100 with one.
|
||||
@@ -2484,7 +2523,7 @@ test "the three breakdowns conserve the window's total" {
|
||||
)});
|
||||
}
|
||||
|
||||
const result = try overview(&database, arena, agg_since, agg_width, agg_buckets);
|
||||
const result = try overview(&database, arena, agg_since, agg_width, agg_buckets, null);
|
||||
try testing.expect(result.totals.queries > 0);
|
||||
|
||||
var typed: u64 = 0;
|
||||
@@ -2518,7 +2557,7 @@ test "the aggregations pass a redacted client through as the log stored it" {
|
||||
aggRow(1, logger.hidden_marker, 1, .upstream, "9.9.9.9"),
|
||||
});
|
||||
|
||||
const result = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets)).clients;
|
||||
const result = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets, null)).clients;
|
||||
try testing.expectEqual(@as(usize, 1), result.clients.len);
|
||||
try testing.expectEqualStrings(logger.hidden_marker, result.clients[0].client);
|
||||
try testing.expectEqual(@as(u64, 2), result.clients[0].buckets[0]);
|
||||
@@ -2556,7 +2595,7 @@ test "a prune committed mid-read is invisible to the reader's transaction" {
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
var tx = try db.ReadTx.begin(&reader);
|
||||
const before = (try overview(&reader, arena, agg_since, 1000, 1)).totals;
|
||||
const before = (try overview(&reader, arena, agg_since, 1000, 1, null)).totals;
|
||||
const watermark_before = try availableSince(&reader);
|
||||
try testing.expectEqual(@as(u64, 3), before.queries);
|
||||
|
||||
@@ -2567,13 +2606,13 @@ test "a prune committed mid-read is invisible to the reader's transaction" {
|
||||
|
||||
// Neither half of the answer moved: the rows the reader would report and
|
||||
// the watermark it would tag them with still describe one state.
|
||||
const during = (try overview(&reader, arena, agg_since, 1000, 1)).totals;
|
||||
const during = (try overview(&reader, arena, agg_since, 1000, 1, null)).totals;
|
||||
try testing.expectEqual(before.queries, during.queries);
|
||||
try testing.expectEqual(watermark_before, try availableSince(&reader));
|
||||
try tx.commit();
|
||||
|
||||
// The next response sees the prune — both halves of it.
|
||||
const after = (try overview(&reader, arena, agg_since, 1000, 1)).totals;
|
||||
const after = (try overview(&reader, arena, agg_since, 1000, 1, null)).totals;
|
||||
try testing.expectEqual(@as(u64, 1), after.queries);
|
||||
try testing.expectEqual(pruned.available_since, try availableSince(&reader));
|
||||
}
|
||||
@@ -3127,7 +3166,7 @@ fn expectOverviewMatchesOracle(database: *db.Db, since: i64) !void {
|
||||
|
||||
for (overview_windows) |window| {
|
||||
const want = try oracle.build(database, arena, since, window.bucket_seconds, window.bucket_count);
|
||||
const got = try overview(database, arena, since, window.bucket_seconds, window.bucket_count);
|
||||
const got = try overview(database, arena, since, window.bucket_seconds, window.bucket_count, null);
|
||||
expectOverviewEqual(want, got) catch |err| {
|
||||
std.debug.print("overview mismatch at bucket_seconds={d}\n", .{window.bucket_seconds});
|
||||
return err;
|
||||
@@ -3239,16 +3278,16 @@ test "overview refuses a window the projections cannot express" {
|
||||
|
||||
const since = bucketOf(agg_since);
|
||||
|
||||
try testing.expectError(error.Misuse, overview(&database, arena, since, 0, 48));
|
||||
try testing.expectError(error.Misuse, overview(&database, arena, since, 1800, 0));
|
||||
try testing.expectError(error.Misuse, overview(&database, arena, std.math.maxInt(i64) - 1, 3600, 48));
|
||||
try testing.expectError(error.Misuse, overview(&database, arena, since, 0, 48, null));
|
||||
try testing.expectError(error.Misuse, overview(&database, arena, since, 1800, 0, null));
|
||||
try testing.expectError(error.Misuse, overview(&database, arena, std.math.maxInt(i64) - 1, 3600, 48, null));
|
||||
|
||||
// On the projection path the grid is part of the contract: an unaligned
|
||||
// start or a width that is not a whole number of grains cannot be answered
|
||||
// from 30-minute rows, and guessing would be worse than refusing.
|
||||
try testing.expectError(error.Misuse, overview(&database, arena, since + 1, 1800, 48));
|
||||
try testing.expectError(error.Misuse, overview(&database, arena, since, 2700, 48));
|
||||
try testing.expectError(error.Misuse, overview(&database, arena, since + 1, 1800, 48, null));
|
||||
try testing.expectError(error.Misuse, overview(&database, arena, since, 2700, 48, null));
|
||||
|
||||
// Below the grain none of that applies: the raw rows carry every second.
|
||||
_ = try overview(&database, arena, since + 1, 60, 60);
|
||||
_ = try overview(&database, arena, since + 1, 60, 60, null);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user