From e6da819ec8605bc7f0f0abc46ebe92579d550af6 Mon Sep 17 00:00:00 2001 From: Alex Martin Date: Fri, 15 May 2026 14:50:26 -0700 Subject: [PATCH 01/18] feat(dashboard): add risk activity page --- .../dashboard/src/components/app-sidebar.tsx | 1 + .../src/components/log-workbench.tsx | 74 +++ .../dashboard/src/components/page-header.tsx | 1 + .../src/pages/security/RiskActivity.tsx | 464 ++++++++++++++++++ .../src/pages/security/SecurityOverview.tsx | 157 +----- .../src/pages/security/risk-finding-ui.tsx | 85 ++++ .../src/pages/security/risk-finding-utils.ts | 41 ++ client/dashboard/src/routes.tsx | 7 + 8 files changed, 690 insertions(+), 140 deletions(-) create mode 100644 client/dashboard/src/components/log-workbench.tsx create mode 100644 client/dashboard/src/pages/security/RiskActivity.tsx create mode 100644 client/dashboard/src/pages/security/risk-finding-ui.tsx create mode 100644 client/dashboard/src/pages/security/risk-finding-utils.ts diff --git a/client/dashboard/src/components/app-sidebar.tsx b/client/dashboard/src/components/app-sidebar.tsx index 913e17d81d..0a7e2e7634 100644 --- a/client/dashboard/src/components/app-sidebar.tsx +++ b/client/dashboard/src/components/app-sidebar.tsx @@ -132,6 +132,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { item={routes.riskOverview} scope="project:read" /> + ; + scrollRef?: React.Ref; + contentClassName?: string; + className?: string; +} + +export function LogWorkbench({ + title, + description, + actions, + filters, + status, + header, + children, + footer, + detail, + onScroll, + scrollRef, + contentClassName, + className, +}: LogWorkbenchProps) { + return ( + <> +
+
+
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ {actions ? ( +
{actions}
+ ) : null} +
+ {filters ? ( +
{filters}
+ ) : null} +
+ +
+
+ {status} + {header} +
+ {children} +
+ {footer} +
+
+
+ + {detail} + + ); +} diff --git a/client/dashboard/src/components/page-header.tsx b/client/dashboard/src/components/page-header.tsx index d2101e0a26..30bdfbd597 100644 --- a/client/dashboard/src/components/page-header.tsx +++ b/client/dashboard/src/components/page-header.tsx @@ -71,6 +71,7 @@ const breadcrumbSubstitutions = { "audit-logs": "Audit Logs", "admin-settings": "Admin Settings", "risk-overview": "Risk Overview", + "risk-activity": "Risk Activity", "risk-policies": "Risk Policies", // The URL segments `slack` and `clis` are preserved for backwards // compatibility, but the sidebar/route titles were rebranded — map them diff --git a/client/dashboard/src/pages/security/RiskActivity.tsx b/client/dashboard/src/pages/security/RiskActivity.tsx new file mode 100644 index 0000000000..e0e02041e8 --- /dev/null +++ b/client/dashboard/src/pages/security/RiskActivity.tsx @@ -0,0 +1,464 @@ +import { LogWorkbench } from "@/components/log-workbench"; +import { Page } from "@/components/page-layout"; +import { RequireScope } from "@/components/require-scope"; +import { Button } from "@/components/ui/button"; +import { Drawer, DrawerContent } from "@/components/ui/drawer"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useSdkClient } from "@/contexts/Sdk"; +import { cn } from "@/lib/utils"; +import { ChatDetailPanel } from "@/pages/chatLogs/ChatDetailPanel"; +import type { RiskResult } from "@gram/client/models/components"; +import { + invalidateAllRiskListShadowMCPApprovals, + useRiskApproveShadowMCPMutation, + useRiskListPolicies, +} from "@gram/client/react-query/index.js"; +import { Icon } from "@speakeasy-api/moonshine"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { RefreshCw, ShieldOff } from "lucide-react"; +import { useCallback, useMemo, useRef } from "react"; +import { useSearchParams } from "react-router"; +import { toast } from "sonner"; +import { CategoryLabel, MaskedMatch } from "./risk-finding-ui"; + +export default function RiskActivity() { + return ( + + + + + + + + + + + ); +} + +function RiskActivityContent() { + const client = useSdkClient(); + const queryClient = useQueryClient(); + const [searchParams, setSearchParams] = useSearchParams(); + const selectedChatId = searchParams.get("chat_id"); + const policyFilter = searchParams.get("policy_id") ?? ""; + const containerRef = useRef(null); + + const setSelectedChatId = useCallback( + (chatId: string | null) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (chatId) { + next.set("chat_id", chatId); + } else { + next.delete("chat_id"); + } + return next; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + const setPolicyFilter = useCallback( + (policyId: string) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (policyId) { + next.set("policy_id", policyId); + } else { + next.delete("policy_id"); + } + return next; + }, + { replace: true }, + ); + containerRef.current?.scrollTo({ top: 0 }); + }, + [setSearchParams], + ); + + const { data: policiesData, isLoading: policiesLoading } = + useRiskListPolicies(); + const policies = useMemo( + () => policiesData?.policies ?? [], + [policiesData?.policies], + ); + const policyMessageById = useMemo(() => { + const m = new Map(); + for (const policy of policies) { + if (policy.userMessage && policy.userMessage.trim() !== "") { + m.set(policy.id, policy.userMessage); + } + } + return m; + }, [policies]); + + const resultsQuery = useInfiniteQuery({ + queryKey: ["risk", "results", "list", policyFilter], + queryFn: async ({ pageParam }) => { + return client.risk.results.list({ + cursor: pageParam, + limit: 50, + policyId: policyFilter || undefined, + }); + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + }); + + const results = useMemo( + () => resultsQuery.data?.pages.flatMap((p) => p.results) ?? [], + [resultsQuery.data], + ); + const totalCount = resultsQuery.data?.pages[0]?.totalCount ?? results.length; + const isInitialLoading = policiesLoading || resultsQuery.isLoading; + + const approveMutation = useRiskApproveShadowMCPMutation(); + const handleExclude = useCallback( + (policyId: string, match: string, serverName?: string) => { + approveMutation.mutate( + { + request: { + approveShadowMCPRequestBody: { + policyId, + match, + serverName, + }, + }, + }, + { + onSuccess: () => { + toast.success("Excluded from policy"); + queryClient.invalidateQueries({ + queryKey: ["risk", "results", "list"], + }); + invalidateAllRiskListShadowMCPApprovals(queryClient); + }, + onError: (err) => { + toast.error(`Failed to exclude: ${err.message ?? "unknown error"}`); + }, + }, + ); + }, + [approveMutation, queryClient], + ); + + const handleScroll = useCallback( + (e: React.UIEvent) => { + const container = e.currentTarget; + const distanceFromBottom = + container.scrollHeight - (container.scrollTop + container.clientHeight); + + if (resultsQuery.isFetchingNextPage || resultsQuery.isFetching) return; + if (!resultsQuery.hasNextPage) return; + + if (distanceFromBottom < 200) { + resultsQuery.fetchNextPage(); + } + }, + [resultsQuery], + ); + + return ( + resultsQuery.refetch()} + disabled={resultsQuery.isFetching} + aria-label="Refresh risk activity" + > + + Refresh + + } + filters={ + + } + status={ + resultsQuery.isFetching && results.length > 0 ? ( +
+
+
+ ) : null + } + header={} + footer={ + results.length > 0 ? ( + resultsQuery.fetchNextPage()} + /> + ) : null + } + detail={ + !open && setSelectedChatId(null)} + direction="right" + > + + {selectedChatId && ( + setSelectedChatId(null)} + onDelete={() => setSelectedChatId(null)} + collapseNonRisk + /> + )} + + + } + scrollRef={containerRef} + onScroll={handleScroll} + > + + + ); +} + +function RiskActivityHeader() { + return ( +
+
Occurred
+
Category
+
Rule
+
Chat
+
User
+
Match
+
Policy Note
+
Actions
+
+ ); +} + +function RiskActivityRows({ + error, + isLoading, + results, + policyMessageById, + isExcluding, + onSelectChat, + onExclude, +}: { + error: Error | null; + isLoading: boolean; + results: RiskResult[]; + policyMessageById: Map; + isExcluding: boolean; + onSelectChat: (chatId: string | null) => void; + onExclude: (policyId: string, match: string, serverName?: string) => void; +}) { + if (error) { + return ( +
+
+ +
+ + Error loading risk activity + + + {error.message} + +
+ ); + } + + if (isLoading) { + return ( +
+ + Loading risk activity... +
+ ); + } + + if (results.length === 0) { + return ( +
+
+ +
+ + No risk activity found + + + Findings will appear here as messages are analyzed. + +
+ ); + } + + return ( + <> + {results.map((result) => ( + + ))} + + ); +} + +function RiskActivityRow({ + result, + policyNote, + isExcluding, + onSelectChat, + onExclude, +}: { + result: RiskResult; + policyNote: string | undefined; + isExcluding: boolean; + onSelectChat: (chatId: string | null) => void; + onExclude: (policyId: string, match: string, serverName?: string) => void; +}) { + const isShadowMCP = result.source === "shadow_mcp"; + + return ( + + ) : null} +
+ + ); +} + +function RiskActivityFooter({ + count, + totalCount, + hasNextPage, + isFetchingNextPage, + onLoadMore, +}: { + count: number; + totalCount: number; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onLoadMore: () => void; +}) { + return ( +
+ + Showing {count.toLocaleString()} of {totalCount.toLocaleString()}{" "} + {totalCount === 1 ? "finding" : "findings"} + {hasNextPage && " - Scroll to load more"} + + {hasNextPage ? ( + + ) : null} +
+ ); +} diff --git a/client/dashboard/src/pages/security/SecurityOverview.tsx b/client/dashboard/src/pages/security/SecurityOverview.tsx index edda84478a..8a414fd67d 100644 --- a/client/dashboard/src/pages/security/SecurityOverview.tsx +++ b/client/dashboard/src/pages/security/SecurityOverview.tsx @@ -1,3 +1,4 @@ +import { Button, Icon } from "@speakeasy-api/moonshine"; import { Page } from "@/components/page-layout"; import { RequireScope } from "@/components/require-scope"; import { Type } from "@/components/ui/type"; @@ -17,9 +18,8 @@ import { SelectValue, } from "@/components/ui/select"; import { useRoutes } from "@/routes"; -import { Eye, EyeOff, Shield, ShieldOff } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { useCallback, useMemo, useState } from "react"; +import { Shield, ShieldOff } from "lucide-react"; +import { useCallback, useMemo } from "react"; import { useSearchParams } from "react-router"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -29,91 +29,10 @@ import { } from "@gram/client/react-query/index.js"; import { useSdkClient } from "@/contexts/Sdk"; import { toast } from "sonner"; -import { - DETECTION_RULES, - RULE_CATEGORY_META, - type RuleCategory, -} from "./policy-data"; -import { humanizeRuleId } from "./rule-ids"; import { ChatDetailPanel } from "@/pages/chatLogs/ChatDetailPanel"; import { Drawer, DrawerContent } from "@/components/ui/drawer"; import { MetricCard } from "@/components/chart/MetricCard"; -import { Button as MoonshineButton, Icon } from "@speakeasy-api/moonshine"; - -// DETECTION_RULES.id is the canonical rule_id the backend writes to -// risk_results, so lookup maps key by it directly. -const RULE_ID_TO_CATEGORY = new Map(); -const RULE_ID_TO_TITLE = new Map(); -for (const [category, rules] of Object.entries(DETECTION_RULES)) { - for (const rule of rules) { - RULE_ID_TO_CATEGORY.set(rule.id, category as RuleCategory); - RULE_ID_TO_TITLE.set(rule.id, rule.title); - } -} - -const SOURCE_TO_CATEGORY = new Map([ - ["destructive_tool", "destructive_tool"], - ["shadow_mcp", "shadow_mcp"], - ["prompt_injection", "prompt_injection"], - ["cli_destructive", "cli_destructive"], -]); - -function getCategoryForFinding( - source: string | undefined, - ruleId: string | undefined, -): RuleCategory | null { - if (ruleId) { - const byRule = RULE_ID_TO_CATEGORY.get(ruleId); - if (byRule) return byRule; - } - if (source) { - return SOURCE_TO_CATEGORY.get(source) ?? null; - } - return null; -} - -function getRuleTitleFallback(ruleId: string | undefined): string { - if (!ruleId) return "-"; - return RULE_ID_TO_TITLE.get(ruleId) ?? humanizeRuleId(ruleId); -} - -function CategoryLabel({ - source, - ruleId, -}: { - source?: string; - ruleId?: string; -}) { - const category = getCategoryForFinding(source, ruleId); - if (category) { - return ( - - {RULE_CATEGORY_META[category].label} - - ); - } - // Unknown source: title-case it so the table cell still reads cleanly - // (e.g. a future "presidio_pro" source renders as "Presidio Pro"). - return ( - - {source ? humanizeRuleId(source.replace(/_/g, "-")) : "-"} - - ); -} - -// Renders a rule id with a tooltip-quality fallback when the dashboard -// hasn't seen this rule before. The backend may roll out new gitleaks, -// presidio, or prompt_injection rules independently of the dashboard, so -// every snake_case id needs to display legibly without a code change. -function RuleLabel({ ruleId }: { source?: string; ruleId?: string }) { - if (!ruleId) return -; - const title = getRuleTitleFallback(ruleId); - return ( - - {title} - - ); -} +import { CategoryLabel, MaskedMatch, RuleLabel } from "./risk-finding-ui"; export default function SecurityOverview() { return ( @@ -130,48 +49,6 @@ export default function SecurityOverview() { ); } -function MaskedMatch({ value }: { value: string | undefined }) { - const [revealed, setRevealed] = useState(false); - - if (!value) return -; - - if (!revealed) { - return ( - - ); - } - - return ( - - - {value.length > 40 - ? `${value.slice(0, 20)}...${value.slice(-10)}` - : value} - - - - ); -} - function SecurityOverviewContent() { const routes = useRoutes(); const client = useSdkClient(); @@ -315,15 +192,15 @@ function SecurityOverviewContent() { project - routes.policyCenter.goTo()} > - Manage Policies - + Manage Policies + - - + +
@@ -363,15 +240,15 @@ function SecurityOverviewContent() { - routes.policyCenter.goTo()} > - Manage Policies - + Manage Policies + - - + + @@ -439,7 +316,7 @@ function SecurityOverviewContent() { {chatSummaryQuery.hasNextPage && (
+ ); + } + + return ( + + + {value.length > 40 + ? `${value.slice(0, 20)}...${value.slice(-10)}` + : value} + + + + ); +} diff --git a/client/dashboard/src/pages/security/risk-finding-utils.ts b/client/dashboard/src/pages/security/risk-finding-utils.ts new file mode 100644 index 0000000000..8c66687769 --- /dev/null +++ b/client/dashboard/src/pages/security/risk-finding-utils.ts @@ -0,0 +1,41 @@ +import { DETECTION_RULES, type RuleCategory } from "./policy-data"; +import { humanizeRuleId } from "./rule-ids"; + +// DETECTION_RULES.id is the canonical rule_id the backend writes to +// risk_results, so lookup maps key by it directly. +export const RULE_ID_TO_CATEGORY = new Map(); + +export const RULE_ID_TO_TITLE = new Map(); + +for (const [category, rules] of Object.entries(DETECTION_RULES)) { + for (const rule of rules) { + RULE_ID_TO_CATEGORY.set(rule.id, category as RuleCategory); + RULE_ID_TO_TITLE.set(rule.id, rule.title); + } +} + +export function getRuleTitleFallback(ruleId: string | undefined): string { + if (!ruleId) return "-"; + return RULE_ID_TO_TITLE.get(ruleId) ?? humanizeRuleId(ruleId); +} + +export const SOURCE_TO_CATEGORY = new Map([ + ["destructive_tool", "destructive_tool"], + ["shadow_mcp", "shadow_mcp"], + ["prompt_injection", "prompt_injection"], + ["cli_destructive", "cli_destructive"], +]); + +export function getCategoryForFinding( + source: string | undefined, + ruleId: string | undefined, +): RuleCategory | null { + if (ruleId) { + const byRule = RULE_ID_TO_CATEGORY.get(ruleId); + if (byRule) return byRule; + } + if (source) { + return SOURCE_TO_CATEGORY.get(source) ?? null; + } + return null; +} diff --git a/client/dashboard/src/routes.tsx b/client/dashboard/src/routes.tsx index 4641ad1f9a..0e649667c7 100644 --- a/client/dashboard/src/routes.tsx +++ b/client/dashboard/src/routes.tsx @@ -71,6 +71,7 @@ import SlackAppsIndex, { SlackAppsRoot } from "./pages/slackapp/SlackApp"; import TriggersIndex, { TriggersRoot } from "./pages/triggers/Triggers"; import SlackAppDetailPage from "./pages/slackapp/SlackAppDetail"; import SecurityOverview from "./pages/security/SecurityOverview"; +import RiskActivity from "./pages/security/RiskActivity"; import PolicyCenter from "./pages/security/PolicyCenter"; import Team from "./pages/team/Team"; import SourceDetails from "./pages/sources/SourceDetails"; @@ -441,6 +442,12 @@ const ROUTE_STRUCTURE = { stage: "beta", component: SecurityOverview, }, + riskActivity: { + title: "Risk Activity", + url: "risk-activity", + icon: "shield", + component: RiskActivity, + }, policyCenter: { title: "Risk Policies", url: "risk-policies", From bca134cf88c50c190d02ff8eaadcc68da7972334 Mon Sep 17 00:00:00 2001 From: Alex Martin Date: Fri, 15 May 2026 14:52:13 -0700 Subject: [PATCH 02/18] chore(dashboard): generalize risk shared files --- client/dashboard/src/pages/security/RiskActivity.tsx | 2 +- .../src/pages/security/{risk-finding-ui.tsx => risk-ui.tsx} | 2 +- .../src/pages/security/{risk-finding-utils.ts => risk-utils.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename client/dashboard/src/pages/security/{risk-finding-ui.tsx => risk-ui.tsx} (96%) rename client/dashboard/src/pages/security/{risk-finding-utils.ts => risk-utils.ts} (100%) diff --git a/client/dashboard/src/pages/security/RiskActivity.tsx b/client/dashboard/src/pages/security/RiskActivity.tsx index e0e02041e8..7ea3801baf 100644 --- a/client/dashboard/src/pages/security/RiskActivity.tsx +++ b/client/dashboard/src/pages/security/RiskActivity.tsx @@ -25,7 +25,7 @@ import { RefreshCw, ShieldOff } from "lucide-react"; import { useCallback, useMemo, useRef } from "react"; import { useSearchParams } from "react-router"; import { toast } from "sonner"; -import { CategoryLabel, MaskedMatch } from "./risk-finding-ui"; +import { CategoryLabel, MaskedMatch } from "./risk-ui"; export default function RiskActivity() { return ( diff --git a/client/dashboard/src/pages/security/risk-finding-ui.tsx b/client/dashboard/src/pages/security/risk-ui.tsx similarity index 96% rename from client/dashboard/src/pages/security/risk-finding-ui.tsx rename to client/dashboard/src/pages/security/risk-ui.tsx index 3cb04061af..d07f998fee 100644 --- a/client/dashboard/src/pages/security/risk-finding-ui.tsx +++ b/client/dashboard/src/pages/security/risk-ui.tsx @@ -1,7 +1,7 @@ import { Eye, EyeOff } from "lucide-react"; import { useState } from "react"; import { RULE_CATEGORY_META } from "./policy-data"; -import { getCategoryForFinding, getRuleTitleFallback } from "./risk-finding-utils"; +import { getCategoryForFinding, getRuleTitleFallback} from "./risk-utils"; import { humanizeRuleId } from "./rule-ids"; export function CategoryLabel({ diff --git a/client/dashboard/src/pages/security/risk-finding-utils.ts b/client/dashboard/src/pages/security/risk-utils.ts similarity index 100% rename from client/dashboard/src/pages/security/risk-finding-utils.ts rename to client/dashboard/src/pages/security/risk-utils.ts From f003413015bd47ec852b5bc6432b5b57e6732dfa Mon Sep 17 00:00:00 2001 From: Alex Martin Date: Fri, 15 May 2026 14:54:31 -0700 Subject: [PATCH 03/18] fix(dashboard): polish risk activity timestamp --- client/dashboard/src/pages/security/RiskActivity.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/dashboard/src/pages/security/RiskActivity.tsx b/client/dashboard/src/pages/security/RiskActivity.tsx index 7ea3801baf..f89945ec0e 100644 --- a/client/dashboard/src/pages/security/RiskActivity.tsx +++ b/client/dashboard/src/pages/security/RiskActivity.tsx @@ -264,7 +264,7 @@ function RiskActivityContent() { function RiskActivityHeader() { return (
-
Occurred
+
Timestamp
Category
Rule
Chat
@@ -378,7 +378,7 @@ function RiskActivityRow({ } }} > -
+
{result.createdAt ? new Date(result.createdAt).toLocaleString() : "-"}
From 055b5d712454ebcaacdf521b7981d59bd8f95a6e Mon Sep 17 00:00:00 2001 From: Alex Martin Date: Fri, 15 May 2026 15:06:04 -0700 Subject: [PATCH 04/18] fix(dashboard): adjust risk activity table spacing --- .../src/pages/security/RiskActivity.tsx | 41 ++++++++----------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/client/dashboard/src/pages/security/RiskActivity.tsx b/client/dashboard/src/pages/security/RiskActivity.tsx index f89945ec0e..d50fb8843f 100644 --- a/client/dashboard/src/pages/security/RiskActivity.tsx +++ b/client/dashboard/src/pages/security/RiskActivity.tsx @@ -264,14 +264,14 @@ function RiskActivityContent() { function RiskActivityHeader() { return (
-
Timestamp
-
Category
-
Rule
-
Chat
-
User
-
Match
-
Policy Note
-
Actions
+
Timestamp
+
Category
+
Rule
+
Session Name
+
User
+
Match
+
Policy Note
+
Actions
); } @@ -378,22 +378,20 @@ function RiskActivityRow({ } }} > -
+
{result.createdAt ? new Date(result.createdAt).toLocaleString() : "-"}
-
+
-
+
{result.ruleId ?? "-"}
-
+
{result.chatTitle ?? "Untitled"}
-
- {result.userId ?? "-"} -
-
+
{result.userId ?? "-"}
+
{isShadowMCP && result.match ? ( {result.match} @@ -402,16 +400,13 @@ function RiskActivityRow({ )}
-
+
{policyNote ?? "-"}
-
+
{isShadowMCP && result.match ? ( ) : null} From aa92908fb7c9efc9e8d16bce84738627b3062451 Mon Sep 17 00:00:00 2001 From: Alex Martin Date: Fri, 15 May 2026 15:11:00 -0700 Subject: [PATCH 05/18] fix(dashboard): make risk activity columns responsive --- .../src/pages/security/RiskActivity.tsx | 47 +++++++++++-------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/client/dashboard/src/pages/security/RiskActivity.tsx b/client/dashboard/src/pages/security/RiskActivity.tsx index d50fb8843f..c927d74a36 100644 --- a/client/dashboard/src/pages/security/RiskActivity.tsx +++ b/client/dashboard/src/pages/security/RiskActivity.tsx @@ -27,6 +27,9 @@ import { useSearchParams } from "react-router"; import { toast } from "sonner"; import { CategoryLabel, MaskedMatch } from "./risk-ui"; +const RISK_ACTIVITY_GRID = + "grid grid-cols-[172px_minmax(140px,0.9fr)_minmax(160px,1fr)_minmax(180px,1.15fr)_minmax(160px,1fr)_minmax(200px,1.25fr)_minmax(180px,1.1fr)_110px] gap-3"; + export default function RiskActivity() { return ( @@ -263,15 +266,20 @@ function RiskActivityContent() { function RiskActivityHeader() { return ( -
-
Timestamp
-
Category
-
Rule
-
Session Name
-
User
-
Match
-
Policy Note
-
Actions
+
+
Timestamp
+
Category
+
Rule
+
Session Name
+
User
+
Match
+
Policy Note
+
Actions
); } @@ -369,7 +377,8 @@ function RiskActivityRow({ ) : null}
- +
); } diff --git a/client/dashboard/src/pages/security/risk-ui.tsx b/client/dashboard/src/pages/security/risk-ui.tsx index cc5d374e76..45f9ba22ba 100644 --- a/client/dashboard/src/pages/security/risk-ui.tsx +++ b/client/dashboard/src/pages/security/risk-ui.tsx @@ -15,7 +15,7 @@ export function CategoryLabel({ const label = category ? RULE_CATEGORY_META[category].label : null; const shortLabel = category === "pii" ? "PII" : null; return ( - + {shortLabel ? ( From 195b17fa20f8ee030bbae34fd456af6fe898650a Mon Sep 17 00:00:00 2001 From: Alex Martin Date: Fri, 15 May 2026 15:41:52 -0700 Subject: [PATCH 09/18] fix(dashboard): use moonshine risk activity buttons --- client/dashboard/src/pages/security/RiskActivity.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/client/dashboard/src/pages/security/RiskActivity.tsx b/client/dashboard/src/pages/security/RiskActivity.tsx index 5ccadebb12..453d9b1d0d 100644 --- a/client/dashboard/src/pages/security/RiskActivity.tsx +++ b/client/dashboard/src/pages/security/RiskActivity.tsx @@ -1,7 +1,6 @@ import { LogWorkbench } from "@/components/log-workbench"; import { Page } from "@/components/page-layout"; import { RequireScope } from "@/components/require-scope"; -import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent } from "@/components/ui/drawer"; import { Select, @@ -19,7 +18,7 @@ import { useRiskApproveShadowMCPMutation, useRiskListPolicies, } from "@gram/client/react-query/index.js"; -import { Icon } from "@speakeasy-api/moonshine"; +import { Button, Icon } from "@speakeasy-api/moonshine"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { RefreshCw, ShieldOff } from "lucide-react"; import { useCallback, useMemo, useRef } from "react"; @@ -178,7 +177,7 @@ function RiskActivityContent() { description="Review policy findings across recent analyzed chats." actions={
); diff --git a/client/dashboard/src/components/page-header.tsx b/client/dashboard/src/components/page-header.tsx index 30bdfbd597..b60e3540b7 100644 --- a/client/dashboard/src/components/page-header.tsx +++ b/client/dashboard/src/components/page-header.tsx @@ -71,7 +71,7 @@ const breadcrumbSubstitutions = { "audit-logs": "Audit Logs", "admin-settings": "Admin Settings", "risk-overview": "Risk Overview", - "risk-activity": "Risk Activity", + "risk-events": "Risk Events", "risk-policies": "Risk Policies", // The URL segments `slack` and `clis` are preserved for backwards // compatibility, but the sidebar/route titles were rebranded — map them diff --git a/client/dashboard/src/pages/logs/Logs.tsx b/client/dashboard/src/pages/logs/Logs.tsx index 12be8cc4d5..a6cc181d67 100644 --- a/client/dashboard/src/pages/logs/Logs.tsx +++ b/client/dashboard/src/pages/logs/Logs.tsx @@ -4,6 +4,7 @@ import { Page } from "@/components/page-layout"; import { RequireScope } from "@/components/require-scope"; import { LogsTools } from "@/components/observe/LogsTools"; import { ObserveTabNav } from "@/components/observe/ObserveTabNav"; +import RiskEvents from "@/pages/security/RiskEvents"; export function LogsRoot() { return ( @@ -37,3 +38,11 @@ export function LogsMCPPage() { ); } + +export function LogsRiskEventsPage() { + return ( + + + + ); +} diff --git a/client/dashboard/src/pages/security/RiskActivity.tsx b/client/dashboard/src/pages/security/RiskEvents.tsx similarity index 92% rename from client/dashboard/src/pages/security/RiskActivity.tsx rename to client/dashboard/src/pages/security/RiskEvents.tsx index e46dca55b3..e22c8d013f 100644 --- a/client/dashboard/src/pages/security/RiskActivity.tsx +++ b/client/dashboard/src/pages/security/RiskEvents.tsx @@ -1,6 +1,4 @@ import { LogWorkbench } from "@/components/log-workbench"; -import { Page } from "@/components/page-layout"; -import { RequireScope } from "@/components/require-scope"; import { Drawer, DrawerContent } from "@/components/ui/drawer"; import { Select, @@ -26,25 +24,10 @@ import { useSearchParams } from "react-router"; import { toast } from "sonner"; import { CategoryLabel, MaskedMatch, RuleLabel } from "./risk-ui"; -const RISK_ACTIVITY_GRID = +const RISK_EVENTS_GRID = "grid grid-cols-[172px_minmax(0,0.9fr)_minmax(0,1fr)_minmax(0,1.15fr)_minmax(0,1fr)_minmax(0,1.25fr)_minmax(0,1.1fr)_110px] gap-3"; -export default function RiskActivity() { - return ( - - - - - - - - - - - ); -} - -function RiskActivityContent() { +export default function RiskEvents() { const client = useSdkClient(); const queryClient = useQueryClient(); const [searchParams, setSearchParams] = useSearchParams(); @@ -173,7 +156,7 @@ function RiskActivityContent() { return ( resultsQuery.refetch()} disabled={resultsQuery.isFetching} - aria-label="Refresh risk activity" + aria-label="Refresh risk events" > ) : null } - header={} + header={} footer={ results.length > 0 ? ( - - @@ -283,7 +266,7 @@ function RiskActivityHeader() { ); } -function RiskActivityRows({ +function RiskEventsRows({ error, isLoading, results, @@ -307,7 +290,7 @@ function RiskActivityRows({
- Error loading risk activity + Error loading risk events {error.message} @@ -320,7 +303,7 @@ function RiskActivityRows({ return (
- Loading risk activity... + Loading risk events...
); } @@ -332,7 +315,7 @@ function RiskActivityRows({
- No risk activity found + No risk events found Findings will appear here as messages are analyzed. @@ -344,7 +327,7 @@ function RiskActivityRows({ return ( <> {results.map((result) => ( - Date: Mon, 18 May 2026 15:53:50 -0700 Subject: [PATCH 14/18] fix: invalidate generated risk result queries --- client/dashboard/src/pages/security/RiskEvents.tsx | 2 ++ client/dashboard/src/pages/security/SecurityOverview.tsx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/client/dashboard/src/pages/security/RiskEvents.tsx b/client/dashboard/src/pages/security/RiskEvents.tsx index e22c8d013f..512f35d4e1 100644 --- a/client/dashboard/src/pages/security/RiskEvents.tsx +++ b/client/dashboard/src/pages/security/RiskEvents.tsx @@ -12,6 +12,7 @@ import { cn } from "@/lib/utils"; import { ChatDetailPanel } from "@/pages/chatLogs/ChatDetailPanel"; import type { RiskResult } from "@gram/client/models/components"; import { + invalidateAllRiskListResults, invalidateAllRiskListShadowMCPApprovals, useRiskApproveShadowMCPMutation, useRiskListPolicies, @@ -127,6 +128,7 @@ export default function RiskEvents() { queryClient.invalidateQueries({ queryKey: ["risk", "results", "list"], }); + invalidateAllRiskListResults(queryClient); invalidateAllRiskListShadowMCPApprovals(queryClient); }, onError: (err) => { diff --git a/client/dashboard/src/pages/security/SecurityOverview.tsx b/client/dashboard/src/pages/security/SecurityOverview.tsx index 5504ba6801..542587955d 100644 --- a/client/dashboard/src/pages/security/SecurityOverview.tsx +++ b/client/dashboard/src/pages/security/SecurityOverview.tsx @@ -23,6 +23,7 @@ import { useCallback, useMemo } from "react"; import { useSearchParams } from "react-router"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { + invalidateAllRiskListResults, useRiskApproveShadowMCPMutation, useRiskListPolicies, invalidateAllRiskListShadowMCPApprovals, @@ -118,6 +119,7 @@ function SecurityOverviewContent() { queryClient.invalidateQueries({ queryKey: ["risk", "results", "list"], }); + invalidateAllRiskListResults(queryClient); invalidateAllRiskListShadowMCPApprovals(queryClient); }, onError: (err) => { From 8a6869756d68946287bdbee2abf0ef0d036da8fc Mon Sep 17 00:00:00 2001 From: Alex Martin Date: Mon, 18 May 2026 15:54:49 -0700 Subject: [PATCH 15/18] perf: virtualize risk event rows --- .../src/pages/security/RiskEvents.tsx | 52 ++++++++++++++----- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/client/dashboard/src/pages/security/RiskEvents.tsx b/client/dashboard/src/pages/security/RiskEvents.tsx index 512f35d4e1..eca79dafb2 100644 --- a/client/dashboard/src/pages/security/RiskEvents.tsx +++ b/client/dashboard/src/pages/security/RiskEvents.tsx @@ -19,8 +19,9 @@ import { } from "@gram/client/react-query/index.js"; import { Button, Icon } from "@speakeasy-api/moonshine"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { RefreshCw, ShieldOff } from "lucide-react"; -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useMemo, useRef, type RefObject } from "react"; import { useSearchParams } from "react-router"; import { toast } from "sonner"; import { CategoryLabel, MaskedMatch, RuleLabel } from "./risk-ui"; @@ -241,6 +242,7 @@ export default function RiskEvents() { results={results} policyMessageById={policyMessageById} isExcluding={approveMutation.isPending} + scrollRef={containerRef} onSelectChat={setSelectedChatId} onExclude={handleExclude} /> @@ -274,6 +276,7 @@ function RiskEventsRows({ results, policyMessageById, isExcluding, + scrollRef, onSelectChat, onExclude, }: { @@ -282,9 +285,17 @@ function RiskEventsRows({ results: RiskResult[]; policyMessageById: Map; isExcluding: boolean; + scrollRef: RefObject; onSelectChat: (chatId: string | null) => void; onExclude: (policyId: string, match: string, serverName?: string) => void; }) { + const rowVirtualizer = useVirtualizer({ + count: results.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 52, + overscan: 12, + }); + if (error) { return (
@@ -327,18 +338,33 @@ function RiskEventsRows({ } return ( - <> - {results.map((result) => ( - - ))} - +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const result = results[virtualRow.index]; + if (!result) return null; + + return ( +
+ +
+ ); + })} +
); } From 9ce8b2cc42a259018121c57e102617f0e408cc3a Mon Sep 17 00:00:00 2001 From: Alex Martin Date: Mon, 18 May 2026 15:55:39 -0700 Subject: [PATCH 16/18] fix: allow horizontal risk event scrolling --- client/dashboard/src/components/log-workbench.tsx | 9 ++++++++- client/dashboard/src/pages/security/RiskEvents.tsx | 10 ++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/client/dashboard/src/components/log-workbench.tsx b/client/dashboard/src/components/log-workbench.tsx index 454f78ff72..c336290156 100644 --- a/client/dashboard/src/components/log-workbench.tsx +++ b/client/dashboard/src/components/log-workbench.tsx @@ -13,6 +13,7 @@ export interface LogWorkbenchProps { detail?: React.ReactNode; onScroll?: React.UIEventHandler; scrollRef?: React.Ref; + surfaceClassName?: string; contentClassName?: string; className?: string; } @@ -29,6 +30,7 @@ export function LogWorkbench({ detail, onScroll, scrollRef, + surfaceClassName, contentClassName, className, }: LogWorkbenchProps) { @@ -53,7 +55,12 @@ export function LogWorkbench({
-
+
{status} {header}
) : null } - header={} + header={ +
+ +
+ } footer={ results.length > 0 ? (
Timestamp
From 955f0aa22c3691d73b7576f4b725205b4a5a168b Mon Sep 17 00:00:00 2001 From: Alex Martin Date: Mon, 18 May 2026 16:12:22 -0700 Subject: [PATCH 17/18] fix: use moonshine button slots --- .../src/pages/security/RiskEvents.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/client/dashboard/src/pages/security/RiskEvents.tsx b/client/dashboard/src/pages/security/RiskEvents.tsx index 149e773144..698b7f3c8a 100644 --- a/client/dashboard/src/pages/security/RiskEvents.tsx +++ b/client/dashboard/src/pages/security/RiskEvents.tsx @@ -169,10 +169,15 @@ export default function RiskEvents() { disabled={resultsQuery.isFetching} aria-label="Refresh risk events" > - - Refresh + + + + Refresh } filters={ @@ -446,8 +451,10 @@ function RiskEventsRow({ }} title="Exclude this MCP server from the policy" > - - Exclude + + + + Exclude ) : null}
From ab0f1605b26aa689de8de1e16b3ae2907d948b43 Mon Sep 17 00:00:00 2001 From: Alex Martin Date: Mon, 18 May 2026 16:16:37 -0700 Subject: [PATCH 18/18] docs(changeset): Add a beta Risk Events log under Logs for reviewing and managing policy-flagged or blocked findings. --- .changeset/dark-webs-shave.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dark-webs-shave.md diff --git a/.changeset/dark-webs-shave.md b/.changeset/dark-webs-shave.md new file mode 100644 index 0000000000..a552c56446 --- /dev/null +++ b/.changeset/dark-webs-shave.md @@ -0,0 +1,5 @@ +--- +"dashboard": minor +--- + +Add a beta Risk Events log under Logs for reviewing and managing policy-flagged or blocked findings.