From 2ee959ec3b696c5beba7b8b62bec2221ca65d525 Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 4 Apr 2026 02:51:42 -0400 Subject: [PATCH] feat(dashboard): redesign as DevOps command center (#371) * feat(dashboard): redesign as DevOps command center Transform the dashboard from a basic stats viewer into a high-signal operational command center with 5 composable sections: - Health status bar with system health derivation (Healthy/Degraded/Critical) - Resource gauges with visual progress bars and threshold coloring - Paginated stack health table with per-stack UP/DN, CPU, memory, and click-to-navigate (8 per page) - Enhanced historical CPU/RAM charts with skeleton empty states - Recent alerts feed with severity-coded notifications Extract monolithic HomeDashboard.tsx (447 lines) into composable sub-components under dashboard/ directory. Remove Docker Run to Compose converter from the landing surface. Add defensive .ok check on container status fallback in EditorLayout. * feat(dashboard): add Clear All Notifications button to Recent Alerts Add a destructive ghost button below the alerts feed that calls DELETE /api/notifications to clear all notifications, then refreshes the list. Button only appears when there are alerts to clear. * feat(dashboard): add pagination to Recent Alerts section Same pattern as Stack Health table: 8 items per page with prev/next chevron controls and page indicator in the card header. Pagination auto-hides when there are 8 or fewer alerts. Page resets on clear all. * fix(dashboard): resolve container count oscillation and add cursor hover detail Fix container stats flickering between 0 and correct values by moving state resets to the top of each useEffect body (runs once per node switch, not on every poll tick). Add animate-ui cursor primitive and wire it to the active containers number in ResourceGauges to show managed/external breakdown on hover. Silence noisy Docker socket errors when engine is unreachable. * feat(dashboard): add cursor hover to health status with reason breakdown Wrap the health badge (pulsing dot + label) in a CursorFollow tooltip that explains why the node is Critical, Degraded, or Healthy. Shows specific metrics (e.g. "RAM at 97.9%", "Disk at 96.4%") when hovered. Displays "All systems nominal" for healthy nodes. * fix(dashboard): resolve OOM from unbounded Docker stats polling Three root causes addressed: 1. updateGlobalDockerNetwork had no overlap guard. When Docker was slow, 3-second interval ticks stacked up, creating dozens of concurrent container.stats() calls that exhausted the heap. Added isUpdatingNetwork flag and increased interval from 3s to 5s. 2. Historical metrics query returned ~20K rows (1-minute buckets x 14 containers x 24h). Downsampled to 5-minute buckets, reducing response size by ~5x. 3. Dashboard polling continued when the browser tab was hidden, creating phantom load. Replaced setInterval with visibilityInterval helper that pauses polling on tab hide and resumes with an immediate fetch on focus. * fix(dashboard): use loadFile for stack navigation from Stack Health table The onNavigateToStack callback was only calling setSelectedFile and setActiveView, skipping the full load flow (YAML content, env files, containers, backup info). This caused the editor to show stale state with a "Start" button for running stacks and empty YAML. Now calls loadFile() which is the same path the sidebar uses. * refactor(dashboard): simplify Containers card layout Replace 2-column grid with vertical layout matching other gauge cards. Active count uses text-2xl hero number, exited count sits in subtitle position. Removed redundant total count row. * fix(dashboard): unify notification types and fix multi-node clear - Replace duplicate Notification interface in EditorLayout with shared NotificationItem from dashboard/types.ts - Tighten is_read type from number | boolean to number (matches SQLite) - Pass notifications from EditorLayout (which aggregates all nodes) to HomeDashboard as props, removing duplicate local-only polling from useDashboardData - Fix Clear All to use clearAllNotifications (deletes from all nodes) instead of fetchNotifications (which was just a re-fetch, causing remote notifications to reappear immediately after clearing) - Delegate DELETE responsibility from RecentAlerts to parent handler * fix(dashboard): handle optional nodeId in notification operations Guard against undefined nodeId when calling fetchForNode for mark-read, delete, and clear-all notification operations. The shared NotificationItem type has nodeId as optional since the API response doesn't include it; EditorLayout enriches it but TypeScript correctly flags the possibility. --- CHANGELOG.md | 18 +- backend/src/services/DatabaseService.ts | 7 +- backend/src/services/DockerController.ts | 14 +- frontend/src/components/EditorLayout.tsx | 39 +- frontend/src/components/HomeDashboard.tsx | 474 ++---------------- .../animate-ui/primitives/animate/cursor.tsx | 392 +++++++++++++++ .../components/dashboard/HealthStatusBar.tsx | 135 +++++ .../components/dashboard/HistoricalCharts.tsx | 105 ++++ .../src/components/dashboard/RecentAlerts.tsx | 133 +++++ .../components/dashboard/ResourceGauges.tsx | 168 +++++++ .../components/dashboard/StackHealthTable.tsx | 176 +++++++ frontend/src/components/dashboard/index.ts | 7 + frontend/src/components/dashboard/types.ts | 69 +++ .../components/dashboard/useDashboardData.ts | 133 +++++ 14 files changed, 1408 insertions(+), 462 deletions(-) create mode 100644 frontend/src/components/animate-ui/primitives/animate/cursor.tsx create mode 100644 frontend/src/components/dashboard/HealthStatusBar.tsx create mode 100644 frontend/src/components/dashboard/HistoricalCharts.tsx create mode 100644 frontend/src/components/dashboard/RecentAlerts.tsx create mode 100644 frontend/src/components/dashboard/ResourceGauges.tsx create mode 100644 frontend/src/components/dashboard/StackHealthTable.tsx create mode 100644 frontend/src/components/dashboard/index.ts create mode 100644 frontend/src/components/dashboard/types.ts create mode 100644 frontend/src/components/dashboard/useDashboardData.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index de00d050..76d5f0e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,15 +15,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -* **stacks:** state-aware sidebar context menu — actions now adapt to stack state (running stacks show Stop/Restart/Update, stopped stacks show Deploy) -* **stacks:** "Open App" action in sidebar context menu — quickly open a stack's web interface without navigating to the detail view +* **dashboard:** redesign as DevOps command center with 5 operational sections: health status bar, resource gauges with progress bars, paginated stack health table, historical charts, and recent alerts feed +* **dashboard:** stack health table with per-stack UP/DN status, aggregated CPU/memory metrics, click-to-navigate, and pagination (8 per page) +* **dashboard:** system health derivation (Healthy/Degraded/Critical) based on resource thresholds and alert state +* **stacks:** state-aware sidebar context menu, actions now adapt to stack state (running stacks show Stop/Restart/Update, stopped stacks show Deploy) +* **stacks:** "Open App" action in sidebar context menu, quickly open a stack's web interface without navigating to the detail view * **stacks:** bulk status endpoint now returns detected web port per stack for Open App support ### Changed +* **dashboard:** replaced 6 flat stat cards with 5 denser resource gauge cards featuring visual progress bars and threshold coloring +* **dashboard:** extracted monolithic HomeDashboard.tsx (447 lines) into composable sub-components under `dashboard/` directory * **updates:** reduced manual image update check cooldown from 10 minutes to 2 minutes * **updates:** rate limit error message now dynamically derives from the configured cooldown constant +### Removed + +* **dashboard:** removed Docker Run to Compose converter from the dashboard (utility that doesn't belong on the primary landing surface) + +### Fixed + +* **dashboard:** resolved OOM caused by unbounded concurrent Docker stats polling. Added overlap guard to `updateGlobalDockerNetwork`, increased its interval from 3s to 5s, downsampled historical metrics from 1-minute to 5-minute buckets, and paused all dashboard polling when the browser tab is hidden +* **stacks:** added missing `.ok` check on container status fallback response in EditorLayout, preventing potential JSON parse errors on failed requests + ## [0.36.0](https://github.com/AnsoCode/Sencho/compare/v0.35.0...v0.36.0) (2026-04-04) diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 0b1df9c7..66f186a1 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -874,6 +874,9 @@ export class DatabaseService { public getContainerMetrics(hoursLookback = 24): any[] { const cutoff = Date.now() - (hoursLookback * 60 * 60 * 1000); + // Aggregate into 5-minute buckets (300000ms) to keep response size bounded. + // With 24h lookback that's ~288 buckets per container instead of ~1440. + const bucketMs = 300000; const stmt = this.db.prepare(` SELECT container_id, @@ -882,10 +885,10 @@ export class DatabaseService { AVG(memory_mb) as memory_mb, MAX(net_rx_mb) as net_rx_mb, MAX(net_tx_mb) as net_tx_mb, - (timestamp / 60000) * 60000 as timestamp + (timestamp / ${bucketMs}) * ${bucketMs} as timestamp FROM container_metrics WHERE timestamp >= ? - GROUP BY container_id, stack_name, (timestamp / 60000) + GROUP BY container_id, stack_name, (timestamp / ${bucketMs}) ORDER BY timestamp ASC `); return stmt.all(cutoff); diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 834b5d2b..d3c8021f 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -836,8 +836,11 @@ class DockerController { export const globalDockerNetwork = { rxSec: 0, txSec: 0 }; let lastNetSum = { rx: 0, tx: 0, timestamp: Date.now() }; +let isUpdatingNetwork = false; export const updateGlobalDockerNetwork = async () => { + if (isUpdatingNetwork) return; // Prevent overlapping calls + isUpdatingNetwork = true; try { const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); const dockerController = DockerController.getInstance(nodeId); @@ -878,12 +881,15 @@ export const updateGlobalDockerNetwork = async () => { } lastNetSum = { rx: currentRxSum, tx: currentTxSum, timestamp: now }; - } catch (error) { - console.error('Failed to update global docker network stats:', error); + } catch { + // Silently skip when Docker is unreachable (e.g. no local engine). + // Network stats will remain at their last known values. + } finally { + isUpdatingNetwork = false; } }; -// Start the interval tracker -setInterval(updateGlobalDockerNetwork, 3000); +// Poll network stats every 5s (reduced from 3s to lower Docker daemon pressure) +setInterval(updateGlobalDockerNetwork, 5000); export default DockerController; diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index e36bbea9..810c51c4 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -5,6 +5,7 @@ import Editor from '@monaco-editor/react'; import TerminalComponent from './Terminal'; import ErrorBoundary from './ErrorBoundary'; import HomeDashboard from './HomeDashboard'; +import type { NotificationItem } from './dashboard/types'; import BashExecModal from './BashExecModal'; import HostConsole from './HostConsole'; import { AdmiralGate } from './AdmiralGate'; @@ -73,15 +74,6 @@ interface StackStatusInfo { type StackAction = 'deploy' | 'stop' | 'restart' | 'update' | 'delete' | 'rollback'; -interface Notification { - id: number; - level: 'info' | 'warning' | 'error'; - message: string; - timestamp: number; - is_read: number; // 0 | 1 (SQLite boolean) - nodeId: number; - nodeName: string; -} const formatBytes = (bytes: number) => { if (bytes === 0) return '0 B'; @@ -203,7 +195,7 @@ export default function EditorLayout() { const [stackUpdates, setStackUpdates] = useState>({}); // Notifications & Settings state - const [notifications, setNotifications] = useState([]); + const [notifications, setNotifications] = useState([]); const [settingsModalOpen, setSettingsModalOpen] = useState(false); const [settingsInitialSection, setSettingsInitialSection] = useState<'account' | 'labels'>('account'); const [alertSheetOpen, setAlertSheetOpen] = useState(false); @@ -359,6 +351,7 @@ export default function EditorLayout() { const statusResults = await Promise.allSettled( fileList.map(async (file) => { const containersRes = await apiFetch(`/stacks/${file}/containers`); + if (!containersRes.ok) return { file, status: 'unknown' as const }; const containers = await containersRes.json(); const hasRunning = Array.isArray(containers) && containers.some((c: ContainerInfo) => c.State === 'running'); return { file, status: hasRunning ? 'running' as const : (Array.isArray(containers) && containers.length > 0 ? 'exited' as const : 'unknown' as const) }; @@ -469,8 +462,8 @@ export default function EditorLayout() { const msg = JSON.parse(event.data as string); if (msg.type === 'notification' && msg.payload) { const localNode = nodesRef.current.find(n => n.type === 'local'); - const tagged: Notification = { - ...(msg.payload as Omit), + const tagged: NotificationItem = { + ...(msg.payload as Omit), nodeId: localNode?.id ?? -1, nodeName: localNode?.name ?? 'Local', }; @@ -554,7 +547,7 @@ export default function EditorLayout() { // Read node name from ref so it stays fresh even if the node was renamed const current = nodesRef.current.find(n => n.id === rn.id); setNotifications(prev => - [{ ...msg.payload as Omit, nodeId: rn.id, nodeName: current?.name ?? rn.name }, ...prev] + [{ ...msg.payload as Omit, nodeId: rn.id, nodeName: current?.name ?? rn.name }, ...prev] .sort((a, b) => b.timestamp - a.timestamp) ); } @@ -631,17 +624,17 @@ export default function EditorLayout() { ...remoteNodes.map(n => fetchForNode('/notifications', n.id)), ]); - const all: Notification[] = []; + const all: NotificationItem[] = []; if (localResult.status === 'fulfilled' && localResult.value.ok) { - const data = await localResult.value.json() as Omit[]; + const data = await localResult.value.json() as Omit[]; data.forEach(n => all.push({ ...n, nodeId: localNode?.id ?? -1, nodeName: localNode?.name ?? 'Local' })); } for (let i = 0; i < remoteNodes.length; i++) { const result = remoteResults[i]; if (result?.status === 'fulfilled' && result.value.ok) { - const data = await result.value.json() as Omit[]; + const data = await result.value.json() as Omit[]; const rn = remoteNodes[i]; data.forEach(n => all.push({ ...n, nodeId: rn.id, nodeName: rn.name })); } @@ -669,7 +662,7 @@ export default function EditorLayout() { const markAllRead = async () => { try { const localNode = nodesRef.current.find(n => n.type === 'local'); - const unreadNodeIds = [...new Set(notifications.filter(n => !n.is_read).map(n => n.nodeId))]; + const unreadNodeIds = [...new Set(notifications.filter(n => !n.is_read && n.nodeId != null).map(n => n.nodeId as number))]; await Promise.allSettled(unreadNodeIds.map(nodeId => nodeId === localNode?.id ? apiFetch('/notifications/read', { method: 'POST', localOnly: true }) @@ -682,12 +675,12 @@ export default function EditorLayout() { } }; - const deleteNotification = async (notif: Notification) => { + const deleteNotification = async (notif: NotificationItem) => { try { const localNode = nodesRef.current.find(n => n.type === 'local'); if (notif.nodeId === localNode?.id) { await apiFetch(`/notifications/${notif.id}`, { method: 'DELETE', localOnly: true }); - } else { + } else if (notif.nodeId != null) { await fetchForNode(`/notifications/${notif.id}`, notif.nodeId, { method: 'DELETE' }); } setNotifications(prev => prev.filter(n => !(n.id === notif.id && n.nodeId === notif.nodeId))); @@ -700,7 +693,7 @@ export default function EditorLayout() { const clearAllNotifications = async () => { try { const localNode = nodesRef.current.find(n => n.type === 'local'); - const uniqueNodeIds = [...new Set(notifications.map(n => n.nodeId))]; + const uniqueNodeIds = [...new Set(notifications.filter(n => n.nodeId != null).map(n => n.nodeId as number))]; await Promise.allSettled(uniqueNodeIds.map(nodeId => nodeId === localNode?.id ? apiFetch('/notifications', { method: 'DELETE', localOnly: true }) @@ -2241,7 +2234,11 @@ export default function EditorLayout() { setFilterNodeId(null)} /> ) : ( - + { loadFile(stackFile); }} + notifications={notifications} + onClearNotifications={clearAllNotifications} + /> )} diff --git a/frontend/src/components/HomeDashboard.tsx b/frontend/src/components/HomeDashboard.tsx index 3eb7c999..6a689c1e 100644 --- a/frontend/src/components/HomeDashboard.tsx +++ b/frontend/src/components/HomeDashboard.tsx @@ -1,447 +1,55 @@ -import { useState, useEffect, useMemo } from 'react'; import { useNodes } from '@/context/NodeContext'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from './ui/card'; -import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; -import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'; -import { Button } from './ui/button'; -import { Input } from './ui/input'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog'; -import { Activity, Square, ArrowRight, Plus, Cpu, HardDrive, MemoryStick, Network } from 'lucide-react'; -import { apiFetch } from '@/lib/api'; -import { toast } from '@/components/ui/toast-store'; -import { Label } from './ui/label'; +import type { NotificationItem } from './dashboard/types'; +import { + HealthStatusBar, + ResourceGauges, + StackHealthTable, + HistoricalCharts, + RecentAlerts, + useDashboardData, +} from './dashboard'; -interface Stats { - active: number; - managed: number; - unmanaged: number; - exited: number; - total: number; +interface HomeDashboardProps { + onNavigateToStack?: (stackFile: string) => void; + notifications: NotificationItem[]; + onClearNotifications: () => void | Promise; } -interface MetricPoint { - timestamp: number; - cpu_percent: number; - memory_mb: number; -} - -interface SystemStats { - cpu: { - usage: string; - cores: number; - }; - memory: { - total: number; - used: number; - free: number; - usagePercent: string; - }; - disk: { - fs: string; - mount: string; - total: number; - used: number; - free: number; - usagePercent: string; - } | null; - network?: { - rxBytes: number; - txBytes: number; - rxSec: number; - txSec: number; - }; -} - -const formatBytes = (bytes: number) => { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; -}; - -export default function HomeDashboard() { +export default function HomeDashboard({ onNavigateToStack, notifications, onClearNotifications }: HomeDashboardProps) { const { activeNode } = useNodes(); - const [dockerRunInput, setDockerRunInput] = useState(''); - const [isConverting, setIsConverting] = useState(false); - const [convertedYaml, setConvertedYaml] = useState(''); - const [createDialogOpen, setCreateDialogOpen] = useState(false); - const [newStackName, setNewStackName] = useState(''); - const [stats, setStats] = useState({ active: 0, managed: 0, unmanaged: 0, exited: 0, total: 0 }); - const [systemStats, setSystemStats] = useState(null); - const [metrics, setMetrics] = useState([]); - - const getValueColor = (value: number, warn = 80, crit = 90) => { - if (value >= crit) return 'text-destructive/80'; - if (value >= warn) return 'text-warning/80'; - return 'text-stat-value'; - }; - - // Fetch container stats - re-runs when active node changes so stale data is cleared immediately - useEffect(() => { - setStats({ active: 0, managed: 0, unmanaged: 0, exited: 0, total: 0 }); - const fetchStats = async () => { - try { - const res = await apiFetch('/stats'); - if (!res.ok) return; - const data = await res.json(); - setStats(data); - } catch (error) { - console.error('Failed to fetch stats:', error); - } - }; - fetchStats(); - const interval = setInterval(fetchStats, 5000); - return () => clearInterval(interval); - }, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps - - // Fetch system stats (CPU/RAM/Disk/Network) - 5s polling, re-runs on node switch - useEffect(() => { - setSystemStats(null); - const fetchSystemStats = async () => { - try { - const res = await apiFetch('/system/stats'); - if (res.ok) setSystemStats(await res.json()); - } catch (error) { - console.error('Failed to fetch system stats:', error); - } - }; - fetchSystemStats(); - const interval = setInterval(fetchSystemStats, 5000); - return () => clearInterval(interval); - }, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps - - // Fetch historical metrics - intentionally slow 60s poll to prevent OOM. - // The backend now returns 1-minute buckets (down from raw 5s points), so - // re-fetching more often than 60s would return the same data anyway. - useEffect(() => { - setMetrics([]); - const fetchMetrics = async () => { - try { - const res = await apiFetch('/metrics/historical'); - if (res.ok) setMetrics(await res.json()); - } catch (error) { - console.error('Failed to fetch historical metrics:', error); - } - }; - fetchMetrics(); - const interval = setInterval(fetchMetrics, 60000); - return () => clearInterval(interval); - }, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps - - const chartData = useMemo(() => { - const buckets: Record = {}; - const cores = systemStats?.cpu.cores || 1; - - metrics.forEach(m => { - const date = new Date(m.timestamp); - date.setSeconds(0, 0); - const key = date.getTime() + ''; - - if (!buckets[key]) { - buckets[key] = { - time: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), - timestamp: date.getTime(), - cpu: 0, - ram: 0 - }; - } - buckets[key].cpu += (m.cpu_percent / cores); - buckets[key].ram += (m.memory_mb / 1024); - }); - - return Object.values(buckets).sort((a, b) => a.timestamp - b.timestamp); - }, [metrics, systemStats]); - - const chartConfig = { - cpu: { label: 'CPU Usage (%)', color: 'var(--chart-1)' }, - ram: { label: 'RAM Usage (GB)', color: 'var(--chart-2)' }, - }; - - const handleConvert = async () => { - if (!dockerRunInput.trim()) return; - setIsConverting(true); - try { - const response = await apiFetch('/convert', { - method: 'POST', - body: JSON.stringify({ dockerRun: dockerRunInput }), - }); - if (!response.ok) throw new Error('Conversion failed'); - const data = await response.json(); - setConvertedYaml(data.yaml); - } catch (error) { - console.error('Conversion error:', error); - toast.error('Failed to convert docker run command'); - } finally { - setIsConverting(false); - } - }; - - const handleCreateStack = async () => { - if (!newStackName.trim() || !convertedYaml) return; - // Send stackName directly (no .yml extension - backend creates directory) - const stackName = newStackName.trim(); - try { - // Create the stack - const createResponse = await apiFetch('/stacks', { - method: 'POST', - body: JSON.stringify({ stackName }), - }); - if (!createResponse.ok) { - const err = await createResponse.json().catch(() => ({})); - throw new Error(err.error || 'Failed to create stack'); - } - - // Save the converted YAML content - const saveResponse = await apiFetch(`/stacks/${stackName}`, { - method: 'PUT', - body: JSON.stringify({ content: convertedYaml }), - }); - if (!saveResponse.ok) { - const err = await saveResponse.json().catch(() => ({})); - throw new Error(err.error || 'Failed to save stack content'); - } - - setCreateDialogOpen(false); - setNewStackName(''); - setConvertedYaml(''); - setDockerRunInput(''); - window.location.reload(); // Refresh to show new stack - } catch (error) { - console.error('Failed to create stack:', error); - toast.error((error as Error).message || 'Failed to create stack'); - } - }; - - const handleUseConvertedYaml = () => { - setCreateDialogOpen(true); - }; + const data = useDashboardData(); return ( -
- {/* Container Stats Row */} -
- - - Active Containers - - - -
{stats.active}
-

- {stats.managed} managed · {stats.unmanaged} external -

-
-
+
+ - - - Exited Containers - - - -
{stats.exited}
-

Stopped or crashed

-
-
+ - - - Docker Network - - - -
- {systemStats?.network - ? `${formatBytes(systemStats.network.rxSec)}/s ↓` - : '...'} -
-

- {systemStats?.network - ? `${formatBytes(systemStats.network.txSec)}/s ↑` - : 'Loading...'} -

-
-
-
+ {})} + /> - {/* Host System Stats Row */} -
- - - Host CPU - - - -
- {systemStats ? `${systemStats.cpu.usage}%` : '...'} -
-

