diff --git a/ui/docs/app-extensions.md b/ui/docs/app-extensions.md index d9236fd68..d255efb1e 100644 --- a/ui/docs/app-extensions.md +++ b/ui/docs/app-extensions.md @@ -452,8 +452,9 @@ happy with this application's chrome may still want its own mark on it. ```tsx branding: { - AppIcon: MyMark, // receives { collapsed }; supplied whole, like everything else - appName: "My Product", // used for the document title + AppIcon: MyMark, // receives { collapsed }; supplied whole, like everything else + appName: "My Product", // used for the document title + faviconUrl: "/my-mark.svg", // the tab icon; a URL, since the browser loads it itself } ``` diff --git a/ui/index.html b/ui/index.html index 81b27b97e..86318f564 100644 --- a/ui/index.html +++ b/ui/index.html @@ -4,6 +4,13 @@ kagent + + + + + + + diff --git a/ui/src/appExtensions/appExtensions.test.ts b/ui/src/appExtensions/appExtensions.test.ts index c8892e1f0..9b75d6ff8 100644 --- a/ui/src/appExtensions/appExtensions.test.ts +++ b/ui/src/appExtensions/appExtensions.test.ts @@ -1,6 +1,7 @@ import { createElement } from "react"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + applyExtensionBranding, applyExtensionFieldValues, buildSidebarSections, defineExtensionFormField, @@ -426,6 +427,52 @@ describe("composing several installed extensions", () => { }); }); +describe("applyExtensionBranding", () => { + const favicon = () => document.querySelector("link[data-app-favicon]"); + + beforeEach(() => { + // Head first: replacing its contents drops the element with everything else, + // which resets `document.title` to "". + document.head.innerHTML = '<link rel="icon" data-app-favicon href="/favicon.svg" />'; + document.title = "kagent"; + }); + + it("leaves the application's own title and icon alone when an extension sets neither", () => { + applyExtensionBranding({}); + + // Not reset to a default: every extension would otherwise have to restate the + // branding it was perfectly happy with. + expect(document.title).toBe("kagent"); + expect(favicon()?.getAttribute("href")).toBe("/favicon.svg"); + }); + + it("retargets the shipped link rather than adding a second one", () => { + applyExtensionBranding({ appName: "My Product", faviconUrl: "/my-mark.svg" }); + + expect(document.title).toBe("My Product"); + expect(document.querySelectorAll("link[data-app-favicon]")).toHaveLength(1); + expect(favicon()?.href).toContain("/my-mark.svg"); + }); + + it("still applies the icon when the host page shipped no link to retarget", () => { + document.head.innerHTML = ""; + + applyExtensionBranding({ faviconUrl: "/my-mark.svg" }); + // Twice: the link it creates carries the marker, so the second call retargets the + // first rather than leaving two icons for the browser to choose between. + applyExtensionBranding({ faviconUrl: "/other-mark.svg" }); + + expect(document.querySelectorAll('link[rel="icon"]')).toHaveLength(1); + expect(favicon()?.href).toContain("/other-mark.svg"); + }); + + it("does not advertise SVG for an icon that is not one", () => { + applyExtensionBranding({ faviconUrl: "/my-mark.png" }); + + expect(favicon()?.getAttribute("type")).toBe(""); + }); +}); + describe("validateAppExtensions", () => { const page = { path: "/insights", element: createElement("div") }; diff --git a/ui/src/appExtensions/branding.ts b/ui/src/appExtensions/branding.ts index ff92436df..904260bc6 100644 --- a/ui/src/appExtensions/branding.ts +++ b/ui/src/appExtensions/branding.ts @@ -27,17 +27,40 @@ export interface ExtensionBranding { AppIcon?: ComponentType<ExtensionAppIconProps>; /** Product name, used for the document title. */ appName?: string; + /** + * Replaces the tab icon. A URL and not a component, unlike `AppIcon`: the browser + * loads this itself from a `<link>`, so there is nothing for us to render. + */ + faviconUrl?: string; } /** - * Applies the document title a distribution asked for. + * Applies the title and tab icon a distribution asked for. * * Takes the merged branding rather than the install, so the "later extension wins" * rule is applied once, where every other singular capability applies it — see * `selectors.ts`. + * + * Both are left alone when unset rather than reset to a default: the document already + * carries this application's own, and writing them unconditionally would mean every + * extension had to restate the branding it was happy with. */ -export function applyExtensionDocumentTitle( - branding: ExtensionBranding | undefined, -): void { +export function applyExtensionBranding(branding: ExtensionBranding | undefined): void { if (branding?.appName) document.title = branding.appName; + if (!branding?.faviconUrl) return; + + // The tag `index.html` ships, or a fresh one if a host page dropped it — a missing + // icon is not a reason to leave the extension's branding unapplied. The new one carries + // the marker too, so a second call retargets this link instead of appending another. + let link = document.querySelector<HTMLLinkElement>("link[data-app-favicon]"); + if (!link) { + link = document.createElement("link"); + link.rel = "icon"; + link.dataset.appFavicon = ""; + document.head.appendChild(link); + } + link.href = branding.faviconUrl; + // `index.html` declares SVG. Left alone, a PNG would be served under a type that says + // otherwise, which browsers are entitled to act on when choosing between icons. + link.type = branding.faviconUrl.endsWith(".svg") ? "image/svg+xml" : ""; } diff --git a/ui/src/appExtensions/index.ts b/ui/src/appExtensions/index.ts index 51aa32b7e..d6f7129a5 100644 --- a/ui/src/appExtensions/index.ts +++ b/ui/src/appExtensions/index.ts @@ -144,7 +144,7 @@ export type { ExtensionTableColumn, ExtensionTableId } from "./tableColumns"; // Branding: the product's own name and mark, which is identity rather than // styling and so should not cost a layout replacement. -export { applyExtensionDocumentTitle } from "./branding"; +export { applyExtensionBranding } from "./branding"; export type { ExtensionAppIconProps, ExtensionBranding } from "./branding"; // Navigation overrides: the other half of contributing an entry — changing one diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 48b53f5ef..12faac787 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -4,7 +4,7 @@ import { isMockMode } from "./api/config"; import { activeAppExtensions } from "./appExtensions/activeExtensions"; import { extensionBranding, extensionThemes } from "./appExtensions/selectors"; import { loadExtensionStylesheets } from "./appExtensions/theme"; -import { applyExtensionDocumentTitle } from "./appExtensions/branding"; +import { applyExtensionBranding } from "./appExtensions/branding"; import { AuthProvider } from "./auth"; import { App } from "./App"; @@ -17,7 +17,7 @@ async function bootstrap() { // Before the first render: a web font that arrives afterwards reflows // everything already painted. loadExtensionStylesheets(extensionThemes(activeAppExtensions)); - applyExtensionDocumentTitle(extensionBranding(activeAppExtensions)); + applyExtensionBranding(extensionBranding(activeAppExtensions)); // Which backend is serving is decided in one place, `api/config.ts`, and read // here rather than re-derived: two independent readings of the same env var diff --git a/ui/src/mocks/fixtures.ts b/ui/src/mocks/fixtures.ts index 919d91c72..67c213acd 100644 --- a/ui/src/mocks/fixtures.ts +++ b/ui/src/mocks/fixtures.ts @@ -308,11 +308,25 @@ export const mockSubstrateStatus: SubstrateStatusResponse = { // Last in the fixture and first once sorted: ate-api returns actors in no // particular order, so a fixture that is already in the right order cannot tell // a page that sorts from one that does not. - { actorId: "actor-0aa1", status: "Failed", version: 1 }, + // + // The raw wire constant, because that is what a real controller sends for a state + // it has no name for — a fixture of tidy words would let `ACTOR_STATE_CRASHED` + // reach the page unread and no test object. + { actorId: "actor-0aa1", status: "ACTOR_STATE_CRASHED", version: 1 }, // Shares "Running" with actor-7f21, which is what makes a two-key sort observable: // with every status distinct, sorting by status then by id looks the same as // sorting by status alone. { actorId: "actor-3b55", status: "Running", version: 1 }, + // Parked rather than broken, and the only status here that reads as neither: + // without it nothing on the page is drawn in the idle tone. + { actorId: "actor-5d17", status: "Paused", version: 1 }, + // The controller's other unnamed state. `ACTOR_STATE_CRASHED` alone would pass a + // humaniser that special-cased that one word; two of them do not. + { actorId: "actor-2e40", status: "ACTOR_STATE_DELETING", version: 1 }, + // A transition, and a word the page recognises by its shape rather than from a + // list — the same rule that has to carry `Suspending` and `Pausing`. + { actorId: "actor-8b91", status: "Resuming", version: 1 }, + { actorId: "actor-c3f5", status: "Suspended", version: 3 }, ], workers: [ { diff --git a/ui/src/pages/SubstratePage.tsx b/ui/src/pages/SubstratePage.tsx index 5e928c98f..209fe6538 100644 --- a/ui/src/pages/SubstratePage.tsx +++ b/ui/src/pages/SubstratePage.tsx @@ -14,7 +14,8 @@ import { Typography, } from "antd"; import type { ColumnsType } from "antd/es/table"; -import { useTheme } from "@emotion/react"; +import { useTheme, type CSSObject, type Theme } from "@emotion/react"; +import { useThemeMode } from "@/theme/themeMode"; import { Radio, Search } from "lucide-react"; import { PageFrame } from "@/components/Structure/PageFrame"; import { StatTile } from "@/components/dashboard/StatTile"; @@ -29,6 +30,7 @@ import { type SubstrateSortOrder, type SubstrateWorkerSortField, type SubstrateActorTemplateEntry, + type SubstrateStatusCount, type SubstrateWorkerEntry, type SubstrateWorkerPoolEntry, } from "@/api"; @@ -50,10 +52,11 @@ const GROWING_TABLE_HEIGHT = 420; /** * The interval polling starts at, in seconds. * - * A second is quick enough to watch an actor move between workers and slow enough to - * leave running while reading, which is what this control is for. + * Half a second is quick enough to watch an actor move between workers, which is what + * this control is for. It is also the floor below, so the default is the fastest this + * page will ask — a reader who turns polling on wants to see the cluster move. */ -const DEFAULT_POLL_SECONDS = 1; +const DEFAULT_POLL_SECONDS = 0.5; /** * The fastest this page will ask, in seconds. @@ -95,7 +98,25 @@ const NAMESPACE_PARAM = "namespace"; const ALL_NAMESPACES = ""; /** - * What a status or phase is telling you, as four readings rather than a dozen strings. + * A wire enum as a word: `ACTOR_STATE_CRASHED` reads as `Crashed`. + * + * The controller names the states it knows, but falls back to the protobuf constant for + * any it does not, so an unmapped state reaches this page as a wire symbol. Proto names + * every value after its own enum, and that prefix only repeats the column header, so it + * goes rather than being spelled out as `Actor state crashed`. + * + * Anything not shaped like a constant is returned untouched: a status the controller has + * already written for a reader must not be rewritten by a guess about its casing. + */ +function humanizeEnum(label: string): string { + const value = label.trim(); + if (!/^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/.test(value)) return value; + const words = value.replace(/^[A-Z0-9]+_STATE_/, "").toLowerCase().replace(/_/g, " "); + return words.charAt(0).toUpperCase() + words.slice(1); +} + +/** + * What a status or phase is telling you, as five readings rather than a dozen strings. * * The substrate's vocabulary is not a closed enum on the wire: `phase` and `status` are * plain strings that ate-api and the ActorTemplate controller each fill in their own way, @@ -103,40 +124,61 @@ const ALL_NAMESPACES = ""; * `neutral` and is shown as it arrived — inventing a colour for a word this page has * never seen would be a claim about health nobody made. */ -type StatusTone = "healthy" | "warning" | "progress" | "idle" | "neutral"; +type StatusTone = "healthy" | "danger" | "warning" | "progress" | "idle" | "neutral"; function statusTone(label: string): StatusTone { - const value = label.trim().toLowerCase(); + const value = humanizeEnum(label).trim().toLowerCase(); if (value === "ready" || value === "running") return "healthy"; - if (value === "failed" || value === "suspending") return "warning"; - if (value === "suspended" || value === "unknown" || value === "") return "idle"; - // Substrings, because these arrive spelled several ways: `Resuming`, `WaitingForWorker`, - // `GoldenSnapshotPending`. All of them mean the same thing to a reader — something is - // under way and the next read will say something different. - if (value.includes("resume") || value.includes("wait") || value.includes("golden")) { + // A crashed or failed actor is not a caution, it is the thing that went wrong. + if (value === "failed" || value === "crashed") return "danger"; + // Deletion is in flight like the transitions below and is checked before them, because + // it is the one that does not come back: an actor that reads the same shade as one + // taking a snapshot is an actor nobody looks at twice. + if (value.includes("delet")) return "warning"; + // `idle` among them because that is the word the workers table already uses for a pod + // holding no actor, and a parked worker and a parked actor are the same news. + if ( + value === "suspended" || + value === "paused" || + value === "idle" || + value === "unknown" || + value === "" + ) { + return "idle"; + } + // Shapes rather than words, because these arrive spelled several ways: `Resuming`, + // `Suspending`, `WaitingForWorker`, `GoldenSnapshotPending`. All of them mean the same + // thing to a reader — something is under way and the next read will say otherwise. + if (value.endsWith("ing") || value.includes("wait") || value.includes("golden")) { return "progress"; } return "neutral"; } /** - * A status, coloured by what it means. + * Each tone's three colours, the theme's own rather than antd's presets. + * + * antd derives a tag's three from one foreground token on the assumption of a light + * page. `primary` is not among them in any tone — it is a fill chosen to carry light + * text, and as ink on this page it measures about 2.2:1. * - * The triples are the theme's own rather than antd's presets, for the reason the palette - * states: antd derives a tag's three colours from one foreground token on the assumption - * of a light page. `primary` is not among them in any tone — it is a fill chosen to carry - * light text, and as ink on this page it measures about 2.2:1. + * `color` is the saturated one and the only one that carries meaning on its own: the + * fills are near-identical tints, about ΔE 3 apart, so a stripe painted with them + * would read as one stripe. That is what the bar below fills with, and it is why the + * bar and the chips read the same status the same way. */ -function StatusChip({ label }: { label: string }) { - const theme = useTheme(); - const tone = statusTone(label); - - const pill = { +function statusPalette(theme: Theme): Record<StatusTone, CSSObject> { + return { healthy: { background: theme.color.successBg, borderColor: theme.color.successBorder, color: theme.color.successText, }, + danger: { + background: theme.color.dangerBg, + borderColor: theme.color.dangerBorder, + color: theme.color.dangerText, + }, warning: { background: theme.color.warningBg, borderColor: theme.color.warningBorder, @@ -149,7 +191,9 @@ function StatusChip({ label }: { label: string }) { }, idle: { background: theme.color.bgElevated, - borderColor: theme.color.border, + // `borderStrong` and not `border`: the hairline token is the app's dividers, and at + // 1.4:1 it is a decorative edge rather than a boundary. This one measures 3.5:1. + borderColor: theme.color.borderStrong, color: theme.color.textMuted, }, neutral: { @@ -157,7 +201,15 @@ function StatusChip({ label }: { label: string }) { borderColor: theme.color.borderStrong, color: theme.color.text, }, - }[tone]; + }; +} + +/** A status, coloured by what it means. */ +function StatusChip({ label }: { label: string }) { + const theme = useTheme(); + const tone = statusTone(label); + const text = humanizeEnum(label); + const pill = statusPalette(theme)[tone]; return ( <Tag @@ -166,9 +218,9 @@ function StatusChip({ label }: { label: string }) { /* * The substrate's vocabulary is open-ended: `phase` and `status` are plain * strings, and a value this build has never seen is shown as it arrived. Some - * of them are long — a cluster answered with `ACTOR_STATE_CRASHED`, which at - * one line overflowed its column and printed itself across the next one. So - * the tag wraps inside the width it is given rather than spilling out of it. + * of them are long — `WaitingForWorker` at one line overflowed its column and + * printed itself across the next one. So the tag wraps inside the width it is + * given rather than spilling out of it. */ whiteSpace: "normal", maxWidth: "100%", @@ -176,11 +228,347 @@ function StatusChip({ label }: { label: string }) { }} data-tone={tone} > - {label.trim() === "" ? "not reported" : label} + {text === "" ? "not reported" : text} </Tag> ); } +/** + * A count at a glance: 999 stays 999, 1,100 becomes `1.1k`. + * + * The legend and the bar are read sideways, and a cluster answered with 410,110 actors — + * a row of exact figures there is a row nobody reads. The exact numbers stay where they + * are acted on: the tiles, the section counts and the table. + * + * `K` lowercased because that is the convention for thousands; `M` and above are left as + * `Intl` writes them, where uppercase is the convention instead. + */ +const compactNumber = new Intl.NumberFormat(undefined, { + notation: "compact", + maximumFractionDigits: 1, +}); +const atAGlance = (count: number) => compactNumber.format(count).replace("K", "k"); + +/** + * Every actor state a controller can report, so the legend is the vocabulary rather than + * today's sample: a reader learns that `Crashed` is a thing that happens by seeing it at + * zero, not by waiting for one. + * + * Mirrors `ActorStatusLabel` in `go/core/internal/substrate/list.go`, which names the + * `ate.dev` `ActorState` enum. Drift is not a failure here: this decides only what is + * listed at zero, and any state the controller reports that is missing from it is added + * to the legend from the data — so a new one appears the first time it happens. + */ +const ACTOR_STATES = [ + "Crashed", + "Deleting", + "Pausing", + "Resuming", + "Running", + "Snapshotting", + "Suspending", + "Paused", + "Suspended", + "Unknown", +]; + +/** + * How many actors the bar will draw one segment each for. + * + * A segment per actor is what makes the bar countable — eight ticks with two green is + * read, not estimated. It stops being countable long before it stops being drawable, and + * a cluster answered with 410,110 actors, so past this the bar falls back to one + * proportional band per status. The number is where counting gives out, not where the + * browser does. + */ +const ACTORS_DRAWN_INDIVIDUALLY = 80; + +/** + * The whole actor inventory as one bar, coloured by what each actor is doing. + * + * Two running of ten with the rest suspended is two green segments and eight grey. The + * tile above says how many are running; only this says what the other eight are doing, + * and with the table paged it is the one place the whole distribution appears at all — a + * reader on page one of 410,110 actors has otherwise no way to learn that most of them + * have crashed. + * + * The fills are the pills' own text colours, so an actor is the same colour here as in + * the table. Not the pills' fills: those are near-identical tints about ΔE 3 apart, and a + * bar painted with them would read as one long smudge. + */ +function StatusBar({ + counts, + title, + caption, + emptyText, + testId, + vocabulary, + noun, + unread, +}: { + counts: SubstrateStatusCount[]; + /** Every status worth listing at zero. Anything counted but missing is added to it. */ + vocabulary: string[]; + /** What is being counted, for the places with room to say it: `Actors`, `Workers`. */ + noun: string; + /** True when the read failed, so nothing here is a count of anything. */ + unread?: boolean; + /** + * The bar's accessible name, announced with its breakdown. Not drawn: the legend + * beneath already names every colour on it, and a heading over a card that is already + * called "Actors" would only say it twice. + */ + title: string; + /** What this bar is counting, when it is not simply the whole scope. */ + caption?: string; + emptyText: string; + testId: string; +}) { + const theme = useTheme(); + const { mode } = useThemeMode(); + const dark = mode === "dark"; + const palette = statusPalette(theme); + // Short in the legend, where the swatch and the column already say what is counted. + const read = (entry: SubstrateStatusCount) => + `${entry.status || "not reported"}: ${atAGlance(entry.count)}`; + // Long wherever the reading stands on its own — `Suspended Actors: 6` rather than a + // number under a status a tooltip has floated away from. + const readFull = (entry: SubstrateStatusCount) => + `${entry.status || "Not reported"} ${noun}: ${atAGlance(entry.count)}`; + + /* + * Counted by the word rather than by the wire value. + * + * A controller that has learned a state sends `Crashed` and one that has not sends + * `ACTOR_STATE_CRASHED`; both read as `Crashed`, and keyed by the raw string they came + * out as two entries — the legend listed `Crashed` twice, once at zero. + */ + const merged = new Map<string, number>(); + 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) => ( + <div + key={key} + data-tone={tone} + css={{ + /* The pill's own colour, as a wash behind its own outline. Both are mixed toward + the page rather than used at full strength — which dims them on a dark page and + lightens them on a light one, from one expression. At full strength eight of + these is a row of paint chips. + The strengths come from the track's own custom properties, so hovering the bar + deepens every segment at once without any of them having to know the tone. */ + ...paint(tone), + flexGrow: grow, + flexBasis: 0, + /* One crashed actor in 410,110 is 0.0002% of the width: without a floor it is not + a pixel, let alone something to point at — and it is the most important thing + on the bar. */ + minWidth: 6, + height: 18, + // Only the two ends are rounded, so the row reads as one bar rather than as a + // line of separate lozenges. + borderRadius: `${first ? 4 : 0}px ${last ? 4 : 0}px ${last ? 4 : 0}px ${first ? 4 : 0}px`, + boxSizing: "border-box", + transition: "background 120ms, border-color 120ms", + }} + /> + ); + + const track = ( + <div + data-testid={testId} + /* The tooltip needs a pointer, which a screen reader has not got and a keyboard + cannot produce. So the same summary is the bar's own name — colour and hover are + never the only things carrying it. */ + role="img" + aria-label={total === 0 ? emptyText : `${title}. ${summary}`} + css={{ + display: "flex", + gap: 3, + minHeight: 18, + /* Hover only: pointing at the bar reveals the breakdown, but nothing happens on + press, and an active state would promise that it does. + Deepening the mix rather than brightening it: `brightness` on a fill that is + mostly page colour washes it out to the page instead of strengthening it, which + on a light theme reads as the segments going transparent. */ + ":hover": { "--seg-fill": dark ? "30%" : "22%" }, + }} + > + {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), + )} + </div> + ); + + /* + * 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 = ( + <div + data-testid={`${testId}-legend`} + css={{ display: "flex", flexWrap: "wrap", gap: "2px 4px", marginTop: 8 }} + > + {keys.map((entry) => ( + <span + key={entry.status} + css={{ + display: "inline-flex", + alignItems: "center", + gap: 6, + fontSize: 12, + /* The padding and the radius are the same whether or not anything holds this + status, and only the fill changes: a highlight that added weight or space + would move every key beside it each time a count crossed zero, on a page + that polls. */ + padding: "2px 8px", + borderRadius: 6, + // A key is something to read past, not text to drag through: selecting it while + // sweeping the pointer along the row is never what anyone meant. + userSelect: "none", + background: entry.count === 0 ? "transparent" : theme.color.bgElevated, + }} + > + {/* 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. */} + <span + aria-hidden + css={{ + ...paint(statusTone(entry.status)), + width: 10, + height: 10, + borderRadius: 3, + opacity: entry.count === 0 ? 0.45 : 1, + }} + /> + <Text + css={{ + color: entry.count === 0 ? theme.color.textMuted : theme.color.text, + fontSize: 12, + opacity: entry.count === 0 ? 0.95 : 1, + }} + > + {read(entry)} + </Text> + </span> + ))} + </div> + ); + + 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. */ + <div + css={{ + marginBottom: 6, + /* Declared here rather than on the bar, because the legend keys are painted from + the same expressions and are the bar's siblings: on the track they resolved to + nothing outside it, and every key came out invisible. + + At rest this is the pill's fill exactly; hovering pulls it toward the pill's own + saturated colour, further on a dark page where the same step shows less. */ + "--seg-fill": "0%", + }} + > + {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 : ( + <Text + data-testid={`${testId}-empty`} + css={{ color: theme.color.textMuted, fontSize: 12, display: "block", marginTop: 8 }} + > + {emptyText} + </Text> + )} + {legend} + </> + ) : ( + <Tooltip + title={ + <> + {caption ? <div css={{ opacity: 0.75 }}>{caption}</div> : null} + {present.map((entry) => ( + <div key={entry.status}>{readFull(entry)}</div> + ))} + </> + } + > + {/* 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. */} + <div> + {track} + {legend} + </div> + </Tooltip> + )} + </div> + ); +} + /** * 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}` : ""} </Text> @@ -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<string, number>(); + 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) => <span css={mono}>{actor.actorId}</span>, }, { @@ -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) => <StatusChip label={actor.status} />, }, { @@ -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 ? ( - <Text css={{ ...mono, ...muted }}> + /* 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. */ + <Text css={{ ...mono, ...muted, whiteSpace: "nowrap" }}> {actor.ateomPodNamespace ?? ""}/{actor.ateomPodName} {actor.ateomPodIp ? ` · ${actor.ateomPodIp}` : ""} </Text> @@ -1273,21 +1702,6 @@ export function SubstratePage() { /> </div> - {/* 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 ? ( - <Space size={6} wrap data-testid="substrate-actor-status-counts"> - <Text css={{ ...muted, fontSize: 12 }}>Actors by status</Text> - {inventory.actorStatusCounts.map((entry) => ( - <Tag key={entry.status} css={mono}> - {entry.status || "not reported"}: {entry.count.toLocaleString()} - </Tag> - ))} - </Space> - ) : null} - <Card title={ <SectionTitle @@ -1398,6 +1812,23 @@ export function SubstratePage() { /> ) : null} + <StatusBar + testId="substrate-actor-status-counts" + title="Actor status" + vocabulary={ACTOR_STATES} + noun="Actors" + unread={Boolean(summary.error || actors.error)} + counts={actorBar.counts} + caption={actorBar.caption} + emptyText={ + actorFilter + ? "No actors match your search." + : ateApiEnabled + ? "No actors in this scope." + : "ate-api is not configured, so there are no actors to show." + } + /> + <Table<SubstrateActorEntry> 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} - <Table<SubstrateWorkerEntry> +<Table<SubstrateWorkerEntry> 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<keyof typeof darkColor, string> = { // 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",