mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-06 06:17:33 +00:00
## Summary
Adds per-node hop-count statistics so repeater operators can choose
`flood.max`, `flood.max.unscoped` and `flood.max.advert` from what their
node actually sees.
- New endpoint `GET /api/nodes/{pubkey}/hop_analytics?days=N`
(`cmd/server/routes.go:299`, `cmd/server/node_hop_analytics.go:312`),
separate from `/analytics` as requested in the issue.
- New card "Hop Count at This Node" on the node analytics page
(`public/node-hop-analytics.js`, wired at
`public/node-analytics.js:130,174`): histogram of hop counts with a box
plot on the same x axis, filters `flood.max` (default),
`flood.max.advert`, `flood.max.unscoped`, driven by the existing range
picker.
- The existing "Hop Distribution" chart is unchanged: it shows path
length at the observer, a different quantity.
- No `direct` tag, although the issue lists one: for DIRECT packets the
path is the remaining route and no flood limit applies, so there is no
hop count to report.
## Hop count definition (firmware 0679dbef)
- `src/helpers/RoutingPolicy.h:15-21`: limits compare
`getPathHashCount()`; `.unscoped` applies to route type FLOOD, `.advert`
to adverts.
- `src/Mesh.cpp:344-350`: `routeRecvPacket` checks with n hashes in the
path, then writes its own hash at index n. So hops = the node's
zero-based index in the path, no +1.
- `src/Mesh.cpp:265-285`: a node forwards a flood once;
`src/Mesh.cpp:651,680`: an originator never forwards its own flood.
- DIRECT packets are excluded: their path is the remaining route
(`src/Mesh.cpp:78-103,334-341`).
Response: `{timeRange, packets: [{hash, timestamp, hops, tags}],
ambiguous}`. Tags: `flood`, `scoped` or `unscoped`, `advert`. Documented
in `docs/api-spec.md:679` and `cmd/server/openapi.go:90`.
## Attribution
`cmd/server/node_hop_analytics.go:198-309`. The result depends only on
the observed paths, the prefix map and the neighbor graph, so it is the
same after a restart as after live ingest.
- Every observation of every flood packet in the window is read.
`byNode` holds the server resolver's pick at ingest and other picks
after a cold load; `byPathHop` indexes only each packet's longest path,
which for a busy relay often runs through another branch of the flood.
- A packet counts when the node's prefix sits at exactly one index
across its observations, and either the node is the only relay candidate
for that prefix (`prefixMap.relayCandidates`,
`cmd/server/store.go:6795`), or the hop resolves to the node under the
ingestor's strict rule (`cmd/ingestor/path_resolver.go:143-214`) in at
least one observation and to another node in none. Strict rule: earlier
hops identified without a tiebreak, exactly one candidate adjacent in
`neighbor_edges` to the previous hop (the originator for hop 0 of an
advert), nodes already on the path excluded.
- The server resolver's tiebreaks (affinity, GPS distance, advert count,
pubkey order) are not used.
- Everything else with the node's prefix goes to `ambiguous`. In
practice that is most packets with a colliding 1-byte path hash.
On a read-only 7-day dump of a 1,669-node mesh DB, for one busy
repeater: 23,081 packets attributed, 11,437 ambiguous. Taking candidates
from `byPathHop` instead gave 9,995 attributed, with the histogram mode
moved from 2 to 3-5 hops.
## Performance
Scans `s.packets` under the read lock, no SQL per packet. Per
observation: one substring test for the node's first prefix byte; the
hop scan only for observations containing it; the strict walk only for
colliding prefixes, with per-request caches for candidates and
adjacency. `BenchmarkNodeHopPackets` models one 7-day request at that
scale (73,782 flood packets, 1,430,280 observations): 44-87 ms/op, 13.4
MB, 40 allocs on a throttling laptop.
Response size for that repeater over 7 days: about 23k entries, 2.3 MB
JSON, 375 KB gzipped. `hash` and `timestamp` are 61% of the raw and 91%
of the gzipped bytes; they stay because the issue asks for them so a
client can join entries to packets and bin by time.
## Tests
- Go: `cmd/server/node_hop_analytics_test.go`: 12 unit tests, a
live-ingest test through `IngestNewFromDB` (a colliding prefix without
independent attribution goes to `ambiguous`, not to the node the
resolver picked), live ingest versus cold load of the same DB, route
test, benchmark. 15 mutations of the attribution logic each fail a test.
- JS: `test-node-hop-analytics.js` (filters, histogram, quartiles and
whiskers with a fixture that separates 1.5 IQR from 3 IQR, render),
registered in `test-all.sh` and `.github/workflows/deploy.yml`.
- `gofmt`, `go vet ./...`, `go test ./...` in `cmd/server`,
`scripts/check-css-vars.js` pass.
## Staging validation
Build `c646310f` (this PR's review follow-up together with the other
open follow-ups), after a container restart and full load, on a busy
Belgian repeater:
- `hop_analytics?days=7`: 23,302 packets, 11,548 ambiguous, median 4,
adverts never above hop 7 (matching the firmware default
`flood_max_advert = 8`, `examples/simple_repeater/MyMesh.cpp:922`), 1.2
s. The first version reported 23,035 packets and 86 ambiguous in 534 ms,
because it trusted the resolver's pick for colliding prefixes.
- The card rendered on the first version with no console errors; the
rework does not touch the frontend beyond a test fixture.
## Not verified
- Response time and lock hold for 30 days on the busiest node on a
14-day store.
- Server relay candidates exclude companions and listeners while the
ingestor's prefix index does not, so a few strict attributions can
differ from the ingestor's persisted `resolved_path`.
- Identical numbers across a second container restart were shown in a Go
test, not repeated on staging.
- Dark theme, phone width, and switching the range picker in the
browser.
- Filter state is not reflected in the URL hash (the range picker is not
either).
Fixes #1812
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
239 lines
23 KiB
HTML
239 lines
23 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 () {
|
|
try {
|
|
var saved = localStorage.getItem('meshcore-theme');
|
|
var t = 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 */ }
|
|
})();
|
|
</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="nav-drawer.css?v=__BUST__">
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/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="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
|
crossorigin="anonymous"></script>
|
|
<script src="vendor/leaflet.markercluster.js?v=__BUST__"></script>
|
|
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
|
<script src="https://unpkg.com/chart.js@4/dist/chart.umd.min.js"></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="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="packets.js?v=__BUST__"></script>
|
|
<script src="geo-filter-overlay.js?v=__BUST__"></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="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="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="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="mobile-page-actions.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
|
</body>
|
|
</html>
|