mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-10 14:25:46 +00:00
## What
Adds an **Export JSON** button to the Nodes topbar that downloads the
currently visible node list as a MeshCore companion-app config file:
```json
{
"contacts": [
{
"type": 2,
"name": "BE-MGU-RP03 | ON7YT",
"custom_name": null,
"public_key": "7a7a37d4819fb27440ed1439ca7d281fdecceb83b27e61a69745feb004d726a4",
"flags": 0,
"latitude": "51.07307",
"longitude": "5.5796",
"last_advert": 1786545431,
"last_modified": 1786545431,
"out_path_list": null
}
]
}
```
That is the exact shape the companion app itself writes, so the file
imports straight back into a companion app as contacts. The practical
use case is per-area: pick an area in the area filter, export, and hand
someone the repeaters for that region instead of having them wait for
adverts.
## Scope of the export
WYSIWYG — the button exports the rows the table is showing, in the
table's current order, so area, region, role tab, search, last-heard and
status filters all carry over. The button label shows how many contacts
the file will contain and is disabled when that count is zero.
## Field mapping
| JSON field | Source | Notes |
|---|---|---|
| `type` | `node.role` | repeater→2, companion→1, room→3, sensor→4;
unknown/empty→1 |
| `name` | `node.name` | unchanged, emoji included |
| `custom_name` | — | always `null` |
| `public_key` | `node.public_key` | full 64-hex |
| `flags` | — | always `0` |
| `latitude` / `longitude` | `node.lat` / `node.lon` | stringified, as
the format expects |
| `last_advert` | `node.last_seen` | RFC3339 → unix seconds |
| `last_modified` | mirrors `last_advert` | no separate source exists |
| `out_path_list` | — | always `null`; the companion app discovers
routes itself |
Nodes are skipped when they have no name, a pubkey shorter than 64 hex
chars, or no usable position (missing, non-numeric, or null island).
Filename: `corescope_nodes_<area|all>_YYYY-MM-DD-HHMMSS.json`.
## Shape of the change
The mapping lives in a self-contained `public/nodes-export.js`
(`window.NodesExport.buildContacts/filename/download`); `nodes.js` only
gains the button markup, a click handler and a small
`updateExportBtn()`. No backend change, no new API call — the export
reuses the already-fetched node list, so there is nothing per-node to
fetch.
## Tests
- `test-nodes-export.js` — field mapping and key order, role→type table,
skip rules, order preservation, filename format (added to
`test-all.sh`).
- `test-nodes-export-wiring.js` — `index.html` loads the module before
`nodes.js`; the button lives in the topbar and hands the filtered
`nodes` array plus `AreaFilter.getSelected()` to
`NodesExport.download()` (added to `test-all.sh`).
- `test-nodes-export-e2e.js` — Playwright: downloads the file, validates
the JSON shape per contact, asserts the button count matches the file,
and that narrowing the search shrinks the export set.
## Browser validation
Deployed to staging and checked in Chromium:
- Desktop 1400×900 — button renders at the right of the topbar next to
the count pills, `Export JSON (1962)`.
- Mobile 390×844 — topbar stacks, button visible, no horizontal overflow
(`scrollWidth == clientWidth`).
- E2E against staging: 1761 contacts exported,
`corescope_nodes_all_2026-08-12-164349.json`, shape validated, search
narrowing confirmed.
- With the `BE-LIM` area filter active: 44 contacts,
`corescope_nodes_BE-LIM_2026-08-12-164445.json`, type histogram `{1: 1,
2: 42, 3: 1}`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
93 lines
3.7 KiB
JavaScript
93 lines
3.7 KiB
JavaScript
// E2E for the Nodes JSON export button (#/nodes → "Export JSON").
|
|
// Defaults to localhost:3000 — NEVER point at prod (AGENTS.md). CI sets BASE_URL.
|
|
const { chromium } = require('playwright');
|
|
const BASE = process.env.BASE_URL || 'http://localhost:3000';
|
|
|
|
const REF_KEYS = ['type', 'name', 'custom_name', 'public_key', 'flags', 'latitude',
|
|
'longitude', 'last_advert', 'last_modified', 'out_path_list'];
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch();
|
|
const page = await browser.newPage({ acceptDownloads: true });
|
|
|
|
await page.goto(BASE + '/#/nodes');
|
|
await page.waitForSelector('#nodesLeft[data-loaded="true"]', { timeout: 30000 });
|
|
|
|
const btn = await page.$('#nodesExportBtn');
|
|
if (!btn) throw new Error('#nodesExportBtn is missing from the nodes topbar');
|
|
|
|
const label = (await btn.textContent()).trim();
|
|
if (await btn.isDisabled()) {
|
|
// No exportable node in this dataset (all lack a name or a position).
|
|
if (label !== 'Export JSON') {
|
|
throw new Error('disabled button should carry no count, got "' + label + '"');
|
|
}
|
|
console.log('nodes-export E2E SKIP (no exportable node in dataset)');
|
|
await browser.close();
|
|
return;
|
|
}
|
|
|
|
const m = label.match(/^Export JSON \((\d+)\)$/);
|
|
if (!m) throw new Error('enabled button must show the contact count, got "' + label + '"');
|
|
const expectedCount = Number(m[1]);
|
|
|
|
const [download] = await Promise.all([
|
|
page.waitForEvent('download', { timeout: 15000 }),
|
|
btn.click(),
|
|
]);
|
|
|
|
const name = download.suggestedFilename();
|
|
if (!/^corescope_nodes_[A-Za-z0-9_-]+_\d{4}-\d{2}-\d{2}-\d{6}\.json$/.test(name)) {
|
|
throw new Error('unexpected download filename: ' + name);
|
|
}
|
|
|
|
const stream = await download.createReadStream();
|
|
let raw = '';
|
|
for await (const chunk of stream) raw += chunk;
|
|
const payload = JSON.parse(raw);
|
|
|
|
if (!Array.isArray(payload.contacts)) throw new Error('export must have a contacts array');
|
|
if (payload.contacts.length !== expectedCount) {
|
|
throw new Error('button said ' + expectedCount + ' contacts, file has ' + payload.contacts.length);
|
|
}
|
|
if (Object.keys(payload).length !== 1) {
|
|
throw new Error('export must contain only the contacts key, got ' + Object.keys(payload).join(','));
|
|
}
|
|
|
|
payload.contacts.forEach(function (c, i) {
|
|
const keys = Object.keys(c).join(',');
|
|
if (keys !== REF_KEYS.join(',')) {
|
|
throw new Error('contact ' + i + ' key mismatch: ' + keys);
|
|
}
|
|
if (typeof c.public_key !== 'string' || c.public_key.length < 64) {
|
|
throw new Error('contact ' + i + ' has a truncated public_key');
|
|
}
|
|
if (typeof c.latitude !== 'string' || typeof c.longitude !== 'string') {
|
|
throw new Error('contact ' + i + ' coordinates must be strings');
|
|
}
|
|
if (c.latitude === '0' && c.longitude === '0') {
|
|
throw new Error('contact ' + i + ' is at null island and should have been skipped');
|
|
}
|
|
if (typeof c.last_advert !== 'number' || typeof c.type !== 'number') {
|
|
throw new Error('contact ' + i + ' last_advert/type must be numbers');
|
|
}
|
|
});
|
|
|
|
// The export is WYSIWYG: narrowing the table with the search box must narrow
|
|
// the export too.
|
|
const first = payload.contacts[0].name;
|
|
await page.fill('#nodeSearch', first);
|
|
await page.waitForFunction(function (want) {
|
|
var b = document.getElementById('nodesExportBtn');
|
|
return b && b.textContent.trim() !== want;
|
|
}, label, { timeout: 15000 });
|
|
const narrowed = (await page.textContent('#nodesExportBtn')).trim();
|
|
const nm = narrowed.match(/^Export JSON \((\d+)\)$/);
|
|
if (!nm || Number(nm[1]) >= expectedCount) {
|
|
throw new Error('search should shrink the export set, got "' + narrowed + '" vs ' + expectedCount);
|
|
}
|
|
|
|
console.log('nodes-export E2E OK (' + expectedCount + ' contacts, ' + name + ')');
|
|
await browser.close();
|
|
})();
|