mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-09-02 18:33:49 +00:00
Expose owner node telemetry and heard neighbors
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user