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:
Martin
2026-07-03 09:53:49 +02:00
co-authored by Claude Opus 4.8
parent 9e54c7788f
commit d9685b65b9
11 changed files with 1002 additions and 3 deletions
+296
View File
@@ -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>
);
}