diff --git a/PLAN-spring-clean-2026-08-06.md b/PLAN-spring-clean-2026-08-06.md new file mode 100644 index 0000000..6129dcd --- /dev/null +++ b/PLAN-spring-clean-2026-08-06.md @@ -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 4–5 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`. diff --git a/SPRING-DASHBOARD-2026-08-06.md b/SPRING-DASHBOARD-2026-08-06.md new file mode 100644 index 0000000..b82c631 --- /dev/null +++ b/SPRING-DASHBOARD-2026-08-06.md @@ -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. diff --git a/SPRING-DASHBOARD-2026-08-06.png b/SPRING-DASHBOARD-2026-08-06.png new file mode 100644 index 0000000..e700443 Binary files /dev/null and b/SPRING-DASHBOARD-2026-08-06.png differ diff --git a/backend/src/api/routes/owner.ts b/backend/src/api/routes/owner.ts index 342883d..5ed5b52 100644 --- a/backend/src/api/routes/owner.ts +++ b/backend/src/api/routes/owner.ts @@ -17,9 +17,7 @@ import { } from '../../repositories/ownerAlerts.js'; type OwnerDashboard = { - totals: { - ownedNodes: number; - }; + nodes: unknown[]; }; type OwnerLiveCacheEntry = { diff --git a/backend/src/owner/ownerAccess.ts b/backend/src/owner/ownerAccess.ts index d6aa86a..f37a78a 100644 --- a/backend/src/owner/ownerAccess.ts +++ b/backend/src/owner/ownerAccess.ts @@ -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( + `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) }; } diff --git a/backend/src/owner/ownerDashboard.test.ts b/backend/src/owner/ownerDashboard.test.ts new file mode 100644 index 0000000..68015b8 --- /dev/null +++ b/backend/src/owner/ownerDashboard.test.ts @@ -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 => ({ + 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()); +}); diff --git a/backend/src/owner/ownerDashboard.ts b/backend/src/owner/ownerDashboard.ts new file mode 100644 index 0000000..0706ad3 --- /dev/null +++ b/backend/src/owner/ownerDashboard.ts @@ -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(); + + 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) + )); +} diff --git a/backend/src/owner/ownerRepository.ts b/backend/src/owner/ownerRepository.ts index 3f9f3a6..07a7d29 100644 --- a/backend/src/owner/ownerRepository.ts +++ b/backend/src/owner/ownerRepository.ts @@ -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`, diff --git a/backend/src/owner/ownerService.ts b/backend/src/owner/ownerService.ts index 2397b61..8238717 100644 --- a/backend/src/owner/ownerService.ts +++ b/backend/src/owner/ownerService.ts @@ -3,9 +3,7 @@ import type { OwnerSession } from './ownerSession.js'; import { BoundedTtlMap } from '../cache/boundedTtlMap.js'; type OwnerDashboard = { - totals: { - ownedNodes: number; - }; + nodes: unknown[]; } & Record; 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, }, diff --git a/frontend/src/components/owner/OwnerPortalSections.tsx b/frontend/src/components/owner/OwnerPortalSections.tsx index 9e2d04b..809827c 100644 --- a/frontend/src/components/owner/OwnerPortalSections.tsx +++ b/frontend/src/components/owner/OwnerPortalSections.tsx @@ -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: { ); } - -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 { - 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([]); - const [deliveries, setDeliveries] = useState([]); - const [ruleType, setRuleType] = useState('offline_minutes'); - const [threshold, setThreshold] = useState('30'); - const [webhook, setWebhook] = useState(''); - const [message, setMessage] = useState(null); - const [busyRuleId, setBusyRuleId] = useState(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 ( -
-

Alert settings

-

Create per-node health rules. Delivery is idempotent, retried up to five times, and paused after a terminal failure.

-
- - - - - -
- {message &&

{message}

} -
- {rules.map((rule) => ( -
-
- {rule.rule_type.replace(/_/g, ' ')} · {rule.threshold} - - {rule.destination.configured ? `Webhook: ${rule.destination.host}` : 'No delivery channel'} - {rule.pause_reason ? ` · Paused: ${rule.pause_reason.replace(/_/g, ' ')}` : ''} - - - {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'} - -
-
- - -
-
- ))} -
-
-

Delivery history

- {deliveries.length === 0 ?

No alert deliveries yet.

: deliveries.map((delivery) => ( -
- - {delivery.is_test ? 'Test' : delivery.rule_type.replace(/_/g, ' ')} - {delivery.destination_host} - {delivery.status.replace(/_/g, ' ')} - -

- Node {delivery.node_id.slice(0, 12)}… · {delivery.attempts} attempt{delivery.attempts === 1 ? '' : 's'} · - {' '}{new Date(delivery.created_at).toLocaleString()} -

- {delivery.last_error &&

{delivery.last_error}

} - {delivery.attempt_history.length > 0 && ( -
    - {delivery.attempt_history.map((attempt) => ( -
  1. - Attempt {attempt.attempt}: {attempt.outcome} - {attempt.httpStatus ? ` (HTTP ${attempt.httpStatus})` : ''} - {attempt.error ? ` — ${attempt.error}` : ''} -
  2. - ))} -
- )} -
- ))} -
-
- ); -} diff --git a/frontend/src/pages/OwnerPortalPage.tsx b/frontend/src/pages/OwnerPortalPage.tsx index f9cb779..2cc62a2 100644 --- a/frontend/src/pages/OwnerPortalPage.tsx +++ b/frontend/src/pages/OwnerPortalPage.tsx @@ -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(null); const [liveError, setLiveError] = useState(null); const [lastHopStrength, setLastHopStrength] = useState([]); - const [activeSection, setActiveSection] = useState<'dashboard' | 'live' | 'settings'>('dashboard'); const [ownerSessionKey, setOwnerSessionKey] = useState(null); const clearOwnerSession = () => { @@ -294,12 +293,12 @@ export const OwnerPortalPage: React.FC = () => { {!loading && dashboard ? ( <> - - {activeSection === 'dashboard' &&
+
-

Dashboard

+
+

Repeater owner dashboard

+

Live status, identity history, telemetry, and packet activity for your owned nodes.

+
@@ -314,13 +313,31 @@ export const OwnerPortalPage: React.FC = () => { onChange={(e) => setSelectedNodeId(e.target.value)} > {dashboard.nodes.map((node) => ( - ))}
) : null} +
+ {dashboard.nodes.map((node) => ( +
+
+ {node.name ?? 'Unnamed node'} + {node.members.length} member key{node.members.length === 1 ? '' : 's'} +
+

Canonical ID {node.canonicalId}

+
    + {node.members.map((member) =>
  • {member}
  • )} +
+
+ ))} +
{live?.ownerNode.name ?? 'Unnamed'}{nodeRoleLabel(live?.ownerNode.role ?? null)}
{live?.ownerNode.network ?? '-'}Network
@@ -336,9 +353,9 @@ export const OwnerPortalPage: React.FC = () => {
{live?.packetsReceived24h ?? 0}Packets Received (24h)
{liveError ?

Live data error: {liveError}

: null} -
} +
- {activeSection === 'live' &&
+

Node Telemetry

@@ -488,10 +505,7 @@ export const OwnerPortalPage: React.FC = () => { ) : null}
- } - - {activeSection === 'settings' && } - + ) : null} diff --git a/frontend/src/pages/owner-portal.css b/frontend/src/pages/owner-portal.css index b039b51..e4e3f63 100644 --- a/frontend/src/pages/owner-portal.css +++ b/frontend/src/pages/owner-portal.css @@ -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) { diff --git a/frontend/src/pages/owner/ownerPortalModel.test.ts b/frontend/src/pages/owner/ownerPortalModel.test.ts index db0378f..72483f9 100644 --- a/frontend/src/pages/owner/ownerPortalModel.test.ts +++ b/frontend/src/pages/owner/ownerPortalModel.test.ts @@ -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); }); diff --git a/frontend/src/pages/owner/ownerPortalModel.ts b/frontend/src/pages/owner/ownerPortalModel.ts index fe1d89c..ac7dece 100644 --- a/frontend/src/pages/owner/ownerPortalModel.ts +++ b/frontend/src/pages/owner/ownerPortalModel.ts @@ -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'); } diff --git a/frontend/test/e2e/owner.spec.ts b/frontend/test/e2e/owner.spec.ts index 0627864..a7efc03 100644 --- a/frontend/test/e2e/owner.spec.ts +++ b/frontend/test/e2e/owner.spec.ts @@ -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 } })