Expose owner node telemetry and heard neighbors

This commit is contained in:
gadgethd
2026-08-07 00:55:51 +00:00
parent 55d1d319dd
commit 33bfdc1c6d
9 changed files with 641 additions and 18 deletions
+157 -9
View File
@@ -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,
};
+88
View File
@@ -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<string, OwnerLiveCacheEntry>;
@@ -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,
+23
View File
@@ -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 = () => {
</div>
</section>
<section className="owner-panel owner-status-panel">
<div className="owner-panel__head">
<div>
<h2>Node status</h2>
<p className="prose-note">Latest nullable diagnostics reported by the node.</p>
</div>
<span className="owner-status-panel__sample">{live?.status?.sampled_at ? fmtTs(live.status.sampled_at) : 'No sample'}</span>
</div>
<OwnerStatusFields status={live?.status ?? null} />
</section>
<div className="owner-dashboard-grid">
<section className="prose-section owner-panel owner-panel--map">
<div className="owner-panel__head">
@@ -476,6 +489,16 @@ export const OwnerPortalPage: React.FC = () => {
</div>
</section>
<section className="prose-section owner-panel owner-panel--neighbors">
<div className="owner-panel__head">
<div>
<h2>Heard neighbors</h2>
<p className="prose-note" style={{ marginTop: 0 }}>The latest neighbor sample, sorted by last-seen recency (up to 32 nodes).</p>
</div>
</div>
<OwnerHeardNeighbors neighbors={live?.heardNeighbors ?? []} />
</section>
<section className="prose-section owner-panel owner-panel--packets">
<div className="owner-panel__head"><h2>Live Packets Received By {nodeRoleLabel(live?.ownerNode.role ?? null)}</h2></div>
<div className="owner-packets">
+131
View File
@@ -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;
@@ -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 <p className="prose-note owner-neighbors-empty">No neighbor sample has been received for this node yet.</p>;
}
return (
<div className="owner-neighbors">
<div className="owner-neighbors__meta">Latest sample: {sampledAt ? new Date(sampledAt).toLocaleString() : '—'}</div>
<div className="owner-neighbors__table-wrap">
<table className="owner-neighbors__table">
<thead>
<tr><th scope="col">ID</th><th scope="col">RSSI</th><th scope="col">SNR</th><th scope="col">Last seen</th></tr>
</thead>
<tbody>
{rows.map((neighbor, index) => (
<tr key={`${neighbor.id}-${neighbor.last_seen_at ?? neighbor.sampled_at ?? 'unknown'}-${index}`}>
<td><code title={neighbor.id}>{neighbor.id}</code></td>
<td>{signalValue(neighbor.rssi, 'dBm')}</td>
<td>{signalValue(neighbor.snr, 'dB')}</td>
<td>{formatNeighborAge(neighbor.last_seen_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
@@ -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<{
@@ -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 }) => (
<div className="owner-status-field">
<dt>{label}</dt>
<dd>{value}</dd>
</div>
);
const StatusGroup: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
<section className="owner-status-group">
<h3>{title}</h3>
<dl>{children}</dl>
</section>
);
export const OwnerStatusFields: React.FC<{ status: OwnerStatus | null }> = ({ status }) => {
if (!status) {
return <p className="prose-note owner-status-empty">No status telemetry has been received for this node yet.</p>;
}
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 (
<div className="owner-status-groups">
<StatusGroup title="System">
<StatusField label="Board temperature" value={numberValue(status.board_temp_c, ' °C')} />
<StatusField label="Wi-Fi SSID" value={textValue(status.wifi_ssid)} />
<StatusField label="Wi-Fi uptime" value={formatDurationMs(status.wifi_uptime_ms)} />
<StatusField label="NTP" value={ntpState} />
<StatusField label="NTP sync age" value={ntpAge} />
<StatusField label="Boot count" value={numberValue(status.boot_count)} />
<StatusField label="Reset reason" value={textValue(status.reset_reason)} />
<StatusField label="Boot epoch" value={formatEpochSeconds(status.boot_epoch)} />
<StatusField label="Git commit" value={textValue(status.git_commit)} />
</StatusGroup>
<StatusGroup title="Power">
<StatusField label="Battery" value={numberValue(status.battery_mv, ' mV')} />
<StatusField label="Solar" value={numberValue(status.solar_mv, ' mV')} />
</StatusGroup>
<StatusGroup title="RF">
<StatusField label="Wi-Fi RSSI" value={numberValue(status.wifi_rssi, ' dBm')} />
<StatusField label="Channel ID" value={numberValue(status.channel_id)} />
<StatusField label="Channel utilization" value={numberValue(status.channel_utilization, ' %')} />
<StatusField label="Air utilization TX" value={numberValue(status.air_util_tx, ' %')} />
<StatusField label="Air utilization RX" value={numberValue(status.air_util_rx, ' %')} />
<StatusField label="Last RX RSSI" value={numberValue(status.last_rx_rssi, ' dBm')} />
<StatusField label="Last RX SNR" value={numberValue(status.last_rx_snr, ' dB')} />
<StatusField label="TX power" value={numberValue(status.tx_power_dbm, ' dBm')} />
<StatusField label="Nodes heard (24h)" value={numberValue(status.nodes_heard_24h)} />
</StatusGroup>
<StatusGroup title="Config">
<StatusField label="Max loop" value={formatDurationMs(status.max_loop_ms)} />
<StatusField label="Max loop at" value={formatDurationMs(status.max_loop_at_ms)} />
<StatusField label="Config version" value={textValue(status.config_version)} />
<StatusField label="Config CRC32" value={textValue(status.config_crc32)} />
<StatusField label="Filesystem free / total" value={`${fsFree} / ${fsTotal}`} />
<StatusField label="NVS free entries" value={numberValue(status.nvs_free_entries)} />
</StatusGroup>
<StatusGroup title="MQTT">
<StatusField label="Broker URI" value={textValue(status.mqtt.broker_uri)} />
<StatusField label="Broker username" value={textValue(status.mqtt.broker_username)} />
<StatusField label="Uptime" value={formatDurationMs(status.mqtt.uptime_ms)} />
<StatusField label="Reconnects (1h)" value={numberValue(status.mqtt.reconnect_attempts_1h)} />
<StatusField label="Status publishes" value={numberValue(status.mqtt.session_status_publishes)} />
<StatusField label="Packet publishes" value={numberValue(status.mqtt.session_packet_publishes)} />
<StatusField label="Last offline epoch" value={formatEpochSeconds(status.mqtt.last_offline_epoch)} />
</StatusGroup>
</div>
);
};
@@ -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);
+87 -2
View File
@@ -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;