mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-02 21:58:06 +00:00
feat(fleet): add Pro fleet management features and container drill-down (#174)
Add fleet health summary cards, container-level drill-down, node sorting and filtering, fleet-wide search, and critical node detection to the Fleet View. Fix ProGate to wrap placeholder content and add error toasts.
This commit is contained in:
@@ -1,11 +1,22 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Server, Cpu, MemoryStick, HardDrive, Container, RefreshCw, ChevronDown, ChevronRight, Layers, Wifi, WifiOff } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Server, Cpu, MemoryStick, HardDrive, RefreshCw, ChevronDown, ChevronRight,
|
||||
Layers, Wifi, WifiOff, Search, ArrowUpDown, AlertTriangle, Box, Activity,
|
||||
Play, Square, RotateCcw, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { ProGate } from './ProGate';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface FleetNodeStats {
|
||||
active: number;
|
||||
@@ -31,6 +42,43 @@ interface FleetNode {
|
||||
stacks: string[] | null;
|
||||
}
|
||||
|
||||
interface StackContainer {
|
||||
Id?: string;
|
||||
Names?: string[];
|
||||
Image?: string;
|
||||
State?: 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';
|
||||
|
||||
function loadPreferences(): FleetPreferences {
|
||||
try {
|
||||
const stored = localStorage.getItem(PREFS_KEY);
|
||||
if (stored) return JSON.parse(stored) as FleetPreferences;
|
||||
} catch { /* use defaults */ }
|
||||
return { sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false };
|
||||
}
|
||||
|
||||
function savePreferences(prefs: FleetPreferences) {
|
||||
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
|
||||
}
|
||||
|
||||
// --- Utilities ---
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
@@ -39,6 +87,31 @@ function formatBytes(bytes: number): string {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function getNodeCpu(node: FleetNode): number {
|
||||
return node.systemStats ? parseFloat(node.systemStats.cpu.usage) : 0;
|
||||
}
|
||||
|
||||
function getNodeMem(node: FleetNode): number {
|
||||
return node.systemStats ? parseFloat(node.systemStats.memory.usagePercent) : 0;
|
||||
}
|
||||
|
||||
function getNodeDisk(node: FleetNode): number {
|
||||
return node.systemStats?.disk ? parseFloat(node.systemStats.disk.usagePercent) : 0;
|
||||
}
|
||||
|
||||
function isCritical(node: FleetNode): boolean {
|
||||
return getNodeCpu(node) > 90 || getNodeDisk(node) > 90;
|
||||
}
|
||||
|
||||
function containerName(c: StackContainer): string {
|
||||
if (c.Names && c.Names.length > 0) {
|
||||
return c.Names[0].replace(/^\//, '');
|
||||
}
|
||||
return c.Id?.slice(0, 12) ?? 'unknown';
|
||||
}
|
||||
|
||||
// --- Sub-Components ---
|
||||
|
||||
function UsageBar({ percent, color }: { percent: number; color: string }) {
|
||||
return (
|
||||
<div className="h-1.5 w-full bg-muted rounded-full overflow-hidden">
|
||||
@@ -50,6 +123,139 @@ function UsageBar({ percent, color }: { percent: number; color: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, sub, alert }: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
alert?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-xl border bg-card p-4 ${alert ? 'border-red-500/30 bg-red-500/5' : ''}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className={`w-4 h-4 ${alert ? 'text-red-500' : 'text-muted-foreground'}`} />
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
<div className={`text-2xl font-bold ${alert ? 'text-red-500' : ''}`}>{value}</div>
|
||||
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContainerRow({ container, nodeId, onNavigate }: {
|
||||
container: StackContainer;
|
||||
nodeId: number;
|
||||
onNavigate: (nodeId: number) => void;
|
||||
}) {
|
||||
const name = containerName(container);
|
||||
const state = container.State?.toLowerCase() ?? 'unknown';
|
||||
const image = container.Image;
|
||||
const status = container.Status ?? '';
|
||||
|
||||
const stateColor = state === 'running' ? 'bg-emerald-500' :
|
||||
state === 'restarting' ? 'bg-amber-500' : 'bg-red-500';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors group">
|
||||
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${stateColor}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium truncate">{name}</span>
|
||||
<Badge variant="outline" className="text-[9px] px-1 py-0 h-3.5 shrink-0">{state}</Badge>
|
||||
</div>
|
||||
{(image || status) && (
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{image && <span className="text-[10px] text-muted-foreground truncate">{image}</span>}
|
||||
{status && <span className="text-[10px] text-muted-foreground shrink-0">{image ? '· ' : ''}{status}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => onNavigate(nodeId)}
|
||||
title="Open in editor"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StackSection({ stackName, nodeId, onNavigate }: {
|
||||
stackName: string;
|
||||
nodeId: number;
|
||||
onNavigate: (nodeId: number) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [containers, setContainers] = useState<StackContainer[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleExpand = async () => {
|
||||
const next = !expanded;
|
||||
setExpanded(next);
|
||||
|
||||
if (next && containers === null) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch(`/fleet/node/${nodeId}/stacks/${encodeURIComponent(stackName)}/containers`, { localOnly: true });
|
||||
if (res.ok) {
|
||||
setContainers(await res.json());
|
||||
} else {
|
||||
toast.error('Failed to load containers for ' + stackName);
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to load containers for ' + stackName);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const runningCount = containers?.filter(c => c.State?.toLowerCase() === 'running').length ?? 0;
|
||||
const totalCount = containers?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={handleExpand}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 rounded-md text-xs hover:bg-muted/50 transition-colors text-left group"
|
||||
>
|
||||
{expanded ? <ChevronDown className="w-3 h-3 shrink-0" /> : <ChevronRight className="w-3 h-3 shrink-0" />}
|
||||
<Layers className="w-3 h-3 text-muted-foreground shrink-0" />
|
||||
<span className="truncate flex-1">{stackName}</span>
|
||||
{containers !== null && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
{runningCount}/{totalCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="ml-4 mt-1 space-y-0.5">
|
||||
{loading ? (
|
||||
<div className="space-y-2 px-3 py-1">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
</div>
|
||||
) : containers && containers.length > 0 ? (
|
||||
containers.map(c => (
|
||||
<ContainerRow
|
||||
key={c.Id ?? containerName(c)}
|
||||
container={c}
|
||||
nodeId={nodeId}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground px-3 py-1">No containers</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId: number) => void }) {
|
||||
const { isPro } = useLicense();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -57,9 +263,9 @@ function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId:
|
||||
const [loadingStacks, setLoadingStacks] = useState(false);
|
||||
|
||||
const isOnline = node.status === 'online';
|
||||
const cpuPercent = node.systemStats ? parseFloat(node.systemStats.cpu.usage) : 0;
|
||||
const memPercent = node.systemStats ? parseFloat(node.systemStats.memory.usagePercent) : 0;
|
||||
const diskPercent = node.systemStats?.disk ? parseFloat(node.systemStats.disk.usagePercent) : 0;
|
||||
const cpuPercent = getNodeCpu(node);
|
||||
const memPercent = getNodeMem(node);
|
||||
const diskPercent = getNodeDisk(node);
|
||||
|
||||
const handleExpand = async () => {
|
||||
if (!isPro) return;
|
||||
@@ -72,9 +278,11 @@ function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId:
|
||||
const res = await apiFetch(`/fleet/node/${node.id}/stacks`, { localOnly: true });
|
||||
if (res.ok) {
|
||||
setStacks(await res.json());
|
||||
} else {
|
||||
toast.error('Failed to load stacks for ' + node.name);
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
toast.error('Failed to load stacks for ' + node.name);
|
||||
} finally {
|
||||
setLoadingStacks(false);
|
||||
}
|
||||
@@ -103,6 +311,11 @@ function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId:
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4">
|
||||
{node.type}
|
||||
</Badge>
|
||||
{isOnline && isCritical(node) && (
|
||||
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 h-4">
|
||||
<AlertTriangle className="w-2.5 h-2.5 mr-0.5" /> Critical
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,7 +382,7 @@ function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId:
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pro Expandable Stack List */}
|
||||
{/* Pro Expandable Stack List with Container Drill-Down */}
|
||||
{isOnline && isPro && (
|
||||
<div className="border-t">
|
||||
<button
|
||||
@@ -179,29 +392,30 @@ function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId:
|
||||
{expanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
<Layers className="w-3.5 h-3.5" />
|
||||
Stack details
|
||||
{stacks !== null && (
|
||||
<span className="ml-auto text-[10px]">{stacks.length} stacks</span>
|
||||
)}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="px-4 pb-3">
|
||||
<div className="px-2 pb-3">
|
||||
{loadingStacks ? (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 px-2">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-6 w-3/4" />
|
||||
</div>
|
||||
) : stacks && stacks.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-0.5">
|
||||
{stacks.map(stack => (
|
||||
<button
|
||||
<StackSection
|
||||
key={stack}
|
||||
onClick={() => onNavigate(node.id)}
|
||||
className="flex items-center gap-2 w-full px-2.5 py-1.5 rounded-md text-xs hover:bg-muted transition-colors text-left"
|
||||
>
|
||||
<Container className="w-3 h-3 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{stack}</span>
|
||||
</button>
|
||||
stackName={stack}
|
||||
nodeId={node.id}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground py-1">No stacks found</p>
|
||||
<p className="text-xs text-muted-foreground py-1 px-2">No stacks found</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -211,6 +425,8 @@ function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId:
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
interface FleetViewProps {
|
||||
onNavigateToNode: (nodeId: number) => void;
|
||||
}
|
||||
@@ -219,8 +435,18 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
const [nodes, setNodes] = useState<FleetNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [prefs, setPrefs] = useState<FleetPreferences>(loadPreferences);
|
||||
const { isPro } = useLicense();
|
||||
|
||||
const updatePrefs = useCallback((update: Partial<FleetPreferences>) => {
|
||||
setPrefs(prev => {
|
||||
const next = { ...prev, ...update };
|
||||
savePreferences(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchOverview = useCallback(async (showRefresh = false) => {
|
||||
if (showRefresh) setRefreshing(true);
|
||||
try {
|
||||
@@ -247,9 +473,77 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
return () => clearInterval(interval);
|
||||
}, [isPro, fetchOverview]);
|
||||
|
||||
const onlineCount = nodes.filter(n => n.status === 'online').length;
|
||||
// --- Computed values ---
|
||||
|
||||
const onlineNodes = useMemo(() => nodes.filter(n => n.status === 'online'), [nodes]);
|
||||
const onlineCount = onlineNodes.length;
|
||||
const totalContainers = nodes.reduce((sum, n) => sum + (n.stats?.active ?? 0), 0);
|
||||
const totalContainersAll = nodes.reduce((sum, n) => sum + (n.stats?.total ?? 0), 0);
|
||||
const totalStacks = nodes.reduce((sum, n) => sum + (n.stacks?.length ?? 0), 0);
|
||||
const criticalCount = onlineNodes.filter(isCritical).length;
|
||||
|
||||
const avgCpu = onlineNodes.length > 0
|
||||
? (onlineNodes.reduce((sum, n) => sum + getNodeCpu(n), 0) / onlineNodes.length).toFixed(1)
|
||||
: '0';
|
||||
const worstCpuNode = onlineNodes.length > 0
|
||||
? onlineNodes.reduce((worst, n) => getNodeCpu(n) > getNodeCpu(worst) ? n : worst, onlineNodes[0])
|
||||
: null;
|
||||
|
||||
const totalMemUsed = onlineNodes.reduce((sum, n) => sum + (n.systemStats?.memory.used ?? 0), 0);
|
||||
const totalMemTotal = onlineNodes.reduce((sum, n) => sum + (n.systemStats?.memory.total ?? 0), 0);
|
||||
|
||||
// --- Filtering & Sorting (Pro) ---
|
||||
|
||||
const processedNodes = useMemo(() => {
|
||||
let filtered = [...nodes];
|
||||
|
||||
// Search (Pro only, but harmless if applied — free users won't see the search bar)
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(n =>
|
||||
n.name.toLowerCase().includes(q) ||
|
||||
n.stacks?.some(s => s.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
if (isPro) {
|
||||
// Status filter
|
||||
if (prefs.filterStatus === 'online') filtered = filtered.filter(n => n.status === 'online');
|
||||
if (prefs.filterStatus === 'offline') filtered = filtered.filter(n => n.status !== 'online');
|
||||
|
||||
// Type filter
|
||||
if (prefs.filterType === 'local') filtered = filtered.filter(n => n.type === 'local');
|
||||
if (prefs.filterType === 'remote') filtered = filtered.filter(n => n.type === 'remote');
|
||||
|
||||
// Critical filter
|
||||
if (prefs.filterCritical) filtered = filtered.filter(isCritical);
|
||||
|
||||
// Sort
|
||||
filtered.sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (prefs.sortBy) {
|
||||
case 'name':
|
||||
cmp = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'cpu':
|
||||
cmp = getNodeCpu(b) - getNodeCpu(a);
|
||||
break;
|
||||
case 'memory':
|
||||
cmp = getNodeMem(b) - getNodeMem(a);
|
||||
break;
|
||||
case 'containers':
|
||||
cmp = (b.stats?.active ?? 0) - (a.stats?.active ?? 0);
|
||||
break;
|
||||
case 'status':
|
||||
cmp = (a.status === 'online' ? 0 : 1) - (b.status === 'online' ? 0 : 1);
|
||||
break;
|
||||
}
|
||||
return prefs.sortDir === 'desc' ? -cmp : cmp;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [nodes, searchQuery, isPro, prefs]);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
@@ -301,18 +595,154 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node Grid */}
|
||||
{/* Fleet Content */}
|
||||
{!loading && nodes.length > 0 && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{nodes.map(node => (
|
||||
<NodeCard
|
||||
key={node.id}
|
||||
node={node}
|
||||
onNavigate={onNavigateToNode}
|
||||
{/* Pro: Fleet Health Summary Cards */}
|
||||
{isPro && onlineNodes.length > 0 && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
<StatCard
|
||||
icon={Box}
|
||||
label="Containers"
|
||||
value={`${totalContainers}`}
|
||||
sub={`${totalContainersAll} total across fleet`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label="Fleet CPU"
|
||||
value={`${avgCpu}%`}
|
||||
sub={worstCpuNode ? `Peak: ${worstCpuNode.name} (${worstCpuNode.systemStats?.cpu.usage}%)` : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
icon={MemoryStick}
|
||||
label="Fleet Memory"
|
||||
value={formatBytes(totalMemUsed)}
|
||||
sub={totalMemTotal > 0 ? `of ${formatBytes(totalMemTotal)} (${((totalMemUsed / totalMemTotal) * 100).toFixed(0)}%)` : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
icon={AlertTriangle}
|
||||
label="Alerts"
|
||||
value={`${criticalCount}`}
|
||||
sub={criticalCount > 0 ? `${criticalCount} node${criticalCount > 1 ? 's' : ''} above 90% CPU or disk` : 'All nodes healthy'}
|
||||
alert={criticalCount > 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pro: Search, Sort & Filter Toolbar */}
|
||||
{isPro && (
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
{/* Search */}
|
||||
<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) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<Select value={prefs.sortBy} onValueChange={(v) => updatePrefs({ sortBy: v as SortField })}>
|
||||
<SelectTrigger className="w-[150px] h-9">
|
||||
<ArrowUpDown className="w-3.5 h-3.5 mr-1.5 shrink-0" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
<SelectItem value="cpu">CPU Usage</SelectItem>
|
||||
<SelectItem value="memory">Memory Usage</SelectItem>
|
||||
<SelectItem value="containers">Containers</SelectItem>
|
||||
<SelectItem value="status">Status</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-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>
|
||||
|
||||
{/* Filter pills */}
|
||||
<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 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 Types' : type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={prefs.filterCritical ? 'destructive' : '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>
|
||||
)}
|
||||
|
||||
{/* Node Grid */}
|
||||
{processedNodes.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{processedNodes.map(node => (
|
||||
<NodeCard
|
||||
key={node.id}
|
||||
node={node}
|
||||
onNavigate={onNavigateToNode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Search className="w-10 h-10 text-muted-foreground/50 mb-3" />
|
||||
<h3 className="text-sm font-medium mb-1">No nodes match your filters</h3>
|
||||
<p className="text-xs text-muted-foreground">Try adjusting your search or filter criteria.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
updatePrefs({ filterStatus: 'all', filterType: 'all', filterCritical: false });
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pro auto-refresh indicator */}
|
||||
{isPro && (
|
||||
@@ -321,11 +751,21 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Free tier upgrade prompt for advanced features */}
|
||||
{/* Free tier: Pro gate for advanced features */}
|
||||
{!isPro && nodes.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<ProGate featureName="Fleet Management">
|
||||
<></>
|
||||
{/* Preview of what Pro unlocks */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
</div>
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="h-9 rounded-md border bg-card flex-1 max-w-sm" />
|
||||
<div className="h-9 rounded-md border bg-card w-[150px]" />
|
||||
</div>
|
||||
</ProGate>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user