Initial commit baby!
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { metricsApi, devicesApi } from '../../services/api';
|
||||
import type { Interface, TrafficPoint, ResourcePoint } from '../../types';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
|
||||
interface Props {
|
||||
deviceId: number;
|
||||
}
|
||||
|
||||
function formatBps(val: number): string {
|
||||
if (val >= 1e9) return `${(val / 1e9).toFixed(2)} GB/s`;
|
||||
if (val >= 1e6) return `${(val / 1e6).toFixed(2)} MB/s`;
|
||||
if (val >= 1e3) return `${(val / 1e3).toFixed(1)} KB/s`;
|
||||
return `${Math.round(val)} B/s`;
|
||||
}
|
||||
|
||||
function formatTime(iso: string, compact: boolean): string {
|
||||
const d = new Date(iso);
|
||||
if (compact) {
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return d.toLocaleString([], {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
const TRAFFIC_RANGES = ['1h', '3h', '6h', '12h', '24h'] as const;
|
||||
const RESOURCE_RANGES = ['6h', '12h', '24h', '7d'] as const;
|
||||
|
||||
export default function GraphsTab({ deviceId }: Props) {
|
||||
const [trafficIface, setTrafficIface] = useState('');
|
||||
const [trafficRange, setTrafficRange] = useState<string>('1h');
|
||||
const [resourceRange, setResourceRange] = useState<string>('24h');
|
||||
|
||||
const { data: interfaces = [] } = useQuery({
|
||||
queryKey: ['interfaces', deviceId],
|
||||
queryFn: () => devicesApi.getInterfaces(deviceId).then((r) => r.data as Interface[]),
|
||||
});
|
||||
|
||||
const { data: rawTraffic = [] } = useQuery({
|
||||
queryKey: ['traffic', deviceId, trafficIface, trafficRange],
|
||||
queryFn: () =>
|
||||
metricsApi.interfaceTraffic(deviceId, trafficIface, trafficRange).then((r) => r.data),
|
||||
enabled: !!trafficIface,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const { data: rawResources = [] } = useQuery({
|
||||
queryKey: ['resources-history', deviceId, resourceRange],
|
||||
queryFn: () =>
|
||||
metricsApi.deviceResources(deviceId, resourceRange).then((r) => r.data as ResourcePoint[]),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const compact = trafficRange === '1h' || trafficRange === '3h';
|
||||
const resourceCompact = resourceRange === '6h' || resourceRange === '12h' || resourceRange === '24h';
|
||||
|
||||
const trafficData = (rawTraffic as TrafficPoint[]).map((p) => ({
|
||||
time: formatTime(p.time, compact),
|
||||
rx: Math.round(p.rx),
|
||||
tx: Math.round(p.tx),
|
||||
}));
|
||||
|
||||
const resourceData = (rawResources as ResourcePoint[]).map((p) => {
|
||||
const memPct =
|
||||
p.memory_total && p.memory_total > 0
|
||||
? Math.round(((p.memory_used || 0) / p.memory_total) * 100)
|
||||
: 0;
|
||||
return {
|
||||
time: formatTime(p.time, resourceCompact),
|
||||
cpu: Math.round(p.cpu_load || 0),
|
||||
mem: memPct,
|
||||
};
|
||||
});
|
||||
|
||||
const RangeButtons = ({
|
||||
ranges,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
ranges: readonly string[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) => (
|
||||
<div className="flex gap-1">
|
||||
{ranges.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => onChange(r)}
|
||||
className={`px-2 py-1 text-xs rounded font-medium transition-colors ${
|
||||
value === r
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Interface Traffic ── */}
|
||||
<div className="card p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Interface Traffic</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="input py-1.5 text-sm"
|
||||
value={trafficIface}
|
||||
onChange={(e) => setTrafficIface(e.target.value)}
|
||||
>
|
||||
<option value="">Select interface...</option>
|
||||
{(interfaces as Interface[])
|
||||
.filter((i) => !i.disabled)
|
||||
.map((i) => (
|
||||
<option key={i.name} value={i.name}>
|
||||
{i.name}
|
||||
{i.speed ? ` (${i.speed})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<RangeButtons ranges={TRAFFIC_RANGES} value={trafficRange} onChange={setTrafficRange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!trafficIface ? (
|
||||
<div className="h-48 flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
Select an interface to view traffic history
|
||||
</div>
|
||||
) : trafficData.length === 0 ? (
|
||||
<div className="h-48 flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
No traffic data yet for this interface in the selected range
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={trafficData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-gray-200 dark:text-slate-600" opacity={0.5} />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tickFormatter={formatBps} tick={{ fontSize: 10 }} width={72} />
|
||||
<Tooltip formatter={(v) => formatBps(v as number)} contentStyle={{ fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="rx" stroke="#3b82f6" name="RX (download)" dot={false} strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="tx" stroke="#10b981" name="TX (upload)" dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── CPU & Memory ── */}
|
||||
<div className="card p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">CPU & Memory Usage</h3>
|
||||
<RangeButtons ranges={RESOURCE_RANGES} value={resourceRange} onChange={setResourceRange} />
|
||||
</div>
|
||||
|
||||
{resourceData.length === 0 ? (
|
||||
<div className="h-48 flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
No resource history available yet — data accumulates over time
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={resourceData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-gray-200 dark:text-slate-600" opacity={0.5} />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis domain={[0, 100]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 10 }} />
|
||||
<Tooltip formatter={(v) => `${v}%`} contentStyle={{ fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="cpu" stroke="#f59e0b" name="CPU Load" dot={false} strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="mem" stroke="#8b5cf6" name="Memory Used" dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user