feat: expand discovery with systemd services, LXC/VMs, SSH terminal, and filebrowser
- Add systemd service discovery to backend - Add Proxmox LXC/VM detection - Add hostType field to config for better host categorization - Fix SSH trust between hosts (ubuntu/grizzley -> truenas/proxmox) - Add SSH terminal support via xterm.js - Add filebrowser for browsing host filesystems - Update frontend types and components for new node types
This commit is contained in:
147
src/App.tsx
147
src/App.tsx
@@ -3,16 +3,34 @@ import { ReactFlowProvider } from '@xyflow/react';
|
||||
import { useTopologyStore } from './store/topologyStore';
|
||||
import {
|
||||
defaultNetworkInfo,
|
||||
defaultHosts,
|
||||
discoverHosts,
|
||||
convertToTopology
|
||||
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';
|
||||
|
||||
const POLLING_INTERVAL_MS = 30000;
|
||||
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 {
|
||||
@@ -22,26 +40,86 @@ function App() {
|
||||
setHosts,
|
||||
setLastUpdated,
|
||||
setIsLoading,
|
||||
setDataSource,
|
||||
incrementFailures,
|
||||
resetFailures,
|
||||
setLastSuccessfulDiscovery,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
isLoading
|
||||
isLoading,
|
||||
pollInterval,
|
||||
toggleCommandPalette,
|
||||
terminalOpen,
|
||||
terminalHost,
|
||||
closeTerminal
|
||||
} = useTopologyStore();
|
||||
|
||||
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 hostNames = ['ubuntu', 'grizzley', 'truenas', 'ice', 'panda', 'proxmox'];
|
||||
const discoveryResult = await discoverHosts(hostNames);
|
||||
const response = await fetch(`${API_BASE_URL}/api/discover`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
const { nodes, edges } = convertToTopology(
|
||||
discoveryResult.hosts,
|
||||
defaultNetworkInfo
|
||||
);
|
||||
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,
|
||||
@@ -60,29 +138,37 @@ function App() {
|
||||
setNetworkInfo(defaultNetworkInfo);
|
||||
setHosts(hosts);
|
||||
setLastUpdated(new Date());
|
||||
} catch (error) {
|
||||
console.error('Discovery failed:', error);
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
setNetworkInfo(defaultNetworkInfo);
|
||||
setHosts(defaultHosts);
|
||||
setLastUpdated(new Date());
|
||||
setDataSource('simulated');
|
||||
incrementFailures();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [setNodes, setEdges, setNetworkInfo, setHosts, setLastUpdated, setIsLoading]);
|
||||
}, [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();
|
||||
|
||||
const intervalId = setInterval(loadData, POLLING_INTERVAL_MS);
|
||||
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(loadData, pollIntervalRef.current);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [loadData]);
|
||||
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<div className="h-screen w-screen flex flex-col bg-slate-900">
|
||||
<StaleWarning />
|
||||
<Header onRefresh={loadData} isLoading={isLoading} />
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
@@ -100,14 +186,20 @@ function App() {
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<CommandPalette />
|
||||
{terminalOpen && terminalHost && (
|
||||
<TerminalPanel host={terminalHost} onClose={closeTerminal} />
|
||||
)}
|
||||
</div>
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
const { lastUpdated, nodes } = useTopologyStore();
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
const { lastUpdated, nodes, dataSource, pollInterval } = useTopologyStore();
|
||||
const [countdown, setCountdown] = useState(Math.ceil(pollInterval / 1000));
|
||||
const pollIntervalRef = useRef(pollInterval);
|
||||
pollIntervalRef.current = pollInterval;
|
||||
|
||||
const formatTime = (date: Date | null) => {
|
||||
if (!date) return 'Never';
|
||||
@@ -115,22 +207,25 @@ function Footer() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setCountdown(30);
|
||||
setCountdown(Math.ceil(pollInterval / 1000));
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
setCountdown(prev => {
|
||||
if (prev <= 1) return 30;
|
||||
if (prev <= 1) return Math.ceil(pollIntervalRef.current / 1000);
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [lastUpdated]);
|
||||
}, [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">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user