From 7eb2760b213b6f3c3e25df7c507704161c70b9e1 Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Sat, 23 May 2026 18:10:27 -0700 Subject: [PATCH] fix(dashboard): render risk overview shell while data loads Mirror the project-home pattern: always render both Page.Section blocks on /risk-overview and swap individual metric/chart card bodies for skeletons during the initial fetch. Also pass placeholderData: keepPreviousData to useRiskOverview so changing the time range no longer blanks the page after first load. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/pages/security/SecurityOverview.tsx | 222 ++++++++++-------- 1 file changed, 127 insertions(+), 95 deletions(-) diff --git a/client/dashboard/src/pages/security/SecurityOverview.tsx b/client/dashboard/src/pages/security/SecurityOverview.tsx index fe1e66a637..98789989ee 100644 --- a/client/dashboard/src/pages/security/SecurityOverview.tsx +++ b/client/dashboard/src/pages/security/SecurityOverview.tsx @@ -5,9 +5,11 @@ import { InsightsConfig } from "@/components/insights-sidebar"; import { Page } from "@/components/page-layout"; import { RequireScope } from "@/components/require-scope"; import { DashboardCard } from "@/components/ui/dashboard-card"; +import { Skeleton } from "@/components/ui/skeleton"; import { Button, Icon } from "@speakeasy-api/moonshine"; import { TimeRangePicker, type DateRangePreset } from "@gram-ai/elements"; import { useRiskOverview } from "@gram/client/react-query/index.js"; +import { keepPreviousData } from "@tanstack/react-query"; import { Shield } from "lucide-react"; import { useMemo, type ReactNode } from "react"; import { Link, Outlet, useLocation } from "react-router"; @@ -198,8 +200,11 @@ function SecurityOverviewContent() { onClearCustomRange={clearCustomRange} /> ); - const overviewQuery = useRiskOverview({ from, to }); + const overviewQuery = useRiskOverview({ from, to }, undefined, { + placeholderData: keepPreviousData, + }); const overview = overviewQuery.data; + const isOverviewLoading = overviewQuery.isLoading; const categoriesIndexHref = useMemo(() => { const r = ( @@ -292,16 +297,6 @@ function SecurityOverviewContent() { }); }, [overview?.topUsers, routes.riskOverview, location.search]); - if (overviewQuery.isLoading) { - return ( - -
-

Loading...

-
-
- ); - } - if (overviewQuery.error) { return ( @@ -323,30 +318,32 @@ function SecurityOverviewContent() { ); } - if (!overview) { - return null; - } - - if (overview.activePolicies === 0) { + // Only collapse to the empty state once data has actually arrived — + // during the first fetch we render the full shell with skeletons so the + // layout never blinks between "Loading…" and the real page. + if (overview && overview.activePolicies === 0) { return ; } - const hasFindings = overview.findings > 0; + const hasFindings = (overview?.findings ?? 0) > 0; // Brief security-flavoured context for the AI Insights sidebar. Numbers are // pulled from the current risk overview query so the assistant can reason // about "this period" without re-fetching, but it must still call the risk - // tools for anything that isn't a top-line metric. - const insightsContext = [ - "Page: Security Overview.", - `Selected date range: ${rangeLabel}.`, - `Active risk policies: ${overview.activePolicies}.`, - `Findings in current range: ${overview.findings}.`, - `Messages scanned: ${overview.messagesScanned}.`, - `Flagged sessions: ${overview.flaggedSessions}.`, - "Available risk tools: listRiskResultsForAgent (finding-level, match is redacted to ), listRiskResultsByChat (chat-level rollups), listRiskPolicies, getRiskPolicyStatus, listShadowMCPApprovals.", - "Never echo match_redacted values verbatim. Refer to findings by rule_id and source.", - ].join(" "); + // tools for anything that isn't a top-line metric. Only mount once `overview` + // is populated so the contextInfo never embeds stale or undefined counts. + const insightsContext = overview + ? [ + "Page: Security Overview.", + `Selected date range: ${rangeLabel}.`, + `Active risk policies: ${overview.activePolicies}.`, + `Findings in current range: ${overview.findings}.`, + `Messages scanned: ${overview.messagesScanned}.`, + `Flagged sessions: ${overview.flaggedSessions}.`, + "Available risk tools: listRiskResultsForAgent (finding-level, match is redacted to ), listRiskResultsByChat (chat-level rollups), listRiskPolicies, getRiskPolicyStatus, listShadowMCPApprovals.", + "Never echo match_redacted values verbatim. Refer to findings by rule_id and source.", + ].join(" ") + : null; const insightsSuggestions = [ { @@ -377,76 +374,99 @@ function SecurityOverviewContent() { return ( <> - + {insightsContext && ( + + )}
- - - - + {isOverviewLoading ? ( + + ) : ( + + )} + {isOverviewLoading ? ( + + ) : ( + + )} + {isOverviewLoading ? ( + + ) : ( + + )} + {isOverviewLoading ? ( + + ) : ( + + )}
- {overview.activePolicies > 0 && ( - -
- - } - > - - - - } - > - - - - } - > - - -
+ +
+ + } + > + + + + } + > + + + + } + > + + +
+ {isOverviewLoading || !overview ? ( + + ) : ( -
- )} + )} +
); } @@ -529,21 +549,33 @@ function RiskActivitySection({ children }: { children: ReactNode }) { function DashboardChartCard({ title, empty, + loading, children, action, }: { title: string; empty: boolean; + loading?: boolean; children: ReactNode; action?: ReactNode; }) { return ( - {empty ? : children} + {loading ? : empty ? : children} ); } +function SkeletonList() { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ); +} + function ChartEmptyState() { return

No findings recorded

; }