diff --git a/ui/playwright/helpers/app.ts b/ui/playwright/helpers/app.ts index 1781c46fc..17d04749d 100644 --- a/ui/playwright/helpers/app.ts +++ b/ui/playwright/helpers/app.ts @@ -17,7 +17,9 @@ export const routes = { models: "/models", modelNew: "/models/new", mcpServers: "/mcp", + mcpServerNew: "/mcp/new", prompts: "/prompts", + promptNew: "/prompts/new", substrate: "/substrate", /* The templates list is a tab of the agents page now. The old address still resolves — it redirects here — but a test should go where the reader goes. */ diff --git a/ui/playwright/tests/forms/required-fields.spec.ts b/ui/playwright/tests/forms/required-fields.spec.ts new file mode 100644 index 000000000..a49c02d60 --- /dev/null +++ b/ui/playwright/tests/forms/required-fields.spec.ts @@ -0,0 +1,157 @@ +import type { Locator, Page } from "@playwright/test"; +import { test, expect } from "../../fixtures/test"; +import { expectSettled, loadPage, routes } from "../../helpers/app"; + +/** The fixture configuration the edit form opens on. */ +const modelEdit = "/models/kagent/default-model-config/edit"; + +/** The fixture template the details page renders read-only. */ +const templateDetail = "/agent-templates/kagent/k8s-agent-7f3a91c"; + +/** + * The asterisk on a field the form will not submit without. + * + * antd draws it from `required` on a `Form.Item`, and every authoring surface here + * gates its own submit in code — `draftProblems`, `modelDraftIssues`, + * `validateMcpServerForm` — rather than through antd's rules. So the mark and the + * gate are two separate statements about the same field, and nothing but a test + * keeps them agreeing. They had already come apart: the whole agent-template form + * carried no mark at all while refusing to save without a model configuration, and + * the model form's API key was required to create with nothing on screen to say so. + * + * Asserted as a pair each time — what is marked *and* what is not. A test that only + * checked the marks would pass just as well on a form that marked every field, which + * tells a reader nothing about which ones matter. + */ + +/** One field's label, by the text a reader sees on it. */ +function fieldLabel(page: Page, text: string): Locator { + // Exact, because these labels are prefixes of each other: "Name" would otherwise + // match "Namespace" too, and match it first. + return page + .locator(".ant-form-item-label label") + .filter({ has: page.getByText(text, { exact: true }) }); +} + +/** + * The labels that carry the mark, and the ones that must not. + * + * Both lists in one helper so a call site reads as the claim it is making. + */ +async function expectRequired( + page: Page, + { marked, unmarked }: { marked: string[]; unmarked: string[] }, +): Promise { + for (const text of marked) { + const label = fieldLabel(page, text); + await expect(label, `"${text}" is required, so it must be marked`).toHaveCount(1); + await expect(label).toHaveClass(/ant-form-item-required/); + } + for (const text of unmarked) { + const label = fieldLabel(page, text); + await expect(label, `"${text}" is optional, so it must not be marked`).toHaveCount(1); + await expect(label).not.toHaveClass(/ant-form-item-required/); + } +} + +test("forms: a field the form refuses to submit without is marked required", async ({ + page, +}) => { + await test.step("1. the agent template form", async () => { + await loadPage(page, routes.agentTemplateNew, { title: "New agent template" }); + await expectSettled(page); + + // Name and the model configuration are what `draftProblems` refuses to submit + // without. Everything else on this form is genuinely optional — including the + // system prompt, which a template may take from its harness instead. + await expectRequired(page, { + marked: ["Name", "Model configuration"], + unmarked: ["Description", "System prompt"], + }); + }); + + await test.step("2. the model form, where the API key is required only to create", async () => { + await loadPage(page, routes.modelNew, { title: "New model" }); + await expectSettled(page); + + await expectRequired(page, { + marked: ["Provider", "Model", "Name", "Namespace", "API key"], + // A radio group that arrives with a choice already made cannot be missing one. + unmarked: ["Authentication"], + }); + }); + + await test.step("3. and not on an edit, which keeps the key it already has", async () => { + await loadPage(page, `${modelEdit}?mock=ok`); + await expectSettled(page); + + // The fixture holds its credential in a Secret, so the inline-key field is not + // on screen until that is what the reader is choosing. + await page + .getByTestId("model-auth-type") + .getByText("API key", { exact: true }) + .click(); + + // The label says the same thing in words; the mark has to agree with it, or the + // form is asking for a credential the cluster already holds. + await expectRequired(page, { + marked: ["Name", "Namespace"], + unmarked: ["API key (leave blank to keep existing)"], + }); + }); + + await test.step("4. the harness form", async () => { + await loadPage(page, routes.harnessNew, { title: "New harness" }); + await expectSettled(page); + + await expectRequired(page, { + marked: [ + "Namespace", + "Name", + "Runtime adapter", + "Workload image", + "Worker pool", + "Snapshot location", + ], + // Optional in the CRD's sense and warned about on screen instead: a harness + // with no selector is created and admits nothing. + unmarked: ["Admits agent templates labelled"], + }); + }); + + await test.step("5. the MCP server form, whose namespace really is optional", async () => { + await loadPage(page, routes.mcpServerNew, { title: "New MCP server" }); + await expectSettled(page); + + // Unlike every other form here: `validateMcpServerForm` accepts a blank + // namespace and the controller defaults it, so a mark would be a lie. + await expectRequired(page, { + marked: ["Name", "Server URL"], + unmarked: ["Namespace", "TLS", "Headers"], + }); + }); + + await test.step("6. the prompt library form", async () => { + await loadPage(page, routes.promptNew, { title: "New prompt library" }); + await expectSettled(page); + + await expectRequired(page, { marked: ["Namespace", "Name"], unmarked: [] }); + }); +}); + +/** + * Read-only is not a form, so it asks for nothing. + * + * The template details page renders the same component with `readOnly`, and an + * asterisk there would be asking a reader to supply something they are only looking + * at — on a template that already has it. + */ +test("forms: the read-only template view marks nothing as required", async ({ page }) => { + await loadPage(page, templateDetail); + await expectSettled(page); + + await expect(page.locator(".ant-form-item-label label").first()).toBeVisible(); + await expect(page.locator(".ant-form-item-label label.ant-form-item-required")).toHaveCount( + 0, + ); +}); diff --git a/ui/playwright/tests/substrate/substrate.spec.ts b/ui/playwright/tests/substrate/substrate.spec.ts index c0b7d9ced..5a74b89a7 100644 --- a/ui/playwright/tests/substrate/substrate.spec.ts +++ b/ui/playwright/tests/substrate/substrate.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from "../../fixtures/test"; import { expectSettled, loadPage, routes } from "../../helpers/app"; +import { paint, settledPaint } from "../../helpers/style"; /** * Substrate — the inventory, its scope, and the three ways the read can answer. @@ -316,58 +317,84 @@ test("substrate: each list narrows on its own, and a match is found wherever it }); /** - * Which tables offer a sort, which is currently a decision nobody has revisited. + * All four tables sort the same way, and the paged two say honestly what they sorted. * - * Every column of the actor and worker tables used to sort. The sorters were taken away - * when those lists became paged reads, and the reasoning was sound: a client-side sorter - * reorders the page it was handed, so "sort by status descending" shows the last status - * on *this page* rather than in the cluster, and the first row of a sorted 410,110 - * actors is almost certainly not among the hundred on screen. + * The actor and worker columns used to carry a header of this page's own: a button around + * the title, an arrow beside it, and nothing outside those few words to click. It was + * written that way to avoid antd's `sorter`, which reorders the rows the table was handed + * — and one page out of 410,110 reordered is not the cluster sorted. * - * That reason has gone. `ListSubstrateActors` and `ListSubstrateWorkers` were removed, - * `GetSubstrateStatus` returns the whole inventory again, and these tables hold every - * row — so a sorter here would now be as honest as the one the templates table keeps. - * The sorters have not come back, which is why this test's name no longer claims a - * principle: it pins what the page does today, and the open question is whether the - * actor and worker tables should sort again now that they could. + * The concern was right and the remedy was not: the page ended up with two tables that + * sort by clicking a header and two that sort by clicking the words inside one, which is + * a page a reader has to learn twice. What the columns declare now is `sorter: true` — + * antd's header, with no comparator behind it — so the whole cell is the target and the + * chevrons show the direction, while the table still reorders nothing itself. A click + * becomes the next read, which orders every row before this page gets a slice of it. * - * Kept rather than deleted because the assertion is still load-bearing in one direction. - * If these reads are paged again — and `DEFERRED.md` explains why they were the first - * time — a sorter added in the meantime becomes exactly the half-truth described above, - * and this is what would object. + * Not the server, which takes a namespace and nothing else: the ordering is applied in + * `localPage` over the whole inventory. That is still the honest claim at this size — + * the order holds over the cluster rather than over the hundred rows on screen — and + * what the strip beside each table has to say, which is the half this pins. If a + * comparator is ever handed to one of these tables, the order would hold over the page + * alone and these assertions are what would object. */ -test("substrate: the actor and worker tables offer no sort, and the inline ones do", async ({ +test("substrate: every table sorts through the same header, and the paged two order the lot", async ({ page, }) => { await loadPage(page, routes.substrate, { title: "Substrate" }); await expectSettled(page); - await test.step("1. the actor and worker columns offer no sort", async () => { - for (const testId of ["substrate-actors-table", "substrate-workers-table"]) { + await test.step("1. every table's headers are antd's own sort controls", async () => { + for (const testId of [ + "substrate-pools-table", + "substrate-templates-table", + "substrate-actors-table", + "substrate-workers-table", + ]) { const headers = page.getByTestId(testId).locator("th"); await expect(headers.first()).toBeVisible(); const sortable = await headers.evaluateAll((cells) => cells.filter((cell) => cell.className.includes("column-has-sorters")).length, ); + const total = await headers.count(); expect( sortable, - `${testId} offers no sort today; adding one is a decision, and paging these reads again would make it wrong`, - ).toBe(0); + `${testId}: every column sorts, and through the header rather than a control inside it`, + ).toBe(total); } }); - await test.step("2. the pools and templates do sort", async () => { - const headers = page.getByTestId("substrate-templates-table").locator("th"); - await expect(headers.first()).toHaveClass(/column-has-sorters/); + await test.step("2. the actors' order covers every row, and cycles back to the default", async () => { + const order = page.getByTestId("substrate-actors-order"); + await expect(order).toContainText("status, then actor"); + + // The header, not the words in it: clicking the cell is what a reader does on the + // two tables above, and this is the assertion that the same click works here. + const header = page.getByTestId("substrate-actors-table").locator("th").first(); + await header.click(); + await expect(order).toContainText("Sorted across the whole inventory: actor, ascending"); + + await header.click(); + await expect(order).toContainText("Sorted across the whole inventory: actor, descending"); + + // antd's third click clears the sort, which for a read that always arrives ordered + // means the order it falls back to rather than no order at all. + await header.click(); + await expect(order).toContainText("status, then actor"); }); - await test.step("3. the actors are grouped by status, in an order nobody asked for", async () => { + await test.step("3. and the workers' the same", async () => { + const order = page.getByTestId("substrate-workers-order"); + await expect(order).toContainText("pool, then pod"); + + await page.getByTestId("substrate-workers-table").locator("th").nth(1).click(); + await expect(order).toContainText("Sorted across the whole inventory: pool, ascending"); + }); + + await test.step("4. the actors are grouped by status, in an order nobody asked for", async () => { // Stated rather than asked for: ate-api returns actors in whatever order it holds // them, so the same actor would appear somewhere different on every poll. Something - // has to impose an order, and with the paged reads gone that something is - // `api/grpc/operations.ts`, which sorts what it filtered — where it used to be the - // server. Either way the point is that a row does not move under the pointer while - // it is being read. + // has to impose an order, and that something is the read rather than the table. const statuses = await page .getByTestId("substrate-actors-table") .locator(".ant-table-row") @@ -377,3 +404,51 @@ test("substrate: the actor and worker tables offer no sort, and the inline ones expect(statuses).toEqual([...statuses].sort()); }); }); + +/** + * Nothing on this page is a link, and nothing on it lights up under the pointer. + * + * A row that changes colour on hover reads as a click target. None of these four is one: + * there is no page for an actor, a worker, a pool or a template to open. The app has a + * rule for exactly this — hover is opt-in through `clickable-table-row` — and it was + * written as `tr:hover > td`, which a virtual table has neither of. So the two windowed + * tables here went on hovering while every other static table in the app had stopped, + * and this page offered both behaviours at once. + * + * Both bodies are checked because they are different markup: the pools and templates are + * a real `table`, the actors and workers are divs from antd's virtual list. + */ +test("substrate: rows nobody can click do not light up under the pointer", async ({ + page, +}) => { + await loadPage(page, routes.substrate, { title: "Substrate" }); + await expectSettled(page); + + for (const testId of [ + "substrate-pools-table", + "substrate-templates-table", + "substrate-actors-table", + "substrate-workers-table", + ]) { + const row = page.getByTestId(testId).locator(".ant-table-row").first(); + await expect(row).toBeVisible(); + const cell = row.locator(".ant-table-cell").first(); + + const atRest = (await paint(cell)).background; + await row.hover(); + /* + * That the hover landed is asserted before what it painted. antd marks the hovered + * row's cells whatever the app then does with them, so this separates "the rule + * suppressed the highlight" from "the pointer never arrived" — which the colour + * comparison alone cannot do, and which a fixed wait on a loaded box invites. + */ + await expect(cell).toHaveClass(/ant-table-cell-row-hover/); + // Waited out rather than polled: the claim is that nothing happens, and there is no + // event for a transition that never starts. See `helpers/style`. + const hovered = (await settledPaint(cell)).background; + + expect(hovered, `${testId}: a row that cannot be clicked must not look clickable`).toBe( + atRest, + ); + } +}); diff --git a/ui/playwright/tests/theme-contrast.spec.ts b/ui/playwright/tests/theme-contrast.spec.ts index bf541e0a3..f82935a14 100644 --- a/ui/playwright/tests/theme-contrast.spec.ts +++ b/ui/playwright/tests/theme-contrast.spec.ts @@ -16,6 +16,40 @@ import { test, expect } from "../fixtures/test"; */ const AA_SMALL_TEXT = 4.5; +/** What WCAG asks of a control's own edges and state, rather than of its text. */ +const AA_NON_TEXT = 3; + +/** + * The first colour in a CSS value — a box-shadow's colour rather than its offsets. + * + * With its alpha, which is the whole point: antd's elevation shadow is white at one + * percent, and read as opaque white it measures as the most visible edge on the page + * instead of the invisible one it is. That is the mistake the note above this file + * records against the *page* colours, made a second time against a shadow. + */ +function colourOf(value: string): { rgb: number[]; a: number } { + const parts = value.match(/rgba?\(([^)]+)\)/)?.[1].split(",").map(Number) ?? [ + 0, 0, 0, 1, + ]; + return { rgb: parts.slice(0, 3), a: parts.length > 3 ? parts[3] : 1 }; +} + +/** A translucent colour flattened onto what is behind it. */ +function over(fg: { rgb: number[]; a: number }, bg: number[]): number[] { + return fg.rgb.map((channel, index) => channel * fg.a + bg[index] * (1 - fg.a)); +} + +function contrast(a: number[], b: number[]): number { + const luminance = (rgb: number[]) => { + const [r, g, blue] = rgb.map((channel) => { + const s = channel / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * blue; + }; + const [lighter, darker] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (lighter + 0.05) / (darker + 0.05); +} /** Contrast of an element's text against everything painted behind it. */ const PROBE = () => { @@ -141,3 +175,78 @@ test.describe("dark theme: brand-coloured text", () => { ).toBeGreaterThanOrEqual(AA_SMALL_TEXT); }); }); + +/** + * A control's own edges, which a contrast check aimed at text walks straight past. + * + * The segmented control was reported as hard to read on the dark theme while measuring + * fine for text — 12.6:1 selected, 8.2:1 not. What was missing was the control: its + * track measured 1.00:1 against the page, the same colour, and its selected pill 1.12:1 + * against the track, behind an elevation shadow antd paints at one percent white. Both + * halves were legible and nothing said which one was chosen. + * + * Non-text contrast is a separate threshold and needs measuring separately, which is + * what this block does: 3:1 for the edge that identifies the state, with the text + * checks kept beside it so that edge cannot be won by dimming the labels. + */ +test.describe("dark theme: control edges", () => { + test.use({ colorScheme: "dark" }); + + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => window.localStorage.setItem("kagent.themeMode", "dark")); + await page.addInitScript(PROBE); + }); + + test("which half of a segmented control is chosen is visible, not just legible", async ({ + page, + }) => { + await page.goto("/mcp/new"); + await expect(page.getByTestId("mcp-kind")).toBeVisible(); + + const surfaces = await page.evaluate(() => { + const track = document.querySelector('[data-testid="mcp-kind"]'); + const pill = document.querySelector( + '[data-testid="mcp-kind"] .ant-segmented-item-selected', + ); + if (!track || !pill) throw new Error("the segmented control is not on the page"); + return { + ring: getComputedStyle(pill).boxShadow, + track: getComputedStyle(track).backgroundColor, + page: getComputedStyle(document.body).backgroundColor, + }; + }); + + /* + * The pill's edge, against the trough it sits in and against the page around it. A + * fill cannot carry this at the dark end — the page is near-black, so 3:1 on + * luminance alone takes a mid-grey pill that reads as disabled — so the theme + * outlines the pill instead, the way it outlines every other control. + */ + const track = colourOf(surfaces.track).rgb; + const behind = colourOf(surfaces.page).rgb; + // Flattened onto what each sits on before measuring, so a shadow that is barely + // there measures as barely there. + const ringOnTrack = over(colourOf(surfaces.ring), track); + const ringOnPage = over(colourOf(surfaces.ring), behind); + + expect(contrast(ringOnTrack, track)).toBeGreaterThanOrEqual(AA_NON_TEXT); + expect(contrast(ringOnPage, behind)).toBeGreaterThanOrEqual(AA_NON_TEXT); + }); + + test("and both of its labels stay readable", async ({ page }) => { + await page.goto("/mcp/new"); + await expect(page.getByTestId("mcp-kind")).toBeVisible(); + + const ratios = await page.evaluate(() => + [ + ...document.querySelectorAll( + '[data-testid="mcp-kind"] .ant-segmented-item-label', + ), + ].map((label) => + (window as unknown as { __contrast: (el: Element) => number }).__contrast(label), + ), + ); + expect(ratios.length).toBe(2); + for (const ratio of ratios) expect(ratio).toBeGreaterThanOrEqual(AA_SMALL_TEXT); + }); +}); diff --git a/ui/src/api/grpc/operations.ts b/ui/src/api/grpc/operations.ts index 472bc5c80..03a6aca94 100644 --- a/ui/src/api/grpc/operations.ts +++ b/ui/src/api/grpc/operations.ts @@ -1211,6 +1211,13 @@ const cluster: Pick< if (sortField === "workerPod") { return `${actor.ateomPodNamespace ?? ""}/${actor.ateomPodName ?? ""}\0${actor.actorId}`; } + /* + * `status` and `default` are one branch because they are one ordering: the + * default *is* status then id, as the field's own type says. So the Status + * header changes nothing ascending and reverses the grouping descending, which + * is correct and not obvious — named here so that a change to the default order + * has to decide what Status means rather than quietly turning it into a no-op. + */ return `${actor.status}\0${actor.actorId}`; }, (actor) => @@ -1243,6 +1250,8 @@ const cluster: Pick< const pod = `${worker.workerNamespace}/${worker.workerPod}`; if (sortField === "pod") return pod; if (sortField === "actor") return `${worker.actorId || "\uffff"}\0${pod}`; + // `pool` and `default` are one ordering for the reason the actors' `status` is: + // the default is pool then pod. return `${worker.workerPool}\0${pod}`; }, (worker) => diff --git a/ui/src/api/hooks/useSubstrate.ts b/ui/src/api/hooks/useSubstrate.ts index 55de9e606..3b72835a1 100644 --- a/ui/src/api/hooks/useSubstrate.ts +++ b/ui/src/api/hooks/useSubstrate.ts @@ -63,10 +63,19 @@ export function useSubstrateActors( sortField = "default", sortOrder = "asc", } = input; - // The sort is part of the key for the same reason the filter is: it changes what - // the server returns, so asking for a different one must re-read rather than - // re-render the previous answer in a new order — which would be the client-side - // sorting this replaced. + /* + * The sort is part of the key for the same reason the filter is: it changes which + * rows this read answers with and in what order, so asking for a different one has + * to re-read rather than re-render the previous answer. + * + * What that costs is worth naming rather than leaving to be discovered. The ordering + * is applied in `localPage`, after `GetSubstrateStatus` has already handed back every + * row — the RPC takes a namespace and nothing else — so each header click re-reads + * the whole inventory to reorder rows the browser was just holding. Removing that + * means moving the sort and the slice out of the transport so a reorder can be a + * re-render, which changes what this operation returns and belongs in its own change + * rather than smuggled into a header fix. + */ return useApiResource( ["substrate.actors", namespace, filter, limit, pageToken, sortField, sortOrder], () => diff --git a/ui/src/components/agent-template-form/AgentTemplateForm.tsx b/ui/src/components/agent-template-form/AgentTemplateForm.tsx index 50a9be249..4239a1811 100644 --- a/ui/src/components/agent-template-form/AgentTemplateForm.tsx +++ b/ui/src/components/agent-template-form/AgentTemplateForm.tsx @@ -218,6 +218,11 @@ export function AgentTemplateForm({ {isCreate ? (
diff --git a/ui/src/components/mcp/mcpServerRequest.test.ts b/ui/src/components/mcp/mcpServerRequest.test.ts index 403e4ea58..c9de6c971 100644 --- a/ui/src/components/mcp/mcpServerRequest.test.ts +++ b/ui/src/components/mcp/mcpServerRequest.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { emptyMcpServerForm, + suggestName, toCreateRequest, validateMcpServerForm, type McpServerFormValues, @@ -72,3 +73,25 @@ describe("validateMcpServerForm — scheme-less URL", () => { expect(issues.find((i) => i.field === "url")).toBeDefined(); }); }); + +describe("suggestName", () => { + it("derives a name from the host or the package", () => { + expect(suggestName(urlForm({ name: "", url: "mcp.example.com/sse" }))).toBe( + "mcp-example-com", + ); + expect( + suggestName({ + ...emptyMcpServerForm(), + kind: "command", + packageName: "@modelcontextprotocol/server-filesystem@1.2.3", + }), + ).toBe("server-filesystem"); + }); + + it("suggests nothing when there is nothing to derive from", () => { + // Otherwise an unrelated edit — switching kind, typing a namespace — writes + // an invented name into a field the reader has not filled in yet. + expect(suggestName(emptyMcpServerForm())).toBe(""); + expect(suggestName({ ...emptyMcpServerForm(), kind: "command" })).toBe(""); + }); +}); diff --git a/ui/src/components/mcp/mcpServerRequest.ts b/ui/src/components/mcp/mcpServerRequest.ts index 092f5e510..a18932a7f 100644 --- a/ui/src/components/mcp/mcpServerRequest.ts +++ b/ui/src/components/mcp/mcpServerRequest.ts @@ -298,14 +298,14 @@ export function toCreateRequest( * A server name guessed from whatever identifies it. * * Saves typing a name that is almost always derivable, while staying a plain - * suggestion the form stops offering once the user types their own. + * suggestion the form stops offering once the user types their own. Empty when + * there is nothing to derive from: a guessed name in a field the reader has not + * reached yet reads as a value they chose, and the placeholder says more. */ export function suggestName(values: McpServerFormValues): string { const source = values.kind === "url" ? hostnameOf(values.url) : lastSegment(values.packageName); - const slug = slugifyResourceName(source); - if (slug) return slug; - return values.kind === "url" ? "remote-server" : "tool-server"; + return slugifyResourceName(source); } function hostnameOf(url: string): string { diff --git a/ui/src/components/model-form/ModelForm.tsx b/ui/src/components/model-form/ModelForm.tsx index 70554ef64..dcc9121d8 100644 --- a/ui/src/components/model-form/ModelForm.tsx +++ b/ui/src/components/model-form/ModelForm.tsx @@ -561,6 +561,9 @@ export function ModelForm({ /> } + /* Required to create and not to edit, which is what the label says + too: an edit that leaves this blank keeps the key already stored. */ + required={!isEdit} validateStatus={ submitted && !isEdit && diff --git a/ui/src/pages/SubstratePage.tsx b/ui/src/pages/SubstratePage.tsx index 5e928c98f..e3df5b67f 100644 --- a/ui/src/pages/SubstratePage.tsx +++ b/ui/src/pages/SubstratePage.tsx @@ -13,7 +13,9 @@ import { Tooltip, Typography, } from "antd"; +import type { TableProps } from "antd"; import type { ColumnsType } from "antd/es/table"; +import type { SortOrder } from "antd/es/table/interface"; import { useTheme } from "@emotion/react"; import { Radio, Search } from "lucide-react"; import { PageFrame } from "@/components/Structure/PageFrame"; @@ -383,67 +385,84 @@ function usePageStack(resetKey: string) { }; } +/** One paged table's order: which column, and which way. */ +type PagedSort = { + field: Field | "default"; + order: SubstrateSortOrder; +}; + /** - * A column header that asks the *server* to sort. + * What antd should draw on a column's header, from the order that was applied. * - * Not antd's own `sorter`, deliberately. That reorders the rows the table was - * given, which for one page out of hundreds of thousands looks like sorting and is - * not — the first row of the sorted cluster is almost certainly not on this page. - * So the header sends the column and the direction, and the rows come back ordered. + * The header is antd's own — the whole cell is the target, and the direction is its + * pair of chevrons — because a page where two tables sort by clicking a header and two + * more by clicking the words inside one is a page a reader has to learn twice. * - * Clicking cycles ascending → descending → back to the default order, so a reader - * can undo a sort without knowing which column was the default one. + * What is not antd's is the sorting: the columns below declare `sorter: true`, the form + * that gives a column that header and leaves the table no comparator to run. That is + * right here, but for the opposite reason to the one this comment used to give. No + * order is sent anywhere — `GetSubstrateStatus` takes a namespace and nothing else — + * so the read hands back the whole inventory and `localPage` orders all of it before + * slicing out a page. The ordering is already over every row, and a comparator would + * re-sort the hundred on screen: one page out of 410,110 reordered is not the cluster + * sorted, and the first row of the sorted set is almost certainly not on it. */ -function SortableHeader({ - title, - field, - sort, - onSort, -}: { - title: string; - field: Field; - sort: { field: Field | "default"; order: SubstrateSortOrder }; - onSort: (field: Field | "default", order: SubstrateSortOrder) => void; -}) { - const theme = useTheme(); - const isActive = sort.field === field; - const arrow = !isActive ? "" : sort.order === "asc" ? " ↑" : " ↓"; +function sortDirectionFor( + sort: PagedSort, + field: Field, +): SortOrder | null { + if (sort.field !== field) return null; + return sort.order === "asc" ? "ascend" : "descend"; +} - return ( - - ); +/** + * A paged table's `onChange`, routed into the order it is read in. + * + * antd cycles a header ascending → descending → unsorted, and the third of those is + * the table's default order rather than no order at all: these rows arrive sorted by + * something whatever happens, and `default` is the grouping they fall back to. + * + * `columnKey` carries the sort field, so a column's key and the field it orders by are + * the same string by construction — see the column definitions. + */ +function pagedSortChange( + apply: (sort: PagedSort) => void, +): NonNullable["onChange"]> { + return (_pagination, _filters, sorter, extra) => { + if (extra.action !== "sort") return; + // An array under antd's multi-sort. These tables are single-sort — the read + // orders by one column — and taking the first entry keeps this correct if that + // ever changes: the order sent is then the column the reader chose last. + const active = Array.isArray(sorter) ? sorter[0] : sorter; + const field = active?.columnKey; + + if (!active?.order || typeof field !== "string") { + apply({ field: "default", order: "asc" }); + return; + } + apply({ + field: field as Field, + order: active.order === "ascend" ? "asc" : "desc", + }); + }; } /** - * What the server actually did, said beside the table. + * Which order the rows on screen are in, said beside the table. * - * The order applied comes back on the response rather than being assumed from the - * control, so a request the server did not honour reads as what it did rather than - * as what was asked for. The age is here for the same reason: these reads are - * memoised for a fraction of a second, and a page that showed cached numbers while - * claiming to poll would be the polling bug this codebase has already shipped once. + * "Across the whole inventory" is the claim worth making, and it is the true one: the + * read fetches every row and orders all of them before this page gets a slice, so the + * order holds over the cluster rather than over the hundred rows in front of the + * reader. It does not say the server sorted them, because nothing asks the server to — + * that sentence stood here over a client-side sort. + * + * The order comes back on the response rather than being assumed from the control, so + * what is claimed is what was applied. The age is here for a related reason: these + * reads are memoised for a fraction of a second, and a page that showed cached numbers + * while claiming to poll would be the polling bug this codebase has already shipped + * once. */ -function ServerOrder({ +function AppliedOrder({ field, order, computedAt, @@ -464,7 +483,7 @@ function ServerOrder({ data-testid={testId} css={{ color: theme.color.textMuted, fontSize: 12 }} > - Sorted by the server: {labels[field] ?? field} + Sorted across the whole inventory: {labels[field] ?? field} {order === "desc" ? ", descending" : ", ascending"} {age ? ` · ${age}` : ""} @@ -616,17 +635,18 @@ export function SubstratePage() { const workerFilter = useDebounced(workerQuery.trim(), FILTER_DEBOUNCE_MS); /* - * The order each paged table is read in, sent to the server rather than applied - * here — see `SortableHeader` for why a local sort would be a lie at this size. + * The order each paged table is read in, applied by the read rather than by the + * table — see `sortDirectionFor` for why sorting the page in hand would be a lie at + * this size, and `useSubstrateActors` for what carrying it in the read key costs. */ - const [actorSort, setActorSort] = useState<{ - field: SubstrateActorSortField; - order: SubstrateSortOrder; - }>({ field: "default", order: "asc" }); - const [workerSort, setWorkerSort] = useState<{ - field: SubstrateWorkerSortField; - order: SubstrateSortOrder; - }>({ field: "default", order: "asc" }); + const [actorSort, setActorSort] = useState>({ + field: "default", + order: "asc", + }); + const [workerSort, setWorkerSort] = useState>({ + field: "default", + order: "asc", + }); // A new order is a new result, so the page stack resets with it — a token from // the previous order names a row's position in an ordering that no longer holds. @@ -822,10 +842,10 @@ export function SubstratePage() { * headers sorts by both. The numbers are a fixed priority rather than click order, * so they are chosen to put the column worth *grouping* by first. * - * The actor and worker tables have no sorters at all any more, and that is the - * honest consequence of paging: a client-side sorter reorders the page it was - * given, which looks like sorting and is not — the first row of the sorted cluster - * is almost certainly not on this page. The server's order is stated instead. + * A comparator here rather than a read, because these two lists arrive whole: the + * summary carries every pool and every template, so sorting them in the browser + * sorts all of them. The paged tables below wear the same header and mean something + * different by it — see `sortDirectionFor`. */ const workerPoolColumns: ColumnsType = useMemo( () => [ @@ -915,51 +935,46 @@ export function SubstratePage() { ); /* - * Every column asks the server to sort, and none of them sorts locally. + * Every column orders the whole inventory, and none of them sorts the page locally. * - * antd's own `sorter` is deliberately absent: it reorders the rows the table was - * handed, and one page out of 410,110 reordered is not the cluster sorted. + * `sorter: true` rather than a comparator: it is the form that gives a column antd's + * own header — the whole cell clickable, the direction in its chevrons, the same as + * the two tables above — while leaving the table nothing to reorder. A click becomes + * the next read, which orders every row before slicing this page out of it; see + * `sortDirectionFor` for why that is not the same as sorting on the server. + * + * Each column's `key` *is* its sort field, which is what lets the change handler send + * `columnKey` straight on — so the type says so, and a key that is not one of them + * fails to compile rather than silently sorting by nothing. That is the shape that + * let `pod` stand where `workerPod` belonged. */ - const actorColumns: ColumnsType = useMemo( + const actorColumns: (ColumnsType[number] & { + key: SubstrateActorSortField; + })[] = useMemo( () => [ { - title: ( - setActorSort({ field, order })} - /> - ), + title: "Actor", key: "actorId", + sorter: true, + sortOrder: sortDirectionFor(actorSort, "actorId"), width: 320, render: (_, actor) => {actor.actorId}, }, { - title: ( - setActorSort({ field, order })} - /> - ), + title: "Status", key: "status", + sorter: true, + sortOrder: sortDirectionFor(actorSort, "status"), // Wide enough for the longest status seen on a real cluster // (`ACTOR_STATE_CRASHED`) without wrapping it to three lines. width: 190, render: (_, actor) => , }, { - title: ( - setActorSort({ field, order })} - /> - ), + title: "Template", key: "template", + sorter: true, + sortOrder: sortDirectionFor(actorSort, "template"), width: 260, render: (_, actor) => actor.actorTemplateName @@ -967,15 +982,10 @@ export function SubstratePage() { : "—", }, { - title: ( - setActorSort({ field, order })} - /> - ), - key: "pod", + title: "Worker pod", + key: "workerPod", + sorter: true, + sortOrder: sortDirectionFor(actorSort, "workerPod"), width: 320, render: (_, actor) => actor.ateomPodName ? ( @@ -991,44 +1001,32 @@ export function SubstratePage() { [actorSort, mono, muted, qualified], ); - const workerColumns: ColumnsType = useMemo( + /** The same, for the workers: antd's header, the order the read applied. */ + const workerColumns: (ColumnsType[number] & { + key: SubstrateWorkerSortField; + })[] = useMemo( () => [ { - title: ( - setWorkerSort({ field, order })} - /> - ), + title: "Pod", key: "pod", + sorter: true, + sortOrder: sortDirectionFor(workerSort, "pod"), width: 360, render: (_, worker) => qualified(worker.workerNamespace, worker.workerPod), }, { - title: ( - setWorkerSort({ field, order })} - /> - ), + title: "Pool", key: "pool", + sorter: true, + sortOrder: sortDirectionFor(workerSort, "pool"), width: 220, render: (_, worker) => worker.workerPool, }, { - title: ( - setWorkerSort({ field, order })} - /> - ), + title: "Actor", key: "actor", + sorter: true, + sortOrder: sortDirectionFor(workerSort, "actor"), width: 360, // "idle" rather than a dash: a worker with no actor on it is available, which // is a state worth reading, where a dash says only that a cell is empty. @@ -1404,6 +1402,11 @@ export function SubstratePage() { columns={actorColumns} dataSource={actorRows} loading={actors.isLoading} + onChange={pagedSortChange( + setActorSort, + )} + /* antd's own pager is off because the pages come from the server by token, + not by number — `PageControls` below turns them. */ pagination={false} virtual scroll={{ y: GROWING_TABLE_HEIGHT, x: 1040 }} @@ -1424,7 +1427,7 @@ export function SubstratePage() { }} /> - ( + setWorkerSort, + )} pagination={false} virtual scroll={{ y: GROWING_TABLE_HEIGHT, x: 940 }} @@ -1507,7 +1513,7 @@ export function SubstratePage() { }} /> - tr.ant-table-row:not(.clickable-table-row):hover - > td { + .ant-table-row:not(.clickable-table-row) + > .ant-table-cell-row-hover { background: inherit; } + /* Written against the cell class for the same reason as the rule above: a + virtual body has no table cell for a tr/td selector to find. */ .ant-table-wrapper .ant-table-tbody - > tr.ant-table-row.clickable-table-row - > td { + .ant-table-row.clickable-table-row + > .ant-table-cell { cursor: pointer; } @@ -78,8 +93,8 @@ export function GlobalStyles() { */ .ant-table-wrapper .ant-table-tbody - > tr.ant-table-row.clickable-table-row:active - > td { + .ant-table-row.clickable-table-row:active + > .ant-table-cell { background: ${theme.color.primary}4D; } @@ -91,8 +106,10 @@ export function GlobalStyles() { * action, and the smaller one won on the mouse. It keeps its shape and its * plus/minus, and takes the row's states. */ + /* Class-based for the reason the hover rules above are: a virtual body has no + tr for this to match, so keying off the row class covers both bodies. */ .ant-table-wrapper - tr.clickable-table-row + .ant-table-row.clickable-table-row .ant-table-row-expand-icon { cursor: pointer; transition: none; diff --git a/ui/src/theme/theme.ts b/ui/src/theme/theme.ts index 657c2c761..8e0118652 100644 --- a/ui/src/theme/theme.ts +++ b/ui/src/theme/theme.ts @@ -266,6 +266,32 @@ export function antdThemeFor(mode: ThemeMode): ThemeConfig { colorBorder: color.borderStrong, colorTextPlaceholder: color.textMuted, }, + /* + * The segmented control, which on the dark theme had no visible edge anywhere. + * + * Measured rather than looked at: the track sat at 1.00:1 against the page — the + * same colour — and the selected pill at 1.12:1 against the track, behind an + * elevation shadow antd paints at one percent white. So nothing said "toggle" and + * nothing but a slightly brighter label said which half was chosen. The light + * theme escapes it because its pill is white with a dark shadow under it, so this + * leaves that mode alone. + * + * Fixed with an edge rather than a fill, the way the inputs above are. A fill + * cannot carry it at this end of the range: the page is near-black, so reaching + * 3:1 on luminance alone takes a mid-grey pill that reads as disabled. A + * one-pixel `borderStrong` ring measures 3.9:1 against the page and is the same + * boundary every other control in the app is outlined with, which leaves the two + * fills with only the pill's shape to carry. + */ + ...(mode === "dark" + ? { + Segmented: { + trackBg: color.bgElevated, + itemSelectedBg: color.border, + boxShadowTertiary: `0 0 0 1px ${color.borderStrong}`, + }, + } + : {}), Radio: { /* * The selected option's label and border are the brand colour used as ink, and