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:
2026-02-20 17:18:33 -08:00
parent a4cff9894c
commit 3dc5d236a2
23 changed files with 2680 additions and 70 deletions

View File

@@ -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>