admin: fix dashboard phantom scroll, drop last-failure column from upstream table
Gates / frontend (push) Successful in 1m11s
Gates / test (push) Successful in 1m40s
Gates / test-aarch64 (push) Successful in 6m38s
Gates / package (push) Successful in 5m42s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 27m54s
Gates / frontend (push) Successful in 1m11s
Gates / test (push) Successful in 1m40s
Gates / test-aarch64 (push) Successful in 6m38s
Gates / package (push) Successful in 5m42s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 27m54s
the chart's screen-reader table wore srOnly directly; overflow and height do not apply to a table box, so it laid out 1200px tall below the page while clip-path hid the paint. wrap it in a hidden div, which clips properly and keeps the table role. failure detail is the diagnostics page's job since milestone 27; the column and the now dead formatAge go.
This commit is contained in:
@@ -193,7 +193,6 @@ test("dashboard renders stats, chart, disk card, upstream table and health banne
|
|||||||
expect(screen.getByText("https://dns.example/dns-query")).toBeTruthy();
|
expect(screen.getByText("https://dns.example/dns-query")).toBeTruthy();
|
||||||
expect(screen.getByText("90.0%")).toBeTruthy();
|
expect(screen.getByText("90.0%")).toBeTruthy();
|
||||||
expect(screen.getByText("100.0%")).toBeTruthy();
|
expect(screen.getByText("100.0%")).toBeTruthy();
|
||||||
expect(screen.getByText("timeout · 3h ago")).toBeTruthy();
|
|
||||||
expect(screen.getByText("1/2 available")).toBeTruthy();
|
expect(screen.getByText("1/2 available")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import type { StatsTimeseries } from "@/lib/types";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import TimeseriesChart from "./TimeseriesChart";
|
||||||
|
|
||||||
|
const SINCE = 1_700_000_000;
|
||||||
|
|
||||||
|
function timeseries(bucketCount: number): StatsTimeseries {
|
||||||
|
return {
|
||||||
|
period: "24h",
|
||||||
|
since: SINCE,
|
||||||
|
until: SINCE + bucketCount * 1800,
|
||||||
|
bucket_seconds: 1800,
|
||||||
|
buckets: Array.from({ length: bucketCount }, (_, i) => ({
|
||||||
|
ts: SINCE + i * 1800,
|
||||||
|
queries: i + 1,
|
||||||
|
blocked: 1,
|
||||||
|
cached: 1,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The element wearing the shared hidden style, found by its compiled classes. */
|
||||||
|
function hiddenElement(container: HTMLElement): Element | null {
|
||||||
|
const classes = stylex.props(shared.srOnly).className?.split(" ").filter(Boolean) ?? [];
|
||||||
|
expect(classes.length).toBeGreaterThan(0);
|
||||||
|
return container.querySelector(classes.map((name) => `.${name}`).join(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the data table is the SVG's accessible equivalent", () => {
|
||||||
|
render(<TimeseriesChart data={timeseries(3)} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
|
||||||
|
const table = screen.getByRole("table", { name: "Queries per time bucket" });
|
||||||
|
expect(within(table).getAllByRole("row").length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `overflow` does not apply to a table box and `height` on one is a minimum, so
|
||||||
|
* the hidden style has to sit on a block container wrapping the table. Worn by
|
||||||
|
* the table itself it clips the paint but not the layout, and 48 invisible rows
|
||||||
|
* push the document's scroll height a screen past the app shell.
|
||||||
|
*/
|
||||||
|
test("the hidden data table is clipped by a block wrapper, not by the table itself", () => {
|
||||||
|
const { container } = render(<TimeseriesChart data={timeseries(48)} />);
|
||||||
|
|
||||||
|
const hidden = hiddenElement(container);
|
||||||
|
expect(hidden?.tagName).toBe("DIV");
|
||||||
|
expect(hidden?.querySelector("table")).not.toBeNull();
|
||||||
|
});
|
||||||
@@ -292,29 +292,31 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<table {...stylex.props(shared.srOnly)}>
|
<div {...stylex.props(shared.srOnly)}>
|
||||||
<caption>Queries per time bucket</caption>
|
<table>
|
||||||
<thead>
|
<caption>Queries per time bucket</caption>
|
||||||
<tr>
|
<thead>
|
||||||
<th scope="col">Time</th>
|
<tr>
|
||||||
<th scope="col">Queries</th>
|
<th scope="col">Time</th>
|
||||||
<th scope="col">Blocked</th>
|
<th scope="col">Queries</th>
|
||||||
<th scope="col">Cached</th>
|
<th scope="col">Blocked</th>
|
||||||
<th scope="col">Other</th>
|
<th scope="col">Cached</th>
|
||||||
</tr>
|
<th scope="col">Other</th>
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{layout.bars.map((bar) => (
|
|
||||||
<tr key={bar.bucket.ts}>
|
|
||||||
<th scope="row">{formatTime(bar.bucket.ts)}</th>
|
|
||||||
<td>{bar.bucket.queries}</td>
|
|
||||||
<td>{bar.bucket.blocked}</td>
|
|
||||||
<td>{bar.bucket.cached}</td>
|
|
||||||
<td>{bar.other}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{layout.bars.map((bar) => (
|
||||||
|
<tr key={bar.bucket.ts}>
|
||||||
|
<th scope="row">{formatTime(bar.bucket.ts)}</th>
|
||||||
|
<td>{bar.bucket.queries}</td>
|
||||||
|
<td>{bar.bucket.blocked}</td>
|
||||||
|
<td>{bar.bucket.cached}</td>
|
||||||
|
<td>{bar.other}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,6 @@ import UpstreamHealthTable from "./UpstreamHealthTable";
|
|||||||
|
|
||||||
const NOW_S = 1_700_000_000;
|
const NOW_S = 1_700_000_000;
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.spyOn(Date, "now").mockReturnValue(NOW_S * 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
const ZERO: UpstreamPeriodStats = {
|
const ZERO: UpstreamPeriodStats = {
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
successes: 0,
|
successes: 0,
|
||||||
@@ -27,7 +19,6 @@ function period(overrides: Partial<UpstreamPeriodStats> = {}): UpstreamPeriodSta
|
|||||||
successes: 90,
|
successes: 90,
|
||||||
failures: 10,
|
failures: 10,
|
||||||
success_rate: 0.9,
|
success_rate: 0.9,
|
||||||
// 3h30m ago, far from a unit boundary.
|
|
||||||
last_failure_at: NOW_S - 12_600,
|
last_failure_at: NOW_S - 12_600,
|
||||||
last_failure_error: "Timeout",
|
last_failure_error: "Timeout",
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -69,7 +60,7 @@ test("the ranged columns sit under a header naming the selected period", () => {
|
|||||||
renderTable([entry()]);
|
renderTable([entry()]);
|
||||||
|
|
||||||
expect(screen.getByRole("columnheader", { name: "Selected period · 24h" })).toBeTruthy();
|
expect(screen.getByRole("columnheader", { name: "Selected period · 24h" })).toBeTruthy();
|
||||||
for (const name of ["Upstream", "Status now", "Attempts", "Failures", "Success rate", "Last failure"]) {
|
for (const name of ["Upstream", "Status now", "Attempts", "Failures", "Success rate"]) {
|
||||||
expect(screen.getByRole("columnheader", { name })).toBeTruthy();
|
expect(screen.getByRole("columnheader", { name })).toBeTruthy();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +69,14 @@ test("the ranged columns sit under a header naming the selected period", () => {
|
|||||||
expect(screen.queryByRole("columnheader", { name: "Available" })).toBeNull();
|
expect(screen.queryByRole("columnheader", { name: "Available" })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("failure detail is the Diagnostics page's job; the card never shows it", () => {
|
||||||
|
renderTable([entry()]);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("columnheader", { name: "Last failure" })).toBeNull();
|
||||||
|
expect(screen.queryByText(/Timeout/)).toBeNull();
|
||||||
|
expect(screen.queryByText(/ago$/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test("status now is one word from live state, not from the window", () => {
|
test("status now is one word from live state, not from the window", () => {
|
||||||
renderTable([
|
renderTable([
|
||||||
entry({ url: "https://a.example/dns-query" }),
|
entry({ url: "https://a.example/dns-query" }),
|
||||||
@@ -90,20 +89,7 @@ test("status now is one word from live state, not from the window", () => {
|
|||||||
expect(within(rowOf("https://c.example/dns-query")).getByText("Disabled")).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", () => {
|
test("a window with no attempts renders an em-dash and never a perfect rate", () => {
|
||||||
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 })]);
|
renderTable([entry({ period: ZERO })]);
|
||||||
|
|
||||||
const cells = within(rowOf("https://dns.example/dns-query")).getAllByRole("cell");
|
const cells = within(rowOf("https://dns.example/dns-query")).getAllByRole("cell");
|
||||||
@@ -113,7 +99,6 @@ test("a window with no attempts renders em-dashes and never a perfect rate", ()
|
|||||||
"0",
|
"0",
|
||||||
"0",
|
"0",
|
||||||
"—",
|
"—",
|
||||||
"—",
|
|
||||||
]);
|
]);
|
||||||
expect(screen.queryByText("100.0%")).toBeNull();
|
expect(screen.queryByText("100.0%")).toBeNull();
|
||||||
expect(screen.queryByText("0.0%")).toBeNull();
|
expect(screen.queryByText("0.0%")).toBeNull();
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { formatAge } from "@/lib/format";
|
|
||||||
import type { UpstreamHealth, UpstreamHealthEntry, UpstreamPeriodStats } from "@/lib/types";
|
import type { UpstreamHealth, UpstreamHealthEntry, UpstreamPeriodStats } from "@/lib/types";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
@@ -137,19 +136,11 @@ function successRate(period: UpstreamPeriodStats): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The age is formatted once, when the row renders; nothing here ticks. It is
|
* The dashboard answers availability only. Failure detail — what failed, when,
|
||||||
* measured against the browser's clock rather than the response's `until`, so a
|
* and how often — is the Diagnostics page's job, so `last_failure_at` and
|
||||||
* cached response ages visibly instead of freezing at the moment it was served.
|
* `last_failure_error` are read there rather than repeated in this row.
|
||||||
*/
|
*/
|
||||||
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 }) {
|
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);
|
const idle = health.upstreams.length > 0 && health.upstreams.every(({ period }) => period.attempts === 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -168,7 +159,7 @@ export default function UpstreamHealthTable({ health }: { health: UpstreamHealth
|
|||||||
<thead>
|
<thead>
|
||||||
<tr {...stylex.props(styles.groupRow)}>
|
<tr {...stylex.props(styles.groupRow)}>
|
||||||
<td colSpan={2} />
|
<td colSpan={2} />
|
||||||
<th scope="colgroup" colSpan={4} {...stylex.props(styles.groupHead)}>
|
<th scope="colgroup" colSpan={3} {...stylex.props(styles.groupHead)}>
|
||||||
Selected period · {health.period}
|
Selected period · {health.period}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -188,9 +179,6 @@ export default function UpstreamHealthTable({ health }: { health: UpstreamHealth
|
|||||||
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
|
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
|
||||||
Success rate
|
Success rate
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" {...stylex.props(styles.th)}>
|
|
||||||
Last failure
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -220,9 +208,6 @@ export default function UpstreamHealthTable({ health }: { health: UpstreamHealth
|
|||||||
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
|
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
|
||||||
{successRate(upstream.period)}
|
{successRate(upstream.period)}
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(styles.cell, styles.small, styles.muted)}>
|
|
||||||
{lastFailure(upstream.period, nowSeconds)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { formatAge, formatBytes, formatDuration, formatMicros, formatTime } from "@/lib/format";
|
import { formatBytes, formatDuration, formatMicros, formatTime } from "@/lib/format";
|
||||||
|
|
||||||
test("formatTime renders unix seconds in the given locale and zone", () => {
|
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.
|
// 2024-01-01T00:00:00Z; ICU emits U+202F before AM/PM in recent Node.
|
||||||
@@ -15,23 +15,19 @@ test("formatBytes humanizes with binary units", () => {
|
|||||||
expect(formatBytes(2 * 1024 ** 4)).toBe("2.0 TiB");
|
expect(formatBytes(2 * 1024 ** 4)).toBe("2.0 TiB");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("formatAge steps up a unit at each boundary and truncates", () => {
|
test("formatDuration 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("formatDuration is the same span without the 'ago', and never negative", () => {
|
|
||||||
expect(formatDuration(0)).toBe("0s");
|
expect(formatDuration(0)).toBe("0s");
|
||||||
expect(formatDuration(59)).toBe("59s");
|
expect(formatDuration(59)).toBe("59s");
|
||||||
|
expect(formatDuration(60)).toBe("1m");
|
||||||
|
expect(formatDuration(3599)).toBe("59m");
|
||||||
expect(formatDuration(3600)).toBe("1h");
|
expect(formatDuration(3600)).toBe("1h");
|
||||||
|
expect(formatDuration(10800)).toBe("3h");
|
||||||
|
expect(formatDuration(86399)).toBe("23h");
|
||||||
expect(formatDuration(86400)).toBe("1d");
|
expect(formatDuration(86400)).toBe("1d");
|
||||||
|
expect(formatDuration(400000)).toBe("4d");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatDuration is never negative", () => {
|
||||||
// Clock skew between the server's timestamps and the browser's clock.
|
// Clock skew between the server's timestamps and the browser's clock.
|
||||||
expect(formatDuration(-5)).toBe("0s");
|
expect(formatDuration(-5)).toBe("0s");
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-15
@@ -28,21 +28,9 @@ const AGE_UNITS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seconds of elapsed time → a coarse "3h ago". Truncating and single-unit on
|
* Seconds of elapsed time → a coarse "3h". Truncating and single-unit on
|
||||||
* purpose: this labels a snapshot the caller renders once, so a reader must not
|
* purpose, for a span the caller labels itself, as in "active for 3h". A
|
||||||
* take it for a live count. Nothing re-renders it as it ages.
|
* negative span reads "0s": clock skew is not a duration.
|
||||||
*/
|
|
||||||
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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seconds of elapsed time → a coarse "3h", the same single truncated unit as
|
|
||||||
* `formatAge` without the "ago". For a span the caller labels itself, as in
|
|
||||||
* "active for 3h". A negative span reads "0s": clock skew is not a duration.
|
|
||||||
*/
|
*/
|
||||||
export function formatDuration(seconds: number): string {
|
export function formatDuration(seconds: number): string {
|
||||||
for (const unit of AGE_UNITS) {
|
for (const unit of AGE_UNITS) {
|
||||||
|
|||||||
@@ -193,7 +193,15 @@ export const styles = stylex.create({
|
|||||||
mono: {
|
mono: {
|
||||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||||
},
|
},
|
||||||
/** Visible to a screen reader only; the element keeps its place in the a11y tree. */
|
/**
|
||||||
|
* Visible to a screen reader only; the element keeps its place in the a11y tree.
|
||||||
|
*
|
||||||
|
* Apply it to a block container. `overflow` has no effect on a table box, and
|
||||||
|
* `height` on one is a minimum, so a `<table>` wearing this still lays out at
|
||||||
|
* its full content height and pushes the page's scrollable overflow past the
|
||||||
|
* app shell — invisible, because `clip-path` still hides the paint. Wrap the
|
||||||
|
* table in a hidden `<div>` instead of hiding the table itself.
|
||||||
|
*/
|
||||||
srOnly: {
|
srOnly: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
width: 1,
|
width: 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user