Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Dashboard error states

A screen that cannot load its data has to say why. Before #962 every screen rendered the same sentence — Failed to load X. — for causes needing entirely different responses, so it pointed at none of them.

That is not a hypothetical cost. During the #924 dogfooding pass the Keys screen showed “Failed to load your keys.” while the real cause was every /api/v1/me/* route returning 401 (#942). The message sent the operator to check their key configuration; the actual cause was found afterwards by reading traces. An error that cannot separate you are not signed in from the server is down costs more time than no error at all, because it invites a wrong hypothesis and the operator spends their attention there first.

The rule

Never render a load failure by hand. Use LoadError:

{keys.error && (
  <LoadError
    error={keys.error}
    resource={t("errors.resources.virtualKeys")}
    onRetry={() => keys.refetch()}
  />
)}

resource is the translated noun for what failed — it is interpolated into both the title and the body, so each reads as a sentence in every locale. Some bodies name it, so a call site that filled only the title showed the reader a raw {{resource}} (#1362); catalog parity cannot catch that, because the placeholder is present in every locale and it is the render that drops it. src/lib/load-error.test.ts holds the copy to the one variable the component passes, and the EveryKind story asserts no {{ survives to the DOM. Pass onRetry whenever the caller holds a query handle; the component decides whether offering it is honest.

What it distinguishes

classifyLoadError in ui/src/lib/load-error.ts maps a thrown value to one of seven kinds. ApiError already carries status and the control plane’s code, so no screen has to parse a message to find out what happened.

kindcauserecovery offered
unauthenticated401sign in again
forbidden403none — ask an administrator
openMode401 with code open_mode_no_sessionnone — set ROLTER_ADMIN_TOKEN
noStore404 with code no_such_endpoint (or that message prefix from an older control plane)none — set ROLTER_DATABASE_URL
unreachablethe thrown value is not an ApiError, so fetch never connectedretry
server5xxretry
unknownany other non-ok statusretry

noStore is the third one that looks like something else. Every CRUD and settings route is mounted only when the control plane runs with a database, so a config-file-only deployment answers Users, Keys, Providers and every settings screen with the API’s JSON 404. That is the deployment’s shape, not a wrong URL and not a failure a retry can change (#1204).

No analytics store is not a load error

A control plane with no ClickHouse is a deployment shape rolter supports, so LoadError has no kind for it. The analytics routes are mounted; they just have no ClickHouse behind them, so they answer 503, and a control plane too old to serve a route answers 404. getAnalytics turns both into an AnalyticsUnavailableError rather than an ApiError, which carries no status. Read as a load failure it would be wrong twice over: the status-less rule calls it unreachable and tells the operator the control plane could not be reached while it was answering every request (#1236), and a red role="alert" puts a deployment shape in the voice of a 500 that a screen reader announces as urgent on every visit (#1984, #1976, #2016).

Every screen that reads analytics states the answer in the shared AnalyticsUnavailable panel in ui/src/components/, an informational role="status": a neutral surface, the CLICKHOUSE_URL guidance in monospace, the control plane’s own message under it, and no retry, because no retry can help. A screen passes its own copy as i18nKey, with a title and a body under it, and the body says what the screen will show once the store is there. The panel is chosen on the error’s class, before LoadError is reached:

{error instanceof AnalyticsUnavailableError ? (
  <AnalyticsUnavailable error={error} i18nKey="pages.logs.noAnalytics" />
) : (
  <LoadError error={error} resource={t("errors.resources.requestLogs")} onRetry={retry} />
)}
screenwhere the panel goes
LLM Logs (#1984)in place of the table and its filters
Dashboard (#1976)in place of every card, once any read answers it while holding nothing; the setup checklist above it stays
MCP Logs (#2016)in place of the stats and the table; also inside a call’s detail drawer if the store goes away under an open list
Cost Attribution (#2016)in place of the spend strip; the business units or customers below it keep working, since they are read from Postgres
Account (#2016)above the key cards; each card still says its usage is unavailable, and the keys can still be created and rotated

A screen that hands an AnalyticsUnavailableError to LoadError anyway gets the server kind, not unreachable: the control plane did answer. That is a fallback for a screen that forgot the panel, not a way to show the state.

On the Dashboard the panel is one answer for the screen rather than one per card. It replaces every card as soon as any of the four reads answers AnalyticsUnavailableError while holding no data, since each card would otherwise be a skeleton or an error about a store that was never there. The setup checklist above it stays, because it reads rows rather than traffic.

A polled query that fails

A screen that polls with refetchInterval can fail in a way a one-shot read cannot. react-query sends a query that has never held data back to pending on every refetch, and clears its error when it does. So a poll that keeps firing after a failed first load unmounts the LoadError, shows the skeleton while the retries run, then mounts the alert again. A screen reader hears the alert anew on every cycle. LLM Logs did this every five seconds (#1984).

Stop polling while the query is in error with no data, using a function refetchInterval:

refetchInterval: (q) =>
  streaming && !(q.state.status === "error" && q.state.data === undefined) ? pollMs : false,

The LoadError’s retry is then what asks again, and a retry that lands starts the poll again. A failure with rows already on screen keeps its error through a refetch, so that case may go on polling, and should say it is retrying.

Anything that claims the feed is live (a pulse, “Streaming”) reads the query, never the pause toggle alone. It says live only after a fetch that succeeded. On a failure it says so, gives the time from errorUpdatedAt through useFormat(), and says “retrying” only when a poll will actually retry. The Failed story in Logs.stories.tsx holds a reference to the alert node across three polling intervals and asserts it is still connected: an alert that flickered would have been unmounted and replaced, so the held node would be detached even if a new alert had since appeared.

The Dashboard polls four queries under the same rule (#1975), and each card owns the state of the read behind it (#1976). The four figures, the spend chart, the traffic donut, the by-model bars and the recent rows each read one query through CardRead, which shows the card’s own skeleton while the read is awaited, the card’s own LoadError with a retry for that read alone when it failed holding nothing, and its content once it holds data. The content is a render prop handed the data, so a card cannot say “No traffic yet.” or “Nothing logged yet.” about a read that has not answered or that failed. One failing endpoint takes down the cards that read it and leaves the rest of the screen up. The donut and the bars read the same endpoint, so one failure of it is one alert: the traffic share card holds the LoadError and its retry, and the by-model card holds a plain sentence (pages.dashboard.sharedRead, not a live region, no button) saying it reads the same data and where to retry. Either card comes back with that read.

When every read fails at once the screen says so once (#2342). All four queries in error holding nothing, and none of them the no-analytics answer, replace the cards with one screen-level LoadError that names the analytics (errors.resources.analytics), quotes the error of the figures read, and offers one Try again that refetches all four reads. The setup checklist stays above it, as it does above the no-analytics panel. Before, a total outage put five role="alert" blocks on screen, so a screen reader announced five alerts on mount, two of them (the traffic share and the by-model bars) stating one failure twice.

A partial failure is unchanged: a card whose read failed holds its own alert while the ones that answered stay up, and those keep polling. So does a failure next to a read that is still out, since a read that has not answered is not a failure: the screen becomes one alert only when the last read fails. The per-card error signals of useErrorState are withheld while the screen-level alert is the placeholder on screen, so one outage is one signal, dashboard-analytics.

A poll that fails over data a card already shows keeps that data, and every card says so. RefreshFailed writes Refresh failed at {time}, retrying in the danger text colour under the figures and under each chart, and the Recent requests card, the one that says “Live”, swaps the word for the same sentence in its label, or for Load failed at {time} when its first read failed and polling stopped. The line is plain text rather than a live region: every failed poll rewrites it, and five cards announcing once a minute would be noise. Each card keeps the pollEvery rule for its own read, so a query that never held data stops polling and holds its alert.

The Recent requests label follows the same rule as every feed that claims to be live: it says Live only once a read has succeeded. While the first read is out it says Loading, and on a failure it gives the time. It used to say Live from the first paint because it tested !isError, which is also true of a read nobody has answered yet (#2341).

The AFailedPollKeepsWhatLoaded and AFirstLoadThatFailsStopsPolling stories in Dashboard.stories.tsx pin the two rules; the second one is the total outage, and asserts exactly one alert, held across three intervals with nothing asked, whose retry asks for each of the four reads once. OneFailedCardLeavesTheRestUp, AFailedRecentReadIsNotAnEmptyOne, TheTwoCardsOnOneReadShareOneAlert, SeveralFailedCardsEachHoldTheirOwnAlert, AReadStillOutIsNotYetAnOutage and AFailedCardHoldsItsAlertWhileTheOthersKeepPolling pin the partial failure: the per-card alert, its retry, where the outage starts, and the polling that goes on around it. The …IsStillLoading stories pin each card’s skeleton, and TheRecentLabelSaysLiveOnlyAfterTheFirstRead the label.

A polled screen that reads a time window keeps the window’s name in the query key and works the bounds out inside the query function, with windowBounds() from ui/src/lib/time-window.ts. Bounds fixed when the screen mounted keep widening on a tab left open, so “the last 24 hours” would mean every hour since the tab was opened, and a key that carried a fresh timestamp would fetch on every render. The Dashboard and LLM Logs (#2315) do this, and the TheWindowRollsForward… stories record two reads and assert the second since is later than the first.

Two of these are easy to collapse and must not be. A plain 401 is fixed by signing in; open_mode_no_session is a control plane running with no admin token, which has no accounts to sign into at all — signing in again is exactly the wrong advice. And a retry button on a 403 suggests the failure was transient when it was a permission, so isRetryable withholds it.

A 401 is handled once, not per screen

Since #1196 the shell owns the expired session. api.ts calls the handler AuthProvider registered through setSessionExpiredHandler whenever a request that carried the session token is answered 401 — the token and the cached account are dropped and the sign-in screen explains why. A screen still renders LoadError for the request that failed, but it no longer does so with a dead token attached and no way out except signing out by hand.

Three 401s are deliberately not that: open_mode_no_session (there is no account to sign in to), a refused POST /api/v1/auth/login (the password was wrong, the session was not), and an expired invitation token. None of them says anything about the session in localStorage.

Two things that are not this component

An empty result is not a failure. A successful request returning zero rows renders an empty state. Routing it here would tell an operator something is broken when nothing is. The placeholder for that, and the one for a request still in flight, are in loading and empty states.

A failure is not an empty result either. The reverse mistake is the common one. A failed read holds no rows, so a screen that derives its empty state or its count from the rows renders “No providers yet” and “0 providers” directly under the LoadError that says the list could not be read, which turns an outage into “nothing configured” (#2211). The empty state waits for a read that succeeded and a count waits for the data it counts; the ListEmptyRow, Table and ListSummary primitives that enforce this are in loading and empty states. A screen’s error stories assert it with expectNoFalseEmpty after expectLoadError.

The control plane’s own message is never swallowed. LoadError prints it beneath the summary. The dashboard’s classification is a helpful gloss, not a replacement — #962 happened because the gloss was the only thing on screen and it was wrong.

A screen that makes one request per row

A screen whose list read is followed by a detail read per row has a failure the single-query screens do not: the list arrives, some of the detail reads do not, and the rows they belong to still have to render as something. Defaulting them to the empty answer is the bug #1461 was filed over — the Complexity Router mapped policyQueries[i]?.data?.tiers ?? [] and so drew a route whose policy had 500’d, 403’d or simply not landed yet in the group headed “No policy yet”, beside a button offering to create the policy it already had.

Keep the four states apart and let each one say what it is:

  • loading — a skeleton for that row, not an empty answer;
  • failed — one LoadError for the group, the affected rows named under it, and no way in: an editor seeded from a read that failed saves a fresh draft over contents nobody has seen;
  • configured and unconfigured — the two real answers.

Routing Rules made the same mistake with its target reads (#2133): targetQueries[i]?.data ?? [] drew a route whose targets were still loading, or had failed, as “No targets yet.” and “0 targets”. It resolves the failure inside the row, not in a group. The route’s name, strategy and labels are known there and only its targets are not, so the card stays in the grid and holds a skeleton, or its own LoadError with a retry for that one read, where the targets would be. Its target count is left out until the read answers. The rule that matters is the same on both screens: the empty copy and the zero count wait for an answer. When route editing lands (#2134), a card whose targets failed must not open an editor seeded from nothing.

Two consequences fall out of this. A count in the screen’s summary counts only the rows that resolved, because a denominator that includes the unread ones states them as empty. And useScreenReady / useErrorState follow the detail reads too — a screen that reports itself interactive while every row is still a skeleton is measuring the wrong moment.

An editor opened from such a row re-reads its own record (refetchOnMount: "always") rather than seeding from the list’s cache, so it has the same two ways of holding nothing and has to render both.

Adding a screen

Add the resource noun to errors.resources.* in every catalog under ui/src/lib/i18n/locales/ (see i18n) and use it as above. The six errors.load.* kinds already exist; a new screen needs no new error copy.

A mutation that fails is a different surface: it is reported where the action was taken, not where the data would have been. For a destructive action that means inside the confirmation, which stays open so the message has somewhere to live — see destructive actions.

A sheet reports its failure in two parts. EditorSheet’s errorMessage is the screen’s own translated lead (“Could not create the key”) and errorDetail is what the control plane said, read with errorDetail() from ui/src/lib/toast.ts. The server answers in English whatever the locale and the dashboard has no table to translate it with, so the lead keeps the sheet readable in every language and the server’s words sit under it in mono, the way a failed toast and LoadError carry them. A sheet that passes only errorMessage renders one line as before; Account’s mint sheet is the first to use both, and the other sheets follow as #2216 reaches them.

One-shot feedback: toasts

Inline messages are for what stays on screen: a field that failed validation, a load that failed. Feedback about something that just happened — a save that went through, a delete the control plane refused, a row toggle that bounced — goes through the toast queue (useToast() in ui/src/lib/toast.tsx, rendered once by <Toaster /> in the shell). A success is a polite role="status" announcement that dismisses itself; a failure is an assertive role="alert" that stays longer, carries the control plane’s own message as its detail line, and can be dismissed by hand. Outside the provider — a story, a test — the hook is a no-op, so a screen never has to know whether the shell is around it. Use t("toast.*") for the titles so every screen says “saved” the same way.

Which outcomes toast

Every useMutation reports its outcome. The rule is where, not whether: the toast carries what the surface that triggered the action cannot, because that surface is gone by the time the answer arrives.

the actionsuccessfailure
a settings screen’s Savetoast — the sticky footer’s “…updated.” flash is gonetoast; the footer no longer keeps a copy
a sheet or dialog that closes on successtoasttoast, plus the inline line the still-open sheet already carried
a delete behind a ConfirmDialogtoasttoast, plus the dialog’s own error line
a row togglenothing — the switch staying flipped is the confirmationtoast; a switch that bounces back says nothing at all
a reveal-once secret (mint a key, issue a token)nothing — the secret on screen is the confirmationthe inline line beside the button

Field-level validation never moves: a value that will not parse belongs next to the field, on screen for as long as it is wrong.

The settings screens invalidate

setQueryData alone left every other reader of the key on the value it already had — the screen looked saved and the rest of the dashboard did not agree. The eleven settings screens now write the response and invalidate the query, so the save is what the next read sees (#1197).