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:
Anso
2026-03-25 23:09:53 -04:00
committed by GitHub
parent 6e2a6cf0c0
commit 0630f57ca8
6 changed files with 642 additions and 30 deletions
+9
View File
@@ -26,10 +26,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **auth:** Login and Setup pages redesigned with split-panel branding layout (dark branding panel + theme-aware form)
* **auth:** Optional admin email field on Setup for future license recovery
* **ui:** Mobile-responsive login/setup with compact logo header
* **fleet:** Fleet health summary cards — aggregated container, CPU, memory, and alert counts across all nodes (Pro)
* **fleet:** Container-level drill-down — expand stacks to see individual containers with state, uptime, and quick navigation (Pro)
* **fleet:** Node sorting by name, CPU, memory, container count, or status with persistent preferences (Pro)
* **fleet:** Status, type, and critical resource filtering with pill-style toolbar (Pro)
* **fleet:** Fleet-wide search across node names and stack names (Pro)
* **fleet:** Critical node detection with red badge for nodes exceeding 90% CPU or disk usage
* **fleet:** Error toasts on stack and container fetch failures (replaces silent error swallowing)
* **fleet:** ProGate now wraps placeholder content instead of empty children for a better upgrade preview
### Fixed
* **e2e:** Use explicit `data-stacks-loaded` string values for reliable attribute selector matching
* **app-store:** Fix crash caused by removed `Github` icon in newer lucide-react versions
## [0.3.1](https://github.com/AnsoCode/Sencho/compare/v0.3.0...v0.3.1) (2026-03-25)
+40
View File
@@ -590,6 +590,46 @@ app.get('/api/fleet/node/:nodeId/stacks', async (req: Request, res: Response): P
}
});
// Pro-gated: container details for a specific stack on a specific node
app.get('/api/fleet/node/:nodeId/stacks/:stackName/containers', async (req: Request, res: Response): Promise<void> => {
if (!requirePro(req, res)) return;
try {
const nodeId = parseInt(req.params.nodeId as string, 10);
const stackName = req.params.stackName as string;
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node) {
res.status(404).json({ error: 'Node not found' });
return;
}
if (node.type === 'remote') {
if (!node.api_url || !node.api_token) {
res.status(503).json({ error: 'Remote node not configured' });
return;
}
const response = await fetch(`${node.api_url.replace(/\/$/, '')}/api/stacks/${encodeURIComponent(stackName)}/containers`, {
headers: { Authorization: `Bearer ${node.api_token}` },
signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
res.status(502).json({ error: 'Failed to fetch containers from remote node' });
return;
}
const containers = await response.json();
res.json(containers);
return;
}
const dockerController = DockerController.getInstance(nodeId);
const containers = await dockerController.getContainersByStack(stackName);
res.json(containers);
} catch (error) {
console.error('[Fleet] Node stack containers error:', error);
res.status(500).json({ error: 'Failed to fetch stack containers' });
}
});
async function fetchLocalNodeOverview(node: Node): Promise<FleetNodeOverview> {
try {
const composeDir = path.resolve(NodeRegistry.getInstance().getComposeDir(node.id));
+1
View File
@@ -35,6 +35,7 @@
"features/global-observability",
"features/host-console",
"features/multi-node",
"features/fleet-view",
"features/alerts-notifications"
]
},
+118
View File
@@ -0,0 +1,118 @@
---
title: Fleet View
description: Monitor all your nodes from a single dashboard with real-time health metrics, search, filtering, and container drill-down.
---
The **Fleet** tab gives you a bird's-eye view of every node in your Sencho deployment — local and remote — on one screen. It is available to all tiers, with advanced features unlocked by Sencho Pro.
<Frame>
<img src="/images/fleet-view/fleet-overview.png" alt="Fleet View showing health summary cards, toolbar, and node grid" />
</Frame>
## Community features
Every Sencho installation gets the full fleet monitoring grid at no cost.
### Node grid
Each node appears as a card showing:
| Data | Description |
|------|-------------|
| **Status badge** | Online (green) or Offline (grayed out) |
| **Type badge** | `local` or `remote` |
| **Running containers** | Count of containers in `running` state |
| **Stopped containers** | Count of containers in `exited` state |
| **Stacks** | Total number of Compose stacks on the node |
| **CPU usage** | Current percentage with colour-coded bar (green → amber → red) |
| **RAM usage** | Used / total with percentage bar |
| **Disk usage** | Used / total with percentage bar |
Offline nodes are visually dimmed and show a "Node unreachable" placeholder instead of stats.
### Manual refresh
Click the **Refresh** button in the top-right to re-fetch data from all nodes. The button shows a spinner while loading.
---
## Pro features
<Note>
The features below require a Sencho Pro license. Community users see an upgrade prompt in place of these controls.
</Note>
### Fleet health summary cards
Four cards appear above the node grid, aggregating data across all online nodes:
| Card | What it shows |
|------|---------------|
| **Containers** | Total running containers, with fleet-wide total in subtitle |
| **Fleet CPU** | Average CPU across all online nodes, plus which node has the highest load |
| **Fleet Memory** | Total RAM used / total available across the fleet |
| **Alerts** | Count of nodes with critical resource usage (CPU or disk above 90%). Card turns red when any exist |
### Auto-refresh
Fleet data automatically refreshes every 30 seconds. A subtle indicator at the bottom of the page confirms this is active.
### Search
The search bar filters the node grid in real time. It matches against:
- Node names (e.g. typing `dev` shows only nodes with "dev" in the name)
- Stack names (e.g. typing `plex` shows only nodes that have a "plex" stack)
### Sorting
Use the sort dropdown to order nodes by:
- **Name** (alphabetical)
- **CPU Usage** (highest first)
- **Memory Usage** (highest first)
- **Containers** (most first)
- **Status** (online first)
Click the arrow button next to the dropdown to toggle ascending/descending.
Sort preferences are saved to your browser and persist across sessions.
### Filtering
Filter pills let you narrow the grid:
| Filter | Options |
|--------|---------|
| **Status** | All · Online · Offline |
| **Type** | All Types · Local · Remote |
| **Critical Only** | Show only nodes with CPU or disk above 90% |
A "Clear filters" button appears when filters hide all nodes.
### Stack drill-down
Click **Stack details** on any online node card to expand the stack list. Each stack shows a count of its containers.
Click a stack name to expand it further and see individual containers with:
- Container name
- State badge (running, exited, restarting)
- Uptime (e.g. "Up 43 hours")
Hover over any container row to reveal an **Open in editor** button that navigates you directly to that node's stack editor.
<Frame>
<img src="/images/fleet-view/fleet-drill-down.png" alt="Fleet View with stack expanded showing container details" />
</Frame>
### Critical node detection
Nodes with CPU or disk usage above 90% automatically receive a red **Critical** badge. Combined with the **Critical Only** filter, this lets you quickly triage overloaded servers.
---
## How fleet data is fetched
Fleet View queries all registered nodes in parallel. Each node responds independently — one slow or offline node does not block the others. Local node data comes from the Docker socket and system stats directly. Remote node data is fetched over the Distributed API proxy using each node's Bearer token.
<Note>
Fleet View always runs on your primary (local) Sencho instance. It is never proxied through a remote node.
</Note>
+4
View File
@@ -15,6 +15,10 @@ Full in-browser code editor for your Compose and environment files. Toggle edit
Add remote Sencho instances as nodes. All dashboard operations — stack management, logs, stats — work identically whether you're targeting your local machine or a server on the other side of the world. Uses a transparent HTTP proxy model; no SSH or shared Docker sockets required. [Learn more →](/features/multi-node)
## Fleet View
Monitor your entire infrastructure from a single screen. The fleet dashboard shows all nodes with health metrics, container counts, and resource usage. Pro users unlock fleet health summary cards, container drill-down, search, sorting, filtering, and critical node detection. [Learn more →](/features/fleet-view)
## Real-time logs & stats
Stream container logs and resource metrics (CPU, memory, network I/O) live in the browser via WebSocket and Server-Sent Events connections. The Home dashboard shows historical CPU and RAM charts over the last 24 hours.
+470 -30
View File
@@ -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>
)}