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
89 lines
2.8 KiB
TypeScript
89 lines
2.8 KiB
TypeScript
import { formatCount } from "@/lib/format";
|
|
import type { ClientPrefix, ClientPrefixInput } from "@/lib/types";
|
|
|
|
export interface PrefixRow {
|
|
prefix: string;
|
|
group_id: number;
|
|
/** Raw input text; empty means "use the server default (100)". */
|
|
priority: string;
|
|
}
|
|
|
|
export interface PrefixEditorState {
|
|
baseline: PrefixRow[];
|
|
rows: PrefixRow[];
|
|
}
|
|
|
|
export type PrefixEditorAction =
|
|
| { type: "reset"; prefixes: ClientPrefix[] }
|
|
| { type: "add"; groupId: number }
|
|
| { type: "remove"; index: number }
|
|
| { type: "edit"; index: number; patch: Partial<PrefixRow> };
|
|
|
|
function fromServer(prefixes: ClientPrefix[]): PrefixRow[] {
|
|
return prefixes.map((p) => ({ prefix: p.prefix, group_id: p.group_id, priority: String(p.priority) }));
|
|
}
|
|
|
|
export function initPrefixEditor(prefixes: ClientPrefix[]): PrefixEditorState {
|
|
const rows = fromServer(prefixes);
|
|
return { baseline: rows, rows };
|
|
}
|
|
|
|
export function prefixEditorReducer(state: PrefixEditorState, action: PrefixEditorAction): PrefixEditorState {
|
|
switch (action.type) {
|
|
case "reset":
|
|
return initPrefixEditor(action.prefixes);
|
|
case "add":
|
|
return { ...state, rows: [...state.rows, { prefix: "", group_id: action.groupId, priority: "" }] };
|
|
case "remove":
|
|
return { ...state, rows: state.rows.filter((_, i) => i !== action.index) };
|
|
case "edit":
|
|
return {
|
|
...state,
|
|
rows: state.rows.map((row, i) => (i === action.index ? { ...row, ...action.patch } : row)),
|
|
};
|
|
}
|
|
}
|
|
|
|
function sameRow(a: PrefixRow, b: PrefixRow): boolean {
|
|
return a.prefix === b.prefix && a.group_id === b.group_id && a.priority === b.priority;
|
|
}
|
|
|
|
export function isDirty(state: PrefixEditorState): boolean {
|
|
if (state.rows.length !== state.baseline.length) return true;
|
|
return state.rows.some((row, i) => {
|
|
const base = state.baseline[i];
|
|
return base === undefined || !sameRow(row, base);
|
|
});
|
|
}
|
|
|
|
/** Which input the message is about, so the editor can point that input at it. */
|
|
export interface PrefixProblem {
|
|
index: number;
|
|
field: "prefix" | "priority";
|
|
message: string;
|
|
}
|
|
|
|
export function firstProblem(rows: PrefixRow[]): PrefixProblem | null {
|
|
for (const [index, row] of rows.entries()) {
|
|
if (row.prefix.trim() === "")
|
|
return { index, field: "prefix", message: `Row ${formatCount(index + 1)}: prefix is required.` };
|
|
const priority = row.priority.trim();
|
|
if (priority !== "" && !/^\d+$/.test(priority))
|
|
return {
|
|
index,
|
|
field: "priority",
|
|
message: `Row ${formatCount(index + 1)}: priority must be a whole number.`,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function toInputs(rows: PrefixRow[]): ClientPrefixInput[] {
|
|
return rows.map((row) => {
|
|
const input: ClientPrefixInput = { prefix: row.prefix.trim(), group_id: row.group_id };
|
|
const priority = row.priority.trim();
|
|
if (priority !== "") input.priority = Number(priority);
|
|
return input;
|
|
});
|
|
}
|