diff --git a/SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts b/SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts index bde39fd5..86acc30d 100644 --- a/SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "@playwright/test"; +import { pickOption } from "./helpers"; /** A seeded partner, used for the attachment this test makes and then removes. */ const PARTNER = "Acme Retail"; @@ -31,8 +32,7 @@ test("API gateway: create, attach partner, create subscription detour, edit atta await page.getByRole("button", { name: "Attach partner" }).click(); await expect(page).toHaveURL(/\/api-gateways\/\d+\/attach$/); - await page.getByRole("combobox", { name: "Partner" }).click(); - await page.getByRole("option", { name: new RegExp(PARTNER) }).click(); + await pickOption(page, "Partner", new RegExp(PARTNER)); await expect(page.getByRole("combobox", { name: "Partner" })).toHaveValue(PARTNER); const subscriptionName = `Playwright GW Subscription ${Date.now()}`; @@ -40,11 +40,9 @@ test("API gateway: create, attach partner, create subscription detour, edit atta // The picked partner rides along as a query param through the detour. await expect(page).toHaveURL(/\/api-gateways\/\d+\/attach\/new-subscription/); await page.fill("#ngi-name", subscriptionName); - await page.getByRole("combobox", { name: "Information type" }).click(); - await page.getByRole("option", { name: /Shipment order/ }).click(); + await pickOption(page, "Information type", /Shipment order/); await expect(page.getByRole("combobox", { name: "Information type" })).toHaveValue(/Shipment order/); - await page.getByRole("combobox", { name: "handler adapter" }).click(); - await page.getByRole("option", { name: "NativeHttpHandler" }).click(); + await pickOption(page, "handler adapter", "NativeHttpHandler"); await page.locator("#prop-Url").fill("https://example.com/sink"); await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/sink"); await page.getByRole("button", { name: "Create subscription" }).click(); @@ -93,8 +91,7 @@ test("Bus gateway: create, add route with match expression, edit route, remove, await page.goto("bus-gateways/new"); await page.fill("#nbg-name", name); // Bus-enabled types only, and this one is the one no seeded gateway already listens for. - await page.getByRole("combobox", { name: "Information type" }).click(); - await page.getByRole("option", { name: /Delivery proof/ }).click(); + await pickOption(page, "Information type", /Delivery proof/); await expect(page.getByRole("combobox", { name: "Information type" })).toHaveValue(/Delivery proof/); await page.getByRole("button", { name: "Create gateway" }).click(); await expect(page).toHaveURL(/\/bus-gateways\/\d+$/); @@ -115,8 +112,7 @@ test("Bus gateway: create, add route with match expression, edit route, remove, // Its delivery is a node on the same canvas. await page.getByRole("button", { name: /^Delivery/ }).click(); - await page.getByRole("combobox", { name: "handler adapter" }).click(); - await page.getByRole("option", { name: "NativeHttpHandler" }).click(); + await pickOption(page, "handler adapter", "NativeHttpHandler"); await page.locator("#prop-Url").fill("https://example.com/sink"); await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/sink"); diff --git a/SW.Bitween.Web/ClientApp/e2e/helpers.ts b/SW.Bitween.Web/ClientApp/e2e/helpers.ts index cc9bccfa..82af48a9 100644 --- a/SW.Bitween.Web/ClientApp/e2e/helpers.ts +++ b/SW.Bitween.Web/ClientApp/e2e/helpers.ts @@ -1,4 +1,4 @@ -import type { Page } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; /** Checkbox labels carry their description in the accessible name, so anchor at the start. */ export const startsWith = (text: string) => @@ -11,6 +11,41 @@ export const ADMIN_PASSWORD = "Mtm@dmin!2"; export const FIRST_PASSWORD = "Pl4ywright!1"; export const ROTATED_PASSWORD = "R0tated!Pass2"; +/** + * Choose an option from a SearchSelect, and leave the page interactive. + * + * SearchSelect is a Headless UI `Combobox` with `immediate`, so it opens its + * listbox whenever the input takes focus — and Headless UI restores focus to + * that input a few milliseconds after the list unmounts. So every close + * schedules a reopen, and a test that moves on inside that window gets its + * focus stolen and then wedged: an open listbox marks the rest of the page + * `inert`, and an inert page can never be clicked to dismiss the listbox. The + * failure reads as "
intercepts pointer events" and never recovers. + * + * A person never sees this — their next action is far slower than the restore, + * which is why the app behaves correctly by hand. This is the test catching up + * with the UI, so it dismisses the list until it stays shut rather than + * assuming one Escape is enough. + */ +export async function pickOption(page: Page, combobox: string | RegExp, option: string | RegExp) { + await page.getByRole("combobox", { name: combobox }).click(); + await page.getByRole("option", { name: option }).click(); + + const listbox = page.locator('[role="listbox"]'); + const settled = async () => { + await page.waitForTimeout(SETTLE_MS); + return (await listbox.count()) === 0; + }; + for (let attempt = 0; attempt < 10; attempt++) { + if (await listbox.count()) await page.keyboard.press("Escape"); + if ((await settled()) && (await settled())) return; + } + throw new Error(`the ${combobox} listbox would not stay closed`); +} + +/** Long enough for Headless UI's deferred focus restore to have fired. */ +const SETTLE_MS = 120; + export async function signIn(page: Page, email: string, password: string) { await page.goto("login"); await page.fill("#login-email", email); diff --git a/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts b/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts index 443e15b0..afac38a8 100644 --- a/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "@playwright/test"; +import { pickOption } from "./helpers"; const ADMIN_EMAIL = "admin@Bitween.systems"; const ADMIN_PASSWORD = "Mtm@dmin!2"; @@ -19,28 +20,23 @@ test("scheduled job create, adapters, pause/resume, receive now, list, delete", // The information type is a searchable picker, and the stages below it are cards that open // in place — there is no wizard to "Continue" through any more. - await page.getByRole("combobox", { name: "Information type" }).click(); - await page.getByRole("option", { name: /Shipment order/ }).click(); - // Wait for the pick to land: without this the next click can run first and the form is still - // missing its information type, leaving "Create job" disabled. + await pickOption(page, "Information type", /Shipment order/); await expect(page.getByRole("combobox", { name: "Information type" })).toHaveValue(/Shipment order/); // Source — open by default. Receiver adapter plus its one required prop. - await page.getByRole("combobox", { name: "receiver adapter" }).click(); - await page.getByRole("option", { name: "NativeHttpReceiver" }).click(); + await pickOption(page, "receiver adapter", "NativeHttpReceiver"); await expect(page.getByRole("combobox", { name: "receiver adapter" })).toHaveValue("NativeHttpReceiver"); await page.locator("#prop-Url").fill("https://example.com/feed"); await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/feed"); - // Collapse Source before opening Delivery: its property form renders asynchronously and the - // cards below it keep moving while it does, so clicking straight through hits a moving target. + // Collapse Source before opening Delivery: only one stage is open at a time, which is what + // keeps #prop-Url unambiguous below. await page.getByRole("button", { name: "Close this step" }).click(); // Delivery — handler adapter and its required prop (transformation stays "Passes through"). // Only one stage is open at a time, so #prop-Url is unambiguous here. await page.getByRole("button", { name: /^Delivery/ }).click(); - await page.getByRole("combobox", { name: "handler adapter" }).click(); - await page.getByRole("option", { name: "NativeHttpHandler" }).click(); + await pickOption(page, "handler adapter", "NativeHttpHandler"); await expect(page.getByRole("combobox", { name: "handler adapter" })).toHaveValue("NativeHttpHandler"); await page.locator("#prop-Url").fill("https://example.com/sink"); await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/sink"); @@ -87,8 +83,10 @@ test("scheduled job create, adapters, pause/resume, receive now, list, delete", await expect(row).toBeVisible({ timeout: 15000 }); await expect(row.getByText("undefined")).toHaveCount(0); - // The whole row is the link on this table — there is no separate open button. - await row.click(); + // The name, not the row's centre. The whole row opens the subscription, but it also + // carries links of its own — information type, partner — and which one sits under the + // centre depends on how wide the columns happen to be. + await row.getByText(name).click(); await expect(page).toHaveURL(/\/subscriptions\/\d+$/); await page.getByRole("button", { name: "Delete" }).click(); await page.getByRole("button", { name: "Delete subscription" }).click(); diff --git a/SW.Bitween.Web/ClientApp/e2e/table-layout.spec.ts b/SW.Bitween.Web/ClientApp/e2e/table-layout.spec.ts new file mode 100644 index 00000000..872d5e33 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/e2e/table-layout.spec.ts @@ -0,0 +1,222 @@ +import { test, expect, type Page } from "@playwright/test"; +import { signInAsAdmin } from "./helpers"; + +/** + * The layout contracts the tables have to keep, whatever is in them. + * + * These are all data-shape problems: a customer with 60-character subscription + * names, ten promoted properties, a 4KB minified payload and 45 subscriptions on + * one information type. None of that exists in a dev database, so every test + * here rewrites the API response on the way past rather than seeding rows — + * nothing is written, and the assertions don't drift with whatever the local + * data happens to be. + */ + +const LONG_NAMES = [ + "Customer Aggregation Trace Out Manifest - Sodexi Cassini EDI", + "Agent Tracing - Colissimo EDI Daily Reconciliation", + // No spaces anywhere: there is nothing for the browser to break on when it + // works out the column's intrinsic minimum, which is what `wrap-anywhere` + // exists to handle. With `break-words` this one alone widened Aggregations by + // nearly 900px. + "Customer_Aggregation_Scan_Out_CUSTOMS_Chronopost_Returns", +]; + +/** Replaces every `name` the API returns with a production-length one. */ +async function withLongNames(page: Page) { + let n = 0; + await page.route("**/api/**", async (route) => { + const res = await route.fetch(); + if (!(res.headers()["content-type"] ?? "").includes("json")) return route.fulfill({ response: res }); + let body: unknown; + try { + body = await res.json(); + } catch { + return route.fulfill({ response: res }); + } + const walk = (v: unknown): void => { + if (Array.isArray(v)) return v.forEach(walk); + if (v && typeof v === "object") + for (const k of Object.keys(v as Record)) { + const o = v as Record; + if (k === "name" && typeof o[k] === "string" && o[k]) o[k] = LONG_NAMES[n++ % LONG_NAMES.length]; + else walk(o[k]); + } + }; + walk(body); + await route.fulfill({ response: res, json: body }); + }); +} + +/** Every table on the page that is wider than the box it scrolls inside. */ +const overflowing = (page: Page) => + page.evaluate(() => + [...document.querySelectorAll("table")] + .map((t) => { + const box = t.closest("div[class*=overflow-x-auto]") ?? t.parentElement!; + return { head: [...t.querySelectorAll("th")].map((h) => h.textContent!.trim()).join("/"), + over: t.scrollWidth - box.clientWidth }; + }) + .filter((r) => r.over > 1), + ); + +/** Leaf elements whose text is cut off — an ellipsis the reader can't get past. */ +const clipped = (page: Page) => + page.evaluate(() => + [...document.querySelectorAll("td")] + .flatMap((td) => [td, ...td.querySelectorAll("*")]) + .filter((e) => e.children.length === 0 && e.scrollWidth > e.clientWidth + 1) + .map((e) => e.textContent!.trim().slice(0, 40)), + ); + +test.beforeEach(({ page }) => signInAsAdmin(page)); + +const LIST_PAGES = [ + "subscriptions", "aggregations", "scheduled-jobs", "bus-gateways", "api-gateways", + "partners", "information-types", "work-groups", "retry-policies", "exchanges", "queue-health", +]; + +test("no list table is wider than the card it sits in", async ({ page }) => { + // The densest pages carry twelve columns; the padding and the wrap floors are + // tuned so they still land inside a 1440px window with nothing off the right. + await page.setViewportSize({ width: 1440, height: 900 }); + for (const slug of LIST_PAGES) { + await page.goto(slug); + await page.waitForTimeout(1200); + expect(await overflowing(page), `${slug} has a table wider than its card`).toEqual([]); + } +}); + +test("long names wrap rather than collapsing into a row of ellipses", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await withLongNames(page); + + for (const slug of ["subscriptions", "aggregations", "scheduled-jobs"]) { + await page.goto(slug); + await page.waitForTimeout(1500); + // Exceptions and joined key lists are allowed to clip — they carry a title. + // A name never is. + const cut = (await clipped(page)).filter((t) => LONG_NAMES.some((n) => n.startsWith(t.replace(/…$/, "")))); + expect(cut, `${slug} clipped a name`).toEqual([]); + expect(await overflowing(page), `${slug} widened past its card`).toEqual([]); + } +}); + +test("promoted properties open in a panel, not just a tooltip", async ({ page, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + + await page.route("**/xchanges?**", async (route) => { + const res = await route.fetch(); + let body: any; + try { body = await res.json(); } catch { return route.fulfill({ response: res }); } + // Ten properties, one value too long for a chip, and a null — the value + // shape that used to take the page down on paging. + for (const row of body.result ?? []) + row.promotedProperties = { + "Trace Code": "SHOR020", "Agent Code": null, "First Time": "True", + CreatedBy: "madebydaily.shopify.com", "Order Ref": "SO-2026-0088341-RETURN-LINE-2", + Weight: "2.4kg", Destination: "FR-75011", Service: "EXPRESS", Attempt: "3", Manifest: "M-88214", + }; + await route.fulfill({ response: res, json: body }); + }); + + await page.goto("exchanges"); + const trigger = page.getByRole("button", { name: "Show all 10 promoted properties" }).first(); + await trigger.click(); + + // Every property, in full — including the one too long to have fitted a chip. + await expect(page.getByText("10 promoted properties")).toBeVisible(); + await expect(page.getByText("SO-2026-0088341-RETURN-LINE-2")).toBeVisible(); + + // Opening the panel is not a request to expand the row underneath it. + await expect(page.getByText("EXCHANGE ID")).toHaveCount(0); + + await page.getByRole("button", { name: "Copy all" }).click(); + const copied = await page.evaluate(() => navigator.clipboard.readText()); + expect(copied).toContain("Order Ref=SO-2026-0088341-RETURN-LINE-2"); + expect(copied.split("\n")).toHaveLength(10); +}); + +test("paging the exchanges list survives a null promoted value", async ({ page }) => { + await page.route("**/xchanges?**", async (route) => { + const res = await route.fetch(); + let body: any; + try { body = await res.json(); } catch { return route.fulfill({ response: res }); } + // A promoted path that resolved to nothing arrives as null, not "". + for (const row of body.result ?? []) + row.promotedProperties = { "Agent Code": null, "Trace Code": null, "First Time": "True" }; + await route.fulfill({ response: res, json: body }); + }); + + const crashes: string[] = []; + page.on("pageerror", (e) => crashes.push(e.message)); + + await page.goto("exchanges"); + const next = page.getByRole("button", { name: "Next" }).first(); + if (!(await next.isDisabled())) { + await next.click(); + await expect(page.getByText("Unexpected Application Error")).toHaveCount(0); + } + expect(crashes).toEqual([]); +}); + +test("a long payload doesn't stretch the exchanges table", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + const payload = JSON.stringify({ + Shipment: { + Uid: "STF.365673", Number: "3304169024", Account: "VFS-FR-UKEMB-PAR", + Pieces: Array.from({ length: 12 }, (_, i) => ({ Barcode: `STF36567300${i}`, WeightKg: 2.4 + i })), + }, + }); + await page.route("**/bitweendocs**", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ key: "k", data: payload }) })); + + await page.goto("exchanges"); + await page.waitForTimeout(1200); + const before = await page.evaluate(() => document.querySelector("table")!.scrollWidth); + + // The expander, not the row: the row's cells carry links of their own. + await page.locator("tbody tr").first().locator("td").last().click(); + await expect(page.getByRole("button", { name: "Download this document" })).toBeVisible(); + + // Laid out over lines, and the one line of minified JSON never sets the width. + await expect(page.getByText('"Shipment": {')).toBeVisible(); + expect(await overflowing(page)).toEqual([]); + expect(await page.evaluate(() => document.querySelector("table")!.scrollWidth)).toBe(before); + + // Raw shows it exactly as it arrived, and still can't stretch anything. + await page.getByRole("button", { name: "Raw" }).click(); + await expect(page.getByText('{"Shipment":{"Uid":"STF.365673"', { exact: false })).toBeVisible(); + expect(await overflowing(page)).toEqual([]); + expect(await page.evaluate(() => document.querySelector("table")!.scrollWidth)).toBe(before); +}); + +test("a panel list pages and filters once it runs long", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.route("**/subscriptions?filter=DocumentId*", async (route) => { + const res = await route.fetch(); + let body: any; + try { body = await res.json(); } catch { return route.fulfill({ response: res }); } + const one = body.result?.[0]; + if (one) + body.result = Array.from({ length: 45 }, (_, i) => ({ + ...one, id: 900000 + i, name: `${LONG_NAMES[i % LONG_NAMES.length]} ${i}`, + })); + await route.fulfill({ response: res, json: body }); + }); + + await page.goto("information-types"); + await page.locator("tbody tr").first().click(); + await expect(page).toHaveURL(/\/information-types\/\d+$/); + + // Long names in a ~360px panel used to push Type off the right-hand edge. + await expect(page.getByRole("columnheader", { name: "Type" }).first()).toBeVisible(); + expect(await overflowing(page)).toEqual([]); + + await expect(page.getByText("1–10 of 45")).toBeVisible(); + const box = page.getByPlaceholder("Search 45 subscriptions"); + await box.fill(LONG_NAMES[0].slice(0, 20)); + // Filtering to one page takes the pager away but leaves the box that got you there. + await expect(page.getByText(/of 45$/)).toHaveCount(0); + await expect(box).toBeVisible(); +}); diff --git a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts index d8e335a8..6be40cf9 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts @@ -33,7 +33,7 @@ interface RawXchangeRow { inputKey: string | null; outputKey: string | null; responseKey: string | null; - promotedProperties: Record | null; + promotedProperties: Record | null; retryFor: string | null; aggregationXchangeId: string | null; responseBad: boolean | null; @@ -50,7 +50,7 @@ interface RawDelayedRetryRow { documentName: string; exception: string | null; startedOn: string; - promotedProperties: Record | null; + promotedProperties: Record | null; retryPolicyId: number | null; retryPolicyName: string | null; } diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 3337a7fe..258e96fe 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -119,7 +119,7 @@ export interface ExchangeRef { status: ExchangeStatus; on: string; /** What the exchange was, in the information type's own terms. The lead column. */ - promotedProperties?: Record | null; + promotedProperties?: Record | null; /** Documents produced as the exchange moved through the pipeline, for drill-down previews. */ documents?: ExchangeDocument[]; } @@ -619,7 +619,7 @@ export type ReceiveOutcome = "Failed" | "NoNewData" | "Received"; export interface ReceiveAttemptExchange { id: string; status: ExchangeStatus; - promotedProperties: Record | null; + promotedProperties: Record | null; } export interface ReceiveAttemptRow { @@ -820,7 +820,7 @@ export interface ExchangeRow { /** A pending auto-retry, when the retry policy scheduled one. */ scheduledRetryOn: string | null; exception: string | null; - promotedProperties: Record | null; + promotedProperties: Record | null; /** True when the subscription has no mapper — the Mapped stage is skipped. */ mapperSkipped: boolean; files: { @@ -872,7 +872,7 @@ export interface ScheduledRetryRow { /** When the failed exchange originally started. */ startedOn: string; /** What the exchange carries — how a pending retry identifies itself in a list. */ - promotedProperties: Record | null; + promotedProperties: Record | null; /** * The shared retry policy the subscription currently points at. Null when the * policy is defined inline on the subscription instead, so the subscription — not diff --git a/SW.Bitween.Web/ClientApp/src/components/config/PartnerFields.tsx b/SW.Bitween.Web/ClientApp/src/components/config/PartnerFields.tsx index a88fbb31..f5fb73cd 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/PartnerFields.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/PartnerFields.tsx @@ -149,8 +149,8 @@ export function PartnerFields({ columns={[ { header: "Key", - truncate: true, - cell: (c) => {c.name}, + wrap: true, + cell: (c) => {c.name}, }, { header: "Prefix", diff --git a/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx b/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx index ed4ef5d3..89dc25ea 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx @@ -1,5 +1,6 @@ -import { useMemo, type ReactNode } from "react"; +import { Fragment, useMemo, useState, type ReactNode } from "react"; import { Link } from "react-router"; +import { Check, ChevronDown, Copy } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { api, @@ -210,7 +211,7 @@ export function PromotedProps({ max = 3, fallbackId, }: { - properties: Record | null; + properties: Record | null; max?: number; /** * Shown when the information type promotes nothing, or promotes nothing this @@ -220,7 +221,10 @@ export function PromotedProps({ */ fallbackId?: string; }) { - const entries = Object.entries(properties ?? {}); + // The backend hands the promoted bag over as it found it, so a promoted path + // that resolved to nothing arrives as a null value rather than as an empty + // string. Normalise once, here, so nothing downstream has to keep asking. + const entries: [string, string][] = Object.entries(properties ?? {}).map(([k, v]) => [k, v ?? ""]); if (entries.length === 0) return fallbackId ? ( @@ -229,27 +233,98 @@ export function PromotedProps({ ) : ( ); + const shown = entries.slice(0, max); const rest = entries.length - shown.length; + // A value the chip had to cut is every bit as hidden as one that didn't fit in + // the cell at all, so either is reason enough to offer the panel. Cutting by + // characters rather than by CSS is what makes that knowable here. + const cut = shown.some(([, v]) => v.length > VALUE_CHIP_CAP); + return ( - `${k}=${v}`).join("\n")} - > + {shown.map(([k, v]) => ( {k}= - {v} + {v.length > VALUE_CHIP_CAP ? `${v.slice(0, VALUE_CHIP_CAP)}…` : v} ))} - {rest > 0 && +{rest}} + {(rest > 0 || cut) && ( + + {rest > 0 ? `+${rest}` : } + + } + > + + + )} ); } +/** + * How much of one value a chip shows before the panel has to carry it. Long + * enough for a trace code or a hostname, short enough that one runaway value + * can't take the column away from every other row on the page. + */ +const VALUE_CHIP_CAP = 28; + +/** + * Every promoted property, in full. + * + * The row can only afford three chips and a cut value, and these are the fields + * people actually search on — "which of these is Northwind's order" is answered + * here, so it can't be a `title` attribute: a tooltip can't be selected, copied, + * or scrolled, and ten properties don't fit in one. Keys and values line up in + * two columns and long values wrap, because a value cut twice is no better than + * a value cut once. + */ +function PromotedPropsPanel({ entries }: { entries: [string, string][] }) { + const [copied, setCopied] = useState(false); + + const copy = async () => { + await navigator.clipboard.writeText(entries.map(([k, v]) => `${k}=${v}`).join("\n")); + setCopied(true); + setTimeout(() => setCopied(false), 1600); + }; + + return ( + <> +
+

+ {entries.length} promoted {entries.length === 1 ? "property" : "properties"} +

+ +
+
+ {entries.map(([k, v]) => ( + +
{k}
+
{v}
+
+ ))} +
+ + ); +} + /** * Recent exchanges for one partner / information type / subscription. * @@ -260,10 +335,9 @@ export function PromotedProps({ * are missing" is answered by reading order numbers off these rows; it used to * take eight clicks. * - * The id gets no column of its own. These panels are ~360px wide, and a complete - * guid pushes the table past the panel edge — MiniTable scrolls rather than - * truncating, so the last column ends up clipped instead of shortened. Clicking - * the row opens it in Exchanges, where the full id is shown and copyable. + * The id gets no column of its own. These panels are ~360px wide and a complete + * guid would spend most of that on 32 characters nobody reads. Clicking the row + * opens it in Exchanges, where the full id is shown and copyable. * * When an exchange has no promoted properties there is nothing to lead with, so * the short id stands in — a row still needs a handle, and admitting "no @@ -283,6 +357,7 @@ export function ExchangesList({ const columns = [ { header: "What", + wrap: true, cell: (x: ExchangeRef) => { const properties = Object.entries(x.promotedProperties ?? {}); return ( @@ -297,6 +372,7 @@ export function ExchangesList({ > @@ -320,8 +396,9 @@ export function ExchangesList({ : [ { header: "Partner", + truncate: true, cell: (x: ExchangeRef) => ( - + {x.partnerName ?? "—"} ), @@ -357,15 +434,16 @@ export function SetupList({ items }: { items: SubscriptionSetupRef[] }) { s.id} + search={{ text: (s) => s.name, noun: "subscriptions" }} empty="Not used by any subscription." columns={[ { header: "Subscription", - truncate: true, + wrap: true, cell: (s) => ( {s.name} @@ -679,9 +757,7 @@ export function useWiredSubscriptionColumns( }, { header: "Last error", - // A bounded width, not `truncate`: MiniTable ignores that flag, and an - // unbounded stack trace would push everything else out of the panel. - className: "max-w-48 overflow-hidden", + truncate: true, cell: (row) => { const message = rowsById.get(subscriptionIdOf(row))?.lastException; return message ? ( @@ -738,7 +814,7 @@ export function LinkListCell({ to={only.href} onClick={(e) => e.stopPropagation()} title={only.name} - className="block truncate text-[13px] text-ink-700 hover:text-crimson-700 hover:underline" + className="block text-[13px] text-ink-700 hover:text-crimson-700 hover:underline" > {only.name} @@ -816,17 +892,17 @@ export function TrailTable({ entries }: { entries: TrailEntry[] }) { }, { header: "By", - truncate: true, + wrap: true, cell: (e) => e.byUserId ? ( {e.by} ) : ( - {e.by} + {e.by} ), }, { @@ -854,15 +930,16 @@ export function SubscriptionMiniList({ s.id} + search={{ text: (s) => s.name, noun: "subscriptions" }} empty={emptyText} columns={[ { header: "Subscription", - truncate: true, + wrap: true, cell: (s) => ( {s.name} diff --git a/SW.Bitween.Web/ClientApp/src/components/ui/Table.tsx b/SW.Bitween.Web/ClientApp/src/components/ui/Table.tsx index b07e55c9..2cefa427 100644 --- a/SW.Bitween.Web/ClientApp/src/components/ui/Table.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/ui/Table.tsx @@ -1,4 +1,6 @@ -import type { ReactNode } from "react"; +import { useMemo, useState, type ReactNode } from "react"; +import { Search } from "lucide-react"; +import { Button } from "./basics"; export interface Column { /** Header text. Empty string for action/icon columns. */ @@ -18,11 +20,22 @@ export interface Column { /** Right-align the column (counts, times, actions). */ align?: "right"; /** - * This column holds free text that may be long (a name, a path, an - * exception) and should ellipsis rather than widen the table. The cell - * still needs `truncate` on its own child element to show the ellipsis. + * This column holds free text that may be long (a path, an exception, a + * joined list of keys) and should ellipsis rather than widen the table. The + * cell still needs `truncate` on its own child element to show the ellipsis, + * and a `title` carrying the whole string. + * + * Only for text nobody needs in full at a glance. A name is never that — + * use `wrap`. */ truncate?: boolean; + /** + * This column holds a name, and a name has to be readable in full: a list of + * “Customer Aggregation Trace Out - AL…” rows tells nobody which row is + * theirs. The column takes the table's slack the way `truncate` does, but + * wraps onto as many lines as the name needs instead of clipping it. + */ + wrap?: boolean; } /** @@ -32,11 +45,24 @@ export interface Column { * minimum and the row turns into a line of ellipses. So every *other* column * gets `w-px whitespace-nowrap` — the standard shrink-to-content trick — which * leaves all the slack for the truncating ones to share. + * + * `wrap` columns take that same slack but carry no `max-w-0`, so the table + * hands them width in proportion to how long their text really is and they + * wrap inside it. `wrap-anywhere` rather than `break-words`: only the former + * feeds mid-token breaks into intrinsic sizing, and a name with no spaces in it + * would otherwise set the column's min-content and widen the table anyway. Both kinds also carry a floor, because the slack being + * shared is what collapsed three columns into a grid of ellipses: below the + * floor the card scrolls sideways instead of shaving every column at once. */ -const widthClass = (c: Column) => (c.truncate ? "max-w-0" : "w-px whitespace-nowrap"); +const widthClass = (c: Column, compact = false) => + c.wrap + ? `${compact ? "min-w-20" : "min-w-26"} wrap-anywhere` + : c.truncate + ? `max-w-0 ${compact ? "min-w-16" : "min-w-20"}` + : "w-px whitespace-nowrap"; const cellClass = (c: Column) => - `px-3 py-1.5 ${widthClass(c)} ${c.align === "right" ? "text-right" : ""} ${c.className ?? ""}`; + `px-2 py-1.5 ${widthClass(c)} ${c.align === "right" ? "text-right" : ""} ${c.className ?? ""}`; /** * The page-level table: a bordered card holding every row of a list page. @@ -106,6 +132,13 @@ export function Table({ * lists ("Acme Logistics runs Acme invoice submission"): a sentence per row * reads fine at three rows and is useless at thirty, and it wasted the whole * right half of the row on nothing. + * + * It fits its panel rather than growing past it. It used to do the opposite — + * grow and scroll sideways — on the reasoning that squeezing a column in a + * ~360px panel gives a row of "3c8…". `wrap` retired that reasoning: a narrow + * column can now be read in full over two lines, and growing was costing more + * than it saved, because what it pushed out of sight was the Status and When + * columns on the right, which nobody thinks to scroll a panel sideways to find. */ export function MiniTable({ columns, @@ -113,7 +146,8 @@ export function MiniTable({ rowKey, empty, onRowClick, - fitWidth = false, + pageSize = 10, + search, }: { columns: Column[]; rows: T[]; @@ -123,54 +157,119 @@ export function MiniTable({ /** Makes the whole row the way in, as on the page-level table. */ onRowClick?: (row: T) => void; /** - * Honour `truncate` and stay inside the panel instead of growing past it. + * Rows per page. The pager appears only once there are more than this many, + * so the panels the server caps at eight rows never grow one. + */ + pageSize?: number; + /** + * Makes the list filterable, on the same rule as the pager: a list worth + * searching is a list too long to read. `noun` names what is in it, because + * "Search 45 rows" tells nobody what they are about to search. * - * Off by default because most callers are 2–4 column lists in the ~360px - * sidebar, where collapsing a column to ellipsis its text gives a row of - * "3c8…" — there, growing and scrolling sideways is the better trade. A - * wide table in the main column is the opposite case: it has the room, and - * what it pushes out of reach is the action buttons on the right, which - * nobody thinks to scroll a table sideways to find. + * Only for lists the server hands over whole — filtering one that is already + * just the latest eight reads as a search over everything, and isn't. */ - fitWidth?: boolean; + search?: { text: (row: T) => string; noun: string }; }) { + const [query, setQuery] = useState(""); + const [page, setPage] = useState(0); + + const matches = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!search || q === "") return rows; + return rows.filter((r) => search.text(r).toLowerCase().includes(q)); + }, [rows, query, search]); + if (rows.length === 0) return

{empty}

; + // The box stays put once the list is long enough to have earned it, so + // filtering down to three rows doesn't take away the thing that got you + // there. The pager is the opposite: it goes when there is one page left. + const searchable = search !== undefined && rows.length > pageSize; + const paged = matches.length > pageSize; + // A filter can shorten the list past the page you were on, which would leave + // the panel empty with rows still in it. + const start = Math.min(page, Math.max(0, Math.ceil(matches.length / pageSize) - 1)) * pageSize; + const shown = paged ? matches.slice(start, start + pageSize) : matches; + const cell = (c: Column) => - `px-1 ${fitWidth ? widthClass(c) : "whitespace-nowrap"} ${c.align === "right" ? "text-right" : ""} ${c.className ?? ""}`; + `px-1 ${widthClass(c, true)} ${c.align === "right" ? "text-right" : ""} ${c.className ?? ""}`; return ( -
- - - - {columns.map((c, i) => ( - - ))} - - - - {rows.map((row) => ( - onRowClick(row) : undefined} - className={`border-b border-ink-50 last:border-b-0 ${ - onRowClick ? "cursor-pointer hover:bg-ink-50/60" : "" - }`} - > - {columns.map((c, i) => ( - +
+ {searchable && ( +
+ + { + setQuery(e.target.value); + setPage(0); + }} + placeholder={`Search ${rows.length} ${search.noun}`} + aria-label={`Search ${search.noun}`} + className="h-8 w-full rounded-lg border border-ink-200 bg-white pr-2.5 pl-8 text-[13px] placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" + /> +
+ )} + + {matches.length === 0 ? ( +

No {search?.noun ?? "rows"} match “{query.trim()}”.

+ ) : ( +
+
- - {c.header} - -
- {c.cell(row)} -
+ + + {columns.map((c, i) => ( + + ))} + + + + {shown.map((row) => ( + onRowClick(row) : undefined} + className={`border-b border-ink-50 last:border-b-0 ${ + onRowClick ? "cursor-pointer hover:bg-ink-50/60" : "" + }`} + > + {columns.map((c, i) => ( + + ))} + ))} - - ))} - -
+ + {c.header} + +
+ {c.cell(row)} +
+ + +
+ )} + + {paged && ( +
+ + {start + 1}–{Math.min(start + pageSize, matches.length)} of {matches.length} + + + + + +
+ )} ); } diff --git a/SW.Bitween.Web/ClientApp/src/lib/__tests__/documentPreview.test.ts b/SW.Bitween.Web/ClientApp/src/lib/__tests__/documentPreview.test.ts new file mode 100644 index 00000000..12c644f7 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/lib/__tests__/documentPreview.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { formatDocument } from "../documentPreview"; + +describe("formatDocument", () => { + it("lays a minified object out over lines", () => { + expect(formatDocument('{"Shipment":{"Uid":"STF.365673","Flags":2}}')).toBe( + '{\n "Shipment": {\n "Uid": "STF.365673",\n "Flags": 2\n }\n}', + ); + }); + + it("indents nested elements", () => { + expect(formatDocument("1")).toBe("\n 1\n \n"); + }); + + it("leaves text sitting between elements where it is", () => { + expect(formatDocument("

hello there

")).toBe("

hello there\n

"); + }); + + it("keeps a space between two tags, which is character data and not formatting", () => { + // The reflow used to swallow this, on the assumption that whitespace between + // tags is only ever indentation. In mixed content it is the payload. + expect(formatDocument("1 2")).toBe("\n 1 2\n"); + }); + + /** + * The property that matters more than tidy output: reflowing a payload must + * never lose or move a character of it, however odd the markup is. Malformed + * XML comes back strangely indented and completely intact. + */ + it.each([ + '1', + '', + '', + '1 2', + 'text 1', + ])("adds whitespace but never removes any: %s", (src) => { + // Undoing only the newline-plus-indent this inserts has to give the input + // back character for character. A swallowed space fails here. + expect(formatDocument(src)!.replace(/\n */g, "")).toBe(src); + }); + + // Everything below comes back null, and the caller then shows the payload + // exactly as it arrived. + it("declines a payload that is neither JSON nor markup", () => { + expect(formatDocument("UNB+UNOA:1+SENDER+RECEIVER+260831:1200+1'")).toBeNull(); + }); + + it("declines malformed JSON", () => { + expect(formatDocument('{"Uid":"STF.365673"')).toBeNull(); + }); + + it("declines markup carrying CDATA, which a newline would corrupt", () => { + expect(formatDocument(" ")).toBeNull(); + }); + + it("declines markup carrying a comment", () => { + expect(formatDocument("")).toBeNull(); + }); + + it("declines what is already laid out, so the toggle stays hidden", () => { + expect(formatDocument('{\n "Uid": "STF.365673"\n}')).toBeNull(); + }); + + it("declines an empty payload", () => { + expect(formatDocument(" ")).toBeNull(); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/lib/documentPreview.ts b/SW.Bitween.Web/ClientApp/src/lib/documentPreview.ts new file mode 100644 index 00000000..1f45ceed --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/lib/documentPreview.ts @@ -0,0 +1,67 @@ +/** + * Turning an exchange's payload into something readable. + * + * Integrations send minified JSON and single-line XML, and the drawer used to + * print that verbatim: one 4KB line, which is both unreadable and — because the + * drawer lives in a table cell that sizes to its content — wide enough to + * stretch every other row on the page. + */ + +/** Two spaces per level, matching the mapper's editors. */ +const INDENT = " "; + +/** + * `text` laid out over multiple lines, or `null` when it isn't a format we can + * lay out — in which case the caller shows it as it came. + * + * Never throws and never guesses: anything that doesn't parse cleanly comes + * back as `null` rather than as a mangled approximation of itself, because a + * payload people are reading to diagnose a failure has to be the payload. + */ +export function formatDocument(text: string): string | null { + const trimmed = text.trim(); + if (trimmed === "") return null; + + const first = trimmed[0]; + if (first === "{" || first === "[") return formatJson(trimmed); + if (first === "<") return formatXml(trimmed); + return null; +} + +function formatJson(text: string): string | null { + try { + const formatted = JSON.stringify(JSON.parse(text), null, 2); + // A scalar or an already-formatted document gains nothing from the toggle. + return formatted === undefined || formatted === text ? null : formatted; + } catch { + return null; + } +} + +function formatXml(text: string): string | null { + // A newline landing inside either of these would change the data rather than + // its shape, and a payload someone is reading to diagnose a failure has to + // survive being displayed. Neither is common enough to be worth handling. + if (text.includes("\n<").split("\n"); + if (lines.length === 1) return null; + + let depth = 0; + return lines + .map((line) => { + if (line.startsWith("]*>$/.test(line) && !line.endsWith("/>")) depth += 1; + return indented; + }) + .join("\n"); +} diff --git a/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx index ad062c7e..621347fc 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx @@ -198,15 +198,15 @@ export function AggregationsPage() { { header: "Aggregation", headerTitle: "The roll-up job itself. Open it to configure what it delivers.", - truncate: true, - cell: (r) => {r.name}, + wrap: true, + cell: (r) => {r.name}, }, { // The whole point of the row: an aggregation with no source name is one // whose source was deleted, and it will never produce anything again. header: "Rolls up", headerTitle: "The subscription whose successful exchanges this collects. Fixed when the aggregation was created.", - truncate: true, + wrap: true, cell: (r) => { const name = r.aggregationForId === null ? null : nameById.get(r.aggregationForId); if (r.aggregationForId === null) @@ -215,7 +215,7 @@ export function AggregationsPage() { e.stopPropagation()} - className="block truncate text-[13px] text-ink-700 hover:text-crimson-700 hover:underline" + className="block text-[13px] text-ink-700 hover:text-crimson-700 hover:underline" > {name} @@ -227,9 +227,9 @@ export function AggregationsPage() { { header: "Collects", headerTitle: "Which file of each collected exchange the roll-up links to. Links only \u2014 the files are not combined.", - truncate: true, + wrap: true, cell: (r) => ( - + {AGGREGATION_TARGET_LABEL[r.aggregationTarget]} ), @@ -304,7 +304,7 @@ export function AggregationsPage() { { header: "Partner", headerTitle: "Who the roll-up exchange belongs to. Not the partners of the exchanges it collected \u2014 one roll-up can cover many.", - truncate: true, + wrap: true, cell: (r) => ( { const id = setupById.get(r.id)?.workGroupId ?? null; const name = id === null ? null : (workGroupNames.get(id) ?? null); @@ -328,7 +328,7 @@ export function AggregationsPage() { e.stopPropagation()} - className="block truncate text-[13px] text-ink-700 hover:text-crimson-700 hover:underline" + className="block text-[13px] text-ink-700 hover:text-crimson-700 hover:underline" > {name} @@ -340,7 +340,7 @@ export function AggregationsPage() { { header: "Retry policy", headerTitle: "What happens when the roll-up\u2019s delivery fails. None means a failure is recorded and left alone.", - truncate: true, + wrap: true, cell: (r) => { const id = setupById.get(r.id)?.retryPolicyId ?? null; const name = id === null ? null : (retryPolicyNames.get(id) ?? null); @@ -349,7 +349,7 @@ export function AggregationsPage() { e.stopPropagation()} - className="block truncate text-[13px] text-ink-700 hover:text-crimson-700 hover:underline" + className="block text-[13px] text-ink-700 hover:text-crimson-700 hover:underline" > {name} @@ -362,7 +362,7 @@ export function AggregationsPage() { header: "Status", headerTitle: "Whether it is turned on, holding work, and executing right now. Hover a badge for what it means.", cell: (r) => ( - + diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx index ded2212f..8d17f3c7 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx @@ -118,10 +118,10 @@ export function ApiGatewaysPage() { columns={[ { header: "Gateway", - truncate: true, + wrap: true, cell: (g) => ( - - + + {g.name} {/* Beside the name, not in the Health column: health reports on what the @@ -145,7 +145,7 @@ export function ApiGatewaysPage() { }, { header: "Partners", - truncate: true, + wrap: true, cell: (g) => ( ( - - + + {g.name} {/* Beside the name, not in the Health column: health reports on what the @@ -193,12 +193,12 @@ export function BusGatewaysPage() { // built from (`…busservice.`) — the fact you need when // a publisher says it sent something and nothing arrived. header: "Message type", - truncate: true, + wrap: true, cell: (g: BusGatewayRow) => { const t = infoTypeById.get(g.informationTypeId); return t?.busMessageTypeName ? ( {t.busMessageTypeName} @@ -233,7 +233,7 @@ export function BusGatewaysPage() { : []), { header: "Routes to", - truncate: true, + wrap: true, cell: (g) => ( (bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`); -function CopyButton({ value, label }: { value: string; label: string }) { +function CopyButton({ + value, + label, + className = "rounded-md p-1 text-ink-400 hover:bg-ink-100 hover:text-ink-700", +}: { + value: string; + label: string; + /** Overridden by the document toolbar, which sits on a dark ground. */ + className?: string; +}) { const [copied, setCopied] = useState(false); return ( @@ -48,6 +58,111 @@ function MetaItem({ label, children }: { label: string; children: ReactNode }) { ); } +/** + * How much of a payload is laid out before it becomes a download instead. + * + * The fetch has already happened by this point, so what this avoids is not + * transfer but wrapping a few million text nodes, which is what actually locks + * the tab up. + */ +const MAX_PREVIEW_CHARS = 256 * 1024; + +/** + * The active stage's document. + * + * Worth its own component because getting it wrong took the whole page with it: + * the drawer sits in a `` of an auto-layout table, so the cell sizes + * to its content and one 4KB line of minified JSON set the width of every row on + * the page. `overflow-auto` never stood a chance — there was no width for it to + * overflow. Wrapping is the fix, and laying the payload out is what makes the + * wrapped result worth reading; `wrap-anywhere` rather than `break-words` + * because only the former shrinks the cell's intrinsic width, which is the + * number the table was sizing itself from. + * + * Copy and download always carry the payload exactly as it arrived, never the + * laid-out version — what people paste into a ticket has to be the bytes. + */ +function DocumentPreview({ + name, + size, + content, + loading, + errored, +}: { + name: string; + size: number; + content: string | undefined; + loading: boolean; + errored: boolean; +}) { + const [raw, setRaw] = useState(false); + + const full = content ?? ""; + const clipped = full.length > MAX_PREVIEW_CHARS; + const head = clipped ? full.slice(0, MAX_PREVIEW_CHARS) : full; + const formatted = useMemo(() => (clipped ? null : formatDocument(head)), [clipped, head]); + + const body = loading + ? "Loading…" + : errored + ? "Failed to load this document." + : ((raw ? null : formatted) ?? head); + + // Some records store a size of 0, which reads as a lie sitting next to four + // kilobytes of visible payload. Once the document is here, measure it. + const bytes = size || (full === "" ? 0 : new Blob([full]).size); + + const download = () => { + const url = URL.createObjectURL(new Blob([full], { type: "text/plain" })); + const a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); + URL.revokeObjectURL(url); + }; + + const dark = "rounded-md p-1 text-ink-400 hover:bg-white/10 hover:text-ink-100"; + + return ( +
+
+ + {name} + · {kb(bytes)} + {!loading && !errored && full !== "" && ( +
+ {formatted && ( + + )} + + +
+ )} +
+
+        {body}
+      
+ {clipped && ( +

+ Showing the first 256 KB — download the document to read the rest. +

+ )} +
+ ); +} + const STAGE_ORDER: ExchangeDocStage[] = ["Input", "Mapped", "Handled"]; /** @@ -155,9 +270,13 @@ export function ExchangeDrawer({ x }: { x: ExchangeRow }) { {/* — active stage document — */} {activeKey && ( -
-          {activeLoading ? "Loading…" : activeErrored ? "Failed to load this document." : activeContent}
-        
+ )} {/* — failure — */} diff --git a/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetPage.tsx index ec56524e..c1ebb16b 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetPage.tsx @@ -129,15 +129,16 @@ export function GlobalValueSetPage() { u.subscriptionSetup.id} + search={{ text: (u) => u.subscriptionSetup.name, noun: "subscriptions" }} empty="Not referenced anywhere yet — safe to delete." columns={[ { header: "Subscription", - truncate: true, + wrap: true, cell: (u) => ( {u.subscriptionSetup.name} diff --git a/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetsPage.tsx index b273b5fd..f059641f 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetsPage.tsx @@ -182,7 +182,7 @@ export function GlobalValueSetsPage() { }, { header: "Used by", - truncate: true, + wrap: true, cell: (s) => referencesGlobal(i, s.id))} />, }, ]} diff --git a/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypePage.tsx b/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypePage.tsx index 82934462..b57efc44 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypePage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypePage.tsx @@ -103,15 +103,16 @@ export function InformationTypePage() { g.gatewayId} + search={{ text: (g) => g.gatewayName, noun: "bus gateways" }} empty="" columns={[ { header: "Bus gateway", - truncate: true, + wrap: true, cell: (g) => ( {g.gatewayName} diff --git a/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx index 093c05f4..88c1a73e 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx @@ -234,7 +234,7 @@ export function InformationTypesPage() { }, { header: "Used by", - truncate: true, + wrap: true, cell: (t) => s.informationTypeId === t.id)} />, }, ]} diff --git a/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifierPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifierPage.tsx index 394a2b85..5ee8458c 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifierPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifierPage.tsx @@ -28,11 +28,11 @@ function NotificationsList({ items }: { items: NotificationEntry[] }) { columns={[ { header: "Exchange", - truncate: true, + wrap: true, cell: (n) => ( {n.xchangeId} diff --git a/SW.Bitween.Web/ClientApp/src/pages/partners/PartnerPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/partners/PartnerPage.tsx index 7ef65254..720452ee 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/partners/PartnerPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/partners/PartnerPage.tsx @@ -148,15 +148,16 @@ export function PartnerPage() { g.key} + search={{ text: (g) => g.name, noun: "gateways" }} empty="" columns={[ { header: "Gateway", - truncate: true, + wrap: true, cell: (g) => ( {g.name} diff --git a/SW.Bitween.Web/ClientApp/src/pages/partners/PartnersPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/partners/PartnersPage.tsx index 9ea7710d..93775827 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/partners/PartnersPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/partners/PartnersPage.tsx @@ -177,7 +177,7 @@ export function PartnersPage() { }, { header: "Used by", - truncate: true, + wrap: true, cell: (p) => , }, ]} diff --git a/SW.Bitween.Web/ClientApp/src/pages/queue-health/QueueHealthPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/queue-health/QueueHealthPage.tsx index 467e196a..a519ddb7 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/queue-health/QueueHealthPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/queue-health/QueueHealthPage.tsx @@ -224,23 +224,23 @@ export function QueueHealthPage() { - - - - - - - - - - - + + + + + + + + + + + {LANE_ORDER.filter((lane) => byLane.get(lane)?.length).flatMap((lane) => [ - - - - - - - + + + - - - - - + + +
LaneQueueNodesIn flightQueuedRetryingDeadPrefetchIn/sAck/sHealthLaneQueueNodesIn flightQueuedRetryingDeadPrefetchIn/sAck/sHealth
+ {LANES[lane].label} @@ -251,7 +251,7 @@ export function QueueHealthPage() { // Keyed on the queue name: the consumer name is the C# class, which // repeats across every work-group lane.
+ {linkFor(c) !== null ? ( {roleOf(c)} - {c.queueName} + + {c.queueName} {c.totalNodes}{c.processingCount}{c.queueCount} 0 ? "font-medium text-warn-700" : "text-ink-700"}`}> + {c.totalNodes}{c.processingCount}{c.queueCount} 0 ? "font-medium text-warn-700" : "text-ink-700"}`}> {c.retryCount} 0 ? "font-medium text-danger-700" : "text-ink-700"}`}> + 0 ? "font-medium text-danger-700" : "text-ink-700"}`}> {c.failedCount} {c.prefetch}{c.incomingRate}{c.ackRate} + {c.prefetch}{c.incomingRate}{c.ackRate} {c.isBackpressured && ( @@ -324,10 +324,10 @@ export function QueueHealthPage() { - - - - + + + + @@ -339,13 +339,13 @@ export function QueueHealthPage() { {q.queueName} {q.queues > 1 && +{q.queues - 1}} - - - diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPoliciesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPoliciesPage.tsx index 7d8eb035..54b49398 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPoliciesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPoliciesPage.tsx @@ -158,7 +158,7 @@ export function RetryPoliciesPage() { }, { header: "Used by", - truncate: true, + wrap: true, cell: (p) => s.retryPolicyId === p.id)} />, }, ]} diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx index 34b9940b..5eab93a7 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx @@ -333,7 +333,6 @@ export function RetryPolicyPage() { g.id} - fitWidth onRowClick={canEdit ? (g) => setEditingGroup(g) : undefined} empty="No groups yet — failures under this policy are never retried." columns={[ @@ -344,11 +343,11 @@ export function RetryPolicyPage() { }, { header: "Group", - truncate: true, + wrap: true, cell: (g) => ( {g.name} @@ -365,7 +364,7 @@ export function RetryPolicyPage() { }, { header: "Applies to", - truncate: true, + wrap: true, cell: (g) => { // Scope first and short, conditions second: every row in a policy tends to // share the scope, so leading with "errors matching " spent the column's @@ -380,7 +379,7 @@ export function RetryPolicyPage() { : g.matchers.map((m) => matcherSummary(m)).join(" or "); return ( {scope && {scope} · } diff --git a/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx index 0d2cba8b..8aa336fc 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx @@ -195,8 +195,8 @@ export function ScheduledJobsPage() { columns={[ { header: "Job", - truncate: true, - cell: (r) => {r.name}, + wrap: true, + cell: (r) => {r.name}, }, { header: "Pulls in", @@ -285,7 +285,7 @@ export function ScheduledJobsPage() { }, { header: "Partner", - truncate: true, + wrap: true, cell: (r) => ( { const id = setupById.get(r.id)?.workGroupId ?? null; const name = id === null ? null : (workGroupNames.get(id) ?? null); @@ -310,7 +310,7 @@ export function ScheduledJobsPage() { e.stopPropagation()} - className="block truncate text-[13px] text-ink-700 hover:text-crimson-700 hover:underline" + className="block text-[13px] text-ink-700 hover:text-crimson-700 hover:underline" > {name} @@ -321,7 +321,7 @@ export function ScheduledJobsPage() { }, { header: "Retry policy", - truncate: true, + wrap: true, cell: (r) => { const id = setupById.get(r.id)?.retryPolicyId ?? null; const name = id === null ? null : (retryPolicyNames.get(id) ?? null); @@ -330,7 +330,7 @@ export function ScheduledJobsPage() { e.stopPropagation()} - className="block truncate text-[13px] text-ink-700 hover:text-crimson-700 hover:underline" + className="block text-[13px] text-ink-700 hover:text-crimson-700 hover:underline" > {name} @@ -342,7 +342,7 @@ export function ScheduledJobsPage() { { header: "Status", cell: (r) => ( - + diff --git a/SW.Bitween.Web/ClientApp/src/pages/scheduled-retries/ScheduledRetriesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/scheduled-retries/ScheduledRetriesPage.tsx index 0442de5c..935a0cca 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/scheduled-retries/ScheduledRetriesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/scheduled-retries/ScheduledRetriesPage.tsx @@ -194,24 +194,24 @@ export function ScheduledRetriesPage() { // exception alone can't explain. This is the subscription's policy as it // stands now — editing a policy doesn't reschedule retries it already queued. header: "Scheduled by", - truncate: true, + wrap: true, cell: (r) => r.retryPolicyId !== null ? ( can("retry-policies.view") ? ( {r.retryPolicyName} ) : ( - {r.retryPolicyName} + {r.retryPolicyName} ) ) : r.subscriptionId !== null ? ( policy on {r.subscriptionName ?? "its subscription"} diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionsPage.tsx index 6dc2e125..4c65cbbf 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionsPage.tsx @@ -222,8 +222,8 @@ export function SubscriptionsPage() { columns={[ { header: "Subscription", - truncate: true, - cell: (r) => {r.name}, + wrap: true, + cell: (r) => {r.name}, }, { header: "Type", cell: (r) => }, { @@ -243,7 +243,7 @@ export function SubscriptionsPage() { }, { header: "Partner", - truncate: true, + wrap: true, cell: (r) => ( ( {e.name} @@ -354,11 +354,11 @@ export function Overview({ columns={[ { header: "Notifier", - truncate: true, + wrap: true, cell: (n) => ( {n.name} diff --git a/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupsPage.tsx index 7c31908b..d92c99b5 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupsPage.tsx @@ -164,7 +164,7 @@ export function WorkGroupsPage() { }, { header: "Used by", - truncate: true, + wrap: true, cell: (g) => s.workGroupId === g.id)} />, }, // `enabled: false` on the `live` query only stops it refetching — it doesn't clear a
QueueQueuedRetryingDeadQueueQueuedRetryingDead
0 ? "font-medium text-warn-700" : "text-ink-400"}`}> + 0 ? "font-medium text-warn-700" : "text-ink-400"}`}> {q.messages} 0 ? "font-medium text-warn-700" : "text-ink-400"}`}> + 0 ? "font-medium text-warn-700" : "text-ink-400"}`}> {q.retryMessages} 0 ? "font-medium text-danger-700" : "text-ink-400"}`}> + 0 ? "font-medium text-danger-700" : "text-ink-400"}`}> {q.deadMessages}