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
+24
View File
@@ -317,5 +317,29 @@
"LOW": "Niedrig",
"MEDIUM": "Mittel",
"HIGH": "Hoch"
},
"dependencies": {
"title": "Abhängigkeiten & kritische Pfade",
"sub": "Verkettung von Prozessen und Assets · kritische Pfade & Single Points of Failure",
"crumb": "Fachdaten",
"search": "Suchen …",
"criticalToggle": "Kritische Pfade",
"fit": "Zentrieren",
"graphView": "Netzwerkgraph",
"analysis": "Analyse",
"spofTitle": "Single Points of Failure",
"spofNone": "Keine SPOF erkannt.",
"spofHint": "{count} kritische Prozesse hängen davon ab",
"critPathTitle": "Kritischster Pfad",
"critPathNone": "Kein kritischer Pfad.",
"critProcesses": "Kritische Prozesse",
"critEdges": "Kritische Kanten",
"legend": "Legende",
"legCritical": "Kritischer Pfad",
"legStandard": "Standard-Abhängigkeit",
"legSpof": "Single Point of Failure",
"legCrit": "Kritisch (K≥3)",
"empty": "Noch keine Prozesse/Assets für einen Graphen vorhanden.",
"openGraph": "Im Abhängigkeitsgraph anzeigen"
}
}
+24
View File
@@ -317,5 +317,29 @@
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High"
},
"dependencies": {
"title": "Dependencies & critical paths",
"sub": "Process/asset chains · critical paths & single points of failure",
"crumb": "Core data",
"search": "Search …",
"criticalToggle": "Critical paths",
"fit": "Fit view",
"graphView": "Network graph",
"analysis": "Analysis",
"spofTitle": "Single points of failure",
"spofNone": "No SPOF detected.",
"spofHint": "{count} critical processes depend on it",
"critPathTitle": "Most critical path",
"critPathNone": "No critical path.",
"critProcesses": "Critical processes",
"critEdges": "Critical edges",
"legend": "Legend",
"legCritical": "Critical path",
"legStandard": "Standard dependency",
"legSpof": "Single point of failure",
"legCrit": "Critical (K≥3)",
"empty": "No processes/assets available for a graph yet.",
"openGraph": "Show in dependency graph"
}
}
+254
View File
@@ -9,13 +9,16 @@
"version": "0.1.0",
"dependencies": {
"@base-ui/react": "^1.6.0",
"@dagrejs/dagre": "^3.0.0",
"@dnd-kit/core": "^6.3.1",
"@node-rs/argon2": "^2.0.2",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@xyflow/react": "^12.11.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.4.2",
"html-to-image": "^1.11.13",
"lucide-react": "^1.23.0",
"next": "16.2.10",
"next-auth": "^5.0.0-beta.31",
@@ -556,6 +559,21 @@
}
}
},
"node_modules/@dagrejs/dagre": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz",
"integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==",
"license": "MIT",
"dependencies": {
"@dagrejs/graphlib": "4.0.1"
}
},
"node_modules/@dagrejs/graphlib": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz",
"integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==",
"license": "MIT"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -4107,6 +4125,55 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -4823,6 +4890,48 @@
"win32"
]
},
"node_modules/@xyflow/react": {
"version": "12.11.1",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.1.tgz",
"integrity": "sha512-L+zBoLGSXham0MnlY8QqjfR7/C5JNw0zxkaey5aZ5XmCgJBAdH4+WRIu8CR40d3l/BdU635V6YbhBK1jMo8/6Q==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.78",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"@types/react": ">=17",
"@types/react-dom": ">=17",
"react": ">=17",
"react-dom": ">=17"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@xyflow/system": {
"version": "0.0.78",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.78.tgz",
"integrity": "sha512-lY0z2qP33fUhTva9Vaxrk0lqZta2pkbxB1trHAx1omnJqRtPvDlAQYV2r5fhS6AdpkulYmbNW0svy+A4/t4B/g==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -5516,6 +5625,12 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/cli-cursor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
@@ -5828,6 +5943,111 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -7722,6 +7942,12 @@
"node": ">=16.9.0"
}
},
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -12481,6 +12707,34 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
}
}
}
+3
View File
@@ -10,13 +10,16 @@
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@dagrejs/dagre": "^3.0.0",
"@dnd-kit/core": "^6.3.1",
"@node-rs/argon2": "^2.0.2",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@xyflow/react": "^12.11.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.4.2",
"html-to-image": "^1.11.13",
"lucide-react": "^1.23.0",
"next": "16.2.10",
"next-auth": "^5.0.0-beta.31",
+131
View File
@@ -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>
);
}
+1 -1
View File
@@ -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 },
+19
View File
@@ -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);
}
+8 -1
View File
@@ -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>
+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>
);
}
+8 -1
View File
@@ -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>
+234
View File
@@ -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 (14), 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),
},
};
}