feat(owner): clean up spring dashboard

This commit is contained in:
gadgethd
2026-08-06 11:25:07 +00:00
parent cddc5efdff
commit 1e5bc3b299
15 changed files with 526 additions and 399 deletions
+129
View File
@@ -0,0 +1,129 @@
# UKMesh Spring Clean 2026-08-06
Plan-first, then execute **one item at a time** (Ben's rule). All code work via
**MissionDeck agents on gpt-5.6-luna @ max** on the VPS canonical tree
(`~/ukmesh/meshcore-analytics` on 57.129.131.35, branch `main`). No GitHub
push (not set up on the new VPS yet) — agents commit locally + deploy live per
the meshcore-analytics-ops skill (image pins, digest updates, scoped rollout,
screenshot verification).
Pre-work: merge `feat/meshcore-decryption` into `main` on the VPS so agents
start from the live state.
## Recon findings (2026-08-06)
- **GNOME**: `GNOME-MSG-RPT` exists as 3 node rows (keys D7696E…10…A4, D7696E…16…A4 —
one nibble differs, likely re-keyed device, and 230F10… with 0 adverts);
`GNOME-STKTN-RPT` ×2; `GNOME-MOBILE-RPT` stale since 2026-07-01. Data is split
across duplicate rows → map/dashboard shows stale or partial data.
- **Ingest dip**: packets/day 154.8K (Aug 3) → 121.5K (Aug 4) → 100.2K (Aug 5)
→ ~6.2K by 01:30 Aug 6. Migration cutover was Aug 5 22:46Z. Other services
(discord-bot etc.) report higher counts for the same repeaters.
- **Feed**: `getRecentMessages` = type-5 packets, **24h window**, LIMIT 50 →
quiet channels (bot) show a handful. Need per-channel history up to 50.
- **Owner dashboard dupes**: same repeater name, different public keys
(2E0MTU RPT Hilperton ×2, 7UKR S62 RPT ×2, GNOME-MSG-RPT ×3…).
- **Live map feed card**: wide metadata (IATA, hops, GRP tag); message text
should be primary, text smaller.
- **Path algorithm**: makes hops that conflict with the ITM/line-of-sight data.
- **Repeaters**: stale ones remain on map (should drop unless seen in
multibyte-hop paths); MQTT-only repeaters render specially (should not).
## Overnight progress log (2026-08-06, Ben asleep — autonomous run)
- **[DONE] Item 1 — MQTT ingest**: root cause = failed DB batch writes discarded without retry (lossy during DB instability) + clean-session MQTT client (missed messages on reconnect). Observer feeds declined Aug 4 pre-cutover (not ours). Fix: idempotent transient batch retries, persistent clean=false QoS-1 session, outcome/retry metrics. Commit 90d0dce, digest 7e085b1286d5 pinned, verified (health healthy, HopReach 200, 1,457 pkts/30min). Report: INGEST-AUDIT-2026-08-06.md.
- **[DONE] Item 2 — GNOME repeater**: general evidence-based canonical node-identity merge (migration 036 + nodeIdentity.ts + canonical views wired into all read paths). GNOME MSG 3 keys → D769…F344A4, STKTN 3 → 6582…A104F8; stale GNOME-MOBILE separate; false-merge families (Dunston-1/2/3, NorthMesh RPT-1/2, Reach Yagi E/NWW) correctly NOT merged (active-to-active never merges, ordinal/directional suffixes = hard ambiguity). Commit df7cfbe, digest 03e54021. Report: GNOME-REPEATER-2026-08-06.md.
- **Perf regression found+fixed by me (Hermes)**: canonical views called meshcore_canonical_node_id per row (5×/row on links) = 38s scans → synthetic WS health check critical. Fix: set-based alias LEFT JOINs (38s→1.5s) + MATERIALIZED CTE wrapper (planner was re-running the links aggregation 13,070× per consumer query; viable-links 24.3s→3.1s) + dropped dead terrain_profile_json column (zero readers). Migration 037 (applied, idempotent), commit 977ce17. Verified: health healthy, HopReach 200, WS initial_state 507ms, GNOME API rows canonical.
- **IN PROGRESS — Item 3 (feed history to 50)**: root cause = getRecentMessages is 24h-window LIMIT 50 → quiet channels (bot) show few messages. Agent: ukmesh-feed-history (to spawn).
- Gateway restart 03:40 local (02:40Z) also restarted MissionDeck + VPS app-server; MCP bridge fixed by killing mcp.js children; no service restarts performed by me.
## Execution order (one at a time)
1. **MQTT ingest audit + fix** (backend) — compare mosquitto/subscription counts
vs packets table per observer; find the Aug 45 drop (QoS, topic filter,
dedupe, concurrency); fix root cause; verify per-observer parity.
2. **GNOME repeater data** (backend) — why not showing properly: node-row
dedupe (same-name/similar-key merge), stale GNOME rows, advert handling.
3. **Feed history to 50** (backend + frontend) — per-channel message fetch
(extend window / dedicated endpoint), sidebar channels show up to 50.
4. **Owner dashboard repeater dedupe** (frontend/backend) — same-name repeaters
shown once (merge by canonical identity; keep key list).
5. **Live map feed card** — message-first compact layout, smaller metadata.
6. **Path algorithm honesty** — respect ITM/LOS hop feasibility; no wild hops.
7. **Stale repeaters off the map** — unless seen in multibyte packet hops.
8. **MQTT repeaters not shown specially** — render like any other repeater
(or not at all per product decision — default: normal repeater).
9. **Repeater UI polish** (website) — match site fonts/design language.
10. **Repeater observation register page** — verify it works; remove if dead.
11. **Topology map on UK map** — render repeater topology over the UK map.
12. **Spam page** — verify whether it works / is needed; fix or remove.
13. **Install page full fact-check audit****MissionDeck agent (luna max)**,
read-only audit of every claim vs reality; produce corrections list; apply.
14. **Health page removal** — delete page + nav entry.
15. **Docs expansion + fact-check** — expand from MeshCore knowledge; agent
fact-check; keep page.
16. **Health check redesign** — packet-send-and-track feature + site-consistent
styling.
17. **Owner dashboard single page** — collapse multi-section pages back to one.
18. **MQTT owners page cleanup** — remove settings section + dead/unwired
features (projected features get removed, not kept).
## Rules
- One item at a time: investigate → agent fix (luna max) → quality gates →
deploy live → verify (API + screenshot) → report → next.
- Agents: `provider: codex@ukmesh`, `model: gpt-5.6-luna`, `effort: max`,
`yolo: true`, cwd `/home/ben/ukmesh/meshcore-analytics`, main branch, NO
GitHub push, commit locally, deploy per ops skill (image digests, scoped
rollout, screenshot proof).
- Backend changes → re-verify hopreach contract + /api/health after deploy.
- Record progress in this file after each item.
## Morning verification + continuation (2026-08-06 ~09:15Z)
- Items 1-3 re-verified live by Hermes: ingest 4-4.6k pkts/hr steady, GNOME rows canonical (3 rows), feed history 50 spanning Jul 27-Aug 5. ALL HEALTHY.
- Item 6 (path LOS/ITM): COVERED by pathing prod integration — champion vit_src includes ITM/corridor interpolation (REPORT-pathing-experiment.md), live commits 5dd15d9 (physics gates) + a76ab1e (terrain-aware paths). Marked done pending visual confirm in Wave 2.
- Remaining 15 items executing in waves (one website/app image owner at a time):
- Wave 1 [RUNNING]: items 4+17+18 (owner dashboard dedupe/one-page/cleanup) — agent ukmesh-spring-dashboard
- Wave 2: items 5+7+8+9 (feed card, stale repeaters, MQTT repeaters, polish) + visual check item 6
- Wave 3: items 10+12+14 (observation register, spam page, health page removal)
- Wave 4: item 11 (topology map on UK map)
- Wave 5: item 16 (health check redesign — separate meshcore-health-check app) + item 13 (install page audit) + item 15 (docs)
## Spring dashboard completion (2026-08-06)
- **[DONE] Item 4 — owner repeater dedupe.** The owner dashboard now reads the
canonical `node_identity_nodes` view and carries `canonicalId` plus the
complete `members` key list into the owner session/live payload. The owner
boundary also combines separately-authorized canonical rows with the same
normalized repeater name, retaining an authorized source key for existing
live endpoint access. The deployed live grouping produced one
`GNOME-MSG-RPT` entry with 3 member keys and one `2E0MTU RPT Hilperton` entry
with 2 member keys.
- **[DONE] Item 17 — single owner page.** Removed the dashboard/live/settings
section navigation; identity, summary, telemetry, map, alerts, trends,
link health, sender, heard-by, and packet sections are all reachable on one
stacked owner page.
- **[DONE] Item 18 — MQTT owners cleanup.** Removed the settings UI and its
inactive controls, plus the unused roadmap/totals payload and related CSS.
The backend alert API/worker remains because it is an active operational
delivery path, not a projected page feature.
- **Implementation:** added canonical grouping tests and updated owner
response/e2e coverage. Local implementation commit is recorded in the final
handoff after verification.
- **Quality gates:** backend `npx tsc --noEmit`; backend `npm test` = 273/273
passing (271 existing tests plus 2 grouping tests); frontend
`npx tsc --noEmit`, `npm test` = 76/76, `npm run lint:css`, and `npm run
build` all pass. Build emitted only the existing large-chunk warnings.
- **Deployment:** built and deployed backend, app, and website with the
spring-dash tags. Deployed image IDs were backend
`sha256:6a11941684f87f029bbcf98b50ec2764f92ede5ea6f5f8d9c029ba7a0cac1164`,
app `sha256:30b286f26b8fdf1b8a04a41585475822bd80658a9057c627c4df626370fc12b2`,
and website
`sha256:96ab70c6b4f168d6dc11c036bc7119a578b6b22d89ae8e505544d68cfbe87b92`.
Per deployment instruction, only `APP_IMAGE` and `WEBSITE_IMAGE` were
changed in the ignored `.env`; the backend tag was supplied explicitly for
this rollout.
- **Live verification:** `/api/health` returned `healthy`,
`/hopreach/api/nodes` returned HTTP 200, and backend/app/website containers
are healthy. The Googlebot Playwright smoke used the real owner route
`https://ukmesh.com/login` (the deployed architecture serves `/login` on
the website host; `app.ukmesh.com` is the map-only build), with a read-only
live grouping payload because no owner MQTT credential is present on the
host. Browser assertions found 2 identity cards, exactly 1 GNOME card, 1
2E0MTU card, 3 and 2 member keys respectively, zero section tabs, and zero
settings UI. Screenshot: `SPRING-DASHBOARD-2026-08-06.png`.
+59
View File
@@ -0,0 +1,59 @@
# UKMesh spring dashboard — 2026-08-06
Items 4, 17, and 18 are implemented and deployed locally on `main`.
## What changed
- Owner dashboard identity data now comes from the canonical
`node_identity_nodes` view and exposes `canonicalId` and all merged/source
`members`. The owner display groups same-name authorized identities as one
entry while retaining an authorized source key for existing live queries.
Member keys and the canonical ID are visible on each entry.
- The owner dashboard no longer has Dashboard/Live/Settings navigation. All
existing identity, telemetry, map, alert, trend, link, sender, heard-by,
and packet sections are stacked on one page.
- The settings UI, its inactive controls, roadmap/"coming soon" payload, and
related dead CSS were removed. The backend alert API and worker remain as an
active operational delivery path.
## Verification
Live owner grouping from the deployed backend produced exactly two relevant
entries: one `GNOME-MSG-RPT` entry with 3 member keys and one `2E0MTU RPT
Hilperton` entry with 2 member keys. The screenshot
[SPRING-DASHBOARD-2026-08-06.png](./SPRING-DASHBOARD-2026-08-06.png) records the
browser smoke result.
The Googlebot Playwright smoke loaded the real public owner route
`https://ukmesh.com/login`. This is the owner route in the deployed build;
`https://app.ukmesh.com` is the map-only app build. Because no owner MQTT
credential is available on this host, the smoke used the read-only live
grouping payload from production and intercepted only the authenticated session
and live requests; the deployed public JS/CSS and route were loaded from the
real site. Assertions found 2 identity cards, 1 GNOME card, 1 2E0MTU card,
member counts 3/2, zero section tabs, and zero settings UI.
Quality gates:
- Backend `npx tsc --noEmit`: pass.
- Backend `npm test`: 273/273 pass (271 existing tests plus 2 grouping tests;
no pre-existing failures).
- Frontend `npx tsc --noEmit`: pass.
- Frontend `npm test`: 76/76 pass.
- Frontend `npm run lint:css`: pass.
- Frontend `npm run build`: pass; only existing large-chunk warnings.
- `GET http://127.0.0.1:3000/api/health`: `healthy`.
- `GET http://127.0.0.1:3000/hopreach/api/nodes`: HTTP `200`.
## Deployment
Deployed services use the spring-dash image tags and are healthy:
- Backend: `sha256:6a11941684f87f029bbcf98b50ec2764f92ede5ea6f5f8d9c029ba7a0cac1164`
- App: `sha256:30b286f26b8fdf1b8a04a41585475822bd80658a9057c627c4df626370fc12b2`
- Website: `sha256:96ab70c6b4f168d6dc11c036bc7119a578b6b22d89ae8e505544d68cfbe87b92`
Only `APP_IMAGE` and `WEBSITE_IMAGE` were updated in the ignored `.env`, as
required. The backend image was supplied explicitly for this rollout without
altering other `.env` lines. The implementation commit hash is added to this
record immediately after the local code commit.
Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

+1 -3
View File
@@ -17,9 +17,7 @@ import {
} from '../../repositories/ownerAlerts.js';
type OwnerDashboard = {
totals: {
ownedNodes: number;
};
nodes: unknown[];
};
type OwnerLiveCacheEntry = {
+21 -70
View File
@@ -5,6 +5,7 @@ import { getOwnerNodeIdsForUsername } from '../db/ownerAuth.js';
import { query } from '../db/index.js';
import { getNodeIdsForUserInAcl, readAclFile } from '../mqtt/aclManager.js';
import { reconcileOwnerAuthorization } from './ownerAclReconciler.js';
import { groupOwnerNodes, type OwnerDashboardRow } from './ownerDashboard.js';
import { parseOwnerGrantConfig } from './ownerGrantConfig.js';
function normalizeNodeIds(nodeIds: string[]): string[] {
@@ -155,75 +156,25 @@ function verifyMqttCredentialsViaBroker(mqttUsername: string, mqttPassword: stri
}
export async function buildOwnerDashboard(nodeIds: string[]) {
if (nodeIds.length < 1) {
return {
nodes: [],
totals: {
ownedNodes: 0,
packets24h: 0,
packets7d: 0,
packetsReceived24h: 0,
},
roadmap: [
'Per-node packet history for owner nodes',
'Advert and heartbeat trend views',
'RSSI and SNR trend views from observer reports',
'Node placement planner (coming next)',
],
};
}
const ownedNodes = await query<OwnerDashboardRow>(
`SELECT n.node_id AS canonical_id,
n.name,
n.network,
n.last_seen::text,
n.advert_count,
n.lat,
n.lon,
n.iata,
n.role,
n.identity_source_ids AS members
FROM node_identity_nodes n
WHERE n.node_id IN (
SELECT meshcore_canonical_node_id(source_node_id)
FROM unnest($1::text[]) AS source(source_node_id)
)
ORDER BY n.last_seen DESC NULLS LAST`,
[nodeIds],
);
const [ownedNodes, packetSummary, rxSummary] = await Promise.all([
query<{
node_id: string;
name: string | null;
network: string;
last_seen: string | null;
advert_count: number | null;
lat: number | null;
lon: number | null;
iata: string | null;
}>(
`SELECT node_id, name, network, last_seen, advert_count, lat, lon, iata
FROM nodes
WHERE node_id = ANY($1::text[])
ORDER BY last_seen DESC NULLS LAST`,
[nodeIds],
),
query<{ packets_24h: number; packets_7d: number }>(
`SELECT
COUNT(*) FILTER (WHERE time > NOW() - INTERVAL '24 hours')::int AS packets_24h,
COUNT(*) FILTER (WHERE time > NOW() - INTERVAL '7 days')::int AS packets_7d
FROM packets
WHERE src_node_id = ANY($1::text[])`,
[nodeIds],
),
query<{ packets_24h: number }>(
`SELECT
COUNT(*) FILTER (WHERE time > NOW() - INTERVAL '24 hours')::int AS packets_24h
FROM packets
WHERE rx_node_id = ANY($1::text[])`,
[nodeIds],
),
]);
return {
nodes: ownedNodes.rows.map((row) => ({
...row,
last_seen: row.last_seen ? new Date(row.last_seen).toISOString() : null,
advert_count: Number(row.advert_count ?? 0),
})),
totals: {
ownedNodes: ownedNodes.rows.length,
packets24h: Number(packetSummary.rows[0]?.packets_24h ?? 0),
packets7d: Number(packetSummary.rows[0]?.packets_7d ?? 0),
packetsReceived24h: Number(rxSummary.rows[0]?.packets_24h ?? 0),
},
roadmap: [
'Per-node packet history for owner nodes',
'Advert and heartbeat trend views',
'RSSI and SNR trend views from observer reports',
'Node placement planner (coming next)',
],
};
return { nodes: groupOwnerNodes(ownedNodes.rows, nodeIds) };
}
+73
View File
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { groupOwnerNodes, type OwnerDashboardRow } from './ownerDashboard.js';
const row = (overrides: Partial<OwnerDashboardRow>): OwnerDashboardRow => ({
canonical_id: 'A1'.repeat(32),
name: null,
network: 'ukmesh',
last_seen: null,
advert_count: 0,
lat: null,
lon: null,
iata: null,
role: 2,
members: [],
...overrides,
});
test('owner dashboard groups same-name identities and preserves every member key', () => {
const olderKey = 'B2'.repeat(32);
const newerKey = 'C3'.repeat(32);
const gnomeCanonical = 'D4'.repeat(32);
const gnomeRotation = 'E5'.repeat(32);
const nodes = groupOwnerNodes([
row({
canonical_id: olderKey,
name: '2E0MTU RPT Hilperton',
members: [olderKey],
last_seen: '2026-07-25T12:00:00Z',
advert_count: 6,
}),
row({
canonical_id: newerKey,
name: ' 2E0MTU RPT Hilperton ',
members: [newerKey],
last_seen: '2026-07-31T12:00:00Z',
advert_count: 3,
}),
row({
canonical_id: gnomeCanonical,
name: 'GNOME-MSG-RPT',
members: [gnomeCanonical, gnomeRotation],
last_seen: '2026-08-06T12:00:00Z',
advert_count: 10,
}),
], [olderKey, gnomeRotation]);
assert.equal(nodes.length, 2);
const hilperton = nodes.find((node) => node.name === ' 2E0MTU RPT Hilperton ' || node.name === '2E0MTU RPT Hilperton');
assert.ok(hilperton);
assert.equal(hilperton.node_id, olderKey);
assert.equal(hilperton.canonicalId, newerKey);
assert.deepEqual(hilperton.members, [olderKey, newerKey].sort());
assert.equal(hilperton.advert_count, 9);
const gnome = nodes.find((node) => node.name === 'GNOME-MSG-RPT');
assert.ok(gnome);
assert.equal(gnome.node_id, gnomeRotation);
assert.deepEqual(gnome.members, [gnomeCanonical, gnomeRotation].sort());
});
test('owner dashboard keeps unnamed canonical identities separate', () => {
const first = 'F6'.repeat(32);
const second = '07'.repeat(32);
const nodes = groupOwnerNodes([
row({ canonical_id: first, members: [first] }),
row({ canonical_id: second, members: [second] }),
], [first, second]);
assert.deepEqual(nodes.map((node) => node.canonicalId).sort(), [first, second].sort());
assert.deepEqual(nodes.map((node) => node.members), [[first], [second]].sort());
});
+110
View File
@@ -0,0 +1,110 @@
export type OwnerDashboardRow = {
canonical_id: string;
name: string | null;
network: string;
last_seen: string | null;
advert_count: number | null;
lat: number | null;
lon: number | null;
iata: string | null;
role: number | null;
members: string[];
};
export type OwnerDashboardNode = {
// Keep an authorized source key here so existing owner live endpoints can
// continue to authorize the selected entry without broadening access.
node_id: string;
canonicalId: string;
members: string[];
name: string | null;
network: string;
last_seen: string | null;
advert_count: number;
lat: number | null;
lon: number | null;
iata: string | null;
role: number | null;
};
function normalizedKey(value: string): string {
return value.trim().toUpperCase();
}
function normalizedName(value: string | null): string | null {
const normalized = value?.trim().replace(/\s+/g, ' ').toUpperCase() ?? '';
return normalized || null;
}
function timestampMs(value: string | null): number {
if (!value) return Number.NEGATIVE_INFINITY;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : Number.NEGATIVE_INFINITY;
}
function compareRows(left: OwnerDashboardRow, right: OwnerDashboardRow): number {
return timestampMs(right.last_seen) - timestampMs(left.last_seen)
|| Number(right.advert_count ?? 0) - Number(left.advert_count ?? 0)
|| normalizedKey(left.canonical_id).localeCompare(normalizedKey(right.canonical_id));
}
/**
* Converts canonical identity rows into the owner-facing display list.
*
* The canonical view normally provides one row per identity. Exact same-name
* rows are also combined here because owner authorization can retain older
* keys that the evidence-based global merge intentionally leaves separate.
* Their keys stay visible in `members`, while the freshest row supplies the
* display position and metadata.
*/
export function groupOwnerNodes(
rows: OwnerDashboardRow[],
authorizedNodeIds: string[],
): OwnerDashboardNode[] {
const authorized = authorizedNodeIds.map(normalizedKey);
const groups = new Map<string, OwnerDashboardRow[]>();
for (const row of rows) {
const canonicalId = normalizedKey(row.canonical_id);
const displayName = normalizedName(row.name);
const groupKey = displayName ? `name:${displayName}` : `canonical:${canonicalId}`;
const group = groups.get(groupKey) ?? [];
group.push({
...row,
canonical_id: canonicalId,
members: Array.from(new Set([
canonicalId,
...(Array.isArray(row.members) ? row.members : []).map(normalizedKey),
])).filter(Boolean),
});
groups.set(groupKey, group);
}
const result = Array.from(groups.values()).map((group) => {
const ordered = [...group].sort(compareRows);
const representative = ordered[0]!;
const members = Array.from(new Set(group.flatMap((row) => row.members))).sort();
const accessNodeId = authorized.find((nodeId) => members.includes(nodeId))
?? representative.canonical_id;
return {
node_id: accessNodeId,
canonicalId: representative.canonical_id,
members,
name: representative.name,
network: representative.network,
last_seen: representative.last_seen,
advert_count: group.reduce((sum, row) => sum + Number(row.advert_count ?? 0), 0),
lat: representative.lat,
lon: representative.lon,
iata: representative.iata,
role: representative.role,
} satisfies OwnerDashboardNode;
});
return result.sort((left, right) => (
timestampMs(right.last_seen) - timestampMs(left.last_seen)
|| (left.name ?? '').localeCompare(right.name ?? '')
|| left.canonicalId.localeCompare(right.canonicalId)
));
}
+3 -1
View File
@@ -229,8 +229,10 @@ export function createOwnerRepository(deps: OwnerRepositoryDeps) {
lat: number | null;
lon: number | null;
role: number | null;
members: string[];
}>(
`SELECT node_id, name, network, iata, advert_count, last_seen, lat, lon, role
`SELECT node_id, name, network, iata, advert_count, last_seen, lat, lon, role,
identity_source_ids AS members
FROM node_identity_nodes
WHERE node_id = meshcore_canonical_node_id($1)
LIMIT 1`,
+5 -5
View File
@@ -3,9 +3,7 @@ import type { OwnerSession } from './ownerSession.js';
import { BoundedTtlMap } from '../cache/boundedTtlMap.js';
type OwnerDashboard = {
totals: {
ownedNodes: number;
};
nodes: unknown[];
} & Record<string, unknown>;
type OwnerLiveCacheEntry = {
@@ -154,7 +152,7 @@ export function createOwnerService(deps: OwnerServiceDeps) {
const mappedNodeIds = await autoLinkOwnerNodeIds(mqttUsername);
const dashboard = await buildOwnerDashboard(mappedNodeIds);
if (dashboard.totals.ownedNodes < 1) {
if (dashboard.nodes.length < 1) {
throw new Error('NO_ACTIVE_OWNER_NODE');
}
@@ -183,7 +181,7 @@ export function createOwnerService(deps: OwnerServiceDeps) {
}
const dashboard = await buildOwnerDashboard(freshNodeIds);
if (dashboard.totals.ownedNodes < 1) {
if (dashboard.nodes.length < 1) {
throw new Error('NO_ACTIVE_OWNER_NODE');
}
ownerDashboardCache.set(cacheKey, { ts: Date.now(), dashboard, nodeIds: freshNodeIds });
@@ -412,6 +410,8 @@ export function createOwnerService(deps: OwnerServiceDeps) {
nodeId: selectedNodeId,
ownerNode: {
...ownerNode,
canonicalId: ownerNode.node_id,
members: ownerNode.members,
advert_count: Number(ownerNode.advert_count ?? 0),
last_seen: ownerNode.last_seen ? new Date(ownerNode.last_seen).toISOString() : null,
},
@@ -1,4 +1,4 @@
import { FormEvent, ReactNode, useEffect, useState } from 'react';
import { type FormEvent } from 'react';
import { LoadingIndicator } from '../LoadingIndicator.js';
export function OwnerLoginSection(props: {
@@ -27,210 +27,3 @@ export function OwnerLoginSection(props: {
</section>
);
}
export function OwnerSection({ children }: { children: ReactNode }) {
return <>{children}</>;
}
type Rule = {
id: string;
node_id: string;
rule_type: string;
threshold: number;
enabled: boolean;
pause_reason: string | null;
last_delivery_success_at: string | null;
last_delivery_error_at: string | null;
last_delivery_error: string | null;
destination: { configured: boolean; host: string | null };
};
type DeliveryAttempt = {
attempt: number;
outcome: string;
httpStatus: number | null;
error: string | null;
startedAt: string;
completedAt: string;
};
type Delivery = {
id: string;
rule_id: string;
node_id: string;
rule_type: string;
destination_host: string;
status: string;
attempts: number;
is_test: boolean;
next_attempt_at: string;
delivered_at: string | null;
last_error: string | null;
created_at: string;
attempt_history: DeliveryAttempt[];
};
async function ownerCsrf(): Promise<string> {
const response = await fetch('/api/owner/csrf', { cache: 'no-store' });
const value = await response.json() as { csrfToken?: string };
if (!response.ok || !value.csrfToken) throw new Error('Could not prepare secure request');
return value.csrfToken;
}
export function OwnerAlertSettings({ nodes, selectedNodeId }: { nodes: Array<{ node_id: string; name?: string | null }>; selectedNodeId: string }) {
const [rules, setRules] = useState<Rule[]>([]);
const [deliveries, setDeliveries] = useState<Delivery[]>([]);
const [ruleType, setRuleType] = useState('offline_minutes');
const [threshold, setThreshold] = useState('30');
const [webhook, setWebhook] = useState('');
const [message, setMessage] = useState<string | null>(null);
const [busyRuleId, setBusyRuleId] = useState<string | null>(null);
const load = async () => {
const [ruleResponse, deliveryResponse] = await Promise.all([
fetch('/api/owner/alert-rules', { cache: 'no-store' }),
fetch('/api/owner/alert-deliveries', { cache: 'no-store' }),
]);
if (!ruleResponse.ok || !deliveryResponse.ok) throw new Error('Could not load alert delivery state');
const nextRules = await ruleResponse.json() as Rule[];
const history = await deliveryResponse.json() as { deliveries?: Delivery[] };
setRules(Array.isArray(nextRules) ? nextRules : []);
setDeliveries(Array.isArray(history.deliveries) ? history.deliveries : []);
};
useEffect(() => {
void load().catch((error: Error) => setMessage(error.message));
const timer = window.setInterval(() => {
if (document.visibilityState === 'visible') void load().catch(() => {});
}, 15_000);
return () => window.clearInterval(timer);
}, []);
const submit = (event: FormEvent) => {
event.preventDefault();
ownerCsrf().then((csrfToken) => fetch('/api/owner/alert-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ nodeId: selectedNodeId, ruleType, threshold: Number(threshold), webhook, enabled: true }),
})).then(async (response) => {
const value = await response.json().catch(() => ({})) as { error?: string };
if (!response.ok) throw new Error(value.error ?? `HTTP ${response.status}`);
setMessage('Alert rule saved.');
void load();
}).catch((error: Error) => setMessage(error.message));
};
const sendTest = async (ruleId: string) => {
setBusyRuleId(ruleId);
setMessage(null);
try {
const csrfToken = await ownerCsrf();
const response = await fetch(`/api/owner/alert-rules/${ruleId}/test`, {
method: 'POST',
headers: {
'X-CSRF-Token': csrfToken,
'Idempotency-Key': crypto.randomUUID(),
},
});
const value = await response.json().catch(() => ({})) as { error?: string; status?: string };
if (!response.ok) throw new Error(value.error ?? `HTTP ${response.status}`);
setMessage(value.status === 'already_queued'
? 'That test request was already queued.'
: 'Test delivery queued. Its result will appear below.');
await load();
} catch (error) {
setMessage((error as Error).message);
} finally {
setBusyRuleId(null);
}
};
const removeRule = async (ruleId: string) => {
setBusyRuleId(ruleId);
setMessage(null);
try {
const csrfToken = await ownerCsrf();
const response = await fetch(`/api/owner/alert-rules/${ruleId}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken },
});
if (!response.ok) throw new Error(`Could not remove rule (HTTP ${response.status})`);
setMessage('Alert rule removed.');
await load();
} catch (error) {
setMessage((error as Error).message);
} finally {
setBusyRuleId(null);
}
};
return (
<section className="prose-section owner-settings">
<h2>Alert settings</h2>
<p>Create per-node health rules. Delivery is idempotent, retried up to five times, and paused after a terminal failure.</p>
<form className="owner-settings__form" onSubmit={submit}>
<label>Node<select value={selectedNodeId} disabled>{nodes.map((node) => <option key={node.node_id} value={node.node_id}>{node.name ?? node.node_id}</option>)}</select></label>
<label>Condition<select value={ruleType} onChange={(event) => setRuleType(event.target.value)}><option value="offline_minutes">Offline minutes</option><option value="battery_below_mv">Battery below mV</option><option value="link_loss_above_db">Path loss above dB</option></select></label>
<label>Threshold<input type="number" min="1" value={threshold} onChange={(event) => setThreshold(event.target.value)} /></label>
<label>HTTPS webhook (optional)<input type="url" value={webhook} onChange={(event) => setWebhook(event.target.value)} /></label>
<button type="submit" className="site-btn site-btn--primary">Save rule</button>
</form>
{message && <p role="status" aria-live="polite">{message}</p>}
<div className="owner-settings__rules">
{rules.map((rule) => (
<article key={rule.id}>
<div>
<strong>{rule.rule_type.replace(/_/g, ' ')} · {rule.threshold}</strong>
<small>
{rule.destination.configured ? `Webhook: ${rule.destination.host}` : 'No delivery channel'}
{rule.pause_reason ? ` · Paused: ${rule.pause_reason.replace(/_/g, ' ')}` : ''}
</small>
<small>
{rule.last_delivery_success_at
? `Last success ${new Date(rule.last_delivery_success_at).toLocaleString()}`
: rule.last_delivery_error_at
? `Last error ${new Date(rule.last_delivery_error_at).toLocaleString()}`
: 'No delivery attempts yet'}
</small>
</div>
<div className="owner-settings__rule-actions">
<button
type="button"
disabled={!rule.destination.configured || busyRuleId === rule.id}
onClick={() => void sendTest(rule.id)}
>
Send test
</button>
<button
type="button"
disabled={busyRuleId === rule.id}
onClick={() => void removeRule(rule.id)}
>
Remove
</button>
</div>
</article>
))}
</div>
<section className="owner-settings__history" aria-labelledby="owner-delivery-history">
<h3 id="owner-delivery-history">Delivery history</h3>
{deliveries.length === 0 ? <p>No alert deliveries yet.</p> : deliveries.map((delivery) => (
<details key={delivery.id}>
<summary>
<span>{delivery.is_test ? 'Test' : delivery.rule_type.replace(/_/g, ' ')}</span>
<span>{delivery.destination_host}</span>
<span className={`owner-delivery-status owner-delivery-status--${delivery.status}`}>{delivery.status.replace(/_/g, ' ')}</span>
</summary>
<p>
Node {delivery.node_id.slice(0, 12)} · {delivery.attempts} attempt{delivery.attempts === 1 ? '' : 's'} ·
{' '}{new Date(delivery.created_at).toLocaleString()}
</p>
{delivery.last_error && <p role="alert">{delivery.last_error}</p>}
{delivery.attempt_history.length > 0 && (
<ol>
{delivery.attempt_history.map((attempt) => (
<li key={attempt.attempt}>
Attempt {attempt.attempt}: {attempt.outcome}
{attempt.httpStatus ? ` (HTTP ${attempt.httpStatus})` : ''}
{attempt.error ? `${attempt.error}` : ''}
</li>
))}
</ol>
)}
</details>
))}
</section>
</section>
);
}
+29 -15
View File
@@ -1,7 +1,7 @@
import React, { FormEvent, useEffect, useMemo, useState } from 'react';
import './owner-portal.css';
import { LoadingIndicator } from '../components/LoadingIndicator.js';
import { OwnerAlertSettings, OwnerLoginSection, OwnerSection } from '../components/owner/OwnerPortalSections.js';
import { OwnerLoginSection } from '../components/owner/OwnerPortalSections.js';
import { useRuntimeFeatures } from '../config/runtimeFeatures.js';
import { useVisibilityPoll } from '../hooks/useVisibilityPoll.js';
import { ApiResponseError, fetchJson } from '../utils/api.js';
@@ -51,7 +51,6 @@ export const OwnerPortalPage: React.FC = () => {
const [live, setLive] = useState<OwnerLiveResponse | null>(null);
const [liveError, setLiveError] = useState<string | null>(null);
const [lastHopStrength, setLastHopStrength] = useState<LastHopStrengthPoint[]>([]);
const [activeSection, setActiveSection] = useState<'dashboard' | 'live' | 'settings'>('dashboard');
const [ownerSessionKey, setOwnerSessionKey] = useState<string | null>(null);
const clearOwnerSession = () => {
@@ -294,12 +293,12 @@ export const OwnerPortalPage: React.FC = () => {
{!loading && dashboard ? (
<>
<nav className="owner-section-tabs" aria-label="Owner portal sections">
{(['dashboard', 'live', 'settings'] as const).map((section) => <button key={section} type="button" aria-pressed={activeSection === section} onClick={() => setActiveSection(section)}>{section}</button>)}
</nav>
{activeSection === 'dashboard' && <OwnerSection><section className="prose-section">
<section className="prose-section">
<div className="owner-head">
<h2>Dashboard</h2>
<div>
<h1>Repeater owner dashboard</h1>
<p className="prose-note">Live status, identity history, telemetry, and packet activity for your owned nodes.</p>
</div>
<button type="button" className="site-btn site-btn--ghost" onClick={handleLogout}>
Logout
</button>
@@ -314,13 +313,31 @@ export const OwnerPortalPage: React.FC = () => {
onChange={(e) => setSelectedNodeId(e.target.value)}
>
{dashboard.nodes.map((node) => (
<option key={node.node_id} value={node.node_id}>
{node.name ?? node.node_id}
<option key={node.canonicalId} value={node.node_id}>
{node.name ?? node.canonicalId}
</option>
))}
</select>
</div>
) : null}
<div className="owner-node-identities" aria-label="Owned repeater identities">
{dashboard.nodes.map((node) => (
<article
key={node.canonicalId}
className={`owner-node-identity${node.node_id === selectedNodeId ? ' owner-node-identity--selected' : ''}`}
data-canonical-id={node.canonicalId}
>
<div className="owner-node-identity__head">
<strong>{node.name ?? 'Unnamed node'}</strong>
<span>{node.members.length} member key{node.members.length === 1 ? '' : 's'}</span>
</div>
<p className="owner-node-identity__canonical">Canonical ID <code>{node.canonicalId}</code></p>
<ul className="owner-node-identity__members" aria-label={`Member keys for ${node.name ?? node.canonicalId}`}>
{node.members.map((member) => <li key={member}><code>{member}</code></li>)}
</ul>
</article>
))}
</div>
<div className="site-stats-grid site-stats-grid--6 owner-summary-grid">
<div className="site-stat"><span className="site-stat__value">{live?.ownerNode.name ?? 'Unnamed'}</span><span className="site-stat__label">{nodeRoleLabel(live?.ownerNode.role ?? null)}</span></div>
<div className="site-stat"><span className="site-stat__value">{live?.ownerNode.network ?? '-'}</span><span className="site-stat__label">Network</span></div>
@@ -336,9 +353,9 @@ export const OwnerPortalPage: React.FC = () => {
<div className="site-stat"><span className="site-stat__value">{live?.packetsReceived24h ?? 0}</span><span className="site-stat__label">Packets Received (24h)</span></div>
</div>
{liveError ? <p className="prose-note owner-login__error">Live data error: {liveError}</p> : null}
</section></OwnerSection>}
</section>
{activeSection === 'live' && <OwnerSection><section className="owner-panel owner-telemetry-panel">
<section className="owner-panel owner-telemetry-panel">
<div className="owner-panel__head">
<div>
<h2>Node Telemetry</h2>
@@ -488,10 +505,7 @@ export const OwnerPortalPage: React.FC = () => {
) : null}
</div>
</section>
</div></OwnerSection>}
{activeSection === 'settings' && <OwnerAlertSettings nodes={dashboard.nodes} selectedNodeId={selectedNodeId} />}
</div>
</>
) : null}
</div>
+55 -78
View File
@@ -315,46 +315,6 @@
font-size: 11px;
font-family: var(--font-mono);
}
.owner-table-wrap {
width: 100%;
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.owner-table {
width: 100%;
min-width: 820px;
border-collapse: collapse;
}
.owner-table td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.owner-table th {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
vertical-align: top;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.owner-table td code {
font-family: var(--font-mono);
color: var(--text-primary);
}
.owner-roadmap {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.owner-roadmap__item {
padding: 16px;
}
.owner-select {
margin: 12px 0 16px;
display: flex;
@@ -373,6 +333,60 @@
color: var(--text-primary);
padding: 8px 10px;
}
.owner-node-identities {
display: grid;
gap: 10px;
margin: 16px 0;
}
.owner-node-identity {
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-panel-alt);
}
.owner-node-identity--selected {
border-color: rgba(0, 196, 255, 0.55);
}
.owner-node-identity__head {
display: flex;
justify-content: space-between;
gap: 12px;
}
.owner-node-identity__head strong {
color: var(--text-primary);
overflow-wrap: anywhere;
}
.owner-node-identity__head span {
color: var(--text-secondary);
font-size: 11px;
}
.owner-node-identity__canonical {
color: var(--text-secondary);
font-size: 11px;
margin: 6px 0 0;
font-family: var(--font-mono);
overflow-wrap: anywhere;
}
.owner-node-identity__members {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 8px 0 0;
padding: 0;
}
.owner-node-identity__members li {
min-width: 0;
}
.owner-node-identity__members code {
display: block;
padding: 3px 6px;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-primary);
font-size: 10px;
overflow-wrap: anywhere;
}
.owner-map-wrap {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
@@ -432,49 +446,12 @@
font-size: 13px;
line-height: 1.45;
}
.owner-section-tabs {
position: sticky;
top: 0;
z-index: 5;
display: flex;
gap: 8px;
padding: 10px 0;
background: var(--bg-base);
}
.owner-section-tabs button {
flex: 1;
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 10px;
background: var(--bg-panel);
color: var(--text-secondary);
text-transform: capitalize;
}
.owner-section-tabs button[aria-pressed='true'] { border-color: var(--accent); color: var(--text-primary); }
.owner-settings__form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.owner-settings__form label { display: grid; gap: 4px; }
.owner-settings__form input, .owner-settings__form select { min-width: 0; padding: 10px; background: var(--bg-panel); border: 1px solid var(--border); color: var(--text-primary); }
.owner-settings__rules article { display: flex; justify-content: space-between; gap: 10px; padding: 10px; border-bottom: 1px solid var(--border); }
.owner-settings__rules article > div:first-child { display: grid; gap: 3px; min-width: 0; }
.owner-settings__rules article small { color: var(--text-muted); font-size: 0.75rem; overflow-wrap: anywhere; }
.owner-settings__rule-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.owner-settings__rule-actions button:disabled { cursor: not-allowed; opacity: 0.45; }
.owner-settings__history { margin-top: 24px; }
.owner-settings__history details { border-top: 1px solid var(--border); padding: 9px 0; }
.owner-settings__history summary { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; gap: 8px; cursor: pointer; }
.owner-settings__history p,
.owner-settings__history ol { color: var(--text-muted); font-size: 0.8rem; }
.owner-delivery-status { border-radius: 999px; padding: 2px 7px; background: var(--bg-active); text-transform: capitalize; }
.owner-delivery-status--succeeded { color: var(--online); }
.owner-delivery-status--failed,
.owner-delivery-status--dead_lettered { color: var(--danger); }
@media (max-width: 700px) {
.site-prose--wide { padding-inline: 12px; }
.owner-summary-grid.site-stats-grid--6 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.owner-dashboard-grid { grid-template-columns: 1fr; grid-auto-rows: auto; }
.owner-panel { min-height: 300px; }
.owner-telemetry-strip { grid-template-columns: 1fr; }
.owner-settings__form { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.owner-summary-grid { grid-template-columns: 1fr; }
@@ -486,8 +463,8 @@
.owner-list__row { grid-template-columns: 1fr; }
.owner-list__metrics { justify-content: flex-start; }
.owner-telemetry-strip { grid-template-columns: 1fr; }
.owner-roadmap { 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; }
}
@media (max-width: 1180px) {
@@ -39,8 +39,32 @@ test('owner response guards reject structurally incomplete payloads', () => {
ok: true,
dashboard: {
nodes: [],
totals: {},
roadmap: [],
},
}), true);
assert.equal(isOwnerSessionResponse({
ok: true,
dashboard: {
nodes: [{
node_id: 'A'.repeat(64),
canonicalId: 'A'.repeat(64),
members: ['A'.repeat(64)],
}],
},
}), true);
assert.equal(isOwnerSessionResponse({
ok: true,
dashboard: {
nodes: [{ node_id: 'A'.repeat(64) }],
},
}), false);
assert.equal(isOwnerSessionResponse({
ok: true,
dashboard: {
nodes: [{
node_id: 'A'.repeat(64),
canonicalId: 'A'.repeat(64),
members: [42],
}],
},
}), false);
});
+7 -9
View File
@@ -3,6 +3,8 @@ import { ScopedCache } from '../../utils/scopedCache.js';
export type OwnerNode = {
node_id: string;
canonicalId: string;
members: string[];
name: string | null;
network: string;
last_seen: string | null;
@@ -21,13 +23,6 @@ export function nodeRoleLabel(role: number | null): string {
export type OwnerDashboard = {
nodes: OwnerNode[];
totals: {
ownedNodes: number;
packets24h: number;
packets7d: number;
packetsReceived24h: number;
};
roadmap: string[];
};
export type OwnerSessionResponse = {
@@ -183,8 +178,11 @@ export function isOwnerSessionResponse(value: unknown): value is OwnerSessionRes
if (!isRecord(value) || value['ok'] !== true || !isRecord(value['dashboard'])) return false;
const dashboard = value['dashboard'];
return Array.isArray(dashboard['nodes'])
&& isRecord(dashboard['totals'])
&& Array.isArray(dashboard['roadmap'])
&& dashboard['nodes'].every((node) => isRecord(node)
&& typeof node['node_id'] === 'string'
&& typeof node['canonicalId'] === 'string'
&& Array.isArray(node['members'])
&& node['members'].every((member) => typeof member === 'string'))
&& (value['mqttUsername'] == null || typeof value['mqttUsername'] === 'string');
}
+7 -8
View File
@@ -5,6 +5,8 @@ const NODE_ID = 'A'.repeat(64);
const dashboard = {
nodes: [{
node_id: NODE_ID,
canonicalId: NODE_ID,
members: [NODE_ID],
name: 'Alpha Repeater',
network: 'ukmesh',
last_seen: '2026-07-16T10:00:00Z',
@@ -14,13 +16,6 @@ const dashboard = {
iata: 'TST',
role: 2,
}],
totals: {
ownedNodes: 1,
packets24h: 25,
packets7d: 100,
packetsReceived24h: 20,
},
roadmap: [],
};
test('session polling does not reset the repeater owner content', async ({ page }) => {
@@ -62,6 +57,8 @@ test('session polling does not reset the repeater owner content', async ({ page
await page.goto('/login');
await expect(page.getByText('Alpha Repeater', { exact: true })).toBeVisible();
await expect(page.locator('.owner-section-tabs')).toHaveCount(0);
await expect(page.locator('.owner-settings')).toHaveCount(0);
const initialSessionRequests = sessionRequests;
const initialLiveRequests = liveRequests;
@@ -119,8 +116,10 @@ test('owner map construction remains one across repeated live polls', async ({ p
await page.route('**/api/owner/live-last-hop?**', (route) => route.fulfill({ json: { points: [] } }));
await page.goto('/login');
await page.getByRole('button', { name: 'live', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Direct Sender Map' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Node Telemetry' })).toBeVisible();
await expect(page.locator('.owner-section-tabs')).toHaveCount(0);
await expect(page.locator('.owner-settings')).toHaveCount(0);
await expect(page.locator('.owner-map')).toHaveCount(1);
await expect.poll(() => page.evaluate(() => (
(window as typeof window & { __ownerMapLifecycle: { constructions: number } })