mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 01:37:05 +00:00
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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user