Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
249 lines
7.8 KiB
TypeScript
249 lines
7.8 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.
|
|
*
|
|
* Every panel reads the same window (`overviewWindow.ts`) and renders on its
|
|
* own. A donut whose request failed shows its own error while the charts keep
|
|
* their data, and no two panels ever describe different spans.
|
|
*/
|
|
|
|
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/queries/qtype";
|
|
import type { Period, StatsRoutes, StatsTypes } from "@/lib/types";
|
|
import { styles as shared } from "@/ui/styles";
|
|
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 "./donutLayout";
|
|
import { useOverviewWindow, type Panel } from "./overviewWindow";
|
|
import { DEFAULT_PERIOD, PERIODS } 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({
|
|
page: {
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "1rem",
|
|
},
|
|
headingRow: {
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: "0.75rem",
|
|
},
|
|
heading: {
|
|
fontSize: "1.5rem",
|
|
lineHeight: "2rem",
|
|
fontWeight: 600,
|
|
},
|
|
periodGroup: {
|
|
display: "flex",
|
|
gap: "0.25rem",
|
|
},
|
|
period: {
|
|
borderStyle: "none",
|
|
borderRadius: "0.25rem",
|
|
paddingInline: "0.625rem",
|
|
paddingBlock: "0.25rem",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
},
|
|
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
|
|
periodSelected: {
|
|
backgroundColor: {
|
|
default: "oklch(92% 0.004 286.32)",
|
|
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
|
|
},
|
|
color: colors.text,
|
|
fontWeight: 500,
|
|
},
|
|
periodIdle: {
|
|
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
|
color: colors.textSecondary,
|
|
},
|
|
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))" },
|
|
},
|
|
loading: {
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: colors.textMuted,
|
|
},
|
|
});
|
|
|
|
function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
|
|
return (
|
|
<div role="group" aria-label="Period" {...stylex.props(styles.periodGroup)}>
|
|
{PERIODS.map((option) => (
|
|
<button
|
|
key={option}
|
|
type="button"
|
|
aria-pressed={option === period}
|
|
onClick={() => onChange(option)}
|
|
{...stylex.props(
|
|
styles.period,
|
|
option === period ? styles.periodSelected : styles.periodIdle,
|
|
shared.focusRing,
|
|
)}
|
|
>
|
|
{option}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* One panel's three states. Loading and error are the panel's own: a failure
|
|
* here never reaches past this box, which is what keeps a failed donut from
|
|
* blanking the charts beside it.
|
|
*/
|
|
function PanelBody<T>({ panel, children }: { panel: Panel<T>; children: (data: T) => React.ReactNode }) {
|
|
if (panel.status === "error") return <InlineError error={panel.error} onRetry={panel.retry} />;
|
|
if (panel.status === "loading") {
|
|
return (
|
|
<p role="status" {...stylex.props(styles.loading, shared.pulse)}>
|
|
Loading…
|
|
</p>
|
|
);
|
|
}
|
|
return <>{children(panel.data)}</>;
|
|
}
|
|
|
|
function typeSlices(data: StatsTypes): DonutSlice[] {
|
|
return data.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(data: StatsRoutes): DonutSlice[] {
|
|
return data.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 (
|
|
<div {...stylex.props(styles.page)}>
|
|
<div {...stylex.props(styles.headingRow)}>
|
|
<h1 {...stylex.props(styles.heading)}>Overview</h1>
|
|
<PeriodPicker
|
|
period={period}
|
|
onChange={(next) => void navigate({ search: (prev) => ({ ...prev, period: next }) })}
|
|
/>
|
|
</div>
|
|
|
|
<PanelBody panel={overview.totals}>{(totals) => <StatTiles stats={totals} />}</PanelBody>
|
|
|
|
{/* One notice for the page: every panel is judged against the same window,
|
|
so a second copy would only repeat this sentence. */}
|
|
{overview.coverage !== null && <CoverageNotice coverage={overview.coverage} />}
|
|
|
|
<section aria-labelledby="overview-queries" {...stylex.props(styles.panel)}>
|
|
<h2 id="overview-queries" {...stylex.props(styles.panelHeading)}>
|
|
Queries over time
|
|
</h2>
|
|
<PanelBody panel={overview.timeseries}>{(data) => <TimeseriesChart data={data} />}</PanelBody>
|
|
</section>
|
|
|
|
<section aria-labelledby="overview-clients" {...stylex.props(styles.panel)}>
|
|
<h2 id="overview-clients" {...stylex.props(styles.panelHeading)}>
|
|
Client activity over time
|
|
</h2>
|
|
<PanelBody panel={overview.clients}>{(data) => <ClientChart data={data} />}</PanelBody>
|
|
</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>
|
|
<PanelBody panel={overview.types}>
|
|
{(data) => <Donut slices={typeSlices(data)} caption="Queries by DNS type" unit="Queries" />}
|
|
</PanelBody>
|
|
</section>
|
|
<section aria-labelledby="overview-routes" {...stylex.props(styles.panel)}>
|
|
<h2 id="overview-routes" {...stylex.props(styles.panelHeading)}>
|
|
Upstream servers
|
|
</h2>
|
|
<PanelBody panel={overview.routes}>
|
|
{(data) => (
|
|
<Donut
|
|
slices={routeSlices(data)}
|
|
caption="Queries by how they were answered"
|
|
unit="Queries"
|
|
/>
|
|
)}
|
|
</PanelBody>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|