- {systemStats ? `${systemStats.cpu.cores} cores` : 'Loading...'} -

-
-
+ - - - Host RAM - - - -
- {systemStats ? `${systemStats.memory.usagePercent}%` : '...'} -
-

- {systemStats - ? `${formatBytes(systemStats.memory.used)} / ${formatBytes(systemStats.memory.total)}` - : 'Loading...'} -

-
-
- - - - Host Disk - - - -
- {systemStats?.disk ? `${systemStats.disk.usagePercent}%` : '...'} -
-

- {systemStats?.disk - ? `${formatBytes(systemStats.disk.used)} / ${formatBytes(systemStats.disk.total)}` - : 'Loading...'} -

-
-
-
- - {/* Historical Charts */} -
- - - - - Normalized CPU Usage - - Total CPU percentage over total host cores. - - - {chartData.length > 0 ? ( - - - - - `${Number(val).toFixed(0)}%`} domain={[0, 100]} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} /> - } /> - - - - ) : ( -
- No historical CPU data. -
- )} -
-
- - - - - - Normalized RAM Usage - - Total RAM allocation in GB. - - - {chartData.length > 0 ? ( - - - - - `${Number(val).toFixed(1)} GB`} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} /> - } /> - - - - ) : ( -
- No historical RAM data. -
- )} -
-
-
- - {/* Docker Run Converter */} - - - Convert Docker Run to Compose -

- Paste your docker run command below to convert it to a Docker Compose YAML file. -

-
- -