168 lines
5.9 KiB
TypeScript
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>
|
|
);
|
|
}
|