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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+254
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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