diff --git a/CHANGELOG.md b/CHANGELOG.md index 54786a6e..537b5e00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/backend/src/index.ts b/backend/src/index.ts index 8d59f877..bea276bb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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 => { + 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 { try { const composeDir = path.resolve(NodeRegistry.getInstance().getComposeDir(node.id)); diff --git a/docs/docs.json b/docs/docs.json index 68eca739..1083cdc7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -35,6 +35,7 @@ "features/global-observability", "features/host-console", "features/multi-node", + "features/fleet-view", "features/alerts-notifications" ] }, diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx new file mode 100644 index 00000000..a716aa62 --- /dev/null +++ b/docs/features/fleet-view.mdx @@ -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. + + + Fleet View showing health summary cards, toolbar, and node grid + + +## 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 + + + The features below require a Sencho Pro license. Community users see an upgrade prompt in place of these controls. + + +### 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. + + + Fleet View with stack expanded showing container details + + +### 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. + + + Fleet View always runs on your primary (local) Sencho instance. It is never proxied through a remote node. + diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index 04fc698d..98267301 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -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. diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index aafada4b..01192fcb 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -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 (
@@ -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 ( +
+
+ + {label} +
+
{value}
+ {sub &&

{sub}

} +
+ ); +} + +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 ( +
+
+
+
+ {name} + {state} +
+ {(image || status) && ( +
+ {image && {image}} + {status && {image ? '· ' : ''}{status}} +
+ )} +
+ +
+ ); +} + +function StackSection({ stackName, nodeId, onNavigate }: { + stackName: string; + nodeId: number; + onNavigate: (nodeId: number) => void; +}) { + const [expanded, setExpanded] = useState(false); + const [containers, setContainers] = useState(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 ( +
+ + {expanded && ( +
+ {loading ? ( +
+ + +
+ ) : containers && containers.length > 0 ? ( + containers.map(c => ( + + )) + ) : ( +

No containers

+ )} +
+ )} +
+ ); +} + 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: {node.type} + {isOnline && isCritical(node) && ( + + Critical + + )}
@@ -169,7 +382,7 @@ function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId: )} - {/* Pro Expandable Stack List */} + {/* Pro Expandable Stack List with Container Drill-Down */} {isOnline && isPro && (
{expanded && ( -
+
{loadingStacks ? ( -
+
) : stacks && stacks.length > 0 ? ( -
+
{stacks.map(stack => ( - + stackName={stack} + nodeId={node.id} + onNavigate={onNavigate} + /> ))}
) : ( -

No stacks found

+

No stacks found

)}
)} @@ -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([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [prefs, setPrefs] = useState(loadPreferences); const { isPro } = useLicense(); + const updatePrefs = useCallback((update: Partial) => { + 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 (
@@ -301,18 +595,154 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
)} - {/* Node Grid */} + {/* Fleet Content */} {!loading && nodes.length > 0 && ( <> -
- {nodes.map(node => ( - 0 && ( +
+ - ))} -
+ + 0 ? `of ${formatBytes(totalMemTotal)} (${((totalMemUsed / totalMemTotal) * 100).toFixed(0)}%)` : undefined} + /> + 0 ? `${criticalCount} node${criticalCount > 1 ? 's' : ''} above 90% CPU or disk` : 'All nodes healthy'} + alert={criticalCount > 0} + /> +
+ )} + + {/* Pro: Search, Sort & Filter Toolbar */} + {isPro && ( +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="pl-9 h-9" + /> +
+ + {/* Sort */} + + + + + {/* Filter pills */} +
+ {(['all', 'online', 'offline'] as FilterStatus[]).map(status => ( + + ))} +
+ +
+ {(['all', 'local', 'remote'] as FilterType[]).map(type => ( + + ))} +
+ + +
+ )} + + {/* Node Grid */} + {processedNodes.length > 0 ? ( +
+ {processedNodes.map(node => ( + + ))} +
+ ) : ( +
+ +

No nodes match your filters

+

Try adjusting your search or filter criteria.

+ +
+ )} {/* Pro auto-refresh indicator */} {isPro && ( @@ -321,11 +751,21 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {

)} - {/* Free tier upgrade prompt for advanced features */} + {/* Free tier: Pro gate for advanced features */} {!isPro && nodes.length > 0 && (
- <> + {/* Preview of what Pro unlocks */} +
+
+
+
+
+
+
+
+
+
)}