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:
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* The compact status block at the sidebar's foot (milestone 39): whether
|
||||
* filtering is in force, how fast queries are arriving, and how long the
|
||||
* process has been up. Three readings the operator glances at from any page,
|
||||
* each from data the shell already holds — no request of its own.
|
||||
*
|
||||
* Queries/min derives from the Overview body the page in front of the reader
|
||||
* is drawing, so it appears only while that page is open and its window has
|
||||
* landed; on every other page the row is absent rather than stale.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMatch } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatDuration, formatRate } from "@/lib/format";
|
||||
import { overviewQuery, versionQuery } from "@/lib/queries";
|
||||
import { useProtection } from "@/features/pause/protection";
|
||||
import { DEFAULT_PERIOD } from "@/features/overview/period";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
block: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.4375rem",
|
||||
marginInline: "1rem",
|
||||
marginBottom: "0.75rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.875rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
line: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.625rem",
|
||||
margin: 0,
|
||||
},
|
||||
key: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.4375rem",
|
||||
},
|
||||
value: {
|
||||
fontWeight: 550,
|
||||
color: colors.text,
|
||||
},
|
||||
dot: {
|
||||
flexShrink: 0,
|
||||
width: "7px",
|
||||
height: "7px",
|
||||
borderRadius: "999px",
|
||||
},
|
||||
dotActive: { backgroundColor: colors.chartGreen },
|
||||
dotPaused: { backgroundColor: colors.warnBorderStrong },
|
||||
dotUnavailable: { backgroundColor: colors.chartRed },
|
||||
dotUnknown: { backgroundColor: colors.borderStrong },
|
||||
/** The narrow header's rendering: the dot and the state alone, inline. */
|
||||
inline: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.4375rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
});
|
||||
|
||||
const PROTECTION_TEXT = {
|
||||
active: "Active",
|
||||
paused: "Paused",
|
||||
unavailable: "Unavailable",
|
||||
unknown: "Unknown",
|
||||
} as const;
|
||||
|
||||
const DOT_STYLE = {
|
||||
active: styles.dotActive,
|
||||
paused: styles.dotPaused,
|
||||
unavailable: styles.dotUnavailable,
|
||||
unknown: styles.dotUnknown,
|
||||
} as const;
|
||||
|
||||
/** The dot and the word, for the narrow header where there is room for nothing more. */
|
||||
export function ProtectionDot() {
|
||||
const { state } = useProtection();
|
||||
return (
|
||||
<span {...stylex.props(styles.inline)}>
|
||||
<span aria-hidden="true" {...stylex.props(styles.dot, DOT_STYLE[state])} />
|
||||
<span>Protection {PROTECTION_TEXT[state].toLowerCase()}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uptime as of now, not as of the fetch: the version answer is a minute old at
|
||||
* most, so the seconds it reported are aged by the time since it landed. A
|
||||
* restart shows within that minute. Ticks every second, because the first
|
||||
* minute reads in seconds.
|
||||
*/
|
||||
function useUptimeSeconds(): number | null {
|
||||
const version = useQuery(versionQuery());
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
if (version.data === undefined) return null;
|
||||
return version.data.uptime_seconds + Math.max(0, now - version.dataUpdatedAt) / 1000;
|
||||
}
|
||||
|
||||
function useQueriesPerMinute(): number | null {
|
||||
const match = useMatch({ from: "/shell/overview", shouldThrow: false });
|
||||
const period = match?.search.period ?? DEFAULT_PERIOD;
|
||||
// Reads the body the page fetched; never fetches one of its own. A failed
|
||||
// refetch leaves the last body in the cache, and the page is then showing
|
||||
// its error, not that body: the row goes with it.
|
||||
const overview = useQuery({ ...overviewQuery(period, match?.search.client), enabled: false });
|
||||
if (match === undefined || overview.isError || overview.data === undefined) return null;
|
||||
if (overview.data.period !== period) return null;
|
||||
const minutes = (overview.data.until - overview.data.since) / 60;
|
||||
return minutes > 0 ? overview.data.totals.queries / minutes : null;
|
||||
}
|
||||
|
||||
export default function SidebarStatus() {
|
||||
const { state } = useProtection();
|
||||
const rate = useQueriesPerMinute();
|
||||
const uptime = useUptimeSeconds();
|
||||
return (
|
||||
<dl aria-label="Status" {...stylex.props(styles.block)}>
|
||||
<div {...stylex.props(styles.line)}>
|
||||
<dt {...stylex.props(styles.key)}>
|
||||
<span aria-hidden="true" {...stylex.props(styles.dot, DOT_STYLE[state])} />
|
||||
Protection
|
||||
</dt>
|
||||
<dd {...stylex.props(styles.value)}>{PROTECTION_TEXT[state]}</dd>
|
||||
</div>
|
||||
{rate !== null && (
|
||||
<div {...stylex.props(styles.line)}>
|
||||
<dt>Queries/min</dt>
|
||||
<dd {...stylex.props(styles.value, shared.tabularNums)}>{formatRate(rate)}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div {...stylex.props(styles.line)}>
|
||||
<dt>Uptime</dt>
|
||||
<dd {...stylex.props(styles.value, shared.tabularNums)}>
|
||||
{uptime === null ? "—" : formatDuration(uptime)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user