mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-09-01 17:08:17 +00:00
feat(owner): clean up spring dashboard
This commit is contained in:
@@ -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`.
|
||||
@@ -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 |
@@ -17,9 +17,7 @@ import {
|
||||
} from '../../repositories/ownerAlerts.js';
|
||||
|
||||
type OwnerDashboard = {
|
||||
totals: {
|
||||
ownedNodes: number;
|
||||
};
|
||||
nodes: unknown[];
|
||||
};
|
||||
|
||||
type OwnerLiveCacheEntry = {
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
Reference in New Issue
Block a user