- 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)
196 lines
6.4 KiB
TypeScript
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>
|
|
);
|
|
}
|