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
537 lines
16 KiB
TypeScript
537 lines
16 KiB
TypeScript
import { useState } from "react";
|
|
import {
|
|
useInfiniteQuery,
|
|
useMutation,
|
|
useQueryClient,
|
|
type InfiniteData,
|
|
type UseInfiniteQueryResult,
|
|
} from "@tanstack/react-query";
|
|
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
|
import * as stylex from "@stylexjs/stylex";
|
|
import * as api from "@/lib/api";
|
|
import InlineError from "@/lib/InlineError";
|
|
import { formatDuration, formatTime } from "@/lib/format";
|
|
import { diagnosticPurgeMutation, diagnosticsInfiniteQuery, diagnosticsPurgeResolvedMutation } from "@/lib/queries";
|
|
import type { DiagnosticEvent, DiagnosticSeverity, DiagnosticState, DiagnosticsPage as Page } from "@/lib/types";
|
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
|
import Select from "@/ui/Select";
|
|
import { styles as shared } from "@/ui/styles";
|
|
import { colors } from "@/ui/tokens.stylex";
|
|
import HealthStrip from "./HealthStrip";
|
|
import SeverityBadge from "./SeverityBadge";
|
|
import { diagnosticsFilterOf } from "./filter";
|
|
import { DIAGNOSTIC_COMPONENTS, componentLabel, copyFor } from "./eventCopy";
|
|
|
|
const DARK = "@media (prefers-color-scheme: dark)";
|
|
|
|
const STATE_OPTIONS = [
|
|
{ value: "all", label: "Active and resolved" },
|
|
{ value: "active", label: "Active only" },
|
|
{ value: "resolved", label: "Resolved only" },
|
|
];
|
|
|
|
const SEVERITY_OPTIONS = [
|
|
{ value: "any", label: "Any severity" },
|
|
{ value: "warning", label: "Warnings" },
|
|
{ value: "error", label: "Errors" },
|
|
];
|
|
|
|
const COMPONENT_OPTIONS = [
|
|
{ value: "any", label: "All components" },
|
|
...DIAGNOSTIC_COMPONENTS.map((component) => ({ value: component, label: componentLabel(component) })),
|
|
];
|
|
|
|
const styles = stylex.create({
|
|
heading: {
|
|
fontSize: "1.5rem",
|
|
lineHeight: "2rem",
|
|
fontWeight: 600,
|
|
},
|
|
intro: {
|
|
marginTop: "0.25rem",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: colors.textMuted,
|
|
maxWidth: "48rem",
|
|
},
|
|
filterGrid: {
|
|
marginTop: "1rem",
|
|
display: "grid",
|
|
gap: "0.75rem",
|
|
gridTemplateColumns: {
|
|
default: "repeat(1, minmax(0, 1fr))",
|
|
"@media (min-width: 640px)": "repeat(3, minmax(0, 1fr))",
|
|
},
|
|
maxWidth: "48rem",
|
|
},
|
|
sectionHeading: {
|
|
marginTop: "1.5rem",
|
|
fontSize: "1.125rem",
|
|
lineHeight: "1.75rem",
|
|
fontWeight: 600,
|
|
},
|
|
sectionHeadingRow: {
|
|
display: "flex",
|
|
alignItems: "baseline",
|
|
flexWrap: "wrap",
|
|
justifyContent: "space-between",
|
|
gap: "0.75rem",
|
|
},
|
|
/**
|
|
* Nothing open is the normal state of a working install, so it gets one
|
|
* quiet muted line — no border, no icon, no alert role. A panel here would
|
|
* read as a broken page rather than as good news.
|
|
*/
|
|
healthy: {
|
|
marginTop: "0.5rem",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: colors.textSecondary,
|
|
},
|
|
empty: {
|
|
marginTop: "0.5rem",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: colors.textMuted,
|
|
},
|
|
cardList: {
|
|
marginTop: "0.75rem",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "0.5rem",
|
|
listStyleType: "none",
|
|
padding: 0,
|
|
},
|
|
card: {
|
|
borderRadius: "0.25rem",
|
|
borderWidth: 1,
|
|
borderStyle: "solid",
|
|
borderColor: colors.border,
|
|
backgroundColor: colors.surfaceRaised,
|
|
paddingInline: "0.75rem",
|
|
paddingBlock: "0.625rem",
|
|
},
|
|
cardTop: {
|
|
display: "flex",
|
|
alignItems: "baseline",
|
|
flexWrap: "wrap",
|
|
gap: "0.5rem",
|
|
},
|
|
cardTitle: {
|
|
fontWeight: 500,
|
|
color: colors.primaryOnSurface,
|
|
textDecorationLine: "none",
|
|
},
|
|
subject: {
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: colors.textSecondary,
|
|
wordBreak: "break-all",
|
|
},
|
|
meta: {
|
|
marginTop: "0.25rem",
|
|
fontSize: "0.75rem",
|
|
lineHeight: "1rem",
|
|
color: colors.textMuted,
|
|
},
|
|
rangeNotice: {
|
|
marginTop: "0.75rem",
|
|
borderRadius: "0.25rem",
|
|
borderWidth: 1,
|
|
borderStyle: "solid",
|
|
borderColor: colors.border,
|
|
backgroundColor: colors.surfaceHover,
|
|
paddingInline: "0.75rem",
|
|
paddingBlock: "0.5rem",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: colors.textSecondary,
|
|
},
|
|
tableWrap: {
|
|
marginTop: "0.75rem",
|
|
overflowX: "auto",
|
|
borderRadius: "0.25rem",
|
|
borderWidth: 1,
|
|
borderStyle: "solid",
|
|
borderColor: colors.border,
|
|
},
|
|
table: {
|
|
width: "100%",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
},
|
|
head: {
|
|
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
|
|
textAlign: "left",
|
|
},
|
|
th: {
|
|
paddingInline: "0.75rem",
|
|
paddingBlock: "0.5rem",
|
|
fontWeight: 500,
|
|
color: colors.textSecondary,
|
|
whiteSpace: "nowrap",
|
|
},
|
|
row: {
|
|
borderTopWidth: { default: 1, ":first-child": 0 },
|
|
borderTopStyle: "solid",
|
|
borderTopColor: colors.border,
|
|
},
|
|
cell: {
|
|
paddingInline: "0.75rem",
|
|
paddingBlock: "0.5rem",
|
|
},
|
|
nowrap: {
|
|
whiteSpace: "nowrap",
|
|
},
|
|
rowLink: {
|
|
color: colors.primaryOnSurface,
|
|
textDecorationLine: "none",
|
|
},
|
|
footer: {
|
|
marginTop: "0.75rem",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.75rem",
|
|
},
|
|
note: {
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: colors.textMuted,
|
|
},
|
|
moreError: {
|
|
marginTop: "0.5rem",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: colors.dangerText,
|
|
},
|
|
});
|
|
|
|
type Section = UseInfiniteQueryResult<InfiniteData<Page, unknown>, Error>;
|
|
|
|
/** The two enum filters, narrowed from the picker's string rather than cast. */
|
|
function asState(value: string): DiagnosticState | undefined {
|
|
return value === "active" || value === "resolved" ? value : undefined;
|
|
}
|
|
|
|
function asSeverity(value: string): DiagnosticSeverity | undefined {
|
|
return value === "warning" || value === "error" ? value : undefined;
|
|
}
|
|
|
|
function errorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
function occurrenceText(count: number): string {
|
|
return `${count} ${count === 1 ? "occurrence" : "occurrences"}`;
|
|
}
|
|
|
|
function rowsOf(section: Section): DiagnosticEvent[] {
|
|
return (section.data?.pages ?? []).flatMap((page) => page.events);
|
|
}
|
|
|
|
/**
|
|
* The cursor comes from the newest page on screen, not from `hasNextPage`:
|
|
* while placeholder data stands in for a filter change the query state is
|
|
* empty, and the button would flash away and back.
|
|
*/
|
|
function hasMore(section: Section): boolean {
|
|
const pages = section.data?.pages ?? [];
|
|
const last = pages[pages.length - 1];
|
|
return last !== undefined && last.next_before !== null;
|
|
}
|
|
|
|
function MoreButton({ section }: { section: Section }) {
|
|
const more = hasMore(section);
|
|
// A 401 is already redirecting via the cache-level handleUnauthorized.
|
|
const isUnauthorized = section.error instanceof api.ApiError && section.error.status === 401;
|
|
const failed = section.isFetchNextPageError && !isUnauthorized ? errorMessage(section.error) : null;
|
|
if (!more && failed === null) return null;
|
|
return (
|
|
<>
|
|
<div {...stylex.props(styles.footer)}>
|
|
{more && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (section.isFetchingNextPage || section.isPlaceholderData) return;
|
|
void section.fetchNextPage();
|
|
}}
|
|
disabled={section.isFetchingNextPage || section.isPlaceholderData}
|
|
{...stylex.props(shared.button, shared.focusRing)}
|
|
>
|
|
{section.isFetchingNextPage ? "Loading…" : "Load more"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{failed !== null && (
|
|
<p role="alert" {...stylex.props(styles.moreError)}>
|
|
Failed to load more: {failed}
|
|
</p>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The window the page is bounded to, whenever it is bounded.
|
|
*
|
|
* A link from a query detail arrives with an absolute five-minute window, and
|
|
* an empty Active section inside it means something very different from an
|
|
* empty Active section over the whole history. The page has to say which it is
|
|
* showing, and offer the way out of it.
|
|
*/
|
|
function RangeNotice({ since, until }: { since?: number; until?: number }) {
|
|
const navigate = useNavigate({ from: "/diagnostics" });
|
|
if (since === undefined && until === undefined) return null;
|
|
const from = since === undefined ? "the start of the history" : formatTime(since);
|
|
const to = until === undefined ? "now" : formatTime(until);
|
|
return (
|
|
<p role="status" {...stylex.props(styles.rangeNotice)}>
|
|
Showing events that overlap {from} to {to}.{" "}
|
|
<button
|
|
type="button"
|
|
onClick={() => void navigate({ search: (prev) => ({ ...prev, since: undefined, until: undefined }) })}
|
|
{...stylex.props(shared.linkButton, shared.focusRing)}
|
|
>
|
|
Clear the time range
|
|
</button>
|
|
</p>
|
|
);
|
|
}
|
|
|
|
function ActiveCard({ event, now }: { event: DiagnosticEvent; now: number }) {
|
|
const copy = copyFor(event.code);
|
|
return (
|
|
<li {...stylex.props(styles.card)}>
|
|
<div {...stylex.props(styles.cardTop)}>
|
|
<SeverityBadge severity={event.severity} />
|
|
<Link
|
|
to="/diagnostics/$id"
|
|
params={{ id: String(event.id) }}
|
|
{...stylex.props(styles.cardTitle, shared.focusRing)}
|
|
>
|
|
{copy.title}
|
|
</Link>
|
|
<span {...stylex.props(styles.subject)}>{event.subject}</span>
|
|
</div>
|
|
<p {...stylex.props(styles.meta)}>
|
|
Active for {formatDuration(now - event.first_seen)} · {occurrenceText(event.occurrences)} · last failure{" "}
|
|
{formatTime(event.last_seen)}
|
|
</p>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function HistoryRow({ event, onPurge, busy }: { event: DiagnosticEvent; onPurge: () => void; busy: boolean }) {
|
|
const copy = copyFor(event.code);
|
|
return (
|
|
<tr {...stylex.props(styles.row)}>
|
|
<td {...stylex.props(styles.cell)}>
|
|
<SeverityBadge severity={event.severity} />
|
|
</td>
|
|
<td {...stylex.props(styles.cell)}>
|
|
<Link
|
|
to="/diagnostics/$id"
|
|
params={{ id: String(event.id) }}
|
|
{...stylex.props(styles.rowLink, shared.focusRing)}
|
|
>
|
|
{copy.title}
|
|
</Link>
|
|
</td>
|
|
<td {...stylex.props(styles.cell)}>{event.subject}</td>
|
|
<td {...stylex.props(styles.cell, styles.nowrap)}>{formatTime(event.first_seen)}</td>
|
|
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
|
{event.resolved_at === null ? "—" : formatTime(event.resolved_at)}
|
|
</td>
|
|
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{event.occurrences}</td>
|
|
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
|
<button
|
|
type="button"
|
|
onClick={onPurge}
|
|
disabled={busy}
|
|
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
|
|
>
|
|
Purge
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
export default function DiagnosticsPage() {
|
|
const search = useSearch({ from: "/shell/diagnostics" });
|
|
const navigate = useNavigate({ from: "/diagnostics" });
|
|
const state = search.state ?? "all";
|
|
|
|
const base = diagnosticsFilterOf(search);
|
|
|
|
const active = useInfiniteQuery(diagnosticsInfiniteQuery({ ...base, state: "active" }, state !== "resolved"));
|
|
const history = useInfiniteQuery(diagnosticsInfiniteQuery({ ...base, state: "resolved" }, state !== "active"));
|
|
|
|
const queryClient = useQueryClient();
|
|
const purgeOne = useMutation(diagnosticPurgeMutation(queryClient));
|
|
const purgeAll = useMutation(diagnosticsPurgeResolvedMutation(queryClient));
|
|
// `null` is "no dialog"; the id is which row it is about, and `"all"` the
|
|
// whole history. One piece of state, so the two dialogs cannot both be open.
|
|
const [pendingPurge, setPendingPurge] = useState<number | "all" | null>(null);
|
|
|
|
const activeRows = rowsOf(active);
|
|
const historyRows = rowsOf(history);
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const purging = purgeOne.isPending || purgeAll.isPending;
|
|
|
|
function setSearch(patch: Partial<typeof search>) {
|
|
void navigate({ search: (prev) => ({ ...prev, ...patch }) });
|
|
}
|
|
|
|
function confirmPurge() {
|
|
if (pendingPurge === null) return;
|
|
if (pendingPurge === "all") {
|
|
purgeAll.mutate();
|
|
} else {
|
|
purgeOne.mutate(pendingPurge);
|
|
}
|
|
setPendingPurge(null);
|
|
}
|
|
|
|
return (
|
|
<section>
|
|
<h1 {...stylex.props(styles.heading)}>Diagnostics</h1>
|
|
<p {...stylex.props(styles.intro)}>
|
|
Operational failures, one entry per subject that failed. An entry opens on the first failure, counts
|
|
repeats, and closes when the subject recovers.
|
|
</p>
|
|
|
|
<HealthStrip />
|
|
|
|
<RangeNotice since={search.since} until={search.until} />
|
|
|
|
<div {...stylex.props(styles.filterGrid)}>
|
|
<Select
|
|
variant="compactField"
|
|
label="Show"
|
|
value={state}
|
|
onChange={(value) => setSearch({ state: asState(value) })}
|
|
options={STATE_OPTIONS}
|
|
/>
|
|
<Select
|
|
variant="compactField"
|
|
label="Severity"
|
|
value={search.severity ?? "any"}
|
|
onChange={(value) => setSearch({ severity: asSeverity(value) })}
|
|
options={SEVERITY_OPTIONS}
|
|
/>
|
|
<Select
|
|
variant="compactField"
|
|
label="Component"
|
|
value={search.component ?? "any"}
|
|
onChange={(value) => setSearch({ component: value === "any" ? undefined : value })}
|
|
options={COMPONENT_OPTIONS}
|
|
/>
|
|
</div>
|
|
|
|
{state !== "resolved" && (
|
|
<>
|
|
<h2 {...stylex.props(styles.sectionHeading)}>Active</h2>
|
|
{active.status === "error" ? (
|
|
<InlineError error={active.error} onRetry={() => void active.refetch()} />
|
|
) : active.data === undefined ? (
|
|
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
|
Loading diagnostics…
|
|
</p>
|
|
) : activeRows.length === 0 ? (
|
|
<p {...stylex.props(styles.healthy)} role="status">
|
|
No active operational issues.
|
|
</p>
|
|
) : (
|
|
<>
|
|
<ul {...stylex.props(styles.cardList)}>
|
|
{activeRows.map((event) => (
|
|
<ActiveCard key={event.id} event={event} now={now} />
|
|
))}
|
|
</ul>
|
|
<MoreButton section={active} />
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{state !== "active" && (
|
|
<>
|
|
<div {...stylex.props(styles.sectionHeading, styles.sectionHeadingRow)}>
|
|
<h2>Resolved</h2>
|
|
{historyRows.length > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setPendingPurge("all")}
|
|
disabled={purging}
|
|
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
|
|
>
|
|
Purge all resolved
|
|
</button>
|
|
)}
|
|
</div>
|
|
{history.status === "error" ? (
|
|
<InlineError error={history.error} onRetry={() => void history.refetch()} />
|
|
) : history.data === undefined ? (
|
|
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
|
Loading history…
|
|
</p>
|
|
) : historyRows.length === 0 ? (
|
|
<p {...stylex.props(styles.empty)}>Nothing has failed and recovered in the retained window.</p>
|
|
) : (
|
|
<>
|
|
<div {...stylex.props(styles.tableWrap)}>
|
|
<table {...stylex.props(styles.table)}>
|
|
<thead {...stylex.props(styles.head)}>
|
|
<tr>
|
|
<th {...stylex.props(styles.th)}>Severity</th>
|
|
<th {...stylex.props(styles.th)}>Event</th>
|
|
<th {...stylex.props(styles.th)}>Subject</th>
|
|
<th {...stylex.props(styles.th)}>Started</th>
|
|
<th {...stylex.props(styles.th)}>Resolved</th>
|
|
<th {...stylex.props(styles.th)}>Occurrences</th>
|
|
<th {...stylex.props(styles.th)}>
|
|
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{historyRows.map((event) => (
|
|
<HistoryRow
|
|
key={event.id}
|
|
event={event}
|
|
busy={purging}
|
|
onPurge={() => setPendingPurge(event.id)}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p {...stylex.props(styles.footer, styles.note)}>
|
|
Showing {historyRows.length} resolved {historyRows.length === 1 ? "entry" : "entries"}
|
|
{hasMore(history) ? "" : " — end of history"}
|
|
</p>
|
|
<MoreButton section={history} />
|
|
</>
|
|
)}
|
|
<InlineError error={purgeOne.error ?? purgeAll.error} />
|
|
</>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
isOpen={pendingPurge !== null}
|
|
title={pendingPurge === "all" ? "Purge resolved history" : "Purge event"}
|
|
message={
|
|
pendingPurge === "all"
|
|
? "Purge all resolved events? Active events are kept."
|
|
: "Purge this resolved event? Its history is gone for good."
|
|
}
|
|
confirmLabel={pendingPurge === "all" ? "Purge all" : "Purge"}
|
|
onConfirm={confirmPurge}
|
|
onCancel={() => setPendingPurge(null)}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|