diff --git a/dashboard/web/src/components/Panel.tsx b/dashboard/web/src/components/Panel.tsx
index 5873fe2..fe3b022 100644
--- a/dashboard/web/src/components/Panel.tsx
+++ b/dashboard/web/src/components/Panel.tsx
@@ -1,5 +1,5 @@
-import type { ReactNode } from 'react';
-import { Info } from '@phosphor-icons/react';
+import { useEffect, useRef, type ReactNode } from 'react';
+import { Info, WarningCircle } from '@phosphor-icons/react';
import type { Result } from '../types';
@@ -69,6 +69,54 @@ export function PanelEmpty({ result }: { result: { reason: string; hint?: string
);
}
+/**
+ * Shown above a panel's content when the newest response was unavailable but an
+ * earlier one wasn't.
+ *
+ * An upstream declining once is not a reason to throw away data that is seconds
+ * old — but it is a reason to say so, because silently showing stale numbers is
+ * worse than showing none. Carries the same reason and hint `PanelEmpty` would,
+ * so the fix stays discoverable without the panel going blank.
+ */
+export function PanelStale({ result }: { result: { reason: string; hint?: string } }) {
+ return (
+
+
+
+
Showing last known data — {result.reason}
+ {result.hint && (
+
+ {result.hint}
+
+ )}
+
+
+ );
+}
+
+/** Remembers the most recent payload that was actually available. */
+function useLastAvailable(data: Result | null): T | null {
+ const lastAvailable = useRef(null);
+ useEffect(() => {
+ if (data?.available) lastAvailable.current = data;
+ }, [data]);
+ return lastAvailable.current;
+}
+
/** Placeholder while a panel's first request is in flight. */
export function PanelLoading() {
return (
@@ -98,6 +146,20 @@ export function PanelBody({
empty?: string;
children: (value: T) => ReactNode;
}) {
+ const lastAvailable = useLastAvailable(data);
+
+ const render = (value: T) => {
+ const rendered = children(value);
+ if (empty && Array.isArray(rendered) && rendered.length === 0) {
+ return (
+
+ {empty}
+
+ );
+ }
+ return <>{rendered}>;
+ };
+
if (loading && !data) return ;
// A transport failure leaves `data` null with `loading` false. Reporting that
// as "No response yet" would imply the request is still coming.
@@ -112,15 +174,24 @@ export function PanelBody({
/>
);
}
- if (!data.available) return ;
- const rendered = children(data);
- if (empty && Array.isArray(rendered) && rendered.length === 0) {
- return (
-
- {empty}
-
- );
+ if (!data.available) {
+ // An upstream blip arrives as a *successful* response carrying
+ // `available: false`, so `usePolled` can't tell it from real data and
+ // replaces the last good payload with it. Without this branch a single
+ // failed poll blanks the panel until the server-side TTL lapses — up to a
+ // minute for the calendar — which is exactly what "a failed refresh keeps
+ // the last good data on screen" is supposed to prevent.
+ if (lastAvailable) {
+ return (
+ <>
+
+ {render(lastAvailable)}
+ >
+ );
+ }
+ return ;
}
- return <>{rendered}>;
+
+ return render(data);
}