mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-13 18:37:45 +00:00
Part E of #2128: a logged-in user watches nodes and gets one mail when a watched node goes offline, comes back, or reports a low battery. Admins can add instance events: a new foreign node, and an observer going offline or back. This covers the per-user mail part of #775. Stacked on #2139 (part D, which builds on #2138). Review the commits after that branch. ## The situation A sysop learns that a repeater went silent, or that its battery is running down, only by opening CoreScope. #775 asks for notifications. Accounts (part A) now give a verified address to mail and a mailer with delivery status. ## What this PR adds **Events.** They use the instance thresholds, so a mail does not disagree with the node page: - `node.offline`: silent for the role's `healthThresholds` window. Last heard comes from the packet store, and repeaters and rooms count relayed traffic (#1598). - `node.battery`: advert telemetry below `batteryThresholds.lowMv`, recovered at `lowMv + 100`. - Admins only: `foreign.new` (once per node) and `observer.offline`. **Store.** Schema v5 with `notification_prefs`, `notification_watches` and `notification_state`. **Server** (only with `userManagement.notifications.enabled`) - A notifier next to the janitor, every `intervalMinutes` (5). The evaluator is a pure function of watches, prefs, stored states and node snapshots. - No burst after a restart or a new watch: a subject's first evaluation stores its state without mailing. The loop waits for the startup load. While ingest is stale (newest packet older than 30 minutes) the offline checks pause, and after recovery they wait one silent window. - One mail per user per check. Limits: 20 per user and 100 per instance per rolling 24 hours, 50 watches per user. A change over a limit is recorded and never mailed later. - Every mail has a one-click unsubscribe (`List-Unsubscribe` and `List-Unsubscribe-Post`). The GET only redirects to a confirm page, so link scanners cannot unsubscribe anyone. Node names are cleaned of control and bidi characters before they go into a mail. - Routes: `GET`/`PUT /api/account/notifications`, `PUT`/`DELETE /api/account/notifications/watches/{pubkey}`, `POST /api/account/notifications/watch-my-nodes` (copies the synced "my nodes" list), `GET`/`POST /api/notifications/unsubscribe`. `mailer.Message` gains `Headers`. **Frontend.** A "Notify me" toggle on the node pages, a Notifications section on the account page (`#/account?section=notifications`), an unsubscribe page, and the notification mail count on the admin overview. Spec: [`docs/specs/2026-10-07-node-notifications-design.md`](https://github.com/efiten/CoreScope/blob/feat/notifications/docs/specs/2026-10-07-node-notifications-design.md). ## Performance Each check reads watches, prefs and states from `users.db` (one query each), the watched nodes from the analyzer DB in chunks of 500, and last-heard times from the packet store. | Measurement | Result | |---|---| | One check on our staging instance, 130,000 packets in memory, 1 watcher | 6.9 ms, of which 0.93 ms holding the store read lock | | Evaluator benchmark, 100 users with 50 watches each, 2,000 nodes | 1.75 ms | No work is added to ingest, broadcast or any request path. ## Verification - `internal/users` and `internal/mailer` with `-race`; the `cmd/server` suite plus `-race` on the notifier tests; 74 new Go tests. The same Windows-only failure as noted in #2138 applies. - `sh test-all.sh` exits 0; the XSS gate in diff mode passes. - User-management E2E: 26 of 26 steps locally. With notifications off the new steps fail, so they do test the flag. - On our staging and production instance since 7 October 2026. ## Not in this PR - Other channels (Discord, Telegram, webhooks). Detection is separate from delivery, so one can be added. - The topology, RF and anomaly alerts of #775. - If the whole server starts after a feed outage that already ended, the grace window is unknown and a watcher can get one wrong offline mail. The user guide says so. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
255 lines
24 KiB
HTML
255 lines
24 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="account.css?v=__BUST__">
|
|
<link rel="stylesheet" href="admin.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"
|
|
integrity="sha384-mFKkGiGvT5vo1fEyGCD3hshDdKmW3wzXW/x+fWriYJArD0R3gawT6lMvLboM22c0"
|
|
crossorigin="anonymous"></script>
|
|
<script src="https://unpkg.com/chart.js@4.5.1/dist/chart.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.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="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.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>
|