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:
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Overview: what the resolver did over a period the reader chooses, in the
|
||||
* layout Pi-hole's dashboard established — four totals, two full-width charts,
|
||||
* two breakdown donuts. Nothing on this page is a current-state readout; the
|
||||
* five health conditions live on Diagnostics, and protection lives in the
|
||||
* sidebar beside its control.
|
||||
* Overview: what the resolver did over a period the reader chooses, for the
|
||||
* household or for one device, in the layout the decision record settled
|
||||
* (ui-visual-redesign.md) — four totals, two full-width charts, then the cache
|
||||
* rate and the two breakdown rings in a row of cards. Nothing on this page is a
|
||||
* current-state readout; the five health conditions live on Diagnostics, and
|
||||
* protection lives in the sidebar beside its control.
|
||||
*
|
||||
* The period is URL state, so a view is a link: `/overview?period=1h` opens
|
||||
* exactly what the sender was reading.
|
||||
* The scope is URL state, so a view is a link: `/overview?period=1h&client=…`
|
||||
* opens exactly what the sender was reading.
|
||||
*
|
||||
* One request feeds every panel (`overviewWindow.ts`), so the page has one
|
||||
* loading state and one error state rather than six: there is no longer a
|
||||
@@ -14,29 +15,20 @@
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import CoverageNotice from "@/lib/CoverageNotice";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { qtypeName } from "@/features/provenance/qtype";
|
||||
import type { Overview, OverviewRouteRow, OverviewTypeRow } from "@/lib/types";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import Card from "@/ui/Card";
|
||||
import CacheCard from "./CacheCard";
|
||||
import ClientChart from "./ClientChart";
|
||||
import Donut from "./Donut";
|
||||
import StatTiles from "./StatTiles";
|
||||
import TimeseriesChart from "./TimeseriesChart";
|
||||
import type { DonutSlice } from "./Donut";
|
||||
import { OverviewFrame, OverviewLoading } from "./OverviewFrame";
|
||||
import { OverviewFrame, OverviewLoading, useOverviewScope } from "./OverviewFrame";
|
||||
import { useOverviewWindow, type Panel } from "./overviewWindow";
|
||||
import { DEFAULT_PERIOD } from "./period";
|
||||
import { qtypeKey, routeKey, seriesColor } from "./seriesColors";
|
||||
|
||||
/**
|
||||
* Where the two donuts stop competing for width and sit side by side. `Donut`
|
||||
* carries the same query for the alignment it switches at that width; StyleX
|
||||
* requires the string to be a literal in the module that uses it, so the two
|
||||
* agree by inspection rather than by sharing a constant.
|
||||
*/
|
||||
const TWO_COLUMN = "@media (min-width: 1280px)";
|
||||
import { qtypeKey, routeKey, seriesColor, typeRampColor } from "./seriesColors";
|
||||
|
||||
const ROUTE_LABELS = {
|
||||
blocked: "Blocked",
|
||||
@@ -48,31 +40,17 @@ const ROUTE_LABELS = {
|
||||
} as const;
|
||||
|
||||
const styles = stylex.create({
|
||||
panel: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.75rem",
|
||||
},
|
||||
panelHeading: {
|
||||
marginBottom: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
donutRow: {
|
||||
/** As many cards per row as fit at 20rem each: three on a desktop, one on a phone. */
|
||||
cards: {
|
||||
display: "grid",
|
||||
gap: "1rem",
|
||||
gridTemplateColumns: { default: "minmax(0, 1fr)", [TWO_COLUMN]: "repeat(2, minmax(0, 1fr))" },
|
||||
gap: "1.25rem",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(20rem, 1fr))",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The page's three states. The heading and the period picker stay put through
|
||||
* all three, so the reader can rescope or retry without waiting for anything.
|
||||
* The page's three states. The heading and the toolbar stay put through all
|
||||
* three, so the reader can rescope or retry without waiting for anything.
|
||||
*/
|
||||
function PageBody({ panel, children }: { panel: Panel<Overview>; children: (data: Overview) => React.ReactNode }) {
|
||||
if (panel.status === "error") return <InlineError error={panel.error} onRetry={panel.retry} />;
|
||||
@@ -80,12 +58,13 @@ function PageBody({ panel, children }: { panel: Panel<Overview>; children: (data
|
||||
return <>{children(panel.data)}</>;
|
||||
}
|
||||
|
||||
/** The API ranks the types by count, so the ramp's darkest step is the busiest type. */
|
||||
function typeSlices(types: OverviewTypeRow[]): DonutSlice[] {
|
||||
return types.map((row) => ({
|
||||
return types.map((row, rank) => ({
|
||||
key: qtypeKey(row.qtype),
|
||||
label: row.qtype === null ? "Unknown" : qtypeName(row.qtype),
|
||||
value: row.count,
|
||||
color: seriesColor(qtypeKey(row.qtype)),
|
||||
color: typeRampColor(rank),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -108,56 +87,67 @@ function routeSlices(routes: OverviewRouteRow[]): DonutSlice[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** What the cache card reads: hits are the buckets' cached counts, forwarded is every upstream or zone answer. */
|
||||
function cacheOf(data: Overview) {
|
||||
return {
|
||||
queries: data.totals.queries,
|
||||
hits: data.buckets.reduce((sum, bucket) => sum + bucket.cached, 0),
|
||||
forwarded: data.routes
|
||||
.filter((row) => row.route === "upstream" || row.route === "forward_zone")
|
||||
.reduce((sum, row) => sum + row.count, 0),
|
||||
avg_response_time_us: data.totals.avg_response_time_us,
|
||||
};
|
||||
}
|
||||
|
||||
export default function OverviewPage() {
|
||||
const period = useSearch({ from: "/shell/overview" }).period ?? DEFAULT_PERIOD;
|
||||
const navigate = useNavigate({ from: "/overview" });
|
||||
const overview = useOverviewWindow(period);
|
||||
const [scope, setScope] = useOverviewScope();
|
||||
const overview = useOverviewWindow(scope.period, scope.client);
|
||||
|
||||
return (
|
||||
<OverviewFrame
|
||||
period={period}
|
||||
onChange={(next) => void navigate({ search: (prev) => ({ ...prev, period: next }) })}
|
||||
>
|
||||
<OverviewFrame scope={scope} onChange={setScope}>
|
||||
<PageBody panel={overview}>
|
||||
{(data) => (
|
||||
<>
|
||||
<StatTiles stats={{ since: data.since, until: data.until, ...data.totals }} />
|
||||
<StatTiles
|
||||
stats={{ since: data.since, until: data.until, client: scope.client, ...data.totals }}
|
||||
/>
|
||||
|
||||
{/* One notice for the page: every panel came out of this one
|
||||
response, so a second copy would only repeat this sentence. */}
|
||||
<CoverageNotice coverage={data.coverage} />
|
||||
|
||||
<section aria-labelledby="overview-queries" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-queries" {...stylex.props(styles.panelHeading)}>
|
||||
Queries over time
|
||||
</h2>
|
||||
<Card
|
||||
title="Queries over time"
|
||||
description="Every query the resolver answered in this period, with the blocked share along the bottom."
|
||||
>
|
||||
<TimeseriesChart data={data} />
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<section aria-labelledby="overview-clients" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-clients" {...stylex.props(styles.panelHeading)}>
|
||||
Client activity over time
|
||||
</h2>
|
||||
<Card
|
||||
title="Client activity over time"
|
||||
description="Which devices made the queries, stacked per bucket."
|
||||
>
|
||||
<ClientChart data={data} />
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<div {...stylex.props(styles.donutRow)}>
|
||||
<section aria-labelledby="overview-types" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-types" {...stylex.props(styles.panelHeading)}>
|
||||
Query types
|
||||
</h2>
|
||||
<div {...stylex.props(styles.cards)}>
|
||||
<CacheCard data={cacheOf(data)} />
|
||||
<Card
|
||||
title="Query types"
|
||||
description="The record types clients asked for across this period."
|
||||
>
|
||||
<Donut slices={typeSlices(data.types)} caption="Queries by DNS type" unit="Queries" />
|
||||
</section>
|
||||
<section aria-labelledby="overview-routes" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-routes" {...stylex.props(styles.panelHeading)}>
|
||||
Upstream servers
|
||||
</h2>
|
||||
</Card>
|
||||
<Card
|
||||
title="Upstream servers"
|
||||
description="How each query was answered: by which resolver, from cache, or not at all."
|
||||
>
|
||||
<Donut
|
||||
slices={routeSlices(data.routes)}
|
||||
caption="Queries by how they were answered"
|
||||
unit="Queries"
|
||||
/>
|
||||
</section>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user