mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 11:37:32 +00:00
Closes #2270, closes #2271. The ingestor took only the confirmed region scopes out of an observer's `/neighbors` report (#1865) and dropped the rest. Nothing could therefore say when an observer last reported, or whom it heard. ## Ingestor `observer_neighbor_reports` (created in `dbschema.Apply`) keeps the latest report of each observer, one row per observer, replaced by every live report. The report is unauthenticated, so `neighbors[]` is stored sanitised: - only 64-hex keys - `snr` and `heard_secs_ago` finite and in range - `status` limited to `[a-z_]` - `scopes` clamped - at most 500 rows `total_neighbors` is believed only when it is not below the number of rows that arrived. A **retained replay** only fills a missing row, and it is dated by the report's own `timestamp`. After an ingestor restart, a long-dead observer therefore does not look as if it just reported. ## API The table is optional for the server (no `AssertReady` change); without it the fields are simply absent. - `GET /api/observers`: `neighbors_report_at` and `neighbors_count`, from one batch query in the existing cached builder. - `GET /api/observers/{id}`: also `neighbor_report`, which is `{received_at, reported_at, neighbor_count, queried_neighbors, self_scopes, self_default_scope, neighbors: [{pubkey, name, role, snr, heard_secs_ago, status, scopes}]}`. Names and roles come from the node table in one query. ## Frontend `public/observer-neighbors.js` is shared by both pages and escapes everything. - **#2270, observers list:** a sortable **Neighbors** column, e.g. `3h ago · 12`, or `—` when the observer never reported. The same value appears in the slide-over. - **#2270, observer page:** **Neighbors Report** and **Neighbors** stat cards. - **#2271, observer page:** a **Neighbors** table listing each neighbor by name (a link to the node) or key prefix, with SNR, heard, scope query status and scopes. The heading says when the list is shorter than the count, e.g. `(2 of 3 listed)`. ## Tests - **ingestor:** sanitising, the cap, live replace, the retained rules (fill only, dated by the report, undated ignored) and the MQTT topic end to end. The existing #1865 scope tests are unchanged and still pass. - **server:** the list summary, the detail with names, and both endpoints without the table. - **frontend:** - unit tests of the helpers - an XSS case for every rendered field; I checked that it fails when escaping is removed - `test-observers-headings` now expects the new column after `Last Packet` - **E2E:** `test-observer-neighbors-2270-e2e.js` intercepts the observer endpoints, since the fixture has no reports. It is listed in `scripts/non-unit-tests.json` and on shard 2. Gates that pass: - `go test -race` in `cmd/server`, `cmd/ingestor` and `internal/dbschema` - `sh test-all.sh` - `eslint@8`, with 0 errors - the XSS diff gate - these E2E suites: the new one, `test-issue-1639-observers-sort-e2e.js`, `test-observer-iata-1188-e2e.js` and `test-e2e-playwright.js` Performance: the list adds one primary-key-sized query per cache fill, and the detail adds two small queries per request. Also fixes #2330 (folded in from #2337, second commit). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
275 lines
25 KiB
HTML
275 lines
25 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<link rel="icon" href="favicon.ico" type="image/x-icon">
|
|
<link rel="icon" href="favicon.svg" type="image/svg+xml">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
<title>CoreScope</title>
|
|
|
|
<!-- Open Graph / Discord embed -->
|
|
<meta property="og:title" content="CoreScope">
|
|
<meta property="og:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, route analysis, and deep mesh analytics.">
|
|
<meta property="og:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
|
|
<meta property="og:image:width" content="1200">
|
|
<meta property="og:image:height" content="630">
|
|
<!-- No og:url: it is the canonical destination OG consumers navigate to, so a
|
|
hardcoded value sends every self-hosted instance's shared links to that one
|
|
host (#1890). Omitted, consumers use the URL they crawled — correct for
|
|
every deployment, no configuration needed. -->
|
|
<meta property="og:type" content="website">
|
|
<meta name="theme-color" content="#0a0a0a">
|
|
|
|
<!-- Twitter Card -->
|
|
<meta name="twitter:card" content="summary_large_image">
|
|
<meta name="twitter:title" content="CoreScope">
|
|
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
|
|
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
|
|
<!-- PR #893 follow-up: apply persisted theme before stylesheet/paint to prevent
|
|
FOUC (light flash for users who chose dark). Matches keys used by app.js. -->
|
|
<script>
|
|
(function () {
|
|
// #2236: ?theme=dark|light in the hash query (e.g. #/map?embed=1&theme=dark)
|
|
// wins over the saved choice for this page load. app.js reads
|
|
// window.__themeOverride and never persists it.
|
|
try {
|
|
var q = new URLSearchParams(location.hash.split('?')[1] || '').get('theme');
|
|
window.__themeOverride = q === 'dark' || q === 'light' ? q : null;
|
|
} catch (_) { window.__themeOverride = null; }
|
|
try {
|
|
var saved = localStorage.getItem('meshcore-theme');
|
|
var t = window.__themeOverride || (saved === 'dark' || saved === 'light'
|
|
? saved
|
|
: (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'));
|
|
document.documentElement.setAttribute('data-theme', t);
|
|
} catch (_) {
|
|
/* localStorage may be blocked; CSS handles default */
|
|
if (window.__themeOverride) document.documentElement.setAttribute('data-theme', window.__themeOverride);
|
|
}
|
|
})();
|
|
</script>
|
|
<link rel="stylesheet" href="style.css?v=__BUST__">
|
|
<link rel="stylesheet" href="route-view.css?v=__BUST__">
|
|
<link rel="stylesheet" href="home.css?v=__BUST__">
|
|
<link rel="stylesheet" href="live.css?v=__BUST__">
|
|
<link rel="stylesheet" href="node-reach.css?v=__BUST__">
|
|
<link rel="stylesheet" href="node-scopes.css?v=__BUST__">
|
|
<link rel="stylesheet" href="scope-audit.css?v=__BUST__">
|
|
<link rel="stylesheet" href="node-reach-coverage.css?v=__BUST__">
|
|
<link rel="stylesheet" href="bottom-nav.css?v=__BUST__">
|
|
<link rel="stylesheet" href="account.css?v=__BUST__">
|
|
<link rel="stylesheet" href="admin.css?v=__BUST__">
|
|
<link rel="stylesheet" href="nav-drawer.css?v=__BUST__">
|
|
<!-- Leaflet, leaflet.heat and Chart.js are served from vendor/ (byte-identical
|
|
to the unpkg dist files, verified against these SRI hashes), not from
|
|
unpkg.com: no third-party request on every page view, and maps and
|
|
charts keep working where unpkg is blocked or down. -->
|
|
<link rel="stylesheet" href="vendor/leaflet-1.9.4/leaflet.css"
|
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
crossorigin="anonymous">
|
|
<link rel="stylesheet" href="vendor/MarkerCluster.css?v=__BUST__">
|
|
<link rel="stylesheet" href="vendor/MarkerCluster.Default.css?v=__BUST__">
|
|
<script src="vendor/leaflet-1.9.4/leaflet.js"
|
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
|
crossorigin="anonymous"></script>
|
|
<script src="vendor/leaflet.markercluster.js?v=__BUST__"></script>
|
|
<script src="vendor/leaflet-heat-0.2.0.js"
|
|
integrity="sha384-mFKkGiGvT5vo1fEyGCD3hshDdKmW3wzXW/x+fWriYJArD0R3gawT6lMvLboM22c0"
|
|
crossorigin="anonymous"></script>
|
|
<script src="vendor/chart-4.5.1.umd.min.js"
|
|
integrity="sha384-jb8JQMbMoBUzgWatfe6COACi2ljcDdZQ2OxczGA3bGNeWe+6DChMTBJemed7ZnvJ"
|
|
crossorigin="anonymous"></script>
|
|
</head>
|
|
<body>
|
|
<!-- #1380 — Colorblind a11y stretch goal: Brettel/Vienot 1997 dichromatic
|
|
simulation overlay. Toggled by body[data-cb-sim="deut|prot|trit|achromat"].
|
|
Preview-only (not persisted). See public/customize-v2.js radio group. -->
|
|
<svg aria-hidden="true" focusable="false" width="0" height="0" style="position:absolute;width:0;height:0;overflow:hidden" data-cb-sim-defs>
|
|
<defs>
|
|
<filter id="cb-deut" color-interpolation-filters="sRGB">
|
|
<feColorMatrix type="matrix" values="
|
|
0.367 0.861 -0.228 0 0
|
|
0.280 0.673 0.047 0 0
|
|
-0.012 0.043 0.969 0 0
|
|
0 0 0 1 0"/>
|
|
</filter>
|
|
<filter id="cb-prot" color-interpolation-filters="sRGB">
|
|
<feColorMatrix type="matrix" values="
|
|
0.152 1.053 -0.205 0 0
|
|
0.115 0.786 0.099 0 0
|
|
-0.004 -0.048 1.052 0 0
|
|
0 0 0 1 0"/>
|
|
</filter>
|
|
<filter id="cb-trit" color-interpolation-filters="sRGB">
|
|
<feColorMatrix type="matrix" values="
|
|
1.256 -0.077 -0.179 0 0
|
|
-0.078 0.931 0.148 0 0
|
|
0.005 0.691 0.304 0 0
|
|
0 0 0 1 0"/>
|
|
</filter>
|
|
<filter id="cb-achromat" color-interpolation-filters="sRGB">
|
|
<feColorMatrix type="matrix" values="
|
|
0.299 0.587 0.114 0 0
|
|
0.299 0.587 0.114 0 0
|
|
0.299 0.587 0.114 0 0
|
|
0 0 0 1 0"/>
|
|
</filter>
|
|
</defs>
|
|
</svg>
|
|
<style>
|
|
/* #1380 — sim overlay activation (preview-only; cleared on reload). */
|
|
body[data-cb-sim="deut"] { filter: url(#cb-deut); }
|
|
body[data-cb-sim="prot"] { filter: url(#cb-prot); }
|
|
body[data-cb-sim="trit"] { filter: url(#cb-trit); }
|
|
body[data-cb-sim="achromat"] { filter: url(#cb-achromat); }
|
|
</style>
|
|
<a class="skip-link" href="#app">Skip to content</a>
|
|
<nav class="top-nav" role="navigation" aria-label="Main navigation">
|
|
<div class="nav-left">
|
|
<a href="#/" class="nav-brand" aria-label="CoreScope home">
|
|
<svg class="brand-logo" xmlns="http://www.w3.org/2000/svg" width="125" height="36" viewBox="150 10 970 280" aria-hidden="true" focusable="false"><path d="M540 100 A 30 30 0 1 0 540 160" fill="none" stroke="var(--logo-accent)" stroke-width="8" opacity="1.00"/>
|
|
<path d="M540 73 A 57 57 0 1 0 540 187" fill="none" stroke="var(--logo-accent)" stroke-width="8" opacity="0.82"/>
|
|
<path d="M540 46 A 84 84 0 1 0 540 214" fill="none" stroke="var(--logo-accent)" stroke-width="8" opacity="0.64"/>
|
|
<path d="M540 19 A 111 111 0 1 0 540 241" fill="none" stroke="var(--logo-accent)" stroke-width="8" opacity="0.46"/>
|
|
<path d="M660 100 A 30 30 0 1 1 660 160" fill="none" stroke="var(--logo-accent-hi)" stroke-width="8" opacity="1.00"/>
|
|
<path d="M660 73 A 57 57 0 1 1 660 187" fill="none" stroke="var(--logo-accent-hi)" stroke-width="8" opacity="0.82"/>
|
|
<path d="M660 46 A 84 84 0 1 1 660 214" fill="none" stroke="var(--logo-accent-hi)" stroke-width="8" opacity="0.64"/>
|
|
<path d="M660 19 A 111 111 0 1 1 660 241" fill="none" stroke="var(--logo-accent-hi)" stroke-width="8" opacity="0.46"/>
|
|
|
|
<polyline points="540.00,130.00 540.30,130.17 540.60,130.35 540.90,130.53 541.20,130.71 541.50,130.89 541.80,131.07 542.10,131.26 542.40,131.45 542.70,131.64 543.00,131.83 543.30,132.02 543.60,132.21 543.90,132.41 544.20,132.61 544.50,132.80 544.80,133.00 545.10,133.20 545.40,133.40 545.70,133.60 546.00,133.81 546.30,134.01 546.60,134.21 546.90,134.42 547.20,134.62 547.50,134.82 547.80,135.03 548.10,135.23 548.40,135.44 548.70,135.64 549.00,135.84 549.30,136.04 549.60,136.24 549.90,136.44 550.20,136.64 550.50,136.83 550.80,137.02 551.10,137.22 551.40,137.41 551.70,137.59 552.00,137.78 552.30,137.96 552.60,138.14 552.90,138.31 553.20,138.49 553.50,138.66 553.80,138.82 554.10,138.98 554.40,139.14 554.70,139.29 555.00,139.44 555.30,139.58 555.60,139.71 555.90,139.84 556.20,139.97 556.50,140.09 556.80,140.20 557.10,140.31 557.40,140.41 557.70,140.50 558.00,140.59 558.30,140.67 558.60,140.74 558.90,140.80 559.20,140.85 559.50,140.90 559.80,140.94 560.10,140.97 560.40,140.99 560.70,141.00 561.00,141.00 561.30,140.99 561.60,140.97 561.90,140.95 562.20,140.91 562.50,140.86 562.80,140.80 563.10,140.73 563.40,140.65 563.70,140.56 564.00,140.46 564.30,140.35 564.60,140.23 564.90,140.09 565.20,139.95 565.50,139.79 565.80,139.62 566.10,139.44 566.40,139.25 566.70,139.05 567.00,138.84 567.30,138.61 567.60,138.38 567.90,138.13 568.20,137.87 568.50,137.60 568.80,137.33 569.10,137.04 569.40,136.74 569.70,136.43 570.00,136.11 570.30,135.78 570.60,135.45 570.90,135.10 571.20,134.75 571.50,134.38 571.80,134.01 572.10,133.64 572.40,133.25 572.70,132.86 573.00,132.46 573.30,132.06 573.60,131.65 573.90,131.24 574.20,130.82 574.50,130.40 574.80,129.98 575.10,129.56 575.40,129.13 575.70,128.71 576.00,128.28 576.30,127.85 576.60,127.43 576.90,127.00 577.20,126.58 577.50,126.17 577.80,125.75 578.10,125.35 578.40,124.94 578.70,124.55 579.00,124.16 579.30,123.78 579.60,123.41 579.90,123.05 580.20,122.70 580.50,122.36 580.80,122.03 581.10,121.71 581.40,121.41 581.70,121.13 582.00,120.85 582.30,120.60 582.60,120.36 582.90,120.14 583.20,119.93 583.50,119.75 583.80,119.58 584.10,119.43 584.40,119.31 584.70,119.20 585.00,119.12 585.30,119.06 585.60,119.02 585.90,119.00 586.20,119.01 586.50,119.04 586.80,119.09 587.10,119.17 587.40,119.27 587.70,119.39 588.00,119.54 588.30,119.71 588.60,119.91 588.90,120.13 589.20,120.37 589.50,120.64 589.80,120.92 590.10,121.24 590.40,121.57 590.70,121.92 591.00,122.30 591.30,122.69 591.60,123.11 591.90,123.54 592.20,123.99 592.50,124.46 592.80,124.94 593.10,125.44 593.40,125.95 593.70,126.48 594.00,127.01 594.30,127.56 594.60,128.11 594.90,128.67 595.20,129.24 595.50,129.81 595.80,130.38 596.10,130.96 596.40,131.53 596.70,132.10 597.00,132.67 597.30,133.24 597.60,133.79 597.90,134.34 598.20,134.87 598.50,135.40 598.80,135.91 599.10,136.40 599.40,136.88 599.70,137.34 600.00,137.78 600.30,138.19 600.60,138.59 600.90,138.96 601.20,139.30 601.50,139.61 601.80,139.90 602.10,140.15 602.40,140.37 602.70,140.56 603.00,140.72 603.30,140.84 603.60,140.93 603.90,140.98 604.20,141.00 604.50,140.98 604.80,140.92 605.10,140.83 605.40,140.70 605.70,140.53 606.00,140.32 606.30,140.08 606.60,139.80 606.90,139.49 607.20,139.14 607.50,138.75 607.80,138.34 608.10,137.89 608.40,137.42 608.70,136.91 609.00,136.38 609.30,135.82 609.60,135.24 609.90,134.64 610.20,134.01 610.50,133.37 610.80,132.72 611.10,132.05 611.40,131.37 611.70,130.69 612.00,130.00 612.30,129.31 612.60,128.62 612.90,127.93 613.20,127.25 613.50,126.58 613.80,125.91 614.10,125.27 614.40,124.64 614.70,124.03 615.00,123.45 615.30,122.89 615.60,122.36 615.90,121.86 616.20,121.39 616.50,120.96 616.80,120.57 617.10,120.21 617.40,119.90 617.70,119.64 618.00,119.41 618.30,119.24 618.60,119.11 618.90,119.03 619.20,119.00 619.50,119.02 619.80,119.09 620.10,119.21 620.40,119.39 620.70,119.61 621.00,119.88 621.30,120.20 621.60,120.57 621.90,120.98 622.20,121.44 622.50,121.94 622.80,122.48 623.10,123.06 623.40,123.68 623.70,124.33 624.00,125.01 624.30,125.71 624.60,126.44 624.90,127.19 625.20,127.96 625.50,128.73 625.80,129.52 626.10,130.31 626.40,131.10 626.70,131.89 627.00,132.67 627.30,133.44 627.60,134.19 627.90,134.93 628.20,135.63 628.50,136.31 628.80,136.96 629.10,137.57 629.40,138.14 629.70,138.67 630.00,139.15 630.30,139.58 630.60,139.95 630.90,140.28 631.20,140.54 631.50,140.75 631.80,140.90 632.10,140.98 632.40,141.00 632.70,140.96 633.00,140.85 633.30,140.68 633.60,140.44 633.90,140.14 634.20,139.78 634.50,139.36 634.80,138.89 635.10,138.36 635.40,137.78 635.70,137.14 636.00,136.47 636.30,135.75 636.60,134.99 636.90,134.20 637.20,133.38 637.50,132.54 637.80,131.68 638.10,130.81 638.40,129.93 638.70,129.05 639.00,128.17 639.30,127.31 639.60,126.45 639.90,125.62 640.20,124.82 640.50,124.05 640.80,123.31 641.10,122.62 641.40,121.98 641.70,121.39 642.00,120.85 642.30,120.38 642.60,119.97 642.90,119.63 643.20,119.36 643.50,119.17 643.80,119.05 644.10,119.00 644.40,119.03 644.70,119.14 645.00,119.33 645.30,119.59 645.60,119.93 645.90,120.34 646.20,120.83 646.50,121.38 646.80,121.99 647.10,122.67 647.40,123.40 647.70,124.18 648.00,125.01 648.30,125.87 648.60,126.77 648.90,127.69 649.20,128.64 649.50,129.60 649.80,130.56 650.10,131.52 650.40,132.47 650.70,133.40 651.00,134.31 651.30,135.19 651.60,136.02 651.90,136.82 652.20,137.56 652.50,138.24 652.80,138.86 653.10,139.41 653.40,139.88 653.70,140.27 654.00,140.59 654.30,140.81 654.60,140.95 654.90,141.00 655.20,140.96 655.50,140.82 655.80,140.60 656.10,140.29 656.40,139.89 656.70,139.40 657.00,138.84 657.30,138.19 657.60,137.48 657.90,136.70 658.20,135.86 658.50,134.97 658.80,134.03 659.10,133.06 659.40,132.06 659.70,131.03 660.00,130.00" fill="none" stroke="var(--logo-muted)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" opacity="1.0"/>
|
|
<circle class="logo-node-a" cx="540" cy="130" r="13" fill="var(--logo-accent)"/>
|
|
<circle class="logo-node-b" cx="660" cy="130" r="13" fill="var(--logo-accent-hi)"/>
|
|
<g class="brand-wordmark"><text x="426.20" y="161.68" font-family="Aldrich, monospace" font-size="88" text-anchor="end" dominant-baseline="alphabetic" font-weight="700" letter-spacing="4.4" fill="var(--logo-accent)">CORE</text><text x="773.80" y="161.68" font-family="Aldrich, monospace" font-size="88" text-anchor="start" dominant-baseline="alphabetic" font-weight="700" letter-spacing="4.4" fill="var(--logo-accent-hi)">SCOPE</text></g></svg>
|
|
<svg class="brand-mark-only" xmlns="http://www.w3.org/2000/svg" width="60" height="32" viewBox="425 15 250 230" aria-hidden="true" focusable="false"><path d="M540 100 A 30 30 0 1 0 540 160" fill="none" stroke="var(--logo-accent)" stroke-width="8" opacity="1.00"/><path d="M540 73 A 57 57 0 1 0 540 187" fill="none" stroke="var(--logo-accent)" stroke-width="8" opacity="0.82"/><path d="M540 46 A 84 84 0 1 0 540 214" fill="none" stroke="var(--logo-accent)" stroke-width="8" opacity="0.64"/><path d="M540 19 A 111 111 0 1 0 540 241" fill="none" stroke="var(--logo-accent)" stroke-width="8" opacity="0.46"/><path d="M660 100 A 30 30 0 1 1 660 160" fill="none" stroke="var(--logo-accent-hi)" stroke-width="8" opacity="1.00"/><path d="M660 73 A 57 57 0 1 1 660 187" fill="none" stroke="var(--logo-accent-hi)" stroke-width="8" opacity="0.82"/><path d="M660 46 A 84 84 0 1 1 660 214" fill="none" stroke="var(--logo-accent-hi)" stroke-width="8" opacity="0.64"/><path d="M660 19 A 111 111 0 1 1 660 241" fill="none" stroke="var(--logo-accent-hi)" stroke-width="8" opacity="0.46"/><circle class="logo-node-a" cx="540" cy="130" r="13" fill="var(--logo-accent)"/><circle class="logo-node-b" cx="660" cy="130" r="13" fill="var(--logo-accent-hi)"/></svg>
|
|
</a>
|
|
<div class="nav-links">
|
|
<a href="#/home" class="nav-link" data-route="home" data-priority="high">Home</a>
|
|
<a href="#/packets" class="nav-link" data-route="packets" data-priority="high">Packets</a>
|
|
<a href="#/map" class="nav-link" data-route="map" data-priority="high">Map</a>
|
|
<a href="#/live" class="nav-link" data-route="live" data-priority="high"><svg class="ph-icon" aria-hidden="true" focusable="false"><use href="/icons/phosphor-sprite.svg#ph-broadcast"></use></svg> Live</a>
|
|
<a href="#/channels" class="nav-link" data-route="channels">Channels</a>
|
|
<a href="#/nodes" class="nav-link" data-route="nodes" data-priority="high">Nodes</a>
|
|
<a href="#/tools" class="nav-link" data-route="tools">Tools</a>
|
|
<a href="#/observers" class="nav-link" data-route="observers">Observers</a>
|
|
<a href="#/analytics" class="nav-link" data-route="analytics">Analytics</a>
|
|
<a href="#/scope-audit" class="nav-link" data-route="scope-audit">Scope Audit</a>
|
|
<a href="#/perf" class="nav-link" data-route="perf"><svg class="ph-icon" aria-hidden="true" focusable="false"><use href="/icons/phosphor-sprite.svg#ph-lightning"></use></svg> Perf</a>
|
|
<a href="#/audio-lab" class="nav-link" data-route="audio-lab"><svg class="ph-icon" aria-hidden="true" focusable="false"><use href="/icons/phosphor-sprite.svg#ph-music-note"></use></svg> Lab</a>
|
|
</div>
|
|
<div class="nav-more-wrap">
|
|
<button class="nav-btn nav-more-btn" id="navMoreBtn" aria-haspopup="true" aria-expanded="false" aria-controls="navMoreMenu" title="More pages">More ▾</button>
|
|
<div class="nav-more-menu" id="navMoreMenu" role="menu"></div>
|
|
</div>
|
|
</div>
|
|
<div class="nav-right">
|
|
<div class="nav-stats" id="navStats" title="Live stats"></div>
|
|
<div class="nav-fav-wrap">
|
|
<button class="nav-btn" id="favToggle" title="Favorites" aria-label="Favorites"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-star-fill"/></svg></button>
|
|
<div class="nav-fav-dropdown" id="favDropdown"></div>
|
|
</div>
|
|
<button class="nav-btn" id="searchToggle" title="Search (Ctrl+K)" aria-label="Search"><svg class="ph-icon" aria-hidden="true" focusable="false"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"></use></svg></button>
|
|
<button class="nav-btn" id="customizeToggle" title="Customize theme & branding" aria-label="Customize"><svg class="ph-icon" aria-hidden="true" focusable="false"><use href="/icons/phosphor-sprite.svg#ph-palette"></use></svg></button>
|
|
<label class="theme-toggle" id="darkModeToggle" title="Toggle dark mode">
|
|
<input type="checkbox" id="darkModeCheckbox" role="switch" aria-label="Toggle dark mode">
|
|
<span class="theme-toggle-track" aria-hidden="true">
|
|
<span class="theme-toggle-thumb"></span>
|
|
<span class="theme-toggle-icon theme-toggle-sun"><svg class="ph-icon" aria-hidden="true" focusable="false"><use href="/icons/phosphor-sprite.svg#ph-sun"></use></svg></span>
|
|
<span class="theme-toggle-icon theme-toggle-moon"><svg class="ph-icon" aria-hidden="true" focusable="false"><use href="/icons/phosphor-sprite.svg#ph-moon"></use></svg></span>
|
|
</span>
|
|
</label>
|
|
<button class="nav-btn hamburger" id="hamburger" title="Menu" aria-label="Toggle navigation menu"><svg class="ph-icon" aria-hidden="true" focusable="false"><use href="/icons/phosphor-sprite.svg#ph-list"></use></svg></button>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- Search overlay -->
|
|
<div id="searchOverlay" class="search-overlay hidden" role="search" aria-label="Search packets, nodes, channels">
|
|
<div class="search-box">
|
|
<input type="text" id="searchInput" placeholder="Search packets, nodes, channels…" autofocus>
|
|
<div id="searchResults" class="search-results" role="listbox"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<main id="app" role="main"></main>
|
|
|
|
<script src="vendor/qrcode.js"></script>
|
|
<script src="payload-labels.js?v=__BUST__"></script>
|
|
<script src="roles.js?v=__BUST__"></script>
|
|
<script src="map-tile-providers.js?v=__BUST__"></script>
|
|
<script src="cb-presets.js?v=__BUST__"></script>
|
|
<script src="customize-v2.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="region-filter.js?v=__BUST__"></script>
|
|
<script src="area-filter.js?v=__BUST__"></script>
|
|
<script src="hop-resolver.js?v=__BUST__"></script>
|
|
<script src="hop-filter.js?v=__BUST__"></script>
|
|
<script src="hop-display.js?v=__BUST__"></script>
|
|
<script src="warmup-banner.js?v=__BUST__"></script>
|
|
<script src="app.js?v=__BUST__"></script>
|
|
<script src="bottom-nav.js?v=__BUST__"></script>
|
|
<script src="nav-drawer.js?v=__BUST__"></script>
|
|
<script src="auth.js?v=__BUST__"></script>
|
|
<script src="settings-sync.js?v=__BUST__"></script>
|
|
<script src="touch-gestures.js?v=__BUST__"></script>
|
|
<script src="gesture-hints.js?v=__BUST__"></script>
|
|
<script src="url-state.js?v=__BUST__"></script>
|
|
<script src="home.js?v=__BUST__"></script>
|
|
<script src="table-sort.js?v=__BUST__"></script>
|
|
<script src="packet-filter.js?v=__BUST__"></script>
|
|
<script src="filter-ux.js?v=__BUST__"></script>
|
|
<script src="hash-color.js?v=__BUST__"></script>
|
|
<script src="prefix-reserved.js?v=__BUST__"></script>
|
|
<script src="packet-helpers.js?v=__BUST__"></script>
|
|
<script src="vendor/aes-ecb.js?v=__BUST__"></script>
|
|
<script src="vendor/sha256-hmac.js?v=__BUST__"></script>
|
|
<script src="channel-decrypt.js?v=__BUST__"></script>
|
|
<script src="vendor/jsqr.min.js"></script>
|
|
<script src="channel-qr.js?v=__BUST__"></script>
|
|
<script src="channel-colors.js?v=__BUST__"></script>
|
|
<script src="channel-color-picker.js?v=__BUST__"></script>
|
|
<script src="channel-proposals.js?v=__BUST__"></script>
|
|
<script src="notifications.js?v=__BUST__"></script>
|
|
<script src="packets.js?v=__BUST__"></script>
|
|
<script src="geo-filter-overlay.js?v=__BUST__"></script>
|
|
<script src="map-estimated.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="route-render.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="route-view-utils.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="route-view.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="channels.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="table-sort.js?v=__BUST__"></script>
|
|
<script src="nodes-export.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="reported-nodes.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="rf-params.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="nodes.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="traces.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="path-inspector.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="observer-airtime.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="audio.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="audio-v1-constellation.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="audio-lab.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="drag-manager.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="live.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="observer-neighbors.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="observers.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="mqtt-status-panel.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="observer-detail.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="compare.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="node-hop-analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="node-analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="node-reach-map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="node-reach-coverage.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="node-reach.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="scope-audit.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="rx-coverage.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="perf.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="account.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="admin-users.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="admin-audit.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="admin-overview.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="admin-proposals.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="admin-observer-logins.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="admin.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
<script src="mobile-page-actions.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
</body>
|
|
</html>
|