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. -

-
- -