From 33bfdc1c6dc3bb2dec55ae82bf58c7c19739ade0 Mon Sep 17 00:00:00 2001 From: gadgethd <111318106+gadgethd@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:55:51 +0000 Subject: [PATCH] Expose owner node telemetry and heard neighbors --- backend/src/owner/ownerRepository.ts | 166 +++++++++++++++++- backend/src/owner/ownerService.ts | 88 ++++++++++ frontend/src/pages/OwnerPortalPage.tsx | 23 +++ frontend/src/pages/owner-portal.css | 131 ++++++++++++++ .../src/pages/owner/OwnerHeardNeighbors.tsx | 49 ++++++ .../src/pages/owner/OwnerPortalCharts.tsx | 9 +- .../src/pages/owner/OwnerStatusFields.tsx | 92 ++++++++++ .../src/pages/owner/ownerPortalModel.test.ts | 12 ++ frontend/src/pages/owner/ownerPortalModel.ts | 89 +++++++++- 9 files changed, 641 insertions(+), 18 deletions(-) create mode 100644 frontend/src/pages/owner/OwnerHeardNeighbors.tsx create mode 100644 frontend/src/pages/owner/OwnerStatusFields.tsx diff --git a/backend/src/owner/ownerRepository.ts b/backend/src/owner/ownerRepository.ts index 13a1ce8..c37fa71 100644 --- a/backend/src/owner/ownerRepository.ts +++ b/backend/src/owner/ownerRepository.ts @@ -223,6 +223,7 @@ export function createOwnerRepository(deps: OwnerRepositoryDeps) { linkHealthResult, advertTrendResult, telemetryResult, + heardNeighborsResult, packetsSentResult, packetsReceivedResult, ] = await Promise.all([ @@ -471,18 +472,117 @@ export function createOwnerRepository(deps: OwnerRepositoryDeps) { rx_air_secs: string | null; channel_utilization: number | null; air_util_tx: number | null; + solar_mv: number | null; + board_temp_c: number | null; + wifi_rssi: number | null; + wifi_ssid: string | null; + wifi_uptime_ms: number | null; + ntp_synced: boolean | null; + ntp_sync_age_ms: number | null; + boot_count: number | null; + reset_reason: string | null; + max_loop_ms: number | null; + max_loop_at_ms: number | null; + nodes_heard_24h: number | null; + air_util_rx: number | null; + last_rx_rssi: number | null; + last_rx_snr: number | null; + tx_power_dbm: number | null; + config_version: string | null; + config_crc32: string | null; + fs_free_bytes: number | null; + fs_total_bytes: number | null; + nvs_free_entries: number | null; + channel_id: number | null; + git_commit: string | null; + boot_epoch: number | null; + mqtt_broker_uri: string | null; + mqtt_broker_username: string | null; + mqtt_uptime_ms: number | null; + mqtt_reconnect_attempts_1h: number | null; + mqtt_session_status_publishes: number | null; + mqtt_session_packet_publishes: number | null; + mqtt_last_offline_epoch: number | null; uptime_ms: number | null; rx_publish_calls: number | null; tx_publish_calls: number | null; }>( `SELECT time::text AS time, - battery_mv, + CASE + WHEN jsonb_typeof(stats->'battery_mv') = 'number' THEN (stats->>'battery_mv')::double precision + ELSE battery_mv::double precision + END AS battery_mv, uptime_secs::text AS uptime_secs, tx_air_secs::text AS tx_air_secs, rx_air_secs::text AS rx_air_secs, - channel_utilization, - air_util_tx, + CASE + WHEN jsonb_typeof(stats->'channel_utilization') = 'number' THEN (stats->>'channel_utilization')::double precision + ELSE channel_utilization + END AS channel_utilization, + CASE + WHEN jsonb_typeof(stats->'air_util_tx') = 'number' THEN (stats->>'air_util_tx')::double precision + ELSE air_util_tx + END AS air_util_tx, + CASE WHEN jsonb_typeof(stats->'solar_mv') = 'number' THEN (stats->>'solar_mv')::double precision ELSE NULL END AS solar_mv, + CASE WHEN jsonb_typeof(stats->'board_temp_c') = 'number' THEN (stats->>'board_temp_c')::double precision ELSE NULL END AS board_temp_c, + CASE WHEN jsonb_typeof(stats->'wifi_rssi') = 'number' THEN (stats->>'wifi_rssi')::double precision ELSE NULL END AS wifi_rssi, + CASE WHEN jsonb_typeof(stats->'wifi_ssid') = 'string' THEN stats->>'wifi_ssid' ELSE NULL END AS wifi_ssid, + CASE WHEN jsonb_typeof(stats->'wifi_uptime_ms') = 'number' THEN (stats->>'wifi_uptime_ms')::double precision ELSE NULL END AS wifi_uptime_ms, + CASE WHEN jsonb_typeof(stats->'ntp_synced') = 'boolean' THEN (stats->>'ntp_synced')::boolean ELSE NULL END AS ntp_synced, + CASE WHEN jsonb_typeof(stats->'ntp_sync_age_ms') = 'number' THEN (stats->>'ntp_sync_age_ms')::double precision ELSE NULL END AS ntp_sync_age_ms, + CASE WHEN jsonb_typeof(stats->'boot_count') = 'number' THEN (stats->>'boot_count')::double precision ELSE NULL END AS boot_count, + CASE WHEN jsonb_typeof(stats->'reset_reason') = 'string' THEN stats->>'reset_reason' ELSE NULL END AS reset_reason, + CASE WHEN jsonb_typeof(stats->'max_loop_ms') = 'number' THEN (stats->>'max_loop_ms')::double precision ELSE NULL END AS max_loop_ms, + CASE WHEN jsonb_typeof(stats->'max_loop_at_ms') = 'number' THEN (stats->>'max_loop_at_ms')::double precision ELSE NULL END AS max_loop_at_ms, + CASE WHEN jsonb_typeof(stats->'nodes_heard_24h') = 'number' THEN (stats->>'nodes_heard_24h')::double precision ELSE NULL END AS nodes_heard_24h, + CASE WHEN jsonb_typeof(stats->'air_util_rx') = 'number' THEN (stats->>'air_util_rx')::double precision ELSE NULL END AS air_util_rx, + CASE WHEN jsonb_typeof(stats->'last_rx_rssi') = 'number' THEN (stats->>'last_rx_rssi')::double precision ELSE NULL END AS last_rx_rssi, + CASE WHEN jsonb_typeof(stats->'last_rx_snr') = 'number' THEN (stats->>'last_rx_snr')::double precision ELSE NULL END AS last_rx_snr, + CASE WHEN jsonb_typeof(stats->'tx_power_dbm') = 'number' THEN (stats->>'tx_power_dbm')::double precision ELSE NULL END AS tx_power_dbm, + CASE WHEN jsonb_typeof(stats->'config_version') = 'string' THEN stats->>'config_version' ELSE NULL END AS config_version, + CASE WHEN jsonb_typeof(stats->'config_crc32') IN ('string', 'number') THEN stats->>'config_crc32' ELSE NULL END AS config_crc32, + CASE WHEN jsonb_typeof(stats->'fs_free_bytes') = 'number' THEN (stats->>'fs_free_bytes')::double precision ELSE NULL END AS fs_free_bytes, + CASE WHEN jsonb_typeof(stats->'fs_total_bytes') = 'number' THEN (stats->>'fs_total_bytes')::double precision ELSE NULL END AS fs_total_bytes, + CASE WHEN jsonb_typeof(stats->'nvs_free_entries') = 'number' THEN (stats->>'nvs_free_entries')::double precision ELSE NULL END AS nvs_free_entries, + CASE WHEN jsonb_typeof(stats->'channel_id') = 'number' THEN (stats->>'channel_id')::double precision ELSE NULL END AS channel_id, + CASE WHEN jsonb_typeof(stats->'git_commit') = 'string' THEN stats->>'git_commit' ELSE NULL END AS git_commit, + CASE WHEN jsonb_typeof(stats->'boot_epoch') = 'number' THEN (stats->>'boot_epoch')::double precision ELSE NULL END AS boot_epoch, + CASE + WHEN jsonb_typeof(stats->'mqtt') = 'object' AND jsonb_typeof(stats->'mqtt'->'broker_uri') = 'string' + THEN stats->'mqtt'->>'broker_uri' + ELSE NULL + END AS mqtt_broker_uri, + CASE + WHEN jsonb_typeof(stats->'mqtt') = 'object' AND jsonb_typeof(stats->'mqtt'->'broker_username') = 'string' + THEN stats->'mqtt'->>'broker_username' + ELSE NULL + END AS mqtt_broker_username, + CASE + WHEN jsonb_typeof(stats->'mqtt') = 'object' AND jsonb_typeof(stats->'mqtt'->'uptime_ms') = 'number' + THEN (stats->'mqtt'->>'uptime_ms')::double precision + ELSE NULL + END AS mqtt_uptime_ms, + CASE + WHEN jsonb_typeof(stats->'mqtt') = 'object' AND jsonb_typeof(stats->'mqtt'->'reconnect_attempts_1h') = 'number' + THEN (stats->'mqtt'->>'reconnect_attempts_1h')::double precision + ELSE NULL + END AS mqtt_reconnect_attempts_1h, + CASE + WHEN jsonb_typeof(stats->'mqtt') = 'object' AND jsonb_typeof(stats->'mqtt'->'session_status_publishes') = 'number' + THEN (stats->'mqtt'->>'session_status_publishes')::double precision + ELSE NULL + END AS mqtt_session_status_publishes, + CASE + WHEN jsonb_typeof(stats->'mqtt') = 'object' AND jsonb_typeof(stats->'mqtt'->'session_packet_publishes') = 'number' + THEN (stats->'mqtt'->>'session_packet_publishes')::double precision + ELSE NULL + END AS mqtt_session_packet_publishes, + CASE + WHEN jsonb_typeof(stats->'mqtt') = 'object' AND jsonb_typeof(stats->'mqtt'->'last_offline_epoch') = 'number' + THEN (stats->'mqtt'->>'last_offline_epoch')::double precision + ELSE NULL + END AS mqtt_last_offline_epoch, CASE WHEN jsonb_typeof(stats->'uptime_ms') = 'number' THEN (stats->>'uptime_ms')::double precision ELSE NULL @@ -496,16 +596,63 @@ export function createOwnerRepository(deps: OwnerRepositoryDeps) { ELSE NULL END AS tx_publish_calls FROM node_identity_status_samples - WHERE node_id_raw IN ( - SELECT meshcore_canonical_node_id($1) - UNION - SELECT source_node_id FROM node_identity_aliases - WHERE canonical_node_id = meshcore_canonical_node_id($1) - ) + WHERE node_id = meshcore_canonical_node_id($1) + AND network = COALESCE( + (SELECT network FROM node_identity_nodes + WHERE node_id = meshcore_canonical_node_id($1) + LIMIT 1), + 'ukmesh' + ) AND time > NOW() - INTERVAL '24 hours' ORDER BY time ASC`, [selectedNodeId], ), + query<{ + id: string; + rssi: number | null; + snr: number | null; + last_seen: string | null; + sample_time: string; + }>( + `WITH latest AS ( + SELECT time, neighbors + FROM node_neighbor_samples + WHERE meshcore_canonical_node_id(node_id) = meshcore_canonical_node_id($1) + AND network = COALESCE( + (SELECT network FROM node_identity_nodes + WHERE node_id = meshcore_canonical_node_id($1) + LIMIT 1), + 'ukmesh' + ) + ORDER BY time DESC + LIMIT 1 + ) + SELECT + COALESCE(item->>'id', item->>'node_id', item->>'pubkey', item->>'public_key') AS id, + CASE + WHEN jsonb_typeof(item->'rssi') = 'number' THEN (item->>'rssi')::double precision + WHEN jsonb_typeof(item->'RSSI') = 'number' THEN (item->>'RSSI')::double precision + ELSE NULL + END AS rssi, + CASE + WHEN jsonb_typeof(item->'snr') = 'number' THEN (item->>'snr')::double precision + WHEN jsonb_typeof(item->'SNR') = 'number' THEN (item->>'SNR')::double precision + ELSE NULL + END AS snr, + COALESCE( + CASE WHEN jsonb_typeof(item->'last_seen') IN ('string', 'number') THEN item->>'last_seen' ELSE NULL END, + CASE WHEN jsonb_typeof(item->'lastSeen') IN ('string', 'number') THEN item->>'lastSeen' ELSE NULL END + ) AS last_seen, + latest.time::text AS sample_time + FROM latest + CROSS JOIN LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(latest.neighbors) = 'array' THEN latest.neighbors ELSE '[]'::jsonb END + ) AS neighbor(item) + WHERE COALESCE(item->>'id', item->>'node_id', item->>'pubkey', item->>'public_key') IS NOT NULL + ORDER BY last_seen DESC NULLS LAST + LIMIT 32`, + [selectedNodeId], + ), query<{ packets_24h: number }>( `SELECT COUNT(*)::int AS packets_24h FROM node_identity_packets @@ -540,6 +687,7 @@ export function createOwnerRepository(deps: OwnerRepositoryDeps) { linkHealthResult, advertTrendResult, telemetryResult, + heardNeighborsResult, packetsSentResult, packetsReceivedResult, }; diff --git a/backend/src/owner/ownerService.ts b/backend/src/owner/ownerService.ts index 8238717..71910b3 100644 --- a/backend/src/owner/ownerService.ts +++ b/backend/src/owner/ownerService.ts @@ -31,6 +31,33 @@ type OwnerLastHopCacheEntry = { latestBucket: string | null; }; +function nullableNumber(value: unknown): number | null { + if (value == null || value === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function nullableString(value: unknown): string | null { + return typeof value === 'string' && value.trim() !== '' ? value : null; +} + +function isoTimestamp(value: unknown): string | null { + if (typeof value !== 'string' || !value) return null; + const time = Date.parse(value); + return Number.isFinite(time) ? new Date(time).toISOString() : null; +} + +function neighborTimestamp(value: unknown): string | null { + if (typeof value === 'number' || (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)))) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) return null; + const milliseconds = numeric > 1_000_000_000_000 ? numeric : numeric * 1_000; + const date = new Date(milliseconds); + return Number.isFinite(date.getTime()) ? date.toISOString() : null; + } + return isoTimestamp(value); +} + type OwnerServiceDeps = { ownerLiveCacheTtlMs: number; ownerLiveCache: Map; @@ -213,6 +240,7 @@ export function createOwnerService(deps: OwnerServiceDeps) { linkHealthResult, advertTrendResult, telemetryResult, + heardNeighborsResult, packetsSentResult, packetsReceivedResult, } = await repository.fetchOwnerLiveData(selectedNodeId); @@ -249,6 +277,21 @@ export function createOwnerService(deps: OwnerServiceDeps) { last_seen: row.last_seen ? new Date(row.last_seen).toISOString() : null, })); + const heardNeighbors = heardNeighborsResult.rows + .map((row) => ({ + id: row.id, + rssi: nullableNumber(row.rssi), + snr: nullableNumber(row.snr), + last_seen_at: neighborTimestamp(row.last_seen), + sampled_at: isoTimestamp(row.sample_time), + })) + .sort((a, b) => { + const aTime = a.last_seen_at ? Date.parse(a.last_seen_at) : 0; + const bTime = b.last_seen_at ? Date.parse(b.last_seen_at) : 0; + return bTime - aTime; + }) + .slice(0, 32); + const linkHealth = linkHealthResult.rows.map((row) => ({ ...row, owner_to_peer: Number(row.owner_to_peer ?? 0), @@ -374,6 +417,49 @@ export function createOwnerService(deps: OwnerServiceDeps) { return Array.from(bucketed.values()).sort((a, b) => a.bucket.localeCompare(b.bucket)); })(); + const latestStatusRow = telemetryResult.rows[telemetryResult.rows.length - 1]; + const status = latestStatusRow + ? { + sampled_at: isoTimestamp(latestStatusRow.time), + battery_mv: nullableNumber(latestStatusRow.battery_mv), + solar_mv: nullableNumber(latestStatusRow.solar_mv), + board_temp_c: nullableNumber(latestStatusRow.board_temp_c), + wifi_rssi: nullableNumber(latestStatusRow.wifi_rssi), + wifi_ssid: nullableString(latestStatusRow.wifi_ssid), + wifi_uptime_ms: nullableNumber(latestStatusRow.wifi_uptime_ms), + ntp_synced: typeof latestStatusRow.ntp_synced === 'boolean' ? latestStatusRow.ntp_synced : null, + ntp_sync_age_ms: nullableNumber(latestStatusRow.ntp_sync_age_ms), + boot_count: nullableNumber(latestStatusRow.boot_count), + reset_reason: nullableString(latestStatusRow.reset_reason), + max_loop_ms: nullableNumber(latestStatusRow.max_loop_ms), + max_loop_at_ms: nullableNumber(latestStatusRow.max_loop_at_ms), + nodes_heard_24h: nullableNumber(latestStatusRow.nodes_heard_24h), + channel_utilization: nullableNumber(latestStatusRow.channel_utilization), + air_util_tx: nullableNumber(latestStatusRow.air_util_tx), + air_util_rx: nullableNumber(latestStatusRow.air_util_rx), + last_rx_rssi: nullableNumber(latestStatusRow.last_rx_rssi), + last_rx_snr: nullableNumber(latestStatusRow.last_rx_snr), + tx_power_dbm: nullableNumber(latestStatusRow.tx_power_dbm), + config_version: nullableString(latestStatusRow.config_version), + config_crc32: nullableString(latestStatusRow.config_crc32), + fs_free_bytes: nullableNumber(latestStatusRow.fs_free_bytes), + fs_total_bytes: nullableNumber(latestStatusRow.fs_total_bytes), + nvs_free_entries: nullableNumber(latestStatusRow.nvs_free_entries), + channel_id: nullableNumber(latestStatusRow.channel_id), + git_commit: nullableString(latestStatusRow.git_commit), + boot_epoch: nullableNumber(latestStatusRow.boot_epoch), + mqtt: { + broker_uri: nullableString(latestStatusRow.mqtt_broker_uri), + broker_username: nullableString(latestStatusRow.mqtt_broker_username), + uptime_ms: nullableNumber(latestStatusRow.mqtt_uptime_ms), + reconnect_attempts_1h: nullableNumber(latestStatusRow.mqtt_reconnect_attempts_1h), + session_status_publishes: nullableNumber(latestStatusRow.mqtt_session_status_publishes), + session_packet_publishes: nullableNumber(latestStatusRow.mqtt_session_packet_publishes), + last_offline_epoch: nullableNumber(latestStatusRow.mqtt_last_offline_epoch), + }, + } + : null; + const alerts: Array<{ level: 'info' | 'warn' | 'error'; message: string }> = []; const ownerLastSeenMs = ownerNode.last_seen ? new Date(ownerNode.last_seen).getTime() : 0; const minsSinceSeen = ownerLastSeenMs ? Math.max(0, Math.round((Date.now() - ownerLastSeenMs) / 60000)) : null; @@ -424,6 +510,8 @@ export function createOwnerService(deps: OwnerServiceDeps) { linkHealth, advertTrend24h, telemetry24h, + status, + heardNeighbors, packetsSent24h: Number(packetsSentResult.rows[0]?.packets_24h ?? 0), packetsReceived24h: Number(packetsReceivedResult.rows[0]?.packets_24h ?? 0), alerts, diff --git a/frontend/src/pages/OwnerPortalPage.tsx b/frontend/src/pages/OwnerPortalPage.tsx index 2cf05ac..9531d41 100644 --- a/frontend/src/pages/OwnerPortalPage.tsx +++ b/frontend/src/pages/OwnerPortalPage.tsx @@ -39,6 +39,8 @@ import { formatUptime, } from './owner/OwnerPortalCharts.js'; import { OwnerMapView } from './owner/OwnerMapView.js'; +import { OwnerHeardNeighbors } from './owner/OwnerHeardNeighbors.js'; +import { OwnerStatusFields } from './owner/OwnerStatusFields.js'; export const OwnerPortalPage: React.FC = () => { const { privacyGeneration } = useRuntimeFeatures(); const [mqttUsername, setMqttUsername] = useState(''); @@ -377,6 +379,17 @@ export const OwnerPortalPage: React.FC = () => { +
+
+
+

Node status

+

Latest nullable diagnostics reported by the node.

+
+ {live?.status?.sampled_at ? fmtTs(live.status.sampled_at) : 'No sample'} +
+ +
+
@@ -476,6 +489,16 @@ export const OwnerPortalPage: React.FC = () => {
+
+
+
+

Heard neighbors

+

The latest neighbor sample, sorted by last-seen recency (up to 32 nodes).

+
+
+ +
+

Live Packets Received By {nodeRoleLabel(live?.ownerNode.role ?? null)}

diff --git a/frontend/src/pages/owner-portal.css b/frontend/src/pages/owner-portal.css index 21f870b..fff350c 100644 --- a/frontend/src/pages/owner-portal.css +++ b/frontend/src/pages/owner-portal.css @@ -181,6 +181,122 @@ grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } +.owner-status-panel { + margin-bottom: 16px; + height: auto; +} +.owner-status-panel__sample { + flex: 0 0 auto; + color: var(--text-secondary); + font-size: 11px; + font-family: var(--font-mono); + text-align: right; +} +.owner-status-groups { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; +} +.owner-status-group { + min-width: 0; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-panel-alt); +} +.owner-status-group h3 { + margin: 0 0 10px; + color: var(--text-primary); + font-size: 13px; +} +.owner-status-group dl { + display: grid; + gap: 8px; + margin: 0; +} +.owner-status-field { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); + gap: 8px; + align-items: baseline; +} +.owner-status-field dt { + color: var(--text-secondary); + font-size: 10px; + line-height: 1.3; +} +.owner-status-field dd { + min-width: 0; + margin: 0; + color: var(--text-primary); + font-size: 11px; + font-family: var(--font-mono); + overflow-wrap: anywhere; + text-align: right; +} +.owner-status-empty, +.owner-neighbors-empty { + margin: 0; +} +.owner-neighbors { + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; + flex: 1; +} +.owner-neighbors__meta { + flex: 0 0 auto; + color: var(--text-secondary); + font-size: 11px; + font-family: var(--font-mono); +} +.owner-neighbors__table-wrap { + min-height: 0; + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius); +} +.owner-neighbors__table { + width: 100%; + border-collapse: collapse; + font-size: 11px; +} +.owner-neighbors__table th, +.owner-neighbors__table td { + padding: 8px 10px; + border-bottom: 1px solid var(--border); + text-align: right; + white-space: nowrap; +} +.owner-neighbors__table th:first-child, +.owner-neighbors__table td:first-child { + text-align: left; +} +.owner-neighbors__table th { + position: sticky; + top: 0; + z-index: 1; + color: var(--text-secondary); + background: var(--bg-panel-alt); + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.owner-neighbors__table td { + color: var(--text-primary); + font-family: var(--font-mono); +} +.owner-neighbors__table td:first-child code { + display: block; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; +} +.owner-neighbors__table tr:last-child td { + border-bottom: 0; +} .owner-telemetry-metric { display: flex; flex-direction: column; @@ -484,6 +600,8 @@ .owner-dashboard-grid { grid-template-columns: 1fr; grid-auto-rows: auto; } .owner-panel { min-height: 300px; } .owner-telemetry-strip { grid-template-columns: 1fr; } + .owner-status-groups { grid-template-columns: 1fr; } + .owner-status-panel__sample { text-align: left; } } @media (max-width: 640px) { .owner-summary-grid { grid-template-columns: 1fr; } @@ -495,6 +613,7 @@ .owner-list__row { grid-template-columns: 1fr; } .owner-list__metrics { justify-content: flex-start; } .owner-telemetry-strip { grid-template-columns: 1fr; } + .owner-status-groups { grid-template-columns: 1fr; } .owner-head { flex-direction: column; align-items: flex-start; } .owner-node-identity__head { flex-direction: column; gap: 4px; } .owner-map { height: 260px; } @@ -507,6 +626,9 @@ .owner-dashboard-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .owner-status-groups { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } } @media (max-width: 860px) { @@ -518,6 +640,9 @@ grid-template-columns: 1fr; grid-auto-rows: minmax(280px, auto); } + .owner-status-groups { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } /* Keep telemetry and map cards content-sized on phones. Fixed-height flex/grid @@ -530,6 +655,12 @@ .owner-panel { height: auto; } + .owner-status-groups { + grid-template-columns: 1fr; + } + .owner-status-panel__sample { + text-align: left; + } .owner-telemetry-panel { min-height: 0; margin-bottom: 24px; diff --git a/frontend/src/pages/owner/OwnerHeardNeighbors.tsx b/frontend/src/pages/owner/OwnerHeardNeighbors.tsx new file mode 100644 index 0000000..faeb32e --- /dev/null +++ b/frontend/src/pages/owner/OwnerHeardNeighbors.tsx @@ -0,0 +1,49 @@ +import React, { useMemo } from 'react'; +import { formatNeighborAge, type HeardNeighbor } from './ownerPortalModel.js'; + +function signalValue(value: number | null, suffix: string): string { + if (value == null || !Number.isFinite(value)) return '—'; + return `${value.toFixed(1)} ${suffix}`; +} + +export const OwnerHeardNeighbors: React.FC<{ neighbors: HeardNeighbor[] }> = ({ neighbors }) => { + const rows = useMemo( + () => [...neighbors] + .sort((a, b) => { + const aTime = Date.parse(a.last_seen_at ?? ''); + const bTime = Date.parse(b.last_seen_at ?? ''); + return (Number.isFinite(bTime) ? bTime : Number.NEGATIVE_INFINITY) + - (Number.isFinite(aTime) ? aTime : Number.NEGATIVE_INFINITY); + }) + .slice(0, 32), + [neighbors], + ); + const sampledAt = rows.find((neighbor) => neighbor.sampled_at)?.sampled_at ?? null; + + if (rows.length === 0) { + return

No neighbor sample has been received for this node yet.

; + } + + return ( +
+
Latest sample: {sampledAt ? new Date(sampledAt).toLocaleString() : '—'}
+
+ + + + + + {rows.map((neighbor, index) => ( + + + + + + + ))} + +
IDRSSISNRLast seen
{neighbor.id}{signalValue(neighbor.rssi, 'dBm')}{signalValue(neighbor.snr, 'dB')}{formatNeighborAge(neighbor.last_seen_at)}
+
+
+ ); +}; diff --git a/frontend/src/pages/owner/OwnerPortalCharts.tsx b/frontend/src/pages/owner/OwnerPortalCharts.tsx index 19639c6..b1250ef 100644 --- a/frontend/src/pages/owner/OwnerPortalCharts.tsx +++ b/frontend/src/pages/owner/OwnerPortalCharts.tsx @@ -6,6 +6,7 @@ import { OWNER_TOOLTIP_BG as TIP_BG, OWNER_TOOLTIP_BORDER as TIP_BORDER, formatCompactTs, + formatDurationMs, readExcludedLastHopSeries, writeExcludedLastHopSeries, type LastHopStrengthPoint, @@ -69,13 +70,7 @@ export const TELEMETRY_SERIES = [ export function formatUptime(seconds: number | null): string { if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return '—'; - const total = Math.floor(seconds); - const days = Math.floor(total / 86400); - const hours = Math.floor((total % 86400) / 3600); - const minutes = Math.floor((total % 3600) / 60); - if (days > 0) return `${days}d ${hours}h`; - if (hours > 0) return `${hours}h ${minutes}m`; - return `${minutes}m`; + return formatDurationMs(seconds * 1_000); } const OwnerTelemetryTooltip: React.FC<{ diff --git a/frontend/src/pages/owner/OwnerStatusFields.tsx b/frontend/src/pages/owner/OwnerStatusFields.tsx new file mode 100644 index 0000000..d03f0db --- /dev/null +++ b/frontend/src/pages/owner/OwnerStatusFields.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { + formatDurationMs, + formatEpochSeconds, + type OwnerStatus, +} from './ownerPortalModel.js'; + +function numberValue(value: number | null, suffix = ''): string { + if (value == null || !Number.isFinite(value)) return '—'; + return `${value.toLocaleString(undefined, { maximumFractionDigits: 2 })}${suffix}`; +} + +function textValue(value: string | null): string { + return value?.trim() ? value : '—'; +} + +const StatusField: React.FC<{ label: string; value: string }> = ({ label, value }) => ( +
+
{label}
+
{value}
+
+); + +const StatusGroup: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => ( +
+

{title}

+
{children}
+
+); + +export const OwnerStatusFields: React.FC<{ status: OwnerStatus | null }> = ({ status }) => { + if (!status) { + return

No status telemetry has been received for this node yet.

; + } + + const ntpState = status.ntp_synced == null ? '—' : status.ntp_synced ? 'Synced' : 'Not synced'; + const ntpAge = status.ntp_synced === false ? 'Not synced' : formatDurationMs(status.ntp_sync_age_ms); + const fsFree = numberValue(status.fs_free_bytes, ' B'); + const fsTotal = numberValue(status.fs_total_bytes, ' B'); + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/frontend/src/pages/owner/ownerPortalModel.test.ts b/frontend/src/pages/owner/ownerPortalModel.test.ts index 72483f9..9711e48 100644 --- a/frontend/src/pages/owner/ownerPortalModel.test.ts +++ b/frontend/src/pages/owner/ownerPortalModel.test.ts @@ -2,6 +2,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { cleanPacketBody, + formatDurationMs, + formatEpochSeconds, + formatNeighborAge, isOwnerLiveResponse, isOwnerSessionResponse, isValidMapCoord, @@ -32,6 +35,15 @@ test('owner role and packet presentation remain stable', () => { }), null); }); +test('owner telemetry durations and unsynced epochs are humanized safely', () => { + assert.equal(formatDurationMs((1 * 24 * 60 + 2 * 60 + 3) * 60_000), '1d 2h 3m'); + assert.equal(formatDurationMs(0), '0m'); + assert.equal(formatDurationMs(null), '—'); + assert.equal(formatEpochSeconds(0), 'Unsynced'); + assert.equal(formatNeighborAge(null), '—'); + assert.equal(formatNeighborAge('2026-08-07T00:00:00.000Z', Date.parse('2026-08-07T01:02:00.000Z')), '1h 2m ago'); +}); + test('owner response guards reject structurally incomplete payloads', () => { assert.equal(isOwnerSessionResponse({ ok: true, dashboard: {} }), false); assert.equal(isOwnerLiveResponse({ nodeId: 'aa' }), false); diff --git a/frontend/src/pages/owner/ownerPortalModel.ts b/frontend/src/pages/owner/ownerPortalModel.ts index ac7dece..64b02c6 100644 --- a/frontend/src/pages/owner/ownerPortalModel.ts +++ b/frontend/src/pages/owner/ownerPortalModel.ts @@ -114,6 +114,54 @@ export type LivePacket = { body: string | null; }; +export type OwnerStatus = { + sampled_at: string | null; + battery_mv: number | null; + solar_mv: number | null; + board_temp_c: number | null; + wifi_rssi: number | null; + wifi_ssid: string | null; + wifi_uptime_ms: number | null; + ntp_synced: boolean | null; + ntp_sync_age_ms: number | null; + boot_count: number | null; + reset_reason: string | null; + max_loop_ms: number | null; + max_loop_at_ms: number | null; + nodes_heard_24h: number | null; + channel_utilization: number | null; + air_util_tx: number | null; + air_util_rx: number | null; + last_rx_rssi: number | null; + last_rx_snr: number | null; + tx_power_dbm: number | null; + config_version: string | null; + config_crc32: string | null; + fs_free_bytes: number | null; + fs_total_bytes: number | null; + nvs_free_entries: number | null; + channel_id: number | null; + git_commit: string | null; + boot_epoch: number | null; + mqtt: { + broker_uri: string | null; + broker_username: string | null; + uptime_ms: number | null; + reconnect_attempts_1h: number | null; + session_status_publishes: number | null; + session_packet_publishes: number | null; + last_offline_epoch: number | null; + }; +}; + +export type HeardNeighbor = { + id: string; + rssi: number | null; + snr: number | null; + last_seen_at: string | null; + sampled_at: string | null; +}; + export type OwnerLiveResponse = { nodeId: string; ownerNode: OwnerNode; @@ -140,6 +188,8 @@ export type OwnerLiveResponse = { channelUtilPct: number | null; airUtilTxPct: number | null; }>; + status: OwnerStatus | null; + heardNeighbors: HeardNeighbor[]; packetsSent24h: number; packetsReceived24h: number; alerts: Array<{ level: 'info' | 'warn' | 'error'; message: string }>; @@ -187,8 +237,13 @@ export function isOwnerSessionResponse(value: unknown): value is OwnerSessionRes } export function isOwnerLiveResponse(value: unknown): value is OwnerLiveResponse { - return isRecord(value) - && typeof value['nodeId'] === 'string' + if (!isRecord(value)) return false; + const status = value['status']; + const heardNeighbors = value['heardNeighbors']; + if (status != null && (!isRecord(status) || !isRecord(status['mqtt']))) return false; + if (heardNeighbors != null && (!Array.isArray(heardNeighbors) + || heardNeighbors.some((neighbor) => !isRecord(neighbor) || typeof neighbor['id'] !== 'string'))) return false; + return typeof value['nodeId'] === 'string' && isRecord(value['ownerNode']) && Array.isArray(value['incomingPeers']) && Array.isArray(value['heardBy']) @@ -222,6 +277,36 @@ export function fmtTs(timestamp: string | null): string { return new Date(timestamp).toLocaleString(); } +export function formatDurationMs(milliseconds: number | null): string { + if (milliseconds == null || !Number.isFinite(milliseconds) || milliseconds < 0) return '—'; + const totalMinutes = Math.floor(milliseconds / 60_000); + const days = Math.floor(totalMinutes / 1_440); + const hours = Math.floor((totalMinutes % 1_440) / 60); + const minutes = totalMinutes % 60; + if (days > 0) return `${days}d ${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} + +export function formatEpochSeconds(seconds: number | null): string { + if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return 'Unsynced'; + const date = new Date(seconds * 1_000); + return Number.isFinite(date.getTime()) ? date.toLocaleString() : '—'; +} + +export function formatNeighborAge(timestamp: string | null, now = Date.now()): string { + if (!timestamp) return '—'; + const time = Date.parse(timestamp); + if (!Number.isFinite(time)) return '—'; + const totalMinutes = Math.max(0, Math.floor((now - time) / 60_000)); + const days = Math.floor(totalMinutes / 1_440); + const hours = Math.floor((totalMinutes % 1_440) / 60); + const minutes = totalMinutes % 60; + if (days > 0) return `${days}d ${hours}h ${minutes}m ago`; + if (hours > 0) return `${hours}h ${minutes}m ago`; + return `${minutes}m ago`; +} + export function isValidMapCoord(lat: number | null, lon: number | null): boolean { if (lat == null || lon == null) return false; if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;