milestone 26: upstream health answers for the selected period
This commit is contained in:
@@ -5,6 +5,9 @@ import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
|
||||
/** Wall clock at import; the upstream fixtures date their failures against it. */
|
||||
const NOW_S = Math.floor(Date.now() / 1000);
|
||||
|
||||
const RESPONSES: Record<string, unknown> = {
|
||||
"/api/stats?period=24h": {
|
||||
period: "24h",
|
||||
@@ -59,31 +62,65 @@ const RESPONSES: Record<string, unknown> = {
|
||||
refreshes_gated: 0,
|
||||
snapshot_generation: 3,
|
||||
},
|
||||
"/api/upstream/health": {
|
||||
"/api/upstream/health?period=24h": {
|
||||
period: "24h",
|
||||
since: NOW_S - 86_400,
|
||||
until: NOW_S,
|
||||
available: 1,
|
||||
total: 2,
|
||||
complete: true,
|
||||
upstreams: [
|
||||
{
|
||||
url: "https://dns.example/dns-query",
|
||||
enabled: true,
|
||||
available: false,
|
||||
consecutive_failures: 4,
|
||||
total_successes: 90,
|
||||
total_failures: 10,
|
||||
success_rate: 0.9,
|
||||
last_error: "timeout",
|
||||
period: {
|
||||
attempts: 100,
|
||||
successes: 90,
|
||||
failures: 10,
|
||||
success_rate: 0.9,
|
||||
// 3h30m before the fixture's now, far from a unit boundary.
|
||||
last_failure_at: NOW_S - 12_600,
|
||||
last_failure_error: "timeout",
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "udp://9.9.9.9:53",
|
||||
enabled: true,
|
||||
available: true,
|
||||
consecutive_failures: 0,
|
||||
total_successes: 100,
|
||||
total_failures: 0,
|
||||
success_rate: 1,
|
||||
last_error: "",
|
||||
period: {
|
||||
attempts: 100,
|
||||
successes: 100,
|
||||
failures: 0,
|
||||
success_rate: 1,
|
||||
last_failure_at: null,
|
||||
last_failure_error: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/upstream/health?period=1h": {
|
||||
period: "1h",
|
||||
since: NOW_S - 3600,
|
||||
until: NOW_S,
|
||||
available: 1,
|
||||
total: 2,
|
||||
total: 1,
|
||||
complete: true,
|
||||
upstreams: [
|
||||
{
|
||||
url: "https://dns.example/dns-query",
|
||||
enabled: true,
|
||||
available: true,
|
||||
period: {
|
||||
attempts: 7,
|
||||
successes: 6,
|
||||
failures: 1,
|
||||
success_rate: 6 / 7,
|
||||
last_failure_at: NOW_S - 300,
|
||||
last_failure_error: "timeout",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
};
|
||||
@@ -142,7 +179,7 @@ test("dashboard renders stats, chart, disk card, upstream table and health banne
|
||||
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
|
||||
expect(screen.getByText("Blocked", { selector: "li" })).toBeTruthy();
|
||||
|
||||
expect(screen.getByText("Disk")).toBeTruthy();
|
||||
expect(screen.getByText("Storage now")).toBeTruthy();
|
||||
expect(screen.getByText("warn")).toBeTruthy();
|
||||
expect(screen.getAllByText("400.0 MiB").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("12.0 MiB")).toBeTruthy();
|
||||
@@ -155,10 +192,32 @@ test("dashboard renders stats, chart, disk card, upstream table and health banne
|
||||
expect(screen.getByText("https://dns.example/dns-query")).toBeTruthy();
|
||||
expect(screen.getByText("90.0%")).toBeTruthy();
|
||||
expect(screen.getByText("100.0%")).toBeTruthy();
|
||||
expect(screen.getByText("timeout")).toBeTruthy();
|
||||
expect(screen.getByText("timeout · 3h ago")).toBeTruthy();
|
||||
expect(screen.getByText("1/2 available")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("live state is labeled on its own card, not by a section that disowns the picker", async () => {
|
||||
renderDashboard();
|
||||
await screen.findByRole("heading", { name: "Dashboard" });
|
||||
|
||||
expect(screen.getByText("Storage now")).toBeTruthy();
|
||||
expect(screen.queryByRole("region", { name: "Right now" })).toBeNull();
|
||||
expect(screen.queryByText("Right now")).toBeNull();
|
||||
expect(screen.queryByText("Snapshot state; the period above does not apply.")).toBeNull();
|
||||
});
|
||||
|
||||
test("the period picker rescopes the upstream table", async () => {
|
||||
renderDashboard();
|
||||
await screen.findByRole("heading", { name: "Dashboard" });
|
||||
await screen.findByText("90.0%");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "1h" }));
|
||||
|
||||
await screen.findByText("85.7%");
|
||||
expect(screen.getByRole("columnheader", { name: "Selected period · 1h" })).toBeTruthy();
|
||||
expect(screen.queryByText("90.0%")).toBeNull();
|
||||
});
|
||||
|
||||
test("period picker refetches stats and shows the empty chart state", async () => {
|
||||
renderDashboard();
|
||||
await screen.findByRole("heading", { name: "Dashboard" });
|
||||
@@ -173,7 +232,7 @@ test("period picker refetches stats and shows the empty chart state", async () =
|
||||
});
|
||||
|
||||
test("one failing endpoint degrades its own widget on cold navigation", async () => {
|
||||
failing.add("/api/upstream/health");
|
||||
failing.add("/api/upstream/health?period=24h");
|
||||
renderDashboard();
|
||||
await screen.findByRole("heading", { name: "Dashboard" });
|
||||
|
||||
@@ -184,6 +243,6 @@ test("one failing endpoint degrades its own widget on cold navigation", async ()
|
||||
|
||||
expect(screen.getByText("1,000")).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
|
||||
expect(screen.getByText("Disk")).toBeTruthy();
|
||||
expect(screen.getByText("Storage now")).toBeTruthy();
|
||||
expect(screen.queryByText("https://dns.example/dns-query")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ const styles = stylex.create({
|
||||
"@media (prefers-color-scheme: dark)": "oklch(27.4% 0.006 286.033)",
|
||||
},
|
||||
},
|
||||
/** The chart takes two thirds beside the disk card from `lg`, one column below. */
|
||||
/** The chart takes two thirds beside the storage card from `lg`, one column below. */
|
||||
panelGrid: {
|
||||
display: "grid",
|
||||
gap: "1rem",
|
||||
@@ -123,7 +123,7 @@ export default function DashboardPage() {
|
||||
const stats = useQuery({ ...statsQuery(period), placeholderData: keepPreviousData });
|
||||
const timeseries = useQuery({ ...timeseriesQuery(period), placeholderData: keepPreviousData });
|
||||
const health = useQuery(healthQuery());
|
||||
const upstreamHealth = useQuery(upstreamHealthQuery());
|
||||
const upstreamHealth = useQuery({ ...upstreamHealthQuery(period), placeholderData: keepPreviousData });
|
||||
|
||||
return (
|
||||
<section {...stylex.props(styles.page)}>
|
||||
|
||||
@@ -74,8 +74,10 @@ function stateStyle(state: Health["disk"]["state"]) {
|
||||
export default function DiskCard({ disk }: { disk: Health["disk"] }) {
|
||||
return (
|
||||
<section {...stylex.props(styles.card)}>
|
||||
{/* Live state, unlike the ranged widgets around it; the title says so
|
||||
rather than a section rule the picker would have to disown. */}
|
||||
<h2 {...stylex.props(styles.heading)}>
|
||||
Disk
|
||||
Storage now
|
||||
<span {...stylex.props(styles.badge, stateStyle(disk.state))}>{disk.state}</span>
|
||||
</h2>
|
||||
<dl {...stylex.props(styles.list)}>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import type { UpstreamHealth, UpstreamHealthEntry, UpstreamPeriodStats } from "@/lib/types";
|
||||
import UpstreamHealthTable from "./UpstreamHealthTable";
|
||||
|
||||
const NOW_S = 1_700_000_000;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(NOW_S * 1000);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const ZERO: UpstreamPeriodStats = {
|
||||
attempts: 0,
|
||||
successes: 0,
|
||||
failures: 0,
|
||||
success_rate: null,
|
||||
last_failure_at: null,
|
||||
last_failure_error: null,
|
||||
};
|
||||
|
||||
function period(overrides: Partial<UpstreamPeriodStats> = {}): UpstreamPeriodStats {
|
||||
return {
|
||||
attempts: 100,
|
||||
successes: 90,
|
||||
failures: 10,
|
||||
success_rate: 0.9,
|
||||
// 3h30m ago, far from a unit boundary.
|
||||
last_failure_at: NOW_S - 12_600,
|
||||
last_failure_error: "Timeout",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function entry(overrides: Partial<UpstreamHealthEntry> = {}): UpstreamHealthEntry {
|
||||
return {
|
||||
url: "https://dns.example/dns-query",
|
||||
enabled: true,
|
||||
available: true,
|
||||
period: period(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderTable(upstreams: UpstreamHealthEntry[], overrides: Partial<UpstreamHealth> = {}) {
|
||||
const health: UpstreamHealth = {
|
||||
period: "24h",
|
||||
since: NOW_S - 86_400,
|
||||
until: NOW_S,
|
||||
available: upstreams.filter((upstream) => upstream.available).length,
|
||||
total: upstreams.length,
|
||||
complete: true,
|
||||
upstreams,
|
||||
...overrides,
|
||||
};
|
||||
render(<UpstreamHealthTable health={health} />);
|
||||
}
|
||||
|
||||
function rowOf(url: string): HTMLElement {
|
||||
const cell = screen.getByText(url);
|
||||
const row = cell.closest("tr");
|
||||
if (row === null) throw new Error(`no row for ${url}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
test("the ranged columns sit under a header naming the selected period", () => {
|
||||
renderTable([entry()]);
|
||||
|
||||
expect(screen.getByRole("columnheader", { name: "Selected period · 24h" })).toBeTruthy();
|
||||
for (const name of ["Upstream", "Status now", "Attempts", "Failures", "Success rate", "Last failure"]) {
|
||||
expect(screen.getByRole("columnheader", { name })).toBeTruthy();
|
||||
}
|
||||
|
||||
// The unranged yes/no pair the ranged table replaced.
|
||||
expect(screen.queryByRole("columnheader", { name: "Enabled" })).toBeNull();
|
||||
expect(screen.queryByRole("columnheader", { name: "Available" })).toBeNull();
|
||||
});
|
||||
|
||||
test("status now is one word from live state, not from the window", () => {
|
||||
renderTable([
|
||||
entry({ url: "https://a.example/dns-query" }),
|
||||
entry({ url: "https://b.example/dns-query", available: false }),
|
||||
entry({ url: "https://c.example/dns-query", enabled: false, available: false }),
|
||||
]);
|
||||
|
||||
expect(within(rowOf("https://a.example/dns-query")).getByText("Available")).toBeTruthy();
|
||||
expect(within(rowOf("https://b.example/dns-query")).getByText("Backing off")).toBeTruthy();
|
||||
expect(within(rowOf("https://c.example/dns-query")).getByText("Disabled")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("last failure pairs the error name with its age, em-dash when the window holds none", () => {
|
||||
renderTable([
|
||||
entry({ url: "https://a.example/dns-query" }),
|
||||
entry({
|
||||
url: "https://b.example/dns-query",
|
||||
period: period({ last_failure_at: null, last_failure_error: null }),
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(within(rowOf("https://a.example/dns-query")).getByText("Timeout · 3h ago")).toBeTruthy();
|
||||
expect(within(rowOf("https://b.example/dns-query")).getByText("—")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a window with no attempts renders em-dashes and never a perfect rate", () => {
|
||||
renderTable([entry({ period: ZERO })]);
|
||||
|
||||
const cells = within(rowOf("https://dns.example/dns-query")).getAllByRole("cell");
|
||||
expect(cells.map((cell) => cell.textContent)).toEqual([
|
||||
"https://dns.example/dns-query",
|
||||
"Available",
|
||||
"0",
|
||||
"0",
|
||||
"—",
|
||||
"—",
|
||||
]);
|
||||
expect(screen.queryByText("100.0%")).toBeNull();
|
||||
expect(screen.queryByText("0.0%")).toBeNull();
|
||||
});
|
||||
|
||||
test("the card says so when every upstream was idle in the window", () => {
|
||||
renderTable([entry({ url: "https://a.example/dns-query", period: ZERO }), entry({ period: ZERO })]);
|
||||
|
||||
expect(screen.getByText("No upstream attempts in this period.")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("one upstream with attempts keeps the idle message away", () => {
|
||||
renderTable([entry({ url: "https://a.example/dns-query", period: ZERO }), entry()]);
|
||||
|
||||
expect(screen.queryByText("No upstream attempts in this period.")).toBeNull();
|
||||
});
|
||||
|
||||
test("an incomplete window carries a note; a complete one claims nothing", () => {
|
||||
renderTable([entry()], { complete: false });
|
||||
expect(screen.getByText(/history incomplete/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a complete window shows no completeness text at all", () => {
|
||||
renderTable([entry()], { complete: true });
|
||||
|
||||
expect(screen.queryByText(/history incomplete/i)).toBeNull();
|
||||
expect(screen.queryByText(/complete/i)).toBeNull();
|
||||
});
|
||||
|
||||
test("an empty pool says so instead of drawing a table", () => {
|
||||
renderTable([]);
|
||||
|
||||
expect(screen.getByText("No upstreams configured.")).toBeTruthy();
|
||||
expect(screen.queryByRole("table")).toBeNull();
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import type { UpstreamHealth } from "@/lib/types";
|
||||
import { formatAge } from "@/lib/format";
|
||||
import type { UpstreamHealth, UpstreamHealthEntry, UpstreamPeriodStats } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const numberFormat = new Intl.NumberFormat();
|
||||
|
||||
const styles = stylex.create({
|
||||
card: {
|
||||
borderRadius: "0.25rem",
|
||||
@@ -34,6 +37,13 @@ const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
note: {
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
tableWrap: {
|
||||
overflowX: "auto",
|
||||
},
|
||||
@@ -43,6 +53,24 @@ const styles = stylex.create({
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/**
|
||||
* The two live columns are left outside the span: everything under it answers
|
||||
* for the selected window, and nothing else on this card does.
|
||||
*/
|
||||
groupRow: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
groupHead: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
paddingInline: "1rem",
|
||||
paddingBottom: "0.25rem",
|
||||
textAlign: "center",
|
||||
fontWeight: 500,
|
||||
},
|
||||
headRow: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
@@ -85,12 +113,36 @@ const styles = stylex.create({
|
||||
},
|
||||
});
|
||||
|
||||
function YesNo({ value, badValue }: { value: boolean; badValue: boolean }) {
|
||||
const bad = value === badValue;
|
||||
return <span {...stylex.props(bad && styles.bad)}>{value ? "yes" : "no"}</span>;
|
||||
/** Live pool state in one word. Configuration first: a disabled upstream is not backing off. */
|
||||
function statusNow(upstream: UpstreamHealthEntry): "Available" | "Backing off" | "Disabled" {
|
||||
if (!upstream.enabled) return "Disabled";
|
||||
return upstream.available ? "Available" : "Backing off";
|
||||
}
|
||||
|
||||
/**
|
||||
* `success_rate` is null exactly when the window holds no attempt, and that must
|
||||
* not read as perfect reliability — hence the em-dash rather than `100.0%`.
|
||||
*/
|
||||
function successRate(period: UpstreamPeriodStats): string {
|
||||
return period.success_rate === null ? "—" : `${(period.success_rate * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The age is formatted once, when the row renders; nothing here ticks. It is
|
||||
* measured against the browser's clock rather than the response's `until`, so a
|
||||
* cached response ages visibly instead of freezing at the moment it was served.
|
||||
*/
|
||||
function lastFailure(period: UpstreamPeriodStats, nowSeconds: number): string {
|
||||
if (period.last_failure_at === null) return "—";
|
||||
const age = formatAge(Math.max(0, nowSeconds - period.last_failure_at));
|
||||
const error = period.last_failure_error;
|
||||
return error === null || error === "" ? age : `${error} · ${age}`;
|
||||
}
|
||||
|
||||
export default function UpstreamHealthTable({ health }: { health: UpstreamHealth }) {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const idle = health.upstreams.length > 0 && health.upstreams.every(({ period }) => period.attempts === 0);
|
||||
|
||||
return (
|
||||
<section {...stylex.props(styles.card)}>
|
||||
<h2 {...stylex.props(styles.heading)}>
|
||||
@@ -105,15 +157,21 @@ export default function UpstreamHealthTable({ health }: { health: UpstreamHealth
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr {...stylex.props(styles.groupRow)}>
|
||||
<td colSpan={2} />
|
||||
<th scope="colgroup" colSpan={4} {...stylex.props(styles.groupHead)}>
|
||||
Selected period · {health.period}
|
||||
</th>
|
||||
</tr>
|
||||
<tr {...stylex.props(styles.headRow)}>
|
||||
<th scope="col" {...stylex.props(styles.th)}>
|
||||
URL
|
||||
Upstream
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.th)}>
|
||||
Enabled
|
||||
Status now
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.th)}>
|
||||
Available
|
||||
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
|
||||
Attempts
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
|
||||
Failures
|
||||
@@ -122,35 +180,53 @@ export default function UpstreamHealthTable({ health }: { health: UpstreamHealth
|
||||
Success rate
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.th)}>
|
||||
Last error
|
||||
Last failure
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{health.upstreams.map((upstream) => (
|
||||
<tr key={upstream.url} {...stylex.props(styles.row)}>
|
||||
<td {...stylex.props(styles.cell, styles.small, shared.mono)}>{upstream.url}</td>
|
||||
<td {...stylex.props(styles.cell)}>
|
||||
<YesNo value={upstream.enabled} badValue={false} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell)}>
|
||||
<YesNo value={upstream.available} badValue={false} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
|
||||
{upstream.total_failures}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
|
||||
{(upstream.success_rate * 100).toFixed(1)}%
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.muted)}>
|
||||
{upstream.last_error || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{health.upstreams.map((upstream) => {
|
||||
const status = statusNow(upstream);
|
||||
return (
|
||||
<tr key={upstream.url} {...stylex.props(styles.row)}>
|
||||
<td {...stylex.props(styles.cell, styles.small, shared.mono)}>
|
||||
{upstream.url}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell)}>
|
||||
<span
|
||||
{...stylex.props(
|
||||
status === "Backing off" && styles.bad,
|
||||
status === "Disabled" && styles.muted,
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
|
||||
{numberFormat.format(upstream.period.attempts)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
|
||||
{numberFormat.format(upstream.period.failures)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
|
||||
{successRate(upstream.period)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.muted)}>
|
||||
{lastFailure(upstream.period, nowSeconds)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{idle && <p {...stylex.props(styles.note)}>No upstream attempts in this period.</p>}
|
||||
{!health.complete && (
|
||||
<p {...stylex.props(styles.note)}>
|
||||
History incomplete: outcomes were dropped in this window, so these counts are a lower bound.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,7 +118,8 @@ export const getStatsTimeseries = (period?: Period): Promise<StatsTimeseries> =>
|
||||
export const getLookup = (domain: string, groupId?: number): Promise<LookupResult> =>
|
||||
request(`/api/lookup${qs({ domain, group_id: groupId })}`);
|
||||
|
||||
export const getUpstreamHealth = (): Promise<UpstreamHealth> => request("/api/upstream/health");
|
||||
export const getUpstreamHealth = (period?: Period): Promise<UpstreamHealth> =>
|
||||
request(`/api/upstream/health${qs({ period })}`);
|
||||
|
||||
// Groups
|
||||
|
||||
|
||||
@@ -333,16 +333,23 @@ export const sample_update_upstream: UpstreamEcho = {
|
||||
|
||||
export const sample_get_upstream_health: UpstreamHealth = {
|
||||
available: 0,
|
||||
complete: true,
|
||||
period: "24h",
|
||||
since: 0,
|
||||
total: 0,
|
||||
until: 0,
|
||||
upstreams: [
|
||||
{
|
||||
available: true,
|
||||
consecutive_failures: 0,
|
||||
enabled: true,
|
||||
last_error: "",
|
||||
success_rate: 0,
|
||||
total_failures: 0,
|
||||
total_successes: 0,
|
||||
period: {
|
||||
attempts: 0,
|
||||
failures: 0,
|
||||
last_failure_at: null,
|
||||
last_failure_error: null,
|
||||
success_rate: null,
|
||||
successes: 0,
|
||||
},
|
||||
url: "https://dns.example/dns-query",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatBytes, formatMicros, formatTime } from "@/lib/format";
|
||||
import { formatAge, formatBytes, formatMicros, formatTime } from "@/lib/format";
|
||||
|
||||
test("formatTime renders unix seconds in the given locale and zone", () => {
|
||||
// 2024-01-01T00:00:00Z; ICU emits U+202F before AM/PM in recent Node.
|
||||
@@ -15,6 +15,18 @@ test("formatBytes humanizes with binary units", () => {
|
||||
expect(formatBytes(2 * 1024 ** 4)).toBe("2.0 TiB");
|
||||
});
|
||||
|
||||
test("formatAge steps up a unit at each boundary and truncates", () => {
|
||||
expect(formatAge(0)).toBe("0s ago");
|
||||
expect(formatAge(59)).toBe("59s ago");
|
||||
expect(formatAge(60)).toBe("1m ago");
|
||||
expect(formatAge(3599)).toBe("59m ago");
|
||||
expect(formatAge(3600)).toBe("1h ago");
|
||||
expect(formatAge(10800)).toBe("3h ago");
|
||||
expect(formatAge(86399)).toBe("23h ago");
|
||||
expect(formatAge(86400)).toBe("1d ago");
|
||||
expect(formatAge(400000)).toBe("4d ago");
|
||||
});
|
||||
|
||||
test("formatMicros renders milliseconds with one decimal", () => {
|
||||
expect(formatMicros(0)).toBe("0.0 ms");
|
||||
expect(formatMicros(1234)).toBe("1.2 ms");
|
||||
|
||||
@@ -21,6 +21,24 @@ export function formatBytes(bytes: number): string {
|
||||
return `${value.toFixed(1)} ${unit}`;
|
||||
}
|
||||
|
||||
const AGE_UNITS = [
|
||||
{ seconds: 86400, suffix: "d" },
|
||||
{ seconds: 3600, suffix: "h" },
|
||||
{ seconds: 60, suffix: "m" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Seconds of elapsed time → a coarse "3h ago". Truncating and single-unit on
|
||||
* purpose: this labels a snapshot the caller renders once, so a reader must not
|
||||
* take it for a live count. Nothing re-renders it as it ages.
|
||||
*/
|
||||
export function formatAge(seconds: number): string {
|
||||
for (const unit of AGE_UNITS) {
|
||||
if (seconds >= unit.seconds) return `${Math.floor(seconds / unit.seconds)}${unit.suffix} ago`;
|
||||
}
|
||||
return `${Math.floor(seconds)}s ago`;
|
||||
}
|
||||
|
||||
/** Microseconds → milliseconds with one decimal, e.g. 1234 → "1.2 ms". */
|
||||
export function formatMicros(micros: number): string {
|
||||
return `${(micros / 1000).toFixed(1)} ms`;
|
||||
|
||||
@@ -23,7 +23,7 @@ export const queryKeys = {
|
||||
stats: (period: Period) => ["stats", period] as const,
|
||||
timeseries: (period: Period) => ["stats", "timeseries", period] as const,
|
||||
queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const,
|
||||
upstreamHealth: ["upstream-health"] as const,
|
||||
upstreamHealth: (period: Period) => ["upstream-health", period] as const,
|
||||
lookup: (domain: string, groupId?: number) => ["lookup", domain, groupId ?? null] as const,
|
||||
/** Prefix of every `lookup` entry; the invalidation target after any verdict input changes. */
|
||||
lookupAll: ["lookup"] as const,
|
||||
@@ -69,8 +69,15 @@ export const queriesInfiniteQuery = (filter: QueriesFilter = {}) =>
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
export const upstreamHealthQuery = () =>
|
||||
queryOptions({ queryKey: queryKeys.upstreamHealth, queryFn: api.getUpstreamHealth, refetchInterval: 30_000 });
|
||||
// The period is part of the key: the upstream aggregates are ranged like the
|
||||
// stats ones, so the picker has to refetch them rather than reuse a cached
|
||||
// window under a new label.
|
||||
export const upstreamHealthQuery = (period: Period = "24h") =>
|
||||
queryOptions({
|
||||
queryKey: queryKeys.upstreamHealth(period),
|
||||
queryFn: () => api.getUpstreamHealth(period),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
export const lookupQuery = (domain: string, groupId?: number) =>
|
||||
queryOptions({ queryKey: queryKeys.lookup(domain, groupId), queryFn: () => api.getLookup(domain, groupId) });
|
||||
|
||||
+25
-6
@@ -119,21 +119,40 @@ export interface LookupResult {
|
||||
safe_search_rewrite: string | null;
|
||||
}
|
||||
|
||||
export interface UpstreamPeriodStats {
|
||||
attempts: number;
|
||||
successes: number;
|
||||
failures: number;
|
||||
/** successes/attempts, 0 to 1; null when attempts is 0 — no observations is not perfect reliability. */
|
||||
success_rate: number | null;
|
||||
/** The newest failure inside the window, unix seconds; null when the window holds none. */
|
||||
last_failure_at: number | null;
|
||||
/** The error name belonging to last_failure_at; null exactly when it is. */
|
||||
last_failure_error: string | null;
|
||||
}
|
||||
|
||||
export interface UpstreamHealthEntry {
|
||||
url: string;
|
||||
/** Live configuration, not history. */
|
||||
enabled: boolean;
|
||||
/** Live state; false while the upstream is backing off. */
|
||||
available: boolean;
|
||||
consecutive_failures: number;
|
||||
total_successes: number;
|
||||
total_failures: number;
|
||||
success_rate: number;
|
||||
last_error: string;
|
||||
period: UpstreamPeriodStats;
|
||||
}
|
||||
|
||||
export interface UpstreamHealth {
|
||||
upstreams: UpstreamHealthEntry[];
|
||||
period: Period;
|
||||
since: number;
|
||||
until: number;
|
||||
available: number;
|
||||
total: number;
|
||||
/**
|
||||
* No capacity drops known in this process within the selected window; up to about a minute of
|
||||
* the newest outcomes may not have flushed yet, and outcomes lost in an unclean shutdown are
|
||||
* not detectable.
|
||||
*/
|
||||
complete: boolean;
|
||||
upstreams: UpstreamHealthEntry[];
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
|
||||
Reference in New Issue
Block a user