From d9685b65b94d6056e084886a39fc7baeb4e3cbef Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 3 Jul 2026 09:53:49 +0200 Subject: [PATCH] =?UTF-8?q?Iteration=20Teil=20B:=20Modul=20=E2=80=9EAbh?= =?UTF-8?q?=C3=A4ngigkeiten=20&=20kritische=20Pfade"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- messages/de.json | 24 +++ messages/en.json | 24 +++ package-lock.json | 254 ++++++++++++++++++++++++ package.json | 3 + src/app/(app)/dependencies/page.tsx | 131 ++++++++++++ src/app/(app)/layout.tsx | 2 +- src/app/globals.css | 19 ++ src/components/asset-modals.tsx | 9 +- src/components/dependency-graph.tsx | 296 ++++++++++++++++++++++++++++ src/components/process-modals.tsx | 9 +- src/server/dependency-graph.ts | 234 ++++++++++++++++++++++ 11 files changed, 1002 insertions(+), 3 deletions(-) create mode 100644 src/app/(app)/dependencies/page.tsx create mode 100644 src/components/dependency-graph.tsx create mode 100644 src/server/dependency-graph.ts diff --git a/messages/de.json b/messages/de.json index a39976a..41c114a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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" } } \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index 9e8c4cf..6b52c6b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cfc8692..f43ca09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 + } + } } } } diff --git a/package.json b/package.json index 9d6f2fa..3f54c45 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(app)/dependencies/page.tsx b/src/app/(app)/dependencies/page.tsx new file mode 100644 index 0000000..bdd4796 --- /dev/null +++ b/src/app/(app)/dependencies/page.tsx @@ -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 = { + 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 ( +
+ + + {graph.nodes.length === 0 ? ( +
+ {t("empty")} +
+ ) : ( +
+ + +
+ {/* Kennzahlen */} +
+
+
+ {t("critProcesses")} +
+
+ {graph.analysis.criticalProcessCount} +
+
+
+
+ {t("critEdges")} +
+
+ {graph.analysis.criticalEdgeCount} +
+
+
+ + {/* SPOF */} +
+ + {graph.analysis.spofs.length === 0 ? ( +

{t("spofNone")}

+ ) : ( +
    + {graph.analysis.spofs.slice(0, 6).map((s) => ( +
  • +
    {s.name}
    +
    + {t("spofHint", { count: s.count })} +
    +
  • + ))} +
+ )} +
+ + {/* Kritischster Pfad */} +
+ + {graph.analysis.longestCriticalPath.length === 0 ? ( +

{t("critPathNone")}

+ ) : ( +
+ {graph.analysis.longestCriticalPath.map((n, i) => ( + + {i > 0 && } + + {n} + + + ))} +
+ )} +
+ + {/* Legende */} +
+ +
    +
  • + {t("legCritical")} +
  • +
  • + {t("legStandard")} +
  • +
  • + + SPOF + {" "} + {t("legSpof")} +
  • +
  • + {t("legCrit")} +
  • +
+
+
+
+ )} +
+ ); +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 3bace1a..f53ab62 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -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 }, diff --git a/src/app/globals.css b/src/app/globals.css index 7d10648..9c6ab54 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); +} diff --git a/src/components/asset-modals.tsx b/src/components/asset-modals.tsx index 959d3e6..1f76326 100644 --- a/src/components/asset-modals.tsx +++ b/src/components/asset-modals.tsx @@ -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({ ))} + + {tDep("openGraph")} → + diff --git a/src/components/dependency-graph.tsx b/src/components/dependency-graph.tsx new file mode 100644 index 0000000..90a96b9 --- /dev/null +++ b/src/components/dependency-graph.tsx @@ -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 = { + 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>) { + const Icon = KIND_ICON[data.kind] ?? Boxes; + const critColor = + data.criticality >= 4 ? "#ff6b6b" : data.criticality >= 3 ? "#f0ad4e" : "#39c07f"; + return ( +
+ + + + +
+
+ + {data.name} + +
+
+ + {data.kindLabel} · K{data.criticality} + {data.spof && ( + + SPOF + + )} +
+
+ +
+ ); +} + +const nodeTypes = { isms: IsmsNode }; + +function layout(graph: DependencyGraph, kindLabels: Record) { + 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[] = 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; + labels: Record; +}) { + const rf = useReactFlow(); + const [showCritical, setShowCritical] = useState(true); + const [focus, setFocus] = useState(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>(); + 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([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[] = 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(".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 ( +
+ {/* Toolbar */} +
+
+ + runSearch(e.target.value)} + placeholder={labels.search} + className="w-40 border-0 bg-transparent text-[13px] text-foreground outline-none placeholder:text-muted-foreground" + /> +
+ + + +
+ + + + + +
+ ); +} + +export function DependencyGraphView(props: { + graph: DependencyGraph; + kindLabels: Record; + labels: Record; +}) { + return ( + + + + ); +} diff --git a/src/components/process-modals.tsx b/src/components/process-modals.tsx index 0dd901c..cddb946 100644 --- a/src/components/process-modals.tsx +++ b/src/components/process-modals.tsx @@ -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({ ))} + + {tDep("openGraph")} → + diff --git a/src/server/dependency-graph.ts b/src/server/dependency-graph.ts new file mode 100644 index 0000000..6d07ee2 --- /dev/null +++ b/src/server/dependency-graph.ts @@ -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 { + 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(); + for (const a of assets) { + assetCrit.set(a.id, Math.max(a.confidentiality, a.integrity, a.availability)); + } + + const nodes: GraphNode[] = []; + const critOf = new Map(); + + 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(); // 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>(); // assetNodeId → Set kritischer Prozess-IDs + for (const cp of criticalProcesses) { + const seen = new Set(); + 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(); + 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) => { + 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), + }, + }; +}