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,131 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { buildDependencyGraph } from "@/server/dependency-graph";
|
||||
import { PageHead, SectTitle } from "@/components/mockup-ui";
|
||||
import { DependencyGraphView } from "@/components/dependency-graph";
|
||||
|
||||
export default async function DependenciesPage() {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "asset:read");
|
||||
const t = await getTranslations("dependencies");
|
||||
const tType = await getTranslations("assetType");
|
||||
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const graph = await buildDependencyGraph(db);
|
||||
|
||||
const kindLabels: Record<string, string> = {
|
||||
process: "Prozess",
|
||||
INFORMATION: tType("INFORMATION"),
|
||||
SYSTEM: tType("SYSTEM"),
|
||||
APPLICATION: tType("APPLICATION"),
|
||||
LOCATION: tType("LOCATION"),
|
||||
SUPPLIER: tType("SUPPLIER"),
|
||||
PERSON: tType("PERSON"),
|
||||
DATA: tType("DATA"),
|
||||
};
|
||||
const labels = {
|
||||
search: t("search"),
|
||||
criticalToggle: t("criticalToggle"),
|
||||
fit: t("fit"),
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
|
||||
|
||||
{graph.nodes.length === 0 ? (
|
||||
<div className="shadow-card rounded-xl border bg-card p-10 text-center text-sm text-muted-foreground">
|
||||
{t("empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 xl:grid-cols-[1fr_18rem]">
|
||||
<DependencyGraphView graph={graph} kindLabels={kindLabels} labels={labels} />
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Kennzahlen */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="shadow-card rounded-xl border bg-card p-3">
|
||||
<div className="text-[11.5px] font-semibold text-muted-foreground">
|
||||
{t("critProcesses")}
|
||||
</div>
|
||||
<div className="mt-1 font-heading text-2xl font-bold text-[#ff6b6b]">
|
||||
{graph.analysis.criticalProcessCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shadow-card rounded-xl border bg-card p-3">
|
||||
<div className="text-[11.5px] font-semibold text-muted-foreground">
|
||||
{t("critEdges")}
|
||||
</div>
|
||||
<div className="mt-1 font-heading text-2xl font-bold">
|
||||
{graph.analysis.criticalEdgeCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SPOF */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-4">
|
||||
<SectTitle title={t("spofTitle")} />
|
||||
{graph.analysis.spofs.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t("spofNone")}</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2 text-sm">
|
||||
{graph.analysis.spofs.slice(0, 6).map((s) => (
|
||||
<li key={s.id} className="rounded-lg border border-[rgba(255,107,107,0.3)] bg-[rgba(255,107,107,0.08)] p-2.5">
|
||||
<div className="font-semibold text-[#ff6b6b]">{s.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("spofHint", { count: s.count })}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kritischster Pfad */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-4">
|
||||
<SectTitle title={t("critPathTitle")} />
|
||||
{graph.analysis.longestCriticalPath.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t("critPathNone")}</p>
|
||||
) : (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1 text-[12.5px]">
|
||||
{graph.analysis.longestCriticalPath.map((n, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-[#ff6b6b]">→</span>}
|
||||
<span className="rounded-md bg-[rgba(255,107,107,0.12)] px-2 py-0.5 font-medium text-[#ffd0d0]">
|
||||
{n}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legende */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-4 text-[12.5px]">
|
||||
<SectTitle title={t("legend")} />
|
||||
<ul className="mt-2 space-y-1.5 text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-[3px] w-6 rounded bg-[#ff6b6b]" /> {t("legCritical")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-[2px] w-6 rounded bg-[#4a5372]" /> {t("legStandard")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="rounded bg-[rgba(255,107,107,0.18)] px-1.5 py-px text-[9.5px] font-bold text-[#ff6b6b]">
|
||||
SPOF
|
||||
</span>{" "}
|
||||
{t("legSpof")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="inline-block size-2.5 rounded-full bg-[#ff6b6b]" /> {t("legCrit")}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export default async function AppLayout({
|
||||
{ href: "/incidents", label: t("incidents"), icon: Siren, enabled: false },
|
||||
{ href: "/policies", label: t("policies"), icon: BookOpenText, enabled: false },
|
||||
{ href: "/chat", label: t("chat"), icon: MessagesSquare, enabled: false },
|
||||
{ href: "/dependencies", label: t("dependencies"), icon: Network, enabled: false },
|
||||
{ href: "/dependencies", label: t("dependencies"), icon: Network, enabled: true },
|
||||
{ href: "/evidence", label: t("evidence"), icon: FolderCheck, enabled: false },
|
||||
{ href: "/suppliers", label: t("suppliers"), icon: Truck, enabled: false },
|
||||
{ href: "/review", label: t("review"), icon: LineChart, enabled: false },
|
||||
|
||||
@@ -184,3 +184,22 @@ body {
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
}
|
||||
|
||||
/* React Flow — Dark-Anpassungen + Glow für kritische Kanten (Teil B) */
|
||||
.react-flow__edge.critical-edge .react-flow__edge-path {
|
||||
filter: drop-shadow(0 0 4px rgba(255, 107, 107, 0.7));
|
||||
}
|
||||
.react-flow__controls-button {
|
||||
background: var(--elevated) !important;
|
||||
border-color: var(--panel-brd) !important;
|
||||
color: var(--muted-foreground) !important;
|
||||
}
|
||||
.react-flow__controls-button:hover {
|
||||
background: var(--accent) !important;
|
||||
}
|
||||
.react-flow__controls-button svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
.react-flow__edge-text {
|
||||
fill: var(--muted-foreground);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import { Network, Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import {
|
||||
addAssetRelation,
|
||||
@@ -56,6 +56,7 @@ export async function AssetDetailModal({
|
||||
const tRole = await getTranslations("processRole");
|
||||
const tLevel = await getTranslations("riskLevel");
|
||||
const tRisks = await getTranslations("risks");
|
||||
const tDep = await getTranslations("dependencies");
|
||||
const tc = await getTranslations("common");
|
||||
const tp = await getTranslations("processes");
|
||||
|
||||
@@ -189,6 +190,12 @@ export async function AssetDetailModal({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href="/dependencies"
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-[var(--info)] hover:underline"
|
||||
>
|
||||
<Network className="size-3.5" /> {tDep("openGraph")} →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { MoreHorizontal, Pencil, Trash2, X } from "lucide-react";
|
||||
import { MoreHorizontal, Network, Pencil, Trash2, X } from "lucide-react";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import {
|
||||
assignAsset,
|
||||
@@ -82,6 +82,7 @@ export async function ProcessDetailModal({
|
||||
const tCrit = await getTranslations("criticality");
|
||||
const tCat = await getTranslations("processCategory");
|
||||
const tLevel = await getTranslations("riskLevel");
|
||||
const tDep = await getTranslations("dependencies");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const hours = (v: number | null | undefined) => (v != null ? `${v} h` : tc("none"));
|
||||
@@ -185,6 +186,12 @@ export async function ProcessDetailModal({
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Link
|
||||
href="/dependencies"
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-[var(--info)] hover:underline"
|
||||
>
|
||||
<Network className="size-3.5" /> {tDep("openGraph")} →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { TenantDb } from "./db";
|
||||
|
||||
/**
|
||||
* Ableitung des Abhängigkeitsgraphen (SPEC §4.11) aus Process,
|
||||
* ProcessAsset(role), Asset und AssetRelation. Keine redundante
|
||||
* Datenhaltung — der Graph wird bei jedem Aufruf frisch berechnet.
|
||||
*
|
||||
* Kritikalität je Knoten:
|
||||
* - Prozess: BIA-Kritikalität (1–4), sonst 1
|
||||
* - Asset: max(C, I, A)
|
||||
* Kritische Kante: beide Endknoten-Kritikalität ≥ Schwellwert.
|
||||
* Kritischer Pfad: von kritischen Prozessen entlang kritischer Kanten (DFS).
|
||||
* SPOF: Asset, von dem ≥ spofMin kritische Prozesse (transitiv) abhängen.
|
||||
*/
|
||||
|
||||
export type GraphNodeKind =
|
||||
| "process"
|
||||
| "INFORMATION"
|
||||
| "SYSTEM"
|
||||
| "APPLICATION"
|
||||
| "LOCATION"
|
||||
| "SUPPLIER"
|
||||
| "PERSON"
|
||||
| "DATA";
|
||||
|
||||
export type GraphNode = {
|
||||
id: string;
|
||||
entity: "process" | "asset";
|
||||
kind: GraphNodeKind;
|
||||
name: string;
|
||||
sub: string | null;
|
||||
criticality: number;
|
||||
critical: boolean;
|
||||
spof: boolean;
|
||||
dependentCriticalProcesses: number;
|
||||
};
|
||||
|
||||
export type GraphEdge = {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label: string;
|
||||
critical: boolean;
|
||||
};
|
||||
|
||||
export type DependencyGraph = {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
threshold: number;
|
||||
analysis: {
|
||||
spofs: { id: string; name: string; count: number }[];
|
||||
criticalProcessCount: number;
|
||||
criticalEdgeCount: number;
|
||||
longestCriticalPath: string[]; // Knoten-Namen
|
||||
};
|
||||
};
|
||||
|
||||
export async function buildDependencyGraph(
|
||||
db: TenantDb,
|
||||
opts: { threshold?: number; spofMin?: number } = {}
|
||||
): Promise<DependencyGraph> {
|
||||
const threshold = opts.threshold ?? 3;
|
||||
const spofMin = opts.spofMin ?? 2;
|
||||
|
||||
const [processes, assets, relations] = await Promise.all([
|
||||
db.process.findMany({
|
||||
include: {
|
||||
bia: { select: { criticality: true } },
|
||||
processAssets: { select: { assetId: true, role: true } },
|
||||
},
|
||||
}),
|
||||
db.asset.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
confidentiality: true,
|
||||
integrity: true,
|
||||
availability: true,
|
||||
},
|
||||
}),
|
||||
db.assetRelation.findMany({ select: { assetId: true, relatedAssetId: true, type: true } }),
|
||||
]);
|
||||
|
||||
const pid = (id: string) => `p:${id}`;
|
||||
const aid = (id: string) => `a:${id}`;
|
||||
|
||||
const assetCrit = new Map<string, number>();
|
||||
for (const a of assets) {
|
||||
assetCrit.set(a.id, Math.max(a.confidentiality, a.integrity, a.availability));
|
||||
}
|
||||
|
||||
const nodes: GraphNode[] = [];
|
||||
const critOf = new Map<string, number>();
|
||||
|
||||
for (const p of processes) {
|
||||
const c = p.bia?.criticality ?? 1;
|
||||
critOf.set(pid(p.id), c);
|
||||
nodes.push({
|
||||
id: pid(p.id),
|
||||
entity: "process",
|
||||
kind: "process",
|
||||
name: p.name,
|
||||
sub: null,
|
||||
criticality: c,
|
||||
critical: c >= threshold,
|
||||
spof: false,
|
||||
dependentCriticalProcesses: 0,
|
||||
});
|
||||
}
|
||||
for (const a of assets) {
|
||||
const c = assetCrit.get(a.id) ?? 1;
|
||||
critOf.set(aid(a.id), c);
|
||||
nodes.push({
|
||||
id: aid(a.id),
|
||||
entity: "asset",
|
||||
kind: a.type,
|
||||
name: a.name,
|
||||
sub: null,
|
||||
criticality: c,
|
||||
critical: c >= threshold,
|
||||
spof: false,
|
||||
dependentCriticalProcesses: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Kanten: Prozess → Asset (Rolle), Asset → Asset (Relation)
|
||||
const edges: GraphEdge[] = [];
|
||||
const adjacency = new Map<string, string[]>(); // gerichtete Abhängigkeit source→target
|
||||
const addAdj = (s: string, t: string) => {
|
||||
if (!adjacency.has(s)) adjacency.set(s, []);
|
||||
adjacency.get(s)!.push(t);
|
||||
};
|
||||
const isCritEdge = (s: string, t: string) =>
|
||||
(critOf.get(s) ?? 1) >= threshold && (critOf.get(t) ?? 1) >= threshold;
|
||||
|
||||
for (const p of processes) {
|
||||
for (const pa of p.processAssets) {
|
||||
const s = pid(p.id);
|
||||
const t = aid(pa.assetId);
|
||||
edges.push({
|
||||
id: `${s}->${t}`,
|
||||
source: s,
|
||||
target: t,
|
||||
label: pa.role === "PRIMARY" ? "erzeugt" : "nutzt",
|
||||
critical: isCritEdge(s, t),
|
||||
});
|
||||
addAdj(s, t);
|
||||
}
|
||||
}
|
||||
const assetIds = new Set(assets.map((a) => a.id));
|
||||
for (const r of relations) {
|
||||
if (!assetIds.has(r.assetId) || !assetIds.has(r.relatedAssetId)) continue;
|
||||
const s = aid(r.assetId);
|
||||
const t = aid(r.relatedAssetId);
|
||||
edges.push({
|
||||
id: `${s}->${t}`,
|
||||
source: s,
|
||||
target: t,
|
||||
label: r.type === "depends_on" ? "hängt ab" : r.type,
|
||||
critical: isCritEdge(s, t),
|
||||
});
|
||||
addAdj(s, t);
|
||||
}
|
||||
|
||||
// Transitive Abhängigkeiten je kritischem Prozess → SPOF-Zählung je Asset
|
||||
const criticalProcesses = nodes.filter((n) => n.entity === "process" && n.critical);
|
||||
const dependents = new Map<string, Set<string>>(); // assetNodeId → Set kritischer Prozess-IDs
|
||||
for (const cp of criticalProcesses) {
|
||||
const seen = new Set<string>();
|
||||
const stack = [...(adjacency.get(cp.id) ?? [])];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()!;
|
||||
if (seen.has(cur)) continue;
|
||||
seen.add(cur);
|
||||
if (!dependents.has(cur)) dependents.set(cur, new Set());
|
||||
dependents.get(cur)!.add(cp.id);
|
||||
for (const next of adjacency.get(cur) ?? []) stack.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
const spofs: { id: string; name: string; count: number }[] = [];
|
||||
for (const n of nodes) {
|
||||
if (n.entity !== "asset") continue;
|
||||
const count = dependents.get(n.id)?.size ?? 0;
|
||||
n.dependentCriticalProcesses = count;
|
||||
if (count >= spofMin) {
|
||||
n.spof = true;
|
||||
spofs.push({ id: n.id, name: n.name, count });
|
||||
}
|
||||
}
|
||||
spofs.sort((a, b) => b.count - a.count);
|
||||
|
||||
// Längster kritischer Pfad (DFS über kritische Kanten ab kritischen Prozessen)
|
||||
const critAdj = new Map<string, string[]>();
|
||||
for (const e of edges) {
|
||||
if (!e.critical) continue;
|
||||
if (!critAdj.has(e.source)) critAdj.set(e.source, []);
|
||||
critAdj.get(e.source)!.push(e.target);
|
||||
}
|
||||
const nameOf = new Map(nodes.map((n) => [n.id, n.name]));
|
||||
let longest: string[] = [];
|
||||
const dfs = (node: string, path: string[], visited: Set<string>) => {
|
||||
const next = critAdj.get(node) ?? [];
|
||||
if (next.length === 0) {
|
||||
if (path.length > longest.length) longest = [...path];
|
||||
return;
|
||||
}
|
||||
let extended = false;
|
||||
for (const nx of next) {
|
||||
if (visited.has(nx)) continue;
|
||||
extended = true;
|
||||
visited.add(nx);
|
||||
dfs(nx, [...path, nx], visited);
|
||||
visited.delete(nx);
|
||||
}
|
||||
if (!extended && path.length > longest.length) longest = [...path];
|
||||
};
|
||||
for (const cp of criticalProcesses) {
|
||||
dfs(cp.id, [cp.id], new Set([cp.id]));
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
threshold,
|
||||
analysis: {
|
||||
spofs,
|
||||
criticalProcessCount: criticalProcesses.length,
|
||||
criticalEdgeCount: edges.filter((e) => e.critical).length,
|
||||
longestCriticalPath: longest.map((id) => nameOf.get(id) ?? id),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user