mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-15 20:05:13 +02:00
[pitboss/grind] deferred session-0022 (20260517T044708Z-e058)
This commit is contained in:
parent
01eb67e1f9
commit
f4793b0439
11 changed files with 559 additions and 20 deletions
82
frontend/src/graph/adapters/surface.ts
Normal file
82
frontend/src/graph/adapters/surface.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import type { SurfaceEdge, SurfaceMap, SurfaceNode } from '@/api/types';
|
||||
import type { GraphModel } from '../types';
|
||||
|
||||
const MAX_LABEL = 44;
|
||||
const MAX_DETAIL = 48;
|
||||
|
||||
function truncate(value: string, max: number): string {
|
||||
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
||||
}
|
||||
|
||||
export const SURFACE_NODE_KIND: Record<SurfaceNode['node'], string> = {
|
||||
entry_point: 'EntryPoint',
|
||||
data_store: 'DataStore',
|
||||
external_service: 'ExternalService',
|
||||
dangerous_local: 'DangerousLocal',
|
||||
};
|
||||
|
||||
function nodeTitle(node: SurfaceNode): string {
|
||||
switch (node.node) {
|
||||
case 'entry_point':
|
||||
return `${node.method} ${node.route}`;
|
||||
case 'data_store':
|
||||
return `${node.kind}: ${node.label}`;
|
||||
case 'external_service':
|
||||
return `${node.kind}: ${node.label}`;
|
||||
case 'dangerous_local':
|
||||
return node.function_name;
|
||||
}
|
||||
}
|
||||
|
||||
function nodeDetail(node: SurfaceNode): string {
|
||||
switch (node.node) {
|
||||
case 'entry_point':
|
||||
return `${node.framework} · ${node.handler_name}`;
|
||||
case 'data_store':
|
||||
return 'data store';
|
||||
case 'external_service':
|
||||
return 'external service';
|
||||
case 'dangerous_local':
|
||||
return `cap=0x${node.cap_bits.toString(16)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function nodeLocation(node: SurfaceNode): { file: string; line: number } {
|
||||
if (node.node === 'entry_point') return node.handler_location;
|
||||
return node.location;
|
||||
}
|
||||
|
||||
export function adaptSurfaceMap(data: SurfaceMap): GraphModel {
|
||||
return {
|
||||
kind: 'surface',
|
||||
nodes: data.nodes.map((node, index) => {
|
||||
const loc = nodeLocation(node);
|
||||
const title = nodeTitle(node);
|
||||
const detail = nodeDetail(node);
|
||||
const searchText = [title, detail, loc.file].join(' ').toLowerCase();
|
||||
const authBadge =
|
||||
node.node === 'entry_point' && node.auth_required ? ['auth'] : undefined;
|
||||
return {
|
||||
key: String(index),
|
||||
rawId: index,
|
||||
label: truncate(title, MAX_LABEL),
|
||||
kind: SURFACE_NODE_KIND[node.node],
|
||||
detail: truncate(detail, MAX_DETAIL),
|
||||
line: loc.line,
|
||||
badges: authBadge,
|
||||
metadata: {
|
||||
surfaceKind: node.node,
|
||||
node,
|
||||
searchText,
|
||||
},
|
||||
};
|
||||
}),
|
||||
edges: data.edges.map((edge: SurfaceEdge, index) => ({
|
||||
key: `surface:${edge.from}:${edge.to}:${edge.kind}:${index}`,
|
||||
source: String(edge.from),
|
||||
target: String(edge.to),
|
||||
kind: edge.kind,
|
||||
metadata: { ...edge },
|
||||
})),
|
||||
};
|
||||
}
|
||||
123
frontend/src/graph/components/SurfaceGraphCanvas.tsx
Normal file
123
frontend/src/graph/components/SurfaceGraphCanvas.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import type { SurfaceMap } from '@/api/types';
|
||||
import { adaptSurfaceMap } from '../adapters/surface';
|
||||
import { useElkLayout } from '../hooks/useElkLayout';
|
||||
import {
|
||||
collectSearchMatches,
|
||||
extractNeighborhoodSubgraph,
|
||||
} from '../reduction/neighborhood';
|
||||
import { SigmaGraph } from '../rendering/sigma/SigmaGraph';
|
||||
|
||||
interface SurfaceGraphCanvasProps {
|
||||
data: SurfaceMap;
|
||||
selectedNodeId: number | null;
|
||||
onSelectNode: (id: number) => void;
|
||||
}
|
||||
|
||||
export function SurfaceGraphCanvas({
|
||||
data,
|
||||
selectedNodeId,
|
||||
onSelectNode,
|
||||
}: SurfaceGraphCanvasProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [neighborhoodOnly, setNeighborhoodOnly] = useState(false);
|
||||
const [radius, setRadius] = useState(2);
|
||||
|
||||
const fullGraph = useMemo(() => adaptSurfaceMap(data), [data]);
|
||||
const selectedNodeKey =
|
||||
selectedNodeId == null ? null : String(selectedNodeId);
|
||||
|
||||
const matches = useMemo(
|
||||
() => collectSearchMatches(fullGraph, searchQuery, 60),
|
||||
[fullGraph, searchQuery],
|
||||
);
|
||||
const matchKeys = useMemo(
|
||||
() => new Set(matches.map((node) => node.key)),
|
||||
[matches],
|
||||
);
|
||||
|
||||
const visibleGraph = useMemo(() => {
|
||||
if (!neighborhoodOnly || !selectedNodeKey) return fullGraph;
|
||||
return extractNeighborhoodSubgraph(fullGraph, selectedNodeKey, radius);
|
||||
}, [fullGraph, neighborhoodOnly, radius, selectedNodeKey]);
|
||||
|
||||
const { graph, isLoading, error } = useElkLayout(visibleGraph);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="error-state">Failed to compute the surface layout.</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!graph) {
|
||||
return <div className="loading">Preparing surface graph…</div>;
|
||||
}
|
||||
|
||||
const extras = (
|
||||
<>
|
||||
<label className="graph-toolbar-field">
|
||||
<span>Search</span>
|
||||
<input
|
||||
className="graph-toolbar-input"
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Route, label, or path"
|
||||
/>
|
||||
</label>
|
||||
<label className="graph-toolbar-field">
|
||||
<span>Match</span>
|
||||
<select
|
||||
className="graph-toolbar-select"
|
||||
value={selectedNodeKey ?? ''}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
if (!next) return;
|
||||
onSelectNode(Number(next));
|
||||
}}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{matches.map((match) => (
|
||||
<option key={match.key} value={match.key}>
|
||||
{match.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="graph-toolbar-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={neighborhoodOnly}
|
||||
onChange={(event) => setNeighborhoodOnly(event.target.checked)}
|
||||
/>
|
||||
<span>Neighbors only</span>
|
||||
</label>
|
||||
<label className="graph-toolbar-field graph-toolbar-field-compact">
|
||||
<span>Radius</span>
|
||||
<input
|
||||
className="graph-toolbar-range"
|
||||
type="range"
|
||||
min="1"
|
||||
max="4"
|
||||
step="1"
|
||||
value={radius}
|
||||
disabled={!neighborhoodOnly}
|
||||
onChange={(event) => setRadius(Number(event.target.value))}
|
||||
/>
|
||||
<strong>{radius}</strong>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SigmaGraph
|
||||
graph={graph}
|
||||
viewKind="surface"
|
||||
selectedNodeKey={selectedNodeKey}
|
||||
onNodeClick={(key) => onSelectNode(Number(key))}
|
||||
searchMatchKeys={matchKeys}
|
||||
toolbarExtras={extras}
|
||||
loading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -39,6 +39,14 @@ const PRESETS: Record<GraphViewKind, ElkLayoutPreset> = {
|
|||
padding: 32,
|
||||
edgeRouting: 'ORTHOGONAL',
|
||||
},
|
||||
surface: {
|
||||
direction: 'RIGHT',
|
||||
nodeSpacing: 44,
|
||||
layerSpacing: 156,
|
||||
edgeNodeSpacing: 28,
|
||||
padding: 36,
|
||||
edgeRouting: 'POLYLINE',
|
||||
},
|
||||
};
|
||||
|
||||
function measureNode(
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@ const CONFIG: Record<GraphViewKind, TextLayoutConfig> = {
|
|||
maxSecondaryLines: 2,
|
||||
maxSublabelLines: 1,
|
||||
},
|
||||
surface: {
|
||||
primaryChars: 32,
|
||||
secondaryChars: 32,
|
||||
maxPrimaryLines: 2,
|
||||
maxSecondaryLines: 2,
|
||||
maxSublabelLines: 1,
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
|
|
|
|||
|
|
@ -195,6 +195,94 @@ function cfgNodeStyle(
|
|||
}
|
||||
}
|
||||
|
||||
function surfaceNodeStyle(
|
||||
type: string,
|
||||
palette: GraphThemePalette,
|
||||
): NodeStyle {
|
||||
switch (type) {
|
||||
case 'EntryPoint':
|
||||
return {
|
||||
fill: palette.success,
|
||||
stroke: withAlpha(palette.success, 0.85),
|
||||
textFill: '#ffffff',
|
||||
secondaryFill: withAlpha('#ffffff', 0.78),
|
||||
shape: 'double',
|
||||
strokeWidth: 1.8,
|
||||
accentFill: palette.accent,
|
||||
neighborFill: withAlpha(palette.success, 0.75),
|
||||
};
|
||||
case 'DataStore':
|
||||
return {
|
||||
fill: palette.warning,
|
||||
stroke: withAlpha(palette.warning, 0.85),
|
||||
textFill: '#ffffff',
|
||||
secondaryFill: withAlpha('#ffffff', 0.8),
|
||||
shape: 'rect',
|
||||
strokeWidth: 1.5,
|
||||
accentFill: palette.accent,
|
||||
neighborFill: withAlpha(palette.warning, 0.76),
|
||||
};
|
||||
case 'ExternalService':
|
||||
return {
|
||||
fill: palette.accent,
|
||||
stroke: withAlpha(palette.accent, 0.82),
|
||||
textFill: '#ffffff',
|
||||
secondaryFill: withAlpha('#ffffff', 0.8),
|
||||
shape: 'rect',
|
||||
strokeWidth: 1.5,
|
||||
accentFill: palette.accent,
|
||||
neighborFill: palette.accentSoft,
|
||||
};
|
||||
case 'DangerousLocal':
|
||||
return {
|
||||
fill: palette.danger,
|
||||
stroke: withAlpha(palette.danger, 0.86),
|
||||
textFill: '#ffffff',
|
||||
secondaryFill: withAlpha('#ffffff', 0.8),
|
||||
shape: 'terminal',
|
||||
strokeWidth: 1.7,
|
||||
accentFill: palette.accent,
|
||||
neighborFill: withAlpha(palette.danger, 0.75),
|
||||
};
|
||||
default:
|
||||
return {
|
||||
fill: withAlpha(palette.neutral, 0.92),
|
||||
stroke: withAlpha(palette.neutral, 0.8),
|
||||
textFill: '#ffffff',
|
||||
secondaryFill: withAlpha('#ffffff', 0.78),
|
||||
shape: 'rect',
|
||||
strokeWidth: 1.2,
|
||||
accentFill: palette.accent,
|
||||
neighborFill: withAlpha(palette.neutralSoft, 0.88),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function surfaceEdgeStyle(type: string, palette: GraphThemePalette): EdgeStyle {
|
||||
switch (type) {
|
||||
case 'calls':
|
||||
return { color: withAlpha(palette.textSecondary, 0.78), width: 1.4, dash: [] };
|
||||
case 'reads_from':
|
||||
return { color: palette.success, width: 1.5, dash: [] };
|
||||
case 'writes_to':
|
||||
return { color: palette.warning, width: 1.6, dash: [] };
|
||||
case 'talks_to':
|
||||
return { color: palette.accent, width: 1.4, dash: [] };
|
||||
case 'reaches':
|
||||
return { color: palette.danger, width: 1.7, dash: [] };
|
||||
case 'triggers':
|
||||
return { color: palette.success, width: 1.5, dash: [4, 3] };
|
||||
case 'auth_required_on':
|
||||
return { color: palette.textTertiary, width: 1.3, dash: [2, 4] };
|
||||
default:
|
||||
return {
|
||||
color: withAlpha(palette.textTertiary, 0.78),
|
||||
width: 1.3,
|
||||
dash: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function callGraphNodeStyle(
|
||||
palette: GraphThemePalette,
|
||||
metadata?: GraphMetadata,
|
||||
|
|
@ -221,9 +309,15 @@ export function getNodeStyle(
|
|||
metadata?: GraphMetadata,
|
||||
palette = FALLBACK_PALETTE,
|
||||
): NodeStyle {
|
||||
return graphKind === 'callgraph'
|
||||
? callGraphNodeStyle(palette, metadata)
|
||||
: cfgNodeStyle(type, palette, metadata);
|
||||
switch (graphKind) {
|
||||
case 'callgraph':
|
||||
return callGraphNodeStyle(palette, metadata);
|
||||
case 'surface':
|
||||
return surfaceNodeStyle(type, palette);
|
||||
case 'cfg':
|
||||
default:
|
||||
return cfgNodeStyle(type, palette, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
export function getEdgeStyle(
|
||||
|
|
@ -239,6 +333,10 @@ export function getEdgeStyle(
|
|||
};
|
||||
}
|
||||
|
||||
if (graphKind === 'surface') {
|
||||
return surfaceEdgeStyle(type, palette);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'True':
|
||||
return { color: palette.success, width: 1.8, dash: [] };
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export type GraphViewKind = 'callgraph' | 'cfg';
|
||||
export type GraphViewKind = 'callgraph' | 'cfg' | 'surface';
|
||||
|
||||
export interface GraphPoint {
|
||||
x: number;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue