Files
----/frontend/src/pages/GraphPage.tsx
smolkik-code 9b15f8b09c feat: CMDB full-stack app - FastAPI + PostgreSQL + React
- PostgreSQL schema: 14 tables, JSONB attributes, audit triggers, soft delete
- FastAPI backend: CRUD, search/filter, relationship graph, bulk import, JWT RBAC
- React frontend: CI table, detail card, force-graph, dashboard
- Seed data: homelab scenario (Proxmox, Mikrotik, VMs, services)
- Docker Compose + Kubernetes manifests
- 20 backend tests (pytest + httpx)
2026-06-25 13:01:40 +07:00

196 lines
6.4 KiB
TypeScript

import { useEffect, useState, useRef, useCallback } from 'react';
import { Box, Typography, Card, CardContent, Slider, Button, CircularProgress, Alert } from '@mui/material';
import { ciApi, GraphData } from '../services/api';
import { useSnackbar } from 'notistack';
export default function GraphPage() {
const { enqueueSnackbar } = useSnackbar();
const canvasRef = useRef<HTMLCanvasElement>(null);
const [graph, setGraph] = useState<GraphData | null>(null);
const [loading, setLoading] = useState(true);
const [depth, setDepth] = useState(2);
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const fetchGraph = async () => {
setLoading(true);
try {
const res = await ciApi.graph({ depth });
setGraph(res.data);
} catch {
enqueueSnackbar('Failed to load graph', { variant: 'error' });
} finally {
setLoading(false);
}
};
useEffect(() => { fetchGraph(); }, [depth]);
// Simple force-directed layout on canvas
useEffect(() => {
if (!graph || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const W = canvas.width = canvas.parentElement?.clientWidth || 800;
const H = canvas.height = 600;
const nodeMap = new Map<string, { x: number; y: number; vx: number; vy: number }>();
graph.nodes.forEach((n, i) => {
const angle = (2 * Math.PI * i) / graph.nodes.length;
nodeMap.set(n.id, {
x: W / 2 + Math.cos(angle) * 200,
y: H / 2 + Math.sin(angle) * 200,
vx: 0, vy: 0,
});
});
const GROUP_COLORS: Record<string, string> = {
PhysicalServer: '#4fc3f7', VirtualMachine: '#81c784', Switch: '#ff8a65',
Router: '#ba68c8', Application: '#ffd54f', NAS: '#f48fb1',
};
let animFrame: number;
let iterations = 0;
const simulate = () => {
iterations++;
const alpha = Math.max(0.01, 1 - iterations / 300);
// Repulsion
for (let i = 0; i < graph.nodes.length; i++) {
for (let j = i + 1; j < graph.nodes.length; j++) {
const a = nodeMap.get(graph.nodes[i].id)!;
const b = nodeMap.get(graph.nodes[j].id)!;
let dx = b.x - a.x, dy = b.y - a.y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
let force = 800 / (dist * dist);
force *= alpha;
a.vx -= (dx / dist) * force;
a.vy -= (dy / dist) * force;
b.vx += (dx / dist) * force;
b.vy += (dy / dist) * force;
}
}
// Attraction along edges
graph.edges.forEach((e) => {
const a = nodeMap.get(e.source);
const b = nodeMap.get(e.target);
if (!a || !b) return;
let dx = b.x - a.x, dy = b.y - a.y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
let force = (dist - 100) * 0.01 * alpha;
a.vx += (dx / dist) * force;
a.vy += (dy / dist) * force;
b.vx -= (dx / dist) * force;
b.vy -= (dy / dist) * force;
});
// Center gravity
nodeMap.forEach((n) => {
n.vx += (W / 2 - n.x) * 0.001 * alpha;
n.vy += (H / 2 - n.y) * 0.001 * alpha;
n.vx *= 0.9;
n.vy *= 0.9;
n.x += n.vx;
n.y += n.vy;
n.x = Math.max(30, Math.min(W - 30, n.x));
n.y = Math.max(30, Math.min(H - 30, n.y));
});
// Draw
ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, W, H);
// Edges
ctx.strokeStyle = 'rgba(79, 195, 247, 0.2)';
ctx.lineWidth = 1;
graph.edges.forEach((e) => {
const a = nodeMap.get(e.source);
const b = nodeMap.get(e.target);
if (!a || !b) return;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
});
// Nodes
graph.nodes.forEach((n) => {
const pos = nodeMap.get(n.id)!;
const color = GROUP_COLORS[n.group] || '#ffffff';
const radius = n.id === selectedNode ? 14 : 10;
ctx.beginPath();
ctx.arc(pos.x, pos.y, radius, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = n.id === selectedNode ? 3 : 1;
ctx.stroke();
ctx.fillStyle = '#ffffff';
ctx.font = '11px monospace';
ctx.textAlign = 'center';
ctx.fillText(n.label, pos.x, pos.y - radius - 5);
});
if (iterations < 300) {
animFrame = requestAnimationFrame(simulate);
}
};
simulate();
return () => cancelAnimationFrame(animFrame);
}, [graph, selectedNode]);
return (
<Box>
<Typography variant="h4" gutterBottom>Relationship Graph</Typography>
<Card sx={{ bgcolor: '#161b22', mb: 2 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<Typography>Depth:</Typography>
<Slider value={depth} onChange={(_, v) => setDepth(v as number)}
min={1} max={5} step={1} marks sx={{ width: 200 }} />
<Button variant="contained" onClick={fetchGraph} disabled={loading}>
Reload
</Button>
{graph && (
<Typography variant="body2" color="text.secondary">
{graph.nodes.length} nodes, {graph.edges.length} edges
</Typography>
)}
</Box>
</CardContent>
</Card>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}><CircularProgress /></Box>
) : graph?.nodes.length === 0 ? (
<Alert severity="info">No data to display. Add CIs and relationships first.</Alert>
) : (
<canvas
ref={canvasRef}
style={{ width: '100%', borderRadius: 8, cursor: 'pointer' }}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Find closest node (simplified)
let closest = null, minDist = 20;
graph?.nodes.forEach((n) => {
const pos = (canvasRef.current as any)?._nodePositions?.get(n.id);
if (!pos) return;
const d = Math.sqrt((pos.x - x) ** 2 + (pos.y - y) ** 2);
if (d < minDist) { closest = n.id; minDist = d; }
});
if (closest) setSelectedNode(closest);
}}
/>
)}
</Box>
);
}