From 272227c04a70419d0f10b11c60ade5cc05485317 Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Wed, 6 May 2026 22:22:48 +0200 Subject: [PATCH] feat(frontend): improve runners errors ui --- frontend/AGENTS.md | 1 + frontend/CLAUDE.md | 3 + .../forms/edit-shared-runner-config-form.tsx | 2 +- frontend/src/app/layout.tsx | 52 +- frontend/src/app/runner-config-table.tsx | 47 +- .../app/runner-pool-error-popover.stories.tsx | 216 ++++++++ .../src/app/runner-pool-error-popover.tsx | 521 ++++++++++++++++++ frontend/src/app/runners-table.tsx | 2 +- 8 files changed, 798 insertions(+), 46 deletions(-) create mode 120000 frontend/AGENTS.md create mode 100644 frontend/CLAUDE.md create mode 100644 frontend/src/app/runner-pool-error-popover.stories.tsx create mode 100644 frontend/src/app/runner-pool-error-popover.tsx diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 120000 index 0000000000..681311eb9c --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 0000000000..c65e8e07ea --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1,3 @@ +# Frontend + +- When building new UI components, ship a Ladle story alongside the component (`*.stories.tsx` next to the source). Cover the meaningful states (empty, loading, error, success, edge cases) so the component can be reviewed and iterated on in isolation without booting the whole dashboard. Run with `pnpm dev:ladle` from `frontend/`. Existing example: [src/app/runner-pool-error-popover.stories.tsx](src/app/runner-pool-error-popover.stories.tsx). diff --git a/frontend/src/app/forms/edit-shared-runner-config-form.tsx b/frontend/src/app/forms/edit-shared-runner-config-form.tsx index 7e2d0c5be2..9e30d2e6ab 100644 --- a/frontend/src/app/forms/edit-shared-runner-config-form.tsx +++ b/frontend/src/app/forms/edit-shared-runner-config-form.tsx @@ -465,7 +465,7 @@ export const MaxConcurrentActors = < /> - Maximum actors allowed to run concurrently per runner. + Maximum actors allowed to run concurrently in total. Leave blank for unlimited. diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index d5ee2c2482..5b06f82134 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -12,7 +12,7 @@ import { faWallet, Icon, } from "@rivet-gg/icons"; -import { useSuspenseQuery } from "@tanstack/react-query"; +import { useInfiniteQuery, useSuspenseQuery } from "@tanstack/react-query"; import { Link, useMatch, @@ -46,9 +46,11 @@ import { Skeleton, } from "@/components"; import { + ActorRegion, useCloudNamespaceDataProvider, useDataProvider, useDataProviderCheck, + useEngineCompatDataProvider, } from "@/components/actors"; import { useRootLayoutOptional } from "@/components/actors/root-layout-context"; import type { HeaderLinkProps } from "@/components/header/header-link"; @@ -56,7 +58,9 @@ import { authClient } from "@/lib/auth"; import { features } from "@/lib/features"; import { ensureTrailingSlash } from "@/lib/utils"; import { TEST_IDS } from "@/utils/test-ids"; +import type { RivetActorError } from "@/queries/types"; import { ActorBuildsList } from "./actor-builds-list"; +import { RunnerPoolErrorPopover } from "./runner-pool-error-popover"; import { BillingLimitAlert } from "./billing/billing-limit-alert"; import { BillingPlanBadge } from "./billing/billing-plan-badge"; import { BillingUsageGauge } from "./billing/billing-usage-gauge"; @@ -554,8 +558,11 @@ function HeaderLink({ icon, children, className, ...props }: HeaderLinkProps) { = {}; + for (const page of data.pages) { + for (const config of Object.values(page.runnerConfigs)) { + for (const [dc, dcConfig] of Object.entries( + config.datacenters, + )) { + if (dcConfig.runnerPoolError && !map[dc]) { + map[dc] = dcConfig.runnerPoolError; + } + } + } + } + return Object.keys(map).length > 0 ? map : null; + }, + }); + + if (!errors) return null; + + return ( + ( + + )} + /> + ); +} + function HeaderButton({ children, className, ...props }: ButtonProps) { return ( + + + + +
+ {summary} + + Click to view details + +
+
+
+ + ) : ( + + + + )} + e.preventDefault()} + > + { + setOpen(false); + onEditConfig(); + } + : undefined + } + /> + + + ); +} + +function ErrorPopoverBody({ + groups, + renderRegion, + onEditConfig, +}: { + groups: ErrorGroup[]; + renderRegion: (regionId: string) => ReactNode; + onEditConfig?: () => void; +}) { + const [activeFingerprint, setActiveFingerprint] = useState( + groups[0].classified.fingerprint, + ); + + const showTabs = groups.length > 1; + + return ( +
+
+
+
+ Runner pool errors +
+
+ {groups.length === 1 + ? `${formatRegionCount(groups[0].regions.length)} affected` + : `${groups.length} distinct errors across ${formatRegionCount( + groups.reduce( + (sum, g) => sum + g.regions.length, + 0, + ), + )}`} +
+
+
+ + {showTabs ? ( + + + {groups.map((g) => ( + + + + {g.classified.title} + + + {g.regions.length} + + + ))} + + {groups.map((g) => ( + + + + ))} + + ) : ( + + )} + + {onEditConfig ? ( +
+ +
+ ) : null} +
+ ); +} + +function GroupBody({ + group, + renderRegion, +}: { + group: ErrorGroup; + renderRegion: (regionId: string) => ReactNode; +}) { + const { classified, regions } = group; + + return ( +
+
+ + {regions.length === 1 ? "Region:" : "Regions:"} + + {regions.map((r) => ( + + {renderRegion(r)} + + ))} +
+ + {classified.body ? ( + + ) : ( +

+ {describeKind(classified.kind)} +

+ )} +
+ ); +} + +function ErrorBody({ body, label }: { body: string; label: string }) { + const [copied, setCopied] = useState(false); + const formatted = useMemo(() => formatBody(body), [body]); + + const handleCopied = () => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( +
+
+ + {label} + + + + +
+ + {formatted.language === "json" ? ( + + ) : ( + + {formatted.code} + + )} + +
+ ); +} + +function formatBody(body: string): { code: string; language: "json" | "text" } { + const trimmed = body.trim(); + if ( + (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) + ) { + try { + return { + code: JSON.stringify(JSON.parse(trimmed), null, 2), + language: "json", + }; + } catch { + // Fall through. + } + } + return { code: body, language: "text" }; +} + +function SeverityDot({ severity }: { severity: Severity }) { + return ( + + ); +} + +function formatRegionCount(n: number) { + return n === 1 ? "1 region" : `${n} regions`; +} + +function describeKind(kind: ClassifiedError["kind"]): string { + switch (kind) { + case "downgrade": + return "Runner pool was downgraded to an unsupported version. Revert to a higher version."; + case "serverless_stream_ended_early": + return "Connection terminated before the runner stopped. Check the request lifespan limits on your serverless provider."; + case "internal": + return "An internal error occurred in the runner pool."; + default: + return "Unknown error."; + } +} diff --git a/frontend/src/app/runners-table.tsx b/frontend/src/app/runners-table.tsx index dc3e34136d..3601c5a2f5 100644 --- a/frontend/src/app/runners-table.tsx +++ b/frontend/src/app/runners-table.tsx @@ -56,7 +56,7 @@ export function RunnersTable({ ID Name Datacenter - Slots + Actors Version Created