();
+ for (const entry of counts) {
+ const key = humanizeEnum(entry.status);
+ merged.set(key, (merged.get(key) ?? 0) + entry.count);
+ }
+
+ /*
+ * Grouped by status and ordered by the word, with everything parked pushed to the end.
+ *
+ * Idle is where a bar's dead weight belongs: a cluster that is mostly suspended reads as
+ * a short band of activity against a long grey tail, rather than having the interesting
+ * part cut in half by it. Sorted here rather than trusted from the server, because a bar
+ * whose segments reorder between polls is a bar nobody can point at.
+ */
+ const order = (entries: SubstrateStatusCount[]) =>
+ [...entries].sort((a, b) => {
+ const parked = (entry: SubstrateStatusCount) => (statusTone(entry.status) === "idle" ? 1 : 0);
+ return parked(a) - parked(b) || a.status.localeCompare(b.status);
+ });
+ const entries = [...merged].map(([status, count]) => ({ status, count }));
+ const present = order(entries.filter((entry) => entry.count > 0));
+ const total = present.reduce((sum, entry) => sum + entry.count, 0);
+ const perActor = total > 0 && total <= ACTORS_DRAWN_INDIVIDUALLY;
+ const summary = [caption, present.map(readFull).join(", ")].filter(Boolean).join(". ");
+
+ // The one place a tone becomes two colours, so a legend key and the segment it explains
+ // are the same colour by construction rather than by two expressions agreeing.
+ const paint = (tone: StatusTone): CSSObject => ({
+ /*
+ * The pill's own three colours, not a mix of one of them with the page.
+ *
+ * Mixing toward the page is what turned these grey: every tone converges on the
+ * background as the fill weakens, so at a subtle strength they all read as the same
+ * washed-out slab. Taking the pill's fill and the pill's own border instead makes a
+ * segment the same colour as the chip in the row below by construction, rather than
+ * by two sets of numbers agreeing — and both are lighter than the mix was.
+ */
+ background: `color-mix(in srgb, ${palette[tone].color} var(--seg-fill), ${palette[tone].background})`,
+ border: `1px solid ${palette[tone].borderColor}`,
+ });
+
+ const segment = (tone: StatusTone, key: string, grow: number, first: boolean, last: boolean) => (
+
+ );
+
+ const track = (
+
+ {present
+ .flatMap((entry) => {
+ const tone = statusTone(entry.status);
+ return perActor
+ ? Array.from({ length: entry.count }, (_, i) => ({ tone, key: `${entry.status}-${i}`, grow: 1 }))
+ : [{ tone, key: entry.status, grow: entry.count }];
+ })
+ .map((part, index, all) =>
+ segment(part.tone, part.key, part.grow, index === 0, index === all.length - 1),
+ )}
+
+ );
+
+ /*
+ * The legend, in the bar's own order and colours.
+ *
+ * The bar says the proportions and the legend says the numbers; between them a reader
+ * gets both without hovering anything, which is what a tooltip alone cannot give
+ * someone reading a screenshot or printing the page.
+ */
+ const keys = order(
+ [...new Set([...vocabulary.map(humanizeEnum), ...merged.keys()])].map((status) => ({
+ status,
+ count: merged.get(status) ?? 0,
+ })),
+ );
+
+ const legend = (
+
+ {keys.map((entry) => (
+
+ {/* A status nothing is in is still worth listing, and still worth being the
+ quietest thing here — but the fading is mostly the swatch's job. The text at
+ the swatch's own opacity measured 3.79:1 on a light page, under AA; at 0.95
+ it is 4.64:1 there and 7.20:1 on a dark one, and still visibly the quieter. */}
+
+
+ {read(entry)}
+
+
+ ))}
+
+ );
+
+ return (
+ /* The empty row keeps its height, with the reason beneath it. A bar that vanished when
+ a search stopped matching would move the table under a reader at the moment they
+ were reading why. */
+
+ {total === 0 ? (
+ <>
+ {track}
+ {/* Silent when the read failed: the banner above already says so, and "no actors
+ in this scope" under a broken backend reports a healthy empty cluster. The
+ legend stays either way — it is ten keys and two rows tall, and dropping it
+ as the last actor drains moves the table under whoever is reading it. */}
+ {unread ? null : (
+
+ {emptyText}
+
+ )}
+ {legend}
+ >
+ ) : (
+
+ {caption ? {caption}
: null}
+ {present.map((entry) => (
+ {readFull(entry)}
+ ))}
+ >
+ }
+ >
+ {/* The bar and its legend under one tooltip: they are the same reading, and a
+ breakdown reachable from the chart but not from the key that explains it is
+ a breakdown half the pointers on the page will miss. */}
+
+ {track}
+ {legend}
+
+
+ )}
+
+ );
+}
+
/**
* A section's name and how many rows are under it, which is worth knowing before
* reading them.
@@ -464,7 +852,7 @@ function ServerOrder({
data-testid={testId}
css={{ color: theme.color.textMuted, fontSize: 12 }}
>
- Sorted by the server: {labels[field] ?? field}
+ Sorted: {labels[field] ?? field}
{order === "desc" ? ", descending" : ", ascending"}
{age ? ` · ${age}` : ""}
@@ -781,9 +1169,46 @@ export function SubstratePage() {
[inventory?.actorTemplates, templateQuery],
);
- const actorRows = actors.error ? [] : (actors.data?.actors ?? []);
- const workerRows = workers.error ? [] : (workers.data?.workers ?? []);
+ /* Memoised so the two bars below have a stable dependency: both branches allocate a new
+ array, so an inline expression changed identity on every render and the memos it fed
+ recomputed every tick — which is the one thing they exist to avoid. */
+ const actorRows = useMemo(
+ () => (actors.error ? [] : (actors.data?.actors ?? [])),
+ [actors.error, actors.data?.actors],
+ );
+ /*
+ * What the bar above the actor table counts.
+ *
+ * Unfiltered it is the summary's own counts, which is the only honest source of a whole
+ * cluster: the table holds one page, and a page counted and drawn as the cluster would
+ * report eight actors for a deployment running 410,110.
+ *
+ * A search has no server-side breakdown, so the matches are counted here from the rows
+ * that came back — and those are also a page. `actorBarCaption` is what stops the bar
+ * claiming the rest: it says how many of the matches are actually in it.
+ */
+ const actorBar = useMemo(() => {
+ if (!actorFilter) {
+ return { counts: inventory?.actorStatusCounts ?? [], caption: undefined as string | undefined };
+ }
+ const byStatus = new Map();
+ for (const actor of actorRows) {
+ byStatus.set(actor.status, (byStatus.get(actor.status) ?? 0) + 1);
+ }
+ const matches = actors.data?.totalSize ?? actorRows.length;
+ return {
+ counts: [...byStatus].map(([status, count]) => ({ status, count })),
+ caption:
+ actorRows.length < matches
+ ? `Matching “${actorFilter}”: ${atAGlance(actorRows.length)} of ${atAGlance(matches)} shown`
+ : `Matching “${actorFilter}”: ${atAGlance(matches)}`,
+ };
+ }, [actorFilter, actorRows, actors.data?.totalSize, inventory?.actorStatusCounts]);
+ const workerRows = useMemo(
+ () => (workers.error ? [] : (workers.data?.workers ?? [])),
+ [workers.error, workers.data?.workers],
+ );
/*
* The tiles, from the summary's own counts.
*
@@ -932,7 +1357,7 @@ export function SubstratePage() {
/>
),
key: "actorId",
- width: 320,
+ width: 300,
render: (_, actor) => {actor.actorId},
},
{
@@ -945,9 +1370,10 @@ export function SubstratePage() {
/>
),
key: "status",
- // Wide enough for the longest status seen on a real cluster
- // (`ACTOR_STATE_CRASHED`) without wrapping it to three lines.
- width: 190,
+ // Wide enough for the longest status a controller reports (`Snapshotting`). It was
+ // 190 while `ACTOR_STATE_CRASHED` could reach the page; the words are shorter than
+ // the constants were, and the columns no longer overflow their card because of it.
+ width: 130,
render: (_, actor) => ,
},
{
@@ -960,7 +1386,7 @@ export function SubstratePage() {
/>
),
key: "template",
- width: 260,
+ width: 240,
render: (_, actor) =>
actor.actorTemplateName
? qualified(actor.actorTemplateNamespace, actor.actorTemplateName)
@@ -976,10 +1402,13 @@ export function SubstratePage() {
/>
),
key: "pod",
- width: 320,
+ width: 260,
render: (_, actor) =>
actor.ateomPodName ? (
-
+ /* One line, always. A pod name and an IP together outrun the column, and
+ wrapping them made the row two lines tall — which moves every row under it,
+ on a page that polls. It runs into the slack on its right instead. */
+
{actor.ateomPodNamespace ?? ""}/{actor.ateomPodName}
{actor.ateomPodIp ? ` · ${actor.ateomPodIp}` : ""}
@@ -1273,21 +1702,6 @@ export function SubstratePage() {
/>
- {/* Every actor status, not only the running tally.
- Knowing that 1 of 410,110 actors is running says nothing about the other
- 410,109 — and on this cluster what it does not say is that most of them
- have crashed. The summary counts them all, so the page can. */}
- {inventory && inventory.actorStatusCounts.length > 1 ? (
-
- Actors by status
- {inventory.actorStatusCounts.map((entry) => (
-
- {entry.status || "not reported"}: {entry.count.toLocaleString()}
-
- ))}
-
- ) : null}
-
) : null}
+
+
data-testid="substrate-actors-table"
rowKey={(actor) => actor.actorId}
@@ -1406,7 +1837,9 @@ export function SubstratePage() {
loading={actors.isLoading}
pagination={false}
virtual
- scroll={{ y: GROWING_TABLE_HEIGHT, x: 1040 }}
+ /* The sum of the column widths, so the table asks for exactly what it uses:
+ a wider `x` reserves space no column wants and scrolls the card for it. */
+ scroll={{ y: GROWING_TABLE_HEIGHT, x: 930 }}
size="small"
/* Three different sentences, because they are three different facts and
only one is something to act on: a controller with no ate-api endpoint
@@ -1484,7 +1917,7 @@ export function SubstratePage() {
/>
) : null}
-
+
data-testid="substrate-workers-table"
rowKey={(worker) =>
`${worker.workerNamespace}/${worker.workerPool}/${worker.workerPod}`
diff --git a/ui/src/theme/theme.ts b/ui/src/theme/theme.ts
index 657c2c761..4ba2dcec9 100644
--- a/ui/src/theme/theme.ts
+++ b/ui/src/theme/theme.ts
@@ -59,13 +59,13 @@ const darkColor = {
* filled badge rather than the quiet pill the rest of the page uses.
*/
successBg: "#0c2c18",
- successBorder: "#166534",
+ successBorder: "#218045",
successText: "#4ade80",
warningBg: "#33240a",
- warningBorder: "#92400e",
+ warningBorder: "#a95c13",
warningText: "#fbbf24",
dangerBg: "#3a1417",
- dangerBorder: "#991b1b",
+ dangerBorder: "#be3d3d",
dangerText: "#f87171",
/**
* The brand colour as *foreground* text on the page.
@@ -86,7 +86,7 @@ const darkColor = {
* 4.2:1 and 3.4:1, both under the 4.5 that small text needs.
*/
infoBg: "#101c33",
- infoBorder: "#1e3a8a",
+ infoBorder: "#4366af",
infoText: "#93c5fd",
accentBg: "#1e152e",
accentBorder: "#5b21b6",
@@ -125,19 +125,19 @@ const lightColor: Record = {
// inverted. What was there before came out muddy: antd tinted its mid-green
// towards a light base and landed on something between the two.
successBg: "#dcfce7",
- successBorder: "#86efac",
+ successBorder: "#449e65",
successText: "#166534",
warningBg: "#fef3c7",
- warningBorder: "#fcd34d",
+ warningBorder: "#bf7e28",
warningText: "#92400e",
dangerBg: "#fee2e2",
- dangerBorder: "#fca5a5",
+ dangerBorder: "#ce6666",
dangerText: "#991b1b",
// On a white page the brand purple is already a readable foreground, so this is
// `primary`. The token exists so a component need not know which theme it is on.
primaryText: "#6d28d9",
infoBg: "#eff6ff",
- infoBorder: "#bfdbfe",
+ infoBorder: "#638be8",
infoText: "#1d4ed8",
accentBg: "#f5f3ff",
accentBorder: "#ddd6fe",