- 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
245 lines
9.8 KiB
TypeScript
245 lines
9.8 KiB
TypeScript
import { useCallback } from 'react';
|
|
import { Search, Loader2, Network, HardDrive, Box, Database, Link, ArrowLeftRight, ArrowUpDown, Router, Wifi, Monitor, Container, FolderTree, Folder, Settings } from 'lucide-react';
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
import { useTopologyStore, Orientation, StatusFilter } from '../store/topologyStore';
|
|
import { ViewMode, NodeType } from '../types';
|
|
import { getNodeColor } from '../utils/colors';
|
|
|
|
interface HeaderProps {
|
|
onRefresh?: () => Promise<void>;
|
|
isLoading?: boolean;
|
|
}
|
|
|
|
const viewModes: { mode: ViewMode; label: string; icon: React.ReactNode }[] = [
|
|
{ mode: 'full', label: 'Full', icon: <Link className="w-4 h-4" /> },
|
|
{ mode: 'network', label: 'Network', icon: <Network className="w-4 h-4" /> },
|
|
{ mode: 'host', label: 'Hosts', icon: <HardDrive className="w-4 h-4" /> },
|
|
{ mode: 'service', label: 'Services', icon: <Box className="w-4 h-4" /> },
|
|
{ mode: 'filesystem', label: 'Files', icon: <Database className="w-4 h-4" /> },
|
|
];
|
|
|
|
const orientations: { value: Orientation; label: string; icon: React.ReactNode }[] = [
|
|
{ value: 'LR', label: 'Left to Right', icon: <ArrowLeftRight className="w-4 h-4" /> },
|
|
{ value: 'TB', label: 'Top to Bottom', icon: <ArrowUpDown className="w-4 h-4" /> },
|
|
];
|
|
|
|
const nodeTypeFilters: { type: NodeType; icon: React.ReactNode }[] = [
|
|
{ type: 'gateway', icon: <Router className="w-4 h-4" /> },
|
|
{ type: 'vlan', icon: <Network className="w-4 h-4" /> },
|
|
{ type: 'wifi', icon: <Wifi className="w-4 h-4" /> },
|
|
{ type: 'host_physical', icon: <HardDrive className="w-4 h-4" /> },
|
|
{ type: 'host_vm', icon: <Monitor className="w-4 h-4" /> },
|
|
{ type: 'host_container', icon: <Container className="w-4 h-4" /> },
|
|
{ type: 'service', icon: <Box className="w-4 h-4" /> },
|
|
{ type: 'volume', icon: <Database className="w-4 h-4" /> },
|
|
{ type: 'mount', icon: <FolderTree className="w-4 h-4" /> },
|
|
{ type: 'path', icon: <Folder className="w-4 h-4" /> },
|
|
];
|
|
|
|
export default function Header({ onRefresh, isLoading: externalLoading }: HeaderProps) {
|
|
const {
|
|
viewMode,
|
|
setViewMode,
|
|
orientation,
|
|
setOrientation,
|
|
searchQuery,
|
|
setSearchQuery,
|
|
typeFilters,
|
|
toggleTypeFilter,
|
|
statusFilter,
|
|
setStatusFilter,
|
|
toggleLeftPanel,
|
|
toggleRightPanel,
|
|
leftPanelOpen,
|
|
rightPanelOpen,
|
|
isLoading: storeLoading,
|
|
pollInterval,
|
|
setPollInterval
|
|
} = useTopologyStore(useShallow((s) => ({
|
|
viewMode: s.viewMode,
|
|
setViewMode: s.setViewMode,
|
|
orientation: s.orientation,
|
|
setOrientation: s.setOrientation,
|
|
searchQuery: s.searchQuery,
|
|
setSearchQuery: s.setSearchQuery,
|
|
typeFilters: s.typeFilters,
|
|
toggleTypeFilter: s.toggleTypeFilter,
|
|
statusFilter: s.statusFilter,
|
|
setStatusFilter: s.setStatusFilter,
|
|
toggleLeftPanel: s.toggleLeftPanel,
|
|
toggleRightPanel: s.toggleRightPanel,
|
|
leftPanelOpen: s.leftPanelOpen,
|
|
rightPanelOpen: s.rightPanelOpen,
|
|
isLoading: s.isLoading,
|
|
pollInterval: s.pollInterval,
|
|
setPollInterval: s.setPollInterval,
|
|
})));
|
|
|
|
const loading = externalLoading ?? storeLoading;
|
|
|
|
const handleRefresh = useCallback(async () => {
|
|
if (onRefresh) {
|
|
await onRefresh();
|
|
}
|
|
}, [onRefresh]);
|
|
|
|
return (
|
|
<header className="h-14 bg-slate-800 border-b border-slate-700 px-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 bg-indigo-500 rounded-lg flex items-center justify-center">
|
|
<Network className="w-5 h-5 text-white" />
|
|
</div>
|
|
<h1 className="text-lg font-semibold text-white">Homelab Topology</h1>
|
|
</div>
|
|
|
|
<div className="h-6 w-px bg-slate-600" />
|
|
|
|
<div className="flex items-center gap-1" role="toolbar" aria-label="View mode">
|
|
{viewModes.map(({ mode, label, icon }) => (
|
|
<button
|
|
key={mode}
|
|
onClick={() => setViewMode(mode)}
|
|
aria-pressed={viewMode === mode}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors ${viewMode === mode
|
|
? 'bg-indigo-500/20 text-indigo-400 border border-indigo-500/50'
|
|
: 'text-slate-400 hover:text-white hover:bg-slate-700'
|
|
}`}
|
|
>
|
|
{icon}
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="h-6 w-px bg-slate-600" />
|
|
|
|
<div className="flex items-center gap-2">
|
|
<label htmlFor="orientation-select" className="visually-hidden">Graph orientation</label>
|
|
<select
|
|
id="orientation-select"
|
|
value={orientation}
|
|
onChange={(e) => setOrientation(e.target.value as Orientation)}
|
|
className="h-9 px-3 bg-slate-700 border border-slate-600 rounded-lg text-sm text-slate-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
|
|
>
|
|
{orientations.map(({ value, label }) => (
|
|
<option key={value} value={value}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="h-6 w-px bg-slate-600" />
|
|
|
|
<div className="flex items-center gap-1" role="toolbar" aria-label="Node type filters">
|
|
{nodeTypeFilters.map(({ type, icon }) => {
|
|
const isActive = typeFilters.includes(type);
|
|
const color = getNodeColor(type);
|
|
return (
|
|
<button
|
|
key={type}
|
|
onClick={() => toggleTypeFilter(type)}
|
|
aria-pressed={isActive}
|
|
aria-label={`Filter ${type.replace(/_/g, ' ')}`}
|
|
className={`p-2 rounded-md transition-colors ${isActive
|
|
? 'border'
|
|
: 'text-slate-500 hover:text-slate-300 hover:bg-slate-700'
|
|
}`}
|
|
style={isActive ? {
|
|
backgroundColor: `${color}20`,
|
|
borderColor: `${color}50`,
|
|
color: color
|
|
} : undefined}
|
|
>
|
|
{icon}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="h-6 w-px bg-slate-600" />
|
|
|
|
<div className="flex items-center gap-2">
|
|
<label htmlFor="status-filter" className="visually-hidden">Status filter</label>
|
|
<select
|
|
id="status-filter"
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
|
|
className="h-9 px-3 bg-slate-700 border border-slate-600 rounded-lg text-sm text-slate-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
|
|
>
|
|
<option value="all">All Status</option>
|
|
<option value="running">Running</option>
|
|
<option value="stopped">Stopped</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="h-6 w-px bg-slate-600" />
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Settings className="w-4 h-4 text-slate-400" aria-hidden="true" />
|
|
<label htmlFor="poll-interval" className="visually-hidden">Poll interval</label>
|
|
<select
|
|
id="poll-interval"
|
|
value={pollInterval}
|
|
onChange={(e) => setPollInterval(parseInt(e.target.value, 10))}
|
|
className="h-9 px-3 bg-slate-700 border border-slate-600 rounded-lg text-sm text-slate-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
|
|
>
|
|
<option value={10000}>10 seconds</option>
|
|
<option value={30000}>30 seconds</option>
|
|
<option value={60000}>1 minute</option>
|
|
<option value={300000}>5 minutes</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" aria-hidden="true" />
|
|
<label htmlFor="node-search" className="visually-hidden">Search nodes</label>
|
|
<input
|
|
id="node-search"
|
|
type="text"
|
|
placeholder="Search nodes..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-64 h-9 pl-9 pr-4 bg-slate-700 border border-slate-600 rounded-lg text-sm text-white placeholder-slate-400 focus:outline-none focus:border-indigo-500"
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleRefresh}
|
|
disabled={loading}
|
|
aria-label={loading ? 'Loading data' : 'Refresh data'}
|
|
className="h-9 px-3 flex items-center gap-2 bg-slate-700 hover:bg-slate-600 border border-slate-600 rounded-lg text-sm text-slate-300 transition-colors disabled:opacity-50"
|
|
>
|
|
<Loader2 className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} aria-hidden="true" />
|
|
{loading ? 'Loading...' : 'Refresh'}
|
|
</button>
|
|
|
|
<div className="h-6 w-px bg-slate-600" />
|
|
|
|
<button
|
|
onClick={toggleLeftPanel}
|
|
aria-label={leftPanelOpen ? 'Hide child nodes panel' : 'Show child nodes panel'}
|
|
aria-pressed={leftPanelOpen}
|
|
className={`p-2 rounded-lg transition-colors ${leftPanelOpen ? 'bg-indigo-500/20 text-indigo-400' : 'text-slate-400 hover:text-white hover:bg-slate-700'
|
|
}`}
|
|
>
|
|
<Box className="w-5 h-5" aria-hidden="true" />
|
|
</button>
|
|
|
|
<button
|
|
onClick={toggleRightPanel}
|
|
aria-label={rightPanelOpen ? 'Hide details panel' : 'Show details panel'}
|
|
aria-pressed={rightPanelOpen}
|
|
className={`p-2 rounded-lg transition-colors ${rightPanelOpen ? 'bg-indigo-500/20 text-indigo-400' : 'text-slate-400 hover:text-white hover:bg-slate-700'
|
|
}`}
|
|
>
|
|
<Database className="w-5 h-5" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|