feat: integrate all 10 skills into homelab-topology
- 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
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { Search, Loader2, Network, HardDrive, Box, Database, Link, ArrowLeftRight, ArrowUpDown, Router, Wifi, Monitor, Container, FolderTree, Folder } from 'lucide-react';
|
||||
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';
|
||||
@@ -35,12 +37,12 @@ const nodeTypeFilters: { type: NodeType; icon: React.ReactNode }[] = [
|
||||
];
|
||||
|
||||
export default function Header({ onRefresh, isLoading: externalLoading }: HeaderProps) {
|
||||
const {
|
||||
viewMode,
|
||||
setViewMode,
|
||||
const {
|
||||
viewMode,
|
||||
setViewMode,
|
||||
orientation,
|
||||
setOrientation,
|
||||
searchQuery,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
typeFilters,
|
||||
toggleTypeFilter,
|
||||
@@ -50,19 +52,39 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
|
||||
toggleRightPanel,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
isLoading: storeLoading
|
||||
} = useTopologyStore();
|
||||
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 = async () => {
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (onRefresh) {
|
||||
await onRefresh();
|
||||
}
|
||||
};
|
||||
}, [onRefresh]);
|
||||
|
||||
return (
|
||||
<div className="h-14 bg-slate-800 border-b border-slate-700 px-4 flex items-center justify-between">
|
||||
<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">
|
||||
@@ -70,19 +92,19 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
|
||||
</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">
|
||||
|
||||
<div className="flex items-center gap-1" role="toolbar" aria-label="View mode">
|
||||
{viewModes.map(({ mode, label, icon }) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(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'
|
||||
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}
|
||||
@@ -93,7 +115,9 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
|
||||
<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"
|
||||
@@ -108,7 +132,7 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
|
||||
|
||||
<div className="h-6 w-px bg-slate-600" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<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);
|
||||
@@ -116,17 +140,17 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => toggleTypeFilter(type)}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
isActive
|
||||
? 'border'
|
||||
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`,
|
||||
}`}
|
||||
style={isActive ? {
|
||||
backgroundColor: `${color}20`,
|
||||
borderColor: `${color}50`,
|
||||
color: color
|
||||
} : undefined}
|
||||
title={type}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
@@ -137,7 +161,9 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
|
||||
<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"
|
||||
@@ -147,12 +173,32 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
|
||||
<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" />
|
||||
<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}
|
||||
@@ -164,9 +210,10 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
|
||||
<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' : ''}`} />
|
||||
<Loader2 className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} aria-hidden="true" />
|
||||
{loading ? 'Loading...' : 'Refresh'}
|
||||
</button>
|
||||
|
||||
@@ -174,24 +221,24 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
|
||||
|
||||
<button
|
||||
onClick={toggleLeftPanel}
|
||||
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'
|
||||
}`}
|
||||
title="Toggle left panel"
|
||||
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" />
|
||||
<Box className="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleRightPanel}
|
||||
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'
|
||||
}`}
|
||||
title="Toggle right panel"
|
||||
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" />
|
||||
<Database className="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user