- Added api-security-hardening (helmet, rate limits) - Added nodejs-backend-patterns (error handling) - Added observability-monitoring (pino logging) - Added websocket-engineer (socket.io real-time updates) - Added docker (Multi-stage build, compose) - Added vitest (testing configuration and store tests) - Added data-visualizer (MetricsBar and HostChart) - Added infrastructure-monitoring/proxmox-admin/network-engineer types - Fixed UI accessibility and styling - Cleaned up node_modules tracking
328 lines
9.9 KiB
TypeScript
328 lines
9.9 KiB
TypeScript
import { useEffect, useRef, useCallback, useState, memo } from 'react';
|
|
import { ReactFlowProvider } from '@xyflow/react';
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
import { io as ioClient, Socket } from 'socket.io-client';
|
|
import { useTopologyStore } from './store/topologyStore';
|
|
import {
|
|
defaultNetworkInfo,
|
|
discoverHosts,
|
|
convertToTopology,
|
|
DiscoveredHost
|
|
} from './services/discovery';
|
|
import Header from './components/Header';
|
|
import LeftPanel from './components/LeftPanel';
|
|
import RightPanel from './components/RightPanel';
|
|
import TopologyGraph from './components/Graph/TopologyGraph';
|
|
import CommandPalette from './components/CommandPalette';
|
|
import StaleWarning from './components/StaleWarning';
|
|
import TerminalPanel from './components/TerminalPanel';
|
|
import MetricsBar from './components/Dashboard/MetricsBar';
|
|
|
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
|
|
|
interface ApiHost {
|
|
name: string;
|
|
ip: string;
|
|
online: boolean;
|
|
containers?: string[];
|
|
services?: string[];
|
|
vms?: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }>;
|
|
}
|
|
|
|
interface ApiDiscoveryResponse {
|
|
hosts: ApiHost[];
|
|
timestamp: string;
|
|
errors: string[];
|
|
}
|
|
|
|
function App() {
|
|
const {
|
|
setNodes,
|
|
setEdges,
|
|
setNetworkInfo,
|
|
setHosts,
|
|
setLastUpdated,
|
|
setIsLoading,
|
|
setDataSource,
|
|
incrementFailures,
|
|
resetFailures,
|
|
setLastSuccessfulDiscovery,
|
|
} = useTopologyStore(useShallow((s) => ({
|
|
setNodes: s.setNodes,
|
|
setEdges: s.setEdges,
|
|
setNetworkInfo: s.setNetworkInfo,
|
|
setHosts: s.setHosts,
|
|
setLastUpdated: s.setLastUpdated,
|
|
setIsLoading: s.setIsLoading,
|
|
setDataSource: s.setDataSource,
|
|
incrementFailures: s.incrementFailures,
|
|
resetFailures: s.resetFailures,
|
|
setLastSuccessfulDiscovery: s.setLastSuccessfulDiscovery,
|
|
})));
|
|
|
|
const setConnectionStatus = useTopologyStore((s) => s.setConnectionStatus);
|
|
|
|
const {
|
|
leftPanelOpen,
|
|
rightPanelOpen,
|
|
isLoading,
|
|
pollInterval,
|
|
terminalOpen,
|
|
terminalHost,
|
|
} = useTopologyStore(useShallow((s) => ({
|
|
leftPanelOpen: s.leftPanelOpen,
|
|
rightPanelOpen: s.rightPanelOpen,
|
|
isLoading: s.isLoading,
|
|
pollInterval: s.pollInterval,
|
|
terminalOpen: s.terminalOpen,
|
|
terminalHost: s.terminalHost,
|
|
})));
|
|
|
|
const toggleCommandPalette = useTopologyStore((s) => s.toggleCommandPalette);
|
|
const closeTerminal = useTopologyStore((s) => s.closeTerminal);
|
|
|
|
const isLoadingRef = useRef(isLoading);
|
|
isLoadingRef.current = isLoading;
|
|
const pollIntervalRef = useRef(pollInterval);
|
|
pollIntervalRef.current = pollInterval;
|
|
|
|
const loadData = useCallback(async () => {
|
|
if (isLoadingRef.current) return;
|
|
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE_URL}/api/discover`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data: ApiDiscoveryResponse = await response.json();
|
|
|
|
const discoveredHosts: DiscoveredHost[] = data.hosts.map((h: ApiHost) => ({
|
|
name: h.name,
|
|
ip: h.ip,
|
|
online: h.online,
|
|
containers: (h.containers || []).map((c: string) => ({
|
|
name: c,
|
|
image: '',
|
|
status: 'running',
|
|
ports: [],
|
|
created: ''
|
|
})),
|
|
services: h.services,
|
|
vms: h.vms
|
|
}));
|
|
|
|
const { nodes, edges } = convertToTopology(discoveredHosts, defaultNetworkInfo);
|
|
|
|
const hosts = discoveredHosts.map(h => ({
|
|
name: h.name,
|
|
ip: h.ip,
|
|
type: (h.name === 'ubuntu' ? 'vm' :
|
|
h.name === 'proxmox' || h.name === 'truenas' ? 'physical' : 'rpi5') as 'vm' | 'physical' | 'rpi5' | 'container',
|
|
role: h.name === 'ubuntu' ? 'Primary Docker Host' :
|
|
h.name === 'grizzley' ? 'Edge Services' :
|
|
h.name === 'truenas' ? 'Storage (NAS)' :
|
|
h.name === 'proxmox' ? 'Hypervisor' : 'Host',
|
|
containers: h.containers.map(c => c.name),
|
|
services: h.services,
|
|
vms: h.vms
|
|
}));
|
|
|
|
setNodes(nodes);
|
|
setEdges(edges);
|
|
setNetworkInfo(defaultNetworkInfo);
|
|
setHosts(hosts);
|
|
setLastUpdated(new Date());
|
|
setDataSource('live');
|
|
setLastSuccessfulDiscovery(new Date());
|
|
resetFailures();
|
|
} else {
|
|
throw new Error(`API error: ${response.status}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Discovery failed, using simulated data:', error);
|
|
const discoveryResult = await discoverHosts(['ubuntu', 'grizzley', 'truenas', 'ice', 'panda', 'proxmox']);
|
|
|
|
const { nodes, edges } = convertToTopology(discoveryResult.hosts, defaultNetworkInfo);
|
|
|
|
const hosts = discoveryResult.hosts.map(h => ({
|
|
name: h.name,
|
|
ip: h.ip,
|
|
type: (h.name === 'ubuntu' ? 'vm' :
|
|
h.name === 'proxmox' || h.name === 'truenas' ? 'physical' : 'rpi5') as 'vm' | 'physical' | 'rpi5' | 'container',
|
|
role: h.name === 'ubuntu' ? 'Primary Docker Host' :
|
|
h.name === 'grizzley' ? 'Edge Services' :
|
|
h.name === 'truenas' ? 'Storage (NAS)' :
|
|
h.name === 'proxmox' ? 'Hypervisor' : 'Host',
|
|
containers: h.containers.map(c => c.name)
|
|
}));
|
|
|
|
setNodes(nodes);
|
|
setEdges(edges);
|
|
setNetworkInfo(defaultNetworkInfo);
|
|
setHosts(hosts);
|
|
setLastUpdated(new Date());
|
|
setDataSource('simulated');
|
|
incrementFailures();
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [setNodes, setEdges, setNetworkInfo, setHosts, setLastUpdated, setIsLoading, setDataSource, incrementFailures, resetFailures, setLastSuccessfulDiscovery]);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
e.preventDefault();
|
|
toggleCommandPalette();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [toggleCommandPalette]);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const intervalId = setInterval(loadData, pollIntervalRef.current);
|
|
return () => clearInterval(intervalId);
|
|
}, [loadData]);
|
|
|
|
// --- WebSocket connection (websocket-engineer skill) ---
|
|
useEffect(() => {
|
|
const socket: Socket = ioClient(API_BASE_URL, {
|
|
transports: ['websocket', 'polling'],
|
|
reconnectionDelay: 1000,
|
|
reconnectionDelayMax: 5000,
|
|
reconnectionAttempts: Infinity,
|
|
});
|
|
|
|
socket.on('connect', () => {
|
|
setConnectionStatus('ws');
|
|
});
|
|
|
|
socket.on('disconnect', () => {
|
|
setConnectionStatus('polling');
|
|
});
|
|
|
|
socket.on('connect_error', () => {
|
|
setConnectionStatus('polling');
|
|
});
|
|
|
|
// Listen for real-time topology updates
|
|
socket.on('topology:update', (data: ApiDiscoveryResponse) => {
|
|
if (data?.hosts) {
|
|
const discoveredHosts: DiscoveredHost[] = data.hosts.map((h: ApiHost) => ({
|
|
name: h.name,
|
|
ip: h.ip,
|
|
online: h.online,
|
|
containers: (h.containers || []).map((c: string) => ({
|
|
name: c, image: '', status: 'running', ports: [], created: ''
|
|
})),
|
|
services: h.services,
|
|
vms: h.vms
|
|
}));
|
|
|
|
const { nodes, edges } = convertToTopology(discoveredHosts, defaultNetworkInfo);
|
|
setNodes(nodes);
|
|
setEdges(edges);
|
|
setLastUpdated(new Date());
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
socket.disconnect();
|
|
};
|
|
}, [setConnectionStatus, setNodes, setEdges, setLastUpdated]);
|
|
|
|
return (
|
|
<ReactFlowProvider>
|
|
<div className="h-screen w-screen flex flex-col bg-slate-900">
|
|
{/* Skip link for accessibility */}
|
|
<a href="#main-content" className="skip-link">
|
|
Skip to main content
|
|
</a>
|
|
|
|
<StaleWarning />
|
|
<Header onRefresh={loadData} isLoading={isLoading} />
|
|
<MetricsBar />
|
|
|
|
<div className="flex-1 flex overflow-hidden" role="main" id="main-content" tabIndex={-1}>
|
|
{leftPanelOpen && (
|
|
<LeftPanel />
|
|
)}
|
|
|
|
<div className="flex-1">
|
|
<TopologyGraph />
|
|
</div>
|
|
|
|
{rightPanelOpen && (
|
|
<RightPanel />
|
|
)}
|
|
</div>
|
|
|
|
<Footer />
|
|
<CommandPalette />
|
|
{terminalOpen && terminalHost && (
|
|
<TerminalPanel host={terminalHost} onClose={closeTerminal} />
|
|
)}
|
|
</div>
|
|
</ReactFlowProvider>
|
|
);
|
|
}
|
|
|
|
const Footer = memo(function Footer() {
|
|
const { lastUpdated, nodes, dataSource, pollInterval } = useTopologyStore(
|
|
useShallow((s) => ({
|
|
lastUpdated: s.lastUpdated,
|
|
nodes: s.nodes,
|
|
dataSource: s.dataSource,
|
|
pollInterval: s.pollInterval,
|
|
}))
|
|
);
|
|
const [countdown, setCountdown] = useState(Math.ceil(pollInterval / 1000));
|
|
const pollIntervalRef = useRef(pollInterval);
|
|
pollIntervalRef.current = pollInterval;
|
|
|
|
const formatTime = (date: Date | null) => {
|
|
if (!date) return 'Never';
|
|
return date.toLocaleTimeString();
|
|
};
|
|
|
|
useEffect(() => {
|
|
setCountdown(Math.ceil(pollInterval / 1000));
|
|
|
|
const intervalId = setInterval(() => {
|
|
setCountdown(prev => {
|
|
if (prev <= 1) return Math.ceil(pollIntervalRef.current / 1000);
|
|
return prev - 1;
|
|
});
|
|
}, 1000);
|
|
|
|
return () => clearInterval(intervalId);
|
|
}, [lastUpdated, pollInterval]);
|
|
|
|
return (
|
|
<div
|
|
className="h-8 bg-slate-800 border-t border-slate-700 px-4 flex items-center justify-between text-xs text-slate-400"
|
|
aria-live="polite"
|
|
aria-atomic="true"
|
|
>
|
|
<span>Nodes: {nodes.length}</span>
|
|
<div className="flex items-center gap-4">
|
|
<span className={`px-2 py-0.5 rounded ${dataSource === 'live' ? 'bg-green-900 text-green-400' : 'bg-yellow-900 text-yellow-400'}`}>
|
|
{dataSource === 'live' ? 'Live' : 'Simulated'}
|
|
</span>
|
|
<span>Next refresh: {countdown}s</span>
|
|
<span>Last updated: {formatTime(lastUpdated)}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export default App;
|