Iteration Teil B: Modul „Abhängigkeiten & kritische Pfade"
Referenz: docs/ISMS-Abhaengigkeitskarte-GEFIM.html - Graph-Ableitung serverseitig (dependency-graph.ts) aus Process, ProcessAsset(role), Asset, AssetRelation — keine redundante Haltung. Kritikalität je Knoten (BIA / max C·I·A), kritische Kanten (beide Endknoten ≥ Schwellwert), kritischer Pfad (DFS), SPOF-Erkennung (transitiv abhängige kritische Prozesse ≥ N) - React-Flow-Canvas (Topology-Look, dunkle Leinwand + Punktraster): Custom-Nodes mit Typ-Icon, Kritikalität, Status-Punkt, SPOF-Badge; smoothstep-Kanten mit Labels; kritische Kanten rot + Glow - dagre-Auto-Layout (links Prozesse → rechts Assets); Toolbar mit Suche (Fokus/Zentrieren), Toggle „Kritische Pfade", Fit, PNG-Export; Knoten-Klick hebt zusammenhängenden Pfad hervor, Rest abgedunkelt - Analyse-Panel: kritische Prozesse/Kanten, SPOF-Liste, kritischster Pfad, Legende - Eigener Menüpunkt „Abhängigkeiten"; Absprung aus Asset-/Prozess-Detail - Theme-Tokens für React Flow (Controls, kritische-Kanten-Glow) Verifiziert: 10 Knoten/9 Kanten korrekt aus Demo-Daten, SPOF (Cloud- Hoster, Kundendatenbank), kritischster Pfad, Toggle & Such-Fokus. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9e54c7788f
commit
d9685b65b9
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
Handle,
|
||||
Position,
|
||||
useReactFlow,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import dagre from "@dagrejs/dagre";
|
||||
import { toPng } from "html-to-image";
|
||||
import {
|
||||
Boxes,
|
||||
Database,
|
||||
AppWindow,
|
||||
MapPin,
|
||||
Truck,
|
||||
Users,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Crosshair,
|
||||
Search,
|
||||
Download,
|
||||
Zap,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DependencyGraph, GraphNode, GraphNodeKind } from "@/server/dependency-graph";
|
||||
|
||||
const KIND_ICON: Record<GraphNodeKind, typeof Boxes> = {
|
||||
process: GitBranch,
|
||||
INFORMATION: FileText,
|
||||
SYSTEM: Boxes,
|
||||
APPLICATION: AppWindow,
|
||||
LOCATION: MapPin,
|
||||
SUPPLIER: Truck,
|
||||
PERSON: Users,
|
||||
DATA: Database,
|
||||
};
|
||||
|
||||
type NodeData = GraphNode & { dimmed: boolean; kindLabel: string };
|
||||
|
||||
/** Custom-Node im Topology-Look (Referenz ISMS-Abhaengigkeitskarte). */
|
||||
function IsmsNode({ data }: NodeProps<Node<NodeData>>) {
|
||||
const Icon = KIND_ICON[data.kind] ?? Boxes;
|
||||
const critColor =
|
||||
data.criticality >= 4 ? "#ff6b6b" : data.criticality >= 3 ? "#f0ad4e" : "#39c07f";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-49 items-center gap-2.5 rounded-2xl border px-3 py-2.5 transition-all",
|
||||
"bg-[var(--panel)] backdrop-blur",
|
||||
data.critical
|
||||
? "border-[rgba(255,107,107,0.5)] shadow-[0_0_0_1px_rgba(255,107,107,0.25),0_8px_26px_rgba(255,80,80,0.12)]"
|
||||
: "border-[var(--panel-brd)]",
|
||||
data.dimmed && "opacity-25"
|
||||
)}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} className="!bg-[var(--muted-foreground)]" />
|
||||
<span
|
||||
className={cn(
|
||||
"grid size-9 shrink-0 place-items-center rounded-xl",
|
||||
data.critical ? "bg-[rgba(255,107,107,0.16)] text-[#ff6b6b]" : "bg-[rgba(125,111,214,0.16)] text-[#8dc4e0]"
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[13px] font-semibold text-[var(--txt,#e8ecf7)]">
|
||||
{data.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="inline-block size-2 rounded-full" style={{ background: critColor }} />
|
||||
{data.kindLabel} · K{data.criticality}
|
||||
{data.spof && (
|
||||
<span className="ml-0.5 rounded bg-[rgba(255,107,107,0.18)] px-1.5 py-px text-[9.5px] font-bold text-[#ff6b6b]">
|
||||
SPOF
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} className="!bg-[var(--muted-foreground)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes = { isms: IsmsNode };
|
||||
|
||||
function layout(graph: DependencyGraph, kindLabels: Record<string, string>) {
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({ rankdir: "LR", nodesep: 34, ranksep: 130, marginx: 20, marginy: 20 });
|
||||
graph.nodes.forEach((n) => g.setNode(n.id, { width: 200, height: 64 }));
|
||||
graph.edges.forEach((e) => g.setEdge(e.source, e.target));
|
||||
dagre.layout(g);
|
||||
|
||||
const nodes: Node<NodeData>[] = graph.nodes.map((n) => {
|
||||
const p = g.node(n.id);
|
||||
return {
|
||||
id: n.id,
|
||||
type: "isms",
|
||||
position: { x: p.x - 100, y: p.y - 32 },
|
||||
data: { ...n, dimmed: false, kindLabel: kindLabels[n.kind] ?? n.kind },
|
||||
};
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function Canvas({
|
||||
graph,
|
||||
kindLabels,
|
||||
labels,
|
||||
}: {
|
||||
graph: DependencyGraph;
|
||||
kindLabels: Record<string, string>;
|
||||
labels: Record<string, string>;
|
||||
}) {
|
||||
const rf = useReactFlow();
|
||||
const [showCritical, setShowCritical] = useState(true);
|
||||
const [focus, setFocus] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const baseNodes = useMemo(() => layout(graph, kindLabels), [graph, kindLabels]);
|
||||
|
||||
// Zusammenhang für Fokus-Highlight (ungerichtet)
|
||||
const neighbors = useMemo(() => {
|
||||
const m = new Map<string, Set<string>>();
|
||||
for (const e of graph.edges) {
|
||||
if (!m.has(e.source)) m.set(e.source, new Set());
|
||||
if (!m.has(e.target)) m.set(e.target, new Set());
|
||||
m.get(e.source)!.add(e.target);
|
||||
m.get(e.target)!.add(e.source);
|
||||
}
|
||||
return m;
|
||||
}, [graph.edges]);
|
||||
|
||||
const focusSet = useMemo(() => {
|
||||
if (!focus) return null;
|
||||
const seen = new Set<string>([focus]);
|
||||
const stack = [focus];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()!;
|
||||
for (const nb of neighbors.get(cur) ?? []) {
|
||||
if (!seen.has(nb)) {
|
||||
seen.add(nb);
|
||||
stack.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
}, [focus, neighbors]);
|
||||
|
||||
const nodes: Node<NodeData>[] = useMemo(
|
||||
() =>
|
||||
baseNodes.map((n) => ({
|
||||
...n,
|
||||
data: { ...n.data, dimmed: focusSet ? !focusSet.has(n.id) : false },
|
||||
})),
|
||||
[baseNodes, focusSet]
|
||||
);
|
||||
|
||||
const edges: Edge[] = useMemo(
|
||||
() =>
|
||||
graph.edges.map((e) => {
|
||||
const isCrit = e.critical && showCritical;
|
||||
const inFocus = !focusSet || (focusSet.has(e.source) && focusSet.has(e.target));
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
label: e.label,
|
||||
type: "smoothstep",
|
||||
animated: isCrit,
|
||||
className: isCrit ? "critical-edge" : undefined,
|
||||
labelStyle: { fill: "#8b93ad", fontSize: 10, fontWeight: 600 },
|
||||
labelBgStyle: { fill: "#141a2e" },
|
||||
style: {
|
||||
stroke: isCrit ? "#ff6b6b" : "#4a5372",
|
||||
strokeWidth: isCrit ? 2.5 : 1.5,
|
||||
opacity: inFocus ? 1 : 0.12,
|
||||
},
|
||||
};
|
||||
}),
|
||||
[graph.edges, showCritical, focusSet]
|
||||
);
|
||||
|
||||
const onNodeClick = useCallback((_: unknown, node: Node) => setFocus(node.id), []);
|
||||
const onPaneClick = useCallback(() => setFocus(null), []);
|
||||
|
||||
const runSearch = useCallback(
|
||||
(q: string) => {
|
||||
setQuery(q);
|
||||
if (!q.trim()) return;
|
||||
const hit = baseNodes.find((n) => n.data.name.toLowerCase().includes(q.toLowerCase()));
|
||||
if (hit) {
|
||||
setFocus(hit.id);
|
||||
rf.setCenter(hit.position.x + 100, hit.position.y + 32, { zoom: 1.2, duration: 500 });
|
||||
}
|
||||
},
|
||||
[baseNodes, rf]
|
||||
);
|
||||
|
||||
const exportPng = useCallback(async () => {
|
||||
const el = document.querySelector<HTMLElement>(".react-flow__viewport");
|
||||
if (!el) return;
|
||||
const dataUrl = await toPng(el, {
|
||||
backgroundColor: "#0e1220",
|
||||
filter: (node) =>
|
||||
!(node instanceof HTMLElement && node.classList?.contains("react-flow__controls")),
|
||||
});
|
||||
const a = document.createElement("a");
|
||||
a.href = dataUrl;
|
||||
a.download = "abhaengigkeiten.png";
|
||||
a.click();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative h-[600px] overflow-hidden rounded-2xl border border-[var(--panel-brd)] bg-[var(--bg-1,#141a2e)]">
|
||||
{/* Toolbar */}
|
||||
<div className="absolute top-3 left-3 z-10 flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-[var(--panel-brd)] bg-[var(--panel)] px-3 py-1.5 backdrop-blur">
|
||||
<Search className="size-4 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => runSearch(e.target.value)}
|
||||
placeholder={labels.search}
|
||||
className="w-40 border-0 bg-transparent text-[13px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCritical((v) => !v)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-[13px] font-semibold backdrop-blur",
|
||||
showCritical
|
||||
? "border-[rgba(255,107,107,0.4)] bg-[rgba(255,107,107,0.12)] text-[#ff6b6b]"
|
||||
: "border-[var(--panel-brd)] bg-[var(--panel)] text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{showCritical ? <Zap className="size-4" /> : <ZapOff className="size-4" />}
|
||||
{labels.criticalToggle}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => rf.fitView({ duration: 500, padding: 0.15 })}
|
||||
title={labels.fit}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--panel-brd)] bg-[var(--panel)] px-3 py-1.5 text-[13px] font-semibold text-muted-foreground backdrop-blur hover:text-foreground"
|
||||
>
|
||||
<Crosshair className="size-4" /> {labels.fit}
|
||||
</button>
|
||||
<button
|
||||
onClick={exportPng}
|
||||
title="PNG"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--panel-brd)] bg-[var(--panel)] px-3 py-1.5 text-[13px] font-semibold text-muted-foreground backdrop-blur hover:text-foreground"
|
||||
>
|
||||
<Download className="size-4" /> PNG
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.15 }}
|
||||
minZoom={0.2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={26} size={1} color="rgba(140,155,200,0.18)" />
|
||||
<Controls className="!border-[var(--panel-brd)] !bg-[var(--panel)]" showInteractive={false} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DependencyGraphView(props: {
|
||||
graph: DependencyGraph;
|
||||
kindLabels: Record<string, string>;
|
||||
labels: Record<string, string>;
|
||||
}) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<Canvas {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user