mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 20:29:15 +00:00
refactor(frontend): extract OverviewToolbar from FleetView (F5-4) (#918)
- Move search, sort, filter popover, label filters, and view-mode toggle into FleetView/OverviewToolbar.tsx (~175 LOC removed from shell) - Extend FleetView/types.ts with ViewMode, SortField, SortDir, FilterStatus, FilterType, FleetPreferences, FleetPaletteEntry - Replace hand-rolled view-mode pill buttons with SegmentedControl (proper aria-checked semantics, keyboard navigation) - Hoist SORT_OPTIONS and renderPaletteOption to module-level constants - Stabilise palette options with useMemo inside OverviewToolbar - FleetView.tsx shrinks from ~1,280 to ~1,107 LOC
This commit is contained in:
@@ -2,9 +2,9 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|||||||
import { useExperimental } from '@/hooks/useExperimental';
|
import { useExperimental } from '@/hooks/useExperimental';
|
||||||
import {
|
import {
|
||||||
Server, Cpu, MemoryStick, HardDrive, RefreshCw, ChevronDown, ChevronRight,
|
Server, Cpu, MemoryStick, HardDrive, RefreshCw, ChevronDown, ChevronRight,
|
||||||
Layers, Wifi, WifiOff, Search, ArrowUpDown, AlertTriangle,
|
Layers, Wifi, WifiOff, Search, AlertTriangle,
|
||||||
Play, Square, RotateCcw, ExternalLink, Camera, Download, Loader2,
|
RotateCcw, ExternalLink, Camera, Download, Loader2,
|
||||||
LayoutGrid, Network, SlidersHorizontal,
|
Network, SlidersHorizontal,
|
||||||
Send, KeyRound, ArrowLeftRight,
|
Send, KeyRound, ArrowLeftRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { FleetMasthead } from './fleet/FleetMasthead';
|
import { FleetMasthead } from './fleet/FleetMasthead';
|
||||||
@@ -13,13 +13,11 @@ import { ReconnectingOverlay } from './FleetView/ReconnectingOverlay';
|
|||||||
import { NodeUpdatesSheet } from './FleetView/NodeUpdatesSheet';
|
import { NodeUpdatesSheet } from './FleetView/NodeUpdatesSheet';
|
||||||
import { LocalUpdateConfirmDialog } from './FleetView/LocalUpdateConfirmDialog';
|
import { LocalUpdateConfirmDialog } from './FleetView/LocalUpdateConfirmDialog';
|
||||||
import { UpdateStatusBadge } from './FleetView/UpdateStatusBadge';
|
import { UpdateStatusBadge } from './FleetView/UpdateStatusBadge';
|
||||||
import type { NodeUpdateStatus } from './FleetView/types';
|
import type { NodeUpdateStatus, ViewMode, FleetPreferences, FleetPaletteEntry } from './FleetView/types';
|
||||||
|
import { OverviewToolbar } from './FleetView/OverviewToolbar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
|
||||||
import { springs } from '@/lib/motion';
|
import { springs } from '@/lib/motion';
|
||||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||||
@@ -33,21 +31,12 @@ import { DeploymentsTab } from './blueprints/DeploymentsTab';
|
|||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { LabelDot } from './LabelPill';
|
import { LabelDot } from './LabelPill';
|
||||||
import { type Label as StackLabel, type LabelColor } from './label-types';
|
import { type Label as StackLabel, type LabelColor } from './label-types';
|
||||||
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
|
|
||||||
import { formatVersion } from '@/lib/version';
|
import { formatVersion } from '@/lib/version';
|
||||||
|
|
||||||
interface FleetPaletteEntry {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
color: LabelColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
function labelPaletteKey(name: string, color: LabelColor): string {
|
function labelPaletteKey(name: string, color: LabelColor): string {
|
||||||
return `${name.trim().toLowerCase()}|${color}`;
|
return `${name.trim().toLowerCase()}|${color}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FILTER_SECTION_LABEL_CLASS = 'text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle';
|
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
interface FleetNodeStats {
|
interface FleetNodeStats {
|
||||||
@@ -82,19 +71,6 @@ interface StackContainer {
|
|||||||
Status?: string;
|
Status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortField = 'name' | 'cpu' | 'memory' | 'containers' | 'status';
|
|
||||||
type SortDir = 'asc' | 'desc';
|
|
||||||
type FilterStatus = 'all' | 'online' | 'offline';
|
|
||||||
type FilterType = 'all' | 'local' | 'remote';
|
|
||||||
|
|
||||||
interface FleetPreferences {
|
|
||||||
sortBy: SortField;
|
|
||||||
sortDir: SortDir;
|
|
||||||
filterStatus: FilterStatus;
|
|
||||||
filterType: FilterType;
|
|
||||||
filterCritical: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PREFS_KEY = 'sencho-fleet-preferences';
|
const PREFS_KEY = 'sencho-fleet-preferences';
|
||||||
|
|
||||||
function loadPreferences(): FleetPreferences {
|
function loadPreferences(): FleetPreferences {
|
||||||
@@ -522,7 +498,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
|||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
|
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'topology'>('grid');
|
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||||
const [prefs, setPrefs] = useState<FleetPreferences>(loadPreferences);
|
const [prefs, setPrefs] = useState<FleetPreferences>(loadPreferences);
|
||||||
const [fleetPalette, setFleetPalette] = useState<FleetPaletteEntry[]>([]);
|
const [fleetPalette, setFleetPalette] = useState<FleetPaletteEntry[]>([]);
|
||||||
const [fleetStackLabelMap, setFleetStackLabelMap] = useState<Record<number, Record<string, StackLabel[]>>>({});
|
const [fleetStackLabelMap, setFleetStackLabelMap] = useState<Record<number, Record<string, StackLabel[]>>>({});
|
||||||
@@ -850,12 +826,6 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
|||||||
critical: n.status === 'online' && isCritical(n),
|
critical: n.status === 'online' && isCritical(n),
|
||||||
})), [processedNodes]);
|
})), [processedNodes]);
|
||||||
|
|
||||||
const showPaidControls = isPaid && viewMode === 'grid';
|
|
||||||
const activeFilterCount =
|
|
||||||
(prefs.filterStatus !== 'all' ? 1 : 0) +
|
|
||||||
(prefs.filterType !== 'all' ? 1 : 0) +
|
|
||||||
(prefs.filterCritical ? 1 : 0) +
|
|
||||||
(labelFilters.size > 0 ? 1 : 0);
|
|
||||||
const clearFilters = useCallback(() => {
|
const clearFilters = useCallback(() => {
|
||||||
updatePrefs({ filterStatus: 'all', filterType: 'all', filterCritical: false });
|
updatePrefs({ filterStatus: 'all', filterType: 'all', filterCritical: false });
|
||||||
setLabelFilters(new Set());
|
setLabelFilters(new Set());
|
||||||
@@ -991,162 +961,19 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
|||||||
{/* Fleet Content */}
|
{/* Fleet Content */}
|
||||||
{!loading && nodes.length > 0 && (
|
{!loading && nodes.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{/* Overview Toolbar: Search, Sort, Filters, View Mode */}
|
<OverviewToolbar
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
isPaid={isPaid}
|
||||||
{showPaidControls && (
|
viewMode={viewMode}
|
||||||
<>
|
onViewModeChange={setViewMode}
|
||||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
searchQuery={searchQuery}
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
onSearchQueryChange={setSearchQuery}
|
||||||
<Input
|
prefs={prefs}
|
||||||
placeholder="Search nodes or stacks..."
|
onPrefsChange={updatePrefs}
|
||||||
value={searchQuery}
|
fleetPalette={fleetPalette}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
labelFilters={labelFilters}
|
||||||
className="pl-9 h-9"
|
onLabelFiltersChange={setLabelFilters}
|
||||||
/>
|
onClearFilters={clearFilters}
|
||||||
</div>
|
/>
|
||||||
<div className="w-40">
|
|
||||||
<Combobox
|
|
||||||
options={[
|
|
||||||
{ value: 'name', label: 'Name' },
|
|
||||||
{ value: 'cpu', label: 'CPU Usage' },
|
|
||||||
{ value: 'memory', label: 'Memory Usage' },
|
|
||||||
{ value: 'containers', label: 'Containers' },
|
|
||||||
{ value: 'status', label: 'Status' },
|
|
||||||
]}
|
|
||||||
value={prefs.sortBy}
|
|
||||||
onValueChange={(v) => updatePrefs({ sortBy: v as SortField })}
|
|
||||||
placeholder="Sort by..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 w-9 p-0 shrink-0"
|
|
||||||
onClick={() => updatePrefs({ sortDir: prefs.sortDir === 'asc' ? 'desc' : 'asc' })}
|
|
||||||
title={prefs.sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
|
||||||
>
|
|
||||||
<ArrowUpDown className={`w-4 h-4 ${prefs.sortDir === 'desc' ? 'rotate-180' : ''} transition-transform`} />
|
|
||||||
</Button>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={activeFilterCount > 0 ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
className="h-9 gap-2 shrink-0"
|
|
||||||
>
|
|
||||||
<SlidersHorizontal className="w-4 h-4" />
|
|
||||||
Filters
|
|
||||||
{activeFilterCount > 0 && (
|
|
||||||
<Badge variant="secondary" className="h-5 min-w-[1.25rem] px-1.5 text-[10px] tabular-nums">
|
|
||||||
{activeFilterCount}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align="end" className="w-80 space-y-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className={FILTER_SECTION_LABEL_CLASS}>Status</label>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{(['all', 'online', 'offline'] as FilterStatus[]).map(status => (
|
|
||||||
<Button
|
|
||||||
key={status}
|
|
||||||
variant={prefs.filterStatus === status ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs px-2.5"
|
|
||||||
onClick={() => updatePrefs({ filterStatus: status })}
|
|
||||||
>
|
|
||||||
{status === 'all' ? 'All' : status === 'online' ? (
|
|
||||||
<><Play className="w-3 h-3 mr-1" />Online</>
|
|
||||||
) : (
|
|
||||||
<><Square className="w-3 h-3 mr-1" />Offline</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className={FILTER_SECTION_LABEL_CLASS}>Type</label>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{(['all', 'local', 'remote'] as FilterType[]).map(type => (
|
|
||||||
<Button
|
|
||||||
key={type}
|
|
||||||
variant={prefs.filterType === type ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs px-2.5"
|
|
||||||
onClick={() => updatePrefs({ filterType: type })}
|
|
||||||
>
|
|
||||||
{type === 'all' ? 'All' : type.charAt(0).toUpperCase() + type.slice(1)}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className={FILTER_SECTION_LABEL_CLASS}>Severity</label>
|
|
||||||
<Button
|
|
||||||
variant={prefs.filterCritical ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs px-2.5"
|
|
||||||
onClick={() => updatePrefs({ filterCritical: !prefs.filterCritical })}
|
|
||||||
>
|
|
||||||
<AlertTriangle className="w-3 h-3 mr-1" />
|
|
||||||
Critical Only
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{fleetPalette.length > 0 && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className={FILTER_SECTION_LABEL_CLASS}>Tags</label>
|
|
||||||
<MultiSelectCombobox
|
|
||||||
options={fleetPalette.map(p => ({ value: p.key, label: p.name, color: p.color }))}
|
|
||||||
selected={labelFilters}
|
|
||||||
onSelectionChange={setLabelFilters}
|
|
||||||
placeholder="Tags"
|
|
||||||
renderOption={(option) => (
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<LabelDot color={option.color as LabelColor ?? 'slate'} />
|
|
||||||
{option.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{activeFilterCount > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full h-8 text-xs"
|
|
||||||
onClick={clearFilters}
|
|
||||||
>
|
|
||||||
Clear all filters
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-1 rounded-md border border-card-border bg-card p-0.5 shadow-card-bevel shrink-0">
|
|
||||||
<Button
|
|
||||||
variant={viewMode === 'grid' ? 'default' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
className="h-8 text-xs px-2.5 gap-1.5"
|
|
||||||
onClick={() => setViewMode('grid')}
|
|
||||||
aria-pressed={viewMode === 'grid'}
|
|
||||||
>
|
|
||||||
<LayoutGrid className="w-3.5 h-3.5" strokeWidth={1.5} />
|
|
||||||
Grid
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={viewMode === 'topology' ? 'default' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
className="h-8 text-xs px-2.5 gap-1.5"
|
|
||||||
onClick={() => setViewMode('topology')}
|
|
||||||
aria-pressed={viewMode === 'topology'}
|
|
||||||
>
|
|
||||||
<Network className="w-3.5 h-3.5" strokeWidth={1.5} />
|
|
||||||
Topology
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{viewMode === 'topology' && processedNodes.length > 0 ? (
|
{viewMode === 'topology' && processedNodes.length > 0 ? (
|
||||||
<FleetTopology
|
<FleetTopology
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Search, ArrowUpDown, AlertTriangle, Play, Square,
|
||||||
|
LayoutGrid, Network, SlidersHorizontal,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
|
||||||
|
import { SegmentedControl, type SegmentedControlOption } from '@/components/ui/segmented-control';
|
||||||
|
import { LabelDot } from '../LabelPill';
|
||||||
|
import type { LabelColor } from '../label-types';
|
||||||
|
import type { ViewMode, SortField, FilterStatus, FilterType, FleetPreferences, FleetPaletteEntry } from './types';
|
||||||
|
|
||||||
|
const FILTER_SECTION_LABEL_CLASS = 'text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle';
|
||||||
|
|
||||||
|
const SORT_OPTIONS = [
|
||||||
|
{ value: 'name', label: 'Name' },
|
||||||
|
{ value: 'cpu', label: 'CPU Usage' },
|
||||||
|
{ value: 'memory', label: 'Memory Usage' },
|
||||||
|
{ value: 'containers', label: 'Containers' },
|
||||||
|
{ value: 'status', label: 'Status' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const VIEW_MODE_OPTIONS: SegmentedControlOption<ViewMode>[] = [
|
||||||
|
{ value: 'grid', label: 'Grid', icon: LayoutGrid },
|
||||||
|
{ value: 'topology', label: 'Topology', icon: Network },
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderPaletteOption(option: { label: string; color?: string }) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<LabelDot color={(option.color as LabelColor) ?? 'slate'} />
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverviewToolbarProps {
|
||||||
|
isPaid: boolean;
|
||||||
|
viewMode: ViewMode;
|
||||||
|
onViewModeChange: (mode: ViewMode) => void;
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchQueryChange: (q: string) => void;
|
||||||
|
prefs: FleetPreferences;
|
||||||
|
onPrefsChange: (update: Partial<FleetPreferences>) => void;
|
||||||
|
fleetPalette: FleetPaletteEntry[];
|
||||||
|
labelFilters: Set<string>;
|
||||||
|
onLabelFiltersChange: (filters: Set<string>) => void;
|
||||||
|
onClearFilters: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewToolbar({
|
||||||
|
isPaid,
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
searchQuery,
|
||||||
|
onSearchQueryChange,
|
||||||
|
prefs,
|
||||||
|
onPrefsChange,
|
||||||
|
fleetPalette,
|
||||||
|
labelFilters,
|
||||||
|
onLabelFiltersChange,
|
||||||
|
onClearFilters,
|
||||||
|
}: OverviewToolbarProps) {
|
||||||
|
const showPaidControls = isPaid && viewMode === 'grid';
|
||||||
|
const activeFilterCount =
|
||||||
|
(prefs.filterStatus !== 'all' ? 1 : 0) +
|
||||||
|
(prefs.filterType !== 'all' ? 1 : 0) +
|
||||||
|
(prefs.filterCritical ? 1 : 0) +
|
||||||
|
(labelFilters.size > 0 ? 1 : 0);
|
||||||
|
|
||||||
|
const paletteOptions = useMemo(
|
||||||
|
() => fleetPalette.map(p => ({ value: p.key, label: p.name, color: p.color })),
|
||||||
|
[fleetPalette],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||||
|
{showPaidControls && (
|
||||||
|
<>
|
||||||
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search nodes or stacks..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchQueryChange(e.target.value)}
|
||||||
|
className="pl-9 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-40">
|
||||||
|
<Combobox
|
||||||
|
options={SORT_OPTIONS}
|
||||||
|
value={prefs.sortBy}
|
||||||
|
onValueChange={(v) => onPrefsChange({ sortBy: v as SortField })}
|
||||||
|
placeholder="Sort by..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 w-9 p-0 shrink-0"
|
||||||
|
onClick={() => onPrefsChange({ sortDir: prefs.sortDir === 'asc' ? 'desc' : 'asc' })}
|
||||||
|
title={prefs.sortDir === 'asc' ? 'Switch to descending' : 'Switch to ascending'}
|
||||||
|
>
|
||||||
|
<ArrowUpDown className={`w-4 h-4 ${prefs.sortDir === 'desc' ? 'rotate-180' : ''} transition-transform`} />
|
||||||
|
</Button>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={activeFilterCount > 0 ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-9 gap-2 shrink-0"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="w-4 h-4" />
|
||||||
|
Filters
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<Badge variant="secondary" className="h-5 min-w-[1.25rem] px-1.5 text-[10px] tabular-nums">
|
||||||
|
{activeFilterCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-80 space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className={FILTER_SECTION_LABEL_CLASS}>Status</label>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{(['all', 'online', 'offline'] as FilterStatus[]).map(status => (
|
||||||
|
<Button
|
||||||
|
key={status}
|
||||||
|
variant={prefs.filterStatus === status ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs px-2.5"
|
||||||
|
onClick={() => onPrefsChange({ filterStatus: status })}
|
||||||
|
>
|
||||||
|
{status === 'all' ? 'All' : status === 'online' ? (
|
||||||
|
<><Play className="w-3 h-3 mr-1" />Online</>
|
||||||
|
) : (
|
||||||
|
<><Square className="w-3 h-3 mr-1" />Offline</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className={FILTER_SECTION_LABEL_CLASS}>Type</label>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{(['all', 'local', 'remote'] as FilterType[]).map(type => (
|
||||||
|
<Button
|
||||||
|
key={type}
|
||||||
|
variant={prefs.filterType === type ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs px-2.5"
|
||||||
|
onClick={() => onPrefsChange({ filterType: type })}
|
||||||
|
>
|
||||||
|
{type === 'all' ? 'All' : type.charAt(0).toUpperCase() + type.slice(1)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className={FILTER_SECTION_LABEL_CLASS}>Severity</label>
|
||||||
|
<Button
|
||||||
|
variant={prefs.filterCritical ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs px-2.5"
|
||||||
|
onClick={() => onPrefsChange({ filterCritical: !prefs.filterCritical })}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||||
|
Critical Only
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{fleetPalette.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className={FILTER_SECTION_LABEL_CLASS}>Tags</label>
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={paletteOptions}
|
||||||
|
selected={labelFilters}
|
||||||
|
onSelectionChange={onLabelFiltersChange}
|
||||||
|
placeholder="Tags"
|
||||||
|
renderOption={renderPaletteOption}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full h-8 text-xs"
|
||||||
|
onClick={onClearFilters}
|
||||||
|
>
|
||||||
|
Clear all filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SegmentedControl
|
||||||
|
value={viewMode}
|
||||||
|
onChange={onViewModeChange}
|
||||||
|
ariaLabel="View mode"
|
||||||
|
options={VIEW_MODE_OPTIONS}
|
||||||
|
className="ml-auto shrink-0 shadow-card-bevel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { LabelColor } from '../label-types';
|
||||||
|
|
||||||
export interface NodeUpdateStatus {
|
export interface NodeUpdateStatus {
|
||||||
nodeId: number;
|
nodeId: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -8,3 +10,23 @@ export interface NodeUpdateStatus {
|
|||||||
updateStatus: 'updating' | 'completed' | 'timeout' | 'failed' | null;
|
updateStatus: 'updating' | 'completed' | 'timeout' | 'failed' | null;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ViewMode = 'grid' | 'topology';
|
||||||
|
export type SortField = 'name' | 'cpu' | 'memory' | 'containers' | 'status';
|
||||||
|
export type SortDir = 'asc' | 'desc';
|
||||||
|
export type FilterStatus = 'all' | 'online' | 'offline';
|
||||||
|
export type FilterType = 'all' | 'local' | 'remote';
|
||||||
|
|
||||||
|
export interface FleetPreferences {
|
||||||
|
sortBy: SortField;
|
||||||
|
sortDir: SortDir;
|
||||||
|
filterStatus: FilterStatus;
|
||||||
|
filterType: FilterType;
|
||||||
|
filterCritical: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetPaletteEntry {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
color: LabelColor;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user