Add System Trends sparkline charts using Recharts + Prometheus

- Add recharts dependency for area chart visualizations
- Add /api/metrics route querying Prometheus query_range (CPU, RAM, Network)
- Add SystemTrends component with 3 sparkline area charts (6h window, 2min steps)
- CPU (emerald), RAM (amber), Network I/O (indigo) with gradient fills
- Tooltips show exact values on hover, time axis with formatted labels
- Link to Grafana for deep-dive analysis
- Fetches every 60s, shimmer loading state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-03 22:40:23 +01:00
parent 8583ae52c6
commit 43936fc601
7 changed files with 692 additions and 5 deletions

View File

@@ -0,0 +1,76 @@
import { NextResponse } from 'next/server';
const PROMETHEUS_URL = 'http://192.168.1.3:9091';
const INSTANCE = '192.168.1.3:9100';
const NIC = 'eno1';
interface PrometheusResult {
data: {
result: Array<{
values: Array<[number, string]>;
}>;
};
}
async function queryRange(query: string, start: number, end: number, step: number): Promise<Array<[number, number]>> {
const params = new URLSearchParams({
query,
start: String(start),
end: String(end),
step: String(step),
});
const res = await fetch(`${PROMETHEUS_URL}/api/v1/query_range?${params}`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return [];
const data: PrometheusResult = await res.json();
const result = data.data?.result?.[0];
if (!result) return [];
return result.values.map(([ts, val]) => [ts, parseFloat(val)]);
}
export async function GET() {
try {
const end = Math.floor(Date.now() / 1000);
const start = end - 6 * 3600; // 6 hours
const step = 120; // 2-minute resolution
const [cpu, ram, netRx, netTx] = await Promise.all([
// CPU usage percent
queryRange(
`100 - (avg(rate(node_cpu_seconds_total{mode="idle",instance="${INSTANCE}"}[2m])) * 100)`,
start, end, step
),
// RAM usage percent
queryRange(
`(1 - node_memory_MemAvailable_bytes{instance="${INSTANCE}"} / node_memory_MemTotal_bytes{instance="${INSTANCE}"}) * 100`,
start, end, step
),
// Network receive bytes/sec
queryRange(
`rate(node_network_receive_bytes_total{instance="${INSTANCE}",device="${NIC}"}[2m])`,
start, end, step
),
// Network transmit bytes/sec
queryRange(
`rate(node_network_transmit_bytes_total{instance="${INSTANCE}",device="${NIC}"}[2m])`,
start, end, step
),
]);
return NextResponse.json(
{ cpu, ram, netRx, netTx },
{ headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' } }
);
} catch (error) {
console.error('Metrics error:', error);
return NextResponse.json(
{ error: 'Failed to fetch metrics', cpu: [], ram: [], netRx: [], netTx: [] },
{ status: 500 }
);
}
}

View File

@@ -2,6 +2,7 @@
import { usePortal } from '@/lib/PortalContext';
import { Icon } from './Icons';
import { SystemTrends } from './SystemTrends';
import { getVitalsBg, getVitalsTrack, formatUptime } from '@/lib/stats';
import { STATUS_COLORS, STATUS_LABELS, formatRelativeTime, formatDuration } from '@/lib/deployments';
import type { DeploymentStatus } from '@/lib/deployments';
@@ -179,6 +180,9 @@ export function OverviewTab() {
</div>
</div>
{/* System Trends (6h from Prometheus) */}
<SystemTrends />
{/* Recent Deployments */}
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm p-5">
<h2 className="text-sm font-semibold text-slate-900 dark:text-stone-100 mb-4 flex items-center gap-2">

View File

@@ -0,0 +1,190 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { AreaChart, Area, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { Icon } from './Icons';
import type { MetricsData, MetricSeries } from '@/lib/stats';
import { formatBytes } from '@/lib/stats';
function useMetrics() {
const [data, setData] = useState<MetricsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const refresh = useCallback(async () => {
try {
const res = await fetch('/api/metrics');
if (res.ok) {
const json = await res.json();
setData(json);
setError(false);
} else {
setError(true);
}
} catch {
setError(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
const interval = setInterval(refresh, 60000); // refresh every 60s
return () => clearInterval(interval);
}, [refresh]);
return { data, loading, error };
}
function formatTime(ts: number): string {
const d = new Date(ts * 1000);
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
}
interface SparkChartProps {
label: string;
series: MetricSeries;
color: string;
fillColor: string;
formatValue: (v: number) => string;
domain?: [number, number];
unit?: string;
}
function SparkChart({ label, series, color, fillColor, formatValue, domain, unit }: SparkChartProps) {
const chartData = series.map(([ts, val]) => ({ ts, value: val }));
const lastVal = series.length > 0 ? series[series.length - 1][1] : 0;
return (
<div>
<div className="flex items-baseline justify-between mb-1">
<span className="text-xs font-medium text-slate-500 dark:text-stone-500">{label}</span>
<span className="text-xs font-semibold tabular-nums" style={{ color }}>
{formatValue(lastVal)}{unit || ''}
</span>
</div>
<div className="h-16">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 2, right: 0, bottom: 0, left: 0 }}>
<defs>
<linearGradient id={`grad-${label}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={fillColor} stopOpacity={0.4} />
<stop offset="100%" stopColor={fillColor} stopOpacity={0.05} />
</linearGradient>
</defs>
<XAxis
dataKey="ts"
type="number"
domain={['dataMin', 'dataMax']}
tickFormatter={formatTime}
tick={{ fontSize: 9, fill: '#78716c' }}
axisLine={false}
tickLine={false}
tickCount={4}
/>
{domain && (
<YAxis hide domain={domain} />
)}
<Tooltip
contentStyle={{
backgroundColor: 'rgba(28, 25, 23, 0.95)',
border: '1px solid rgba(120, 113, 108, 0.3)',
borderRadius: '8px',
fontSize: '11px',
padding: '6px 10px',
}}
labelFormatter={(label) => formatTime(Number(label))}
formatter={(value) => [formatValue(Number(value)) + (unit || ''), label]}
cursor={{ stroke: 'rgba(120, 113, 108, 0.3)' }}
/>
<Area
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={1.5}
fill={`url(#grad-${label})`}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
);
}
function ShimmerChart() {
return (
<div>
<div className="flex items-baseline justify-between mb-1">
<div className="h-3 w-10 rounded bg-slate-200 dark:bg-stone-800 animate-pulse" />
<div className="h-3 w-12 rounded bg-slate-200 dark:bg-stone-800 animate-pulse" />
</div>
<div className="h-16 rounded bg-slate-100 dark:bg-stone-800/50 animate-pulse" />
</div>
);
}
export function SystemTrends() {
const { data, loading, error } = useMetrics();
if (error && !data) return null;
return (
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm p-5 lg:col-span-2">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-slate-900 dark:text-stone-100 flex items-center gap-2">
<Icon name="activity" size={16} className="text-slate-400 dark:text-stone-500" />
System Trends
<span className="text-[10px] font-normal text-slate-400 dark:text-stone-600">6h</span>
</h2>
<a
href="http://192.168.1.3:3333"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-slate-400 dark:text-stone-600 hover:text-slate-600 dark:hover:text-stone-400 transition-colors flex items-center gap-1"
>
Grafana
<Icon name="external-link" size={12} />
</a>
</div>
{loading && !data ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<ShimmerChart />
<ShimmerChart />
<ShimmerChart />
</div>
) : data ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<SparkChart
label="CPU"
series={data.cpu}
color="#10b981"
fillColor="#10b981"
formatValue={(v) => `${v.toFixed(1)}%`}
domain={[0, 100]}
/>
<SparkChart
label="RAM"
series={data.ram}
color="#f59e0b"
fillColor="#f59e0b"
formatValue={(v) => `${v.toFixed(1)}%`}
domain={[0, 100]}
/>
<SparkChart
label="Network"
series={data.netRx.map(([ts, val], i) => {
const tx = data.netTx[i]?.[1] || 0;
return [ts, val + tx] as [number, number];
})}
color="#6366f1"
fillColor="#6366f1"
formatValue={(v) => formatBytes(v)}
/>
</div>
) : null}
</div>
);
}

View File

@@ -9,3 +9,4 @@ export { DeploymentsTable } from './DeploymentsTable';
export { DeploymentLogs } from './DeploymentLogs';
export { VitalsBar } from './VitalsBar';
export { OverviewTab } from './OverviewTab';
export { SystemTrends } from './SystemTrends';

View File

@@ -38,3 +38,19 @@ export function formatUptime(seconds: number): string {
const mins = Math.floor((seconds % 3600) / 60);
return `${hours}h ${mins}m`;
}
// Time-series metrics from Prometheus
export type MetricSeries = Array<[number, number]>; // [timestamp, value]
export interface MetricsData {
cpu: MetricSeries;
ram: MetricSeries;
netRx: MetricSeries;
netTx: MetricSeries;
}
export function formatBytes(bytes: number): string {
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB/s`;
if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(1)} KB/s`;
return `${Math.round(bytes)} B/s`;
}