Skip to content

Commit a933911

Browse files
fuziontechclaude
andcommitted
admin/errors: fix detail dialog overflow + stable-sort Live tables
Two admin-console UI fixes: 1. Error detail dialog overflowed its width on long, unbreakable content (e.g. an S3 URL in a DeltaKernel error). DialogContent is a CSS grid, so the message/query <pre> got min-width:auto and pushed the whole modal past max-w-2xl. Add min-w-0 to the content grid-item so it can shrink, plus w-full + break-words on the <pre> blocks so long tokens wrap instead of blowing out the layout. 2. Live tables flickered on every 3s poll because the API returns rows in non-deterministic order (per-org map iteration + cross-CP merge). Sort client-side with a deterministic total order: running queries by start time (oldest first, so long-lived/stuck sessions stay pinned on top), sessions by cluster-unique worker id (they carry no start time). Pure comparators in lib/liveSort.ts with unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUq2EVxvKQFq3YEDNLF5HP
1 parent 17ea236 commit a933911

4 files changed

Lines changed: 96 additions & 5 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from "vitest";
2+
import { compareByStarted, compareByWorker } from "./liveSort";
3+
4+
describe("compareByStarted", () => {
5+
it("orders oldest start first", () => {
6+
const rows = [
7+
{ started_at: "2026-07-01T10:00:02Z", worker_id: 2 },
8+
{ started_at: "2026-07-01T10:00:01Z", worker_id: 1 },
9+
{ started_at: "2026-07-01T10:00:03Z", worker_id: 3 },
10+
];
11+
expect([...rows].sort(compareByStarted).map((r) => r.worker_id)).toEqual([1, 2, 3]);
12+
});
13+
14+
it("is stable across reshuffled input (kills flicker)", () => {
15+
const a = { started_at: "2026-07-01T10:00:01Z", worker_id: 5 };
16+
const b = { started_at: "2026-07-01T10:00:02Z", worker_id: 9 };
17+
const c = { started_at: "2026-07-01T10:00:03Z", worker_id: 1 };
18+
// Any input permutation yields the same output order.
19+
const want = [5, 9, 1];
20+
expect([a, b, c].sort(compareByStarted).map((r) => r.worker_id)).toEqual(want);
21+
expect([c, a, b].sort(compareByStarted).map((r) => r.worker_id)).toEqual(want);
22+
expect([b, c, a].sort(compareByStarted).map((r) => r.worker_id)).toEqual(want);
23+
});
24+
25+
it("breaks ties on worker id so equal timestamps never reorder", () => {
26+
const t = "2026-07-01T10:00:00Z";
27+
const rows = [
28+
{ started_at: t, worker_id: 30 },
29+
{ started_at: t, worker_id: 10 },
30+
{ started_at: t, worker_id: 20 },
31+
];
32+
expect([...rows].sort(compareByStarted).map((r) => r.worker_id)).toEqual([10, 20, 30]);
33+
});
34+
35+
it("sorts missing timestamps last (still deterministic)", () => {
36+
const rows = [
37+
{ worker_id: 7 },
38+
{ started_at: "2026-07-01T10:00:01Z", worker_id: 2 },
39+
{ worker_id: 3 },
40+
];
41+
expect([...rows].sort(compareByStarted).map((r) => r.worker_id)).toEqual([2, 3, 7]);
42+
});
43+
});
44+
45+
describe("compareByWorker", () => {
46+
it("orders by cluster-unique worker id", () => {
47+
const rows = [{ worker_id: 30 }, { worker_id: 10 }, { worker_id: 20 }];
48+
expect([...rows].sort(compareByWorker).map((r) => r.worker_id)).toEqual([10, 20, 30]);
49+
});
50+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Pure, stable ordering for the Live tables. The API returns rows in
2+
// non-deterministic order (per-org map iteration + the cross-CP merge), so every
3+
// 3s poll reshuffles them and the tables flicker. A deterministic client-side
4+
// sort with a cluster-unique tiebreaker (worker id) pins row order across
5+
// refreshes — the same rows stay put, only genuinely new/gone rows move.
6+
7+
export interface StartedRow {
8+
started_at?: string;
9+
worker_id: number;
10+
}
11+
12+
export interface WorkerRow {
13+
worker_id: number;
14+
}
15+
16+
// compareByStarted orders by session start time, OLDEST first — long-lived
17+
// sessions stay pinned at the top (where an operator watching for stuck/runaway
18+
// sessions wants them) and a brand-new row appends at the bottom rather than
19+
// shoving everything down. worker id is the tiebreaker so equal-or-missing
20+
// timestamps never reorder between polls. Timestamps are UTC RFC3339, so a
21+
// lexicographic compare is chronological; missing timestamps sort last.
22+
export function compareByStarted(a: StartedRow, b: StartedRow): number {
23+
const as = a.started_at || "";
24+
const bs = b.started_at || "";
25+
if (as !== bs) {
26+
if (!as) return 1;
27+
if (!bs) return -1;
28+
return as < bs ? -1 : 1;
29+
}
30+
return a.worker_id - b.worker_id;
31+
}
32+
33+
// compareByWorker is the stable ordering for rows with no start time (sessions):
34+
// the cluster-unique worker id alone is a total order, enough to stop flicker.
35+
export function compareByWorker(a: WorkerRow, b: WorkerRow): number {
36+
return a.worker_id - b.worker_id;
37+
}

controlplane/admin/ui/src/pages/Errors.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ function ErrorDetailDialog({ error, onClose }: { error: ErrorEntry | null; onClo
8686
<DialogDescription>Redacted snapshot of one failed query captured on the owning control plane.</DialogDescription>
8787
</DialogHeader>
8888
{error && (
89-
<div className="space-y-4">
89+
<div className="min-w-0 space-y-4">
9090
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
9191
<Field label="Time" value={fmtTime(error.time)} mono />
9292
<Field label="Org" value={error.org} mono />
@@ -99,13 +99,13 @@ function ErrorDetailDialog({ error, onClose }: { error: ErrorEntry | null; onClo
9999
</div>
100100
<div>
101101
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">Message</span>
102-
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 text-xs text-destructive">
102+
<pre className="mt-1 max-h-40 w-full overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-3 text-xs text-destructive">
103103
{error.message || "—"}
104104
</pre>
105105
</div>
106106
<div>
107107
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">Query (redacted)</span>
108-
<pre className="mt-1 max-h-56 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 font-mono text-xs">
108+
<pre className="mt-1 max-h-56 w-full overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-3 font-mono text-xs">
109109
{error.query || "—"}
110110
</pre>
111111
</div>

controlplane/admin/ui/src/pages/Live.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
import { useCancelSession, useKillUserSessions, useQueries, useSessions } from "@/hooks/useApi";
2222
import { fmtAge, fmtDurationMs, fmtInt, fmtTime } from "@/lib/format";
2323
import { idleInTransaction, isIdleSession, sessionStateLabel } from "@/lib/session";
24+
import { compareByStarted, compareByWorker } from "@/lib/liveSort";
2425
import { QueryDetailDialog } from "@/components/QueryDetailDialog";
2526

2627
export function Live() {
@@ -39,13 +40,16 @@ export function Live() {
3940
const matchOrg = (o?: string) => !org || (o ?? "").toLowerCase().includes(org.toLowerCase());
4041
const matchUser = (u?: string) => !user || (u ?? "").toLowerCase().includes(user.toLowerCase());
4142

43+
// Sort deterministically so the tables don't reshuffle on every 3s poll (the
44+
// API returns rows in non-deterministic order). Queries by start time (oldest
45+
// first); sessions by worker id (they carry no start time).
4246
const liveQueries = useMemo(
43-
() => (queries.data ?? []).filter((q) => matchOrg(q.org) && matchUser(q.user)),
47+
() => (queries.data ?? []).filter((q) => matchOrg(q.org) && matchUser(q.user)).sort(compareByStarted),
4448
// eslint-disable-next-line react-hooks/exhaustive-deps
4549
[queries.data, org, user],
4650
);
4751
const liveSessions = useMemo(
48-
() => (sessions.data ?? []).filter((s) => matchOrg(s.org) && matchUser(s.user)),
52+
() => (sessions.data ?? []).filter((s) => matchOrg(s.org) && matchUser(s.user)).sort(compareByWorker),
4953
// eslint-disable-next-line react-hooks/exhaustive-deps
5054
[sessions.data, org, user],
5155
);

0 commit comments

Comments
 (0)