From 029b0dfd37670351fe08285e32610917ff0cc3bc Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Mon, 2 Nov 2026 11:51:31 +0300 Subject: [PATCH 1/3] fix: dashboard opens at once and fills in part by part --- .../ClientApp/e2e/dashboard.spec.ts | 2 + SW.Bitween.Web/ClientApp/src/api/client.ts | 13 +- .../ClientApp/src/api/http/dashboard.ts | 60 ++-- SW.Bitween.Web/ClientApp/src/api/queryKeys.ts | 3 + SW.Bitween.Web/ClientApp/src/api/types.ts | 25 +- .../src/pages/dashboard/DashboardPage.tsx | 292 +++++++++++------- .../__tests__/DashboardPage.test.tsx | 52 +++- docs/operations.md | 4 +- 8 files changed, 262 insertions(+), 189 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts b/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts index 823bf477..c9f8731a 100644 --- a/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts @@ -10,6 +10,8 @@ test("dashboard loads with real aggregated data", async ({ page }) => { await expect(page.getByText("Success rate, 7 days")).toBeVisible(); await expect(page.getByText("Traffic, last 48 hours")).toBeVisible(); await expect(page.getByText("Housekeeping")).toBeVisible(); + // The page opens before its numbers arrive; check them once they have. + await expect(page.getByText("loading…")).toHaveCount(0, { timeout: 15000 }); await expect(page.getByText("undefined")).toHaveCount(0); await expect(page.getByText("NaN")).toHaveCount(0); diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index e16c9404..4788cf76 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -38,7 +38,7 @@ import type { DataSourceStatementUsage, DataSourceTelemetry, DataSourceTestResult, - DashboardData, + LatestFailure, RetrySummary, ExchangeBreakdown, ExchangeQuery, @@ -825,9 +825,14 @@ export interface ApiClient { requeueDeadLetters(queueName: string): Promise; // - dashboard - - getDashboard(): Promise; - // The counts over the exchange tables, which grow with traffic: asked for apart, so a slow one - // fills in on its own tile rather than holding up the page. + // One call per part, so a slow one fills in on its own tile rather than holding up the page. + /** The newest failed or badly answered exchanges of the last two weeks, at most six, newest first. */ + getLatestFailures(): Promise; + /** Automatic retries waiting for their slot. */ + getPendingRetries(): Promise; + /** Queue alerts worth acting on. Null when the RabbitMQ management API is unavailable - never counts as zero. */ + getQueueAlerts(): Promise; + // The counts over the exchange tables, which grow with traffic. /** * Failures that are the newest attempt of their chain. Counts problems rather than attempts: a * chain retried nine times is one of these, not nine. Capped like the exchange search, so a value diff --git a/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts b/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts index 11e9d835..2698bab4 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts @@ -1,6 +1,5 @@ import type { ApiClient } from "../client"; -import type { DashboardData, ExchangeStatus, RetrySummary } from "../types"; -import { subscriptionMethods } from "./subscriptions"; +import type { ExchangeStatus, LatestFailure, RetrySummary } from "../types"; import { get } from "./request"; // --- backend shapes (camelCase over the wire) --- @@ -40,59 +39,46 @@ const LATEST = 6; const startedSince = (ms: number) => encodeURIComponent(`StartedOn:6:${new Date(Date.now() - ms).toISOString()}`); +/** + * What the dashboard shows beside the metrics (which come from /metrics/overview) and the + * subscription rows, one method per part so each can fill in on its own. + * + * Traffic, rates and the busiest used to be counted here, in the browser, from up to 1000 + * exchanges fetched every minute: wrong at any volume above that, and a heavy query for every + * open dashboard. They come from the server's hourly metrics now. + */ export const dashboardMethods = { - /** - * What the dashboard shows beside the metrics (which come from /metrics/overview): the latest - * failures, pending retries, queue alerts and subscriptions needing attention. - * - * Traffic, rates and the busiest used to be counted here, in the browser, from up to 1000 - * exchanges fetched every minute: wrong at any volume above that, and a heavy query for every - * open dashboard. They come from the server's hourly metrics now; what is left here is small, - * bounded queries over the last two weeks. The counts over the exchange tables are asked for - * apart, below. - */ - async getDashboard(): Promise { + /** Two searches over the exchanges of the last two weeks: on a busy instance, seconds. */ + async getLatestFailures(): Promise { const twoWeeks = startedSince(14 * 86_400_000); const latest = (status: number) => get>( `/xchanges?filter=${twoWeeks}&filter=${encodeURIComponent(`StatusFilter:1:${status}`)}&sort=StartedOn:2&size=${LATEST}`, ); - const [failed, badResponses, delayedRes, alertsRaw, subscriptionRows] = await Promise.all([ - latest(3), - latest(2), - get>("/delayedretries?size=1"), - // Depends on RabbitMQ management being configured on the backend - don't let it take the - // rest of the dashboard down when it isn't; the "Queue alerts" tile flags it instead. - get("/ops/alerts").catch(() => null), - subscriptionMethods.listSubscriptionRows(), - ]); - - const subscriptionNameById = new Map(subscriptionRows.map((i) => [i.id, i.name])); - const latestFailures = [...failed.result, ...badResponses.result] + const [failed, badResponses] = await Promise.all([latest(3), latest(2)]); + return [...failed.result, ...badResponses.result] .sort((a, b) => b.startedOn.localeCompare(a.startedOn)) .slice(0, LATEST) .map((r) => ({ id: r.id, status: toStatus(r), subscriptionId: r.subscriptionId, - subscriptionName: r.subscriptionId !== null ? (subscriptionNameById.get(r.subscriptionId) ?? null) : null, informationTypeCode: r.documentName, on: r.startedOn, exception: r.exception, })); + }, - return { - pendingRetries: delayedRes.totalCount, - queueAlerts: alertsRaw === null ? null : alertsRaw.filter((a) => a.severity !== "Info").length, - latestFailures, - attention: { - failingSubscriptions: subscriptionRows - .filter((i) => i.consecutiveFailures > 0) - .map((i) => ({ id: i.id, name: i.name, consecutiveFailures: i.consecutiveFailures })), - pausedSubscriptions: subscriptionRows.filter((i) => i.paused).map((i) => ({ id: i.id, name: i.name })), - }, - }; + async getPendingRetries(): Promise { + return (await get>("/delayedretries?size=1")).totalCount; + }, + + async getQueueAlerts(): Promise { + // Depends on RabbitMQ management being configured on the backend; the "Queue alerts" tile + // flags it when it isn't. + const alerts = await get("/ops/alerts").catch(() => null); + return alerts === null ? null : alerts.filter((a) => a.severity !== "Info").length; }, /** diff --git a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts index 8467396f..13bf4b30 100644 --- a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts +++ b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts @@ -202,6 +202,9 @@ export const keys = { background: ["queue-health", "background"] as const, dashboard: ["dashboard"] as const, // Under the dashboard's own key, so whatever refreshes the dashboard refreshes these too. + dashboardLatestFailures: ["dashboard", "latest-failures"] as const, + dashboardPendingRetries: ["dashboard", "pending-retries"] as const, + dashboardQueueAlerts: ["dashboard", "queue-alerts"] as const, dashboardFailuresToActOn: ["dashboard", "failures-to-act-on"] as const, dashboardInFlight: ["dashboard", "in-flight"] as const, dashboardRetrySummary: ["dashboard", "retry-summary"] as const, diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index ca6077b7..c1acc69d 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -1852,23 +1852,14 @@ export interface RetrySummary { retriesLast7Days: { finished: number; succeeded: number }; } -export interface DashboardData { - pendingRetries: number; - /** Null when the RabbitMQ management API is unavailable - never counts as zero. */ - queueAlerts: number | null; - latestFailures: { - id: string; - status: ExchangeStatus; - subscriptionId: number | null; - subscriptionName: string | null; - informationTypeCode: string; - on: string; - exception: string | null; - }[]; - attention: { - failingSubscriptions: { id: number; name: string; consecutiveFailures: number }[]; - pausedSubscriptions: { id: number; name: string }[]; - }; +/** One of the dashboard's latest failures. */ +export interface LatestFailure { + id: string; + status: ExchangeStatus; + subscriptionId: number | null; + informationTypeCode: string; + on: string; + exception: string | null; } /** diff --git a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx index 5c098773..046aba7a 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx @@ -47,15 +47,23 @@ function StatTile({ to, accent, title, + failed, }: { label: string; + /** Undefined until its numbers arrive. */ value: ReactNode; sub?: ReactNode; to: string; accent?: "danger" | "warn"; /** For a number whose meaning its label cannot carry on its own. */ title?: string; + /** Its numbers couldn't be read: says so instead of loading forever. */ + failed?: boolean; }) { + if (value === undefined) { + value = failed ? "-" : ; + sub = failed ? "unavailable right now" : "loading…"; + } return ( + This couldn't be read in time. The dashboard tries again every minute. + + ) : ( + + ); +} + /** Every hour in the window, the empty ones as zero: a gap in the chart is an hour with nothing. */ function hours(overview: MetricsOverview) { const byHour = new Map(overview.hourly.map((h) => [Date.parse(h.hour), h])); @@ -103,12 +122,8 @@ export function DashboardPage() { const rabbitMqConfigured = useRabbitMqManagementConfigured(); const [healthOffset, setHealthOffset] = useState(0); const canSeeAdapters = useSessionCan("subscriptions.view"); - const dashboard = useQuery({ - queryKey: keys.dashboard, - queryFn: () => api.getDashboard(), - refetchInterval: 60_000, - placeholderData: keepPreviousData, - }); + // Each part is asked for on its own and fills in when it arrives: the page opens at once, and a + // slow part keeps only its own tile waiting. const metrics = useQuery({ queryKey: keys.metrics.overview, queryFn: () => api.getMetricsOverview(), @@ -116,9 +131,30 @@ export function DashboardPage() { placeholderData: keepPreviousData, }); const insights = useQuery({ queryKey: keys.metrics.insights, queryFn: () => api.getInsights() }); - // Counted over the exchange tables, so asked for apart: a slow one fills in on its own tile - // rather than holding up the page. Not retried - the next minute's refresh asks again, rather than - // three retries piling onto a database already struggling with it. + const subscriptions = useQuery({ + queryKey: keys.subscriptions.rows, + queryFn: () => api.listSubscriptionRows(), + refetchInterval: 60_000, + }); + const pendingRetries = useQuery({ + queryKey: keys.dashboardPendingRetries, + queryFn: () => api.getPendingRetries(), + refetchInterval: 60_000, + }); + const queueAlerts = useQuery({ + queryKey: keys.dashboardQueueAlerts, + queryFn: () => api.getQueueAlerts(), + refetchInterval: 60_000, + }); + // Read from the exchange tables, which grow with traffic: the parts most likely to be slow. Not + // retried - the next minute's refresh asks again, rather than three retries piling onto a + // database already struggling with it. + const latestFailures = useQuery({ + queryKey: keys.dashboardLatestFailures, + queryFn: () => api.getLatestFailures(), + refetchInterval: 60_000, + retry: false, + }); const failuresToActOn = useQuery({ queryKey: keys.dashboardFailuresToActOn, queryFn: () => api.getFailuresToActOn(), @@ -138,39 +174,41 @@ export function DashboardPage() { retry: false, }); - const data = dashboard.data; + // Each undefined until its numbers arrive. const o = metrics.data; - if ((dashboard.isError && !data) || (metrics.isError && !o)) - return ( - - Something went wrong fetching dashboard data. It'll keep retrying in the background - try refreshing the page. - - ); - if (!data || !o) return ; + const delta = o && o.last24h.total - o.previous24h.total; + const failed24 = o && o.last24h.failed + o.last24h.badResponse; + const perHour = o && hours(o); + const days = o?.daily.map((d) => ({ + at: d.day, + values: { succeeded: d.succeeded, badResponse: d.badResponse, failed: d.failed }, + })); + const moved30 = o?.daily.reduce((n, d) => n + d.bytes, 0); + const since = o?.since && Date.now() - Date.parse(o.since) < 30 * 86_400_000 ? o.since : null; + const waitingForMetrics = ; - const queueAlerts = rabbitMqConfigured ? data.queueAlerts : null; + // Null when it can't be counted, which never shows as zero. + const alerts = rabbitMqConfigured ? queueAlerts.data : null; const queueAlertsSub = !rabbitMqConfigured ? "needs RabbitMQ management configured" - : queueAlerts === null + : alerts === null ? "unavailable right now" : "live consumer health"; - const delta = o.last24h.total - o.previous24h.total; - const failed24 = o.last24h.failed + o.last24h.badResponse; - const perHour = hours(o); - const days = o.daily.map((d) => ({ - at: d.day, - values: { succeeded: d.succeeded, badResponse: d.badResponse, failed: d.failed }, - })); - const moved30 = o.daily.reduce((n, d) => n + d.bytes, 0); - const sinceRecent = o.since && Date.now() - Date.parse(o.since) < 30 * 86_400_000; + const pending = pendingRetries.data; + const toActOn = failuresToActOn.data; - const unhealthy = [ - ...data.attention.failingSubscriptions.map((s) => ({ ...s, paused: false })), - ...data.attention.pausedSubscriptions.map((s) => ({ ...s, consecutiveFailures: 0, paused: true })), + const subscriptionNames = new Map(subscriptions.data?.map((s) => [s.id, s.name])); + const latest = latestFailures.data?.map((f) => ({ + ...f, + subscriptionName: f.subscriptionId === null ? null : (subscriptionNames.get(f.subscriptionId) ?? null), + })); + const unhealthy = subscriptions.data && [ + ...subscriptions.data.filter((s) => s.consecutiveFailures > 0).map((s) => ({ ...s, paused: false })), + ...subscriptions.data.filter((s) => s.paused).map((s) => ({ ...s, consecutiveFailures: 0, paused: true })), ]; // The list can shrink on a refetch; don't leave the page pointing past its end. Reset the stored // offset too, or a later refetch that grows the list again jumps back to the old page. - const healthStart = healthOffset < unhealthy.length ? healthOffset : 0; + const healthStart = unhealthy && healthOffset < unhealthy.length ? healthOffset : 0; if (healthStart !== healthOffset) setHealthOffset(0); return ( @@ -178,116 +216,122 @@ export function DashboardPage() { - d.total > 0) || o.last24h.total > 0} /> + {o && d.total > 0) || o.last24h.total > 0} />}
- {delta >= 0 ? "+" : ""} - {compact(delta)} vs the day before - {inFlight.data !== undefined && inFlight.data > 0 && <> · {compact(inFlight.data)} in flight} - {inFlight.isError && inFlight.data === undefined && <> · in flight unavailable} - + delta !== undefined && ( + <> + {delta >= 0 ? "+" : ""} + {compact(delta)} vs the day before + {inFlight.data !== undefined && inFlight.data > 0 && <> · {compact(inFlight.data)} in flight} + {inFlight.isError && inFlight.data === undefined && <> · in flight unavailable} + + ) } to="/exchanges" + failed={metrics.isError} /> 0 ? `${compact(o.last24h.badResponse)} bad responses` : "nothing failed"} + value={failed24 === undefined ? undefined : compact(failed24)} + sub={o && (failed24 ? `${compact(o.last24h.badResponse)} bad responses` : "nothing failed")} to="/exchanges?status=failed" - accent={failed24 > 0 ? "danger" : undefined} + accent={failed24 ? "danger" : undefined} + failed={metrics.isError} /> 0 ? `of ${compact(o.last7d.total)} exchanges` : "nothing finished yet"} + value={o && percent(o.last7d.successRate)} + sub={o && (o.last7d.total > 0 ? `of ${compact(o.last7d.total)} exchanges` : "nothing finished yet")} to="/exchanges?status=success" + failed={metrics.isError} /> {/* "Failed" counts attempts, and a chain retried nine times is nine of them - none of which is the current state of anything. This counts the ends of chains: the problems. */} 10_000 - ? "10,000+" - : whole(failuresToActOn.data) - } - sub={ - failuresToActOn.data === undefined - ? failuresToActOn.isError - ? "unavailable right now" - : "counting…" - : failuresToActOn.data > 0 - ? "newest attempt of each chain" - : "every failure was retried" - } + value={toActOn === undefined ? undefined : toActOn > 10_000 ? "10,000+" : whole(toActOn)} + sub={toActOn ? "newest attempt of each chain" : "every failure was retried"} to="/exchanges?status=failed&latest=1" - accent={failuresToActOn.data !== undefined && failuresToActOn.data > 0 ? "danger" : undefined} + accent={toActOn ? "danger" : undefined} title="Failures that nothing has been retried from yet. A chain of nine failed attempts counts once - as the attempt at the end of it, which is the one still to deal with." + failed={failuresToActOn.isError} /> 0 ? "waiting for their slot" : "queue is clear"} + value={pending === undefined ? undefined : whole(pending)} + sub={pending ? "waiting for their slot" : "queue is clear"} to="/scheduled-retries" - accent={data.pendingRetries > 0 ? "warn" : undefined} + accent={pending ? "warn" : undefined} + failed={pendingRetries.isError} /> 0 ? "warn" : undefined} + accent={alerts ? "warn" : undefined} /> 0 ? "usually busy, now silent" : "every busy subscription is busy"} + value={o?.silent.length} + sub={o && (o.silent.length > 0 ? "usually busy, now silent" : "every busy subscription is busy")} to="/subscriptions?health=silent" - accent={o.silent.length > 0 ? "warn" : undefined} + accent={o?.silent.length ? "warn" : undefined} title="Subscriptions that normally have traffic and have had none for well over their usual gap. Nothing fails when a partner stops sending, so nothing else would say so." + failed={metrics.isError} />
- p.weekAgo) }} - label={(at) => hourLabel.format(new Date(at))} - tick={(at) => hourTick.format(new Date(at))} - every={8} - /> + {!perHour ? ( + waitingForMetrics + ) : ( + p.weekAgo) }} + label={(at) => hourLabel.format(new Date(at))} + tick={(at) => hourTick.format(new Date(at))} + every={8} + /> + )} - {days.length === 0 ? ( + {!days ? ( + waitingForMetrics + ) : days.length === 0 ? ( Once exchanges flow, each day's volume shows here. ) : ( - {o.silent.length === 0 ? ( + {!o ? ( + waitingForMetrics + ) : o.silent.length === 0 ? ( } title="Nothing has gone quiet"> Every subscription with regular traffic still has it. @@ -331,7 +377,9 @@ export function DashboardPage() { - {unhealthy.length === 0 ? ( + {!unhealthy ? ( + + ) : unhealthy.length === 0 ? ( No failures piling up, nothing paused. ) : ( <> @@ -386,11 +434,13 @@ export function DashboardPage() {
- {data.latestFailures.length === 0 ? ( + {!latest ? ( + + ) : latest.length === 0 ? ( When an exchange fails, it shows up here first. ) : (
    - {data.latestFailures.map((f) => ( + {latest.map((f) => (
  • @@ -429,13 +479,7 @@ export function DashboardPage() { } > {!retrySummary.data ? ( - retrySummary.isError ? ( - - The retry chains couldn't be read in time. The dashboard tries again every minute. - - ) : ( - - ) + ) : retrySummary.data.failingChains.length === 0 ? ( Nothing has been retried more than once and left failing. ) : ( @@ -482,32 +526,42 @@ export function DashboardPage() {
    - ({ - key: s.subscriptionId, - // Counted while it existed; deleted since, so there is nothing to open. - name: s.name ?? `Deleted subscription #${s.subscriptionId}`, - to: s.name ? subscriptionPath(s.subscriptionId) : null, - ok: s.total - s.failed - s.badResponse, - bad: s.failed + s.badResponse, - }))} - empty="Once exchanges flow, the busiest pipelines rank here." - /> + {!o ? ( + waitingForMetrics + ) : ( + ({ + key: s.subscriptionId, + // Counted while it existed; deleted since, so there is nothing to open. + name: s.name ?? `Deleted subscription #${s.subscriptionId}`, + to: s.name ? subscriptionPath(s.subscriptionId) : null, + ok: s.total - s.failed - s.badResponse, + bad: s.failed + s.badResponse, + }))} + empty="Once exchanges flow, the busiest pipelines rank here." + /> + )} - ({ - key: p.partnerId, - name: p.name ?? `Deleted partner #${p.partnerId}`, - to: p.name ? `/partners/${p.partnerId}` : null, - ok: p.total - p.failed, - bad: p.failed, - }))} - empty="No partner has sent or received anything today." - /> + {!o ? ( + waitingForMetrics + ) : ( + ({ + key: p.partnerId, + name: p.name ?? `Deleted partner #${p.partnerId}`, + to: p.name ? `/partners/${p.partnerId}` : null, + ok: p.total - p.failed, + bad: p.failed, + }))} + empty="No partner has sent or received anything today." + /> + )} - {o.slowestAdapters.length === 0 ? ( + {!o ? ( + waitingForMetrics + ) : o.slowestAdapters.length === 0 ? ( Adapters with five or more calls in a day rank here. ) : (
      @@ -534,7 +588,7 @@ export function DashboardPage() {
    - {o.backlog.length > 1 && ( + {o && o.backlog.length > 1 && ( { ...dashboard([subscription(0)]), ], }); - expect(await screen.findByRole("heading", { name: "Subscription health" })).toBeVisible(); + expect(await screen.findByText("All subscriptions healthy")).toBeVisible(); + // The card waits for the metrics, which say whether anything has moved yet. + expect(await screen.findByText(/moved in and out over the month/)).toBeVisible(); expect(screen.queryByRole("heading", { name: "Getting started" })).not.toBeInTheDocument(); }); @@ -151,24 +153,52 @@ describe("the dashboard", () => { expect(await chains.findByText("Unavailable right now")).toBeVisible(); const health = within(screen.getByRole("heading", { name: "Subscription health" }).closest("section")!); - expect(health.getByText("Health page 1", { exact: true })).toBeVisible(); + expect(await health.findByText("Health page 1", { exact: true })).toBeVisible(); }); - it("doesn't wait for a slow count before showing the rest", async () => { + it("keeps the rest of the page when the metrics can't be read", async () => { renderApp("/dashboard", { handlers: [ - http.get(apiPath("/dashboard/failurestoacton"), async () => { - await delay("infinite"); - return HttpResponse.json({ total: 0 }); - }), + http.get(apiPath("/metrics/overview"), () => new HttpResponse(null, { status: 500 })), + ...dashboard([subscription(0, { failures: 2 })]), + ], + }); + + const exchanges = (await screen.findByText("Exchanges, last 24 hours")).closest("a")!; + expect(await within(exchanges).findByText("unavailable right now")).toBeVisible(); + const traffic = within(screen.getByRole("heading", { name: "Traffic, last 48 hours" }).closest("section")!); + expect(traffic.getByText("Unavailable right now")).toBeVisible(); + + const health = within(screen.getByRole("heading", { name: "Subscription health" }).closest("section")!); + expect(await health.findByText("Health page 1", { exact: true })).toBeVisible(); + }); + + it("opens at once, and each part fills in when it arrives", async () => { + const never = async () => { + await delay("infinite"); + return HttpResponse.json({}); + }; + renderApp("/dashboard", { + handlers: [ + http.get(apiPath("/metrics/overview"), never), + http.get(apiPath("/dashboard/failurestoacton"), never), + // The latest failures; the in-flight count answers. + http.get(apiPath("/xchanges"), ({ request }) => + new URL(request.url).searchParams.getAll("filter").includes("StatusFilter:1:0") ? HttpResponse.json(none) : never(), + ), ...dashboard([subscription(0, { failures: 2 })]), ], }); const health = within((await screen.findByRole("heading", { name: "Subscription health" })).closest("section")!); - expect(health.getByText("Health page 1", { exact: true })).toBeVisible(); - const tile = screen.getByText("Failures to act on").closest("a")!; - expect(within(tile).getByText("counting…")).toBeVisible(); + expect(await health.findByText("Health page 1", { exact: true })).toBeVisible(); + const retries = screen.getByText("Pending auto-retries").closest("a")!; + expect(await within(retries).findByText("queue is clear")).toBeVisible(); + + for (const label of ["Failures to act on", "Exchanges, last 24 hours", "Gone quiet"]) + expect(within(screen.getAllByText(label)[0].closest("a")!).getByText("loading…")).toBeVisible(); + const latest = within(screen.getByRole("heading", { name: "Latest failures" }).closest("section")!); + expect(latest.getByText("Finding the latest failures…")).toBeVisible(); }); it("reads its numbers from the metrics, and names subscriptions that have gone quiet", async () => { @@ -208,7 +238,7 @@ describe("the dashboard", () => { }); const exchanges = (await screen.findByText("Exchanges, last 24 hours")).closest("a")!; - expect(within(exchanges).getByText("12K")).toBeVisible(); + expect(await within(exchanges).findByText("12K")).toBeVisible(); expect(within(exchanges).getByText(/\+1.4K vs the day before/)).toBeVisible(); expect(within(screen.getByText("Success rate, 7 days").closest("a")!).getByText("98.5%")).toBeVisible(); expect(within(screen.getByText("End-to-end time, last 24 hours").closest("a")!).getByText("2.4s")).toBeVisible(); diff --git a/docs/operations.md b/docs/operations.md index 26cb7032..fa19bee4 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -71,7 +71,9 @@ See [Scheduling](scheduling.md). The dashboard, opened from the logo, shows today's exchanges, the 7-day success rate, today's failures, waiting retries, queue alerts, a 14-day chart, the latest failures, unhealthy subscriptions and the busiest subscriptions. The browser computes it from up to 1,000 recent exchanges, so figures on a busy instance are approximate. -Two parts come from the server instead. **Failures to act on** counts failed exchanges that are the newest attempt of their chain, of any age, and links to that search. **Chains that keep failing** lists the five retry chains with the most attempts among those retried in the last 7 days, with the success rate of retries over the same 7 days. A part that can't be read in time shows as unavailable while the rest of the dashboard loads. +Two parts come from the server instead. **Failures to act on** counts failed exchanges that are the newest attempt of their chain, of any age, and links to that search. **Chains that keep failing** lists the five retry chains with the most attempts among those retried in the last 7 days, with the success rate of retries over the same 7 days. + +The page opens at once, and each tile and panel fills in when its own numbers arrive, so a slow one doesn't hold up the rest. One that can't be read in time shows as unavailable, and the dashboard asks again every minute. ## Audit trail From 2589031b897cf9742895910e0735fbad8c92795a Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Mon, 2 Nov 2026 11:52:00 +0300 Subject: [PATCH 2/3] docs: bring the dashboard description up to date --- docs/caveats.md | 1 - docs/operations.md | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/caveats.md b/docs/caveats.md index 1ed5c02f..de7d8c2d 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -26,7 +26,6 @@ These behaviours were found while documenting Bitween from its source code, and | Mappers other than the rules-based mapper overwrite payload keys named `__partner__` or `__globals__`. | `XchangeService.RunMapper` | | An API gateway attached to an inactive subscription returns 404. | `Controllers/GatewayController.cs` | | Bulk retry includes exchanges that have no result yet, so work still in flight can run twice. Deliberate: after a broker outage those are the exchanges a bulk retry is for. | `Resources/Xchanges/BulkRetryPlanner.cs` | -| The dashboard's *Failures to act on* tile says it covers 14 days, but counts failures of any age. | `ClientApp/src/pages/dashboard/DashboardPage.tsx` | | An exchange's delivered time is never recorded. | `Domain/XchangeDelivery.cs` | | Exchanges, results and notifications are kept for ever by default (`ExchangeRetentionDays` 0), while their files expire from storage. The retention panel on the Settings page warns when exchanges outlive their files. | `Services/Retention/ExchangeRetentionJob.cs` | | Cached configuration can stay stale on other instances for up to 10 minutes if the revoke broadcast fails. Adapter descriptions and catalog entries are cached per node; publishing, promoting or withdrawing through Bitween clears them everywhere over the bus, but a package published straight to storage is seen with its old properties for up to a minute, or five for one without a manifest, which is described by starting it. That answer is kept until the adapter's package changes, so it is started once a build rather than every five minutes. | `Services/Caching/InMemoryInfolinkCache.cs`, `Services/ServerlessAdapterDescriber.cs` | diff --git a/docs/operations.md b/docs/operations.md index fa19bee4..e87dd0b4 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -69,9 +69,9 @@ See [Scheduling](scheduling.md). ## Dashboard -The dashboard, opened from the logo, shows today's exchanges, the 7-day success rate, today's failures, waiting retries, queue alerts, a 14-day chart, the latest failures, unhealthy subscriptions and the busiest subscriptions. The browser computes it from up to 1,000 recent exchanges, so figures on a busy instance are approximate. +The dashboard, opened from the logo, shows the last 24 hours of exchanges, failures and end-to-end time, the 7-day success rate, waiting retries, queue alerts and subscriptions that have gone quiet. Below come traffic for the last 48 hours against the same hours a week before, volume for the last 30 days, unhealthy subscriptions, the latest failures, the busiest subscriptions and partners, the slowest adapters, and configuration worth tidying up. The traffic figures come from hourly metrics the server keeps, so they count every exchange however busy the instance is. -Two parts come from the server instead. **Failures to act on** counts failed exchanges that are the newest attempt of their chain, of any age, and links to that search. **Chains that keep failing** lists the five retry chains with the most attempts among those retried in the last 7 days, with the success rate of retries over the same 7 days. +Two parts are read from the exchanges themselves. **Failures to act on** counts failed exchanges that are the newest attempt of their chain, of any age, and links to that search. **Chains that keep failing** lists the five retry chains with the most attempts among those retried in the last 7 days, with the success rate of retries over the same 7 days. The page opens at once, and each tile and panel fills in when its own numbers arrive, so a slow one doesn't hold up the rest. One that can't be read in time shows as unavailable, and the dashboard asks again every minute. From 925d0aa935b35348c98ed77ccd4f090ea777f8e1 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Mon, 2 Nov 2026 12:14:22 +0300 Subject: [PATCH 3/3] fix: dashboard review findings --- .../ClientApp/e2e/dashboard.spec.ts | 6 +++ .../src/pages/dashboard/DashboardPage.tsx | 8 +++- .../__tests__/DashboardPage.test.tsx | 44 +++++++++++++++++-- docs/operations.md | 2 +- 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts b/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts index c9f8731a..05624c59 100644 --- a/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts @@ -12,6 +12,12 @@ test("dashboard loads with real aggregated data", async ({ page }) => { await expect(page.getByText("Housekeeping")).toBeVisible(); // The page opens before its numbers arrive; check them once they have. await expect(page.getByText("loading…")).toHaveCount(0, { timeout: 15000 }); + await expect(page.getByText("Finding the latest failures…")).toHaveCount(0, { timeout: 15000 }); + await expect(page.getByText("Reading the retry chains…")).toHaveCount(0, { timeout: 15000 }); + // A part that couldn't be read stops loading too, and says so. + await expect(page.getByRole("link").filter({ hasText: /^Exchanges, last 24 hours/ })).not.toContainText( + "unavailable right now", + ); await expect(page.getByText("undefined")).toHaveCount(0); await expect(page.getByText("NaN")).toHaveCount(0); diff --git a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx index 046aba7a..4cf3302b 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx @@ -130,7 +130,11 @@ export function DashboardPage() { refetchInterval: 60_000, placeholderData: keepPreviousData, }); - const insights = useQuery({ queryKey: keys.metrics.insights, queryFn: () => api.getInsights() }); + const insights = useQuery({ + queryKey: keys.metrics.insights, + queryFn: () => api.getInsights(), + refetchInterval: 60_000, + }); const subscriptions = useQuery({ queryKey: keys.subscriptions.rows, queryFn: () => api.listSubscriptionRows(), @@ -608,7 +612,7 @@ export function DashboardPage() { description="Configuration that is switched off, unused or out of date - worth a look when tidying up." > {!insights.data ? ( - + ) : insights.data.insights.length === 0 ? ( Everything configured is in use. ) : ( diff --git a/SW.Bitween.Web/ClientApp/src/pages/dashboard/__tests__/DashboardPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/dashboard/__tests__/DashboardPage.test.tsx index 81bfe741..677fcfc8 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/dashboard/__tests__/DashboardPage.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/dashboard/__tests__/DashboardPage.test.tsx @@ -6,6 +6,12 @@ import { metricsHandlers, overview, summary } from "../../../__tests__/support/m const none = { result: [], totalCount: 0 }; +/** An answer that never comes, for a part still loading. */ +const never = async () => { + await delay("infinite"); + return HttpResponse.json({}); +}; + /** A subscription row as GET /subscriptions sends it: `RawSubscription` in src/api/http/subscriptions.ts. */ const subscription = (i: number, { failures = 0, paused = false } = {}) => ({ id: 900000 + i, @@ -160,6 +166,7 @@ describe("the dashboard", () => { renderApp("/dashboard", { handlers: [ http.get(apiPath("/metrics/overview"), () => new HttpResponse(null, { status: 500 })), + http.get(apiPath("/metrics/insights"), () => new HttpResponse(null, { status: 500 })), ...dashboard([subscription(0, { failures: 2 })]), ], }); @@ -168,16 +175,14 @@ describe("the dashboard", () => { expect(await within(exchanges).findByText("unavailable right now")).toBeVisible(); const traffic = within(screen.getByRole("heading", { name: "Traffic, last 48 hours" }).closest("section")!); expect(traffic.getByText("Unavailable right now")).toBeVisible(); + const housekeeping = within(screen.getByRole("heading", { name: "Housekeeping" }).closest("section")!); + expect(await housekeeping.findByText("Unavailable right now")).toBeVisible(); const health = within(screen.getByRole("heading", { name: "Subscription health" }).closest("section")!); expect(await health.findByText("Health page 1", { exact: true })).toBeVisible(); }); it("opens at once, and each part fills in when it arrives", async () => { - const never = async () => { - await delay("infinite"); - return HttpResponse.json({}); - }; renderApp("/dashboard", { handlers: [ http.get(apiPath("/metrics/overview"), never), @@ -201,6 +206,37 @@ describe("the dashboard", () => { expect(latest.getByText("Finding the latest failures…")).toBeVisible(); }); + it("shows the latest failures without waiting for the metrics", async () => { + const failure = { + id: "0f1e2d3c4b5a69788796a5b4c3d2e1f0", + subscriptionId: 900000, + documentName: "ORDER", + status: false, + responseBad: null, + exception: "Request failed with status NotFound", + startedOn: "2026-10-01T10:00:00Z", + }; + renderApp("/dashboard", { + handlers: [ + http.get(apiPath("/metrics/overview"), never), + http.get(apiPath("/xchanges"), ({ request }) => + HttpResponse.json( + new URL(request.url).searchParams.getAll("filter").includes("StatusFilter:1:3") + ? { result: [failure], totalCount: 1 } + : none, + ), + ), + ...dashboard([subscription(0, { failures: 2 })]), + ], + }); + + const latest = within((await screen.findByRole("heading", { name: "Latest failures" })).closest("section")!); + // Named from the subscription rows, which answer apart from it. + expect(await latest.findByRole("link", { name: "Health page 1" })).toBeVisible(); + expect(latest.getByText("Request failed with status NotFound")).toBeVisible(); + expect(within(screen.getByText("Exchanges, last 24 hours").closest("a")!).getByText("loading…")).toBeVisible(); + }); + it("reads its numbers from the metrics, and names subscriptions that have gone quiet", async () => { const metrics = overview({ since: "2026-09-01T00:00:00Z", diff --git a/docs/operations.md b/docs/operations.md index e87dd0b4..667c97d0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -71,7 +71,7 @@ See [Scheduling](scheduling.md). The dashboard, opened from the logo, shows the last 24 hours of exchanges, failures and end-to-end time, the 7-day success rate, waiting retries, queue alerts and subscriptions that have gone quiet. Below come traffic for the last 48 hours against the same hours a week before, volume for the last 30 days, unhealthy subscriptions, the latest failures, the busiest subscriptions and partners, the slowest adapters, and configuration worth tidying up. The traffic figures come from hourly metrics the server keeps, so they count every exchange however busy the instance is. -Two parts are read from the exchanges themselves. **Failures to act on** counts failed exchanges that are the newest attempt of their chain, of any age, and links to that search. **Chains that keep failing** lists the five retry chains with the most attempts among those retried in the last 7 days, with the success rate of retries over the same 7 days. +Some parts are read from the exchanges themselves, so on a busy instance they are the slowest: the latest failures, the count in flight, and the two below. **Failures to act on** counts failed exchanges that are the newest attempt of their chain, of any age, and links to that search. **Chains that keep failing** lists the five retry chains with the most attempts among those retried in the last 7 days, with the success rate of retries over the same 7 days. The page opens at once, and each tile and panel fills in when its own numbers arrive, so a slow one doesn't hold up the rest. One that can't be read in time shows as unavailable, and the dashboard asks again every minute.