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:
76
src/app/api/metrics/route.ts
Normal file
76
src/app/api/metrics/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
190
src/components/SystemTrends.tsx
Normal file
190
src/components/SystemTrends.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -9,3 +9,4 @@ export { DeploymentsTable } from './DeploymentsTable';
|
||||
export { DeploymentLogs } from './DeploymentLogs';
|
||||
export { VitalsBar } from './VitalsBar';
|
||||
export { OverviewTab } from './OverviewTab';
|
||||
export { SystemTrends } from './SystemTrends';
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user