From 1e995ae12a5143fea42f9361d45feb79eeaf375d Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Sun, 2 Aug 2026 17:41:45 +0000 Subject: [PATCH] feat(CC-087): add Telemetry page (live metric cards, mini chart, 60s refetch, API snippet) --- src/app/dashboard/devices/telemetry/page.tsx | 170 +++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 src/app/dashboard/devices/telemetry/page.tsx diff --git a/src/app/dashboard/devices/telemetry/page.tsx b/src/app/dashboard/devices/telemetry/page.tsx new file mode 100644 index 0000000..d7cbdc2 --- /dev/null +++ b/src/app/dashboard/devices/telemetry/page.tsx @@ -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 | 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('/v1/observations', { tenantId }), + enabled: Boolean(tenantId), staleTime: 30_000, refetchInterval: 60_000, + }); + + const { data: sources = [] } = useQuery({ + queryKey: ['telemetry-devices', tenantId], + queryFn: () => apiFetch('/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 = {}; + 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 ( +
+
+ +

Telemetrie Dispozitive

+

+ {isLoading ? 'Se încarcă…' : `${telemetryObs.length} înregistrări telemetrie · ${devices.length} dispozitive`} +

+
+ +
+

Cum trimite un dispozitiv telemetrie

+
+

# POST /v1/observations

+

{'{'} "metric": "temperature", "value": "22.5", "unit": "C",

+

"subjectType": "device", "source": "sensor-office",

+

"confidence": 0.95, "observedAt": "2026-08-02T14:00:00Z" {'}'}

+

# Header: Authorization: Bearer {'<'}api-key{'>'} / X-Tenant-Id: {'<'}id{'>'}

+
+ Gestionează chei API → +
+ + {/* Live summary cards */} + {latestByMetric.length > 0 && ( +
+ {latestByMetric.slice(0, 8).map((o) => ( + + ))} +
+ )} + + {/* Metric selector */} + {metrics.length > 0 && ( +
+ + {metrics.slice(0, 10).map((m) => ( + + ))} +
+ )} + + {/* Mini chart */} + {chartData.values.length > 1 && ( +
+

{chartData.metric} — ultimele {chartData.values.length} valori

+
+ {chartData.values.map((v, i) => ( +
+ ))} +
+
+ min: {Math.min(...chartData.values)} + max: {Math.max(...chartData.values)} +
+
+ )} + + {isLoading ? ( +
Se încarcă…
+ ) : filtered.length === 0 ? ( +
+

📡

+

Nicio telemetrie primită. Configurează un dispozitiv să trimită date.

+ Gestionează dispozitive → +
+ ) : ( +
+ {filtered.slice(0, 100).map((o) => ( +
+

+ {new Date(o.observedAt ?? o.createdAt).toLocaleString('ro-RO', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} +

+

{o.metric}

+

{o.value}{o.unit ? ` ${o.unit}` : ''}

+ {o.source &&

{o.source}

} +
+ ))} +
+ )} +
+ ); +}