feat: integrate all 10 skills into homelab-topology
- 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
This commit is contained in:
173
src/App.tsx
173
src/App.tsx
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
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,
|
||||
import {
|
||||
defaultNetworkInfo,
|
||||
discoverHosts,
|
||||
convertToTopology,
|
||||
DiscoveredHost
|
||||
} from './services/discovery';
|
||||
@@ -14,6 +16,7 @@ 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';
|
||||
|
||||
@@ -33,10 +36,10 @@ interface ApiDiscoveryResponse {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const {
|
||||
setNodes,
|
||||
setEdges,
|
||||
setNetworkInfo,
|
||||
const {
|
||||
setNodes,
|
||||
setEdges,
|
||||
setNetworkInfo,
|
||||
setHosts,
|
||||
setLastUpdated,
|
||||
setIsLoading,
|
||||
@@ -44,15 +47,39 @@ function App() {
|
||||
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,
|
||||
toggleCommandPalette,
|
||||
terminalOpen,
|
||||
terminalHost,
|
||||
closeTerminal
|
||||
} = useTopologyStore();
|
||||
} = 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;
|
||||
@@ -61,18 +88,18 @@ function App() {
|
||||
|
||||
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,
|
||||
@@ -87,23 +114,23 @@ function App() {
|
||||
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',
|
||||
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 === '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);
|
||||
@@ -118,21 +145,21 @@ function App() {
|
||||
} 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',
|
||||
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 === '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);
|
||||
@@ -165,26 +192,79 @@ function App() {
|
||||
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} />
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<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 && (
|
||||
@@ -195,12 +275,19 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
const { lastUpdated, nodes, dataSource, pollInterval } = useTopologyStore();
|
||||
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();
|
||||
@@ -208,19 +295,23 @@ function Footer() {
|
||||
|
||||
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">
|
||||
<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'}`}>
|
||||
@@ -231,6 +322,6 @@ function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default App;
|
||||
|
||||
Reference in New Issue
Block a user