Neue AssetType-Werte SOFTWARE und PROJECT plus 1:1-Profile: - SoftwareProfile (Anbieter-Verknüpfung, Version/Patch-Stand, Freigabestatus BEANTRAGT/FREIGEGEBEN/GESPERRT, Freigeber, Kritikalität, Review) - ProjectProfile (IS-Klassifizierung, ISB-Einbindung, Projektstatus) Migration inkl. RLS-Policies; TENANT_MODELS und Dependency-Graph (Node-Typ + Icons Package/FolderKanban) um beide Typen erweitert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
303 lines
9.6 KiB
TypeScript
303 lines
9.6 KiB
TypeScript
"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,
|
|
Server,
|
|
Package,
|
|
FolderKanban,
|
|
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,
|
|
IT_SERVICE: Server,
|
|
SOFTWARE: Package,
|
|
PROJECT: FolderKanban,
|
|
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>
|
|
);
|
|
}
|