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); }