mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"features/global-observability",
|
||||
"features/host-console",
|
||||
"features/multi-node",
|
||||
"features/fleet-view",
|
||||
"features/alerts-notifications"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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