Skip to content

Commit a8e76a3

Browse files
authored
Focus operator graph views (#1335)
## Summary - focus analyze graph surfaces on scoped, readable defaults instead of all-at-once layouts - make legends data-driven, improve mesh/context/operator readability, and clean up graph chrome - merge blast-radius enrichment into vulnerabilities and remove remediation fix duplication ## Validation - npm --prefix ui run test:run - npm --prefix ui run build Validated in the main workspace with installed UI dependencies. The clean worktree used for branch packaging does not have local node_modules.
1 parent 439efcd commit a8e76a3

15 files changed

Lines changed: 797 additions & 242 deletions

File tree

ui/app/context/page.tsx

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,17 @@ import {
4040
type LateralPath,
4141
type InteractionRisk,
4242
} from "@/lib/context-graph";
43-
import { CONTROLS_CLASS, MINIMAP_CLASS, BACKGROUND_COLOR, BACKGROUND_GAP, minimapNodeColor } from "@/lib/graph-utils";
43+
import {
44+
CONTROLS_CLASS,
45+
MINIMAP_BG,
46+
MINIMAP_CLASS,
47+
MINIMAP_MASK,
48+
BACKGROUND_COLOR,
49+
BACKGROUND_GAP,
50+
legendItemsForVisibleNodes,
51+
minimapNodeColor,
52+
} from "@/lib/graph-utils";
4453
import { FullscreenButton, GraphLegend } from "@/components/graph-chrome";
45-
import { CONTEXT_LEGEND } from "@/lib/graph-utils";
4654

4755
// ─── Stats Bar ──────────────────────────────────────────────────────────────
4856

@@ -84,7 +92,10 @@ function LateralPanel({
8492
const filtered = selectedAgent
8593
? paths.filter((p) => p.source === `agent:${selectedAgent}`)
8694
: paths;
87-
const top10 = filtered.slice(0, 10);
95+
const distinctPaths = Array.from(
96+
new Map(filtered.map((path) => [`${path.hops.join("->")}::${path.vuln_ids.join(",")}`, path])).values(),
97+
);
98+
const topPaths = distinctPaths.slice(0, 6);
8899

89100
return (
90101
<div className="w-72 border-l border-zinc-800 overflow-y-auto bg-zinc-950">
@@ -93,11 +104,11 @@ function LateralPanel({
93104
<h3 className="text-xs font-semibold text-zinc-300 mb-2">
94105
Lateral Movement{selectedAgent ? ` from ${selectedAgent}` : ""}
95106
</h3>
96-
{top10.length === 0 ? (
107+
{topPaths.length === 0 ? (
97108
<p className="text-[10px] text-zinc-600">No lateral paths found</p>
98109
) : (
99110
<div className="space-y-2">
100-
{top10?.map((p, i) => (
111+
{topPaths?.map((p, i) => (
101112
<div
102113
key={i}
103114
className="bg-zinc-900 border border-zinc-800 rounded-lg p-2"
@@ -231,6 +242,24 @@ export default function ContextPage() {
231242
.finally(() => setLoading(false));
232243
}, []);
233244

245+
const activeJob = useMemo(
246+
() => jobs.find((job) => job.job_id === selectedJobId) ?? null,
247+
[jobs, selectedJobId],
248+
);
249+
250+
const agentNames = useMemo(
251+
() => activeJob?.result?.agents.map((agent) => agent.name).sort((left, right) => left.localeCompare(right)) ?? [],
252+
[activeJob],
253+
);
254+
255+
useEffect(() => {
256+
if (agentNames.length === 0) {
257+
setSelectedAgent(null);
258+
return;
259+
}
260+
setSelectedAgent((current) => (current && agentNames.includes(current) ? current : agentNames[0]));
261+
}, [agentNames]);
262+
234263
// Fetch context graph when job changes
235264
useEffect(() => {
236265
if (!selectedJobId) return;
@@ -310,6 +339,14 @@ export default function ContextPage() {
310339
}));
311340
}, [layoutEdges, connectedIds, searchMatches]);
312341

342+
const legendItems = useMemo(() => {
343+
const extras =
344+
displayEdges.some((edge) => edge.animated || Boolean(edge.style?.strokeDasharray))
345+
? [{ label: "Lateral", color: "#f97316", dashed: true, shape: "diamond" as const }]
346+
: [];
347+
return legendItemsForVisibleNodes(displayNodes, extras);
348+
}, [displayEdges, displayNodes]);
349+
313350
const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
314351
setSelectedNode(node.data as LineageNodeData);
315352
setHoveredNodeId(null);
@@ -326,14 +363,6 @@ export default function ContextPage() {
326363
setHoveredNodeId(null);
327364
}, []);
328365

329-
// Agent names for the selector
330-
const agentNames = useMemo(() => {
331-
if (!graphData) return [];
332-
return graphData.nodes
333-
.filter((n) => n.kind === "agent")
334-
.map((n) => n.label);
335-
}, [graphData]);
336-
337366
if (loading) {
338367
return (
339368
<div className="flex items-center justify-center h-[80vh] text-zinc-400">
@@ -377,7 +406,7 @@ export default function ContextPage() {
377406
Context Graph
378407
</h1>
379408
<p className="text-xs text-zinc-500">
380-
Agent interaction graph — lateral movement analysis
409+
Focused lateral-movement map for one agent scope at a time
381410
</p>
382411
</div>
383412
<div className="flex items-center gap-3">
@@ -418,13 +447,13 @@ export default function ContextPage() {
418447
type="text"
419448
value={searchQuery}
420449
onChange={(e) => setSearchQuery(e.target.value)}
421-
placeholder="Search nodes..."
450+
placeholder="Search agent, server, tool, or CVE"
422451
className="w-full bg-zinc-900 border border-zinc-700 rounded pl-7 pr-2 py-1.5 text-xs text-zinc-300 placeholder:text-zinc-600 focus:outline-none focus:border-emerald-600"
423452
/>
424453
</div>
425454

426455
<FullscreenButton />
427-
<GraphLegend items={CONTEXT_LEGEND} />
456+
<GraphLegend items={legendItems} />
428457
</div>
429458
</div>
430459

@@ -446,8 +475,10 @@ export default function ContextPage() {
446475
edges={displayEdges}
447476
nodeTypes={lineageNodeTypes}
448477
fitView
449-
minZoom={0.05}
478+
fitViewOptions={{ padding: 0.18, maxZoom: 1.05 }}
479+
minZoom={0.16}
450480
maxZoom={2.5}
481+
onlyRenderVisibleElements
451482
defaultEdgeOptions={{ type: "smoothstep" }}
452483
proOptions={{ hideAttribution: true }}
453484
onNodeClick={onNodeClick}
@@ -460,7 +491,12 @@ export default function ContextPage() {
460491
>
461492
<Background color={BACKGROUND_COLOR} gap={BACKGROUND_GAP} />
462493
<Controls className={CONTROLS_CLASS} />
463-
<MiniMap nodeColor={minimapNodeColor} className={MINIMAP_CLASS} />
494+
<MiniMap
495+
nodeColor={minimapNodeColor}
496+
className={MINIMAP_CLASS}
497+
bgColor={MINIMAP_BG}
498+
maskColor={MINIMAP_MASK}
499+
/>
464500
</ReactFlow>
465501
)}
466502

ui/app/graph/page.tsx

Lines changed: 71 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,13 @@ import { AlertTriangle, Loader2, ShieldAlert } from "lucide-react";
1515
import { AttackPathCard } from "@/components/attack-path-card";
1616
import { GraphLegend, FullscreenButton } from "@/components/graph-chrome";
1717
import { LineageDetailPanel } from "@/components/lineage-detail";
18-
import { FilterPanel, DEFAULT_FILTERS, type FilterState } from "@/components/lineage-filter";
18+
import {
19+
FilterPanel,
20+
DEFAULT_FILTERS,
21+
createExpandedGraphFilters,
22+
createFocusedGraphFilters,
23+
type FilterState,
24+
} from "@/components/lineage-filter";
1925
import { lineageNodeTypes, type LineageNodeData, type LineageNodeType } from "@/components/lineage-nodes";
2026
import { applyDagreLayout } from "@/lib/dagre-layout";
2127
import {
@@ -28,7 +34,9 @@ import {
2834
BACKGROUND_COLOR,
2935
BACKGROUND_GAP,
3036
CONTROLS_CLASS,
37+
MINIMAP_BG,
3138
MINIMAP_CLASS,
39+
MINIMAP_MASK,
3240
minimapNodeColor,
3341
} from "@/lib/graph-utils";
3442
import {
@@ -328,6 +336,7 @@ export default function GraphPage() {
328336
const [searching, setSearching] = useState(false);
329337
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
330338
const [filters, setFilters] = useState<FilterState>(DEFAULT_FILTERS);
339+
const [initializedFocus, setInitializedFocus] = useState(false);
331340

332341
useEffect(() => {
333342
setLoadingSnapshots(true);
@@ -372,6 +381,8 @@ export default function GraphPage() {
372381
setSearchResults([]);
373382
setSearchQuery("");
374383
setSelectedAttackPathKey(null);
384+
setFilters(DEFAULT_FILTERS);
385+
setInitializedFocus(false);
375386
}, [selectedScanId]);
376387

377388
useEffect(() => {
@@ -485,6 +496,12 @@ export default function GraphPage() {
485496
return buildUnifiedFlowGraph(graphData, filters);
486497
}, [graphData, filters]);
487498

499+
useEffect(() => {
500+
if (initializedFocus || flow.agentNames.length === 0) return;
501+
setFilters(createFocusedGraphFilters(flow.agentNames[0]));
502+
setInitializedFocus(true);
503+
}, [flow.agentNames, initializedFocus]);
504+
488505
const flowNodeDataById = useMemo(
489506
() => new Map(flow.nodes.map((node) => [node.id, node.data])),
490507
[flow.nodes],
@@ -494,14 +511,14 @@ export default function GraphPage() {
494511
() =>
495512
flow.nodes.length > 0
496513
? applyDagreLayout(flow.nodes, flow.edges, {
497-
direction: "LR",
498-
nodeWidth: 180,
499-
nodeHeight: 64,
500-
rankSep: 110,
501-
nodeSep: 28,
514+
direction: filters.agentName || filters.vulnOnly ? "TB" : "LR",
515+
nodeWidth: filters.agentName ? 208 : 188,
516+
nodeHeight: 72,
517+
rankSep: filters.agentName ? 118 : 104,
518+
nodeSep: filters.agentName ? 22 : 28,
502519
})
503520
: { nodes: [], edges: [] },
504-
[flow.nodes, flow.edges],
521+
[flow.nodes, flow.edges, filters.agentName, filters.vulnOnly],
505522
);
506523

507524
const connectedIds = useMemo(
@@ -680,7 +697,7 @@ export default function GraphPage() {
680697
<p className="text-[10px] uppercase tracking-[0.24em] text-sky-400">Unified graph</p>
681698
<h1 className="mt-1 text-lg font-semibold text-zinc-100">Security Graph</h1>
682699
<p className="text-xs text-zinc-500">
683-
Provider → Agent → Server → Package → Findings · Models · Datasets · Containers · Cloud resources · Runtime edges
700+
Focused agent-to-finding graph with packages, credentials, tools, and runtime links.
684701
</p>
685702
</div>
686703

@@ -735,6 +752,37 @@ export default function GraphPage() {
735752
</button>
736753
</form>
737754

755+
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px]">
756+
<button
757+
type="button"
758+
onClick={() => setFilters(createFocusedGraphFilters(filters.agentName ?? flow.agentNames[0] ?? null))}
759+
className="rounded-lg border border-sky-500/30 bg-sky-500/10 px-2.5 py-1 text-sky-200 transition hover:border-sky-400/60"
760+
>
761+
Focused view
762+
</button>
763+
<button
764+
type="button"
765+
onClick={() => setFilters(createExpandedGraphFilters(null))}
766+
className="rounded-lg border border-zinc-700 bg-zinc-900/80 px-2.5 py-1 text-zinc-300 transition hover:border-zinc-500 hover:text-zinc-100"
767+
>
768+
Expanded view
769+
</button>
770+
<span className="rounded-lg border border-zinc-800 bg-zinc-900/80 px-2.5 py-1 text-zinc-400">
771+
{filters.agentName ? `agent ${filters.agentName}` : "all agents"}
772+
</span>
773+
<span className="rounded-lg border border-zinc-800 bg-zinc-900/80 px-2.5 py-1 text-zinc-400">
774+
{filters.severity ? `${filters.severity}+` : "all severities"}
775+
</span>
776+
<span className="rounded-lg border border-zinc-800 bg-zinc-900/80 px-2.5 py-1 text-zinc-400">
777+
depth {filters.maxDepth}
778+
</span>
779+
{filters.vulnOnly && (
780+
<span className="rounded-lg border border-emerald-500/20 bg-emerald-500/10 px-2.5 py-1 text-emerald-200">
781+
vulnerable only
782+
</span>
783+
)}
784+
</div>
785+
738786
{searchResults.length > 0 && (
739787
<div className="mt-2 rounded-2xl border border-zinc-800 bg-zinc-950/90 p-2">
740788
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
@@ -751,7 +799,12 @@ export default function GraphPage() {
751799
</p>
752800
<div className="mt-2 flex flex-wrap gap-2 text-[10px] text-zinc-500">
753801
{result.severity && <span>{result.severity}</span>}
754-
<span>risk {result.risk_score.toFixed(1)}</span>
802+
<span>
803+
risk{" "}
804+
{typeof result.risk_score === "number" && Number.isFinite(result.risk_score)
805+
? result.risk_score.toFixed(1)
806+
: "N/A"}
807+
</span>
755808
</div>
756809
</button>
757810
))}
@@ -893,8 +946,10 @@ export default function GraphPage() {
893946
edges={displayEdges}
894947
nodeTypes={lineageNodeTypes}
895948
fitView
896-
minZoom={0.05}
949+
fitViewOptions={{ padding: 0.18, maxZoom: 1.05 }}
950+
minZoom={0.16}
897951
maxZoom={2.5}
952+
onlyRenderVisibleElements
898953
defaultEdgeOptions={{ type: "smoothstep" }}
899954
proOptions={{ hideAttribution: true }}
900955
onNodeClick={onNodeClick}
@@ -908,7 +963,12 @@ export default function GraphPage() {
908963
>
909964
<Background color={BACKGROUND_COLOR} gap={BACKGROUND_GAP} />
910965
<Controls className={CONTROLS_CLASS} />
911-
<MiniMap nodeColor={minimapNodeColor} className={MINIMAP_CLASS} />
966+
<MiniMap
967+
nodeColor={minimapNodeColor}
968+
className={MINIMAP_CLASS}
969+
bgColor={MINIMAP_BG}
970+
maskColor={MINIMAP_MASK}
971+
/>
912972
</ReactFlow>
913973

914974
{selectedNode && (

0 commit comments

Comments
 (0)