feat(CC-087): add Telemetry page (live metric cards, mini chart, 60s refetch, API snippet)

This commit is contained in:
admin-valentin 2026-08-02 17:41:45 +00:00
parent 4b057f44f9
commit 1e995ae12a

View file

@ -0,0 +1,170 @@
'use client';
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { apiFetch } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface Observation { id: string; metric: string; value: string; unit: string | null; subjectType: string; source: string | null; confidence: number | null; createdAt: string; observedAt: string | null; }
interface DataSource { id: string; name: string; type: string; status: string; config: Record<string, unknown> | null; lastSyncAt: string | null; }
const TELEMETRY_METRICS = ['temperature', 'temperatura', 'humidity', 'umiditate', 'battery', 'baterie', 'cpu', 'ram', 'memory', 'power', 'voltage', 'current', 'pressure', 'presiune', 'light', 'motion', 'pm2.5', 'co2', 'air', 'ping', 'latency'];
const DEVICE_TYPES = ['device', 'iot', 'sensor', 'laptop', 'phone', 'tablet', 'server', 'raspberry', 'arduino'];
const HOURS_12 = Array.from({ length: 12 }, (_, i) => {
const d = new Date();
d.setHours(d.getHours() - (11 - i));
return d.toISOString().slice(0, 13);
});
export default function TelemetryPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const [selectedMetric, setSelectedMetric] = useState('');
const { data: observations = [], isLoading } = useQuery({
queryKey: ['telemetry-obs', tenantId],
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
enabled: Boolean(tenantId), staleTime: 30_000, refetchInterval: 60_000,
});
const { data: sources = [] } = useQuery({
queryKey: ['telemetry-devices', tenantId],
queryFn: () => apiFetch<DataSource[]>('/v1/data-sources', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const devices = sources.filter((s) => DEVICE_TYPES.includes(s.type.toLowerCase()));
const telemetryObs = useMemo(() =>
observations
.filter((o) => o.subjectType === 'device' || TELEMETRY_METRICS.some((k) => o.metric.toLowerCase().includes(k)))
.sort((a, b) => new Date(b.observedAt ?? b.createdAt).getTime() - new Date(a.observedAt ?? a.createdAt).getTime()),
[observations]);
const metrics = useMemo(() => [...new Set(telemetryObs.map((o) => o.metric))].sort(), [telemetryObs]);
const filtered = selectedMetric ? telemetryObs.filter((o) => o.metric === selectedMetric) : telemetryObs;
const latestByMetric = useMemo(() => {
const map: Record<string, Observation> = {};
for (const o of telemetryObs) {
if (!map[o.metric]) map[o.metric] = o;
}
return Object.values(map);
}, [telemetryObs]);
function parseNum(v: string): number | null {
const n = parseFloat(v.replace(',', '.'));
return isNaN(n) ? null : n;
}
const chartData = useMemo(() => {
const metric = selectedMetric || metrics[0] || '';
const metricObs = telemetryObs.filter((o) => o.metric === metric && parseNum(o.value) !== null)
.slice(0, 50).reverse();
const values = metricObs.map((o) => parseNum(o.value) as number);
const maxVal = Math.max(...values, 1);
return { metricObs, values, maxVal, metric };
}, [telemetryObs, selectedMetric, metrics]);
return (
<div className="max-w-4xl space-y-6 p-6">
<div>
<nav className="text-xs text-ink-faint mb-1">
<Link href="/dashboard/devices" className="hover:underline">Dispozitive</Link> / Telemetrie
</nav>
<h1 className="font-display text-2xl font-semibold text-ink">Telemetrie Dispozitive</h1>
<p className="text-sm text-ink-faint mt-1">
{isLoading ? 'Se încarcă…' : `${telemetryObs.length} înregistrări telemetrie · ${devices.length} dispozitive`}
</p>
</div>
<div className="card p-4 bg-primary/5 border-primary/20 space-y-2">
<p className="text-xs font-semibold text-ink">Cum trimite un dispozitiv telemetrie</p>
<div className="font-mono text-[10px] text-ink-faint space-y-1 bg-muted/50 p-3 rounded-lg overflow-x-auto">
<p className="text-primary"># POST /v1/observations</p>
<p>{'{'} "metric": "temperature", "value": "22.5", "unit": "C",</p>
<p> "subjectType": "device", "source": "sensor-office",</p>
<p> "confidence": 0.95, "observedAt": "2026-08-02T14:00:00Z" {'}'}</p>
<p className="text-primary"># Header: Authorization: Bearer {'<'}api-key{'>'} / X-Tenant-Id: {'<'}id{'>'}</p>
</div>
<Link href="/dashboard/settings/api" className="text-[10px] text-primary hover:underline">Gestionează chei API </Link>
</div>
{/* Live summary cards */}
{latestByMetric.length > 0 && (
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(140px,1fr))' }}>
{latestByMetric.slice(0, 8).map((o) => (
<button key={o.metric} onClick={() => setSelectedMetric(selectedMetric === o.metric ? '' : o.metric)}
className={`card p-3 text-center transition-colors ${selectedMetric === o.metric ? 'border-primary/50 bg-primary/5' : 'hover:border-primary/30'}`}>
<p className="text-xl font-bold text-ink truncate">{o.value}{o.unit ? ` ${o.unit}` : ''}</p>
<p className="text-[10px] text-ink-faint mt-0.5 truncate capitalize">{o.metric}</p>
<p className="text-[9px] text-ink-faint">
{new Date(o.observedAt ?? o.createdAt).toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit' })}
</p>
</button>
))}
</div>
)}
{/* Metric selector */}
{metrics.length > 0 && (
<div className="flex flex-wrap gap-2">
<button onClick={() => setSelectedMetric('')}
className={`rounded-full px-3 py-1 text-xs font-medium border transition-colors ${selectedMetric === '' ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
Toate
</button>
{metrics.slice(0, 10).map((m) => (
<button key={m} onClick={() => setSelectedMetric(selectedMetric === m ? '' : m)}
className={`rounded-full px-3 py-1 text-xs font-medium border capitalize transition-colors ${selectedMetric === m ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
{m}
</button>
))}
</div>
)}
{/* Mini chart */}
{chartData.values.length > 1 && (
<div className="card p-4 space-y-2">
<p className="text-xs font-semibold text-ink capitalize">{chartData.metric} ultimele {chartData.values.length} valori</p>
<div className="flex items-end gap-0.5 h-16">
{chartData.values.map((v, i) => (
<div key={i} className="flex-1 bg-primary/50 rounded-sm"
style={{ height: `${(v / chartData.maxVal) * 60}px`, minHeight: '2px' }}
title={`${v}`} />
))}
</div>
<div className="flex justify-between text-[9px] text-ink-faint">
<span>min: {Math.min(...chartData.values)}</span>
<span>max: {Math.max(...chartData.values)}</span>
</div>
</div>
)}
{isLoading ? (
<div className="text-center text-sm text-ink-faint py-8">Se încarcă</div>
) : filtered.length === 0 ? (
<div className="card p-8 text-center space-y-2">
<p className="text-2xl">📡</p>
<p className="text-sm text-ink-faint">Nicio telemetrie primită. Configurează un dispozitiv trimită date.</p>
<Link href="/dashboard/devices" className="text-sm text-primary hover:underline">Gestionează dispozitive </Link>
</div>
) : (
<div className="card divide-y divide-border/50 max-h-96 overflow-y-auto">
{filtered.slice(0, 100).map((o) => (
<div key={o.id} className="flex items-center gap-4 p-3">
<p className="text-[10px] text-ink-faint shrink-0 font-mono w-32">
{new Date(o.observedAt ?? o.createdAt).toLocaleString('ro-RO', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</p>
<p className="text-xs text-ink-faint capitalize shrink-0 w-28 truncate">{o.metric}</p>
<p className="text-sm font-bold text-ink">{o.value}{o.unit ? ` ${o.unit}` : ''}</p>
{o.source && <p className="text-[10px] text-ink-faint truncate flex-1">{o.source}</p>}
</div>
))}
</div>
)}
</div>
);
}