// TelemetryChart.jsx — reusable SVG telemetry chart with range selector. (function(){ const RANGE_OPTIONS = ["1h", "6h", "24h", "7d"]; const STALE_BY_RANGE_MS = { "1h": 5 * 60 * 1000, "6h": 15 * 60 * 1000, "24h": 30 * 60 * 1000, "7d": 2 * 60 * 60 * 1000, }; function _safeDate(value) { if (!value) return null; const d = new Date(value); return Number.isNaN(d.getTime()) ? null : d; } function _formatClock(value) { const d = _safeDate(value); if (!d) return "--:--:--"; return d.toLocaleTimeString("ru-RU", { hour12: false }); } function _staleThresholdMs(range) { return STALE_BY_RANGE_MS[range] || STALE_BY_RANGE_MS["1h"]; } function _getStaleState(lastTs, range, fallbackMode) { if (fallbackMode) return "fallback"; const last = _safeDate(lastTs); if (!last) return "fresh"; return (Date.now() - last.getTime()) > _staleThresholdMs(range) ? "stale" : "fresh"; } function _formatAge(lastTs) { const last = _safeDate(lastTs); if (!last) return "n/a"; const diffSec = Math.max(0, Math.floor((Date.now() - last.getTime()) / 1000)); if (diffSec < 60) return `${diffSec}s`; const min = Math.floor(diffSec / 60); if (min < 60) return `${min}m`; const hr = Math.floor(min / 60); return `${hr}h`; } function _formatAxis(value, range) { const d = _safeDate(value); if (!d) return ""; if (range === "7d") { return d.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }); } return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", hour12: false }); } function _toNumber(value) { const n = Number(value); return Number.isFinite(n) ? n : null; } function _buildPolyline(points, width, height, padding) { if (!Array.isArray(points) || points.length === 0) return ""; const values = points .map((p) => _toNumber(p.value)) .filter((n) => n !== null); if (!values.length) return ""; let min = Math.min.apply(null, values); let max = Math.max.apply(null, values); if (Math.abs(max - min) < 1e-6) { const spread = Math.max(Math.abs(max) * 0.05, 1); min -= spread; max += spread; } const innerW = width - padding * 2; const innerH = height - padding * 2; return points .map((p, i) => { const raw = _toNumber(p.value); const value = raw === null ? min : raw; const x = points.length === 1 ? (padding + innerW / 2) : (padding + (innerW * i) / (points.length - 1)); const y = padding + ((max - value) / (max - min)) * innerH; return `${x.toFixed(2)},${y.toFixed(2)}`; }) .join(" "); } function _getAxisLabels(points, range) { if (!Array.isArray(points) || points.length === 0) { return ["", "", ""]; } const first = points[0]?.ts; const mid = points[Math.floor((points.length - 1) / 2)]?.ts; const last = points[points.length - 1]?.ts; return [_formatAxis(first, range), _formatAxis(mid, range), _formatAxis(last, range)]; } function _lastPointTs(points) { if (!Array.isArray(points) || points.length === 0) return null; return points[points.length - 1]?.ts || null; } function _pointValue(points) { if (!Array.isArray(points) || points.length === 0) return null; return _toNumber(points[points.length - 1]?.value); } function _formatValue(value) { if (value === null || value === undefined) return "—"; if (Math.abs(value) >= 1000) return value.toLocaleString("ru-RU"); return Number.isInteger(value) ? String(value) : value.toFixed(1).replace(".", ","); } // [TASK_START:T005] function useDeviceTelemetry({ deviceId, metricKey, defaultRange = "1h", autoRefreshMs = 60000 }) { const [range, setRange] = React.useState(defaultRange); const [state, setState] = React.useState({ loading: true, error: false, points: [], unit: "", updatedAt: null, dataTimestamp: null, fallbackMode: false, }); const load = React.useCallback(async (opts = {}) => { const silent = Boolean(opts.silent); if (!deviceId || !metricKey) { setState({ loading: false, error: false, points: [], unit: "", updatedAt: null, dataTimestamp: null, fallbackMode: false, }); return; } if (!silent) { setState((prev) => Object.assign({}, prev, { loading: true, error: false, fallbackMode: false })); } try { const payload = await Keeper.api.deviceTelemetrySeries(deviceId, metricKey, range); const points = payload.points || []; const dataTimestamp = _lastPointTs(points) || payload.generated_at || null; const refreshedAt = new Date().toISOString(); setState({ loading: false, error: false, points, unit: payload.unit || "", updatedAt: refreshedAt, dataTimestamp, fallbackMode: false, }); } catch (err) { const nowTs = new Date().toISOString(); // No telemetry for this period/device is an empty state, not an error card. if (err && err.status === 404) { setState({ loading: false, error: false, points: [], unit: "", updatedAt: nowTs, dataTimestamp: null, fallbackMode: false, }); return; } setState((prev) => { const hasFallback = Array.isArray(prev.points) && prev.points.length > 0; return Object.assign({}, prev, { loading: false, error: !hasFallback, updatedAt: nowTs, fallbackMode: hasFallback, }); }); } }, [deviceId, metricKey, range]); React.useEffect(() => { load(); }, [load]); React.useEffect(() => { if (!deviceId || !metricKey) return undefined; const timer = setInterval(() => { load({ silent: true }); }, autoRefreshMs); return () => clearInterval(timer); }, [deviceId, metricKey, range, autoRefreshMs, load]); return { range, setRange, loading: state.loading, error: state.error, points: state.points, unit: state.unit, updatedAt: state.updatedAt, dataTimestamp: state.dataTimestamp, fallbackMode: state.fallbackMode, retry: () => load(), }; } // [TASK_COMPLETE:T005] function useHiveHistoryTelemetry({ hiveId, defaultRange = "24h", autoRefreshMs = 60000 }) { const [range, setRange] = React.useState(defaultRange); const [state, setState] = React.useState({ loading: true, error: false, points: [], unit: "", updatedAt: null, dataTimestamp: null, fallbackMode: false, }); const load = React.useCallback(async (opts = {}) => { const silent = Boolean(opts.silent); if (!hiveId) { setState({ loading: false, error: false, points: [], unit: "", updatedAt: null, dataTimestamp: null, fallbackMode: false, }); return; } if (!silent) { setState((prev) => Object.assign({}, prev, { loading: true, error: false, fallbackMode: false })); } try { const payload = await Keeper.api.hiveActivitySeries(hiveId, range); const points = payload.points || []; const dataTimestamp = _lastPointTs(points) || payload.generated_at || null; const refreshedAt = new Date().toISOString(); setState({ loading: false, error: false, points, unit: payload.unit || "", updatedAt: refreshedAt, dataTimestamp, fallbackMode: false, }); } catch (_) { const nowTs = new Date().toISOString(); setState((prev) => { const hasFallback = Array.isArray(prev.points) && prev.points.length > 0; return Object.assign({}, prev, { loading: false, error: !hasFallback, updatedAt: nowTs, fallbackMode: hasFallback, }); }); } }, [hiveId, range]); React.useEffect(() => { load(); }, [load]); React.useEffect(() => { if (!hiveId || range !== "1h") return undefined; const timer = setInterval(() => { load({ silent: true }); }, autoRefreshMs); return () => clearInterval(timer); }, [hiveId, range, autoRefreshMs, load]); return { range, setRange, loading: state.loading, error: state.error, points: state.points, unit: state.unit, updatedAt: state.updatedAt, dataTimestamp: state.dataTimestamp, fallbackMode: state.fallbackMode, retry: () => load(), }; } // [TASK_START:T004] function TelemetryChart({ title, source = "device", deviceId, hiveId, metricKey, unitLabel = "", accent = "#007BFB", defaultRange = "1h", ranges = RANGE_OPTIONS, showStaleBadge = true, }) { const telemetry = source === "hive" ? useHiveHistoryTelemetry({ hiveId, defaultRange }) : useDeviceTelemetry({ deviceId, metricKey, defaultRange }); const points = telemetry.points || []; const mergedUnit = unitLabel || telemetry.unit || ""; const lastTs = _lastPointTs(points) || telemetry.dataTimestamp || null; const staleState = _getStaleState(lastTs, telemetry.range, telemetry.fallbackMode); const value = _pointValue(points); const width = 340; const height = 140; const padding = 14; const polyline = _buildPolyline(points, width, height, padding); const [x0, x1, x2] = _getAxisLabels(points, telemetry.range); return (
{title}
{mergedUnit || "ед."}
{ranges.map((r) => ( ))}
{_formatValue(value)}
{mergedUnit}
{showStaleBadge && staleState === "stale" && Stale ({_formatAge(lastTs)})} {showStaleBadge && staleState === "fallback" && Fallback ({_formatAge(lastTs)})} Data: {_formatClock(lastTs)} · Refreshed: {_formatClock(telemetry.updatedAt)}
{telemetry.error && (
Could not load telemetry
)} {!telemetry.error && telemetry.loading && points.length === 0 && (
Loading telemetry…
)} {!telemetry.error && !telemetry.loading && points.length === 0 && (
No data for this period
)} {!telemetry.error && points.length > 0 && ( <>
{x0} {x1} {x2}
)}
); } // [TASK_COMPLETE:T004] window.TelemetryChart = TelemetryChart; window.useDeviceTelemetry = useDeviceTelemetry; })();