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.
This commit is contained in:
Anso
2026-04-04 02:51:42 -04:00
committed by GitHub
parent aff1981d25
commit 2ee959ec3b
14 changed files with 1408 additions and 462 deletions
+16 -2
View File
@@ -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)
+5 -2
View File
@@ -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);
+10 -4
View File
@@ -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;
+18 -21
View File
@@ -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<Record<string, boolean>>({});
// Notifications & Settings state
const [notifications, setNotifications] = useState<Notification[]>([]);
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
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<Notification, 'nodeId' | 'nodeName'>),
const tagged: NotificationItem = {
...(msg.payload as Omit<NotificationItem, 'nodeId' | 'nodeName'>),
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<Notification, 'nodeId' | 'nodeName'>, nodeId: rn.id, nodeName: current?.name ?? rn.name }, ...prev]
[{ ...msg.payload as Omit<NotificationItem, 'nodeId' | 'nodeName'>, 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<Notification, 'nodeId' | 'nodeName'>[];
const data = await localResult.value.json() as Omit<NotificationItem, 'nodeId' | 'nodeName'>[];
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<Notification, 'nodeId' | 'nodeName'>[];
const data = await result.value.json() as Omit<NotificationItem, 'nodeId' | 'nodeName'>[];
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() {
<ScheduledOperationsView filterNodeId={filterNodeId} onClearFilter={() => setFilterNodeId(null)} />
</CapabilityGate>
) : (
<HomeDashboard />
<HomeDashboard
onNavigateToStack={(stackFile) => { loadFile(stackFile); }}
notifications={notifications}
onClearNotifications={clearAllNotifications}
/>
)}
</div>
</div>
+41 -433
View File
@@ -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<void>;
}
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<Stats>({ active: 0, managed: 0, unmanaged: 0, exited: 0, total: 0 });
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
const [metrics, setMetrics] = useState<MetricPoint[]>([]);
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<string, { time: string; timestamp: number; cpu: number; ram: number }> = {};
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 (
<div className="flex-1 p-6 space-y-6">
{/* Container Stats Row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Card className="bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-stat-title">Active Containers</CardTitle>
<Activity className="h-4 w-4 text-stat-icon" />
</CardHeader>
<CardContent>
<div className="text-3xl font-medium text-stat-value tabular-nums tracking-tight">{stats.active}</div>
<p className="text-xs text-stat-subtitle mt-1">
{stats.managed} managed · {stats.unmanaged} external
</p>
</CardContent>
</Card>
<div className="flex-1 p-6 space-y-4">
<HealthStatusBar
stats={data.stats}
systemStats={data.systemStats}
notifications={notifications}
activeNodeName={activeNode?.name || 'Local'}
lastUpdated={data.lastUpdated}
/>
<Card className="bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-stat-title">Exited Containers</CardTitle>
<Square className="h-4 w-4 text-stat-icon" />
</CardHeader>
<CardContent>
<div className="text-3xl font-medium text-stat-value tabular-nums tracking-tight">{stats.exited}</div>
<p className="text-xs text-stat-subtitle mt-1">Stopped or crashed</p>
</CardContent>
</Card>
<ResourceGauges
stats={data.stats}
systemStats={data.systemStats}
/>
<Card className="bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-stat-title">Docker Network</CardTitle>
<Network className="h-4 w-4 text-stat-icon" />
</CardHeader>
<CardContent>
<div className="text-xl font-medium text-stat-value whitespace-nowrap">
{systemStats?.network
? `${formatBytes(systemStats.network.rxSec)}/s ↓`
: '...'}
</div>
<p className="text-xs text-stat-subtitle mt-1 whitespace-nowrap">
{systemStats?.network
? `${formatBytes(systemStats.network.txSec)}/s ↑`
: 'Loading...'}
</p>
</CardContent>
</Card>
</div>
<StackHealthTable
stackStatuses={data.stackStatuses}
metrics={data.metrics}
systemStats={data.systemStats}
onNavigateToStack={onNavigateToStack || (() => {})}
/>
{/* Host System Stats Row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Card className="bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-stat-title">Host CPU</CardTitle>
<Cpu className="h-4 w-4 text-stat-icon" />
</CardHeader>
<CardContent>
<div className="text-3xl font-medium text-stat-value tabular-nums tracking-tight">
{systemStats ? `${systemStats.cpu.usage}%` : '...'}
</div>
<p className="text-xs text-stat-subtitle mt-1">
{systemStats ? `${systemStats.cpu.cores} cores` : 'Loading...'}
</p>
</CardContent>
</Card>
<HistoricalCharts
metrics={data.metrics}
systemStats={data.systemStats}
/>
<Card className="bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-stat-title">Host RAM</CardTitle>
<MemoryStick className="h-4 w-4 text-stat-icon" />
</CardHeader>
<CardContent>
<div className={`text-3xl font-medium tabular-nums tracking-tight ${systemStats ? getValueColor(parseFloat(systemStats.memory.usagePercent)) : 'text-stat-value'}`}>
{systemStats ? `${systemStats.memory.usagePercent}%` : '...'}
</div>
<p className="text-xs text-stat-subtitle mt-1">
{systemStats
? `${formatBytes(systemStats.memory.used)} / ${formatBytes(systemStats.memory.total)}`
: 'Loading...'}
</p>
</CardContent>
</Card>
<Card className="bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-stat-title">Host Disk</CardTitle>
<HardDrive className="h-4 w-4 text-stat-icon" />
</CardHeader>
<CardContent>
<div className={`text-3xl font-medium tabular-nums tracking-tight ${systemStats?.disk ? getValueColor(parseFloat(systemStats.disk.usagePercent)) : 'text-stat-value'}`}>
{systemStats?.disk ? `${systemStats.disk.usagePercent}%` : '...'}
</div>
<p className="text-xs text-stat-subtitle mt-1">
{systemStats?.disk
? `${formatBytes(systemStats.disk.used)} / ${formatBytes(systemStats.disk.total)}`
: 'Loading...'}
</p>
</CardContent>
</Card>
</div>
{/* Historical Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="bg-card">
<CardHeader className="pb-2">
<CardTitle className="flex items-center space-x-2 text-sm font-medium text-stat-title">
<Activity className="w-4 h-4 text-stat-icon" />
<span>Normalized CPU Usage</span>
</CardTitle>
<CardDescription className="text-xs">Total CPU percentage over total host cores.</CardDescription>
</CardHeader>
<CardContent className="h-[250px]">
{chartData.length > 0 ? (
<ChartContainer config={chartConfig} className="w-full h-full">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--chart-grid)" />
<XAxis dataKey="time" minTickGap={30} tickMargin={8} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
<YAxis tickFormatter={(val) => `${Number(val).toFixed(0)}%`} domain={[0, 100]} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area type="monotone" dataKey="cpu" stroke="var(--color-cpu)" fill="var(--color-cpu)" fillOpacity={0.4} />
</AreaChart>
</ChartContainer>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No historical CPU data.
</div>
)}
</CardContent>
</Card>
<Card className="bg-card">
<CardHeader className="pb-2">
<CardTitle className="flex items-center space-x-2 text-sm font-medium text-stat-title">
<Activity className="w-4 h-4 text-stat-icon" />
<span>Normalized RAM Usage</span>
</CardTitle>
<CardDescription className="text-xs">Total RAM allocation in GB.</CardDescription>
</CardHeader>
<CardContent className="h-[250px]">
{chartData.length > 0 ? (
<ChartContainer config={chartConfig} className="w-full h-full">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--chart-grid)" />
<XAxis dataKey="time" minTickGap={30} tickMargin={8} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
<YAxis tickFormatter={(val) => `${Number(val).toFixed(1)} GB`} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area type="monotone" dataKey="ram" stroke="var(--color-ram)" fill="var(--color-ram)" fillOpacity={0.4} />
</AreaChart>
</ChartContainer>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No historical RAM data.
</div>
)}
</CardContent>
</Card>
</div>
{/* Docker Run Converter */}
<Card className="bg-card">
<CardHeader>
<CardTitle className="text-lg">Convert Docker Run to Compose</CardTitle>
<p className="text-sm text-muted-foreground">
Paste your <code className="bg-muted px-1 rounded">docker run</code> command below to convert it to a Docker Compose YAML file.
</p>
</CardHeader>
<CardContent className="space-y-4">
<textarea
className="w-full h-32 p-3 rounded-lg border border-muted bg-background text-foreground font-mono text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="docker run -d --name my-app -p 8080:80 -e TZ=UTC nginx:latest"
value={dockerRunInput}
onChange={(e) => setDockerRunInput(e.target.value)}
/>
<div className="flex gap-2">
<Button onClick={handleConvert} disabled={isConverting || !dockerRunInput.trim()}>
{isConverting ? 'Converting...' : 'Convert'}
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
{convertedYaml && (
<div className="space-y-3">
<div className="p-3 rounded-lg bg-muted/50 font-mono text-sm whitespace-pre-wrap overflow-auto max-h-64">
{convertedYaml}
</div>
<Button onClick={handleUseConvertedYaml}>
<Plus className="w-4 h-4 mr-2" />
Create Stack from YAML
</Button>
</div>
)}
</CardContent>
</Card>
{/* Create Stack Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Stack</DialogTitle>
</DialogHeader>
<div className="py-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="stack-name">Stack Name</Label>
<Input
id="stack-name"
placeholder="e.g., myapp"
value={newStackName}
onChange={(e) => setNewStackName(e.target.value)}
/>
</div>
{convertedYaml && (
<div className="space-y-2">
<Label>Converted Compose File (Preview)</Label>
<div className="p-3 rounded-lg bg-muted/50 font-mono text-sm whitespace-pre-wrap overflow-auto max-h-48">
{convertedYaml}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>Cancel</Button>
<Button onClick={handleCreateStack} disabled={!newStackName.trim()}>Create Stack</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<RecentAlerts
notifications={notifications}
onCleared={onClearNotifications}
/>
</div>
);
}
@@ -0,0 +1,392 @@
/* eslint-disable */
'use client';
import * as React from 'react';
import {
motion,
useMotionValue,
useSpring,
AnimatePresence,
type HTMLMotionProps,
type SpringOptions,
} from 'motion/react';
import { getStrictContext } from '@/lib/get-strict-context';
import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot';
type CursorContextType = {
cursorPos: { x: number; y: number };
active: boolean;
global: boolean;
containerRef: React.RefObject<HTMLDivElement | null>;
cursorRef: React.RefObject<HTMLDivElement | null>;
};
const [LocalCursorProvider, useCursor] =
getStrictContext<CursorContextType>('CursorContext');
type CursorProviderProps = {
children: React.ReactNode;
global?: boolean;
};
function CursorProvider({ children, global = false }: CursorProviderProps) {
const [cursorPos, setCursorPos] = React.useState({ x: 0, y: 0 });
const [active, setActive] = React.useState(false);
const containerRef = React.useRef<HTMLDivElement>(null);
const cursorRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const id = '__cursor_none_style__';
if (document.getElementById(id)) return;
const style = document.createElement('style');
style.id = id;
style.textContent = `
.animate-ui-cursor-none, .animate-ui-cursor-none * { cursor: none !important; }
`;
document.head.appendChild(style);
}, []);
React.useEffect(() => {
let removeListeners: () => void;
if (global) {
const handlePointerMove = (e: PointerEvent) => {
setCursorPos({ x: e.clientX, y: e.clientY });
setActive(true);
};
const handlePointerOut = (e: PointerEvent | MouseEvent) => {
if (e instanceof PointerEvent && e.relatedTarget === null) {
setActive(false);
}
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') setActive(false);
};
window.addEventListener('pointermove', handlePointerMove, {
passive: true,
});
window.addEventListener('pointerout', handlePointerOut, {
passive: true,
});
window.addEventListener('mouseout', handlePointerOut, { passive: true });
document.addEventListener('visibilitychange', handleVisibilityChange);
removeListeners = () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerout', handlePointerOut);
window.removeEventListener('mouseout', handlePointerOut);
document.removeEventListener(
'visibilitychange',
handleVisibilityChange,
);
};
} else {
if (!containerRef.current) return;
const parent = containerRef.current.parentElement;
if (!parent) return;
if (getComputedStyle(parent).position === 'static') {
parent.style.position = 'relative';
}
const handlePointerMove = (e: PointerEvent) => {
const rect = parent.getBoundingClientRect();
setCursorPos({ x: e.clientX - rect.left, y: e.clientY - rect.top });
setActive(true);
};
const handlePointerOut = (e: PointerEvent | MouseEvent) => {
if (
e.relatedTarget === null ||
!(parent as Node).contains(e.relatedTarget as Node)
) {
setActive(false);
}
};
parent.addEventListener('pointermove', handlePointerMove, {
passive: true,
});
parent.addEventListener('pointerout', handlePointerOut, {
passive: true,
});
parent.addEventListener('mouseout', handlePointerOut, { passive: true });
removeListeners = () => {
parent.removeEventListener('pointermove', handlePointerMove);
parent.removeEventListener('pointerout', handlePointerOut);
parent.removeEventListener('mouseout', handlePointerOut);
};
}
return removeListeners;
}, [global]);
return (
<LocalCursorProvider
value={{ cursorPos, active, global, containerRef, cursorRef }}
>
{children}
</LocalCursorProvider>
);
}
type CursorContainerProps = WithAsChild<HTMLMotionProps<'div'>>;
function CursorContainer({
ref,
asChild = false,
...props
}: CursorContainerProps) {
const { containerRef, global, active } = useCursor();
React.useImperativeHandle(ref, () => containerRef.current as HTMLDivElement);
const Component = asChild ? Slot : motion.div;
return (
<Component
ref={containerRef}
data-slot="cursor-container"
data-global={global}
data-active={active}
{...props}
/>
);
}
type CursorProps = WithAsChild<
HTMLMotionProps<'div'> & {
children: React.ReactNode;
}
>;
function Cursor({ ref, asChild = false, style, ...props }: CursorProps) {
const { cursorPos, active, containerRef, cursorRef, global } = useCursor();
React.useImperativeHandle(ref, () => cursorRef.current as HTMLDivElement);
const x = useMotionValue(0);
const y = useMotionValue(0);
React.useEffect(() => {
const target = global
? document.documentElement
: containerRef.current?.parentElement;
if (!target) return;
if (active) {
target.classList.add('animate-ui-cursor-none');
} else {
target.classList.remove('animate-ui-cursor-none');
}
return () => {
target.classList.remove('animate-ui-cursor-none');
};
}, [active, global, containerRef]);
React.useEffect(() => {
x.set(cursorPos.x);
y.set(cursorPos.y);
}, [cursorPos, x, y]);
const Component = asChild ? Slot : motion.div;
return (
<AnimatePresence>
{active && (
<Component
ref={cursorRef}
data-slot="cursor"
data-global={global}
data-active={active}
style={{
transform: 'translate(-50%,-50%)',
pointerEvents: 'none',
zIndex: 9999,
position: global ? 'fixed' : 'absolute',
top: y,
left: x,
...style,
}}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
{...props}
/>
)}
</AnimatePresence>
);
}
type CursorFollowSide = 'top' | 'right' | 'bottom' | 'left';
type CursorFollowAlign = 'start' | 'center' | 'end';
type CursorFollowProps = WithAsChild<
Omit<HTMLMotionProps<'div'>, 'transition'> & {
side?: CursorFollowSide;
sideOffset?: number;
align?: CursorFollowAlign;
alignOffset?: number;
transition?: SpringOptions;
children: React.ReactNode;
}
>;
function CursorFollow({
ref,
asChild = false,
side = 'bottom',
sideOffset = 0,
align = 'end',
alignOffset = 0,
style,
transition = { stiffness: 500, damping: 50, bounce: 0 },
...props
}: CursorFollowProps) {
const { cursorPos, active, cursorRef, global } = useCursor();
const cursorFollowRef = React.useRef<HTMLDivElement>(null);
React.useImperativeHandle(
ref,
() => cursorFollowRef.current as HTMLDivElement,
);
const x = useMotionValue(0);
const y = useMotionValue(0);
const springX = useSpring(x, transition);
const springY = useSpring(y, transition);
const calculateOffset = React.useCallback(() => {
const rect = cursorFollowRef.current?.getBoundingClientRect();
const width = rect?.width ?? 0;
const height = rect?.height ?? 0;
let offsetX = 0;
let offsetY = 0;
switch (side) {
case 'top':
offsetY = height + sideOffset;
switch (align) {
case 'start':
offsetX = width + alignOffset;
break;
case 'center':
offsetX = width / 2;
break;
case 'end':
offsetX = -alignOffset;
break;
}
break;
case 'bottom':
offsetY = -sideOffset;
switch (align) {
case 'start':
offsetX = width + alignOffset;
break;
case 'center':
offsetX = width / 2;
break;
case 'end':
offsetX = -alignOffset;
break;
}
break;
case 'left':
offsetX = width + sideOffset;
switch (align) {
case 'start':
offsetY = height + alignOffset;
break;
case 'center':
offsetY = height / 2;
break;
case 'end':
offsetY = -alignOffset;
break;
}
break;
case 'right':
offsetX = -sideOffset;
switch (align) {
case 'start':
offsetY = height + alignOffset;
break;
case 'center':
offsetY = height / 2;
break;
case 'end':
offsetY = -alignOffset;
break;
}
break;
}
return { x: offsetX, y: offsetY };
}, [side, align, sideOffset, alignOffset]);
React.useEffect(() => {
const offset = calculateOffset();
const cursorRect = cursorRef.current?.getBoundingClientRect();
const cursorWidth = cursorRect?.width ?? 20;
const cursorHeight = cursorRect?.height ?? 20;
x.set(cursorPos.x - offset.x + cursorWidth / 2);
y.set(cursorPos.y - offset.y + cursorHeight / 2);
}, [calculateOffset, cursorPos, cursorRef, x, y]);
const Component = asChild ? Slot : motion.div;
return (
<AnimatePresence>
{active && (
<Component
ref={cursorFollowRef}
data-slot="cursor-follow"
data-global={global}
data-active={active}
style={{
transform: 'translate(-50%,-50%)',
pointerEvents: 'none',
zIndex: 9998,
position: global ? 'fixed' : 'absolute',
top: springY,
left: springX,
...style,
}}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
{...props}
/>
)}
</AnimatePresence>
);
}
export {
CursorProvider,
Cursor,
CursorContainer,
CursorFollow,
useCursor,
type CursorProviderProps,
type CursorProps,
type CursorContainerProps,
type CursorFollowProps,
type CursorFollowAlign,
type CursorFollowSide,
type CursorContextType,
};
@@ -0,0 +1,135 @@
import { useMemo } from 'react';
import { Card } from '@/components/ui/card';
import { Activity, Bell } from 'lucide-react';
import {
CursorProvider,
Cursor,
CursorContainer,
CursorFollow,
} from '@/components/animate-ui/primitives/animate/cursor';
import type { Stats, SystemStats, NotificationItem, HealthLevel } from './types';
interface HealthStatusBarProps {
stats: Stats;
systemStats: SystemStats | null;
notifications: NotificationItem[];
activeNodeName: string;
lastUpdated: number;
}
interface HealthResult {
level: HealthLevel;
reasons: string[];
}
function deriveHealth(stats: Stats, systemStats: SystemStats | null, notifications: NotificationItem[]): HealthResult {
const cpu = parseFloat(systemStats?.cpu.usage || '0');
const ram = parseFloat(systemStats?.memory.usagePercent || '0');
const disk = parseFloat(systemStats?.disk?.usagePercent || '0');
const unreadErrors = notifications.filter(n => !n.is_read && n.level === 'error').length;
const reasons: string[] = [];
if (cpu >= 90) reasons.push(`CPU at ${cpu.toFixed(1)}%`);
else if (cpu >= 80) reasons.push(`CPU at ${cpu.toFixed(1)}%`);
if (ram >= 90) reasons.push(`RAM at ${ram.toFixed(1)}%`);
else if (ram >= 80) reasons.push(`RAM at ${ram.toFixed(1)}%`);
if (disk >= 90) reasons.push(`Disk at ${disk.toFixed(1)}%`);
else if (disk >= 80) reasons.push(`Disk at ${disk.toFixed(1)}%`);
if (stats.exited > 0 && unreadErrors > 0) reasons.push(`${stats.exited} exited container${stats.exited !== 1 ? 's' : ''}`);
else if (unreadErrors > 0) reasons.push(`${unreadErrors} unread error${unreadErrors !== 1 ? 's' : ''}`);
if (cpu >= 90 || ram >= 90 || disk >= 90 || (stats.exited > 0 && unreadErrors > 0)) {
return { level: 'critical', reasons };
}
if (cpu >= 80 || ram >= 80 || disk >= 80 || unreadErrors > 0) {
return { level: 'degraded', reasons };
}
return { level: 'healthy', reasons: ['All systems nominal'] };
}
const healthConfig: Record<HealthLevel, { label: string; dotClass: string; textClass: string }> = {
healthy: { label: 'Healthy', dotClass: 'bg-success', textClass: 'text-success' },
degraded: { label: 'Degraded', dotClass: 'bg-warning animate-pulse', textClass: 'text-warning' },
critical: { label: 'Critical', dotClass: 'bg-destructive animate-pulse', textClass: 'text-destructive' },
};
function formatRelativeTime(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 5) return 'just now';
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
return `${Math.floor(minutes / 60)}h ago`;
}
export function HealthStatusBar({ stats, systemStats, notifications, activeNodeName, lastUpdated }: HealthStatusBarProps) {
const { level, reasons } = useMemo(
() => deriveHealth(stats, systemStats, notifications),
[stats, systemStats, notifications]
);
const config = healthConfig[level];
const unreadAlerts = notifications.filter(n => !n.is_read).length;
return (
<Card className="bg-card px-4 py-3">
<div className="flex items-center justify-between gap-4 flex-wrap">
{/* Health badge */}
<div className="flex items-center gap-3">
<div className="relative">
<CursorProvider>
<CursorContainer className="flex items-center gap-2">
<div className={`h-2.5 w-2.5 rounded-full ${config.dotClass}`} />
<span className={`font-mono text-sm font-medium ${config.textClass}`}>
{config.label}
</span>
</CursorContainer>
<Cursor>
<div className={`h-2 w-2 rounded-full ${level === 'healthy' ? 'bg-brand' : level === 'degraded' ? 'bg-warning' : 'bg-destructive'}`} />
</Cursor>
<CursorFollow
side="bottom"
sideOffset={4}
align="center"
transition={{ stiffness: 400, damping: 40, bounce: 0 }}
>
<div className="rounded-md border border-card-border bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] px-2.5 py-1.5 shadow-md">
<div className="flex flex-col gap-0.5 font-mono text-xs tabular-nums">
{reasons.map((reason) => (
<span key={reason} className="text-stat-value whitespace-nowrap">{reason}</span>
))}
</div>
</div>
</CursorFollow>
</CursorProvider>
</div>
<div className="h-4 w-px bg-border" />
<span className="font-mono text-sm text-stat-subtitle">{activeNodeName}</span>
</div>
{/* Right side: counts + last updated */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5 text-sm text-stat-subtitle">
<Activity className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
<span className="font-mono tabular-nums">{stats.active}</span>
<span>running</span>
</div>
{unreadAlerts > 0 && (
<div className="flex items-center gap-1.5 text-sm text-warning">
<Bell className="h-3.5 w-3.5" strokeWidth={1.5} />
<span className="font-mono tabular-nums">{unreadAlerts}</span>
<span>alert{unreadAlerts !== 1 ? 's' : ''}</span>
</div>
)}
<div className="h-4 w-px bg-border" />
<span className="text-xs text-stat-icon font-mono">
{formatRelativeTime(lastUpdated)}
</span>
</div>
</div>
</Card>
);
}
@@ -0,0 +1,105 @@
import { useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';
import { Activity } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import type { MetricPoint, SystemStats } from './types';
interface HistoricalChartsProps {
metrics: MetricPoint[];
systemStats: SystemStats | null;
}
const chartConfig = {
cpu: { label: 'CPU Usage (%)', color: 'var(--chart-1)' },
ram: { label: 'RAM Usage (GB)', color: 'var(--chart-2)' },
};
export function HistoricalCharts({ metrics, systemStats }: HistoricalChartsProps) {
const chartData = useMemo(() => {
const buckets: Record<string, { time: string; timestamp: number; cpu: number; ram: number }> = {};
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 hasData = chartData.length > 0;
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="bg-card">
<CardHeader className="pb-2">
<CardTitle className="flex items-center space-x-2 text-sm font-medium text-stat-title">
<Activity className="w-4 h-4 text-stat-icon" strokeWidth={1.5} />
<span>CPU Usage</span>
</CardTitle>
<CardDescription className="text-xs">Normalized total CPU percentage over host cores (24h).</CardDescription>
</CardHeader>
<CardContent className="h-[250px]">
{hasData ? (
<ChartContainer config={chartConfig} className="w-full h-full">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--chart-grid)" />
<XAxis dataKey="time" minTickGap={30} tickMargin={8} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
<YAxis tickFormatter={(val) => `${Number(val).toFixed(0)}%`} domain={[0, 100]} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area type="monotone" dataKey="cpu" stroke="var(--color-cpu)" fill="var(--color-cpu)" fillOpacity={0.4} />
</AreaChart>
</ChartContainer>
) : (
<div className="flex flex-col items-center justify-center h-full gap-3">
<Skeleton className="w-full h-[180px] rounded-md" />
<span className="text-xs text-stat-icon">Waiting for CPU metrics...</span>
</div>
)}
</CardContent>
</Card>
<Card className="bg-card">
<CardHeader className="pb-2">
<CardTitle className="flex items-center space-x-2 text-sm font-medium text-stat-title">
<Activity className="w-4 h-4 text-stat-icon" strokeWidth={1.5} />
<span>RAM Usage</span>
</CardTitle>
<CardDescription className="text-xs">Total RAM allocation in GB (24h).</CardDescription>
</CardHeader>
<CardContent className="h-[250px]">
{hasData ? (
<ChartContainer config={chartConfig} className="w-full h-full">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--chart-grid)" />
<XAxis dataKey="time" minTickGap={30} tickMargin={8} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
<YAxis tickFormatter={(val) => `${Number(val).toFixed(1)} GB`} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area type="monotone" dataKey="ram" stroke="var(--color-ram)" fill="var(--color-ram)" fillOpacity={0.4} />
</AreaChart>
</ChartContainer>
) : (
<div className="flex flex-col items-center justify-center h-full gap-3">
<Skeleton className="w-full h-[180px] rounded-md" />
<span className="text-xs text-stat-icon">Waiting for RAM metrics...</span>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,133 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Info, AlertTriangle, AlertOctagon, CheckCircle2, Trash2, ChevronLeft, ChevronRight } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import type { NotificationItem } from './types';
interface RecentAlertsProps {
notifications: NotificationItem[];
onCleared?: () => void | Promise<void>;
}
const PAGE_SIZE = 8;
const levelConfig: Record<string, { icon: typeof Info; className: string }> = {
info: { icon: Info, className: 'text-info' },
warning: { icon: AlertTriangle, className: 'text-warning' },
error: { icon: AlertOctagon, className: 'text-destructive' },
};
function formatRelativeTime(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
export function RecentAlerts({ notifications, onCleared }: RecentAlertsProps) {
const [clearing, setClearing] = useState(false);
const [page, setPage] = useState(0);
const sorted = notifications
.slice()
.sort((a, b) => b.timestamp - a.timestamp);
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages - 1);
const pagedAlerts = sorted.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
const needsPagination = sorted.length > PAGE_SIZE;
const handleClearAll = async () => {
setClearing(true);
try {
await onCleared?.();
setPage(0);
} catch (error) {
toast.error((error as Error)?.message || 'Something went wrong.');
} finally {
setClearing(false);
}
};
return (
<Card className="bg-card">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium text-stat-title">Recent Alerts</CardTitle>
{needsPagination && (
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
disabled={safePage === 0}
onClick={() => setPage(safePage - 1)}
>
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
{safePage + 1} / {totalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
disabled={safePage >= totalPages - 1}
onClick={() => setPage(safePage + 1)}
>
<ChevronRight className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent className="pt-0">
{sorted.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-6 text-stat-subtitle">
<CheckCircle2 className="h-4 w-4 text-success" strokeWidth={1.5} />
<span className="text-sm">No recent alerts.</span>
</div>
) : (
<>
<div className="space-y-0.5">
{pagedAlerts.map(n => {
const config = levelConfig[n.level] || levelConfig.info;
const Icon = config.icon;
return (
<div
key={n.id}
className="flex items-center gap-2.5 py-1.5 px-1 rounded-sm hover:bg-accent/5"
>
<Icon className={`h-3.5 w-3.5 shrink-0 ${config.className}`} strokeWidth={1.5} />
<span className={`text-xs flex-1 truncate ${n.is_read ? 'text-stat-subtitle' : 'text-stat-value'}`}>
{n.message}
</span>
<span className="text-xs font-mono tabular-nums text-stat-icon shrink-0">
{formatRelativeTime(n.timestamp)}
</span>
</div>
);
})}
</div>
<div className="mt-3 flex justify-end">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
disabled={clearing}
onClick={handleClearAll}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" strokeWidth={1.5} />
{clearing ? 'Clearing...' : 'Clear All Notifications'}
</Button>
</div>
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,168 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Cpu, MemoryStick, HardDrive, Container, Network } from 'lucide-react';
import {
CursorProvider,
Cursor,
CursorContainer,
CursorFollow,
} from '@/components/animate-ui/primitives/animate/cursor';
import type { Stats, SystemStats } from './types';
interface ResourceGaugesProps {
stats: Stats;
systemStats: SystemStats | null;
}
const formatBytes = (bytes: number): string => {
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(1)) + ' ' + sizes[i];
};
const getValueColor = (value: number, warn = 80, crit = 90): string => {
if (value >= crit) return 'text-destructive/80';
if (value >= warn) return 'text-warning/80';
return 'text-stat-value';
};
const getBarColor = (value: number, warn = 80, crit = 90): string => {
if (value >= crit) return 'var(--destructive)';
if (value >= warn) return 'var(--warning)';
return 'var(--brand)';
};
function GaugeBar({ value, warn = 80, crit = 90 }: { value: number; warn?: number; crit?: number }) {
return (
<div className="mt-2 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${Math.min(value, 100)}%`, backgroundColor: getBarColor(value, warn, crit) }}
/>
</div>
);
}
export function ResourceGauges({ stats, systemStats }: ResourceGaugesProps) {
const cpuVal = parseFloat(systemStats?.cpu.usage || '0');
const ramVal = parseFloat(systemStats?.memory.usagePercent || '0');
const diskVal = parseFloat(systemStats?.disk?.usagePercent || '0');
return (
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
{/* CPU */}
<Card className="bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-xs font-medium text-stat-title">CPU</CardTitle>
<Cpu className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
</CardHeader>
<CardContent className="pt-0">
<div className={`text-2xl font-medium font-mono tabular-nums tracking-tight ${systemStats ? getValueColor(cpuVal) : 'text-stat-value'}`}>
{systemStats ? `${systemStats.cpu.usage}%` : '...'}
</div>
<p className="text-xs text-stat-subtitle mt-0.5">
{systemStats ? `${systemStats.cpu.cores} cores` : '\u00A0'}
</p>
{systemStats && <GaugeBar value={cpuVal} />}
</CardContent>
</Card>
{/* RAM */}
<Card className="bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-xs font-medium text-stat-title">Memory</CardTitle>
<MemoryStick className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
</CardHeader>
<CardContent className="pt-0">
<div className={`text-2xl font-medium font-mono tabular-nums tracking-tight ${systemStats ? getValueColor(ramVal) : 'text-stat-value'}`}>
{systemStats ? `${systemStats.memory.usagePercent}%` : '...'}
</div>
<p className="text-xs text-stat-subtitle mt-0.5">
{systemStats ? `${formatBytes(systemStats.memory.used)} / ${formatBytes(systemStats.memory.total)}` : '\u00A0'}
</p>
{systemStats && <GaugeBar value={ramVal} />}
</CardContent>
</Card>
{/* Disk */}
<Card className="bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-xs font-medium text-stat-title">Disk</CardTitle>
<HardDrive className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
</CardHeader>
<CardContent className="pt-0">
<div className={`text-2xl font-medium font-mono tabular-nums tracking-tight ${systemStats?.disk ? getValueColor(diskVal) : 'text-stat-value'}`}>
{systemStats?.disk ? `${systemStats.disk.usagePercent}%` : '...'}
</div>
<p className="text-xs text-stat-subtitle mt-0.5">
{systemStats?.disk ? `${formatBytes(systemStats.disk.used)} / ${formatBytes(systemStats.disk.total)}` : '\u00A0'}
</p>
{systemStats?.disk && <GaugeBar value={diskVal} />}
</CardContent>
</Card>
{/* Containers */}
<Card className="bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-xs font-medium text-stat-title">Containers</CardTitle>
<Container className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
</CardHeader>
<CardContent className="pt-0">
<div className="relative">
<CursorProvider>
<CursorContainer className="inline-flex items-baseline">
<span className="text-2xl font-medium font-mono tabular-nums tracking-tight text-stat-value">{stats.active}</span>
<span className="text-sm text-stat-subtitle ml-1.5">active</span>
</CursorContainer>
<Cursor>
<div className="h-2 w-2 rounded-full bg-brand" />
</Cursor>
<CursorFollow
side="bottom"
sideOffset={4}
align="center"
transition={{ stiffness: 400, damping: 40, bounce: 0 }}
>
<div className="rounded-md border border-card-border bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] px-2.5 py-1.5 shadow-md">
<div className="flex items-center gap-3 font-mono text-xs tabular-nums">
<span className="text-stat-value">{stats.managed}<span className="text-stat-subtitle ml-1 font-sans">managed</span></span>
<span className="text-stat-icon">|</span>
<span className="text-stat-value">{stats.unmanaged}<span className="text-stat-subtitle ml-1 font-sans">external</span></span>
</div>
</div>
</CursorFollow>
</CursorProvider>
</div>
<p className="text-xs text-stat-subtitle mt-0.5">
<span className="font-mono tabular-nums text-destructive/80">{stats.exited}</span> exited
</p>
</CardContent>
</Card>
{/* Network */}
<Card className="bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-xs font-medium text-stat-title">Network</CardTitle>
<Network className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-1 mt-1">
<div className="flex items-center gap-2">
<span className="text-xs text-stat-icon">RX</span>
<span className="text-sm font-mono tabular-nums text-stat-value">
{systemStats?.network ? `${formatBytes(systemStats.network.rxSec)}/s` : '...'}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-stat-icon">TX</span>
<span className="text-sm font-mono tabular-nums text-stat-value">
{systemStats?.network ? `${formatBytes(systemStats.network.txSec)}/s` : '...'}
</span>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,176 @@
import { useMemo, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { ChevronRight, ChevronLeft, Layers } from 'lucide-react';
import type { StackStatusEntry, MetricPoint, SystemStats } from './types';
interface StackHealthTableProps {
stackStatuses: Record<string, StackStatusEntry>;
metrics: MetricPoint[];
systemStats: SystemStats | null;
onNavigateToStack: (stackFile: string) => void;
}
const PAGE_SIZE = 8;
const formatMemory = (mb: number): string => {
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
return `${mb.toFixed(0)} MB`;
};
export function StackHealthTable({ stackStatuses, metrics, systemStats, onNavigateToStack }: StackHealthTableProps) {
const cores = systemStats?.cpu.cores || 1;
const [page, setPage] = useState(0);
const stackMetrics = useMemo(() => {
const latestPerContainer: Record<string, Record<string, MetricPoint>> = {};
for (const m of metrics) {
if (!m.stack_name) continue;
const stack = m.stack_name;
if (!latestPerContainer[stack]) latestPerContainer[stack] = {};
const existing = latestPerContainer[stack][m.container_id];
if (!existing || m.timestamp > existing.timestamp) {
latestPerContainer[stack][m.container_id] = m;
}
}
const result: Record<string, { cpu: number; mem: number }> = {};
for (const [stack, containers] of Object.entries(latestPerContainer)) {
let cpu = 0;
let mem = 0;
for (const m of Object.values(containers)) {
cpu += m.cpu_percent;
mem += m.memory_mb;
}
result[stack] = { cpu, mem };
}
return result;
}, [metrics]);
const rows = useMemo(() => {
return Object.entries(stackStatuses)
.map(([file, entry]) => {
const name = file.replace(/\.(yml|yaml)$/, '');
const m = stackMetrics[name];
return {
file,
name,
status: entry.status,
cpu: m?.cpu ?? null,
memory: m?.mem ?? null,
};
})
.sort((a, b) => {
const statusOrder = { running: 0, exited: 1, unknown: 2 };
const diff = statusOrder[a.status] - statusOrder[b.status];
if (diff !== 0) return diff;
return a.name.localeCompare(b.name);
});
}, [stackStatuses, stackMetrics]);
const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
// Clamp page to valid range (handles node switch reducing the stack count)
const safePage = Math.min(page, totalPages - 1);
const pagedRows = rows.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
const needsPagination = rows.length > PAGE_SIZE;
const statusDisplay: Record<string, { label: string; className: string }> = {
running: { label: 'UP', className: 'text-success' },
exited: { label: 'DN', className: 'text-destructive' },
unknown: { label: '--', className: 'text-stat-icon' },
};
if (Object.keys(stackStatuses).length === 0) {
return (
<Card className="bg-card">
<CardContent className="py-8">
<div className="flex flex-col items-center justify-center gap-2 text-stat-subtitle">
<Layers className="h-8 w-8 text-stat-icon" strokeWidth={1.5} />
<p className="text-sm">No stacks found. Create one from the sidebar.</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="bg-card">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium text-stat-title">Stack Health</CardTitle>
{needsPagination && (
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
disabled={safePage === 0}
onClick={() => setPage(safePage - 1)}
>
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
{safePage + 1} / {totalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
disabled={safePage >= totalPages - 1}
onClick={() => setPage(safePage + 1)}
>
<ChevronRight className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent className="pt-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent border-b border-border">
<TableHead className="text-xs text-stat-icon font-medium h-8">Stack</TableHead>
<TableHead className="text-xs text-stat-icon font-medium h-8 w-[60px]">Status</TableHead>
<TableHead className="text-xs text-stat-icon font-medium h-8 w-[80px] text-right">CPU</TableHead>
<TableHead className="text-xs text-stat-icon font-medium h-8 w-[90px] text-right">Memory</TableHead>
<TableHead className="h-8 w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{pagedRows.map(row => {
const sd = statusDisplay[row.status] || statusDisplay.unknown;
return (
<TableRow
key={row.file}
className="cursor-pointer border-b border-border/50 hover:bg-accent/5"
onClick={() => onNavigateToStack(row.file)}
>
<TableCell className="py-2.5">
<span className="font-mono text-sm text-stat-value">{row.name}</span>
</TableCell>
<TableCell className="py-2.5">
<span className={`font-mono text-xs font-medium ${sd.className}`}>{sd.label}</span>
</TableCell>
<TableCell className="py-2.5 text-right">
<span className="font-mono text-xs tabular-nums text-stat-subtitle">
{row.cpu !== null ? `${(row.cpu / cores).toFixed(1)}%` : '--'}
</span>
</TableCell>
<TableCell className="py-2.5 text-right">
<span className="font-mono text-xs tabular-nums text-stat-subtitle">
{row.memory !== null ? formatMemory(row.memory) : '--'}
</span>
</TableCell>
<TableCell className="py-2.5">
<ChevronRight className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
@@ -0,0 +1,7 @@
export { HealthStatusBar } from './HealthStatusBar';
export { ResourceGauges } from './ResourceGauges';
export { StackHealthTable } from './StackHealthTable';
export { HistoricalCharts } from './HistoricalCharts';
export { RecentAlerts } from './RecentAlerts';
export { useDashboardData } from './useDashboardData';
export type * from './types';
@@ -0,0 +1,69 @@
export interface Stats {
active: number;
managed: number;
unmanaged: number;
exited: number;
total: number;
}
export 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;
};
}
export interface MetricPoint {
container_id: string;
stack_name: string;
timestamp: number;
cpu_percent: number;
memory_mb: number;
net_rx_mb: number;
net_tx_mb: number;
}
export interface NotificationItem {
id: number;
level: 'info' | 'warning' | 'error';
message: string;
timestamp: number;
is_read: number;
nodeId?: number;
nodeName?: string;
}
export interface StackStatusEntry {
status: 'running' | 'exited' | 'unknown';
mainPort?: number;
}
export type HealthLevel = 'healthy' | 'degraded' | 'critical';
export interface DashboardData {
stats: Stats;
systemStats: SystemStats | null;
metrics: MetricPoint[];
stackStatuses: Record<string, StackStatusEntry>;
lastUpdated: number;
}
@@ -0,0 +1,133 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNodes } from '@/context/NodeContext';
import { apiFetch } from '@/lib/api';
import type { Stats, SystemStats, MetricPoint, StackStatusEntry, DashboardData } from './types';
const DEFAULT_STATS: Stats = { active: 0, managed: 0, unmanaged: 0, exited: 0, total: 0 };
/**
* Start a polling interval that pauses when the tab is hidden.
* Returns a cleanup function that stops the interval.
*/
function visibilityInterval(fn: () => void, ms: number): () => void {
let interval: ReturnType<typeof setInterval> | null = null;
const start = () => {
if (interval) return;
interval = setInterval(fn, ms);
};
const stop = () => {
if (interval) {
clearInterval(interval);
interval = null;
}
};
const onVisChange = () => {
if (document.hidden) {
stop();
} else {
fn(); // Fetch immediately on re-focus
start();
}
};
document.addEventListener('visibilitychange', onVisChange);
start();
return () => {
stop();
document.removeEventListener('visibilitychange', onVisChange);
};
}
export function useDashboardData(): DashboardData {
const { activeNode } = useNodes();
const nodeId = activeNode?.id;
const [stats, setStats] = useState<Stats>(DEFAULT_STATS);
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
const [metrics, setMetrics] = useState<MetricPoint[]>([]);
const [stackStatuses, setStackStatuses] = useState<Record<string, StackStatusEntry>>({});
const [lastUpdated, setLastUpdated] = useState<number>(0);
// Keep a ref to the latest nodeId so async callbacks don't write stale data
// after a node switch has already triggered a new effect cycle.
const nodeIdRef = useRef(nodeId);
useEffect(() => { nodeIdRef.current = nodeId; }, [nodeId]);
const fetchJson = useCallback(async <T>(endpoint: string, options?: { localOnly?: boolean }): Promise<T | null> => {
try {
const res = await apiFetch(endpoint, options);
if (!res.ok) return null;
return await res.json() as T;
} catch {
return null;
}
}, []);
// Container stats: 5s polling, resets on node change
useEffect(() => {
setStats(DEFAULT_STATS); // eslint-disable-line react-hooks/set-state-in-effect
const currentNodeId = nodeId;
const fetchStats = async () => {
if (nodeIdRef.current !== currentNodeId) return; // Stale effect
const data = await fetchJson<Stats>('/stats');
if (data && nodeIdRef.current === currentNodeId) {
setStats(data);
setLastUpdated(Date.now());
}
};
fetchStats();
const cleanup = visibilityInterval(fetchStats, 5000);
return cleanup;
}, [nodeId, fetchJson]);
// System stats: 5s polling, resets on node change
useEffect(() => {
setSystemStats(null); // eslint-disable-line react-hooks/set-state-in-effect
const currentNodeId = nodeId;
const fetchSys = async () => {
if (nodeIdRef.current !== currentNodeId) return;
const data = await fetchJson<SystemStats>('/system/stats');
if (nodeIdRef.current === currentNodeId) {
setSystemStats(data);
if (data) setLastUpdated(Date.now());
}
};
fetchSys();
const cleanup = visibilityInterval(fetchSys, 5000);
return cleanup;
}, [nodeId, fetchJson]);
// Historical metrics: 60s polling, resets on node change
useEffect(() => {
setMetrics([]); // eslint-disable-line react-hooks/set-state-in-effect
const currentNodeId = nodeId;
const fetchMetrics = async () => {
if (nodeIdRef.current !== currentNodeId) return;
const data = await fetchJson<MetricPoint[]>('/metrics/historical');
if (data && nodeIdRef.current === currentNodeId) setMetrics(data);
};
fetchMetrics();
const cleanup = visibilityInterval(fetchMetrics, 60000);
return cleanup;
}, [nodeId, fetchJson]);
// Stack statuses: 10s polling, resets on node change
useEffect(() => {
setStackStatuses({}); // eslint-disable-line react-hooks/set-state-in-effect
const currentNodeId = nodeId;
const fetchStatuses = async () => {
if (nodeIdRef.current !== currentNodeId) return;
const data = await fetchJson<Record<string, StackStatusEntry>>('/stacks/statuses');
if (data && nodeIdRef.current === currentNodeId) setStackStatuses(data);
};
fetchStatuses();
const cleanup = visibilityInterval(fetchStatuses, 10000);
return cleanup;
}, [nodeId, fetchJson]);
return { stats, systemStats, metrics, stackStatuses, lastUpdated };
}