mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat(dashboard): status masthead, unified gauges, stack health sparklines (#676)
* feat(dashboard): status masthead, unified gauges, stack health sparklines Rework the home dashboard around a single cyan-railed status masthead that carries the health state word, node meta, and reasons inline. The resource block collapses into one strip with a CPU hero sparkline, memory and disk gauge bars, and a network tile whose sparkline is built from per-container byte-counter deltas. Stack health becomes an 8-column mono table with row tinting, an uptime column sourced from the oldest running container's creation time, and a per-stack 10-minute CPU sparkline. Historical charts pick up a cyan gradient and an amber peak marker. A shared Sparkline primitive backs all of the above. * docs(dashboard): refresh screenshot for phase B layout * fix(dashboard): anchor sparkline bucketing to latest metric timestamp The cpuHistory, netHistory, and cpuPeakLabel memos called Date.now() inside useMemo, which violates react-hooks/purity: the rule fires because re-renders can produce different bucket boundaries from the same inputs. Derive a historyEndAt anchor from the newest metric sample in the polled series and thread it through to ResourceGauges.
This commit is contained in:
@@ -4376,7 +4376,7 @@ app.get('/api/stacks/statuses', async (req: Request, res: Response) => {
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const bulkInfo = await dockerController.getBulkStackStatuses(stackNames);
|
||||
// Map back to filenames to match frontend expectations
|
||||
const data: Record<string, { status: 'running' | 'exited' | 'unknown'; mainPort?: number }> = {};
|
||||
const data: Record<string, { status: 'running' | 'exited' | 'unknown'; mainPort?: number; runningSince?: number }> = {};
|
||||
for (const stack of stacks) {
|
||||
const name = stack.replace(/\.(yml|yaml)$/, '');
|
||||
data[stack] = bulkInfo[name] ?? { status: 'unknown' };
|
||||
|
||||
@@ -29,6 +29,8 @@ const IGNORE_PORTS = [1900, 53, 22];
|
||||
export interface BulkStackInfo {
|
||||
status: 'running' | 'exited' | 'unknown';
|
||||
mainPort?: number;
|
||||
/** Unix seconds of the oldest running container (approximates stack uptime). */
|
||||
runningSince?: number;
|
||||
}
|
||||
|
||||
export interface ClassifiedImage {
|
||||
@@ -660,6 +662,18 @@ class DockerController {
|
||||
if (container.State === 'running') {
|
||||
result[stackDir].status = 'running';
|
||||
|
||||
// Track the oldest running container's creation time as a proxy for
|
||||
// stack uptime. Docker's listContainers payload exposes Created (unix
|
||||
// seconds) but not StartedAt; for compose stacks the gap is small
|
||||
// enough to treat as uptime without paying for a per-container inspect.
|
||||
const created = typeof container.Created === 'number' ? container.Created : undefined;
|
||||
if (created !== undefined) {
|
||||
const existing = result[stackDir].runningSince;
|
||||
if (existing === undefined || created < existing) {
|
||||
result[stackDir].runningSince = created;
|
||||
}
|
||||
}
|
||||
|
||||
// Detect main web port (first running container with a matchable port wins)
|
||||
if (result[stackDir].mainPort === undefined && Array.isArray(container.Ports) && container.Ports.length > 0) {
|
||||
const ports = container.Ports as { PrivatePort?: number; PublicPort?: number }[];
|
||||
|
||||
+33
-23
@@ -6,53 +6,63 @@ description: Real-time system stats, stack health, historical metrics, and recen
|
||||
The **Home** tab is the first thing you see after logging in. It provides a live overview of your node's health, resource usage, stack status, and recent alert activity.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/dashboard/dashboard-overview.png" alt="Sencho dashboard showing health status, resource gauges, stack health table, and historical charts" />
|
||||
<img src="/images/dashboard/dashboard-overview.png" alt="Sencho dashboard showing status masthead, unified gauge strip, stack health table, and historical charts" />
|
||||
</Frame>
|
||||
|
||||
## Health status bar
|
||||
## Status masthead
|
||||
|
||||
The top bar provides an at-a-glance health assessment for the active node. Sencho evaluates CPU, RAM, disk usage, exited containers, and unread error alerts to derive one of three states:
|
||||
The masthead at the top of the dashboard is the single place to read the node's current condition. It carries:
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
- A **state word** (Healthy, Degraded, or Critical) set in the editorial display face so the reader sees it first.
|
||||
- A **pulsing dot** that mirrors the state color: green when nominal, amber when degraded, rose when critical.
|
||||
- A **meta line** with the active node, the number of nodes in the fleet, and the time since the last successful sync.
|
||||
- A **reasons line** that names exactly which signals moved the state away from Healthy (for example, "CPU 84% · 2 exited · 3 unread errors"). No hovering required.
|
||||
- Three quick stat tiles on the right: containers running, aggregate CPU, and memory in use. Hover the **Running** tile to see the managed / external / exited breakdown.
|
||||
- An alerts counter pinned to the far right.
|
||||
|
||||
Sencho derives the state from CPU, RAM, disk usage, exited containers, and unread error alerts:
|
||||
|
||||
| State | Meaning |
|
||||
|-------|---------|
|
||||
| **Healthy** | All systems nominal. No resources above warning thresholds, no unread errors. |
|
||||
| **Degraded** | At least one resource is above 80%, there are exited containers, or there are unread error alerts. |
|
||||
| **Critical** | At least one resource is above 90%, or there are exited containers combined with unread errors. |
|
||||
|
||||
The bar also shows the active node name, the number of running containers, and the current alert count.
|
||||
## Unified gauge strip
|
||||
|
||||
## Resource gauges
|
||||
A single rail of four tiles shows the numbers that change minute-to-minute:
|
||||
|
||||
Five compact cards display real-time host and container metrics:
|
||||
|
||||
| Card | What it shows |
|
||||
| Tile | What it shows |
|
||||
|------|---------------|
|
||||
| **CPU** | Current CPU usage percentage, core count, and a color-coded gauge bar |
|
||||
| **Memory** | RAM usage percentage, used/total in GB, and a gauge bar |
|
||||
| **Disk** | Disk usage percentage, used/total for the primary mount, and a gauge bar |
|
||||
| **Containers** | Active container count (hover to see managed vs. external breakdown) and exited count |
|
||||
| **Network** | Current RX (receive) and TX (transmit) throughput in bytes/second |
|
||||
| **CPU (hero)** | Current usage with a 10-minute sparkline, average for the window, and the peak value with its time offset |
|
||||
| **Memory** | RAM usage with a compact bar and the exact used/total split |
|
||||
| **Disk** | Mount usage with a compact bar and the exact used/total split |
|
||||
| **Network** | Total throughput per second with the received/transmitted split and a live rhythm spark |
|
||||
|
||||
Gauge bars turn yellow at 80% usage and red at 90%.
|
||||
The CPU tile's sparkline uses cyan as the data color and marks the peak in amber. Memory and disk bars turn amber at 80% and rose at 90%.
|
||||
|
||||
## Stack health table
|
||||
## Stack health
|
||||
|
||||
A table listing every stack in your `COMPOSE_DIR` with live status and resource usage:
|
||||
A mono table of every stack discovered in your `COMPOSE_DIR`, sorted by load so the stacks demanding attention sit at the top:
|
||||
|
||||
| Column | Description |
|
||||
|--------|-------------|
|
||||
| **State dot** | Green when healthy, amber when any container in the stack is pushing CPU above 80%, rose when one has exited or is above 90% |
|
||||
| **Stack** | Stack name (derived from the directory name) |
|
||||
| **Status** | `UP` (running) or `DN` (exited) |
|
||||
| **Host** | Active node this stack belongs to |
|
||||
| **Up** | How long the oldest running container has been up, in compact units (s/m/h/d) |
|
||||
| **CPU** | Latest aggregate CPU for the stack's containers |
|
||||
| **Memory** | Total memory allocated by the stack's containers |
|
||||
| **CPU · 10m** | Per-stack sparkline of the last 10 minutes, tinted to match the row state |
|
||||
|
||||
Click any row to navigate directly to that stack's editor. Stacks are sorted with running stacks first, then alphabetically. If you have more than 8 stacks, the table paginates automatically. For fleet-wide CPU usage, see the **CPU** resource gauge card at the top of the dashboard and the **CPU Usage** historical chart below the table.
|
||||
Warning rows take on a subtle amber wash; critical rows take on a rose wash. Click any row to jump to that stack's editor. If you have more than 8 stacks, the list paginates automatically.
|
||||
|
||||
## Historical metrics charts
|
||||
## Historical charts
|
||||
|
||||
Two area charts display time-series data sampled at one-minute intervals, retained for up to 24 hours:
|
||||
|
||||
- **CPU Usage** - normalized total CPU percentage across all managed containers over host cores
|
||||
- **RAM Usage** - total memory allocated by managed containers, in GB
|
||||
- **CPU** - normalized total CPU percentage across all managed containers over host cores, stroked in cyan with the peak highlighted in amber.
|
||||
- **Memory** - total memory allocated by managed containers in GB.
|
||||
|
||||
Hover over a data point to see the exact value at that moment.
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 122 KiB |
@@ -15,9 +15,12 @@ interface HomeDashboardProps {
|
||||
onClearNotifications: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
const NOOP = () => {};
|
||||
|
||||
export default function HomeDashboard({ onNavigateToStack, notifications, onClearNotifications }: HomeDashboardProps) {
|
||||
const { activeNode, nodes } = useNodes();
|
||||
const data = useDashboardData();
|
||||
const activeNodeName = activeNode?.name || 'Local';
|
||||
|
||||
return (
|
||||
<div className="flex-1 p-6 space-y-4">
|
||||
@@ -25,18 +28,24 @@ export default function HomeDashboard({ onNavigateToStack, notifications, onClea
|
||||
stats={data.stats}
|
||||
systemStats={data.systemStats}
|
||||
notifications={notifications}
|
||||
activeNodeName={activeNode?.name || 'Local'}
|
||||
activeNodeName={activeNodeName}
|
||||
nodeCount={data.nodeCount}
|
||||
lastSyncAt={data.lastSyncAt}
|
||||
/>
|
||||
|
||||
<ResourceGauges
|
||||
stats={data.stats}
|
||||
systemStats={data.systemStats}
|
||||
cpuHistory={data.cpuHistory}
|
||||
netHistory={data.netHistory}
|
||||
historyEndAt={data.historyEndAt}
|
||||
/>
|
||||
|
||||
<StackHealthTable
|
||||
stackStatuses={data.stackStatuses}
|
||||
metrics={data.metrics}
|
||||
onNavigateToStack={onNavigateToStack || (() => {})}
|
||||
stackCpuSeries={data.stackCpuSeries}
|
||||
activeNodeName={activeNodeName}
|
||||
onNavigateToStack={onNavigateToStack ?? NOOP}
|
||||
/>
|
||||
|
||||
<HistoricalCharts
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Activity, Bell } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Bell } from 'lucide-react';
|
||||
import {
|
||||
CursorProvider,
|
||||
Cursor,
|
||||
@@ -14,6 +13,8 @@ interface HealthStatusBarProps {
|
||||
systemStats: SystemStats | null;
|
||||
notifications: NotificationItem[];
|
||||
activeNodeName: string;
|
||||
nodeCount: number;
|
||||
lastSyncAt: number | null;
|
||||
}
|
||||
|
||||
interface HealthResult {
|
||||
@@ -28,18 +29,11 @@ function deriveHealth(stats: Stats, systemStats: SystemStats | null, notificatio
|
||||
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) reasons.push(`${stats.exited} exited container${stats.exited !== 1 ? 's' : ''}`);
|
||||
if (unreadErrors > 0) reasons.push(`${unreadErrors} unread error${unreadErrors !== 1 ? 's' : ''}`);
|
||||
if (cpu >= 80) reasons.push(`CPU ${cpu.toFixed(0)}%`);
|
||||
if (ram >= 80) reasons.push(`RAM ${ram.toFixed(0)}%`);
|
||||
if (disk >= 80) reasons.push(`Disk ${disk.toFixed(0)}%`);
|
||||
if (stats.exited > 0) reasons.push(`${stats.exited} exited`);
|
||||
if (unreadErrors > 0) reasons.push(`${unreadErrors} unread ${unreadErrors === 1 ? 'error' : 'errors'}`);
|
||||
|
||||
if (cpu >= 90 || ram >= 90 || disk >= 90 || (stats.exited > 0 && unreadErrors > 0)) {
|
||||
return { level: 'critical', reasons };
|
||||
@@ -50,72 +44,184 @@ function deriveHealth(stats: Stats, systemStats: SystemStats | null, notificatio
|
||||
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' },
|
||||
const healthConfig: Record<HealthLevel, { label: string; dotClass: string; textClass: string; railClass: string; tintClass: string }> = {
|
||||
healthy: {
|
||||
label: 'Healthy',
|
||||
dotClass: 'bg-success shadow-[0_0_0_3px_color-mix(in_oklch,var(--success)_20%,transparent)]',
|
||||
textClass: 'text-stat-value',
|
||||
railClass: 'bg-brand',
|
||||
tintClass: 'from-brand/[0.06] via-transparent to-transparent',
|
||||
},
|
||||
degraded: {
|
||||
label: 'Degraded',
|
||||
dotClass: 'bg-warning shadow-[0_0_0_3px_color-mix(in_oklch,var(--warning)_22%,transparent)]',
|
||||
textClass: 'text-warning',
|
||||
railClass: 'bg-warning',
|
||||
tintClass: 'from-warning/[0.06] via-transparent to-transparent',
|
||||
},
|
||||
critical: {
|
||||
label: 'Critical',
|
||||
dotClass: 'bg-destructive shadow-[0_0_0_3px_color-mix(in_oklch,var(--destructive)_24%,transparent)]',
|
||||
textClass: 'text-destructive',
|
||||
railClass: 'bg-destructive',
|
||||
tintClass: 'from-destructive/[0.06] via-transparent to-transparent',
|
||||
},
|
||||
};
|
||||
|
||||
export function HealthStatusBar({ stats, systemStats, notifications, activeNodeName }: HealthStatusBarProps) {
|
||||
function formatGib(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
|
||||
}
|
||||
|
||||
function formatAgo(ms: number): string {
|
||||
const clamped = Math.max(0, ms);
|
||||
if (clamped < 60_000) return `${Math.round(clamped / 1000)}s`;
|
||||
if (clamped < 3_600_000) return `${Math.round(clamped / 60_000)}m`;
|
||||
return `${Math.round(clamped / 3_600_000)}h`;
|
||||
}
|
||||
|
||||
function useTicker(intervalMs: number): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [intervalMs]);
|
||||
return now;
|
||||
}
|
||||
|
||||
export function HealthStatusBar({
|
||||
stats,
|
||||
systemStats,
|
||||
notifications,
|
||||
activeNodeName,
|
||||
nodeCount,
|
||||
lastSyncAt,
|
||||
}: HealthStatusBarProps) {
|
||||
const { level, reasons } = useMemo(
|
||||
() => deriveHealth(stats, systemStats, notifications),
|
||||
[stats, systemStats, notifications]
|
||||
[stats, systemStats, notifications],
|
||||
);
|
||||
const config = healthConfig[level];
|
||||
const now = useTicker(1000);
|
||||
const unreadAlerts = notifications.filter(n => !n.is_read).length;
|
||||
const running = `${stats.active}/${stats.total}`;
|
||||
const cpuLabel = systemStats ? `${parseFloat(systemStats.cpu.usage).toFixed(0)}%` : '--';
|
||||
const memLabel = systemStats ? formatGib(systemStats.memory.used) : '--';
|
||||
const lastSyncLabel = lastSyncAt ? `last sync ${formatAgo(now - lastSyncAt)}` : 'connecting…';
|
||||
const metaLine = `${activeNodeName} · ${nodeCount} ${nodeCount === 1 ? 'node' : 'nodes'} · ${lastSyncLabel}`;
|
||||
const reasonsLine = reasons.join(' · ');
|
||||
|
||||
return (
|
||||
<Card className="bg-card shadow-card-bevel 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
|
||||
className={`relative overflow-hidden rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel transition-colors`}
|
||||
>
|
||||
<div className={`pointer-events-none absolute inset-0 bg-gradient-to-r ${config.tintClass}`} />
|
||||
<div className={`absolute inset-y-0 left-0 w-[3px] ${config.railClass}`} />
|
||||
<div className="relative grid grid-cols-[auto_1fr_auto] items-center gap-6 py-5 pl-7 pr-6">
|
||||
{/* State column */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-2.5 w-2.5 rounded-full ${config.dotClass} ${level === 'healthy' ? '' : 'animate-[pulse_2.4s_ease-in-out_infinite]'}`}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={`font-display italic text-3xl leading-none tracking-tight ${config.textClass}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
{metaLine}
|
||||
</span>
|
||||
{reasonsLine ? (
|
||||
<span className="font-mono text-[11px] text-stat-subtitle/90">
|
||||
{reasonsLine}
|
||||
</span>
|
||||
) : null}
|
||||
</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>
|
||||
)}
|
||||
{/* Stats column */}
|
||||
<div className="hidden items-stretch justify-end gap-0 md:flex">
|
||||
<CursorProvider>
|
||||
<CursorContainer>
|
||||
<StatTile label="RUNNING" value={running} tone="value" />
|
||||
</CursorContainer>
|
||||
<Cursor>
|
||||
<span className="h-2 w-2 rounded-full bg-brand" />
|
||||
</Cursor>
|
||||
<CursorFollow
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
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-3 py-2 shadow-md">
|
||||
<div className="flex items-center gap-3 font-mono text-xs tabular-nums">
|
||||
<span className="text-stat-value">
|
||||
{stats.managed}
|
||||
<span className="ml-1 font-sans text-stat-subtitle">managed</span>
|
||||
</span>
|
||||
<span className="text-stat-icon">·</span>
|
||||
<span className="text-stat-value">
|
||||
{stats.unmanaged}
|
||||
<span className="ml-1 font-sans text-stat-subtitle">external</span>
|
||||
</span>
|
||||
{stats.exited > 0 ? (
|
||||
<>
|
||||
<span className="text-stat-icon">·</span>
|
||||
<span className="text-destructive">
|
||||
{stats.exited}
|
||||
<span className="ml-1 font-sans text-stat-subtitle">exited</span>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CursorFollow>
|
||||
</CursorProvider>
|
||||
<StatTile label="CPU" value={cpuLabel} tone={parseFloat(systemStats?.cpu.usage || '0') >= 80 ? 'warn' : 'value'} divider />
|
||||
<StatTile label="MEM" value={memLabel} tone="value" divider />
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
<Bell
|
||||
className={`h-3.5 w-3.5 ${unreadAlerts > 0 ? 'text-warning' : 'text-stat-icon'}`}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<span
|
||||
className={`font-mono text-sm tabular-nums ${unreadAlerts > 0 ? 'text-warning' : 'text-stat-subtitle'}`}
|
||||
>
|
||||
{unreadAlerts}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
{unreadAlerts === 1 ? 'alert' : 'alerts'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatTile({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
divider,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone: 'value' | 'warn';
|
||||
divider?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 px-5 ${divider ? 'border-l border-border/60' : ''}`}>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={`font-mono tabular-nums text-xl leading-none ${tone === 'warn' ? 'text-warning' : 'text-stat-value'}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Area, AreaChart, CartesianGrid, ReferenceDot, 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';
|
||||
|
||||
@@ -43,25 +42,57 @@ export function HistoricalCharts({ metrics, systemStats }: HistoricalChartsProps
|
||||
|
||||
const hasData = chartData.length > 0;
|
||||
|
||||
const cpuPeak = useMemo(() => {
|
||||
if (chartData.length === 0) return null;
|
||||
let peak = chartData[0];
|
||||
for (const row of chartData) {
|
||||
if (row.cpu > peak.cpu) peak = row;
|
||||
}
|
||||
return peak;
|
||||
}, [chartData]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<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>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="font-display italic text-xl leading-none tracking-tight text-stat-value">CPU</h2>
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
last 24h · normalized over cores
|
||||
</span>
|
||||
</div>
|
||||
</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 }}>
|
||||
<defs>
|
||||
<linearGradient id="dash-cpu-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--chart-1)" stopOpacity={0.35} />
|
||||
<stop offset="100%" stopColor="var(--chart-1)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<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, (dataMax: number) => Math.max(100, Math.ceil(dataMax / 10) * 10)]} 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} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cpu"
|
||||
stroke="var(--chart-1)"
|
||||
strokeWidth={1.25}
|
||||
fill="url(#dash-cpu-fill)"
|
||||
/>
|
||||
{cpuPeak ? (
|
||||
<ReferenceDot
|
||||
x={cpuPeak.time}
|
||||
y={cpuPeak.cpu}
|
||||
r={3}
|
||||
fill="var(--chart-2)"
|
||||
stroke="var(--background)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
) : null}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
@@ -75,21 +106,34 @@ export function HistoricalCharts({ metrics, systemStats }: HistoricalChartsProps
|
||||
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<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>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="font-display italic text-xl leading-none tracking-tight text-stat-value">Memory</h2>
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
last 24h · total allocation
|
||||
</span>
|
||||
</div>
|
||||
</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 }}>
|
||||
<defs>
|
||||
<linearGradient id="dash-ram-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--chart-2)" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="var(--chart-2)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<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} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="ram"
|
||||
stroke="var(--chart-2)"
|
||||
strokeWidth={1.25}
|
||||
fill="url(#dash-ram-fill)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
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';
|
||||
import { useMemo } from 'react';
|
||||
import { Sparkline } from '@/components/ui/sparkline';
|
||||
import type { SystemStats } from './types';
|
||||
|
||||
interface ResourceGaugesProps {
|
||||
stats: Stats;
|
||||
systemStats: SystemStats | null;
|
||||
cpuHistory: number[];
|
||||
netHistory: number[];
|
||||
historyEndAt: number | null;
|
||||
}
|
||||
|
||||
const SPARK_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
if (!bytes || 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];
|
||||
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';
|
||||
const getValueTone = (value: number, warn = 80, crit = 90): string => {
|
||||
if (value >= crit) return 'text-destructive';
|
||||
if (value >= warn) return 'text-warning';
|
||||
return 'text-stat-value';
|
||||
};
|
||||
|
||||
@@ -34,135 +32,131 @@ const getBarColor = (value: number, warn = 80, crit = 90): string => {
|
||||
};
|
||||
|
||||
function GaugeBar({ value, warn = 80, crit = 90 }: { value: number; warn?: number; crit?: number }) {
|
||||
const color = getBarColor(value, warn, crit);
|
||||
return (
|
||||
<div className="mt-2 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div className="mt-3 h-1 rounded-full bg-muted/60 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.min(value, 100)}%`, backgroundColor: getBarColor(value, warn, crit) }}
|
||||
style={{
|
||||
width: `${Math.min(value, 100)}%`,
|
||||
backgroundColor: color,
|
||||
boxShadow: `0 0 8px ${color}, 0 0 2px ${color}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResourceGauges({ stats, systemStats }: ResourceGaugesProps) {
|
||||
export function ResourceGauges({ systemStats, cpuHistory, netHistory, historyEndAt }: 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 shadow-card-bevel">
|
||||
<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>
|
||||
const cpuPeak = cpuHistory.length > 0 ? Math.max(...cpuHistory) : 0;
|
||||
const cpuPeakIndex = cpuHistory.length > 0 ? cpuHistory.indexOf(cpuPeak) : -1;
|
||||
const cpuAvg = cpuHistory.length > 0
|
||||
? cpuHistory.reduce((sum, v) => sum + v, 0) / cpuHistory.length
|
||||
: 0;
|
||||
|
||||
{/* RAM */}
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<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>
|
||||
const cpuPeakLabel = useMemo(() => {
|
||||
if (cpuPeakIndex < 0 || cpuHistory.length === 0 || historyEndAt === null) return null;
|
||||
const bucketMs = SPARK_WINDOW_MS / cpuHistory.length;
|
||||
const ts = historyEndAt - (cpuHistory.length - 1 - cpuPeakIndex) * bucketMs;
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}, [cpuPeakIndex, cpuHistory.length, historyEndAt]);
|
||||
|
||||
const netHasSignal = netHistory.some((v) => v > 0);
|
||||
const netTotalPerSec = (systemStats?.network?.rxSec ?? 0) + (systemStats?.network?.txSec ?? 0);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 overflow-hidden rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel md:grid-cols-[2fr_1fr_1fr_1fr]">
|
||||
{/* CPU hero */}
|
||||
<div className="relative p-5 md:border-r md:border-border/60">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
CPU{systemStats ? ` · ${systemStats.cpu.cores} cores` : ''}
|
||||
</div>
|
||||
<div className={`mt-2 font-mono tabular-nums text-4xl leading-none ${systemStats ? getValueTone(cpuVal) : 'text-stat-value'}`}>
|
||||
{systemStats ? `${cpuVal.toFixed(1)}%` : '--'}
|
||||
</div>
|
||||
<div className="mt-1.5 font-mono text-[11px] text-stat-subtitle">
|
||||
{cpuHistory.length > 0
|
||||
? `avg ${cpuAvg.toFixed(0)}% last 10m · peak ${cpuPeak.toFixed(0)}%${cpuPeakLabel ? ` @ ${cpuPeakLabel}` : ''}`
|
||||
: 'collecting metrics…'}
|
||||
</div>
|
||||
<div className="mt-3 h-14 w-full">
|
||||
<Sparkline
|
||||
points={cpuHistory}
|
||||
stroke="var(--chart-1)"
|
||||
fill="var(--chart-1)"
|
||||
peakColor="var(--chart-2)"
|
||||
peakIndex={cpuPeakIndex >= 0 ? cpuPeakIndex : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Memory */}
|
||||
<div className="p-5 border-t border-border/60 md:border-t-0 md:border-r">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
MEMORY
|
||||
</div>
|
||||
<div className={`mt-2 font-mono tabular-nums text-2xl leading-none ${systemStats ? getValueTone(ramVal) : 'text-stat-value'}`}>
|
||||
{systemStats ? `${ramVal.toFixed(0)}%` : '--'}
|
||||
</div>
|
||||
<div className="mt-1.5 font-mono text-[11px] text-stat-subtitle">
|
||||
{systemStats ? `${formatBytes(systemStats.memory.used)} / ${formatBytes(systemStats.memory.total)}` : '\u00A0'}
|
||||
</div>
|
||||
{systemStats ? <GaugeBar value={ramVal} /> : null}
|
||||
</div>
|
||||
|
||||
{/* Disk */}
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<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 shadow-card-bevel">
|
||||
<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>
|
||||
<div className="p-5 border-t border-border/60 md:border-t-0 md:border-r">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
DISK
|
||||
</div>
|
||||
<div className={`mt-2 font-mono tabular-nums text-2xl leading-none ${systemStats?.disk ? getValueTone(diskVal) : 'text-stat-value'}`}>
|
||||
{systemStats?.disk ? `${diskVal.toFixed(0)}%` : '--'}
|
||||
</div>
|
||||
<div className="mt-1.5 font-mono text-[11px] text-stat-subtitle">
|
||||
{systemStats?.disk ? `${formatBytes(systemStats.disk.used)} / ${formatBytes(systemStats.disk.total)}` : '\u00A0'}
|
||||
</div>
|
||||
{systemStats?.disk ? <GaugeBar value={diskVal} /> : null}
|
||||
</div>
|
||||
|
||||
{/* Network */}
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<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 className="p-5 border-t border-border/60 md:border-t-0">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
NETWORK
|
||||
</div>
|
||||
<div className="mt-2 font-mono tabular-nums text-2xl leading-none text-stat-value">
|
||||
{systemStats?.network ? `${formatBytes(netTotalPerSec)}/s` : '--'}
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-2 font-mono text-[11px] text-stat-subtitle">
|
||||
{systemStats?.network ? (
|
||||
<>
|
||||
<span className="text-stat-icon">↓</span>
|
||||
<span className="tabular-nums text-stat-value">{formatBytes(systemStats.network.rxSec)}/s</span>
|
||||
<span className="text-stat-icon">·</span>
|
||||
<span className="text-stat-icon">↑</span>
|
||||
<span className="tabular-nums text-stat-value">{formatBytes(systemStats.network.txSec)}/s</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{'\u00A0'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 h-5 w-full">
|
||||
{systemStats?.network && netHasSignal ? (
|
||||
<Sparkline
|
||||
points={netHistory}
|
||||
stroke="var(--chart-1)"
|
||||
fill="var(--chart-1)"
|
||||
showPeak={false}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
) : systemStats?.network ? (
|
||||
<span className="block h-full w-full border-b border-dashed border-border/60" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,165 +1,246 @@
|
||||
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 { useEffect, useMemo, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronRight, ChevronLeft, Layers } from 'lucide-react';
|
||||
import type { StackStatusEntry, MetricPoint } from './types';
|
||||
import { Sparkline } from '@/components/ui/sparkline';
|
||||
import { ChevronLeft, ChevronRight, Layers } from 'lucide-react';
|
||||
import type { StackStatusEntry, MetricPoint, StackCpuSeries } from './types';
|
||||
|
||||
interface StackHealthTableProps {
|
||||
stackStatuses: Record<string, StackStatusEntry>;
|
||||
metrics: MetricPoint[];
|
||||
stackCpuSeries: Record<string, StackCpuSeries>;
|
||||
activeNodeName: string;
|
||||
onNavigateToStack: (stackFile: string) => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 8;
|
||||
const WARN = 80;
|
||||
const CRIT = 90;
|
||||
const GRID_TEMPLATE = 'grid-cols-[14px_minmax(0,1fr)_minmax(0,120px)_52px_52px_72px_110px_16px]';
|
||||
|
||||
const formatMemory = (mb: number): string => {
|
||||
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
|
||||
return `${mb.toFixed(0)} MB`;
|
||||
};
|
||||
|
||||
export function StackHealthTable({ stackStatuses, metrics, onNavigateToStack }: StackHealthTableProps) {
|
||||
const [page, setPage] = useState(0);
|
||||
function formatUptime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return '--';
|
||||
const days = Math.floor(seconds / 86400);
|
||||
if (days > 0) return `${days}d`;
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
if (hours > 0) return `${hours}h`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes > 0) return `${minutes}m`;
|
||||
return `${Math.max(1, Math.floor(seconds))}s`;
|
||||
}
|
||||
|
||||
const stackMetrics = useMemo(() => {
|
||||
type RowState = 'healthy' | 'warn' | 'error';
|
||||
|
||||
function classifyRow(status: StackStatusEntry['status'], peakCpu: number): RowState {
|
||||
if (status === 'exited') return 'error';
|
||||
if (peakCpu >= CRIT) return 'error';
|
||||
if (peakCpu >= WARN) return 'warn';
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
const stateDot: Record<RowState, string> = {
|
||||
healthy: 'bg-success',
|
||||
warn: 'bg-warning',
|
||||
error: 'bg-destructive',
|
||||
};
|
||||
|
||||
const rowTint: Record<RowState, string> = {
|
||||
healthy: '',
|
||||
warn: 'bg-warning/[0.04]',
|
||||
error: 'bg-destructive/[0.04]',
|
||||
};
|
||||
|
||||
const sparkStroke: Record<RowState, string> = {
|
||||
healthy: 'var(--chart-1)',
|
||||
warn: 'var(--warning)',
|
||||
error: 'var(--destructive)',
|
||||
};
|
||||
|
||||
export function StackHealthTable({
|
||||
stackStatuses,
|
||||
metrics,
|
||||
stackCpuSeries,
|
||||
activeNodeName,
|
||||
onNavigateToStack,
|
||||
}: StackHealthTableProps) {
|
||||
const [page, setPage] = useState(0);
|
||||
// Live-tick the current second so uptime labels advance without a parent
|
||||
// refetch. Thirty-second cadence keeps the DOM calm while still refreshing
|
||||
// every "Nm" bucket change.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 30000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const stackAggregates = 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 (!latestPerContainer[m.stack_name]) latestPerContainer[m.stack_name] = {};
|
||||
const existing = latestPerContainer[m.stack_name][m.container_id];
|
||||
if (!existing || m.timestamp > existing.timestamp) {
|
||||
latestPerContainer[stack][m.container_id] = m;
|
||||
latestPerContainer[m.stack_name][m.container_id] = m;
|
||||
}
|
||||
}
|
||||
|
||||
const result: Record<string, { mem: number }> = {};
|
||||
const result: Record<string, { mem: number; cpu: number }> = {};
|
||||
for (const [stack, containers] of Object.entries(latestPerContainer)) {
|
||||
let mem = 0;
|
||||
let cpu = 0;
|
||||
for (const m of Object.values(containers)) {
|
||||
mem += m.memory_mb;
|
||||
cpu += m.cpu_percent;
|
||||
}
|
||||
result[stack] = { mem };
|
||||
result[stack] = { mem, cpu };
|
||||
}
|
||||
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,
|
||||
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 list = Object.entries(stackStatuses).map(([file, entry]) => {
|
||||
const name = file.replace(/\.(yml|yaml)$/, '');
|
||||
const agg = stackAggregates[name];
|
||||
const series = stackCpuSeries[name];
|
||||
const peakCpu = series?.peakValue ?? agg?.cpu ?? 0;
|
||||
const state = classifyRow(entry.status, peakCpu);
|
||||
return {
|
||||
file,
|
||||
name,
|
||||
status: entry.status,
|
||||
memory: agg?.mem ?? null,
|
||||
cpu: agg?.cpu ?? null,
|
||||
peakCpu,
|
||||
series: series?.points ?? [],
|
||||
peakIndex: series?.peakIndex ?? -1,
|
||||
state,
|
||||
runningSince: entry.runningSince ?? null,
|
||||
};
|
||||
});
|
||||
const stateOrder: Record<RowState, number> = { error: 0, warn: 1, healthy: 2 };
|
||||
list.sort((a, b) => {
|
||||
const diff = stateOrder[a.state] - stateOrder[b.state];
|
||||
if (diff !== 0) return diff;
|
||||
return b.peakCpu - a.peakCpu;
|
||||
});
|
||||
return list;
|
||||
}, [stackStatuses, stackAggregates, stackCpuSeries]);
|
||||
|
||||
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' },
|
||||
};
|
||||
const stackCount = Object.keys(stackStatuses).length;
|
||||
|
||||
if (Object.keys(stackStatuses).length === 0) {
|
||||
if (stackCount === 0) {
|
||||
return (
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<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>
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel py-10">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<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 className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
|
||||
<div className="flex items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="font-display italic text-xl leading-none tracking-tight text-stat-value">
|
||||
Stack health
|
||||
</h2>
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
{stackCount} {stackCount === 1 ? 'stack' : 'stacks'} · sorted by load
|
||||
</span>
|
||||
</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-[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.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>
|
||||
{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>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={`grid ${GRID_TEMPLATE} items-center gap-4 border-t border-border/60 px-5 py-2 font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle`}>
|
||||
<span />
|
||||
<span>STACK</span>
|
||||
<span>HOST</span>
|
||||
<span className="text-right">UP</span>
|
||||
<span className="text-right">CPU</span>
|
||||
<span className="text-right">MEM</span>
|
||||
<span className="text-right">CPU · 10m</span>
|
||||
<span />
|
||||
</div>
|
||||
<ul className="divide-y divide-border/40">
|
||||
{pagedRows.map((row) => (
|
||||
<li
|
||||
key={row.file}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onNavigateToStack(row.file)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onNavigateToStack(row.file);
|
||||
}
|
||||
}}
|
||||
className={`grid ${GRID_TEMPLATE} cursor-pointer items-center gap-4 px-5 py-3 transition-colors hover:bg-accent/5 ${rowTint[row.state]}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full justify-self-center ${stateDot[row.state]}`} aria-hidden="true" />
|
||||
<span className="truncate font-mono text-[13px] text-stat-value">{row.name}</span>
|
||||
<span className="truncate font-mono text-xs text-stat-subtitle">{activeNodeName}</span>
|
||||
<span className="text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{row.runningSince !== null
|
||||
? formatUptime(Math.max(0, Math.floor(now / 1000 - row.runningSince)))
|
||||
: '--'}
|
||||
</span>
|
||||
<span className="text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{row.cpu !== null ? `${row.cpu.toFixed(0)}%` : '--'}
|
||||
</span>
|
||||
<span className="text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{row.memory !== null ? formatMemory(row.memory) : '--'}
|
||||
</span>
|
||||
<span className="ml-auto block h-5 w-[110px]">
|
||||
{row.series.length > 1 ? (
|
||||
<Sparkline
|
||||
points={row.series}
|
||||
stroke={sparkStroke[row.state]}
|
||||
fill={sparkStroke[row.state]}
|
||||
peakColor="var(--chart-2)"
|
||||
peakIndex={row.peakIndex >= 0 ? row.peakIndex : undefined}
|
||||
showPeak={row.state !== 'healthy'}
|
||||
/>
|
||||
) : (
|
||||
<span className="block h-full w-full border-b border-dashed border-border/60" />
|
||||
)}
|
||||
</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,13 +56,30 @@ export interface NotificationItem {
|
||||
export interface StackStatusEntry {
|
||||
status: 'running' | 'exited' | 'unknown';
|
||||
mainPort?: number;
|
||||
/** Unix seconds of the oldest running container (approximates stack uptime). */
|
||||
runningSince?: number;
|
||||
}
|
||||
|
||||
export type HealthLevel = 'healthy' | 'degraded' | 'critical';
|
||||
|
||||
export interface StackCpuSeries {
|
||||
stackName: string;
|
||||
points: number[];
|
||||
peakValue: number;
|
||||
peakIndex: number;
|
||||
latestValue: number;
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
stats: Stats;
|
||||
systemStats: SystemStats | null;
|
||||
metrics: MetricPoint[];
|
||||
stackStatuses: Record<string, StackStatusEntry>;
|
||||
lastSyncAt: number | null;
|
||||
nodeCount: number;
|
||||
stackCpuSeries: Record<string, StackCpuSeries>;
|
||||
cpuHistory: number[];
|
||||
netHistory: number[];
|
||||
/** Anchor timestamp (ms) for the sparkline 10-minute window — the newest metric sample. */
|
||||
historyEndAt: number | null;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import type { Stats, SystemStats, MetricPoint, StackStatusEntry, DashboardData } from './types';
|
||||
import type {
|
||||
Stats,
|
||||
SystemStats,
|
||||
MetricPoint,
|
||||
StackStatusEntry,
|
||||
DashboardData,
|
||||
StackCpuSeries,
|
||||
} from './types';
|
||||
|
||||
const DEFAULT_STATS: Stats = { active: 0, managed: 0, unmanaged: 0, exited: 0, total: 0 };
|
||||
const SPARK_BUCKETS = 20;
|
||||
const SPARK_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Start a polling interval that pauses when the tab is hidden.
|
||||
@@ -42,14 +51,41 @@ function visibilityInterval(fn: () => void, ms: number): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
function bucketCpu(points: MetricPoint[], windowMs: number, buckets: number): number[] {
|
||||
if (points.length === 0) return Array(buckets).fill(0);
|
||||
const now = Date.now();
|
||||
const start = now - windowMs;
|
||||
const bucketMs = windowMs / buckets;
|
||||
const out = Array<number>(buckets).fill(0);
|
||||
const counts = Array<number>(buckets).fill(0);
|
||||
for (const p of points) {
|
||||
if (p.timestamp < start) continue;
|
||||
const idx = Math.min(buckets - 1, Math.max(0, Math.floor((p.timestamp - start) / bucketMs)));
|
||||
out[idx] += p.cpu_percent;
|
||||
counts[idx] += 1;
|
||||
}
|
||||
for (let i = 0; i < buckets; i += 1) {
|
||||
if (counts[i] > 0) out[i] = out[i] / counts[i];
|
||||
}
|
||||
// Forward-fill empty buckets from the previous non-empty one so the line
|
||||
// reads as a continuous trend rather than a sawtooth of zeros.
|
||||
let last = 0;
|
||||
for (let i = 0; i < buckets; i += 1) {
|
||||
if (counts[i] === 0) out[i] = last;
|
||||
else last = out[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function useDashboardData(): DashboardData {
|
||||
const { activeNode } = useNodes();
|
||||
const { activeNode, nodes } = 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 [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
|
||||
|
||||
// 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.
|
||||
@@ -69,12 +105,14 @@ export function useDashboardData(): DashboardData {
|
||||
// Container stats: 5s polling, resets on node change
|
||||
useEffect(() => {
|
||||
setStats(DEFAULT_STATS); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
setLastSyncAt(null);
|
||||
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);
|
||||
setLastSyncAt(Date.now());
|
||||
}
|
||||
};
|
||||
fetchStats();
|
||||
@@ -126,5 +164,123 @@ export function useDashboardData(): DashboardData {
|
||||
return cleanup;
|
||||
}, [nodeId, fetchJson]);
|
||||
|
||||
return { stats, systemStats, metrics, stackStatuses };
|
||||
const stackCpuSeries = useMemo<Record<string, StackCpuSeries>>(() => {
|
||||
if (metrics.length === 0) return {};
|
||||
const grouped = new Map<string, MetricPoint[]>();
|
||||
for (const point of metrics) {
|
||||
if (!point.stack_name) continue;
|
||||
const bucket = grouped.get(point.stack_name) ?? [];
|
||||
bucket.push(point);
|
||||
grouped.set(point.stack_name, bucket);
|
||||
}
|
||||
const out: Record<string, StackCpuSeries> = {};
|
||||
for (const [stackName, rows] of grouped) {
|
||||
const points = bucketCpu(rows, SPARK_WINDOW_MS, SPARK_BUCKETS);
|
||||
let peakValue = -Infinity;
|
||||
let peakIndex = 0;
|
||||
for (let i = 0; i < points.length; i += 1) {
|
||||
if (points[i] > peakValue) {
|
||||
peakValue = points[i];
|
||||
peakIndex = i;
|
||||
}
|
||||
}
|
||||
out[stackName] = {
|
||||
stackName,
|
||||
points,
|
||||
peakValue: peakValue === -Infinity ? 0 : peakValue,
|
||||
peakIndex,
|
||||
latestValue: points[points.length - 1] ?? 0,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}, [metrics]);
|
||||
|
||||
const cores = systemStats?.cpu.cores || 1;
|
||||
|
||||
// Anchor the 10-minute sparkline window to the newest metric sample so the
|
||||
// bucketing memos stay pure (calling Date.now() inside useMemo would violate
|
||||
// react-hooks/purity and could yield inconsistent bucket boundaries across
|
||||
// re-renders).
|
||||
const historyEndAt = useMemo<number | null>(() => {
|
||||
if (metrics.length === 0) return null;
|
||||
let max = metrics[0].timestamp;
|
||||
for (let i = 1; i < metrics.length; i += 1) {
|
||||
if (metrics[i].timestamp > max) max = metrics[i].timestamp;
|
||||
}
|
||||
return max;
|
||||
}, [metrics]);
|
||||
|
||||
// Aggregate host-level CPU normalized over cores, so the sparkline matches
|
||||
// the gauge percentage rather than summing raw container usage.
|
||||
const cpuHistory = useMemo<number[]>(() => {
|
||||
if (metrics.length === 0 || historyEndAt === null) return Array(SPARK_BUCKETS).fill(0);
|
||||
const start = historyEndAt - SPARK_WINDOW_MS;
|
||||
const bucketMs = SPARK_WINDOW_MS / SPARK_BUCKETS;
|
||||
// Per-bucket sum across all containers, tracking how many distinct
|
||||
// timestamps contributed so we can average per bucket.
|
||||
const bucketSum = Array<number>(SPARK_BUCKETS).fill(0);
|
||||
const bucketTimestamps = Array.from({ length: SPARK_BUCKETS }, () => new Set<number>());
|
||||
for (const p of metrics) {
|
||||
if (p.timestamp < start) continue;
|
||||
const idx = Math.min(SPARK_BUCKETS - 1, Math.max(0, Math.floor((p.timestamp - start) / bucketMs)));
|
||||
bucketSum[idx] += p.cpu_percent / cores;
|
||||
bucketTimestamps[idx].add(p.timestamp);
|
||||
}
|
||||
const out = Array<number>(SPARK_BUCKETS).fill(0);
|
||||
let last = 0;
|
||||
for (let i = 0; i < SPARK_BUCKETS; i += 1) {
|
||||
const tsCount = bucketTimestamps[i].size;
|
||||
if (tsCount > 0) {
|
||||
out[i] = bucketSum[i] / tsCount;
|
||||
last = out[i];
|
||||
} else {
|
||||
out[i] = last;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [metrics, cores, historyEndAt]);
|
||||
|
||||
// Network throughput over time: compute per-container deltas between
|
||||
// consecutive samples, assign each delta to the bucket of the later sample,
|
||||
// and sum across containers. This is robust to container churn because each
|
||||
// delta is paired within a single container's lifeline. Negative deltas
|
||||
// (counter reset after a restart) clamp to zero.
|
||||
const netHistory = useMemo<number[]>(() => {
|
||||
if (metrics.length === 0 || historyEndAt === null) return Array(SPARK_BUCKETS).fill(0);
|
||||
const start = historyEndAt - SPARK_WINDOW_MS;
|
||||
const bucketMs = SPARK_WINDOW_MS / SPARK_BUCKETS;
|
||||
const byContainer = new Map<string, MetricPoint[]>();
|
||||
for (const p of metrics) {
|
||||
const bucket = byContainer.get(p.container_id) ?? [];
|
||||
bucket.push(p);
|
||||
byContainer.set(p.container_id, bucket);
|
||||
}
|
||||
const out = Array<number>(SPARK_BUCKETS).fill(0);
|
||||
for (const samples of byContainer.values()) {
|
||||
samples.sort((a, b) => a.timestamp - b.timestamp);
|
||||
for (let i = 1; i < samples.length; i += 1) {
|
||||
const curr = samples[i];
|
||||
if (curr.timestamp < start) continue;
|
||||
const prev = samples[i - 1];
|
||||
const delta = (curr.net_rx_mb + curr.net_tx_mb) - (prev.net_rx_mb + prev.net_tx_mb);
|
||||
if (delta <= 0) continue;
|
||||
const idx = Math.min(SPARK_BUCKETS - 1, Math.max(0, Math.floor((curr.timestamp - start) / bucketMs)));
|
||||
out[idx] += delta;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [metrics, historyEndAt]);
|
||||
|
||||
return {
|
||||
stats,
|
||||
systemStats,
|
||||
metrics,
|
||||
stackStatuses,
|
||||
lastSyncAt,
|
||||
nodeCount: nodes.length,
|
||||
stackCpuSeries,
|
||||
cpuHistory,
|
||||
netHistory,
|
||||
historyEndAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type SvgSvgAttrs = Omit<React.SVGAttributes<SVGSVGElement>, "points" | "strokeWidth">
|
||||
|
||||
export interface SparklineProps extends SvgSvgAttrs {
|
||||
points: number[]
|
||||
stroke?: string
|
||||
fill?: string
|
||||
peakIndex?: number
|
||||
peakColor?: string
|
||||
showPeak?: boolean
|
||||
strokeWidth?: number
|
||||
min?: number
|
||||
max?: number
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
const VIEW_W = 100
|
||||
const VIEW_H = 28
|
||||
const PEAK_R = 0.9
|
||||
|
||||
export const Sparkline = React.forwardRef<SVGSVGElement, SparklineProps>(
|
||||
(
|
||||
{
|
||||
points,
|
||||
stroke = "var(--chart-1)",
|
||||
fill = "var(--chart-1)",
|
||||
peakIndex,
|
||||
peakColor = "var(--data-peak)",
|
||||
showPeak = true,
|
||||
strokeWidth,
|
||||
min,
|
||||
max,
|
||||
width,
|
||||
height,
|
||||
className,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const id = React.useId()
|
||||
|
||||
if (!points || points.length < 2) {
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
|
||||
preserveAspectRatio="none"
|
||||
className={cn("block h-full w-full", className)}
|
||||
width={width}
|
||||
height={height}
|
||||
{...rest}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const lo = min ?? Math.min(...points)
|
||||
const hi = max ?? Math.max(...points)
|
||||
const range = hi - lo || 1
|
||||
|
||||
const coords = points.map((v, i) => {
|
||||
const x = (i / (points.length - 1)) * VIEW_W
|
||||
const y = VIEW_H - ((v - lo) / range) * VIEW_H
|
||||
return [x, y] as const
|
||||
})
|
||||
|
||||
const line = coords.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`).join(" ")
|
||||
const area = `${line} L${VIEW_W.toFixed(2)},${VIEW_H} L0,${VIEW_H} Z`
|
||||
|
||||
const resolvedPeakIndex =
|
||||
typeof peakIndex === "number"
|
||||
? peakIndex
|
||||
: points.indexOf(Math.max(...points))
|
||||
|
||||
const peak = showPeak && resolvedPeakIndex >= 0 ? coords[resolvedPeakIndex] : null
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
|
||||
preserveAspectRatio="none"
|
||||
className={cn("block h-full w-full overflow-visible", className)}
|
||||
width={width}
|
||||
height={height}
|
||||
{...rest}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={`spark-fill-${id}`} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={fill} stopOpacity={0.35} />
|
||||
<stop offset="100%" stopColor={fill} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill={`url(#spark-fill-${id})`} stroke="none" vectorEffect="non-scaling-stroke" />
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth ?? 1.25}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{peak ? (
|
||||
<circle
|
||||
cx={peak[0]}
|
||||
cy={peak[1]}
|
||||
r={PEAK_R}
|
||||
fill={peakColor}
|
||||
stroke="var(--background)"
|
||||
strokeWidth={0.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
) : null}
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Sparkline.displayName = "Sparkline"
|
||||
Reference in New Issue
Block a user