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

@@ -0,0 +1,359 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Search, Loader2, Network, HardDrive, Box, Database, ArrowLeftRight, ArrowUpDown, Router, Wifi, Monitor, Container, FolderTree, Folder, X } from 'lucide-react';
import { useTopologyStore } from '../store/topologyStore';
import { NodeType } from '../types';
interface Command {
id: string;
label: string;
description?: string;
icon: React.ReactNode;
category: 'action' | 'filter' | 'view' | 'orientation';
action: () => void;
}
const nodeTypeLabels: Record<NodeType, string> = {
gateway: 'Gateway',
vlan: 'VLAN',
wifi: 'WiFi',
host_physical: 'Physical Host',
host_vm: 'VM Host',
host_container: 'Container Host',
vm_lxc: 'LXC Container',
vm_qemu: 'QEMU VM',
systemd_service: 'Systemd Service',
service: 'Service',
volume: 'Volume',
mount: 'Mount',
path: 'Path',
};
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
gateway: <Router className="w-4 h-4" />,
vlan: <Network className="w-4 h-4" />,
wifi: <Wifi className="w-4 h-4" />,
host_physical: <HardDrive className="w-4 h-4" />,
host_vm: <Monitor className="w-4 h-4" />,
host_container: <Container className="w-4 h-4" />,
vm_lxc: <Container className="w-4 h-4" />,
vm_qemu: <Monitor className="w-4 h-4" />,
systemd_service: <Box className="w-4 h-4" />,
service: <Box className="w-4 h-4" />,
volume: <Database className="w-4 h-4" />,
mount: <FolderTree className="w-4 h-4" />,
path: <Folder className="w-4 h-4" />
};
function fuzzyMatch(pattern: string, text: string): boolean {
const patternLower = pattern.toLowerCase();
const textLower = text.toLowerCase();
let patternIdx = 0;
for (let i = 0; i < textLower.length && patternIdx < patternLower.length; i++) {
if (textLower[i] === patternLower[patternIdx]) {
patternIdx++;
}
}
return patternIdx === patternLower.length;
}
interface CommandPaletteProps {
onRefresh?: () => Promise<void>;
}
export default function CommandPalette({ onRefresh }: CommandPaletteProps) {
const {
commandPaletteOpen,
toggleCommandPalette,
typeFilters,
toggleTypeFilter,
setViewMode,
setOrientation
} = useTopologyStore();
const [search, setSearch] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const commands = useMemo<Command[]>(() => {
const cmds: Command[] = [
{
id: 'refresh',
label: 'Refresh Discovery',
description: 'Reload topology data from hosts',
icon: <Loader2 className="w-4 h-4" />,
category: 'action',
action: async () => {
if (onRefresh) {
await onRefresh();
}
toggleCommandPalette();
}
},
{
id: 'view-full',
label: 'Set View: Full',
description: 'Show complete topology',
icon: <Network className="w-4 h-4" />,
category: 'view',
action: () => {
setViewMode('full');
toggleCommandPalette();
}
},
{
id: 'view-network',
label: 'Set View: Network',
description: 'Show network infrastructure',
icon: <Network className="w-4 h-4" />,
category: 'view',
action: () => {
setViewMode('network');
toggleCommandPalette();
}
},
{
id: 'view-host',
label: 'Set View: Hosts',
description: 'Show hosts and containers',
icon: <HardDrive className="w-4 h-4" />,
category: 'view',
action: () => {
setViewMode('host');
toggleCommandPalette();
}
},
{
id: 'view-service',
label: 'Set View: Services',
description: 'Show services and volumes',
icon: <Box className="w-4 h-4" />,
category: 'view',
action: () => {
setViewMode('service');
toggleCommandPalette();
}
},
{
id: 'view-filesystem',
label: 'Set View: Files',
description: 'Show filesystem hierarchy',
icon: <Database className="w-4 h-4" />,
category: 'view',
action: () => {
setViewMode('filesystem');
toggleCommandPalette();
}
},
{
id: 'orientation-lr',
label: 'Set Orientation: Left to Right',
icon: <ArrowLeftRight className="w-4 h-4" />,
category: 'orientation',
action: () => {
setOrientation('LR');
toggleCommandPalette();
}
},
{
id: 'orientation-tb',
label: 'Set Orientation: Top to Bottom',
icon: <ArrowUpDown className="w-4 h-4" />,
category: 'orientation',
action: () => {
setOrientation('TB');
toggleCommandPalette();
}
}
];
const nodeTypes: NodeType[] = [
'gateway', 'vlan', 'wifi', 'host_physical', 'host_vm',
'host_container', 'service', 'volume', 'mount', 'path'
];
nodeTypes.forEach((type) => {
const isActive = typeFilters.includes(type);
cmds.push({
id: `filter-${type}`,
label: `${isActive ? 'Disable' : 'Enable'} Filter: ${nodeTypeLabels[type]}`,
description: `${isActive ? 'Hide' : 'Show'} ${nodeTypeLabels[type]} nodes`,
icon: nodeTypeIcons[type],
category: 'filter',
action: () => {
toggleTypeFilter(type);
toggleCommandPalette();
}
});
});
return cmds;
}, [typeFilters, onRefresh, setViewMode, setOrientation, toggleTypeFilter, toggleCommandPalette]);
const filteredCommands = useMemo(() => {
if (!search.trim()) return commands;
return commands.filter(cmd => fuzzyMatch(search, cmd.label));
}, [commands, search]);
useEffect(() => {
setSelectedIndex(0);
}, [filteredCommands.length]);
useEffect(() => {
if (commandPaletteOpen) {
setSearch('');
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [commandPaletteOpen]);
useEffect(() => {
if (listRef.current) {
const selectedEl = listRef.current.children[selectedIndex] as HTMLElement;
if (selectedEl) {
selectedEl.scrollIntoView({ block: 'nearest' });
}
}
}, [selectedIndex]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, filteredCommands.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (filteredCommands[selectedIndex]) {
filteredCommands[selectedIndex].action();
}
break;
case 'Escape':
e.preventDefault();
toggleCommandPalette();
break;
}
}, [filteredCommands, selectedIndex, toggleCommandPalette]);
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
toggleCommandPalette();
}
}, [toggleCommandPalette]);
if (!commandPaletteOpen) return null;
const categoryLabels: Record<Command['category'], string> = {
action: 'Actions',
filter: 'Filters',
view: 'View Mode',
orientation: 'Orientation'
};
const groupedCommands = filteredCommands.reduce((acc, cmd) => {
if (!acc[cmd.category]) {
acc[cmd.category] = [];
}
acc[cmd.category].push(cmd);
return acc;
}, {} as Record<Command['category'], Command[]>);
let globalIndex = 0;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] bg-black/60 backdrop-blur-sm"
onClick={handleBackdropClick}
>
<div className="w-full max-w-xl bg-slate-800 rounded-xl shadow-2xl border border-slate-700 overflow-hidden animate-in fade-in zoom-in-95 duration-150">
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-700">
<Search className="w-5 h-5 text-slate-400 flex-shrink-0" />
<input
ref={inputRef}
type="text"
placeholder="Type a command..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 bg-transparent text-white placeholder-slate-400 outline-none text-base"
/>
<button
onClick={toggleCommandPalette}
className="p-1 text-slate-400 hover:text-white hover:bg-slate-700 rounded"
>
<X className="w-4 h-4" />
</button>
</div>
<div ref={listRef} className="max-h-[50vh] overflow-y-auto py-2">
{filteredCommands.length === 0 ? (
<div className="px-4 py-8 text-center text-slate-400">
No commands found
</div>
) : (
Object.entries(groupedCommands).map(([category, cmds]) => (
<div key={category}>
<div className="px-4 py-2 text-xs font-medium text-slate-500 uppercase tracking-wider">
{categoryLabels[category as Command['category']]}
</div>
{cmds.map((cmd) => {
const currentIndex = globalIndex++;
const isSelected = currentIndex === selectedIndex;
return (
<button
key={cmd.id}
onClick={cmd.action}
onMouseEnter={() => setSelectedIndex(currentIndex)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
isSelected
? 'bg-indigo-500/20 text-white'
: 'text-slate-300 hover:bg-slate-700/50'
}`}
>
<span className={`flex-shrink-0 ${isSelected ? 'text-indigo-400' : 'text-slate-400'}`}>
{cmd.icon}
</span>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{cmd.label}</div>
{cmd.description && (
<div className="text-xs text-slate-500 truncate">{cmd.description}</div>
)}
</div>
{isSelected && (
<span className="text-xs text-slate-500 flex-shrink-0">
Enter
</span>
)}
</button>
);
})}
</div>
))
)}
</div>
<div className="px-4 py-2 border-t border-slate-700 flex items-center gap-4 text-xs text-slate-500">
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-slate-700 rounded text-slate-400"></kbd>
Navigate
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-slate-700 rounded text-slate-400">Enter</kbd>
Select
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-slate-700 rounded text-slate-400">Esc</kbd>
Close
</span>
</div>
</div>
</div>
);
}