Files
nxdns/admin/src/features/overview/OverviewPage.tsx
T

168 lines
5.9 KiB
TypeScript

/**
* 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.
*
* The period is URL state, so a view is a link: `/overview?period=1h` 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
* partial answer to render, and nothing left for a panel to disagree about.
*/
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 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 { 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)";
const ROUTE_LABELS = {
blocked: "Blocked",
cache: "Cache",
local: "Local",
rejected: "Rejected",
upstream: "Upstream",
forward_zone: "Forward zone",
} 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: {
display: "grid",
gap: "1rem",
gridTemplateColumns: { default: "minmax(0, 1fr)", [TWO_COLUMN]: "repeat(2, minmax(0, 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.
*/
function PageBody({ panel, children }: { panel: Panel<Overview>; children: (data: Overview) => React.ReactNode }) {
if (panel.status === "error") return <InlineError error={panel.error} onRetry={panel.retry} />;
if (panel.status === "loading") return <OverviewLoading />;
return <>{children(panel.data)}</>;
}
function typeSlices(types: OverviewTypeRow[]): DonutSlice[] {
return types.map((row) => ({
key: qtypeKey(row.qtype),
label: row.qtype === null ? "Unknown" : qtypeName(row.qtype),
value: row.count,
color: seriesColor(qtypeKey(row.qtype)),
}));
}
/**
* Route slices. A row that names a resolver or a zone is labelled by that name
* with the route kind as secondary text, because one name can legitimately
* appear under two kinds and two rows can both be "Unknown". The four
* source-less kinds are their own label and need no qualifier.
*/
function routeSlices(routes: OverviewRouteRow[]): DonutSlice[] {
return routes.map((row) => {
const named = row.route === "upstream" || row.route === "forward_zone";
return {
key: routeKey(row.route, row.source),
label: named ? (row.source ?? "Unknown") : ROUTE_LABELS[row.route],
...(named ? { secondary: ROUTE_LABELS[row.route] } : {}),
value: row.count,
color: seriesColor(routeKey(row.route, row.source)),
};
});
}
export default function OverviewPage() {
const period = useSearch({ from: "/shell/overview" }).period ?? DEFAULT_PERIOD;
const navigate = useNavigate({ from: "/overview" });
const overview = useOverviewWindow(period);
return (
<OverviewFrame
period={period}
onChange={(next) => void navigate({ search: (prev) => ({ ...prev, period: next }) })}
>
<PageBody panel={overview}>
{(data) => (
<>
<StatTiles stats={{ since: data.since, until: data.until, ...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>
<TimeseriesChart data={data} />
</section>
<section aria-labelledby="overview-clients" {...stylex.props(styles.panel)}>
<h2 id="overview-clients" {...stylex.props(styles.panelHeading)}>
Client activity over time
</h2>
<ClientChart data={data} />
</section>
<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>
<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>
<Donut
slices={routeSlices(data.routes)}
caption="Queries by how they were answered"
unit="Queries"
/>
</section>
</div>
</>
)}
</PageBody>
</OverviewFrame>
);
}