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:
42
src/components/AGENTS.md
Normal file
42
src/components/AGENTS.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Components (React UI)
|
||||
|
||||
**Generated:** 2026-02-19
|
||||
**Location:** src/components/
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
React components for the topology visualization UI - header, panels, and graph.
|
||||
|
||||
## COMPONENTS
|
||||
|
||||
| Component | File | Purpose |
|
||||
|-----------|------|---------|
|
||||
| Header | Header.tsx | Top bar with title, refresh button, view mode |
|
||||
| LeftPanel | LeftPanel.tsx | Collapsible host list sidebar |
|
||||
| RightPanel | RightPanel.tsx | Collapsible details panel with tabs |
|
||||
| TopologyGraph | Graph/TopologyGraph.tsx | React Flow graph rendering |
|
||||
|
||||
## ADDING A NEW COMPONENT
|
||||
|
||||
1. Create in `src/components/{Name}.tsx`
|
||||
2. Export as default or named export
|
||||
3. Import in parent (e.g., App.tsx)
|
||||
|
||||
## PATTERNS
|
||||
|
||||
- Use Zustand store: `import { useTopologyStore } from '../store/topologyStore'`
|
||||
- Tailwind CSS classes for styling
|
||||
- Props interfaces for type safety
|
||||
- Follow existing component patterns (Header is good reference)
|
||||
|
||||
## SUB-COMPONENTS
|
||||
|
||||
- `Graph/TopologyGraph.tsx`: Uses @xyflow/react (React Flow) + dagre for auto-layout
|
||||
- Nodes: Network → Host → Container → Volume hierarchy
|
||||
- Edges: Connect related nodes
|
||||
|
||||
## NOTES
|
||||
|
||||
- Panels are collapsible via store state (leftPanelOpen, rightPanelOpen)
|
||||
- Graph auto-layouts on data change using dagre
|
||||
- All components use Tailwind for styling
|
||||
359
src/components/CommandPalette.tsx
Normal file
359
src/components/CommandPalette.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
src/components/FileBrowser.tsx
Normal file
146
src/components/FileBrowser.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Folder, File, ArrowLeft, X, RefreshCw, Terminal as TerminalIcon } from 'lucide-react';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory' | 'symlink';
|
||||
size: number;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
interface FileBrowserProps {
|
||||
host: string;
|
||||
initialPath?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function FileBrowser({ host, initialPath = '/', onClose }: FileBrowserProps) {
|
||||
const [currentPath, setCurrentPath] = useState(initialPath);
|
||||
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchFiles = async (path: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/files/browse/${host}?path=${encodeURIComponent(path)}`
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
setError(data.error);
|
||||
setFiles([]);
|
||||
} else {
|
||||
setFiles(data.files || []);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to fetch files';
|
||||
setError(msg);
|
||||
setFiles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFiles(currentPath);
|
||||
}, [host, currentPath]);
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
setCurrentPath(path);
|
||||
};
|
||||
|
||||
const goUp = () => {
|
||||
const parts = currentPath.split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
const newPath = parts.length === 0 ? '/' : '/' + parts.join('/');
|
||||
navigateTo(newPath);
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
|
||||
};
|
||||
|
||||
const getFileIcon = (type: string) => {
|
||||
if (type === 'directory') return <Folder className="w-4 h-4 text-amber-400" />;
|
||||
if (type === 'symlink') return <TerminalIcon className="w-4 h-4 text-blue-400" />;
|
||||
return <File className="w-4 h-4 text-slate-400" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-slate-900/95 flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-slate-800 border-b border-slate-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-400">/</span>
|
||||
<span className="text-cyan-400 font-medium">{host}</span>
|
||||
<span className="text-slate-500">:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={currentPath}
|
||||
onChange={(e) => setCurrentPath(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && navigateTo(e.currentTarget.value)}
|
||||
className="bg-slate-700 text-slate-200 px-2 py-1 rounded text-sm font-mono w-64"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => fetchFiles(currentPath)}
|
||||
className="p-1 hover:bg-slate-700 rounded transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 text-slate-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-slate-700 rounded transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<RefreshCw className="w-6 h-6 text-slate-400 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-red-400 p-4">{error}</div>
|
||||
) : (
|
||||
<div className="font-mono text-sm">
|
||||
{currentPath !== '/' && (
|
||||
<button
|
||||
onClick={goUp}
|
||||
className="flex items-center gap-2 w-full px-2 py-1 hover:bg-slate-800 rounded"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-slate-400">..</span>
|
||||
</button>
|
||||
)}
|
||||
{files.map((file) => (
|
||||
<button
|
||||
key={file.path}
|
||||
onClick={() => file.type === 'directory' && navigateTo(file.path)}
|
||||
className={`flex items-center gap-2 w-full px-2 py-1 hover:bg-slate-800 rounded ${
|
||||
file.type !== 'directory' ? 'cursor-default' : ''
|
||||
}`}
|
||||
>
|
||||
{getFileIcon(file.type)}
|
||||
<span className="text-slate-200 flex-1 text-left">{file.name}</span>
|
||||
<span className="text-slate-500 text-xs">{formatSize(file.size)}</span>
|
||||
<span className="text-slate-600 text-xs w-24">{file.modified}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,27 +24,36 @@ const nodeIcons: Record<NodeType, React.ReactNode> = {
|
||||
host_physical: <Server className="w-5 h-5" />,
|
||||
host_vm: <Server className="w-5 h-5" />,
|
||||
host_container: <Server className="w-5 h-5" />,
|
||||
vm_lxc: <Box className="w-4 h-4" />,
|
||||
vm_qemu: <Server className="w-5 h-5" />,
|
||||
systemd_service: <Box className="w-4 h-4" />,
|
||||
service: <Box className="w-4 h-4" />,
|
||||
volume: <Database className="w-4 h-4" />,
|
||||
mount: <Database className="w-4 h-4" />,
|
||||
path: <Folder className="w-4 h-4" />,
|
||||
};
|
||||
|
||||
function CustomNode({ data, selected }: NodeProps) {
|
||||
function CustomNode({ data, selected, id }: NodeProps) {
|
||||
const { highlightPath } = useTopologyStore();
|
||||
const nodeData = data as { type?: NodeType; category?: ServiceCategory; status?: 'running' | 'stopped' | 'unknown'; label?: string; ip?: string };
|
||||
const nodeColor = getNodeColor(nodeData.type || 'service', nodeData.category);
|
||||
const statusColor = getStatusColor(nodeData.status || 'unknown');
|
||||
const isHighlighted = highlightPath.includes(id);
|
||||
const isDimmed = highlightPath.length > 0 && !isHighlighted;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`px-4 py-3 rounded-xl border-2 transition-all ${
|
||||
selected
|
||||
? 'border-sky-400 shadow-lg shadow-sky-400/20'
|
||||
: 'border-slate-600 hover:border-slate-500'
|
||||
: isHighlighted
|
||||
? 'border-indigo-400 shadow-lg shadow-indigo-400/20'
|
||||
: 'border-slate-600 hover:border-slate-500'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: '#1E293B',
|
||||
minWidth: '140px'
|
||||
backgroundColor: isDimmed ? '#0F172A' : '#1E293B',
|
||||
minWidth: '140px',
|
||||
opacity: isDimmed ? 0.4 : 1
|
||||
}}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} className="!bg-slate-400" />
|
||||
@@ -127,7 +136,8 @@ export default function TopologyGraph() {
|
||||
setSelectedNode,
|
||||
getFilteredNodes,
|
||||
orientation,
|
||||
viewMode
|
||||
viewMode,
|
||||
highlightPath
|
||||
} = useTopologyStore();
|
||||
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
@@ -160,27 +170,36 @@ export default function TopologyGraph() {
|
||||
|
||||
const newEdges: Edge[] = storeEdges
|
||||
.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target))
|
||||
.map(edge => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: 'smoothstep',
|
||||
animated: edge.source === selectedNodeId || edge.target === selectedNodeId,
|
||||
style: {
|
||||
stroke: edge.source === selectedNodeId || edge.target === selectedNodeId
|
||||
? '#38BDF8'
|
||||
: '#475569',
|
||||
strokeWidth: edge.source === selectedNodeId || edge.target === selectedNodeId
|
||||
? 2
|
||||
: 1,
|
||||
},
|
||||
markerEnd: {
|
||||
type: 'arrowclosed' as const,
|
||||
color: edge.source === selectedNodeId || edge.target === selectedNodeId
|
||||
? '#38BDF8'
|
||||
: '#475569',
|
||||
},
|
||||
}));
|
||||
.map(edge => {
|
||||
const isPathEdge = highlightPath.includes(edge.source) && highlightPath.includes(edge.target);
|
||||
const isSelected = edge.source === selectedNodeId || edge.target === selectedNodeId;
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: 'smoothstep',
|
||||
animated: isSelected || isPathEdge,
|
||||
style: {
|
||||
stroke: isSelected
|
||||
? '#38BDF8'
|
||||
: isPathEdge
|
||||
? '#818CF8'
|
||||
: '#475569',
|
||||
strokeWidth: isSelected || isPathEdge
|
||||
? 2
|
||||
: 1,
|
||||
opacity: highlightPath.length > 0 && !isPathEdge ? 0.3 : 1
|
||||
},
|
||||
markerEnd: {
|
||||
type: 'arrowclosed' as const,
|
||||
color: isSelected
|
||||
? '#38BDF8'
|
||||
: isPathEdge
|
||||
? '#818CF8'
|
||||
: '#475569',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
|
||||
newNodes,
|
||||
|
||||
@@ -10,6 +10,9 @@ const typeIcons: Record<NodeType, React.ReactNode> = {
|
||||
host_physical: <Server className="w-4 h-4" />,
|
||||
host_vm: <Server className="w-4 h-4" />,
|
||||
host_container: <Server className="w-4 h-4" />,
|
||||
vm_lxc: <Box className="w-4 h-4" />,
|
||||
vm_qemu: <Server 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: <Database className="w-4 h-4" />,
|
||||
@@ -23,6 +26,9 @@ const typeLabels: Record<NodeType, string> = {
|
||||
host_physical: 'Physical',
|
||||
host_vm: 'VM',
|
||||
host_container: 'Container',
|
||||
vm_lxc: 'LXC',
|
||||
vm_qemu: 'QEMU',
|
||||
systemd_service: 'Systemd',
|
||||
service: 'Service',
|
||||
volume: 'Volume',
|
||||
mount: 'Mount',
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Info, FileCode, FolderOpen, BarChart3, Star, X } from 'lucide-react';
|
||||
import { Info, FileCode, FolderOpen, BarChart3, Star, X, Terminal, Folder } from 'lucide-react';
|
||||
import { useTopologyStore } from '../store/topologyStore';
|
||||
import { getNodeColor, getStatusColor, getImportanceLabel, getImportanceColor } from '../utils/colors';
|
||||
import FileBrowser from './FileBrowser';
|
||||
|
||||
type TabId = 'details' | 'config' | 'files' | 'usage' | 'importance';
|
||||
|
||||
@@ -14,8 +15,9 @@ const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [
|
||||
];
|
||||
|
||||
export default function RightPanel() {
|
||||
const { nodes, selectedNodeId, setSelectedNode } = useTopologyStore();
|
||||
const { nodes, selectedNodeId, setSelectedNode, openTerminal } = useTopologyStore();
|
||||
const [activeTab, setActiveTab] = useState<TabId>('details');
|
||||
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
|
||||
|
||||
const selectedNode = nodes.find(n => n.id === selectedNodeId);
|
||||
|
||||
@@ -31,17 +33,38 @@ export default function RightPanel() {
|
||||
|
||||
const nodeColor = getNodeColor(selectedNode.type, selectedNode.data.category);
|
||||
const statusColor = getStatusColor(selectedNode.data.status);
|
||||
const isHost = selectedNode.type.startsWith('host_') || selectedNode.type.startsWith('vm_');
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-slate-800 border-l border-slate-700 flex flex-col">
|
||||
<div className="h-12 px-4 flex items-center justify-between border-b border-slate-700">
|
||||
<h2 className="text-sm font-semibold text-white truncate">{selectedNode.name}</h2>
|
||||
<button
|
||||
onClick={() => setSelectedNode(null)}
|
||||
className="p-1 hover:bg-slate-700 rounded transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-slate-400" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
{isHost && selectedNodeId && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setFileBrowserOpen(true)}
|
||||
className="p-1 hover:bg-slate-700 rounded transition-colors"
|
||||
title="Browse Files"
|
||||
>
|
||||
<Folder className="w-4 h-4 text-slate-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openTerminal(selectedNodeId)}
|
||||
className="p-1 hover:bg-slate-700 rounded transition-colors"
|
||||
title="Open Terminal"
|
||||
>
|
||||
<Terminal className="w-4 h-4 text-slate-400" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSelectedNode(null)}
|
||||
className="p-1 hover:bg-slate-700 rounded transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex border-b border-slate-700">
|
||||
@@ -67,6 +90,13 @@ export default function RightPanel() {
|
||||
{activeTab === 'usage' && <UsageTab node={selectedNode} />}
|
||||
{activeTab === 'importance' && <ImportanceTab node={selectedNode} />}
|
||||
</div>
|
||||
|
||||
{fileBrowserOpen && selectedNodeId && (
|
||||
<FileBrowser
|
||||
host={selectedNodeId}
|
||||
onClose={() => setFileBrowserOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
127
src/components/TerminalPanel.tsx
Normal file
127
src/components/TerminalPanel.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
interface TerminalProps {
|
||||
host: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function TerminalPanel({ host, onClose }: TerminalProps) {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const terminalInstance = useRef<Terminal | null>(null);
|
||||
const inputBuffer = useRef('');
|
||||
const [currentPath, _setCurrentPath] = useState('~');
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current) return;
|
||||
|
||||
const term = new Terminal({
|
||||
theme: {
|
||||
background: '#0f172a',
|
||||
foreground: '#e2e8f0',
|
||||
cursor: '#22d3ee',
|
||||
cursorAccent: '#0f172a',
|
||||
selectionBackground: '#334155',
|
||||
},
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
scrollback: 1000,
|
||||
});
|
||||
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(terminalRef.current);
|
||||
fit.fit();
|
||||
|
||||
terminalInstance.current = term;
|
||||
|
||||
term.writeln('\x1b[36m╔════════════════════════════════════════════════╗\x1b[0m');
|
||||
term.writeln('\x1b[36m║ Homelab Topology - SSH Terminal ║\x1b[0m');
|
||||
term.writeln('\x1b[36m╚════════════════════════════════════════════════╝\x1b[0m');
|
||||
term.writeln(`\x1b[32mConnecting to ${host}...\x1b[0m`);
|
||||
term.writeln('');
|
||||
|
||||
const executeCommand = async (cmd: string) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/terminal/exec`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ host, command: cmd }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.output) {
|
||||
term.writeln(data.output);
|
||||
}
|
||||
if (data.error) {
|
||||
term.writeln(`\x1b[31mError: ${data.error}\x1b[0m`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
term.writeln(`\x1b[31mConnection error: ${msg}\x1b[0m`);
|
||||
}
|
||||
term.write(`\x1b[32m${host}:\x1b[0m${currentPath}$ `);
|
||||
};
|
||||
|
||||
term.onData((data) => {
|
||||
const code = data.charCodeAt(0);
|
||||
|
||||
if (code === 13) {
|
||||
term.writeln('');
|
||||
if (inputBuffer.current.trim()) {
|
||||
executeCommand(inputBuffer.current);
|
||||
} else {
|
||||
term.write(`\x1b[32m${host}:\x1b[0m${currentPath}$ `);
|
||||
}
|
||||
inputBuffer.current = '';
|
||||
} else if (code === 127) {
|
||||
if (inputBuffer.current.length > 0) {
|
||||
inputBuffer.current = inputBuffer.current.slice(0, -1);
|
||||
term.write('\b \b');
|
||||
}
|
||||
} else if (code < 32) {
|
||||
// skip control chars
|
||||
} else {
|
||||
inputBuffer.current += data;
|
||||
term.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
term.write(`\x1b[32m${host}:\x1b[0m${currentPath}$ `);
|
||||
|
||||
const handleResize = () => {
|
||||
fit.fit();
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
term.dispose();
|
||||
};
|
||||
}, [host, currentPath]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-slate-900/95 flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-slate-800 border-b border-slate-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-cyan-400 font-mono">$</span>
|
||||
<span className="text-slate-200 font-medium">{host}</span>
|
||||
<span className="text-slate-500">:</span>
|
||||
<span className="text-slate-400 font-mono">{currentPath}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-slate-700 rounded transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
<div ref={terminalRef} className="flex-1 p-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user