diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ed4cfb..11ad197 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,9 +33,43 @@ jobs: python-version-file: ".python-version" cache: pip + - name: Set up Node + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + + - name: Install frontend deps + run: npm ci + - name: Run pre-commit uses: pre-commit/action@v3.0.1 + frontend: + name: Frontend + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + + - name: Set up Node + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npx tsc --noEmit + + - name: Unit tests + run: npm run test:frontend + + - name: Build + run: npm run build + test: name: Test runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index e042944..10ec52c 100644 --- a/.gitignore +++ b/.gitignore @@ -229,3 +229,9 @@ node_modules/ src/meshcore_hub/web/static/vendor/ src/meshcore_hub/web/static/dist/ src/meshcore_hub/web/static/css/tailwind.css + +# Playwright e2e artifacts +/e2e/.auth/ +/e2e/test-results/ +/e2e/playwright-report/ +/e2e/blob-report/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1f49c18..c6b1462 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,3 +41,15 @@ repos: - alembic>=1.7.0 - types-paho-mqtt>=1.6.0 - types-PyYAML>=6.0.0 + + # Frontend TypeScript gate. Runs in the host node toolchain (language: system) + # so it resolves types from the project's node_modules — requires `npm install`. + # `pass_filenames: false`: tsc typechecks the whole project (tsconfig include). + - repo: local + hooks: + - id: frontend-typecheck + name: frontend typecheck (tsc --noEmit) + entry: npm run typecheck + language: system + pass_filenames: false + files: '(spa-react/.*\.tsx?$|^tsconfig\.json$|^package(-lock)?\.json$)' diff --git a/AGENTS.md b/AGENTS.md index acb911b..2057401 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ ``` `--no-cov` skips coverage for speed; the pipe surfaces only the pass/fail summary. - Use Python (version in `.python-version`); activate a venv in `.venv` before running pytest, pre-commit, or alembic locally. -- **All other operations run inside the compose stack** — never invoke `meshcore-hub` or `npm` directly on the host; build/run/exec via `docker compose` (see Development). +- **Application operations run inside the compose stack** — never invoke `meshcore-hub` directly on the host; build/run/exec via `docker compose` (see Development). The frontend `npm`/`vite`/`tsc` toolchain is the exception — it runs on the host (see Frontend). - **Never `git push` without explicit confirmation** — staging and committing discrete changes is fine. - **Never build the Docker images or run `make build` / `make up`** — the user builds manually to test. Stop after code changes + tests + pre-commit pass. - **Always generate random Alembic revision IDs** — use `python -c "import secrets; print(secrets.token_hex(6))"` or let `alembic revision` auto-generate. Never hand-pick sequential or guessable IDs like `a1b2c3d4e5f6` — they collide with existing migrations and cause cycle errors at upgrade time. @@ -40,6 +40,42 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core ex # Shorthands (Makefile, mqtt+core profiles): make build | make up | make down | make logs ``` +## Frontend (React) + +The web UI is a **React 19 + TypeScript + Vite** SPA in +`src/meshcore_hub/web/static/js/spa-react/` (alias `@/` → that dir). The Jinja2 shell +(`web/templates/spa.html`) renders only SEO/`window.__APP_CONFIG__`/footer; React renders the +navbar, banners, and routed pages into `
` |
| Inline code | `` `code` `` | Styled with monospace font |
| Blockquotes | `> quote` | Left border styling |
| Images | `` | Use absolute paths to `/media/` |
-| Table of contents | `[TOC]` marker | Auto-generated from headings |
+| Task lists | `- [ ] item` / `- [x] item` | GFM task lists |
+| Strikethrough | `~~text~~` | GFM |
## Docker Configuration
diff --git a/docs/i18n.md b/docs/i18n.md
index 6618653..ca834e9 100644
--- a/docs/i18n.md
+++ b/docs/i18n.md
@@ -499,5 +499,5 @@ User profile page (OIDC authenticated users):
If you're unsure about the context of a translation key, check:
1. The "Context" column in this reference
-2. The JavaScript files in `/src/meshcore_hub/web/static/js/spa/pages/`
+2. The JavaScript files in `/src/meshcore_hub/web/static/js/spa-react/pages/`
3. Grep for the key: `grep -r "t('section.key')" src/`
diff --git a/docs/upgrading.md b/docs/upgrading.md
index bbdab6c..177a2b0 100644
--- a/docs/upgrading.md
+++ b/docs/upgrading.md
@@ -2,6 +2,21 @@
This guide covers upgrading from a previous MeshCore Hub release to the current version. Check the relevant version section below before upgrading.
+## v0.17.0
+
+### React Web UI
+
+The web dashboard frontend has been rewritten from lit-html ES modules to **React 19 + TypeScript + Vite**. The entire UI — navbar, footer, banners, theme toggle, and all pages — is now rendered by React. The Jinja2 shell (`spa.html`) is now a thin bootstrap: it renders only SEO `` meta, `window.__APP_CONFIG__`, font preloads, and the early theme-init script.
+
+**No configuration changes, no database migration, no `.env` changes required.** All existing env vars (`FEATURE_*`, `WEB_*`, `SYSTEM_ANNOUNCEMENT`, etc.) work identically. The upgrade is purely a frontend swap — the API, collector, and all backend behaviour are unchanged.
+
+What changed alongside the rewrite:
+
+- The `markdown` Python dependency was removed — announcements and custom pages now ship raw markdown to the client, rendered by `react-markdown`.
+- Vendor JS libraries (Chart.js, Leaflet, react-qr-code) are bundled by Vite into `static/dist/`; only fonts remain vendored in `static/vendor/`.
+- The old `src/meshcore_hub/web/static/js/spa/` directory has been removed. The Vite build (`npm run build`) is required — there is no fallback bundle.
+- Custom CSS overrides and themes are unaffected (Tailwind/DaisyUI unchanged; `app.css` still loaded).
+
## v0.16.0
### Route Health Monitoring
diff --git a/e2e/content/pages/about.md b/e2e/content/pages/about.md
new file mode 100644
index 0000000..7d4d5b8
--- /dev/null
+++ b/e2e/content/pages/about.md
@@ -0,0 +1,17 @@
+---
+title: About
+slug: about
+menu_order: 10
+---
+
+# About the E2E Network
+
+This page is **rendered from markdown** served by the Playwright test stack.
+
+- Deterministic content mounted via `CONTENT_HOME`
+- Sourced from `e2e/content/pages/about.md`
+- Fetched by the SPA from `/spa/pages/about`
+
+## Getting Started
+
+Some introductory detail used as a deep-link target for the heading-anchor tests.
diff --git a/e2e/docker-compose.test.yml b/e2e/docker-compose.test.yml
new file mode 100644
index 0000000..35fe72c
--- /dev/null
+++ b/e2e/docker-compose.test.yml
@@ -0,0 +1,247 @@
+# MeshCore Hub - Playwright End-to-End Test Stack
+#
+# Self-contained, throwaway stack for the Playwright suite in this directory.
+# Runs its OWN ephemeral Postgres (schema created by `migrate` via Alembic) and
+# NEVER touches the local development database: distinct project name, distinct
+# named volumes, no host port for Postgres, and no ${VAR} interpolation (so the
+# dev .env is never consulted).
+#
+# Usage (from the repo root):
+# make e2e-build # docker compose -f e2e/docker-compose.test.yml build
+# make e2e-up # ... up -d
+# make e2e-test # npm run test:e2e (seeds data, then runs Playwright)
+# make e2e-down # ... down -v (destroys the throwaway database)
+
+name: meshcore-e2e
+
+services:
+ # ==========================================================================
+ # PostgreSQL - ephemeral database for the e2e stack (not published to host)
+ # ==========================================================================
+ postgres:
+ image: postgres:17-alpine
+ container_name: meshcore-test-postgres
+ environment:
+ - POSTGRES_USER=meshcorehub
+ - POSTGRES_PASSWORD=e2epassword
+ - POSTGRES_DB=meshcorehub
+ volumes:
+ - test_pg_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U meshcorehub -d meshcorehub"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+ start_period: 5s
+
+ # ==========================================================================
+ # Database Migrations - create the `meshcorehub` schema + tables (Alembic)
+ # ==========================================================================
+ migrate:
+ build:
+ context: ..
+ dockerfile: Dockerfile
+ container_name: meshcore-test-migrate
+ restart: "no"
+ depends_on:
+ postgres:
+ condition: service_healthy
+ volumes:
+ - test_data:/data
+ environment:
+ - DATA_HOME=/data
+ - DATABASE_BACKEND=postgres
+ - DATABASE_HOST=postgres
+ - DATABASE_PORT=5432
+ - DATABASE_NAME=meshcorehub
+ - DATABASE_SCHEMA=meshcorehub
+ - DATABASE_USER=meshcorehub
+ - DATABASE_PASSWORD=e2epassword
+ command: ["db", "upgrade"]
+
+ # ==========================================================================
+ # MQTT Broker
+ # ==========================================================================
+ mqtt:
+ image: ghcr.io/ipnet-mesh/meshcore-mqtt-broker:latest
+ container_name: meshcore-test-mqtt
+ ports:
+ - "11883:1883"
+ volumes:
+ - test_mqtt_data:/data
+ environment:
+ - MQTT_WS_PORT=1883
+ - MQTT_HOST=0.0.0.0
+ - AUTH_EXPECTED_AUDIENCE=mqtt.localhost
+ - SUBSCRIBER_MAX_CONNECTIONS_DEFAULT=5
+ - SUBSCRIBER_1=test-admin:test-password:1
+ - ABUSE_ENFORCEMENT_ENABLED=false
+ - ABUSE_DUPLICATE_WINDOW_SIZE=100
+ - ABUSE_DUPLICATE_WINDOW_MS=300000
+ - ABUSE_DUPLICATE_THRESHOLD=10
+ - ABUSE_MAX_DUPLICATES_PER_PACKET=5
+ - ABUSE_DUPLICATE_RATE_THRESHOLD=0.3
+ - ABUSE_DUPLICATE_RATE_WINDOW_MS=300000
+ - ABUSE_BUCKET_CAPACITY=20
+ - ABUSE_BUCKET_REFILL_RATE=3
+ - ABUSE_MAX_PACKET_SIZE=255
+ - ABUSE_MAX_TOPICS_PER_DAY=3
+ - ABUSE_ANOMALY_THRESHOLD=10
+ - ABUSE_MAX_IATA_CHANGES_24H=3
+ - ABUSE_TOPIC_HISTORY_SIZE=50
+ - ABUSE_TOPIC_HISTORY_WINDOW_MS=86400000
+ - ABUSE_PERSISTENCE_PATH=/data/abuse-detection.db
+ - ABUSE_PERSISTENCE_INTERVAL_MS=300000
+ healthcheck:
+ test: ["CMD", "node", "-e", "const net=require('net');const s=net.createConnection(1883,'127.0.0.1',()=>{s.end();process.exit(0)});s.on('error',()=>process.exit(1));setTimeout(()=>process.exit(1),3000)"]
+ interval: 5s
+ timeout: 5s
+ retries: 3
+ start_period: 5s
+
+ # ==========================================================================
+ # Collector - mounts the deterministic e2e seed script (run via `make e2e-seed`
+ # / the Playwright global setup: `exec -T collector python /seed_data.py`)
+ # ==========================================================================
+ collector:
+ build:
+ context: ..
+ dockerfile: Dockerfile
+ container_name: meshcore-test-collector
+ depends_on:
+ migrate:
+ condition: service_completed_successfully
+ mqtt:
+ condition: service_healthy
+ volumes:
+ - test_data:/data
+ - ./seed_data.py:/seed_data.py:ro
+ environment:
+ - LOG_LEVEL=INFO
+ - MQTT_HOST=mqtt
+ - MQTT_PORT=1883
+ - MQTT_PREFIX=test
+ - MQTT_TRANSPORT=websockets
+ - MQTT_WS_PATH=/
+ - MQTT_USERNAME=test-admin
+ - MQTT_PASSWORD=test-password
+ - DATA_HOME=/data
+ - DATABASE_BACKEND=postgres
+ - DATABASE_HOST=postgres
+ - DATABASE_PORT=5432
+ - DATABASE_NAME=meshcorehub
+ - DATABASE_SCHEMA=meshcorehub
+ - DATABASE_USER=meshcorehub
+ - DATABASE_PASSWORD=e2epassword
+ command: ["collector"]
+ healthcheck:
+ test: ["CMD", "pgrep", "-f", "meshcore-hub"]
+ interval: 5s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+
+ # ==========================================================================
+ # API Server
+ # ==========================================================================
+ api:
+ build:
+ context: ..
+ dockerfile: Dockerfile
+ container_name: meshcore-test-api
+ depends_on:
+ migrate:
+ condition: service_completed_successfully
+ mqtt:
+ condition: service_healthy
+ ports:
+ - "18000:8000"
+ volumes:
+ - test_data:/data
+ environment:
+ - LOG_LEVEL=INFO
+ - MQTT_HOST=mqtt
+ - MQTT_PORT=1883
+ - MQTT_PREFIX=test
+ - MQTT_TRANSPORT=websockets
+ - MQTT_WS_PATH=/
+ - MQTT_USERNAME=test-admin
+ - MQTT_PASSWORD=test-password
+ - DATA_HOME=/data
+ - DATABASE_BACKEND=postgres
+ - DATABASE_HOST=postgres
+ - DATABASE_PORT=5432
+ - DATABASE_NAME=meshcorehub
+ - DATABASE_SCHEMA=meshcorehub
+ - DATABASE_USER=meshcorehub
+ - DATABASE_PASSWORD=e2epassword
+ - API_HOST=0.0.0.0
+ - API_PORT=8000
+ - API_READ_KEY=test-read-key
+ - API_ADMIN_KEY=test-admin-key
+ command: ["api"]
+ healthcheck:
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
+ interval: 5s
+ timeout: 5s
+ retries: 6
+ start_period: 10s
+
+ # ==========================================================================
+ # Web Dashboard (serves the built SPA + proxies /api/* to the API)
+ # - OIDC enabled with a known session secret so Playwright can forge the
+ # signed `meshcore-session` cookie (see e2e/mint_session.py). No real IdP
+ # is required; discovery failure only logs a warning.
+ # - WEB_AUTO_REFRESH_SECONDS=2 makes list polling fast enough to assert.
+ # - CONTENT_HOME provides the custom markdown page (e2e/content/pages).
+ # ==========================================================================
+ web:
+ build:
+ context: ..
+ dockerfile: Dockerfile
+ container_name: meshcore-test-web
+ depends_on:
+ api:
+ condition: service_healthy
+ ports:
+ - "18080:8080"
+ volumes:
+ - ./content:/content:ro
+ environment:
+ - LOG_LEVEL=INFO
+ - API_BASE_URL=http://api:8000
+ # Admin key: the web proxy uses this as its Bearer token to the API,
+ # and admin-only writes (routes/channels) require it at the API layer.
+ # Mirrors the root compose (API_KEY=${API_ADMIN_KEY:-${API_READ_KEY}}).
+ - API_KEY=test-admin-key
+ - WEB_HOST=0.0.0.0
+ - WEB_PORT=8080
+ - NETWORK_NAME=Test Network
+ - SYSTEM_ANNOUNCEMENT=**Outage** window scheduled
+ - NETWORK_ANNOUNCEMENT=**Maintenance** window tonight
+ - WEB_LOCALE=en
+ - WEB_THEME=dark
+ - WEB_AUTO_REFRESH_SECONDS=2
+ - CONTENT_HOME=/content
+ - OIDC_ENABLED=true
+ - OIDC_CLIENT_ID=e2e-client
+ - OIDC_CLIENT_SECRET=e2e-secret
+ - OIDC_DISCOVERY_URL=https://idp.invalid/.well-known/openid-configuration
+ - OIDC_REDIRECT_URI=http://localhost:18080/auth/callback
+ - OIDC_SESSION_SECRET=test-session-secret
+ - OIDC_COOKIE_SECURE=false
+ command: ["web"]
+ healthcheck:
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
+ interval: 5s
+ timeout: 5s
+ retries: 6
+ start_period: 10s
+
+volumes:
+ test_pg_data:
+ name: meshcore_test_pg_data
+ test_data:
+ name: meshcore_test_data
+ test_mqtt_data:
+ name: meshcore_test_mqtt_data
diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts
new file mode 100644
index 0000000..649017b
--- /dev/null
+++ b/e2e/global-setup.ts
@@ -0,0 +1,157 @@
+import { execFile } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { promisify } from "node:util";
+import { chromium } from "@playwright/test";
+
+const execFileAsync = promisify(execFile);
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(HERE, "..");
+const COMPOSE_FILE = path.join(HERE, "docker-compose.test.yml");
+const BASE_URL = process.env.E2E_BASE_URL ?? "http://localhost:18080";
+const SESSION_SECRET = process.env.E2E_SESSION_SECRET ?? "test-session-secret";
+const PYTHON = process.env.E2E_PYTHON ?? path.join(ROOT, ".venv", "bin", "python");
+const AUTH_DIR = path.join(HERE, ".auth");
+
+const READY_TIMEOUT_MS = 120_000;
+const DATA_TIMEOUT_MS = 60_000;
+
+async function seedDatabase(): Promise {
+ try {
+ await execFileAsync(
+ "docker",
+ [
+ "compose",
+ "-f",
+ COMPOSE_FILE,
+ "exec",
+ "-T",
+ "collector",
+ "python",
+ "/seed_data.py",
+ ],
+ { cwd: ROOT },
+ );
+ } catch (error) {
+ const detail = error instanceof Error ? error.message : String(error);
+ throw new Error(
+ "Failed to seed the e2e database. Is the stack running? Start it with " +
+ "`make e2e-up` (or `docker compose -f e2e/docker-compose.test.yml " +
+ "up -d`).\n" +
+ detail,
+ );
+ }
+}
+
+async function poll(
+ url: string,
+ predicate: (body: unknown) => boolean,
+ timeoutMs: number,
+ description: string,
+): Promise {
+ const deadline = Date.now() + timeoutMs;
+ let lastError = "";
+ while (Date.now() < deadline) {
+ try {
+ const response = await fetch(url);
+ if (response.ok) {
+ const body = (await response.json()) as unknown;
+ if (predicate(body)) {
+ return;
+ }
+ lastError = `unexpected response body: ${JSON.stringify(body)}`;
+ } else {
+ lastError = `HTTP ${response.status}`;
+ }
+ } catch (error) {
+ lastError = error instanceof Error ? error.message : String(error);
+ }
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ }
+ throw new Error(
+ `Timed out waiting for ${description} at ${url} (${lastError}). ` +
+ "Is the e2e stack running? Start it with `make e2e-up`.",
+ );
+}
+
+async function waitForStack(): Promise {
+ await poll(
+ `${BASE_URL}/health/ready`,
+ (body) => (body as { status?: string }).status === "ready",
+ READY_TIMEOUT_MS,
+ "the web service to become ready",
+ );
+ await poll(
+ `${BASE_URL}/api/v1/nodes?limit=1`,
+ (body) =>
+ typeof (body as { total?: number }).total === "number" &&
+ (body as { total: number }).total > 0,
+ DATA_TIMEOUT_MS,
+ "seeded data to be visible via the API",
+ );
+}
+
+async function mintSessionCookie(
+ sub: string,
+ name: string,
+ email: string,
+ roles: string,
+): Promise {
+ const { stdout } = await execFileAsync(PYTHON, [
+ path.join(HERE, "mint_session.py"),
+ SESSION_SECRET,
+ sub,
+ name,
+ email,
+ roles,
+ ]);
+ const cookie = stdout.trim();
+ if (!cookie) {
+ throw new Error("mint_session.py produced an empty cookie");
+ }
+ return cookie;
+}
+
+async function writeStorageState(cookie: string, file: string): Promise {
+ const browser = await chromium.launch();
+ try {
+ const context = await browser.newContext();
+ await context.addCookies([
+ {
+ name: "meshcore-session",
+ value: cookie,
+ domain: new URL(BASE_URL).hostname,
+ path: "/",
+ httpOnly: true,
+ secure: false,
+ sameSite: "Lax",
+ },
+ ]);
+ await context.storageState({ path: file });
+ } finally {
+ await browser.close();
+ }
+}
+
+export default async function globalSetup(): Promise {
+ await seedDatabase();
+ await waitForStack();
+
+ fs.mkdirSync(AUTH_DIR, { recursive: true });
+ const adminCookie = await mintSessionCookie(
+ "pw-admin",
+ "PW Admin",
+ "pw-admin@example.com",
+ "admin,member",
+ );
+ const memberCookie = await mintSessionCookie(
+ "pw-member",
+ "PW Member",
+ "pw-member@example.com",
+ "member",
+ );
+ await writeStorageState(adminCookie, path.join(AUTH_DIR, "admin.json"));
+ await writeStorageState(memberCookie, path.join(AUTH_DIR, "member.json"));
+}
diff --git a/e2e/mint_session.py b/e2e/mint_session.py
new file mode 100644
index 0000000..ba76045
--- /dev/null
+++ b/e2e/mint_session.py
@@ -0,0 +1,53 @@
+"""Mint a signed ``meshcore-session`` cookie for Playwright e2e tests.
+
+Reproduces Starlette's SessionMiddleware signing scheme (an
+``itsdangerous.TimestampSigner`` over the base64-encoded session JSON) so the
+forged cookie is accepted by the web tier exactly like a real OIDC login -
+populating ``window.__APP_CONFIG__`` (user + roles) and driving the API proxy's
+``X-User-Id`` / ``X-User-Roles`` injection. No IdP round-trip is performed.
+
+The secret must match the stack's ``OIDC_SESSION_SECRET``
+(``test-session-secret`` in ``e2e/docker-compose.test.yml``).
+
+Usage:
+ python e2e/mint_session.py
+
+Prints the cookie value to stdout.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+import sys
+
+import itsdangerous
+
+
+def mint(secret: str, sub: str, name: str, email: str, roles_csv: str) -> str:
+ """Return a signed session-cookie value for the given identity/roles."""
+ session = {
+ "user": {
+ "sub": sub,
+ "name": name,
+ "email": email,
+ "picture": None,
+ "roles": [r.strip() for r in roles_csv.split(",") if r.strip()],
+ }
+ }
+ data = base64.b64encode(json.dumps(session).encode("utf-8"))
+ signed = itsdangerous.TimestampSigner(secret).sign(data).decode("utf-8")
+ return str(signed)
+
+
+def main() -> None:
+ if len(sys.argv) != 6:
+ raise SystemExit(
+ "usage: mint_session.py "
+ )
+ secret, sub, name, email, roles_csv = sys.argv[1:6]
+ print(mint(secret, sub, name, email, roles_csv))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts
new file mode 100644
index 0000000..893f1ff
--- /dev/null
+++ b/e2e/playwright.config.ts
@@ -0,0 +1,27 @@
+import { defineConfig } from "@playwright/test";
+
+const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:18080";
+
+export default defineConfig({
+ testDir: "./tests",
+ outputDir: "./test-results",
+ globalSetup: "./global-setup.ts",
+ // Single throwaway backend: run serially so mutating specs (routes, profile)
+ // cannot race each other.
+ fullyParallel: false,
+ workers: 1,
+ retries: 0,
+ reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]],
+ use: {
+ baseURL,
+ headless: true,
+ viewport: { width: 1280, height: 800 },
+ actionTimeout: 15_000,
+ navigationTimeout: 30_000,
+ trace: "retain-on-failure",
+ screenshot: "only-on-failure",
+ },
+ expect: {
+ timeout: 15_000,
+ },
+});
diff --git a/e2e/seed_data.py b/e2e/seed_data.py
new file mode 100644
index 0000000..3ec4ed2
--- /dev/null
+++ b/e2e/seed_data.py
@@ -0,0 +1,491 @@
+"""Deterministic seed data for the Playwright end-to-end test stack.
+
+Runs inside the e2e collector container (which has the app and the Postgres
+driver installed) against the throwaway e2e database:
+
+ docker compose -f e2e/docker-compose.test.yml exec -T collector \
+ python /seed_data.py
+
+Idempotent: clears previously seeded rows and recreates them with fixed public
+keys and recent timestamps, so every run yields the same dataset. The e2e
+stack uses its own ephemeral Postgres instance - this never touches the local
+development database.
+"""
+
+from __future__ import annotations
+
+import time
+from datetime import datetime, timedelta, timezone
+
+from sqlalchemy import delete
+from sqlalchemy.exc import OperationalError
+from sqlalchemy.orm import Session
+
+from meshcore_hub.common.config import get_common_settings
+from meshcore_hub.common.database import (
+ create_database_engine,
+ create_session_factory,
+)
+from meshcore_hub.common.models import (
+ Advertisement,
+ Channel,
+ EventObserver,
+ Message,
+ Node,
+ NodeTag,
+ PacketPathHop,
+ RawPacket,
+ Route,
+ RouteNode,
+ RouteObserver,
+ RouteRecentMatch,
+ RouteResult,
+ RouteResultHistory,
+ UserProfile,
+ UserProfileNode,
+)
+
+NOW = datetime.now(timezone.utc)
+
+ALPHA = ("a1fa" + "0" * 64)[:64]
+BRAVO = ("b2b0" + "0" * 64)[:64]
+CHARLIE = ("c3c0" + "0" * 64)[:64]
+DELTA = ("d4d0" + "0" * 64)[:64]
+NORTH_1 = ("aa01" + "0" * 64)[:64]
+NORTH_2 = ("aa02" + "0" * 64)[:64]
+SOUTH_1 = ("bb01" + "0" * 64)[:64]
+SOUTH_2 = ("bb02" + "0" * 64)[:64]
+
+PATH_HOPS = [ALPHA[:4].upper(), BRAVO[:4].upper(), CHARLIE[:4].upper()]
+
+
+def _hash(seed: str) -> str:
+ return (seed + "0" * 32)[:32]
+
+
+def _event_hash(counter: int) -> str:
+ return f"{counter:032x}"
+
+
+def _ago(minutes: float = 0.0, hours: float = 0.0, days: float = 0.0) -> datetime:
+ return NOW - timedelta(minutes=minutes, hours=hours, days=days)
+
+
+def clear(session: Session) -> None:
+ for model in (
+ RouteRecentMatch,
+ RouteResultHistory,
+ RouteResult,
+ RouteObserver,
+ RouteNode,
+ Route,
+ PacketPathHop,
+ RawPacket,
+ EventObserver,
+ Advertisement,
+ Message,
+ UserProfileNode,
+ UserProfile,
+ NodeTag,
+ Node,
+ Channel,
+ ):
+ session.execute(delete(model))
+
+
+def seed_nodes(session: Session) -> dict[str, Node]:
+ content_specs = [
+ (ALPHA, "Alpha Node", "chat", 51.5074, -0.1278),
+ (BRAVO, "Bravo Node", "repeater", 52.4862, -1.8904),
+ (CHARLIE, "Charlie Node", "room", 53.4808, -2.2426),
+ (DELTA, "Delta Node", "chat", None, None),
+ ]
+ observer_specs = [
+ (NORTH_1, "North Observer 1", "North", 51.6, -0.2),
+ (NORTH_2, "North Observer 2", "North", 51.7, -0.3),
+ (SOUTH_1, "South Observer 1", "South", 52.5, -1.9),
+ (SOUTH_2, "South Observer 2", "South", 52.6, -2.0),
+ ]
+
+ nodes: dict[str, Node] = {}
+ for i, (pk, name, adv_type, lat, lon) in enumerate(content_specs):
+ node = Node(
+ public_key=pk,
+ name=name,
+ adv_type=adv_type,
+ lat=lat,
+ lon=lon,
+ first_seen=_ago(days=30),
+ last_seen=_ago(minutes=i + 1),
+ )
+ session.add(node)
+ nodes[pk] = node
+
+ for i, (pk, name, _area, lat, lon) in enumerate(observer_specs):
+ node = Node(
+ public_key=pk,
+ name=name,
+ adv_type="repeater",
+ lat=lat,
+ lon=lon,
+ is_observer=True,
+ first_seen=_ago(days=30),
+ last_seen=_ago(minutes=i + 1),
+ )
+ session.add(node)
+ nodes[pk] = node
+
+ session.flush()
+ for pk, _name, area, _lat, _lon in observer_specs:
+ session.add(NodeTag(node_id=nodes[pk].id, key="area", value=area))
+ for pk in nodes:
+ session.add(NodeTag(node_id=nodes[pk].id, key="name", value=nodes[pk].name))
+ session.flush()
+ return nodes
+
+
+def seed_advertisements(
+ session: Session, nodes: dict[str, Node]
+) -> dict[str, tuple[str, float]]:
+ specs = [
+ (ALPHA, NORTH_1, "ad01", "flood", 30.0),
+ (BRAVO, SOUTH_1, "ad02", "flood", 45.0),
+ (CHARLIE, NORTH_2, "ad03", "direct", 60.0),
+ (DELTA, SOUTH_2, "ad04", "transport_flood", 90.0),
+ (ALPHA, SOUTH_1, "ad05", "flood", 120.0),
+ (BRAVO, NORTH_1, "ad06", "flood", 150.0),
+ ]
+ events: dict[str, tuple[str, float]] = {}
+ for i, (node_pk, observer_pk, seed, route_type, minutes) in enumerate(specs):
+ packet_hash = _hash(seed)
+ event_hash = _event_hash(i + 1)
+ events[packet_hash] = (event_hash, minutes)
+ node = nodes[node_pk]
+ received_at = _ago(minutes=minutes)
+ session.add(
+ Advertisement(
+ observer_node_id=nodes[observer_pk].id,
+ node_id=node.id,
+ public_key=node_pk,
+ name=node.name,
+ adv_type=node.adv_type,
+ received_at=received_at,
+ event_hash=event_hash,
+ packet_hash=packet_hash,
+ route_type=route_type,
+ advert_timestamp=received_at,
+ )
+ )
+ session.add(
+ EventObserver(
+ event_type="advertisement",
+ event_hash=event_hash,
+ observer_node_id=nodes[observer_pk].id,
+ snr=7.5,
+ path_len=2,
+ observed_at=received_at,
+ )
+ )
+ session.flush()
+ return events
+
+
+def seed_messages(
+ session: Session, nodes: dict[str, Node], custom_channel_idx: int
+) -> dict[str, tuple[str, float]]:
+ specs = [
+ ("channel", NORTH_1, "ce01", 10.0, "Hello from the e2e mesh", 17, None),
+ ("channel", SOUTH_1, "ce02", 20.0, "Channel check from the south", 17, None),
+ (
+ "channel",
+ NORTH_2,
+ "ce03",
+ 25.0,
+ "Ops channel traffic",
+ custom_channel_idx,
+ None,
+ ),
+ (
+ "contact",
+ SOUTH_2,
+ "ce04",
+ 15.0,
+ "Direct hello over the mesh",
+ None,
+ ALPHA[:12],
+ ),
+ ]
+ events: dict[str, tuple[str, float]] = {}
+ for i, (mtype, observer_pk, seed, minutes, text, channel_idx, prefix) in enumerate(
+ specs
+ ):
+ packet_hash = _hash(seed)
+ event_hash = _event_hash(100 + i)
+ events[packet_hash] = (event_hash, minutes)
+ received_at = _ago(minutes=minutes)
+ session.add(
+ Message(
+ observer_node_id=nodes[observer_pk].id,
+ message_type=mtype,
+ pubkey_prefix=prefix,
+ channel_idx=channel_idx,
+ text=text,
+ path_len=2,
+ snr=6.5,
+ received_at=received_at,
+ event_hash=event_hash,
+ packet_hash=packet_hash,
+ )
+ )
+ session.add(
+ EventObserver(
+ event_type="message",
+ event_hash=event_hash,
+ observer_node_id=nodes[observer_pk].id,
+ snr=6.5,
+ path_len=2,
+ observed_at=received_at,
+ )
+ )
+ session.flush()
+ return events
+
+
+def seed_raw_packets(
+ session: Session,
+ nodes: dict[str, Node],
+ advert_events: dict[str, tuple[str, float]],
+ message_events: dict[str, tuple[str, float]],
+ custom_channel_idx: int,
+) -> str:
+ channel_indices = {
+ _hash("ce01"): 17,
+ _hash("ce02"): 17,
+ _hash("ce03"): custom_channel_idx,
+ }
+ events = {
+ **{h: (e, m, "advertisement") for h, (e, m) in advert_events.items()},
+ **{
+ h: (e, m, "contact_msg_recv" if h == _hash("ce04") else "channel_msg_recv")
+ for h, (e, m) in message_events.items()
+ },
+ }
+
+ first_raw_packet_id = ""
+ for packet_hash, (event_hash, minutes, event_type) in sorted(events.items()):
+ for j, observer_pk in enumerate((NORTH_1, SOUTH_1)):
+ received_at = _ago(minutes=minutes) + timedelta(seconds=j * 3)
+ raw = RawPacket(
+ observer_node_id=nodes[observer_pk].id,
+ packet_hash=packet_hash,
+ event_hash=event_hash,
+ raw_hex=(packet_hash * 4)[:128],
+ packet_type=5,
+ payload_type=4 if event_type == "advertisement" else 5,
+ event_type=event_type,
+ channel_idx=channel_indices.get(packet_hash),
+ source_pubkey_prefix=ALPHA[:12],
+ route_type="flood",
+ path_len=3,
+ path_hash_bytes=2,
+ snr=8.5 - j * 2.25,
+ decoded={"e2e": True, "packet_hash": packet_hash},
+ received_at=received_at,
+ )
+ session.add(raw)
+ session.flush()
+ if not first_raw_packet_id:
+ first_raw_packet_id = raw.id
+ for position, node_hash in enumerate(PATH_HOPS):
+ session.add(
+ PacketPathHop(
+ raw_packet_id=raw.id,
+ position=position,
+ node_hash=node_hash,
+ packet_hash=packet_hash,
+ event_hash=event_hash,
+ received_at=received_at,
+ observer_node_id=nodes[observer_pk].id,
+ )
+ )
+ session.flush()
+ return first_raw_packet_id
+
+
+def seed_channels(session: Session) -> int:
+ keys = [
+ ("E2E General", "00112233445566778899aabbccddeeff" * 2),
+ ("E2E Ops", "ffeeddccbbaa99887766554433221100" * 2),
+ ]
+ for name, key_hex in keys:
+ session.add(
+ Channel(
+ name=name,
+ key_hex=key_hex,
+ channel_hash=Channel.compute_channel_hash(key_hex),
+ visibility="community",
+ enabled=True,
+ )
+ )
+ session.flush()
+ general_hash = Channel.compute_channel_hash(keys[0][1])
+ return int(general_hash, 16)
+
+
+def seed_routes(
+ session: Session, nodes: dict[str, Node], first_raw_packet_id: str
+) -> None:
+ route = Route(
+ from_label="Alpha Site",
+ to_label="Bravo Site",
+ description="Synthetic e2e route",
+ visibility="community",
+ match_width=2,
+ window_hours=48,
+ packet_count_threshold=3,
+ clear_threshold=6,
+ max_hop_span=8,
+ enabled=True,
+ reversible=True,
+ )
+ session.add(route)
+ session.flush()
+
+ for position, pk in enumerate((ALPHA, BRAVO)):
+ session.add(
+ RouteNode(
+ route_id=route.id,
+ node_id=nodes[pk].id,
+ position=position,
+ expected_hash=pk[:4].upper(),
+ )
+ )
+ session.add(RouteObserver(route_id=route.id, node_id=nodes[NORTH_1].id))
+
+ session.add(
+ RouteResult(
+ route_id=route.id,
+ state="healthy",
+ quality="clear",
+ matched_count=7,
+ threshold=3,
+ effective_clear=6,
+ evaluated_at=_ago(minutes=5),
+ quality_avg="clear",
+ )
+ )
+
+ history = [
+ ("clear", 7),
+ ("clear", 6),
+ ("marginal", 4),
+ ("clear", 5),
+ ("marginal", 3),
+ ("failing", 1),
+ ("clear", 6),
+ ]
+ for day_offset, (quality, matched) in enumerate(history):
+ session.add(
+ RouteResultHistory(
+ route_id=route.id,
+ date=(NOW - timedelta(days=day_offset)).date(),
+ quality=quality,
+ state="unhealthy" if quality == "failing" else "healthy",
+ matched_count=matched,
+ evaluated_at=_ago(days=day_offset),
+ )
+ )
+
+ if first_raw_packet_id:
+ session.add(
+ RouteRecentMatch(
+ route_id=route.id,
+ raw_packet_id=first_raw_packet_id,
+ first_position=0,
+ last_position=1,
+ )
+ )
+ session.flush()
+
+
+def seed_profiles(session: Session, nodes: dict[str, Node]) -> None:
+ specs = [
+ (
+ "pw-admin",
+ "PW Admin",
+ "E2EADM",
+ "admin,member",
+ "Playwright admin user",
+ "https://example.com/pw-admin",
+ ),
+ (
+ "pw-member",
+ "PW Member",
+ "E2EMBR",
+ "member",
+ "Playwright member user",
+ None,
+ ),
+ ("op-north", "Op North", "OPN1", "operator,member", "North operator", None),
+ ("mem-south", "Mem South", "MEMS1", "member", "South member", None),
+ ]
+ profiles: dict[str, UserProfile] = {}
+ for user_id, name, callsign, roles, description, url in specs:
+ profile = UserProfile(
+ user_id=user_id,
+ name=name,
+ callsign=callsign,
+ roles=roles,
+ description=description,
+ url=url,
+ )
+ session.add(profile)
+ profiles[user_id] = profile
+ session.flush()
+
+ session.add(
+ UserProfileNode(
+ user_profile_id=profiles["op-north"].id, node_id=nodes[ALPHA].id
+ )
+ )
+ session.add(
+ UserProfileNode(
+ user_profile_id=profiles["mem-south"].id, node_id=nodes[BRAVO].id
+ )
+ )
+ session.flush()
+
+
+def main() -> None:
+ settings = get_common_settings()
+ engine = create_database_engine(
+ settings.effective_database_url,
+ schema=settings.effective_database_schema,
+ )
+ session_factory = create_session_factory(engine)
+
+ last_error: OperationalError | None = None
+ for _ in range(15):
+ try:
+ with session_factory() as session:
+ clear(session)
+ nodes = seed_nodes(session)
+ custom_channel_idx = seed_channels(session)
+ advert_events = seed_advertisements(session, nodes)
+ message_events = seed_messages(session, nodes, custom_channel_idx)
+ first_raw_packet_id = seed_raw_packets(
+ session, nodes, advert_events, message_events, custom_channel_idx
+ )
+ seed_routes(session, nodes, first_raw_packet_id)
+ seed_profiles(session, nodes)
+ session.commit()
+ print("e2e seed data written")
+ return
+ except OperationalError as exc:
+ last_error = exc
+ time.sleep(2)
+ raise RuntimeError(f"database not reachable after retries: {last_error}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/e2e/tests/advertisements.spec.ts b/e2e/tests/advertisements.spec.ts
new file mode 100644
index 0000000..f802c76
--- /dev/null
+++ b/e2e/tests/advertisements.spec.ts
@@ -0,0 +1,91 @@
+import { expect, test } from "@playwright/test";
+import { countApiCalls, expectListLoaded, openFilters } from "../utils/helpers";
+
+const ALPHA_KEY = "a1fa" + "0".repeat(60);
+
+test.use({ permissions: ["clipboard-read", "clipboard-write"] });
+
+test.describe("advertisements", () => {
+ test("filter options work", async ({ page }) => {
+ await page.goto("/advertisements");
+ await expectListLoaded(page);
+ const table = page.locator("table");
+
+ await openFilters(page);
+ await page.locator('select[name="route_type"]').selectOption("all");
+ await page.getByRole("button", { name: "Filter" }).click();
+ await expect(page).toHaveURL(/route_type=all/);
+ await expect(table.getByText("Charlie Node").first()).toBeVisible();
+
+ await openFilters(page);
+ await page.locator('input[name="search"]').fill("Bravo");
+ await page.getByRole("button", { name: "Filter" }).click();
+ await expect(page).toHaveURL(/search=Bravo/);
+ // Two Bravo adverts (one per area); toHaveCount auto-waits out the refetch.
+ await expect(page.getByTestId("list-row")).toHaveCount(2);
+ await expect(table.getByText("Alpha Node")).toHaveCount(0);
+ });
+
+ test("auto-refresh works and can be paused", async ({ page }) => {
+ await page.goto("/advertisements");
+ await expectListLoaded(page);
+
+ const toggle = page.getByTestId("auto-refresh-toggle");
+ await expect(toggle).toBeChecked();
+
+ const active = await countApiCalls(page, "/api/v1/advertisements?", 5000);
+ expect(active).toBeGreaterThanOrEqual(2);
+
+ await toggle.click();
+ await expect(toggle).not.toBeChecked();
+ const paused = await countApiCalls(page, "/api/v1/advertisements?", 4500);
+ expect(paused).toBe(0);
+ });
+
+ test("table row actions work", async ({ page }) => {
+ await page.goto("/advertisements");
+ await expectListLoaded(page);
+ const table = page.locator("table");
+
+ await table.getByRole("link", { name: "Alpha Node" }).first().click();
+ await expect(page).toHaveURL(new RegExp(`/nodes/${ALPHA_KEY}`));
+ await page.goBack();
+ await expectListLoaded(page);
+
+ // Click a plain-text cell (Time): node links and copyable keys
+ // stopPropagation / have their own handlers.
+ await page.getByTestId("list-row").first().locator("td").nth(3).click();
+ await expect(page).toHaveURL(/\/packets\/hash\//);
+
+ await page.goto("/advertisements");
+ await expectListLoaded(page);
+ const copyable = table.locator('code[title="Click to copy"]').first();
+ await copyable.click();
+ await expect(page.getByText("Copied!").first()).toBeVisible();
+ });
+
+ test("observer toggles filter the list", async ({ page }) => {
+ await page.goto("/advertisements");
+ await expectListLoaded(page);
+
+ const north = page.locator('[data-testid="observer-area"][data-area="North"]');
+ const south = page.locator('[data-testid="observer-area"][data-area="South"]');
+ await expect(north.first()).toBeVisible();
+ await expect(south.first()).toBeVisible();
+ await expect(page.getByTestId("list-row")).toHaveCount(5);
+
+ await north.first().click();
+ await expect(north.first()).toHaveClass(/badge-ghost/);
+ await expect(page.getByTestId("list-row")).toHaveCount(3);
+
+ await north.first().click();
+ await expect(north.first()).toHaveClass(/badge-primary/);
+ await expect(page.getByTestId("list-row")).toHaveCount(5);
+
+ await south.first().click();
+ await expect(page.getByTestId("list-row")).toHaveCount(2);
+ await north.first().click();
+ await expect(north.first()).toHaveClass(/badge-primary/);
+ await expect(page.getByTestId("list-row")).toHaveCount(2);
+ });
+});
diff --git a/e2e/tests/announcements.spec.ts b/e2e/tests/announcements.spec.ts
new file mode 100644
index 0000000..b2fe72b
--- /dev/null
+++ b/e2e/tests/announcements.spec.ts
@@ -0,0 +1,31 @@
+import { expect, test } from "@playwright/test";
+
+test.describe("announcements", () => {
+ test("system announcement renders markdown bold", async ({ page }) => {
+ await page.goto("/");
+
+ // SYSTEM_ANNOUNCEMENT=**Outage** window scheduled is shipped as raw
+ // markdown in __APP_CONFIG__ and rendered client-side by .
+ // **Outage** must become a element end-to-end.
+ const banner = page.locator("#system-banner");
+ await expect(banner).toBeVisible();
+ await expect(banner.locator("strong")).toHaveText("Outage");
+ await expect(banner).toContainText("window scheduled");
+ });
+
+ test("network announcement dismiss persists across reload", async ({ page }) => {
+ await page.goto("/");
+
+ const banner = page.locator("#flash-banner");
+ await expect(banner).toBeVisible();
+ await expect(banner.locator("strong")).toHaveText("Maintenance");
+
+ await page.getByRole("button", { name: "Dismiss" }).click();
+ await expect(banner).not.toBeVisible();
+
+ // The dismiss flag is persisted in sessionStorage — reloading must NOT
+ // bring the banner back.
+ await page.reload();
+ await expect(banner).not.toBeVisible();
+ });
+});
diff --git a/e2e/tests/custom-page.spec.ts b/e2e/tests/custom-page.spec.ts
new file mode 100644
index 0000000..8a4d61d
--- /dev/null
+++ b/e2e/tests/custom-page.spec.ts
@@ -0,0 +1,55 @@
+import { expect, test } from "@playwright/test";
+
+test.describe("custom pages", () => {
+ test("markdown content is rendered", async ({ page }) => {
+ await page.goto("/pages/about");
+
+ const prose = page.locator(".card-body .prose");
+ await expect(prose).toBeVisible();
+ await expect(
+ prose.getByRole("heading", { name: "About the E2E Network" }),
+ ).toBeVisible();
+ await expect(prose.getByText("rendered from markdown")).toBeVisible();
+ await expect(
+ prose.getByText("Fetched by the SPA from /spa/pages/about"),
+ ).toBeVisible();
+ });
+
+ test("custom page appears in the navigation", async ({ page }) => {
+ await page.goto("/");
+
+ const link = page.locator('[data-testid="nav-link"][data-nav-href="/pages/about"]');
+ await expect(link.first()).toBeVisible();
+ await link.first().click();
+ await expect(page).toHaveURL(/\/pages\/about/);
+ await expect(page.locator(".card-body .prose")).toBeVisible();
+ });
+
+ test("heading anchor updates the URL hash on click", async ({ page }) => {
+ await page.goto("/pages/about");
+
+ // rehype-slug assigns the id; rehype-autolink-headings (behavior: "wrap")
+ // wraps the heading text in an .
+ const anchor = page.locator("h2#getting-started a");
+ await expect(anchor).toHaveAttribute("href", "#getting-started");
+ await anchor.click();
+ await expect(page).toHaveURL(/#getting-started$/);
+ });
+
+ test("direct hash navigation scrolls the heading into view", async ({ page }) => {
+ // Exercises the async-load scroll effect in CustomPage.tsx: the heading is
+ // absent until /spa/pages/about resolves, then scrollIntoView fires.
+ await page.goto("/pages/about#getting-started");
+
+ const heading = page.getByRole("heading", { name: "Getting Started" });
+ await expect(heading).toBeInViewport();
+ });
+
+ test("unknown slug shows a not-found error", async ({ page }) => {
+ await page.goto("/pages/does-not-exist");
+
+ const alert = page.locator('[role="alert"]');
+ await expect(alert).toBeVisible();
+ await expect(alert).toContainText(/not found/i);
+ });
+});
diff --git a/e2e/tests/dashboard.spec.ts b/e2e/tests/dashboard.spec.ts
new file mode 100644
index 0000000..80894cb
--- /dev/null
+++ b/e2e/tests/dashboard.spec.ts
@@ -0,0 +1,35 @@
+import { expect, test } from "@playwright/test";
+
+test.describe("dashboard", () => {
+ test("all widgets render", async ({ page }) => {
+ await page.goto("/dashboard");
+ const main = page.locator("main");
+
+ await expect(main.getByRole("heading", { name: "Dashboard" })).toBeVisible();
+
+ for (const title of [
+ "Nodes",
+ "Adverts",
+ "Messages",
+ "Packets",
+ "Packet Types",
+ "Path Bytes",
+ "Route Health",
+ "Route Trends",
+ "Recent Adverts",
+ "Recent Channel Messages",
+ ]) {
+ await expect(main.getByText(title, { exact: true }).first()).toBeVisible();
+ }
+
+ expect(await page.locator("canvas").count()).toBeGreaterThanOrEqual(5);
+
+ await expect(main.getByText("Alpha Site")).toBeVisible();
+ await expect(main.getByText("Bravo Site").first()).toBeVisible();
+
+ await expect(main.getByText("Alpha Node").first()).toBeVisible();
+ await expect(
+ main.locator('a[href^="/nodes/"]').first(),
+ ).toBeVisible();
+ });
+});
diff --git a/e2e/tests/global.spec.ts b/e2e/tests/global.spec.ts
new file mode 100644
index 0000000..36d9399
--- /dev/null
+++ b/e2e/tests/global.spec.ts
@@ -0,0 +1,79 @@
+import { expect, test } from "@playwright/test";
+
+const NAV_TARGETS = [
+ "/",
+ "/dashboard",
+ "/nodes",
+ "/advertisements",
+ "/routes",
+ "/channels",
+ "/messages",
+ "/packets",
+ "/map",
+ "/members",
+ "/pages/about",
+];
+
+test.describe("global", () => {
+ test("all navigation links work", async ({ page }) => {
+ await page.goto("/");
+ // Scope to the desktop menu: MobileNav renders the same links (hidden >= lg).
+ const navLinks = page.locator(".navbar-center [data-testid='nav-link']");
+ await expect(navLinks.first()).toBeVisible();
+
+ await expect(navLinks).toHaveCount(NAV_TARGETS.length);
+ for (const href of NAV_TARGETS) {
+ await expect(
+ page.locator(`.navbar-center [data-nav-href="${href}"]`),
+ ).toHaveCount(1);
+ }
+
+ for (const href of NAV_TARGETS) {
+ const link = page.locator(`.navbar-center [data-nav-href="${href}"]`);
+ await link.click();
+ await expect(page).toHaveURL(new RegExp(href === "/" ? "/$" : href));
+ await expect(link).toHaveClass(/active/);
+ }
+ });
+
+ test("footer renders network name and attribution", async ({ page }) => {
+ await page.goto("/");
+
+ const footer = page.locator("footer");
+ await expect(footer).toBeVisible();
+ // Network name comes from __APP_CONFIG__ (NETWORK_NAME=Test Network).
+ await expect(footer.getByText("Test Network")).toBeVisible();
+ // Hub attribution link.
+ await expect(
+ footer.getByRole("link", { name: "MeshCore Hub" }),
+ ).toBeVisible();
+ // Tagline (i18n key footer.tagline).
+ await expect(
+ footer.getByText("Off-Grid, Open-Source Encrypted Messaging"),
+ ).toBeVisible();
+ });
+
+ test("dark/light toggle works and persists", async ({ page }) => {
+ await page.goto("/");
+ // The checkbox itself is visually hidden by daisyUI's swap; click the label.
+ const toggle = page.getByTestId("theme-toggle");
+ const toggleControl = page.locator("label.swap");
+ const html = page.locator("html");
+
+ await expect(html).toHaveAttribute("data-theme", "dark");
+ await expect(toggle).not.toBeChecked();
+
+ await toggleControl.click();
+ await expect(html).toHaveAttribute("data-theme", "light");
+ await expect(toggle).toBeChecked();
+
+ await page.reload();
+ await expect(html).toHaveAttribute("data-theme", "light");
+
+ await page.locator("label.swap").click();
+ await expect(html).toHaveAttribute("data-theme", "dark");
+
+ await page.reload();
+ await expect(html).toHaveAttribute("data-theme", "dark");
+ });
+});
diff --git a/e2e/tests/home.spec.ts b/e2e/tests/home.spec.ts
new file mode 100644
index 0000000..ccd53cf
--- /dev/null
+++ b/e2e/tests/home.spec.ts
@@ -0,0 +1,50 @@
+import { expect, test } from "@playwright/test";
+
+const HERO_TARGETS = [
+ "/dashboard",
+ "/nodes",
+ "/advertisements",
+ "/routes",
+ "/channels",
+ "/messages",
+ "/packets",
+ "/map",
+ "/members",
+];
+
+test.describe("home", () => {
+ test("renders hero, stats and activity panels", async ({ page }) => {
+ await page.goto("/");
+
+ await expect(page.locator("h1.hero-title")).toHaveText("Test Network");
+ await expect(
+ page.getByText("Welcome to the Test Network mesh network dashboard."),
+ ).toBeVisible();
+
+ const stats = page.locator(".stat");
+ await expect(stats.first()).toBeVisible();
+ expect(await stats.count()).toBeGreaterThanOrEqual(4);
+ await expect(page.getByText("All discovered nodes")).toBeVisible();
+
+ await expect(
+ page.getByRole("heading", { name: "Network Activity" }),
+ ).toBeVisible();
+ expect(await page.locator("canvas").count()).toBeGreaterThanOrEqual(1);
+ });
+
+ test("hero navigation links work", async ({ page }) => {
+ await page.goto("/");
+ const cards = page.getByTestId("hero-card");
+ await expect(cards.first()).toBeVisible();
+
+ for (const href of HERO_TARGETS) {
+ const card = page.locator(
+ `[data-testid="hero-card"][data-hero-href="${href}"]`,
+ );
+ await expect(card).toBeVisible();
+ await card.click();
+ await expect(page).toHaveURL(new RegExp(href));
+ await page.goto("/");
+ }
+ });
+});
diff --git a/e2e/tests/map.spec.ts b/e2e/tests/map.spec.ts
new file mode 100644
index 0000000..98708c4
--- /dev/null
+++ b/e2e/tests/map.spec.ts
@@ -0,0 +1,43 @@
+import { expect, test } from "@playwright/test";
+import { openFilters } from "../utils/helpers";
+
+test.describe("map", () => {
+ test("renders markers and filter options work (incl. show labels)", async ({
+ page,
+ }) => {
+ await page.goto("/map");
+
+ const markers = page.locator(".map-marker");
+ await expect(markers.first()).toBeVisible();
+ await expect(markers).toHaveCount(7);
+ await expect(page.getByText("7 nodes on map")).toBeVisible();
+
+ await openFilters(page);
+
+ await page
+ .locator('select:has(option[value="repeater"])')
+ .selectOption("repeater");
+ await expect(markers).toHaveCount(5);
+ await expect(page.getByText("5 shown")).toBeVisible();
+
+ await page.getByLabel("Show Labels").check();
+ await expect(page.locator(".show-labels").first()).toBeVisible();
+ await expect(page.locator(".map-label").first()).toBeVisible();
+
+ await page.getByRole("button", { name: "Clear Filters" }).click();
+ await expect(markers).toHaveCount(7);
+ await expect(page.locator(".show-labels")).toHaveCount(0);
+ await expect(page.getByLabel("Show Labels")).not.toBeChecked();
+ });
+
+ test("marker popup links to node detail", async ({ page }) => {
+ await page.goto("/map");
+ await expect(page.locator(".map-marker").first()).toBeVisible();
+
+ await page.locator(".map-marker").first().click();
+ const popup = page.locator(".leaflet-popup");
+ await expect(popup).toBeVisible();
+ await popup.getByRole("link", { name: "View Details" }).click();
+ await expect(page).toHaveURL(/\/nodes\/[0-9a-f]{64}/);
+ });
+});
diff --git a/e2e/tests/members.spec.ts b/e2e/tests/members.spec.ts
new file mode 100644
index 0000000..304d09b
--- /dev/null
+++ b/e2e/tests/members.spec.ts
@@ -0,0 +1,50 @@
+import { expect, test } from "@playwright/test";
+import { MEMBER_STATE } from "../utils/helpers";
+
+const BRAVO_KEY = "b2b0" + "0".repeat(60);
+
+test.use({ storageState: MEMBER_STATE });
+
+test.describe("members", () => {
+ test("lists operators and members", async ({ page }) => {
+ await page.goto("/members");
+
+ // Level 2: the group headings (level 1 is the page title "Members").
+ await expect(
+ page.getByRole("heading", { name: "Operators", level: 2 }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Members", level: 2 }),
+ ).toBeVisible();
+ await expect(page.getByText("Op North")).toBeVisible();
+ await expect(page.getByText("Mem South")).toBeVisible();
+ expect(await page.getByTestId("member-card").count()).toBeGreaterThanOrEqual(4);
+ });
+
+ test("clicking a member shows the profile page", async ({ page }) => {
+ await page.goto("/members");
+
+ await page
+ .getByTestId("member-card")
+ .filter({ hasText: "Mem South" })
+ .first()
+ .click();
+ await expect(page).toHaveURL(/\/profile\/[0-9a-f-]{36}/);
+ await expect(page.getByText("Mem South").first()).toBeVisible();
+ await expect(page.locator('nav[aria-label="Breadcrumb"]')).toBeVisible();
+ });
+
+ test("clicking a member node shows the node detail page", async ({ page }) => {
+ await page.goto("/members");
+
+ const card = page
+ .getByTestId("member-card")
+ .filter({ hasText: "Mem South" })
+ .first();
+ await card
+ .locator(`[data-testid="member-node-badge"][data-node-key="${BRAVO_KEY}"]`)
+ .click();
+ await expect(page).toHaveURL(new RegExp(`/nodes/${BRAVO_KEY}`));
+ await expect(page.getByText("Bravo Node").first()).toBeVisible();
+ });
+});
diff --git a/e2e/tests/messages.spec.ts b/e2e/tests/messages.spec.ts
new file mode 100644
index 0000000..c06dca2
--- /dev/null
+++ b/e2e/tests/messages.spec.ts
@@ -0,0 +1,69 @@
+import { expect, test } from "@playwright/test";
+import { countApiCalls, expectListLoaded, openFilters } from "../utils/helpers";
+
+test.describe("messages", () => {
+ test("filter options work", async ({ page }) => {
+ await page.goto("/messages");
+ await expectListLoaded(page);
+ const table = page.locator("table");
+ await expect(page.getByTestId("list-row")).toHaveCount(4);
+
+ await openFilters(page);
+ await page.locator('select[name="message_type"]').selectOption("channel");
+ await page.getByRole("button", { name: "Filter" }).click();
+ await expect(page).toHaveURL(/message_type=channel/);
+ await expect(page.getByTestId("list-row")).toHaveCount(3);
+
+ // The channel select auto-submits on change.
+ await openFilters(page);
+ await page
+ .locator('select[name="channel_idx"]')
+ .selectOption({ label: "E2E General" });
+ await expect(page).toHaveURL(/channel_idx=\d+/);
+ await expect(page.getByTestId("list-row")).toHaveCount(1);
+ await expect(table.getByText("Ops channel traffic")).toBeVisible();
+
+ await page.goto("/messages");
+ await expectListLoaded(page);
+ await expect(page.getByTestId("list-row")).toHaveCount(4);
+ });
+
+ test("auto-refresh works and can be paused", async ({ page }) => {
+ await page.goto("/messages");
+ await expectListLoaded(page);
+
+ const toggle = page.getByTestId("auto-refresh-toggle");
+ await expect(toggle).toBeChecked();
+
+ const active = await countApiCalls(page, "/api/v1/messages?", 5000);
+ expect(active).toBeGreaterThanOrEqual(2);
+
+ await toggle.click();
+ await expect(toggle).not.toBeChecked();
+ const paused = await countApiCalls(page, "/api/v1/messages?", 4500);
+ expect(paused).toBe(0);
+ });
+
+ test("table row actions and observer toggle work", async ({ page }) => {
+ await page.goto("/messages");
+ await expectListLoaded(page);
+ const table = page.locator("table");
+
+ await expect(table.locator("span.observer-badge").first()).toBeVisible();
+
+ // Click a plain-text cell (Time) so the row handler navigates.
+ await page.getByTestId("list-row").first().locator("td").nth(1).click();
+ await expect(page).toHaveURL(/\/packets\/hash\//);
+
+ await page.goto("/messages");
+ await expectListLoaded(page);
+ await expect(page.getByTestId("list-row")).toHaveCount(4);
+
+ const north = page
+ .locator('[data-testid="observer-area"][data-area="North"]')
+ .first();
+ await north.click();
+ await expect(north).toHaveClass(/badge-ghost/);
+ await expect(page.getByTestId("list-row")).toHaveCount(2);
+ });
+});
diff --git a/e2e/tests/nodes.spec.ts b/e2e/tests/nodes.spec.ts
new file mode 100644
index 0000000..3be61bb
--- /dev/null
+++ b/e2e/tests/nodes.spec.ts
@@ -0,0 +1,69 @@
+import { expect, test } from "@playwright/test";
+import { countApiCalls, expectListLoaded, openFilters } from "../utils/helpers";
+
+const ALPHA_KEY = "a1fa" + "0".repeat(60);
+
+test.use({ permissions: ["clipboard-read", "clipboard-write"] });
+
+test.describe("nodes", () => {
+ test("filter options work", async ({ page }) => {
+ await page.goto("/nodes");
+ await expectListLoaded(page);
+ // Scope to the desktop table: rows also exist as hidden mobile cards.
+ const table = page.locator("table");
+ const initialCount = await page.getByTestId("list-row").count();
+ expect(initialCount).toBeGreaterThanOrEqual(4);
+
+ await openFilters(page);
+
+ await page.locator('input[name="search"]').fill("Alpha");
+ await page.getByRole("button", { name: "Filter" }).click();
+ await expect(page).toHaveURL(/search=Alpha/);
+ await expect(page.getByTestId("list-row")).toHaveCount(1);
+ await expect(table.getByText("Alpha Node").first()).toBeVisible();
+
+ await page.getByRole("link", { name: "Clear" }).click();
+ await expect(page).not.toHaveURL(/search=/);
+ await expect(page.getByTestId("list-row")).toHaveCount(initialCount);
+
+ await openFilters(page);
+ await page.locator('select[name="adv_type"]').selectOption("repeater");
+ await page.getByRole("button", { name: "Filter" }).click();
+ await expect(page).toHaveURL(/adv_type=repeater/);
+ await expect(table.getByText("Alpha Node")).toHaveCount(0);
+ await expect(table.getByText("Bravo Node").first()).toBeVisible();
+ });
+
+ test("auto-refresh works and can be paused", async ({ page }) => {
+ await page.goto("/nodes");
+ await expectListLoaded(page);
+
+ const toggle = page.getByTestId("auto-refresh-toggle");
+ await expect(toggle).toBeVisible();
+ await expect(toggle).toBeChecked();
+
+ const active = await countApiCalls(page, "/api/v1/nodes?", 5000);
+ expect(active).toBeGreaterThanOrEqual(2);
+
+ await toggle.click();
+ await expect(toggle).not.toBeChecked();
+ const paused = await countApiCalls(page, "/api/v1/nodes?", 4500);
+ expect(paused).toBe(0);
+ });
+
+ test("table row actions work", async ({ page }) => {
+ await page.goto("/nodes");
+ await expectListLoaded(page);
+ const table = page.locator("table");
+
+ await table.getByRole("link", { name: "Alpha Node" }).first().click();
+ await expect(page).toHaveURL(new RegExp(`/nodes/${ALPHA_KEY}`));
+ await expect(page.getByText("Alpha Node").first()).toBeVisible();
+ await page.goBack();
+ await expectListLoaded(page);
+
+ const copyable = table.locator('code[title="Click to copy"]').first();
+ await copyable.click();
+ await expect(page.getByText("Copied!").first()).toBeVisible();
+ });
+});
diff --git a/e2e/tests/packets.spec.ts b/e2e/tests/packets.spec.ts
new file mode 100644
index 0000000..d74f72a
--- /dev/null
+++ b/e2e/tests/packets.spec.ts
@@ -0,0 +1,91 @@
+import { expect, test } from "@playwright/test";
+import { countApiCalls, expectListLoaded, openFilters } from "../utils/helpers";
+
+const ALPHA_KEY = "a1fa" + "0".repeat(60);
+const AD01_HASH = "ad01" + "0".repeat(28);
+
+test.describe("packets", () => {
+ test("filter options work", async ({ page }) => {
+ await page.goto("/packets");
+ await expectListLoaded(page);
+ await expect(page.getByTestId("list-row")).toHaveCount(10);
+
+ await openFilters(page);
+ await page.locator('select[name="event_type"]').selectOption("advertisement");
+ await page.getByRole("button", { name: "Filter" }).click();
+ await expect(page).toHaveURL(/event_type=advertisement/);
+ await expect(page.getByTestId("list-row")).toHaveCount(6);
+
+ await openFilters(page);
+ await page.locator('select[name="path_hash_bytes"]').selectOption("2");
+ await page.getByRole("button", { name: "Filter" }).click();
+ await expect(page).toHaveURL(/path_hash_bytes=2/);
+ await expect(page.getByTestId("list-row")).toHaveCount(6);
+ });
+
+ test("auto-refresh works and can be paused", async ({ page }) => {
+ await page.goto("/packets");
+ await expectListLoaded(page);
+
+ const toggle = page.getByTestId("auto-refresh-toggle");
+ await expect(toggle).toBeChecked();
+
+ const active = await countApiCalls(page, "/api/v1/packet-groups?", 5000);
+ expect(active).toBeGreaterThanOrEqual(2);
+
+ await toggle.click();
+ await expect(toggle).not.toBeChecked();
+ const paused = await countApiCalls(page, "/api/v1/packet-groups?", 4500);
+ expect(paused).toBe(0);
+ });
+
+ test("row click opens the packet group detail", async ({ page }) => {
+ await page.goto("/packets");
+ await expectListLoaded(page);
+
+ await page.getByTestId("list-row").first().click();
+ await expect(page).toHaveURL(/\/packets\/hash\//);
+ await expect(page.locator('nav[aria-label="Breadcrumb"]')).toBeVisible();
+ });
+
+ test("clicking a path node renders the matching-nodes overlay", async ({
+ page,
+ }) => {
+ await page.goto(`/packets/hash/${AD01_HASH}`);
+
+ // Badges render twice (desktop table + hidden mobile cards): scope to visible.
+ const pathHops = page.locator('[data-testid="path-hop"]:visible');
+ await expect(pathHops.first()).toBeVisible();
+ for (const hash of ["A1FA", "B2B0", "C3C0"]) {
+ await expect(
+ page.locator(`[data-testid="path-hop"][data-hash="${hash}"]:visible`).first(),
+ ).toBeVisible();
+ }
+
+ await page
+ .locator('[data-testid="path-hop"][data-hash="A1FA"]:visible')
+ .first()
+ .click();
+ const popover = page.getByTestId("path-nodes-popover");
+ await expect(popover).toBeVisible();
+ await expect(popover.getByText("Nodes matching A1FA")).toBeVisible();
+ await expect(popover.getByText("Alpha Node")).toBeVisible();
+
+ await popover.getByTestId("path-node-link").first().click();
+ await expect(page).toHaveURL(new RegExp(`/nodes/${ALPHA_KEY}`));
+ await expect(page.getByText("Alpha Node").first()).toBeVisible();
+
+ await page.goto(`/packets/hash/${AD01_HASH}`);
+ await page
+ .locator('[data-testid="path-hop"][data-hash="B2B0"]:visible')
+ .first()
+ .click();
+ await expect(page.getByTestId("path-nodes-popover")).toBeVisible();
+ await expect(page.getByText("Bravo Node").first()).toBeVisible();
+ await page
+ .getByTestId("path-nodes-popover")
+ .getByRole("button", { name: "close" })
+ .click();
+ await expect(page.getByTestId("path-nodes-popover")).toHaveCount(0);
+ });
+});
diff --git a/e2e/tests/routes.spec.ts b/e2e/tests/routes.spec.ts
new file mode 100644
index 0000000..cfaa56a
--- /dev/null
+++ b/e2e/tests/routes.spec.ts
@@ -0,0 +1,137 @@
+import { expect, test } from "@playwright/test";
+import { ADMIN_STATE } from "../utils/helpers";
+
+test.use({ storageState: ADMIN_STATE });
+
+const ROUTE_LABEL = "E2E From \u2192 E2E To";
+
+test.describe.serial("routes (admin)", () => {
+ test("add route displays the modal and all options are persisted", async ({
+ page,
+ }) => {
+ await page.goto("/routes");
+
+ await expect(
+ page.locator('[data-testid="route-card"][data-route-label="Alpha Site \u2192 Bravo Site"]'),
+ ).toBeVisible();
+
+ await page.getByTestId("add-route").click();
+ const modal = page.locator('[data-testid="route-modal"]');
+ await expect(modal).toBeVisible();
+ await expect(page.locator("dialog h3")).toHaveText("Add Route");
+
+ await page.getByTestId("route-from").fill("E2E From");
+ await page.getByTestId("route-to").fill("E2E To");
+ await page.getByTestId("route-description").fill("Created by Playwright");
+ await page.getByTestId("route-visibility").selectOption("operator");
+ await page.locator('[data-testid="route-width"][data-width="2"]').click();
+ await expect(
+ page.locator('[data-testid="route-width"][data-width="2"]'),
+ ).toHaveClass(/btn-primary/);
+
+ await page.getByTestId("route-path-search").fill("Alpha");
+ await page.getByTestId("node-search-result").first().click();
+ await expect(page.getByTestId("route-path-chip")).toHaveCount(1);
+
+ await page.getByTestId("route-path-search").fill("Bravo");
+ await page.getByTestId("node-search-result").first().click();
+ await expect(page.getByTestId("route-path-chip")).toHaveCount(2);
+ await expect(modal.getByText("Alpha Node")).toBeVisible();
+ await expect(modal.getByText("Bravo Node")).toBeVisible();
+
+ await page.getByTestId("route-observer-search").fill("North");
+ await page.getByTestId("node-search-result").first().click();
+ await expect(modal.getByText(/North Observer/).first()).toBeVisible();
+
+ await page.getByTestId("route-window").fill("72");
+ await page.getByTestId("route-threshold").fill("4");
+ await page.getByTestId("route-clear-threshold").fill("8");
+ await page.getByTestId("route-max-span").fill("6");
+ await page.getByTestId("route-max-path-length").fill("5");
+ await page.getByTestId("route-enabled").setChecked(false);
+ await page.getByTestId("route-reversible").setChecked(false);
+
+ await page.getByTestId("route-save").click();
+ await expect(modal).toHaveCount(0);
+
+ const card = page.locator(
+ `[data-testid="route-card"][data-route-label="${ROUTE_LABEL}"]`,
+ );
+ await expect(card).toBeVisible();
+
+ await card.getByTestId("edit-route").click();
+ await expect(modal).toBeVisible();
+ await expect(page.locator("dialog h3")).toHaveText("Edit Route");
+
+ await expect(page.getByTestId("route-from")).toHaveValue("E2E From");
+ await expect(page.getByTestId("route-to")).toHaveValue("E2E To");
+ await expect(page.getByTestId("route-description")).toHaveValue(
+ "Created by Playwright",
+ );
+ await expect(page.getByTestId("route-visibility")).toHaveValue("operator");
+ await expect(
+ page.locator('[data-testid="route-width"][data-width="2"]'),
+ ).toHaveClass(/btn-primary/);
+ await expect(page.getByTestId("route-path-chip")).toHaveCount(2);
+ await expect(modal.getByText("Alpha Node")).toBeVisible();
+ await expect(modal.getByText("Bravo Node")).toBeVisible();
+ await expect(modal.getByText(/North Observer/).first()).toBeVisible();
+ await expect(page.getByTestId("route-window")).toHaveValue("72");
+ await expect(page.getByTestId("route-threshold")).toHaveValue("4");
+ await expect(page.getByTestId("route-clear-threshold")).toHaveValue("8");
+ await expect(page.getByTestId("route-max-span")).toHaveValue("6");
+ await expect(page.getByTestId("route-max-path-length")).toHaveValue("5");
+ await expect(page.getByTestId("route-enabled")).not.toBeChecked();
+ await expect(page.getByTestId("route-reversible")).not.toBeChecked();
+
+ await page.getByTestId("route-cancel").click();
+ await expect(modal).toHaveCount(0);
+ });
+
+ test("saving with fewer than 2 path nodes is rejected", async ({ page }) => {
+ await page.goto("/routes");
+ await page.getByTestId("add-route").click();
+
+ await page.getByTestId("route-from").fill("Bad");
+ await page.getByTestId("route-to").fill("Route");
+ await page.getByTestId("route-path-search").fill("Alpha");
+ await page.getByTestId("node-search-result").first().click();
+ await expect(page.getByTestId("route-path-chip")).toHaveCount(1);
+
+ // The alert blocks the page until dismissed, so accept it the moment it
+ // appears (handling it only after the awaited click would deadlock).
+ const dialogPromise = page.waitForEvent("dialog");
+ void dialogPromise.then((dialog) => dialog.accept());
+ await page.getByTestId("route-save").click();
+ expect((await dialogPromise).message()).toBe(
+ "At least 2 path nodes are required.",
+ );
+
+ await expect(page.locator('[data-testid="route-modal"]')).toBeVisible();
+ await page.getByTestId("route-cancel").click();
+ });
+
+ test("delete route shows a confirm dialog and removes the route", async ({
+ page,
+ }) => {
+ await page.goto("/routes");
+
+ const card = page.locator(
+ `[data-testid="route-card"][data-route-label="${ROUTE_LABEL}"]`,
+ );
+ await expect(card).toBeVisible();
+ await card.getByTestId("delete-route").click();
+
+ const confirm = page.locator("dialog.modal-open");
+ await expect(confirm).toBeVisible();
+ await expect(
+ confirm.getByRole("heading", { name: "Delete Route" }),
+ ).toBeVisible();
+ await expect(
+ confirm.getByText(/Are you sure you want to delete route/),
+ ).toBeVisible();
+
+ await confirm.getByRole("button", { name: "Delete" }).click();
+ await expect(card).toHaveCount(0);
+ });
+});
diff --git a/e2e/tests/users.spec.ts b/e2e/tests/users.spec.ts
new file mode 100644
index 0000000..42ecd0f
--- /dev/null
+++ b/e2e/tests/users.spec.ts
@@ -0,0 +1,47 @@
+import { expect, test } from "@playwright/test";
+import { MEMBER_STATE } from "../utils/helpers";
+
+test.use({ storageState: MEMBER_STATE });
+
+test.describe("users", () => {
+ test("user profile menu works", async ({ page }) => {
+ await page.goto("/");
+
+ await page.getByTestId("user-menu").click();
+ await expect(page.getByText("PW Member")).toBeVisible();
+ await expect(page.getByText("member", { exact: true })).toBeVisible();
+ await expect(page.getByTestId("user-menu-profile")).toBeVisible();
+ await expect(page.getByTestId("user-menu-logout")).toBeVisible();
+ });
+
+ test("profile edit works and persists", async ({ page }) => {
+ await page.goto("/profile");
+
+ await expect(page.locator('input[name="name"]')).toBeVisible();
+ await page.locator('input[name="name"]').fill("PW Member Edited");
+ await page.locator('input[name="callsign"]').fill("E2EEDIT");
+ await page
+ .locator('input[name="description"]')
+ .fill("Updated by Playwright");
+ await page
+ .locator('input[name="url"]')
+ .fill("https://example.com/pw-member");
+
+ await page.getByRole("button", { name: "Save Profile" }).click();
+ await expect(page.getByRole("alert")).toContainText(
+ "Profile updated successfully",
+ );
+
+ await page.reload();
+ await expect(page.locator('input[name="name"]')).toHaveValue(
+ "PW Member Edited",
+ );
+ await expect(page.locator('input[name="callsign"]')).toHaveValue("E2EEDIT");
+ await expect(page.locator('input[name="description"]')).toHaveValue(
+ "Updated by Playwright",
+ );
+ await expect(page.locator('input[name="url"]')).toHaveValue(
+ "https://example.com/pw-member",
+ );
+ });
+});
diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json
new file mode 100644
index 0000000..3b1ed25
--- /dev/null
+++ b/e2e/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "types": ["node"]
+ },
+ "include": ["./**/*.ts"]
+}
diff --git a/e2e/utils/helpers.ts b/e2e/utils/helpers.ts
new file mode 100644
index 0000000..2bd023e
--- /dev/null
+++ b/e2e/utils/helpers.ts
@@ -0,0 +1,39 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { expect, type Page } from "@playwright/test";
+
+const AUTH_DIR = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "..",
+ ".auth",
+);
+export const ADMIN_STATE = path.join(AUTH_DIR, "admin.json");
+export const MEMBER_STATE = path.join(AUTH_DIR, "member.json");
+
+export async function expectListLoaded(page: Page): Promise {
+ await expect(page.getByTestId("list-row").first()).toBeVisible();
+}
+
+export async function openFilters(page: Page): Promise {
+ const toggle = page.locator("#filter-toggle");
+ if (!(await toggle.isChecked())) {
+ await toggle.click();
+ }
+}
+
+export async function countApiCalls(
+ page: Page,
+ urlFragment: string,
+ durationMs: number,
+): Promise {
+ let count = 0;
+ const onRequest = (request: { url: () => string }): void => {
+ if (request.url().includes(urlFragment)) {
+ count += 1;
+ }
+ };
+ page.on("request", onRequest);
+ await page.waitForTimeout(durationMs);
+ page.off("request", onRequest);
+ return count;
+}
diff --git a/package-lock.json b/package-lock.json
index bc07d3f..05d2f70 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,457 +8,562 @@
"@fontsource-variable/ibm-plex-sans": "^5",
"@fontsource/ibm-plex-mono": "^5",
"@tailwindcss/cli": "^4",
+ "@tanstack/react-query": "^5.101.4",
"chart.js": "^4",
"daisyui": "^5",
+ "i18next": "^25",
+ "i18next-browser-languagedetector": "^8",
"leaflet": "^1.9.4",
- "lit-html": "^3",
- "qrcodejs": "^1.0.0",
+ "react": "^19",
+ "react-chartjs-2": "^5",
+ "react-dom": "^19",
+ "react-i18next": "^15",
+ "react-leaflet": "^5",
+ "react-markdown": "^10.1.0",
+ "react-qr-code": "^2.2.0",
+ "react-router": "^7",
+ "rehype-autolink-headings": "^7.1.0",
+ "rehype-slug": "^6.0.0",
+ "remark-gfm": "^4.0.1",
"tailwindcss": "^4"
},
"devDependencies": {
- "esbuild": "^0.28.0"
+ "@playwright/test": "^1.61.1",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/jest-dom": "^7.0.0",
+ "@testing-library/react": "^16.3.2",
+ "@types/leaflet": "^1.9.17",
+ "@types/node": "^24.0.0",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "@vitejs/plugin-react": "^4",
+ "jsdom": "^29.1.1",
+ "typescript": "^5.8",
+ "vite": "^6",
+ "vitest": "^4.1.10"
+ },
+ "engines": {
+ "node": ">=20"
}
},
- "node_modules/@esbuild/aix-ppc64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
- "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
- "cpu": [
- "ppc64"
- ],
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "5.1.11",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
+ "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@csstools/css-calc": "^3.2.0",
+ "@csstools/css-color-parser": "^4.1.0",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ },
"engines": {
- "node": ">=18"
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
- "node_modules/@esbuild/android-arm": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
- "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
- "cpu": [
- "arm"
- ],
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
+ "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1"
+ },
"engines": {
- "node": ">=18"
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
- "node_modules/@esbuild/android-arm64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
- "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/@asamuzakjp/generational-cache": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
+ "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
"engines": {
- "node": ">=18"
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
- "node_modules/@esbuild/android-x64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
- "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
- "cpu": [
- "x64"
- ],
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
"engines": {
- "node": ">=18"
+ "node": ">=6.9.0"
}
},
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
- "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
"engines": {
- "node": ">=18"
+ "node": ">=6.9.0"
}
},
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
- "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
- "cpu": [
- "x64"
- ],
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
"engines": {
- "node": ">=18"
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
}
},
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
- "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/@babel/generator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
"engines": {
- "node": ">=18"
+ "node": ">=6.9.0"
}
},
- "node_modules/@esbuild/freebsd-x64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
- "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
- "cpu": [
- "x64"
- ],
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
"engines": {
- "node": ">=18"
+ "node": ">=6.9.0"
}
},
- "node_modules/@esbuild/linux-arm": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
- "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
- "cpu": [
- "arm"
- ],
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
"engines": {
- "node": ">=18"
+ "node": ">=6.9.0"
}
},
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
- "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
"engines": {
- "node": ">=18"
+ "node": ">=6.9.0"
}
},
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
- "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
- "cpu": [
- "ia32"
- ],
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
"engines": {
- "node": ">=18"
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
}
},
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
- "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
- "cpu": [
- "loong64"
- ],
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
"engines": {
- "node": ">=18"
+ "node": ">=6.9.0"
}
},
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
- "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
- "cpu": [
- "mips64el"
- ],
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
"engines": {
- "node": ">=18"
+ "node": ">=6.9.0"
}
},
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
- "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
- "cpu": [
- "ppc64"
- ],
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
"engines": {
- "node": ">=18"
+ "node": ">=6.9.0"
}
},
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
- "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
- "cpu": [
- "riscv64"
- ],
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
"engines": {
- "node": ">=18"
+ "node": ">=6.9.0"
}
},
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
- "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
- "cpu": [
- "s390x"
- ],
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
"engines": {
- "node": ">=18"
+ "node": ">=6.9.0"
}
},
- "node_modules/@esbuild/linux-x64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
- "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
- "cpu": [
- "x64"
- ],
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
"engines": {
- "node": ">=18"
+ "node": ">=6.0.0"
}
},
- "node_modules/@esbuild/netbsd-arm64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
- "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
"engines": {
- "node": ">=18"
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
- "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
- "cpu": [
- "x64"
- ],
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
"engines": {
- "node": ">=18"
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@esbuild/openbsd-arm64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
- "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
"engines": {
- "node": ">=18"
+ "node": ">=6.9.0"
}
},
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
- "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
- "cpu": [
- "x64"
- ],
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
"engines": {
- "node": ">=18"
+ "node": ">=6.9.0"
}
},
- "node_modules/@esbuild/openharmony-arm64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
- "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/@babel/traverse": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "debug": "^4.3.1"
+ },
"engines": {
- "node": ">=18"
+ "node": ">=6.9.0"
}
},
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
- "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
- "cpu": [
- "x64"
- ],
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
"engines": {
- "node": ">=18"
+ "node": ">=6.9.0"
}
},
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
- "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
}
},
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
- "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
- "cpu": [
- "ia32"
- ],
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
+ "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
"dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
],
+ "license": "MIT-0",
"engines": {
- "node": ">=18"
+ "node": ">=20.19.0"
}
},
- "node_modules/@esbuild/win32-x64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
- "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
- "cpu": [
- "x64"
+ "node_modules/@csstools/css-calc": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
+ "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz",
+ "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.0",
+ "@csstools/css-calc": "^3.2.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz",
+ "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
"engines": {
- "node": ">=18"
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
}
},
"node_modules/@fontsource-variable/ibm-plex-sans": {
@@ -855,6 +960,436 @@
"node": ">=0.10"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
+ "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@react-leaflet/core": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
+ "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
+ "license": "Hippocratic-2.1",
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@tailwindcss/cli": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz",
@@ -1188,12 +1723,496 @@
"node": ">= 20"
}
},
- "node_modules/@types/trusted-types": {
- "version": "2.0.7",
- "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
- "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "node_modules/@tanstack/query-core": {
+ "version": "5.101.4",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz",
+ "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.101.4",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz",
+ "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.101.4"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz",
+ "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=22",
+ "npm": ">=6",
+ "yarn": ">=1"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=10 <11"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
"license": "MIT"
},
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/debug": {
+ "version": "4.1.13",
+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
+ "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree-jsx": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
+ "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/hast": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
+ "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/leaflet": {
+ "version": "1.9.21",
+ "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
+ "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
+ "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.13.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
+ "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "license": "MIT"
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz",
+ "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==",
+ "license": "ISC"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
+ "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
+ "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.10",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
+ "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
+ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.10",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
+ "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
+ "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
+ "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/bail": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
+ "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.44",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.44.tgz",
+ "integrity": "sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@@ -1206,6 +2225,121 @@
"node": ">=8"
}
},
+ "node_modules/browserslist": {
+ "version": "4.28.6",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
+ "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.42",
+ "caniuse-lite": "^1.0.30001803",
+ "electron-to-chromium": "^1.5.389",
+ "node-releases": "^2.0.51",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001806",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
+ "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
+ "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/character-entities": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
+ "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
+ "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-reference-invalid": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
+ "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
@@ -1218,6 +2352,63 @@
"pnpm": ">=8"
}
},
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
"node_modules/daisyui": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.0.tgz",
@@ -1227,6 +2418,66 @@
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
}
},
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/decode-named-character-reference": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
+ "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1236,6 +2487,33 @@
"node": ">=8"
}
},
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.394",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.394.tgz",
+ "integrity": "sha512-Wmt2Gm0o8JWBuGgmc4XZ0u9s1RaCRqhxP47phplmfg04+qypTUurpeJGP45A7Fhv7jdrrVH44PLlR9qXo37cVQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/enhanced-resolve": {
"version": "5.24.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz",
@@ -1249,46 +2527,100 @@
"node": ">=10.13.0"
}
},
- "node_modules/esbuild": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
- "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
+ "license": "BSD-2-Clause",
"engines": {
- "node": ">=18"
+ "node": ">=20.19.0"
},
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.28.1",
- "@esbuild/android-arm": "0.28.1",
- "@esbuild/android-arm64": "0.28.1",
- "@esbuild/android-x64": "0.28.1",
- "@esbuild/darwin-arm64": "0.28.1",
- "@esbuild/darwin-x64": "0.28.1",
- "@esbuild/freebsd-arm64": "0.28.1",
- "@esbuild/freebsd-x64": "0.28.1",
- "@esbuild/linux-arm": "0.28.1",
- "@esbuild/linux-arm64": "0.28.1",
- "@esbuild/linux-ia32": "0.28.1",
- "@esbuild/linux-loong64": "0.28.1",
- "@esbuild/linux-mips64el": "0.28.1",
- "@esbuild/linux-ppc64": "0.28.1",
- "@esbuild/linux-riscv64": "0.28.1",
- "@esbuild/linux-s390x": "0.28.1",
- "@esbuild/linux-x64": "0.28.1",
- "@esbuild/netbsd-arm64": "0.28.1",
- "@esbuild/netbsd-x64": "0.28.1",
- "@esbuild/openbsd-arm64": "0.28.1",
- "@esbuild/openbsd-x64": "0.28.1",
- "@esbuild/openharmony-arm64": "0.28.1",
- "@esbuild/sunos-x64": "0.28.1",
- "@esbuild/win32-arm64": "0.28.1",
- "@esbuild/win32-ia32": "0.28.1",
- "@esbuild/win32-x64": "0.28.1"
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
+ "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/estree-util-is-identifier-name": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
+ "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
}
},
"node_modules/fill-range": {
@@ -1303,12 +2635,244 @@
"node": ">=8"
}
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/github-slugger": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
+ "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==",
+ "license": "ISC"
+ },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
+ "node_modules/hast-util-heading-rank": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz",
+ "integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-is-element": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
+ "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-to-jsx-runtime": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
+ "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "devlop": "^1.0.0",
+ "estree-util-is-identifier-name": "^3.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "mdast-util-mdx-expression": "^2.0.0",
+ "mdast-util-mdx-jsx": "^3.0.0",
+ "mdast-util-mdxjs-esm": "^2.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "style-to-js": "^1.0.0",
+ "unist-util-position": "^5.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-to-string": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz",
+ "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-whitespace": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
+ "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/html-parse-stringify": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+ "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+ "license": "MIT",
+ "dependencies": {
+ "void-elements": "3.1.0"
+ }
+ },
+ "node_modules/html-url-attributes": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
+ "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/i18next": {
+ "version": "25.10.10",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.10.tgz",
+ "integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://www.locize.com/i18next"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.locize.com"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2"
+ },
+ "peerDependencies": {
+ "typescript": "^5 || ^6"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/i18next-browser-languagedetector": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
+ "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.23.2"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/inline-style-parser": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
+ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
+ "license": "MIT"
+ },
+ "node_modules/is-alphabetical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
+ "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-alphanumerical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
+ "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-alphabetical": "^2.0.0",
+ "is-decimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-decimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
+ "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -1330,6 +2894,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-hexadecimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
+ "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -1339,6 +2913,25 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -1348,6 +2941,89 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsdom": {
+ "version": "29.1.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
+ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.1.11",
+ "@asamuzakjp/dom-selector": "^7.1.1",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
+ "@exodus/bytes": "^1.15.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.3.5",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.1",
+ "undici": "^7.25.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.1",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
@@ -1603,13 +3279,46 @@
"url": "https://opencollective.com/parcel"
}
},
- "node_modules/lit-html": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz",
- "integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==",
- "license": "BSD-3-Clause",
+ "node_modules/longest-streak": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
+ "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
"dependencies": {
- "@types/trusted-types": "^2.0.2"
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
@@ -1621,6 +3330,856 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/markdown-table": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
+ "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/mdast-util-find-and-replace": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
+ "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "escape-string-regexp": "^5.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-from-markdown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
+ "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark": "^4.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
+ "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-gfm-autolink-literal": "^2.0.0",
+ "mdast-util-gfm-footnote": "^2.0.0",
+ "mdast-util-gfm-strikethrough": "^2.0.0",
+ "mdast-util-gfm-table": "^2.0.0",
+ "mdast-util-gfm-task-list-item": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-autolink-literal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
+ "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-find-and-replace": "^3.0.0",
+ "micromark-util-character": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-strikethrough": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
+ "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-table": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
+ "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "markdown-table": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-task-list-item": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
+ "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-expression": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
+ "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
+ "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "parse-entities": "^4.0.0",
+ "stringify-entities": "^4.0.0",
+ "unist-util-stringify-position": "^4.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdxjs-esm": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
+ "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-phrasing": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
+ "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-markdown": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
+ "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-phrasing": "^4.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "unist-util-visit": "^5.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
+ "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/micromark": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
+ "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@types/debug": "^4.0.0",
+ "debug": "^4.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-core-commonmark": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
+ "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-factory-destination": "^2.0.0",
+ "micromark-factory-label": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-factory-title": "^2.0.0",
+ "micromark-factory-whitespace": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-html-tag-name": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-extension-gfm": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
+ "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-extension-gfm-autolink-literal": "^2.0.0",
+ "micromark-extension-gfm-footnote": "^2.0.0",
+ "micromark-extension-gfm-strikethrough": "^2.0.0",
+ "micromark-extension-gfm-table": "^2.0.0",
+ "micromark-extension-gfm-tagfilter": "^2.0.0",
+ "micromark-extension-gfm-task-list-item": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-autolink-literal": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
+ "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-strikethrough": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
+ "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-table": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
+ "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-tagfilter": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
+ "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-task-list-item": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
+ "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-factory-destination": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
+ "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-label": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
+ "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-space": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
+ "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-title": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
+ "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-whitespace": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
+ "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
+ "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-chunked": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
+ "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-classify-character": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
+ "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-combine-extensions": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
+ "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-numeric-character-reference": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
+ "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-string": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
+ "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
+ "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-html-tag-name": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
+ "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-normalize-identifier": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
+ "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-resolve-all": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
+ "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
+ "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-subtokenize": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
+ "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
+ "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
+ "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -1646,6 +4205,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -1655,22 +4224,629 @@
"node": ">=4"
}
},
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
+ "node_modules/node-releases": {
+ "version": "2.0.51",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
+ "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/parse-entities": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
+ "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "character-entities-legacy": "^3.0.0",
+ "character-reference-invalid": "^2.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "is-alphanumerical": "^2.0.0",
+ "is-decimal": "^2.0.0",
+ "is-hexadecimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/parse-entities/node_modules/@types/unist": {
+ "version": "2.0.11",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
+ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
+ "license": "MIT"
+ },
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
- "node_modules/qrcodejs": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/qrcodejs/-/qrcodejs-1.0.0.tgz",
- "integrity": "sha512-67rj3mMBhSBepaD57qENnltO+r8rSYlqM7HGThks/BiyDAkc86sLvkKqjkqPS5v13f7tvnt6dbEf3qt7zq+BCg=="
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.21",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz",
+ "integrity": "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/property-information": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
+ "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/qrcode-generator": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
+ "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-chartjs-2": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
+ "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "chart.js": "^4.1.1",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.7"
+ }
+ },
+ "node_modules/react-i18next": {
+ "version": "15.7.4",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
+ "integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.27.6",
+ "html-parse-stringify": "^3.0.1"
+ },
+ "peerDependencies": {
+ "i18next": ">= 23.4.0",
+ "react": ">= 16.8.0",
+ "typescript": "^5"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
+ "node_modules/react-leaflet": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
+ "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
+ "license": "Hippocratic-2.1",
+ "dependencies": {
+ "@react-leaflet/core": "^3.0.0"
+ },
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ }
+ },
+ "node_modules/react-markdown": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
+ "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "hast-util-to-jsx-runtime": "^2.0.0",
+ "html-url-attributes": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.0.0",
+ "unified": "^11.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18",
+ "react": ">=18"
+ }
+ },
+ "node_modules/react-qr-code": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.2.0.tgz",
+ "integrity": "sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw==",
+ "license": "MIT",
+ "dependencies": {
+ "prop-types": "^15.8.1",
+ "qrcode-generator": "^2.0.4"
+ },
+ "peerDependencies": {
+ "react": "*"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "7.18.1",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
+ "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/rehype-autolink-headings": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz",
+ "integrity": "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "hast-util-heading-rank": "^3.0.0",
+ "hast-util-is-element": "^3.0.0",
+ "unified": "^11.0.0",
+ "unist-util-visit": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/rehype-slug": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz",
+ "integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "github-slugger": "^2.0.0",
+ "hast-util-heading-rank": "^3.0.0",
+ "hast-util-to-string": "^3.0.0",
+ "unist-util-visit": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-gfm": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
+ "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-gfm": "^3.0.0",
+ "micromark-extension-gfm": "^3.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-parse": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
+ "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-rehype": {
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
+ "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "unified": "^11.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-stringify": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
+ "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
@@ -1681,6 +4857,82 @@
"node": ">=0.10.0"
}
},
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
+ "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/style-to-js": {
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
+ "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "style-to-object": "1.0.14"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
+ "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.7"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tailwindcss": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
@@ -1700,6 +4952,70 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+ "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.9",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz",
+ "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.9"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.9",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz",
+ "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -1711,6 +5027,986 @@
"engines": {
"node": ">=8.0"
}
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+ "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/trough": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
+ "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
+ "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unified": {
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
+ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "bail": "^2.0.0",
+ "devlop": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-plain-obj": "^4.0.0",
+ "trough": "^2.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-is": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
+ "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
+ "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
+ "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
+ "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
+ "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+ "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
+ "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
+ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.10",
+ "@vitest/mocker": "4.1.10",
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/runner": "4.1.10",
+ "@vitest/snapshot": "4.1.10",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.10",
+ "@vitest/browser-preview": "4.1.10",
+ "@vitest/browser-webdriverio": "4.1.10",
+ "@vitest/coverage-istanbul": "4.1.10",
+ "@vitest/coverage-v8": "4.1.10",
+ "@vitest/ui": "4.1.10",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/void-elements": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
}
}
}
diff --git a/package.json b/package.json
index 6c306e0..02dcb30 100644
--- a/package.json
+++ b/package.json
@@ -1,21 +1,57 @@
{
"private": true,
"type": "module",
+ "engines": {
+ "node": ">=20"
+ },
"scripts": {
- "build": "node build.js"
+ "build": "node build.js",
+ "dev": "vite --config vite.config.ts",
+ "test:frontend": "vitest run",
+ "typecheck": "tsc --noEmit",
+ "test:e2e": "playwright test --config=e2e/playwright.config.ts",
+ "typecheck:e2e": "tsc -p e2e/tsconfig.json --noEmit"
},
"devDependencies": {
- "esbuild": "^0.28.0"
+ "@playwright/test": "^1.61.1",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/jest-dom": "^7.0.0",
+ "@testing-library/react": "^16.3.2",
+ "@types/leaflet": "^1.9.17",
+ "@types/node": "^24.0.0",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "@vitejs/plugin-react": "^4",
+ "jsdom": "^29.1.1",
+ "typescript": "^5.8",
+ "vite": "^6",
+ "vitest": "^4.1.10"
},
"dependencies": {
"@fontsource-variable/ibm-plex-sans": "^5",
"@fontsource/ibm-plex-mono": "^5",
"@tailwindcss/cli": "^4",
+ "@tanstack/react-query": "^5.101.4",
"chart.js": "^4",
"daisyui": "^5",
+ "i18next": "^25",
+ "i18next-browser-languagedetector": "^8",
"leaflet": "^1.9.4",
- "lit-html": "^3",
- "qrcodejs": "^1.0.0",
+ "react": "^19",
+ "react-chartjs-2": "^5",
+ "react-dom": "^19",
+ "react-i18next": "^15",
+ "react-leaflet": "^5",
+ "react-markdown": "^10.1.0",
+ "react-qr-code": "^2.2.0",
+ "react-router": "^7",
+ "rehype-autolink-headings": "^7.1.0",
+ "rehype-slug": "^6.0.0",
+ "remark-gfm": "^4.0.1",
"tailwindcss": "^4"
+ },
+ "allowScripts": {
+ "esbuild@0.25.12": true,
+ "@parcel/watcher@2.5.1": true
}
}
diff --git a/pyproject.toml b/pyproject.toml
index 9433f84..dbb6877 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -42,7 +42,6 @@ dependencies = [
"aiosqlite>=0.19.0",
"pyyaml>=6.0.0",
"python-frontmatter>=1.0.0",
- "markdown>=3.5.0",
"prometheus-client>=0.20.0",
"meshcoredecoder>=0.3.2",
"redis[hiredis]>=5.0.0",
@@ -124,7 +123,6 @@ module = [
"uvicorn.*",
"alembic.*",
"frontmatter.*",
- "markdown.*",
"prometheus_client.*",
"meshcoredecoder.*",
"authlib.*",
@@ -151,6 +149,12 @@ module = [
]
ignore_errors = true
+# e2e helper script (run with the project venv); itsdangerous stubs are not
+# present in the isolated pre-commit mypy environment.
+[[tool.mypy.overrides]]
+module = ["mint_session"]
+ignore_missing_imports = true
+
[tool.pytest.ini_options]
minversion = "7.0"
asyncio_mode = "auto"
@@ -163,9 +167,6 @@ addopts = [
"-q",
"--strict-markers",
]
-markers = [
- "e2e: end-to-end tests requiring Docker services (skipped unless --e2e)",
-]
filterwarnings = [
"ignore::DeprecationWarning",
]
diff --git a/src/meshcore_hub/api/routes/channels.py b/src/meshcore_hub/api/routes/channels.py
index a9d40d6..3d3e794 100644
--- a/src/meshcore_hub/api/routes/channels.py
+++ b/src/meshcore_hub/api/routes/channels.py
@@ -25,7 +25,7 @@ router = APIRouter()
def _channels_key_builder(request: Request) -> str:
role = resolve_user_role(request) or "anonymous"
- return f"channels:role={role}:{sorted_query_string(request)}"
+ return f"{request.url.path}:role={role}:{sorted_query_string(request)}"
def _channel_to_read(channel: Channel, include_key: bool = False) -> ChannelRead:
diff --git a/src/meshcore_hub/api/routes/messages.py b/src/meshcore_hub/api/routes/messages.py
index cd1ad76..86f06e0 100644
--- a/src/meshcore_hub/api/routes/messages.py
+++ b/src/meshcore_hub/api/routes/messages.py
@@ -30,7 +30,7 @@ VALID_MSG_SORT_COLUMNS = {"time", "type", "from", "message"}
def _messages_key_builder(request: Request) -> str:
role = resolve_user_role(request) or "anonymous"
- return f"messages:role={role}:{sorted_query_string(request)}"
+ return f"{request.url.path}:role={role}:{sorted_query_string(request)}"
def _get_tag_name(node: Optional[Node]) -> Optional[str]:
diff --git a/src/meshcore_hub/collector/routes.py b/src/meshcore_hub/collector/routes.py
index ddd6792..6726125 100644
--- a/src/meshcore_hub/collector/routes.py
+++ b/src/meshcore_hub/collector/routes.py
@@ -786,8 +786,8 @@ def evaluate_route_history(
# Thresholds for ``compute_average_quality`` — kept in sync with the
-# ``averageTier`` helper in ``web/static/js/charts.js`` so the server-side
-# rolling-average badge matches the chart's per-route line color.
+# ``averageRouteTier`` helper in ``web/static/js/spa-react/utils/charts.ts`` so
+# the server-side rolling-average badge matches the chart's per-route line color.
AVERAGE_QUALITY_CLEAR_AT = 1.5
AVERAGE_QUALITY_MARGINAL_AT = 0.75
diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py
index ae9aa01..1ec54d0 100644
--- a/src/meshcore_hub/web/app.py
+++ b/src/meshcore_hub/web/app.py
@@ -65,7 +65,9 @@ def _sanitize_header_value(value: str) -> str:
def _load_asset_manifest() -> dict[str, Any]:
- """Load the esbuild asset manifest from dist/assets.json.
+ """Load the asset manifest from dist/assets.json.
+
+ Supports both Vite-generated and legacy esbuild manifest formats.
Returns:
Manifest dict with entry names, vendor hashes, and locale version.
@@ -359,6 +361,8 @@ def _build_config_json(app: FastAPI, request: Request) -> str:
"locale_version": getattr(app.state, "locale_version", ""),
"system_maintenance": app.state.system_maintenance,
"spam_score_threshold": app.state.spam_score_threshold,
+ "system_announcement": app.state.system_announcement,
+ "network_announcement": app.state.network_announcement,
}
role_names = {
@@ -571,24 +575,18 @@ def create_app(
if network_announcement is not None
else settings.network_announcement
)
- if raw_announcement:
- import markdown
-
- app.state.network_announcement = markdown.markdown(raw_announcement)
- else:
- app.state.network_announcement = None
+ app.state.network_announcement = (
+ raw_announcement.strip() if raw_announcement else None
+ )
raw_system_announcement = (
system_announcement
if system_announcement is not None
else settings.system_announcement
)
- if raw_system_announcement:
- import markdown
-
- app.state.system_announcement = markdown.markdown(raw_system_announcement)
- else:
- app.state.system_announcement = None
+ app.state.system_announcement = (
+ raw_system_announcement.strip() if raw_system_announcement else None
+ )
app.state.system_maintenance = (
system_maintenance
@@ -706,10 +704,11 @@ def create_app(
page_loader.load_pages()
app.state.page_loader = page_loader
- # Load esbuild asset manifest for cache-busted filenames
+ # Load asset manifest for cache-busted filenames (Vite or legacy esbuild)
manifest = _load_asset_manifest()
app.state.asset_manifest = manifest
app.state.asset_app_js = manifest.get("app.js", "")
+ app.state.asset_app_css = manifest.get("app.css", "")
app.state.vendor_hashes = manifest.get("vendor", {})
app.state.locale_version = manifest.get("locale_version", "")
@@ -996,7 +995,7 @@ def create_app(
{
"slug": page.slug,
"title": page.title,
- "content_html": page.content_html,
+ "content_markdown": page.content_markdown,
}
)
@@ -1233,14 +1232,14 @@ def create_app(
# --- SPA Catch-All (MUST be last) ---
@app.api_route("/{path:path}", methods=["GET"], tags=["SPA"], response_model=None)
async def spa_catchall(request: Request, path: str = "") -> Response:
- """Serve the SPA shell for all non-API routes."""
- templates_inst: Jinja2Templates = request.app.state.templates
- features = request.app.state.features
- page_loader = request.app.state.page_loader
- custom_pages = (
- page_loader.get_menu_pages() if features.get("pages", True) else []
- )
+ """Serve the SPA shell for all non-API routes.
+ The shell is pure bootstrap: SEO , theme-init, the embedded
+ ``__APP_CONFIG__`` JSON, and the Vite bundle mount point. All visual
+ UI (navbar, banners, pages, footer) is rendered client-side by React
+ from ``__APP_CONFIG__``.
+ """
+ templates_inst: Jinja2Templates = request.app.state.templates
config_json = _build_config_json(request.app, request)
return templates_inst.TemplateResponse(
@@ -1248,26 +1247,14 @@ def create_app(
"spa.html",
{
"network_name": request.app.state.network_name,
- "network_city": request.app.state.network_city,
- "network_country": request.app.state.network_country,
- "network_contact_email": request.app.state.network_contact_email,
- "network_contact_discord": request.app.state.network_contact_discord,
- "network_contact_github": request.app.state.network_contact_github,
- "network_contact_youtube": request.app.state.network_contact_youtube,
"network_welcome_text": request.app.state.network_welcome_text,
- "network_announcement": request.app.state.network_announcement,
- "system_announcement": request.app.state.system_announcement,
- "system_maintenance": request.app.state.system_maintenance,
- "oidc_enabled": request.app.state.oidc_enabled,
- "features": features,
- "custom_pages": custom_pages,
"logo_url": request.app.state.logo_url,
"logo_invert_light": request.app.state.logo_invert_light,
"version": __version__,
"default_theme": request.app.state.web_theme,
"config_json": config_json,
"asset_app_js": request.app.state.asset_app_js,
- "vendor_hashes": request.app.state.vendor_hashes,
+ "asset_app_css": request.app.state.asset_app_css,
},
)
diff --git a/src/meshcore_hub/web/pages.py b/src/meshcore_hub/web/pages.py
index 3c60265..348de84 100644
--- a/src/meshcore_hub/web/pages.py
+++ b/src/meshcore_hub/web/pages.py
@@ -1,4 +1,10 @@
-"""Custom markdown pages loader for MeshCore Hub Web Dashboard."""
+"""Custom markdown pages loader for MeshCore Hub Web Dashboard.
+
+Pages are stored as raw markdown and rendered client-side by the React
+```` component (react-markdown + remark-gfm). The loader only
+parses the YAML frontmatter (slug/title/menu_order); the body is shipped
+verbatim so the SPA is the single source of truth for rendering.
+"""
import logging
from dataclasses import dataclass
@@ -6,7 +12,6 @@ from pathlib import Path
from typing import Optional
import frontmatter
-import markdown
logger = logging.getLogger(__name__)
@@ -18,7 +23,7 @@ class CustomPage:
slug: str
title: str
menu_order: int
- content_html: str
+ content_markdown: str
file_path: str
@property
@@ -38,10 +43,6 @@ class PageLoader:
"""
self.pages_dir = Path(pages_dir)
self._pages: dict[str, CustomPage] = {}
- self._md = markdown.Markdown(
- extensions=["tables", "fenced_code", "toc"],
- output_format="html",
- )
def load_pages(self) -> None:
"""Load all markdown pages from the pages directory."""
@@ -67,7 +68,7 @@ class PageLoader:
logger.info(f"Loaded {len(self._pages)} custom page(s)")
def _load_page(self, file_path: Path) -> Optional[CustomPage]:
- """Load a single markdown page.
+ """Load a single markdown page (frontmatter + raw body).
Args:
file_path: Path to the markdown file.
@@ -83,15 +84,11 @@ class PageLoader:
title = post.get("title", slug.replace("-", " ").replace("_", " ").title())
menu_order = post.get("menu_order", 100)
- # Convert markdown to HTML
- self._md.reset()
- content_html = self._md.convert(post.content)
-
return CustomPage(
slug=slug,
title=title,
menu_order=menu_order,
- content_html=content_html,
+ content_markdown=post.content,
file_path=str(file_path),
)
diff --git a/src/meshcore_hub/web/static/css/app.css b/src/meshcore_hub/web/static/css/app.css
index afc28dd..a331740 100644
--- a/src/meshcore_hub/web/static/css/app.css
+++ b/src/meshcore_hub/web/static/css/app.css
@@ -193,6 +193,7 @@
font-weight: 700;
margin-top: 1.5rem;
margin-bottom: 1rem;
+ scroll-margin-top: 6rem;
}
.prose h2 {
@@ -200,6 +201,7 @@
font-weight: 600;
margin-top: 1.25rem;
margin-bottom: 0.75rem;
+ scroll-margin-top: 6rem;
}
.prose h3 {
@@ -207,6 +209,7 @@
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
+ scroll-margin-top: 6rem;
}
.prose h4 {
@@ -214,6 +217,7 @@
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
+ scroll-margin-top: 6rem;
}
.prose p {
@@ -255,6 +259,16 @@
color: color-mix(in oklab, var(--color-primary) 70%, var(--color-base-content));
}
+/* Heading anchor wrappers (rehype-autolink-headings, behavior: "wrap"):
+ inherit the heading color instead of the link color, and only underline on hover. */
+.prose :is(h1, h2, h3, h4, h5, h6) > a {
+ color: inherit;
+ text-decoration: none;
+}
+.prose :is(h1, h2, h3, h4, h5, h6) > a:hover {
+ text-decoration: underline;
+}
+
.prose code {
background: var(--color-base-200);
padding: 0.125rem 0.25rem;
diff --git a/src/meshcore_hub/web/static/js/charts.js b/src/meshcore_hub/web/static/js/charts.js
deleted file mode 100644
index 2dba18c..0000000
--- a/src/meshcore_hub/web/static/js/charts.js
+++ /dev/null
@@ -1,617 +0,0 @@
-/**
- * MeshCore Hub - Chart.js Helpers
- *
- * Provides common chart configuration and initialization helpers
- * for activity charts used on home and dashboard pages.
- */
-
-// Match app typography (IBM Plex Sans); Chart.js defaults to Helvetica/Arial.
-if (typeof Chart !== 'undefined') {
- Chart.defaults.font.family = '"IBM Plex Sans", ui-sans-serif, system-ui, sans-serif';
-}
-
-/**
- * Format a number with locale-appropriate grouping separators.
- * Uses the visitor's browser locale (no explicit locale argument).
- * @param {number} v
- * @returns {string}
- */
-function formatNumber(v) {
- return new Intl.NumberFormat().format(v);
-}
-
-/**
- * Read page colors from CSS custom properties (defined in app.css :root).
- * Falls back to hardcoded values if CSS vars are unavailable.
- */
-function getCSSColor(varName, fallback) {
- return getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || fallback;
-}
-
-function withAlpha(color, alpha) {
- // oklch(0.65 0.24 265) -> oklch(0.65 0.24 265 / 0.1)
- return color.replace(')', ' / ' + alpha + ')');
-}
-
-const ChartColors = {
- get nodes() { return getCSSColor('--color-nodes', 'oklch(0.65 0.24 265)'); },
- get nodesFill() { return withAlpha(this.nodes, 0.1); },
- get adverts() { return getCSSColor('--color-adverts', 'oklch(0.7 0.17 330)'); },
- get advertsFill() { return withAlpha(this.adverts, 0.1); },
- get messages() { return getCSSColor('--color-messages', 'oklch(0.75 0.18 180)'); },
- get messagesFill() { return withAlpha(this.messages, 0.1); },
- get packets() { return getCSSColor('--color-packets', 'oklch(0.72 0.17 145)'); },
- get packetsFill() { return withAlpha(this.packets, 0.1); },
- get routes() { return getCSSColor('--color-routes', 'oklch(0.72 0.17 30)'); },
- get routesFill() { return withAlpha(this.routes, 0.1); },
-
- // Neutral grays (not page-specific)
- grid: 'oklch(0.4 0 0 / 0.2)',
- text: 'oklch(0.7 0 0)',
- tooltipBg: 'oklch(0.25 0 0)',
- tooltipText: 'oklch(0.9 0 0)',
- tooltipBorder: 'oklch(0.4 0 0)',
-
- // Qualitative palette for stacked breakdown bars (6 hues + neutral grey
- // for "other"). Hardcoded oklch values render consistently across light
- // and dark themes without extra CSS tokens.
- breakdown: [
- 'oklch(0.65 0.24 265)', // blue
- 'oklch(0.7 0.17 330)', // magenta
- 'oklch(0.75 0.18 180)', // teal
- 'oklch(0.72 0.17 145)', // green
- 'oklch(0.7 0.19 80)', // yellow-green
- 'oklch(0.65 0.22 25)', // orange
- 'oklch(0.55 0 0)' // neutral grey (for "other")
- ],
-
- // Semantic quality palette for route health charts. Hardcoded oklch
- // values (same approach as `breakdown`) — app.css defines no semantic
- // status colors.
- quality: {
- clear: 'oklch(0.72 0.17 145)',
- marginal: 'oklch(0.75 0.18 85)',
- failing: 'oklch(0.62 0.24 25)',
- no_coverage: 'oklch(0.65 0.15 250)',
- disabled: 'oklch(0.55 0 0)'
- }
-};
-
-/**
- * Create common chart options with optional legend
- * @param {boolean} showLegend - Whether to show the legend
- * @returns {Object} Chart.js options object
- */
-function createChartOptions(showLegend) {
- return {
- responsive: true,
- maintainAspectRatio: false,
- plugins: {
- legend: {
- display: showLegend,
- position: 'top',
- align: 'end',
- labels: {
- color: ChartColors.text,
- boxWidth: 12,
- padding: 8
- }
- },
- tooltip: {
- mode: 'index',
- intersect: false,
- backgroundColor: ChartColors.tooltipBg,
- titleColor: ChartColors.tooltipText,
- bodyColor: ChartColors.tooltipText,
- borderColor: ChartColors.tooltipBorder,
- borderWidth: 1,
- callbacks: {
- label: function(ctx) {
- const label = ctx.dataset.label || '';
- const value = formatNumber(ctx.parsed.y);
- return label ? label + ': ' + value : value;
- }
- }
- }
- },
- scales: {
- x: {
- grid: { color: ChartColors.grid },
- ticks: {
- color: ChartColors.text,
- maxRotation: 45,
- minRotation: 45,
- maxTicksLimit: 10
- }
- },
- y: {
- beginAtZero: true,
- grid: { color: ChartColors.grid },
- ticks: {
- color: ChartColors.text,
- precision: 0,
- callback: function(value) { return formatNumber(value); }
- }
- }
- },
- interaction: {
- mode: 'nearest',
- axis: 'x',
- intersect: false
- }
- };
-}
-
-/**
- * Format date labels for chart display (e.g., "8 Feb")
- * @param {Array} data - Array of objects with 'date' property
- * @returns {Array} Formatted date strings
- */
-function formatDateLabels(data) {
- return data.map(function(d) {
- var date = new Date(d.date);
- return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
- });
-}
-
-/**
- * Create a single-dataset line chart
- * @param {string} canvasId - ID of the canvas element
- * @param {Object} data - Data object with 'data' array containing {date, count} objects
- * @param {string} label - Dataset label
- * @param {string} borderColor - Line color
- * @param {string} backgroundColor - Fill color
- * @param {boolean} fill - Whether to fill under the line
- */
-function createLineChart(canvasId, data, label, borderColor, backgroundColor, fill) {
- var ctx = document.getElementById(canvasId);
- if (!ctx || !data || !data.data || data.data.length === 0) {
- return null;
- }
-
- return new Chart(ctx, {
- type: 'line',
- data: {
- labels: formatDateLabels(data.data),
- datasets: [{
- label: label,
- data: data.data.map(function(d) { return d.count; }),
- borderColor: borderColor,
- backgroundColor: backgroundColor,
- fill: fill,
- tension: 0.3,
- pointRadius: 2,
- pointHoverRadius: 5
- }]
- },
- options: createChartOptions(false)
- });
-}
-
-/**
- * Create a multi-dataset activity chart (for home page).
- * Pass null for advertData or messageData to omit that series.
- * @param {string} canvasId - ID of the canvas element
- * @param {Object|null} advertData - Advertisement data with 'data' array, or null to omit
- * @param {Object|null} messageData - Message data with 'data' array, or null to omit
- */
-function createActivityChart(canvasId, advertData, messageData) {
- var ctx = document.getElementById(canvasId);
- if (!ctx) return null;
-
- // Build datasets from whichever series are provided
- var datasets = [];
- var labels = null;
-
- if (advertData && advertData.data && advertData.data.length > 0) {
- if (!labels) labels = formatDateLabels(advertData.data);
- datasets.push({
- label: (window.t && window.t('entities.advertisements')) || 'Advertisements',
- data: advertData.data.map(function(d) { return d.count; }),
- borderColor: ChartColors.adverts,
- backgroundColor: ChartColors.advertsFill,
- fill: true,
- tension: 0.3,
- pointRadius: 2,
- pointHoverRadius: 5
- });
- }
-
- if (messageData && messageData.data && messageData.data.length > 0) {
- if (!labels) labels = formatDateLabels(messageData.data);
- datasets.push({
- label: (window.t && window.t('entities.messages')) || 'Messages',
- data: messageData.data.map(function(d) { return d.count; }),
- borderColor: ChartColors.messages,
- backgroundColor: ChartColors.messagesFill,
- fill: true,
- tension: 0.3,
- pointRadius: 2,
- pointHoverRadius: 5
- });
- }
-
- if (datasets.length === 0 || !labels) return null;
-
- return new Chart(ctx, {
- type: 'line',
- data: { labels: labels, datasets: datasets },
- options: createChartOptions(true)
- });
-}
-
-/**
- * Create a horizontal 100% stacked bar chart from labeled buckets.
- *
- * Each bucket becomes one dataset sized proportionally to its count. The
- * x-axis is fixed at 0-100% and tooltips show the raw count and percentage.
- * Returns null when buckets is empty or the total is zero (matching
- * createLineChart's empty-data idiom).
- *
- * @param {string} canvasId - ID of the canvas element
- * @param {Array|null} buckets - Array of {label, count} objects
- * @param {Array} colors - Ordered color strings (one per bucket)
- * @returns {Chart|null}
- */
-function createStackedBarChart(canvasId, buckets, colors) {
- var ctx = document.getElementById(canvasId);
- if (!ctx || !buckets || buckets.length === 0) return null;
-
- var total = buckets.reduce(function(sum, b) { return sum + b.count; }, 0);
- if (total === 0) return null;
-
- var datasets = buckets.map(function(bucket, i) {
- var pct = (bucket.count / total) * 100;
- return {
- label: bucket.label,
- data: [pct],
- backgroundColor: colors[i % colors.length],
- borderColor: colors[i % colors.length],
- borderWidth: 1,
- rawCount: bucket.count
- };
- });
-
- return new Chart(ctx, {
- type: 'bar',
- data: {
- labels: [''],
- datasets: datasets
- },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- indexAxis: 'y',
- plugins: {
- legend: { display: false },
- tooltip: {
- backgroundColor: ChartColors.tooltipBg,
- titleColor: ChartColors.tooltipText,
- bodyColor: ChartColors.tooltipText,
- borderColor: ChartColors.tooltipBorder,
- borderWidth: 1,
- callbacks: {
- label: function(ctx) {
- var label = ctx.dataset.label || '';
- var count = formatNumber(ctx.dataset.rawCount);
- var pct = ctx.parsed.x.toFixed(1);
- return label + ': ' + count + ' (' + pct + '%)';
- }
- }
- }
- },
- scales: {
- x: {
- max: 100,
- stacked: true,
- grid: { color: ChartColors.grid },
- ticks: {
- color: ChartColors.text,
- callback: function(value) { return value + '%'; }
- }
- },
- y: {
- stacked: true,
- grid: { display: false },
- ticks: { display: false }
- }
- },
- interaction: {
- mode: 'nearest',
- intersect: false
- }
- }
- });
-}
-
-/**
- * Map a route-quality enum value to the merged 3-tier space used by the
- * dashboard trend chart and Route Health widget.
- *
- * ``clear`` → clear
- * ``marginal`` → marginal
- * anything else → failing (covers ``failing``, ``unknown``,
- * ``no_coverage``, ``disabled``, null)
- */
-function routeQualityToTier(q) {
- if (q === 'clear') return 'clear';
- if (q === 'marginal') return 'marginal';
- return 'failing';
-}
-
-/**
- * Mean tier over the displayed window. Maps the 3-tier space onto a
- * 0/1/2 numeric scale (failing < marginal < clear), averages, then
- * buckets back: >=1.5 → clear, >=0.75 → marginal, else failing.
- * Empty history falls through to failing (matches routeQualityToTier's
- * default for unknown / null quality).
- *
- * Kept in sync with ``compute_average_quality`` in
- * ``src/meshcore_hub/collector/routes.py`` so the server-side rolling
- * badge matches the client-side chart line color.
- *
- * @param {Array<{quality: string}>|null} history
- * @returns {string} tier name (``clear`` / ``marginal`` / ``failing``)
- */
-function averageRouteTier(history) {
- if (!history || history.length === 0) return 'failing';
- var sum = 0;
- for (var i = 0; i < history.length; i++) {
- var tier = routeQualityToTier(history[i].quality);
- sum += (tier === 'clear' ? 2 : tier === 'marginal' ? 1 : 0);
- }
- var mean = sum / history.length;
- if (mean >= 1.5) return 'clear';
- if (mean >= 0.75) return 'marginal';
- return 'failing';
-}
-
-/**
- * Create a multi-line route-status trend chart for the dashboard.
- *
- * Each route becomes one line plotted on a 3-tier categorical Y axis
- * (``failing`` → ``marginal`` → ``clear``, bottom to top). The line's
- * color reflects the route's CURRENT quality (its latest evaluation),
- * so multiple routes in the same health band share a color — the chart
- * reads as a fleet-health overview rather than per-route identity.
- * Hover tooltips still show the route label, tier, and matched_count.
- *
- * Input is the ``routes`` array from ``GET /dashboard/routes-overview``.
- * The top ``maxRoutes`` routes by current ``matched_count`` are drawn;
- * the rest are dropped silently (summing quality tiers is meaningless).
- *
- * Quality → tier mapping (per the merged-3-tier design):
- * ``clear`` → clear
- * ``marginal`` → marginal
- * anything else → failing (covers ``failing``, ``unknown``,
- * ``no_coverage``, ``disabled``, null)
- *
- * @param {string} canvasId - ID of the canvas element
- * @param {Array|null} routes - Array of RouteOverviewEntry objects
- * @param {number} [maxRoutes=6] - Top-N routes drawn distinctly
- * @returns {Chart|null}
- */
-function createRoutesTrendChart(canvasId, routes, maxRoutes) {
- var ctx = document.getElementById(canvasId);
- if (!ctx || !routes || routes.length === 0) return null;
-
- maxRoutes = maxRoutes || 6;
-
- // Bottom-to-top tier order on the categorical Y axis.
- var tierOrder = ['failing', 'marginal', 'clear'];
-
- function tierColor(tier) {
- return ChartColors.quality[tier] || ChartColors.quality.failing;
- }
-
- // Sort by current matched_count desc; routes with null matched_count
- // (disabled / never evaluated) sort to the end.
- var sorted = routes.slice().sort(function(a, b) {
- var am = a.matched_count || 0;
- var bm = b.matched_count || 0;
- return bm - am;
- });
- var top = sorted.slice(0, maxRoutes);
-
- // Use the longest history as the X-axis label source (all routes
- // share the same window in practice, but be defensive).
- var labels = [];
- for (var i = 0; i < top.length; i++) {
- if (top[i].history && top[i].history.length > labels.length) {
- labels = formatDateLabels(top[i].history);
- }
- }
- if (labels.length === 0) return null;
-
- var datasets = top.map(function(entry) {
- var history = entry.history || [];
- var avgTier = averageRouteTier(history);
- return {
- label: entry.from_label + ' \u2192 ' + entry.to_label,
- data: history.map(function(d) { return routeQualityToTier(d.quality); }),
- borderColor: tierColor(avgTier),
- backgroundColor: 'transparent',
- fill: false,
- tension: 0.3,
- cubicInterpolationMode: 'monotone',
- pointRadius: 2,
- pointHoverRadius: 5,
- spanGaps: true,
- _matched: history.map(function(d) { return d.matched_count || 0; })
- };
- });
-
- var opts = createChartOptions(false);
- // Replace the default numeric Y axis with a 3-tier categorical axis.
- opts.scales.y = {
- type: 'category',
- labels: tierOrder,
- reverse: true,
- grid: { color: ChartColors.grid },
- ticks: {
- color: ChartColors.text,
- callback: function(_value, index) {
- var tier = tierOrder[index];
- return (window.t && window.t('routes.quality_' + tier)) || tier;
- }
- }
- };
- // The default tooltip formatter calls formatNumber(ctx.parsed.y),
- // which is wrong for categorical string values; emit tier + matched.
- opts.plugins.tooltip.callbacks = {
- title: function(items) { return items[0].label; },
- label: function(ctx) {
- var tier = tierOrder[ctx.parsed.y] || 'failing';
- var tierLabel = (window.t && window.t('routes.quality_' + tier)) || tier;
- var matched = (ctx.dataset._matched && ctx.dataset._matched[ctx.dataIndex]) || 0;
- return ctx.dataset.label + ': ' + tierLabel + ' (' + matched + ')';
- }
- };
-
- return new Chart(ctx, {
- type: 'line',
- data: { labels: labels, datasets: datasets },
- options: opts
- });
-}
-
-/**
- * Initialize dashboard charts (nodes, advertisements, messages, packets,
- * plus optional packet-breakdown stacked bars and routes overview).
- * Pass null for any data parameter to skip that chart.
- * @param {Object|null} nodeData - Node count data, or null to skip
- * @param {Object|null} advertData - Advertisement data, or null to skip
- * @param {Object|null} messageData - Message data, or null to skip
- * @param {Object|null} packetData - Raw-packet trend data, or null to skip
- * @param {Array|null} [eventTypeData] - Packet event-type breakdown buckets
- * @param {Array|null} [pathWidthData] - Packet path-width breakdown buckets
- * @param {Array|null} [routesData] - Routes overview ``routes`` array
- */
-function initDashboardCharts(nodeData, advertData, messageData, packetData, eventTypeData, pathWidthData, routesData) {
- if (nodeData) {
- createLineChart(
- 'nodeChart',
- nodeData,
- (window.t && window.t('common.total_entity', { entity: t('entities.nodes') })) || 'Total Nodes',
- ChartColors.nodes,
- ChartColors.nodesFill,
- true
- );
- }
-
- if (advertData) {
- createLineChart(
- 'advertChart',
- advertData,
- (window.t && window.t('entities.advertisements')) || 'Advertisements',
- ChartColors.adverts,
- ChartColors.advertsFill,
- true
- );
- }
-
- if (messageData) {
- createLineChart(
- 'messageChart',
- messageData,
- (window.t && window.t('entities.messages')) || 'Messages',
- ChartColors.messages,
- ChartColors.messagesFill,
- true
- );
- }
-
- if (packetData) {
- createLineChart(
- 'packetChart',
- packetData,
- (window.t && window.t('entities.packets')) || 'Packets',
- ChartColors.packets,
- ChartColors.packetsFill,
- true
- );
- }
-
- if (eventTypeData && eventTypeData.length > 0) {
- createStackedBarChart(
- 'packetEventTypeChart',
- eventTypeData,
- ChartColors.breakdown
- );
- }
-
- if (pathWidthData && pathWidthData.length > 0) {
- createStackedBarChart(
- 'packetPathWidthChart',
- pathWidthData,
- ChartColors.breakdown.slice(0, 3)
- );
- }
-
- if (routesData && routesData.length > 0) {
- createRoutesTrendChart('routesTrendChart', routesData);
- }
-}
-
-/**
- * Create a per-route health status strip — single horizontal bar of N equal
- * colored day-segments.
- *
- * @param {string} canvasId - ID of the canvas element
- * @param {Object} routeData - RouteHistory payload with `data` array
- * @returns {Chart|null}
- */
-function createRouteDetailStrip(canvasId, routeData) {
- var ctx = document.getElementById(canvasId);
- if (!ctx || !routeData || !routeData.data || routeData.data.length === 0) {
- return null;
- }
-
- var existing = Chart.getChart(ctx);
- if (existing) existing.destroy();
-
- var datasets = routeData.data.map(function(day) {
- return {
- label: day.date,
- data: [1],
- backgroundColor: ChartColors.quality[day.quality] || ChartColors.quality.no_coverage,
- borderColor: ChartColors.quality[day.quality] || ChartColors.quality.no_coverage,
- borderWidth: 1,
- _quality: day.quality,
- _matched_count: day.matched_count || 0
- };
- });
-
- return new Chart(ctx, {
- type: 'bar',
- data: { labels: [''], datasets: datasets },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- indexAxis: 'y',
- plugins: {
- legend: { display: false },
- tooltip: {
- backgroundColor: ChartColors.tooltipBg,
- titleColor: ChartColors.tooltipText,
- bodyColor: ChartColors.tooltipText,
- borderColor: ChartColors.tooltipBorder,
- borderWidth: 1,
- callbacks: {
- title: function(ctx) { return ctx[0].dataset.label; },
- label: function(ctx) {
- var q = ctx.dataset._quality || 'unknown';
- var label = (window.t && window.t('routes.quality_' + q)) || q;
- return label + ' (' + ctx.dataset._matched_count + ')';
- }
- }
- }
- },
- scales: {
- x: { stacked: true, grid: { display: false }, ticks: { display: false } },
- y: { stacked: true, grid: { display: false }, ticks: { display: false } }
- },
- interaction: { mode: 'nearest', intersect: true }
- }
- });
-}
diff --git a/src/meshcore_hub/web/static/js/spa-react/App.tsx b/src/meshcore_hub/web/static/js/spa-react/App.tsx
new file mode 100644
index 0000000..a99a0ef
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/App.tsx
@@ -0,0 +1,283 @@
+import { useEffect, useState } from "react";
+import { QueryClientProvider } from "@tanstack/react-query";
+import {
+ BrowserRouter,
+ Routes,
+ Route,
+ Navigate,
+ useLocation,
+ useParams,
+} from "react-router";
+import { createQueryClient } from "@/utils/queryClient";
+import { useAppConfig } from "@/context/AppConfigContext";
+import { ErrorBoundary } from "@/components/ErrorBoundary";
+import { Navbar } from "@/components/Navbar";
+import { Announcements } from "@/components/Announcements";
+import { Footer } from "@/components/Footer";
+import { HomePage } from "@/pages/Home";
+import { DashboardPage } from "@/pages/Dashboard";
+import { Nodes } from "@/pages/Nodes";
+import { NodeDetailPage } from "@/pages/NodeDetail";
+import { Channels } from "@/pages/Channels";
+import { RoutesPage } from "@/pages/Routes";
+import { Messages } from "@/pages/Messages";
+import { Advertisements } from "@/pages/Advertisements";
+import { Packets } from "@/pages/Packets";
+import { PacketDetail } from "@/pages/PacketDetail";
+import { PacketGroupDetail } from "@/pages/PacketGroupDetail";
+import { MapPage } from "@/pages/MapPage";
+import { Members } from "@/pages/Members";
+import { CustomPagePage } from "@/pages/CustomPage";
+import { Profile } from "@/pages/Profile";
+import { NotFound } from "@/pages/NotFound";
+import { Maintenance } from "@/pages/Maintenance";
+
+function useNavActiveState() {
+ const location = useLocation();
+ const config = useAppConfig();
+
+ useEffect(() => {
+ const pathname = location.pathname;
+
+ if (document.activeElement?.closest(".dropdown")) {
+ (document.activeElement as HTMLElement).blur();
+ }
+
+ if (!location.hash) {
+ window.scrollTo(0, 0);
+ }
+
+ const networkName = config.network_name || "MeshCore Network";
+ const features = config.features ?? {};
+ const t = window.t;
+ const compose = (key: string) => `${t(key)} - ${networkName}`;
+
+ const titles: Record = { "/": networkName };
+ if (features.dashboard !== false) titles["/dashboard"] = compose("entities.dashboard");
+ if (features.nodes !== false) titles["/nodes"] = compose("entities.nodes");
+ if (features.channels !== false) titles["/channels"] = compose("entities.channels");
+ if (features.routes !== false) titles["/routes"] = compose("entities.routes");
+ if (features.messages !== false) titles["/messages"] = compose("entities.messages");
+ if (features.advertisements !== false) titles["/advertisements"] = compose("entities.advertisements");
+ if (features.packets !== false) titles["/packets"] = compose("entities.packets");
+ if (features.map !== false) titles["/map"] = compose("entities.map");
+ if (features.members !== false) titles["/members"] = compose("entities.members");
+ titles["/profile"] = compose("links.profile");
+
+ if (titles[pathname]) {
+ document.title = titles[pathname];
+ } else if (pathname.startsWith("/nodes/")) {
+ document.title = compose("entities.node_detail");
+ } else {
+ document.title = networkName;
+ }
+ }, [location.pathname, location.hash, config]);
+}
+
+function ShortLinkRedirect() {
+ const { prefix } = useParams();
+ return ;
+}
+
+function AppRoutes() {
+ const config = useAppConfig();
+ const features = config.features ?? {};
+ const maintenanceMode = config.system_maintenance === true;
+
+ useNavActiveState();
+
+ if (maintenanceMode) {
+ return (
+
+ } />
+
+ );
+ }
+
+ return (
+
+
+
+
+ }
+ />
+ {features.dashboard !== false && (
+
+
+
+ }
+ />
+ )}
+ {features.nodes !== false && (
+ <>
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ } />
+ >
+ )}
+ {features.channels !== false && (
+
+
+
+ }
+ />
+ )}
+ {features.routes !== false && (
+
+
+
+ }
+ />
+ )}
+ {features.messages !== false && (
+
+
+
+ }
+ />
+ )}
+ {features.advertisements !== false && (
+
+
+
+ }
+ />
+ )}
+ {features.packets !== false && (
+ <>
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ >
+ )}
+ {features.map !== false && (
+
+
+
+ }
+ />
+ )}
+ {features.members !== false && (
+
+
+
+ }
+ />
+ )}
+ {features.pages !== false && (
+
+
+
+ }
+ />
+ )}
+ {config.oidc_enabled && (
+ <>
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ >
+ )}
+ } />
+
+ );
+}
+
+function Shell() {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
+
+export function App() {
+ const [queryClient] = useState(createQueryClient);
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Alerts.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Alerts.test.tsx
new file mode 100644
index 0000000..87cce95
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/Alerts.test.tsx
@@ -0,0 +1,37 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import {
+ Loading,
+ ErrorAlert,
+ InfoAlert,
+ SuccessAlert,
+ WarningBadge,
+} from "@/components/Alerts";
+
+describe("Alerts", () => {
+ it("Loading renders a centered spinner", () => {
+ const { container } = render( );
+ expect(container.querySelector(".loading-spinner")).not.toBeNull();
+ });
+
+ it("ErrorAlert renders an error-toned alert with the message", () => {
+ render( );
+ const alert = screen.getByRole("alert");
+ expect(alert).toHaveClass("alert-error");
+ expect(alert).toHaveTextContent("Something broke");
+ });
+
+ it("InfoAlert and SuccessAlert render with the correct tones", () => {
+ const { rerender } = render( );
+ expect(screen.getByRole("alert")).toHaveClass("alert-info");
+ rerender( );
+ expect(screen.getByRole("alert")).toHaveClass("alert-success");
+ });
+
+ it("WarningBadge renders a tooltip with the message", () => {
+ const { container } = render( );
+ expect(container.querySelector(".badge-warning")).not.toBeNull();
+ expect(container.querySelector('[data-tip="careful"]')).not.toBeNull();
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Alerts.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Alerts.tsx
new file mode 100644
index 0000000..b4e2ac6
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/Alerts.tsx
@@ -0,0 +1,47 @@
+import { useTranslation } from "react-i18next";
+import { IconError, IconInfo, IconSuccess, IconAlert } from "@/components/icons";
+
+export function Loading() {
+ return (
+
+
+
+ );
+}
+
+export function ErrorAlert({ message }: { message: string }) {
+ return (
+
+
+ {message}
+
+ );
+}
+
+export function InfoAlert({ message }: { message: string }) {
+ return (
+
+
+ {message}
+
+ );
+}
+
+export function SuccessAlert({ message }: { message: string }) {
+ return (
+
+
+ {message}
+
+ );
+}
+
+export function WarningBadge({ message }: { message: string }) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Announcements.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Announcements.test.tsx
new file mode 100644
index 0000000..d77c9c3
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/Announcements.test.tsx
@@ -0,0 +1,95 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it } from "vitest";
+
+import { AppConfigProvider } from "@/context/AppConfigContext";
+import { Announcements } from "@/components/Announcements";
+import { makeConfig } from "@/test/makeConfig";
+import type { AppConfig } from "@/types/config";
+
+function renderAnnouncements(config: AppConfig) {
+ return render(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ sessionStorage.clear();
+});
+
+describe("Announcements", () => {
+ it("renders nothing when there are no announcements", () => {
+ const { container } = renderAnnouncements(makeConfig());
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders the system banner content rendered from markdown", () => {
+ const { container } = renderAnnouncements(
+ makeConfig({ system_announcement: "**Outage** at 22:00" }),
+ );
+ expect(container.querySelector("#system-banner")).not.toBeNull();
+ expect(screen.getByText("Outage").tagName).toBe("STRONG");
+ });
+
+ it("renders the network banner with a dismiss button", () => {
+ const { container } = renderAnnouncements(
+ makeConfig({ network_announcement: "Notice" }),
+ );
+ expect(container.querySelector("#flash-banner")).not.toBeNull();
+ expect(screen.getByLabelText("Dismiss")).toBeInTheDocument();
+ });
+
+ it("renders the network banner content rendered from markdown", () => {
+ const { container } = renderAnnouncements(
+ makeConfig({ network_announcement: "**Maintenance** done" }),
+ );
+ const banner = container.querySelector("#flash-banner");
+ expect(banner).not.toBeNull();
+ expect(screen.getByText("Maintenance").tagName).toBe("STRONG");
+ });
+
+ it("does not render a dismiss control on the system banner", () => {
+ const { container } = renderAnnouncements(
+ makeConfig({ system_announcement: "Heads up" }),
+ );
+ const banner = container.querySelector("#system-banner");
+ expect(banner).not.toBeNull();
+ expect(banner!.querySelector("button")).toBeNull();
+ });
+
+ it("renders the system banner above the network banner", () => {
+ const { container } = renderAnnouncements(
+ makeConfig({
+ system_announcement: "System notice",
+ network_announcement: "Network notice",
+ }),
+ );
+ const system = container.querySelector("#system-banner");
+ const network = container.querySelector("#flash-banner");
+ expect(system).not.toBeNull();
+ expect(network).not.toBeNull();
+ // network follows system in document order
+ expect(
+ system!.compareDocumentPosition(network!) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+
+ it("dismisses the network banner and persists to sessionStorage", () => {
+ const { container } = renderAnnouncements(
+ makeConfig({ network_announcement: "Notice" }),
+ );
+ fireEvent.click(screen.getByLabelText("Dismiss"));
+ expect(container.querySelector("#flash-banner")).toBeNull();
+ expect(sessionStorage.getItem("flash-banner-dismissed")).toBe("1");
+ });
+
+ it("does not render a previously dismissed network banner", () => {
+ sessionStorage.setItem("flash-banner-dismissed", "1");
+ const { container } = renderAnnouncements(
+ makeConfig({ network_announcement: "Notice" }),
+ );
+ expect(container.querySelector("#flash-banner")).toBeNull();
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Announcements.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Announcements.tsx
new file mode 100644
index 0000000..05244af
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/Announcements.tsx
@@ -0,0 +1,61 @@
+import { useState } from "react";
+
+import { Markdown } from "@/components/Markdown";
+import { useAppConfig } from "@/context/AppConfigContext";
+
+export function Announcements() {
+ const config = useAppConfig();
+ const [dismissed, setDismissed] = useState(() => {
+ try {
+ return sessionStorage.getItem("flash-banner-dismissed") === "1";
+ } catch {
+ return false;
+ }
+ });
+
+ const system = config.system_announcement;
+ const network = config.network_announcement;
+
+ const dismiss = () => {
+ setDismissed(true);
+ try {
+ sessionStorage.setItem("flash-banner-dismissed", "1");
+ } catch {
+ // ignore
+ }
+ };
+
+ if (!system && (!network || dismissed)) return null;
+
+ return (
+ <>
+ {system && (
+
+ )}
+ {network && !dismissed && (
+
+ )}
+ >
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/AuthSection.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/AuthSection.test.tsx
new file mode 100644
index 0000000..dedc165
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/AuthSection.test.tsx
@@ -0,0 +1,65 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { AuthSection } from "@/components/AuthSection";
+import { AppConfigProvider } from "@/context/AppConfigContext";
+import { makeConfig } from "@/test/makeConfig";
+import type { AppConfig } from "@/types/config";
+
+function renderAuth(config: Partial = {}) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("AuthSection", () => {
+ it("renders nothing when OIDC is disabled", () => {
+ const { container } = renderAuth({ oidc_enabled: false });
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("shows a login link when OIDC is enabled with no user", () => {
+ renderAuth({ oidc_enabled: true });
+ expect(screen.getByText("auth.login").closest("a")).toHaveAttribute(
+ "href",
+ "/auth/login",
+ );
+ });
+
+ it("shows the avatar image when the user has a picture", () => {
+ renderAuth({
+ oidc_enabled: true,
+ user: { sub: "u1", name: "Jane", picture: "pic.jpg" },
+ });
+ expect(screen.getByAltText("Jane")).toHaveAttribute("src", "pic.jpg");
+ });
+
+ it("shows initials derived from the name when no picture", () => {
+ renderAuth({
+ oidc_enabled: true,
+ user: { sub: "u1", name: "Jane Doe" },
+ });
+ expect(screen.getByText("JD")).toBeInTheDocument();
+ });
+
+ it("renders role badges from config.roles", () => {
+ renderAuth({
+ oidc_enabled: true,
+ roles: ["admin", "operator"],
+ user: { sub: "u1", name: "Jane" },
+ });
+ expect(screen.getByText("admin")).toBeInTheDocument();
+ expect(screen.getByText("operator")).toBeInTheDocument();
+ });
+
+ it("shows the user sub in debug mode", () => {
+ renderAuth({
+ oidc_enabled: true,
+ debug: true,
+ user: { sub: "user-abc", name: "Jane" },
+ });
+ expect(screen.getByText("user-abc")).toBeInTheDocument();
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/AuthSection.tsx b/src/meshcore_hub/web/static/js/spa-react/components/AuthSection.tsx
new file mode 100644
index 0000000..7dadba5
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/AuthSection.tsx
@@ -0,0 +1,86 @@
+import { useTranslation } from "react-i18next";
+import { useAppConfig } from "@/context/AppConfigContext";
+import { IconUser, IconLogout } from "@/components/icons";
+
+export function AuthSection() {
+ const { t } = useTranslation();
+ const config = useAppConfig();
+
+ if (!config.oidc_enabled) return null;
+
+ const user = config.user;
+ if (!user) {
+ return (
+
+ {t("auth.login")}
+
+ );
+ }
+
+ const displayName = user.name || user.email || "User";
+ const initials = displayName
+ .split(" ")
+ .map((w) => w[0])
+ .join("")
+ .slice(0, 2)
+ .toUpperCase();
+
+ const roleBadges = (config.roles ?? []).map((r) => {
+ const key = `auth.role_${r}`;
+ const label = t(key);
+ const name = label !== key ? label : r;
+ return (
+
+ {name}
+
+ );
+ });
+
+ return (
+
+
+ {user.picture ? (
+
+ ) : (
+ {initials}
+ )}
+
+
+ -
+
+ {displayName}
+ {config.debug && user.sub && (
+ {user.sub}
+ )}
+ {roleBadges.length > 0 && (
+ {roleBadges}
+ )}
+
+
+
+ -
+
+
{t("links.profile")}
+
+
+ -
+
+
{t("auth.logout")}
+
+
+
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.test.tsx
new file mode 100644
index 0000000..a240b06
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.test.tsx
@@ -0,0 +1,40 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { AutoRefreshToggle } from "@/components/AutoRefreshToggle";
+
+describe("AutoRefreshToggle", () => {
+ it("renders nothing when the interval is 0", () => {
+ const { container } = render(
+ {}}
+ intervalSeconds={0}
+ />,
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("shows the interval and a checked toggle while running", () => {
+ const onToggle = vi.fn();
+ render(
+ ,
+ );
+ expect(screen.getByText("30s")).toBeInTheDocument();
+ const checkbox = screen.getByRole("checkbox");
+ expect(checkbox).toBeChecked();
+ fireEvent.click(checkbox);
+ expect(onToggle).toHaveBeenCalledOnce();
+ });
+
+ it("shows an unchecked toggle while paused", () => {
+ render(
+ {}} intervalSeconds={30} />,
+ );
+ expect(screen.getByRole("checkbox")).not.toBeChecked();
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.tsx b/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.tsx
new file mode 100644
index 0000000..1c910a1
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.tsx
@@ -0,0 +1,35 @@
+import { useTranslation } from "react-i18next";
+import { IconRefresh } from "@/components/icons";
+
+interface AutoRefreshToggleProps {
+ paused: boolean;
+ onToggle: () => void;
+ intervalSeconds: number;
+}
+
+export function AutoRefreshToggle({
+ paused,
+ onToggle,
+ intervalSeconds,
+}: AutoRefreshToggleProps) {
+ const { t } = useTranslation();
+ if (intervalSeconds <= 0) return null;
+ return (
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Badges.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Badges.test.tsx
new file mode 100644
index 0000000..185ddd8
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/Badges.test.tsx
@@ -0,0 +1,27 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { CallsignBadge, CountBadge, RoleBadge } from "@/components/Badges";
+
+describe("badge recipes", () => {
+ it("CountBadge renders a large badge", () => {
+ render(42 things );
+ const el = screen.getByText("42 things");
+ expect(el).toHaveClass("badge");
+ expect(el).toHaveClass("badge-lg");
+ });
+
+ it("RoleBadge renders a primary small badge", () => {
+ render( );
+ const el = screen.getByText("operator");
+ expect(el).toHaveClass("badge-primary");
+ expect(el).toHaveClass("badge-sm");
+ });
+
+ it("CallsignBadge renders a neutral small badge", () => {
+ render( );
+ const el = screen.getByText("AB1CDE");
+ expect(el).toHaveClass("badge-neutral");
+ expect(el).toHaveClass("badge-sm");
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Badges.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Badges.tsx
new file mode 100644
index 0000000..74c5b8d
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/Badges.tsx
@@ -0,0 +1,13 @@
+import type { ReactNode } from "react";
+
+export function CountBadge({ children }: { children: ReactNode }) {
+ return {children};
+}
+
+export function RoleBadge({ role }: { role: string }) {
+ return {role};
+}
+
+export function CallsignBadge({ callsign }: { callsign: string }) {
+ return {callsign};
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.test.tsx
new file mode 100644
index 0000000..b1382d9
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.test.tsx
@@ -0,0 +1,63 @@
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router";
+import { describe, expect, it } from "vitest";
+
+import { Breadcrumbs, type Crumb } from "@/components/Breadcrumbs";
+
+function renderCrumbs(items: Crumb[]) {
+ return render(
+
+
+ ,
+ );
+}
+
+const items: Crumb[] = [
+ { label: "Home", to: "/" },
+ { label: "Nodes", to: "/nodes" },
+ { label: "AB1234" },
+];
+
+describe("Breadcrumbs", () => {
+ it("renders a nav landmark labelled Breadcrumb", () => {
+ renderCrumbs(items);
+ expect(
+ screen.getByRole("navigation", { name: "Breadcrumb" }),
+ ).toBeInTheDocument();
+ });
+
+ it("links non-final crumbs to their targets", () => {
+ renderCrumbs(items);
+ expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute(
+ "href",
+ "/",
+ );
+ expect(screen.getByRole("link", { name: "Nodes" })).toHaveAttribute(
+ "href",
+ "/nodes",
+ );
+ });
+
+ it("renders the final crumb as plain text with aria-current=page", () => {
+ renderCrumbs(items);
+ expect(
+ screen.queryByRole("link", { name: "AB1234" }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText("AB1234").closest("li")).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ });
+
+ it("renders a crumb without a target as plain text even mid-trail", () => {
+ renderCrumbs([
+ { label: "Home", to: "/" },
+ { label: "Static" },
+ { label: "Leaf" },
+ ]);
+ expect(
+ screen.queryByRole("link", { name: "Static" }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText("Static")).toBeInTheDocument();
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.tsx
new file mode 100644
index 0000000..1ee7bcf
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.tsx
@@ -0,0 +1,30 @@
+import type { ReactNode } from "react";
+import { Link } from "react-router";
+
+export interface Crumb {
+ label: ReactNode;
+ to?: string;
+}
+
+export function Breadcrumbs({ items }: { items: Crumb[] }) {
+ return (
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.test.tsx
new file mode 100644
index 0000000..4c3f143
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.test.tsx
@@ -0,0 +1,86 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { ConfirmDialog } from "@/components/ConfirmDialog";
+
+function renderDialog(props: Partial[0]> = {}) {
+ const onConfirm = vi.fn();
+ const onCancel = vi.fn();
+ render(
+ ,
+ );
+ return { onConfirm, onCancel };
+}
+
+describe("ConfirmDialog", () => {
+ it("renders the title and message", () => {
+ renderDialog();
+ expect(
+ screen.getByRole("heading", { name: "Delete thing" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Are you sure?")).toBeInTheDocument();
+ });
+
+ it("calls onConfirm and onCancel from the respective buttons", () => {
+ const { onConfirm, onCancel } = renderDialog();
+ fireEvent.click(screen.getByRole("button", { name: "Delete" }));
+ expect(onConfirm).toHaveBeenCalledOnce();
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(onCancel).toHaveBeenCalledOnce();
+ });
+
+ it("uses the error tone by default and primary when requested", () => {
+ const { rerender } = render(
+ {}}
+ onCancel={() => {}}
+ />,
+ );
+ expect(screen.getByRole("button", { name: "Go" })).toHaveClass(
+ "btn-error",
+ );
+ rerender(
+ {}}
+ onCancel={() => {}}
+ />,
+ );
+ expect(screen.getByRole("button", { name: "Go" })).toHaveClass(
+ "btn-primary",
+ );
+ });
+
+ it("disables both buttons and shows a spinner while saving", () => {
+ const { container } = render(
+ {}}
+ onCancel={() => {}}
+ />,
+ );
+ expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
+ expect(container.querySelector(".loading-spinner")).not.toBeNull();
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.tsx
new file mode 100644
index 0000000..80568a3
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.tsx
@@ -0,0 +1,55 @@
+import type { ReactNode } from "react";
+
+import { Modal } from "@/components/Modal";
+
+export function ConfirmDialog({
+ title,
+ message,
+ confirmLabel,
+ cancelLabel,
+ saving = false,
+ tone = "error",
+ onConfirm,
+ onCancel,
+}: {
+ title: ReactNode;
+ message: ReactNode;
+ confirmLabel: ReactNode;
+ cancelLabel: ReactNode;
+ saving?: boolean;
+ tone?: "error" | "primary";
+ onConfirm: () => void;
+ onCancel: () => void;
+}) {
+ return (
+
+
+
+ >
+ }
+ >
+ {message}
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.test.tsx
new file mode 100644
index 0000000..9f5818a
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.test.tsx
@@ -0,0 +1,27 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { CopyableValue } from "@/components/CopyableValue";
+import { copyToClipboard } from "@/utils/clipboard";
+
+vi.mock("@/utils/clipboard", () => ({
+ copyToClipboard: vi.fn(),
+}));
+
+describe("CopyableValue", () => {
+ it("copies the value on click (inline variant)", () => {
+ render( );
+ const el = screen.getByText("abc123");
+ expect(el).toHaveClass("font-mono");
+ fireEvent.click(el);
+ expect(copyToClipboard).toHaveBeenCalledWith(expect.anything(), "abc123");
+ });
+
+ it("renders the block variant with block classes", () => {
+ render( );
+ const el = screen.getByText("deadbeef");
+ expect(el).toHaveClass("block");
+ expect(el).toHaveClass("break-all");
+ expect(el).not.toHaveClass("font-mono");
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.tsx b/src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.tsx
new file mode 100644
index 0000000..8852d55
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.tsx
@@ -0,0 +1,23 @@
+import { copyToClipboard } from "@/utils/clipboard";
+
+export function CopyableValue({
+ value,
+ variant = "inline",
+}: {
+ value: string;
+ variant?: "inline" | "block";
+}) {
+ return (
+ copyToClipboard(e, value)}
+ title="Click to copy"
+ >
+ {value}
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Definition.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Definition.test.tsx
new file mode 100644
index 0000000..2cf8b32
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/Definition.test.tsx
@@ -0,0 +1,34 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { DefinitionField, DefinitionGrid } from "@/components/Definition";
+
+describe("DefinitionField", () => {
+ it("renders the label above the value", () => {
+ render(5 (chan) );
+ expect(screen.getByText("Channel")).toBeInTheDocument();
+ expect(screen.getByText("5 (chan)")).toBeInTheDocument();
+ });
+});
+
+describe("DefinitionGrid", () => {
+ it("uses the default two-column grid classes", () => {
+ const { container } = render(
+
+ x
+ ,
+ );
+ expect(container.firstChild).toHaveClass("grid");
+ expect(container.firstChild).toHaveClass("md:grid-cols-2");
+ });
+
+ it("allows a custom className override", () => {
+ const { container } = render(
+
+ x
+ ,
+ );
+ expect(container.firstChild).toHaveClass("grid-cols-3");
+ expect(container.firstChild).not.toHaveClass("md:grid-cols-2");
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Definition.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Definition.tsx
new file mode 100644
index 0000000..5484da1
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/Definition.tsx
@@ -0,0 +1,30 @@
+import type { ReactNode } from "react";
+
+export function DefinitionField({
+ label,
+ children,
+}: {
+ label: ReactNode;
+ children: ReactNode;
+}) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+export function DefinitionGrid({
+ className,
+ children,
+}: {
+ className?: string;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/EmptyState.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/EmptyState.test.tsx
new file mode 100644
index 0000000..62c7f7b
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/EmptyState.test.tsx
@@ -0,0 +1,27 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { EmptyState, EmptyRow } from "@/components/EmptyState";
+
+describe("EmptyState", () => {
+ it("renders its children", () => {
+ render(No nodes found );
+ expect(screen.getByText("No nodes found")).toBeInTheDocument();
+ });
+});
+
+describe("EmptyRow", () => {
+ it("renders a table cell spanning the given columns", () => {
+ const { container } = render(
+
+
+ Nothing here
+
+
,
+ );
+ const td = container.querySelector("td");
+ expect(td).not.toBeNull();
+ expect(td!.getAttribute("colspan")).toBe("5");
+ expect(screen.getByText("Nothing here")).toBeInTheDocument();
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/EmptyState.tsx b/src/meshcore_hub/web/static/js/spa-react/components/EmptyState.tsx
new file mode 100644
index 0000000..9470f46
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/EmptyState.tsx
@@ -0,0 +1,21 @@
+import type { ReactNode } from "react";
+
+export function EmptyState({ children }: { children: ReactNode }) {
+ return {children};
+}
+
+export function EmptyRow({
+ colSpan,
+ children,
+}: {
+ colSpan: number;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ErrorBoundary.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ErrorBoundary.test.tsx
new file mode 100644
index 0000000..cbbea11
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/ErrorBoundary.test.tsx
@@ -0,0 +1,59 @@
+import { render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { ErrorBoundary } from "@/components/ErrorBoundary";
+
+function Thrower({ message }: { message: string }): never {
+ throw new Error(message);
+}
+
+const originalT = window.t;
+
+beforeEach(() => {
+ window.t = (key: string) => key;
+});
+
+afterEach(() => {
+ window.t = originalT;
+});
+
+describe("ErrorBoundary", () => {
+ it("renders children when no error is thrown", () => {
+ render(
+
+ all good
+ ,
+ );
+ expect(screen.getByText("all good")).toBeInTheDocument();
+ });
+
+ it("renders the fallback UI when a child throws", () => {
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText("common.error")).toBeInTheDocument();
+ expect(screen.getByText("common.failed_to_load_page")).toBeInTheDocument();
+ expect(screen.getByText("kaboom")).toBeInTheDocument();
+ const homeLink = screen.getByText("common.go_home");
+ expect(homeLink.closest("a")).toHaveAttribute("href", "/");
+ spy.mockRestore();
+ });
+
+ it("logs the caught error via componentDidCatch", () => {
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
+ render(
+
+
+ ,
+ );
+ expect(spy).toHaveBeenCalledWith(
+ "React ErrorBoundary caught:",
+ expect.any(Error),
+ expect.objectContaining({ componentStack: expect.any(String) }),
+ );
+ spy.mockRestore();
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ErrorBoundary.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ErrorBoundary.tsx
new file mode 100644
index 0000000..a9acdae
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/ErrorBoundary.tsx
@@ -0,0 +1,47 @@
+import { Component, type ErrorInfo, type ReactNode } from "react";
+
+interface Props {
+ children: ReactNode;
+}
+
+interface State {
+ hasError: boolean;
+ error: Error | null;
+}
+
+export class ErrorBoundary extends Component {
+ constructor(props: Props) {
+ super(props);
+ this.state = { hasError: false, error: null };
+ }
+
+ static getDerivedStateFromError(error: Error): State {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
+ console.error("React ErrorBoundary caught:", error, errorInfo);
+ }
+
+ render(): ReactNode {
+ if (this.state.hasError) {
+ return (
+
+
+ {window.t("common.error")}
+
+
+ {window.t("common.failed_to_load_page")}
+
+
+ {this.state.error?.message ?? "Unknown error"}
+
+
+ {window.t("common.go_home")}
+
+
+ );
+ }
+ return this.props.children;
+ }
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/FilterForm.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/FilterForm.test.tsx
new file mode 100644
index 0000000..65d9cb5
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/FilterForm.test.tsx
@@ -0,0 +1,101 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { MemoryRouter, useLocation } from "react-router";
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ FilterField,
+ FilterForm,
+ OperatorSelect,
+ submitOnEnter,
+} from "@/components/FilterForm";
+
+const profiles = [
+ { id: "1", name: "Alice", callsign: "AL", user_id: "u1" },
+ { id: "2", name: "Bob", callsign: null, user_id: "u2" },
+];
+
+describe("OperatorSelect", () => {
+ it("renders an all-operators option plus formatted profile options", () => {
+ render( );
+ expect(screen.getByText("common.all_operators")).toBeInTheDocument();
+ expect(screen.getByText("Alice (AL)")).toBeInTheDocument();
+ // No callsign -> falls back to the plain name
+ expect(screen.getByText("Bob")).toBeInTheDocument();
+ });
+
+ it("supports controlled value and onChange", () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ const select = screen.getByRole("combobox") as HTMLSelectElement;
+ expect(select.value).toBe("1");
+ fireEvent.change(select, { target: { value: "2" } });
+ expect(onChange).toHaveBeenCalledOnce();
+ });
+});
+
+describe("FilterField", () => {
+ it("renders a label wrapping the control", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText("Search")).toBeInTheDocument();
+ expect(screen.getByTestId("control")).toBeInTheDocument();
+ });
+});
+
+describe("submitOnEnter", () => {
+ it("submits the form on Enter", () => {
+ const requestSubmit = vi
+ .spyOn(HTMLFormElement.prototype, "requestSubmit")
+ .mockImplementation(() => {});
+ render(
+ ,
+ );
+ fireEvent.keyDown(screen.getByTestId("inp"), { key: "Enter" });
+ expect(requestSubmit).toHaveBeenCalledOnce();
+ requestSubmit.mockRestore();
+ });
+
+ it("does nothing for other keys", () => {
+ const requestSubmit = vi
+ .spyOn(HTMLFormElement.prototype, "requestSubmit")
+ .mockImplementation(() => {});
+ render(
+ ,
+ );
+ fireEvent.keyDown(screen.getByTestId("inp"), { key: "a" });
+ expect(requestSubmit).not.toHaveBeenCalled();
+ requestSubmit.mockRestore();
+ });
+});
+
+describe("FilterForm clear navigation", () => {
+ function LocationProbe() {
+ const location = useLocation();
+ return (
+ {location.pathname + location.search}
+ );
+ }
+
+ it("clears filters via client-side navigation (no full reload)", () => {
+ render(
+
+
+
+
+
+ ,
+ );
+ expect(screen.getByTestId("loc").textContent).toBe("/nodes?search=foo");
+ fireEvent.click(screen.getByText("common.clear"));
+ expect(screen.getByTestId("loc").textContent).toBe("/nodes");
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/FilterForm.tsx b/src/meshcore_hub/web/static/js/spa-react/components/FilterForm.tsx
new file mode 100644
index 0000000..869fcf4
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/FilterForm.tsx
@@ -0,0 +1,186 @@
+import { useTranslation } from "react-i18next";
+import { Link, useNavigate } from "react-router";
+import { IconFilter } from "@/components/icons";
+
+interface FilterFormProps {
+ basePath: string;
+ children: React.ReactNode;
+ submitLabel?: string;
+ clearLabel?: string;
+}
+
+export function FilterForm({
+ basePath,
+ children,
+ submitLabel,
+ clearLabel,
+}: FilterFormProps) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ const formData = new FormData(e.currentTarget);
+ const params = new URLSearchParams();
+ const keys = new Set(formData.keys());
+ for (const k of keys) {
+ for (const v of formData.getAll(k)) {
+ if (v) params.append(k, v as string);
+ }
+ }
+ const queryStr = params.toString();
+ navigate(queryStr ? `${basePath}?${queryStr}` : basePath);
+ };
+
+ return (
+
+ );
+}
+
+interface FilterToggleProps {
+ open: boolean;
+ onChange: () => void;
+}
+
+export function FilterToggle({ open, onChange }: FilterToggleProps) {
+ const { t } = useTranslation();
+ return (
+
+ );
+}
+
+export function autoSubmit(
+ e: React.ChangeEvent,
+) {
+ e.currentTarget.form?.requestSubmit();
+}
+
+export function submitOnEnter(e: React.KeyboardEvent) {
+ if (e.key === "Enter") e.currentTarget.form?.requestSubmit();
+}
+
+export function FilterField({
+ label,
+ children,
+ className,
+}: {
+ label: string;
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+interface FilterSelectOption {
+ value: string;
+ label: string;
+}
+
+interface FilterSelectProps {
+ name: string;
+ options: FilterSelectOption[];
+ defaultValue?: string;
+ onChange?: (e: React.ChangeEvent) => void;
+ className?: string;
+}
+
+export function FilterSelect({
+ name,
+ options,
+ defaultValue,
+ onChange,
+ className,
+}: FilterSelectProps) {
+ return (
+
+ );
+}
+
+export interface OperatorOption {
+ id: string;
+ name?: string | null;
+ callsign?: string | null;
+ user_id?: string;
+}
+
+interface OperatorSelectProps {
+ name?: string;
+ profiles: OperatorOption[];
+ value?: string;
+ defaultValue?: string;
+ onChange?: (e: React.ChangeEvent) => void;
+ className?: string;
+}
+
+export function OperatorSelect({
+ name,
+ profiles,
+ value,
+ defaultValue,
+ onChange,
+ className,
+}: OperatorSelectProps) {
+ const { t } = useTranslation();
+ const controlled = value !== undefined;
+ return (
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Footer.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Footer.test.tsx
new file mode 100644
index 0000000..e746c98
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/Footer.test.tsx
@@ -0,0 +1,94 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { AppConfigProvider } from "@/context/AppConfigContext";
+import { Footer } from "@/components/Footer";
+import { makeConfig } from "@/test/makeConfig";
+import type { AppConfig } from "@/types/config";
+
+function renderFooter(config: AppConfig) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("Footer", () => {
+ it("renders the network name and version", () => {
+ renderFooter(makeConfig({ network_name: "HamNet", version: "2.3.4" }));
+ expect(screen.getByText("HamNet")).toBeInTheDocument();
+ expect(screen.getByText(/2\.3\.4/)).toBeInTheDocument();
+ });
+
+ it("renders city and country when both are set", () => {
+ renderFooter(
+ makeConfig({ network_name: "HamNet", network_city: "Berlin", network_country: "DE" }),
+ );
+ expect(screen.getByText("HamNet | Berlin, DE")).toBeInTheDocument();
+ });
+
+ it("omits the locale segment when city or country is missing", () => {
+ renderFooter(makeConfig({ network_name: "HamNet", network_city: "Berlin" }));
+ expect(screen.getByText("HamNet")).toBeInTheDocument();
+ expect(screen.queryByText(/Berlin/)).not.toBeInTheDocument();
+ });
+
+ it("renders contact links when provided", () => {
+ renderFooter(
+ makeConfig({
+ network_contact_email: "op@example.com",
+ network_contact_discord: "https://discord.gg/x",
+ network_contact_github: "https://github.com/x",
+ network_contact_youtube: "https://youtube.com/@x",
+ }),
+ );
+ expect(screen.getByText("op@example.com")).toHaveAttribute("href", "mailto:op@example.com");
+ expect(screen.getByText("links.discord")).toHaveAttribute("href", "https://discord.gg/x");
+ expect(screen.getByText("links.youtube")).toHaveAttribute("href", "https://youtube.com/@x");
+ // "links.github" appears twice (MeshCore project link + network contact link) — pick by href
+ expect(screen.getAllByText("links.github").length).toBe(2);
+ expect(
+ screen.getByText("links.github", { selector: 'a[href="https://github.com/x"]' }),
+ ).toBeInTheDocument();
+ });
+
+ it("omits all contact links when none are set", () => {
+ const { container } = renderFooter(makeConfig());
+ const paragraphs = container.querySelectorAll("p");
+ // No contact link hrefs anywhere in the footer
+ expect(container.querySelector('a[href^="mailto:"]')).toBeNull();
+ expect(container.querySelector('a[href*="discord"]')).toBeNull();
+ expect(paragraphs.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("renders partial contact links without stray separators", () => {
+ renderFooter(
+ makeConfig({
+ network_contact_email: "op@example.com",
+ network_contact_youtube: "https://youtube.com/@x",
+ }),
+ );
+ expect(screen.getByText("op@example.com")).toBeInTheDocument();
+ expect(screen.getByText("links.youtube")).toHaveAttribute(
+ "href",
+ "https://youtube.com/@x",
+ );
+ expect(screen.queryByText("links.discord")).not.toBeInTheDocument();
+ // Only the MeshCore project github link — no contact github link
+ expect(screen.getAllByText("links.github").length).toBe(1);
+ });
+
+ it("falls back to 'MeshCore Network' when network_name is empty", () => {
+ renderFooter(makeConfig({ network_name: "" }));
+ expect(screen.getByText("MeshCore Network")).toBeInTheDocument();
+ });
+
+ it("renders the MeshCore Hub attribution link", () => {
+ renderFooter(makeConfig());
+ expect(screen.getByText("MeshCore Hub").closest("a")).toHaveAttribute(
+ "href",
+ "https://github.com/ipnet-mesh/meshcore-hub",
+ );
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Footer.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Footer.tsx
new file mode 100644
index 0000000..079ebba
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/Footer.tsx
@@ -0,0 +1,115 @@
+import { useTranslation } from "react-i18next";
+
+import { useAppConfig } from "@/context/AppConfigContext";
+
+export function Footer() {
+ const config = useAppConfig();
+ const { t } = useTranslation();
+
+ const networkName = config.network_name || "MeshCore Network";
+ const hasLocale = Boolean(config.network_city && config.network_country);
+
+ return (
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/JsonTree.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/JsonTree.test.tsx
new file mode 100644
index 0000000..43beedc
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/JsonTree.test.tsx
@@ -0,0 +1,63 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { JsonTree } from "@/components/JsonTree";
+
+describe("JsonTree primitives", () => {
+ it("renders string values quoted with the success color", () => {
+ const { container } = render( );
+ expect(container.querySelector(".text-success")).not.toBeNull();
+ expect(container.textContent).toContain('"hello"');
+ });
+
+ it("renders numbers with the warning color", () => {
+ const { container } = render( );
+ expect(container.querySelector(".text-warning")).not.toBeNull();
+ expect(container.textContent).toContain("42");
+ });
+
+ it("renders booleans with the info color", () => {
+ const { container } = render( );
+ expect(container.querySelector(".text-info")).not.toBeNull();
+ });
+
+ it("renders null italicized", () => {
+ const { container } = render( );
+ expect(container.querySelector(".italic")).not.toBeNull();
+ expect(container.textContent).toContain("null");
+ });
+});
+
+describe("JsonTree containers", () => {
+ it("renders empty objects and arrays inline", () => {
+ const { container } = render( );
+ expect(container.textContent).toContain("{}");
+ expect(container.textContent).toContain("[]");
+ });
+
+ it("toggles a node via the caret button", () => {
+ const { container } = render(
+ ,
+ );
+ const children = container.querySelector(".json-children");
+ expect(children).not.toHaveClass("hidden");
+ fireEvent.click(container.querySelector(".json-toggle")!);
+ expect(container.querySelector(".json-children")).toHaveClass("hidden");
+ });
+
+ it("expandAll and collapseAll buttons control all nodes", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector(".json-children")).toHaveClass("hidden");
+ fireEvent.click(screen.getByText("packets.expand_all"));
+ expect(container.querySelectorAll(".json-children.hidden").length).toBe(0);
+ fireEvent.click(screen.getByText("packets.collapse_all"));
+ expect(container.querySelectorAll(".json-children.hidden").length).toBeGreaterThan(0);
+ });
+
+ it("respects openDepth to auto-expand the top level", () => {
+ const { container } = render( );
+ expect(container.querySelector(".json-children")).not.toHaveClass("hidden");
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/JsonTree.tsx b/src/meshcore_hub/web/static/js/spa-react/components/JsonTree.tsx
new file mode 100644
index 0000000..7cde7f9
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/JsonTree.tsx
@@ -0,0 +1,156 @@
+import { useState, useCallback, type ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { IconChevronRight } from "@/components/icons";
+
+function primitiveClass(val: unknown): string {
+ if (val === null) return "italic opacity-50";
+ switch (typeof val) {
+ case "string":
+ return "text-success";
+ case "number":
+ return "text-warning";
+ case "boolean":
+ return "text-info";
+ default:
+ return "";
+ }
+}
+
+function formatPrimitive(val: unknown): string {
+ if (val === null) return "null";
+ if (typeof val === "string") return `"${val}"`;
+ return String(val);
+}
+
+function KeyLabel({ k }: { k: string | number | null }) {
+ if (k === null) return null;
+ if (typeof k === "number") {
+ return {k}:;
+ }
+ return "{k}":;
+}
+
+function JsonNode({
+ value,
+ k,
+ depth,
+ openDepth,
+ expandSignal,
+}: {
+ value: unknown;
+ k: string | number | null;
+ depth: number;
+ openDepth: number;
+ expandSignal: boolean | null;
+}) {
+ const [expanded, setExpanded] = useState(depth < openDepth);
+
+ const isExpanded = expandSignal !== null ? expandSignal : expanded;
+ const isContainer = value !== null && typeof value === "object";
+
+ if (!isContainer) {
+ return (
+
+
+
+ {formatPrimitive(value)}
+
+
+ );
+ }
+
+ const isArray = Array.isArray(value);
+ const entries: [string | number, unknown][] = isArray
+ ? (value as unknown[]).map((v, i) => [i, v])
+ : Object.entries(value as Record);
+ const open = isArray ? "[" : "{";
+ const close = isArray ? "]" : "}";
+
+ if (entries.length === 0) {
+ return (
+
+
+
+ {open}
+ {close}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {entries.map(([ek, ev]) => (
+
+ ))}
+
+
+ );
+}
+
+export function JsonTree({
+ value,
+ openDepth = 1,
+}: {
+ value: unknown;
+ openDepth?: number;
+}) {
+ const { t } = useTranslation();
+ const [expandSignal, setExpandSignal] = useState(null);
+
+ const expandAll = useCallback(() => setExpandSignal(true), []);
+ const collapseAll = useCallback(() => setExpandSignal(false), []);
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.test.tsx
new file mode 100644
index 0000000..03a0e1c
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.test.tsx
@@ -0,0 +1,65 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { ListToolbar } from "@/components/ListToolbar";
+
+const autoRefresh = {
+ paused: false,
+ onToggle: () => {},
+ intervalSeconds: 30,
+};
+
+describe("ListToolbar", () => {
+ it("renders the total badge when total is provided", () => {
+ render( );
+ expect(screen.getByText("common.total")).toBeInTheDocument();
+ });
+
+ it("hides the total badge when total is null", () => {
+ render( );
+ expect(screen.queryByText("common.total")).not.toBeInTheDocument();
+ });
+
+ it("renders a warning badge only when there is an error", () => {
+ const { container, rerender } = render(
+ ,
+ );
+ expect(container.querySelector(".badge-warning")).toBeNull();
+ rerender(
+ ,
+ );
+ expect(container.querySelector(".badge-warning")).not.toBeNull();
+ });
+
+ it("renders the auto-refresh toggle when interval is positive", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector('input[type="checkbox"]')).not.toBeNull();
+ });
+
+ it("omits the auto-refresh toggle when interval is not positive", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector('input[type="checkbox"]')).toBeNull();
+ });
+
+ it("renders the filter toggle only when provided", () => {
+ const { container, rerender } = render(
+ ,
+ );
+ expect(container.querySelector("#filter-toggle")).toBeNull();
+ rerender(
+ {} }}
+ />,
+ );
+ expect(container.querySelector("#filter-toggle")).not.toBeNull();
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.tsx
new file mode 100644
index 0000000..d515917
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.tsx
@@ -0,0 +1,43 @@
+import { useTranslation } from "react-i18next";
+
+import { WarningBadge } from "@/components/Alerts";
+import { AutoRefreshToggle } from "@/components/AutoRefreshToggle";
+import { CountBadge } from "@/components/Badges";
+import { FilterToggle } from "@/components/FilterForm";
+import { formatNumber } from "@/utils/format";
+
+export interface ListToolbarAutoRefresh {
+ paused: boolean;
+ onToggle: () => void;
+ intervalSeconds: number;
+}
+
+export function ListToolbar({
+ total,
+ error,
+ autoRefresh,
+ filterToggle,
+}: {
+ total: number | null;
+ error?: string | null;
+ autoRefresh: ListToolbarAutoRefresh;
+ filterToggle?: { open: boolean; onChange: () => void };
+}) {
+ const { t } = useTranslation();
+ return (
+
+ {total !== null && (
+ {t("common.total", { count: formatNumber(total) })}
+ )}
+ {error && }
+
+
+ {filterToggle && }
+
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Markdown.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Markdown.test.tsx
new file mode 100644
index 0000000..e4d221b
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/Markdown.test.tsx
@@ -0,0 +1,111 @@
+import { render } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { Markdown } from "@/components/Markdown";
+
+describe("Markdown", () => {
+ it("renders bold and italic inline markup", () => {
+ const { container } = render({"**bold** and *italic*"} );
+ expect(container.querySelector("strong")?.textContent).toBe("bold");
+ expect(container.querySelector("em")?.textContent).toBe("italic");
+ });
+
+ it("renders GFM tables", () => {
+ const md = `
+| A | B |
+|---|---|
+| 1 | 2 |
+`;
+ const { container } = render({md} );
+ const table = container.querySelector("table");
+ expect(table).not.toBeNull();
+ expect(container.querySelectorAll("th").length).toBe(2);
+ expect(container.querySelectorAll("tbody td").length).toBe(2);
+ });
+
+ it("renders fenced code blocks", () => {
+ const md = [
+ "```python",
+ "def hello():",
+ " pass",
+ "```",
+ ].join("\n");
+ const { container } = render({md} );
+ expect(container.querySelector("pre")).not.toBeNull();
+ expect(container.querySelector("pre code")?.textContent).toContain("def hello():");
+ });
+
+ it("renders links", () => {
+ const { container } = render(
+ {"[click](https://example.com)"} ,
+ );
+ const link = container.querySelector("a");
+ expect(link).toHaveAttribute("href", "https://example.com");
+ expect(link?.textContent).toBe("click");
+ });
+
+ it("assigns slug ids to headings for deep-linking", () => {
+ const md = "# Getting Started\n\n## Sub Section\n";
+ const { container } = render({md} );
+ expect(container.querySelector("h1")).toHaveAttribute("id", "getting-started");
+ expect(container.querySelector("h2")).toHaveAttribute("id", "sub-section");
+ });
+
+ it("wraps headings in anchor links pointing at their id", () => {
+ const md = "# Getting Started\n";
+ const { container } = render({md} );
+ const link = container.querySelector("h1 a");
+ expect(link).toHaveAttribute("href", "#getting-started");
+ });
+
+ it("escapes raw HTML (no rehype-raw) for safety", () => {
+ const { container } = render({"bold"} );
+ // Raw is escaped, not rendered as an element
+ expect(container.querySelector("b")).toBeNull();
+ expect(container.textContent).toContain("bold");
+ });
+
+ it("renders external links with safe target and rel attributes", () => {
+ const { container } = render(
+ {"[click](https://example.com)"} ,
+ );
+ const link = container.querySelector("a");
+ expect(link).toHaveAttribute("target", "_blank");
+ expect(link).toHaveAttribute("rel", "noopener noreferrer");
+ });
+
+ it("does not add target/rel to heading anchor links", () => {
+ const md = "# Heading\n";
+ const { container } = render({md} );
+ const anchor = container.querySelector("h1 a");
+ expect(anchor).toHaveAttribute("href", "#heading");
+ expect(anchor).not.toHaveAttribute("target");
+ expect(anchor).not.toHaveAttribute("rel");
+ });
+
+ it("does not add target/rel to relative links", () => {
+ const { container } = render(
+ {"[about](/pages/about)"} ,
+ );
+ const link = container.querySelector("a");
+ expect(link).toHaveAttribute("href", "/pages/about");
+ expect(link).not.toHaveAttribute("target");
+ expect(link).not.toHaveAttribute("rel");
+ });
+
+ it("applies a custom className override instead of the default prose", () => {
+ const { container } = render(
+ {"text"} ,
+ );
+ const wrapper = container.querySelector("div");
+ expect(wrapper).toHaveClass("flash-banner-content");
+ expect(wrapper).not.toHaveClass("prose");
+ });
+
+ it("renders GFM task lists and strikethrough", () => {
+ const md = "- [x] done\n- [ ] todo\n~~old~~\n";
+ const { container } = render({md} );
+ expect(container.querySelector('input[type="checkbox"]')).not.toBeNull();
+ expect(container.querySelector("del")?.textContent).toBe("old");
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Markdown.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Markdown.tsx
new file mode 100644
index 0000000..9b22994
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/Markdown.tsx
@@ -0,0 +1,45 @@
+import { memo } from "react";
+import MarkdownReact from "react-markdown";
+import remarkGfm from "remark-gfm";
+import rehypeSlug from "rehype-slug";
+import rehypeAutolinkHeadings from "rehype-autolink-headings";
+
+interface MarkdownProps {
+ children: string;
+ className?: string;
+}
+
+export const Markdown = memo(function Markdown({
+ children,
+ className = "prose prose-lg max-w-none",
+}: MarkdownProps) {
+ return (
+
+
+ {children}
+
+ );
+ },
+ }}
+ >
+ {children}
+
+
+ );
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.test.tsx
new file mode 100644
index 0000000..5094b91
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.test.tsx
@@ -0,0 +1,20 @@
+import { render } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { MeshQrCode } from "@/components/MeshQrCode";
+
+describe("MeshQrCode", () => {
+ it("renders an svg QR code inside the white padded wrapper by default", () => {
+ const { container } = render( );
+ expect(container.querySelector("svg")).not.toBeNull();
+ expect(container.firstChild).toHaveClass("bg-white");
+ expect(container.firstChild).toHaveClass("rounded-box");
+ });
+
+ it("accepts a custom className override", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).toHaveClass("shadow-lg");
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.tsx b/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.tsx
new file mode 100644
index 0000000..3a387dd
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.tsx
@@ -0,0 +1,25 @@
+import QRCode from "react-qr-code";
+
+export function MeshQrCode({
+ value,
+ size = 140,
+ level = "L",
+ className = "bg-white p-2 rounded-box",
+}: {
+ value: string;
+ size?: number;
+ level?: "L" | "M" | "Q" | "H";
+ className?: string;
+}) {
+ return (
+
+
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/MobileNav.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/MobileNav.test.tsx
new file mode 100644
index 0000000..9e2b550
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/MobileNav.test.tsx
@@ -0,0 +1,28 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { MobileNav } from "@/components/MobileNav";
+import { makeConfig } from "@/test/makeConfig";
+import { renderWithProviders } from "@/test/renderWithProviders";
+
+function renderMobileNav(config = makeConfig()) {
+ return renderWithProviders( , { config });
+}
+
+describe("MobileNav", () => {
+ it("renders nav items for enabled features", () => {
+ renderMobileNav();
+ const links = screen.getAllByTestId("nav-link");
+ expect(links.length).toBeGreaterThan(0);
+ expect(links[0]).toHaveAttribute("data-nav-href", "/");
+ });
+
+ it("hides links for disabled features", () => {
+ renderMobileNav(makeConfig({ features: { map: false, members: false } }));
+ const hrefs = screen
+ .getAllByTestId("nav-link")
+ .map((l) => l.getAttribute("data-nav-href"));
+ expect(hrefs).not.toContain("/map");
+ expect(hrefs).not.toContain("/members");
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/MobileNav.tsx b/src/meshcore_hub/web/static/js/spa-react/components/MobileNav.tsx
new file mode 100644
index 0000000..a32683e
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/MobileNav.tsx
@@ -0,0 +1,25 @@
+import { NavLink } from "react-router";
+
+import { useNavItems } from "@/hooks/useNavItems";
+
+export function MobileNav() {
+ const items = useNavItems("h-5 w-5");
+
+ return (
+ <>
+ {items.map((item) => (
+
+ (isActive ? "active" : undefined)}
+ >
+ {item.icon} {item.label}
+
+
+ ))}
+ >
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Modal.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Modal.test.tsx
new file mode 100644
index 0000000..00581b0
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/Modal.test.tsx
@@ -0,0 +1,48 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Modal } from "@/components/Modal";
+
+describe("Modal", () => {
+ it("renders the title, children and footer", () => {
+ render(
+ {}} footer={foot}>
+ body content
+ ,
+ );
+ expect(
+ screen.getByRole("heading", { name: "My Title" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("body content")).toBeInTheDocument();
+ expect(screen.getByText("foot")).toBeInTheDocument();
+ });
+
+ it("omits the footer action row when no footer is given", () => {
+ const { container } = render(
+ {}}>
+ body
+ ,
+ );
+ expect(container.querySelector(".modal-action")).toBeNull();
+ });
+
+ it("applies the large size class", () => {
+ const { container } = render(
+ {}}>
+ body
+ ,
+ );
+ expect(container.querySelector(".modal-box-lg")).not.toBeNull();
+ });
+
+ it("calls onClose when the backdrop button is clicked", () => {
+ const onClose = vi.fn();
+ render(
+
+ body
+ ,
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Modal.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Modal.tsx
new file mode 100644
index 0000000..a55d9a3
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/Modal.tsx
@@ -0,0 +1,30 @@
+import type { ReactNode } from "react";
+
+export function Modal({
+ title,
+ children,
+ footer,
+ size = "md",
+ onClose,
+}: {
+ title: ReactNode;
+ children: ReactNode;
+ footer?: ReactNode;
+ size?: "md" | "lg";
+ onClose: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Navbar.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Navbar.test.tsx
new file mode 100644
index 0000000..55c03ab
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/Navbar.test.tsx
@@ -0,0 +1,112 @@
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router";
+import { describe, expect, it } from "vitest";
+
+import { AppConfigProvider } from "@/context/AppConfigContext";
+import { Navbar } from "@/components/Navbar";
+import { makeConfig } from "@/test/makeConfig";
+import type { AppConfig } from "@/types/config";
+
+function renderNavbar(config: AppConfig) {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+// Each nav label renders twice (desktop menu + mobile dropdown).
+const labelCount = (label: string) => screen.queryAllByText(label).length;
+
+describe("Navbar feature gating", () => {
+ it("renders all feature links when every feature is enabled", () => {
+ renderNavbar(makeConfig());
+ expect(labelCount("entities.home")).toBeGreaterThan(0);
+ expect(labelCount("entities.dashboard")).toBeGreaterThan(0);
+ expect(labelCount("entities.nodes")).toBeGreaterThan(0);
+ expect(labelCount("entities.messages")).toBeGreaterThan(0);
+ expect(labelCount("entities.map")).toBeGreaterThan(0);
+ });
+
+ it("hides links for disabled features", () => {
+ renderNavbar(
+ makeConfig({
+ features: { dashboard: false, nodes: false, map: false },
+ }),
+ );
+ expect(labelCount("entities.dashboard")).toBe(0);
+ expect(labelCount("entities.nodes")).toBe(0);
+ expect(labelCount("entities.map")).toBe(0);
+ // Still-enabled features remain
+ expect(labelCount("entities.messages")).toBeGreaterThan(0);
+ expect(labelCount("entities.home")).toBeGreaterThan(0);
+ });
+
+ it("shows only Home when all features are off (maintenance)", () => {
+ renderNavbar(
+ makeConfig({
+ system_maintenance: true,
+ features: {
+ dashboard: false,
+ nodes: false,
+ advertisements: false,
+ routes: false,
+ channels: false,
+ messages: false,
+ packets: false,
+ map: false,
+ members: false,
+ pages: false,
+ },
+ }),
+ );
+ expect(labelCount("entities.home")).toBeGreaterThan(0);
+ expect(labelCount("entities.dashboard")).toBe(0);
+ expect(labelCount("entities.nodes")).toBe(0);
+ expect(labelCount("entities.messages")).toBe(0);
+ });
+
+ it("renders custom pages when the pages feature is enabled", () => {
+ renderNavbar(
+ makeConfig({
+ custom_pages: [
+ { slug: "about", title: "About Us", url: "/pages/about", menu_order: 1 },
+ ],
+ }),
+ );
+ expect(labelCount("About Us")).toBeGreaterThan(0);
+ });
+
+ it("hides custom pages when the pages feature is disabled", () => {
+ renderNavbar(
+ makeConfig({
+ features: { pages: false },
+ custom_pages: [
+ { slug: "about", title: "About Us", url: "/pages/about", menu_order: 1 },
+ ],
+ }),
+ );
+ expect(labelCount("About Us")).toBe(0);
+ });
+});
+
+describe("Navbar auth gating", () => {
+ it("shows the login button when OIDC is enabled and not in maintenance", () => {
+ renderNavbar(makeConfig({ oidc_enabled: true }));
+ expect(labelCount("auth.login")).toBeGreaterThan(0);
+ });
+
+ it("hides auth when OIDC is disabled", () => {
+ renderNavbar(makeConfig({ oidc_enabled: false }));
+ expect(labelCount("auth.login")).toBe(0);
+ });
+
+ it("hides auth in maintenance mode even when OIDC is enabled", () => {
+ renderNavbar(
+ makeConfig({ oidc_enabled: true, system_maintenance: true }),
+ );
+ expect(labelCount("auth.login")).toBe(0);
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Navbar.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Navbar.tsx
new file mode 100644
index 0000000..b33eed1
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/Navbar.tsx
@@ -0,0 +1,71 @@
+import { NavLink } from "react-router";
+
+import { useAppConfig } from "@/context/AppConfigContext";
+import { useNavItems } from "@/hooks/useNavItems";
+import { AuthSection } from "@/components/AuthSection";
+import { MobileNav } from "@/components/MobileNav";
+import { ThemeToggle } from "@/components/ThemeToggle";
+
+export function Navbar() {
+ const config = useAppConfig();
+ const items = useNavItems("h-4 w-4");
+ const logoClass = `theme-logo${
+ config.logo_invert_light ? " theme-logo--invert-light" : ""
+ } h-6 w-6 mr-2`;
+
+ return (
+
+
+
+
+ {config.network_name}
+
+
+
+
+ {items.map((item) => (
+ -
+
(isActive ? "active" : undefined)}
+ >
+ {item.icon} {item.label}
+
+
+ ))}
+
+
+
+
+ {config.oidc_enabled && !config.system_maintenance && }
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/NodeDisplay.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/NodeDisplay.test.tsx
new file mode 100644
index 0000000..56d0f02
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/NodeDisplay.test.tsx
@@ -0,0 +1,46 @@
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router";
+import { describe, expect, it } from "vitest";
+
+import { NodeDisplay, NodeLink } from "@/components/NodeDisplay";
+
+const PUBKEY = "a".repeat(64);
+
+describe("NodeDisplay", () => {
+ it("shows the name and an emoji when name is provided", () => {
+ const { container } = render(
+ ,
+ );
+ expect(screen.getByText("Hub 🔌")).toHaveClass("font-medium");
+ expect(container.querySelector(".text-lg")).not.toBeNull();
+ });
+
+ it("falls back to truncated public key when name is null", () => {
+ render( );
+ expect(screen.getByText(`${PUBKEY.slice(0, 16)}...`)).toBeInTheDocument();
+ });
+
+ it("shows description when provided", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("A node")).toBeInTheDocument();
+ });
+
+ it("omits description when not provided", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector(".opacity-70")).toBeNull();
+ });
+
+ it("NodeLink wraps display in a router Link to the node", () => {
+ const { container } = render(
+
+
+ ,
+ );
+ const link = container.querySelector("a");
+ expect(link).toHaveAttribute("href", `/nodes/${PUBKEY}`);
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/NodeDisplay.tsx b/src/meshcore_hub/web/static/js/spa-react/components/NodeDisplay.tsx
new file mode 100644
index 0000000..d75abb7
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/NodeDisplay.tsx
@@ -0,0 +1,63 @@
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router";
+import { getNodeEmoji } from "@/utils/format";
+
+interface NodeDisplayProps {
+ name: string | null;
+ description?: string | null;
+ publicKey: string;
+ advType: string | null;
+ size?: "sm" | "base";
+}
+
+export function NodeDisplay({
+ name,
+ description,
+ publicKey,
+ advType,
+ size = "base",
+}: NodeDisplayProps) {
+ const { t } = useTranslation();
+ const emoji = getNodeEmoji(name, advType);
+ const nameSize = size === "sm" ? "text-sm" : "text-base";
+
+ return (
+
+
+ {emoji}
+
+
+ {name ? (
+ <>
+ {name}
+ {description && (
+ {description}
+ )}
+ >
+ ) : (
+
+ {publicKey.slice(0, 16)}...
+
+ )}
+
+
+ );
+}
+
+interface NodeLinkProps extends NodeDisplayProps {
+ className?: string;
+}
+
+export function NodeLink({ className, ...display }: NodeLinkProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.test.tsx
new file mode 100644
index 0000000..8155a55
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.test.tsx
@@ -0,0 +1,24 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { NotFoundState } from "@/components/NotFoundState";
+
+describe("NotFoundState", () => {
+ it("renders an error alert with the message by default", () => {
+ const { container } = render( );
+ const alert = screen.getByRole("alert");
+ expect(alert).toHaveClass("alert-error");
+ expect(alert).toHaveTextContent("No such node");
+ expect(container.querySelector("svg")).not.toBeNull();
+ });
+
+ it("renders a warning alert without an icon when tone is warning", () => {
+ const { container } = render(
+ ,
+ );
+ const alert = screen.getByRole("alert");
+ expect(alert).toHaveClass("alert-warning");
+ expect(alert).toHaveTextContent("Gone after retention");
+ expect(container.querySelector("svg")).toBeNull();
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.tsx b/src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.tsx
new file mode 100644
index 0000000..b977487
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.tsx
@@ -0,0 +1,20 @@
+import type { ReactNode } from "react";
+
+import { IconError } from "@/components/icons";
+
+export function NotFoundState({
+ message,
+ tone = "error",
+}: {
+ message: ReactNode;
+ tone?: "error" | "warning";
+}) {
+ return (
+
+ {tone === "error" && (
+
+ )}
+ {message}
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ObserverBadges.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ObserverBadges.test.tsx
new file mode 100644
index 0000000..94d02c1
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/ObserverBadges.test.tsx
@@ -0,0 +1,86 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ ObserverIcons,
+ ObserverFilterBadges,
+ getDisabledObserverAreas,
+ setDisabledObserverAreas,
+ toggleObserverArea,
+} from "@/components/ObserverBadges";
+
+describe("ObserverIcons", () => {
+ it("renders nothing when observers is empty", () => {
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders the count and a tooltip with resolved names", () => {
+ render(
+ ,
+ );
+ const badge = screen.getByText("2");
+ expect(badge.closest("[title]")).toHaveAttribute("title", "Alpha, Beta");
+ });
+});
+
+describe("observer area localStorage helpers", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it("getDisabledObserverAreas returns an empty set by default", () => {
+ expect(getDisabledObserverAreas().size).toBe(0);
+ });
+
+ it("setDisabled/getDisabled round-trip persists areas", () => {
+ setDisabledObserverAreas(new Set(["north", "south"]));
+ const result = getDisabledObserverAreas();
+ expect(result.has("north")).toBe(true);
+ expect(result.has("south")).toBe(true);
+ });
+
+ it("toggleObserverArea adds and removes an area", () => {
+ const afterAdd = toggleObserverArea("north", 3);
+ expect(afterAdd.has("north")).toBe(true);
+ const afterRemove = toggleObserverArea("north", 3);
+ expect(afterRemove.has("north")).toBe(false);
+ });
+
+ it("blocks disabling the last remaining area", () => {
+ setDisabledObserverAreas(new Set(["north", "south"]));
+ const result = toggleObserverArea("west", 3);
+ expect(result.has("west")).toBe(false);
+ expect(result.size).toBe(2);
+ });
+});
+
+describe("ObserverFilterBadges", () => {
+ it("renders nothing when areas is empty", () => {
+ const { container } = render(
+ {}} />,
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders enabled and disabled badges and calls onToggle on click", () => {
+ const onToggle = vi.fn();
+ render(
+ ,
+ );
+ const badges = screen.getAllByTestId("observer-area");
+ expect(badges).toHaveLength(2);
+ expect(badges[0]).toHaveAttribute("data-area", "North");
+ fireEvent.click(badges[0]);
+ expect(onToggle).toHaveBeenCalledWith("North");
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ObserverBadges.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ObserverBadges.tsx
new file mode 100644
index 0000000..b9225ee
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/ObserverBadges.tsx
@@ -0,0 +1,110 @@
+import { useTranslation } from "react-i18next";
+import { formatNumber, truncateKey, extractFirstEmoji } from "@/utils/format";
+
+interface Observer {
+ tag_name?: string;
+ name?: string;
+ public_key: string;
+}
+
+export function ObserverIcons({ observers }: { observers: Observer[] }) {
+ if (!observers || observers.length === 0) return null;
+ const names = observers.map(
+ (o) => o.tag_name || o.name || truncateKey(o.public_key, 8),
+ );
+ const tooltip = names.join(", ");
+ return (
+
+ {formatNumber(observers.length)}
+
+ );
+}
+
+const OBSERVER_FILTER_KEY = "meshcore-observer-areas-disabled";
+
+export function getDisabledObserverAreas(): Set {
+ try {
+ const raw = localStorage.getItem(OBSERVER_FILTER_KEY);
+ if (!raw) return new Set();
+ const arr = JSON.parse(raw);
+ return Array.isArray(arr) ? new Set(arr) : new Set();
+ } catch {
+ return new Set();
+ }
+}
+
+export function setDisabledObserverAreas(disabled: Set): void {
+ try {
+ localStorage.setItem(OBSERVER_FILTER_KEY, JSON.stringify([...disabled]));
+ } catch {
+ // ignore
+ }
+}
+
+export function toggleObserverArea(
+ area: string,
+ totalAreaCount: number,
+): Set {
+ const disabled = getDisabledObserverAreas();
+ if (disabled.has(area)) {
+ disabled.delete(area);
+ } else {
+ if (totalAreaCount - disabled.size <= 1) return disabled;
+ disabled.add(area);
+ }
+ setDisabledObserverAreas(disabled);
+ return disabled;
+}
+
+interface ObserverFilterBadgesProps {
+ areas: string[];
+ disabled: Set;
+ onToggle: (area: string) => void;
+ extraClass?: string;
+}
+
+export function ObserverFilterBadges({
+ areas,
+ disabled,
+ onToggle,
+ extraClass = "flex",
+}: ObserverFilterBadgesProps) {
+ const { t } = useTranslation();
+ if (!areas || areas.length === 0) return null;
+
+ return (
+
+
+ {t("common.filter_observer_label")}:
+
+ {areas.map((area) => {
+ const enabled = !disabled.has(area);
+ const cls = enabled
+ ? "badge badge-primary"
+ : "badge badge-ghost opacity-50";
+ const title = enabled
+ ? t("common.filter_observer_disable")
+ : t("common.filter_observer_enable");
+ const emoji = extractFirstEmoji(area);
+ const label = emoji ? area.replace(emoji, "").trim() || area : area;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/PacketParts.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/PacketParts.test.tsx
new file mode 100644
index 0000000..18fd3eb
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/PacketParts.test.tsx
@@ -0,0 +1,41 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import {
+ Field,
+ RedactedNotice,
+ channelNameDisplay,
+} from "@/components/PacketParts";
+
+describe("Field", () => {
+ it("renders a label and its value", () => {
+ render(12:00 );
+ expect(screen.getByText("Time")).toBeInTheDocument();
+ expect(screen.getByText("12:00")).toBeInTheDocument();
+ });
+});
+
+describe("channelNameDisplay", () => {
+ it("renders an em dash for a null channel index", () => {
+ render(<>{channelNameDisplay(new Map(), null)}>);
+ expect(screen.getByText("—")).toBeInTheDocument();
+ });
+
+ it("renders 'name (idx)' for a known channel", () => {
+ render(<>{channelNameDisplay(new Map([[3, "General"]]), 3)}>);
+ expect(screen.getByText("General (3)")).toBeInTheDocument();
+ });
+
+ it("renders just the index for an unknown channel", () => {
+ render(<>{channelNameDisplay(new Map(), 7)}>);
+ expect(screen.getByText("7")).toBeInTheDocument();
+ });
+});
+
+describe("RedactedNotice", () => {
+ it("renders a warning notice", () => {
+ const { container } = render( );
+ expect(container.querySelector(".alert-warning")).not.toBeNull();
+ expect(container.textContent).toContain("packets.redacted_notice");
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/PacketParts.tsx b/src/meshcore_hub/web/static/js/spa-react/components/PacketParts.tsx
new file mode 100644
index 0000000..0819b80
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/PacketParts.tsx
@@ -0,0 +1,58 @@
+import type { ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { copyToClipboard } from "@/utils/clipboard";
+import { JsonTree } from "@/components/JsonTree";
+
+export { DefinitionField as Field } from "@/components/Definition";
+
+export function RedactedNotice() {
+ const { t } = useTranslation();
+ return (
+
+ {"\u{1F512}"} {t("packets.redacted_notice")}
+
+ );
+}
+
+export function channelNameDisplay(
+ names: Map,
+ channelIdx: number | null,
+): ReactNode {
+ if (channelIdx == null) return —;
+ const name = names.get(channelIdx);
+ return name ? `${name} (${channelIdx})` : `${channelIdx}`;
+}
+
+export function RawHexBlock({ hex }: { hex: string | null }) {
+ const { t } = useTranslation();
+ return (
+
+
+ {t("packets.col_raw")}
+ {hex && (
+
+ )}
+
+
+ {hex || "—"}
+
+
+ );
+}
+
+export function DecodedJsonBlock({ value }: { value: unknown }) {
+ const { t } = useTranslation();
+ return (
+
+ {t("packets.decoded")}
+
+
+
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.test.tsx
new file mode 100644
index 0000000..2fa187e
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.test.tsx
@@ -0,0 +1,44 @@
+import { render, screen } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { describe, expect, it } from "vitest";
+
+import { AppConfigProvider } from "@/context/AppConfigContext";
+import { PageHeader } from "@/components/PageHeader";
+import { makeConfig } from "@/test/makeConfig";
+import type { AppConfig } from "@/types/config";
+
+function renderHeader(config: AppConfig = makeConfig(), children?: ReactNode) {
+ return render(
+
+ {children}
+ ,
+ );
+}
+
+describe("PageHeader", () => {
+ it("renders the title", () => {
+ renderHeader();
+ expect(
+ screen.getByRole("heading", { name: "Nodes" }),
+ ).toBeInTheDocument();
+ });
+
+ it("hides the timezone indicator for UTC", () => {
+ const { container } = renderHeader(makeConfig({ timezone: "UTC" }));
+ expect(container.textContent).not.toContain("UTC");
+ });
+
+ it("shows a non-UTC timezone", () => {
+ renderHeader(makeConfig({ timezone: "America/New_York" }));
+ expect(screen.getByText("America/New_York")).toBeInTheDocument();
+ });
+
+ it("renders right-side children alongside the timezone", () => {
+ renderHeader(
+ makeConfig({ timezone: "EST" }),
+ extra badge,
+ );
+ expect(screen.getByText("EST")).toBeInTheDocument();
+ expect(screen.getByText("extra badge")).toBeInTheDocument();
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.tsx b/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.tsx
new file mode 100644
index 0000000..68f376c
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from "react";
+import { useAppConfig } from "@/context/AppConfigContext";
+
+export function PageHeader({
+ title,
+ children,
+}: {
+ title: ReactNode;
+ children?: ReactNode;
+}) {
+ const config = useAppConfig();
+ const tz = config.timezone || "";
+ return (
+
+ {title}
+
+ {tz && tz !== "UTC" && (
+ {tz}
+ )}
+ {children}
+
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Pagination.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Pagination.test.tsx
new file mode 100644
index 0000000..c74d561
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/Pagination.test.tsx
@@ -0,0 +1,56 @@
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router";
+import { describe, expect, it } from "vitest";
+
+import { Pagination } from "@/components/Pagination";
+
+function renderWithRouter(ui: React.ReactElement) {
+ return render({ui} );
+}
+
+describe("Pagination", () => {
+ it("renders nothing when totalPages <= 1", () => {
+ const { container } = renderWithRouter(
+ ,
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("disables previous on the first page and enables next", () => {
+ renderWithRouter( );
+ expect(screen.getByText("common.previous").closest("button")).toBeDisabled();
+ expect(screen.getByText("common.next").closest("a")).not.toBeNull();
+ });
+
+ it("disables next on the last page", () => {
+ renderWithRouter( );
+ expect(screen.getByText("common.next").closest("button")).toBeDisabled();
+ expect(screen.getByText("common.previous").closest("a")).not.toBeNull();
+ });
+
+ it("marks the current page button as active", () => {
+ renderWithRouter( );
+ expect(screen.getByText("2").closest("button")).toHaveClass("btn-active");
+ });
+
+ it("renders ellipsis for far-away pages", () => {
+ renderWithRouter( );
+ expect(screen.getAllByText("...").length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("preserves extra params in page URLs", () => {
+ renderWithRouter(
+ ,
+ );
+ const nextHref = screen.getByText("common.next").closest("a")?.getAttribute("href");
+ expect(nextHref).toContain("page=3");
+ expect(nextHref).toContain("search=foo");
+ expect(nextHref).toContain("tag=a");
+ expect(nextHref).toContain("tag=b");
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Pagination.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Pagination.tsx
new file mode 100644
index 0000000..005aec7
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/Pagination.tsx
@@ -0,0 +1,93 @@
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router";
+
+interface PaginationProps {
+ page: number;
+ totalPages: number;
+ basePath: string;
+ params?: Record;
+}
+
+export function Pagination({
+ page,
+ totalPages,
+ basePath,
+ params = {},
+}: PaginationProps) {
+ const { t } = useTranslation();
+ if (totalPages <= 1) return null;
+
+ const queryParts: string[] = [];
+ for (const [k, v] of Object.entries(params)) {
+ if (k === "page" || v === null || v === undefined || v === "") continue;
+ if (Array.isArray(v)) {
+ v.forEach((item) =>
+ queryParts.push(
+ `${encodeURIComponent(k)}=${encodeURIComponent(item)}`,
+ ),
+ );
+ } else {
+ queryParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
+ }
+ }
+ const extraQuery = queryParts.length > 0 ? "&" + queryParts.join("&") : "";
+
+ const pageUrl = (p: number) => `${basePath}?page=${p}${extraQuery}`;
+
+ const pageNumbers: React.ReactNode[] = [];
+ for (let p = 1; p <= totalPages; p++) {
+ if (p === page) {
+ pageNumbers.push(
+ ,
+ );
+ } else if (
+ p === 1 ||
+ p === totalPages ||
+ (p >= page - 2 && p <= page + 2)
+ ) {
+ pageNumbers.push(
+
+ {p}
+ ,
+ );
+ } else if (p === 2 || p === totalPages - 1) {
+ pageNumbers.push(
+ ,
+ );
+ }
+ }
+
+ return (
+
+
+ {page > 1 ? (
+
+ {t("common.previous")}
+
+ ) : (
+
+ )}
+ {pageNumbers}
+ {page < totalPages ? (
+
+ {t("common.next")}
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/RouteTypeBadge.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/RouteTypeBadge.test.tsx
new file mode 100644
index 0000000..3730a12
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/RouteTypeBadge.test.tsx
@@ -0,0 +1,36 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { RouteTypeBadge } from "@/components/RouteTypeBadge";
+
+describe("RouteTypeBadge", () => {
+ it("renders nothing for null", () => {
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders Flood badge for flood", () => {
+ render( );
+ expect(screen.getByText("Flood")).toHaveClass("badge-info");
+ });
+
+ it("renders Relay badge for transport_flood", () => {
+ render( );
+ expect(screen.getByText("Relay")).toHaveClass("badge-info");
+ });
+
+ it("renders Zero-hop for direct", () => {
+ render( );
+ expect(screen.getByText("Zero-hop")).toHaveClass("badge-success");
+ });
+
+ it("renders Direct relay for transport_direct", () => {
+ render( );
+ expect(screen.getByText("Direct relay")).toHaveClass("badge-success");
+ });
+
+ it("renders nothing for an unknown type", () => {
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/RouteTypeBadge.tsx b/src/meshcore_hub/web/static/js/spa-react/components/RouteTypeBadge.tsx
new file mode 100644
index 0000000..0816ec2
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/RouteTypeBadge.tsx
@@ -0,0 +1,18 @@
+export function RouteTypeBadge({ routeType }: { routeType: string | null }) {
+ if (!routeType) return null;
+ if (routeType === "flood" || routeType === "transport_flood") {
+ return (
+
+ {routeType === "flood" ? "Flood" : "Relay"}
+
+ );
+ }
+ if (routeType === "direct" || routeType === "transport_direct") {
+ return (
+
+ {routeType === "direct" ? "Zero-hop" : "Direct relay"}
+
+ );
+ }
+ return null;
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.test.tsx
new file mode 100644
index 0000000..ba27375
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.test.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { SectionGroup } from "@/components/SectionGroup";
+
+describe("SectionGroup", () => {
+ it("renders the title heading and children in the default grid", () => {
+ const { container } = render(
+
+ card
+ ,
+ );
+ expect(
+ screen.getByRole("heading", { name: "Community" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("card")).toBeInTheDocument();
+ expect(container.querySelector("div")).toHaveClass("lg:grid-cols-3");
+ });
+
+ it("allows a custom grid className", () => {
+ const { container } = render(
+
+ c
+ ,
+ );
+ expect(container.querySelector(".grid-cols-2")).not.toBeNull();
+ expect(container.querySelector(".lg\\:grid-cols-3")).toBeNull();
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.tsx b/src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.tsx
new file mode 100644
index 0000000..2d381e1
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from "react";
+
+export function SectionGroup({
+ title,
+ className,
+ children,
+}: {
+ title: ReactNode;
+ className?: string;
+ children: ReactNode;
+}) {
+ return (
+ <>
+ {title}
+
+ {children}
+
+ >
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/SortableTable.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/SortableTable.test.tsx
new file mode 100644
index 0000000..f5b09ad
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/SortableTable.test.tsx
@@ -0,0 +1,126 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router";
+import { describe, expect, it, vi } from "vitest";
+
+import { SortableTableHeader, MobileSortSelect } from "@/components/SortableTable";
+
+function renderWithRouter(ui: React.ReactElement) {
+ return render({ui} );
+}
+
+function renderInTable(ui: React.ReactElement) {
+ return renderWithRouter(
+
+
+ {ui}
+
+
,
+ );
+}
+
+describe("SortableTableHeader", () => {
+ it("links to asc when the column is not currently sorted", () => {
+ const { container } = renderInTable(
+ ,
+ );
+ const href = container.querySelector("a")?.getAttribute("href") ?? "";
+ expect(href).toContain("sort=name");
+ expect(href).toContain("order=asc");
+ });
+
+ it("flips asc to desc with the up indicator", () => {
+ const { container } = renderInTable(
+ ,
+ );
+ const link = container.querySelector("a");
+ expect(link?.getAttribute("href")).toContain("order=desc");
+ expect(link?.textContent).toContain("▴");
+ });
+
+ it("flips desc back to asc with the down indicator", () => {
+ const { container } = renderInTable(
+ ,
+ );
+ const link = container.querySelector("a");
+ expect(link?.getAttribute("href")).toContain("order=asc");
+ expect(link?.textContent).toContain("▾");
+ });
+
+ it("preserves existing params in the generated sort URL", () => {
+ const { container } = renderInTable(
+ ,
+ );
+ const href = container.querySelector("a")?.getAttribute("href") ?? "";
+ expect(href).toContain("search=foo");
+ expect(href).toContain("tag=a");
+ expect(href).toContain("tag=b");
+ });
+
+ it("stops propagation on header link click", () => {
+ const parentClick = vi.fn();
+ const { container } = render(
+
+
+
+
+
+
+
+
+ ,
+ );
+ fireEvent.click(container.querySelector("a")!);
+ expect(parentClick).not.toHaveBeenCalled();
+ });
+});
+
+describe("MobileSortSelect", () => {
+ it("renders options with the current value selected", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole("combobox")).toHaveValue("name:asc");
+ expect(screen.getByText("Name desc")).toBeInTheDocument();
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/SortableTable.tsx b/src/meshcore_hub/web/static/js/spa-react/components/SortableTable.tsx
new file mode 100644
index 0000000..28e5bbb
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/SortableTable.tsx
@@ -0,0 +1,121 @@
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router";
+
+function buildSortUrl(
+ basePath: string,
+ params: Record,
+ nextSort: string,
+ nextOrder: string,
+): string {
+ const sp = new URLSearchParams();
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== null && value !== undefined && value !== "") {
+ if (Array.isArray(value)) {
+ value.forEach((item) => sp.append(key, String(item)));
+ } else {
+ sp.set(key, String(value));
+ }
+ }
+ }
+ if (nextSort && nextOrder) {
+ sp.set("sort", nextSort);
+ sp.set("order", nextOrder);
+ }
+ const qs = sp.toString();
+ return qs ? `${basePath}?${qs}` : basePath;
+}
+
+interface SortableTableHeaderProps {
+ label: string;
+ sortKey: string;
+ currentSort: string;
+ currentOrder: string;
+ basePath: string;
+ params?: Record;
+}
+
+export function SortableTableHeader({
+ label,
+ sortKey,
+ currentSort,
+ currentOrder,
+ basePath,
+ params = {},
+}: SortableTableHeaderProps) {
+ let indicator = "";
+ let nextOrder: string;
+
+ if (currentSort !== sortKey) {
+ nextOrder = "asc";
+ } else if (currentOrder === "asc") {
+ nextOrder = "desc";
+ indicator = " \u25B4";
+ } else {
+ nextOrder = "asc";
+ indicator = " \u25BE";
+ }
+
+ const url = buildSortUrl(basePath, params, sortKey, nextOrder);
+
+ return (
+
+ e.stopPropagation()}
+ >
+ {label}
+ {indicator}
+
+
+ );
+}
+
+interface SortOption {
+ value: string;
+ label: string;
+}
+
+interface MobileSortSelectProps {
+ currentSort: string;
+ currentOrder: string;
+ basePath: string;
+ params?: Record;
+ options: SortOption[];
+}
+
+export function MobileSortSelect({
+ currentSort,
+ currentOrder,
+ basePath,
+ params = {},
+ options,
+}: MobileSortSelectProps) {
+ const { t } = useTranslation();
+ const currentValue = `${currentSort}:${currentOrder}`;
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const [sort, order] = e.target.value.split(":");
+ const url = buildSortUrl(basePath, params, sort, order);
+ window.location.href = url;
+ };
+
+ return (
+
+
+ {t("common.sort_by")}
+
+
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/StatCard.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/StatCard.test.tsx
new file mode 100644
index 0000000..7f1f96c
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/StatCard.test.tsx
@@ -0,0 +1,36 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { StatCard } from "@/components/StatCard";
+
+describe("StatCard", () => {
+ it("renders title, formatted value, and description", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Nodes")).toHaveClass("stat-title");
+ expect(screen.getByText("1,234")).toHaveClass("stat-value");
+ expect(screen.getByText("active")).toHaveClass("stat-desc");
+ });
+
+ it("omits description when not provided", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector(".stat-desc")).toBeNull();
+ });
+
+ it("applies the panel color as a CSS variable", () => {
+ const { container } = render(
+ ,
+ );
+ const panel = container.querySelector(".stat") as HTMLElement;
+ expect(panel.style.getPropertyValue("--panel-color")).toBe("#abc");
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/StatCard.tsx b/src/meshcore_hub/web/static/js/spa-react/components/StatCard.tsx
new file mode 100644
index 0000000..5ec749d
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/StatCard.tsx
@@ -0,0 +1,32 @@
+import type { ReactNode } from "react";
+import { formatNumber } from "@/utils/format";
+
+interface StatCardProps {
+ icon: ReactNode;
+ color: string;
+ title: string;
+ value: number | string;
+ description?: string;
+}
+
+export function StatCard({
+ icon,
+ color,
+ title,
+ value,
+ description,
+}: StatCardProps) {
+ return (
+
+
+ {icon}
+
+ {title}
+ {formatNumber(value)}
+ {description && {description}}
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ThemeToggle.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ThemeToggle.test.tsx
new file mode 100644
index 0000000..93e208d
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/ThemeToggle.test.tsx
@@ -0,0 +1,39 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it } from "vitest";
+
+import { ThemeToggle } from "@/components/ThemeToggle";
+
+describe("ThemeToggle", () => {
+ beforeEach(() => {
+ document.documentElement.removeAttribute("data-theme");
+ localStorage.clear();
+ });
+
+ it("initializes unchecked when no data-theme attribute is set", () => {
+ render( );
+ expect(screen.getByTestId("theme-toggle")).not.toBeChecked();
+ });
+
+ it("toggling sets data-theme to light and persists to localStorage", () => {
+ render( );
+ const checkbox = screen.getByTestId("theme-toggle");
+ fireEvent.click(checkbox);
+ expect(document.documentElement.getAttribute("data-theme")).toBe("light");
+ expect(localStorage.getItem("meshcore-theme")).toBe("light");
+ expect(checkbox).toBeChecked();
+ });
+
+ it("toggling back switches to dark", () => {
+ render( );
+ const checkbox = screen.getByTestId("theme-toggle");
+ fireEvent.click(checkbox);
+ fireEvent.click(checkbox);
+ expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
+ expect(localStorage.getItem("meshcore-theme")).toBe("dark");
+ });
+
+ it("renders both sun and moon svg icons", () => {
+ const { container } = render( );
+ expect(container.querySelectorAll("svg").length).toBeGreaterThanOrEqual(2);
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ThemeToggle.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ThemeToggle.tsx
new file mode 100644
index 0000000..4772db7
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/ThemeToggle.tsx
@@ -0,0 +1,46 @@
+import { useState, type ChangeEvent } from "react";
+
+export function ThemeToggle() {
+ const [isLight, setIsLight] = useState(
+ () => document.documentElement.getAttribute("data-theme") === "light",
+ );
+
+ const handleChange = (e: ChangeEvent) => {
+ const light = e.currentTarget.checked;
+ const theme = light ? "light" : "dark";
+ document.documentElement.setAttribute("data-theme", theme);
+ try {
+ localStorage.setItem("meshcore-theme", theme);
+ } catch {
+ // ignore
+ }
+ setIsLight(light);
+ };
+
+ return (
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.test.tsx
new file mode 100644
index 0000000..d6b4910
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.test.tsx
@@ -0,0 +1,39 @@
+import { render } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { TimeAgo } from "@/components/TimeAgo";
+
+vi.mock("@/utils/format", async () => {
+ const actual =
+ await vi.importActual>("@/utils/format");
+ return {
+ ...actual,
+ formatRelativeTime: () => "2 hours ago",
+ useFormatDateTime: () => ({
+ formatDateTime: () => "Jan 1, 2026 12:00",
+ }),
+ };
+});
+
+describe("TimeAgo", () => {
+ it("renders relative text with the full time as title and datetime", () => {
+ const { container } = render( );
+ const time = container.querySelector("time");
+ expect(time).not.toBeNull();
+ expect(time).toHaveAttribute("datetime", "2026-01-01T12:00:00Z");
+ expect(time).toHaveAttribute("title", "Jan 1, 2026 12:00");
+ expect(time).toHaveTextContent("2 hours ago");
+ });
+
+ it("applies a custom className", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("time")).toHaveClass("text-xs");
+ });
+
+ it("renders nothing when iso is null", () => {
+ const { container } = render( );
+ expect(container.querySelector("time")).toBeNull();
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.tsx b/src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.tsx
new file mode 100644
index 0000000..287c444
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.tsx
@@ -0,0 +1,17 @@
+import { formatRelativeTime, useFormatDateTime } from "@/utils/format";
+
+export function TimeAgo({
+ iso,
+ className,
+}: {
+ iso: string | null;
+ className?: string;
+}) {
+ const { formatDateTime } = useFormatDateTime();
+ if (!iso) return null;
+ return (
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/charts/Charts.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/charts/Charts.test.tsx
new file mode 100644
index 0000000..0ef5a72
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/charts/Charts.test.tsx
@@ -0,0 +1,70 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("react-chartjs-2", () => ({
+ Line: () => ,
+ Bar: () => ,
+}));
+
+vi.mock("@/utils/charts", () => ({
+ buildActivityChart: (a: unknown, m: unknown) =>
+ a != null || m != null ? { data: {}, options: {} } : null,
+ buildLineChart: (d: unknown) => (d != null ? { data: {}, options: {} } : null),
+ buildStackedBar: (b: unknown) => (b != null ? { data: {}, options: {} } : null),
+ buildRoutesTrend: (r: unknown) => (r != null ? { data: {}, options: {} } : null),
+ buildRouteDetailStrip: (d: unknown) =>
+ d != null ? { data: {}, options: {} } : null,
+}));
+
+import {
+ ActivityChart,
+ TrendLineChart,
+ StackedBarChart,
+ RoutesTrendChart,
+ RouteDetailStrip,
+} from "@/components/charts/Charts";
+
+// Since @/utils/charts is mocked, the actual data shape is irrelevant —
+// these casts just satisfy the component prop types at compile time.
+const DATA = { data: [] } as never;
+
+describe("Chart wrappers", () => {
+ it("ActivityChart renders a Line when data is present", () => {
+ render( );
+ expect(screen.getByTestId("mock-line-chart")).toBeInTheDocument();
+ });
+
+ it("ActivityChart renders nothing when both series are null", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector('[data-testid="mock-line-chart"]')).toBeNull();
+ });
+
+ it("TrendLineChart renders a Line when data is provided", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("mock-line-chart")).toBeInTheDocument();
+ });
+
+ it("StackedBarChart renders a Bar when buckets are provided", () => {
+ render( );
+ expect(screen.getByTestId("mock-bar-chart")).toBeInTheDocument();
+ });
+
+ it("RoutesTrendChart renders a Line when routes are provided", () => {
+ render( );
+ expect(screen.getByTestId("mock-line-chart")).toBeInTheDocument();
+ });
+
+ it("RouteDetailStrip renders a Bar when data is provided", () => {
+ render( );
+ expect(screen.getByTestId("mock-bar-chart")).toBeInTheDocument();
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/charts/Charts.tsx b/src/meshcore_hub/web/static/js/spa-react/components/charts/Charts.tsx
new file mode 100644
index 0000000..99dd2a0
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/charts/Charts.tsx
@@ -0,0 +1,101 @@
+import type { ReactNode } from "react";
+import { Bar, Line } from "react-chartjs-2";
+import { useTranslation } from "react-i18next";
+
+import {
+ buildActivityChart,
+ buildLineChart,
+ buildRouteDetailStrip,
+ buildRoutesTrend,
+ buildStackedBar,
+ type ActivitySeries,
+ type BreakdownBucket,
+ type RouteHistory,
+ type RouteOverviewEntry,
+} from "@/utils/charts";
+
+function ChartFrame({
+ className,
+ children,
+}: {
+ className: string;
+ children: ReactNode;
+}) {
+ return {children};
+}
+
+export function ActivityChart({
+ advertData,
+ messageData,
+}: {
+ advertData: ActivitySeries | null;
+ messageData: ActivitySeries | null;
+}) {
+ const { t } = useTranslation();
+ const cfg = buildActivityChart(advertData, messageData, t);
+ return (
+
+ {cfg && }
+
+ );
+}
+
+export function TrendLineChart({
+ data,
+ label,
+ borderColor,
+ backgroundColor,
+ fill = true,
+}: {
+ data: ActivitySeries | null;
+ label: string;
+ borderColor: string;
+ backgroundColor: string;
+ fill?: boolean;
+}) {
+ const cfg = buildLineChart(data, label, borderColor, backgroundColor, fill);
+ return (
+
+ {cfg && }
+
+ );
+}
+
+export function StackedBarChart({
+ buckets,
+ colors,
+}: {
+ buckets: BreakdownBucket[] | null;
+ colors: string[];
+}) {
+ const cfg = buildStackedBar(buckets, colors);
+ return (
+
+ {cfg && }
+
+ );
+}
+
+export function RoutesTrendChart({
+ routes,
+}: {
+ routes: RouteOverviewEntry[] | null;
+}) {
+ const { t } = useTranslation();
+ const cfg = buildRoutesTrend(routes, t);
+ return (
+
+ {cfg && }
+
+ );
+}
+
+export function RouteDetailStrip({ data }: { data: RouteHistory | undefined }) {
+ const { t } = useTranslation();
+ const cfg = buildRouteDetailStrip(data, t);
+ return (
+
+ {cfg && }
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/icons/index.tsx b/src/meshcore_hub/web/static/js/spa-react/components/icons/index.tsx
new file mode 100644
index 0000000..d723bb2
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/components/icons/index.tsx
@@ -0,0 +1,531 @@
+import type { SVGProps } from "react";
+
+type IconProps = SVGProps;
+
+function base(props: IconProps) {
+ return {
+ xmlns: "http://www.w3.org/2000/svg",
+ fill: "none",
+ viewBox: "0 0 24 24",
+ stroke: "currentColor",
+ className: "h-5 w-5",
+ ...props,
+ };
+}
+
+export function IconDashboard(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconMap(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconNodes(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconAdvertisements(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconMessages(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconPackets(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconHome(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconMembers(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconPage(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconInfo(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconAlert(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconChart(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconRefresh(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconError(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconSuccess(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconChannel(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconPath(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconUser(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconLogout(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconFilter(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconChevronRight(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconEdit(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconTrash(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconPlus(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconAntenna(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconUsers(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconSettings(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconFrequency(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconBandwidth(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconSpreadingFactor(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconCodingRate(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconTxPower(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconSatelliteDish(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconRuler(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconClock(props: IconProps) {
+ return (
+
+ );
+}
+
+export function IconGithub(props: IconProps) {
+ return (
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/context/AppConfigContext.tsx b/src/meshcore_hub/web/static/js/spa-react/context/AppConfigContext.tsx
new file mode 100644
index 0000000..caaba9d
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/context/AppConfigContext.tsx
@@ -0,0 +1,59 @@
+import { createContext, useContext, type ReactNode } from "react";
+import type { AppConfig } from "@/types/config";
+
+const AppConfigContext = createContext(null);
+
+export function AppConfigProvider({
+ config,
+ children,
+}: {
+ config: AppConfig;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAppConfig(): AppConfig {
+ const ctx = useContext(AppConfigContext);
+ if (!ctx) throw new Error("useAppConfig must be used within AppConfigProvider");
+ return ctx;
+}
+
+export function useFeatures(): Record {
+ return useAppConfig().features;
+}
+
+export function hasRole(roleName: string): boolean {
+ const config = window.__APP_CONFIG__;
+ if (!config?.oidc_enabled) return false;
+ const actualRole = config.role_names?.[roleName] ?? roleName;
+ return (config.roles ?? []).includes(actualRole);
+}
+
+export function getChannelLabelsMap(
+ config: AppConfig = window.__APP_CONFIG__,
+): Map {
+ return new Map(
+ Object.entries(config.channel_labels ?? {})
+ .map(([idx, label]) => [
+ parseInt(idx, 10),
+ typeof label === "string" ? label.trim() : "",
+ ])
+ .filter(
+ ([idx, label]) => Number.isInteger(idx) && (label as string).length > 0,
+ ) as [number, string][],
+ );
+}
+
+export function resolveChannelLabel(
+ channelIdx: number | string,
+ channelLabels: Map = getChannelLabelsMap(),
+): string | null {
+ const parsed = parseInt(String(channelIdx), 10);
+ if (!Number.isInteger(parsed)) return null;
+ return channelLabels.get(parsed) ?? null;
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/hooks/useAutoRefresh.ts b/src/meshcore_hub/web/static/js/spa-react/hooks/useAutoRefresh.ts
new file mode 100644
index 0000000..0095966
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/hooks/useAutoRefresh.ts
@@ -0,0 +1,21 @@
+import { useState, useCallback } from "react";
+import { useAppConfig } from "@/context/AppConfigContext";
+
+interface UseAutoRefreshReturn {
+ paused: boolean;
+ toggle: () => void;
+ intervalSeconds: number;
+ refetchInterval: number | false;
+}
+
+export function useAutoRefresh(): UseAutoRefreshReturn {
+ const config = useAppConfig();
+ const intervalSeconds = config.auto_refresh_seconds || 0;
+ const [paused, setPaused] = useState(false);
+ const toggle = useCallback(() => setPaused((p) => !p), []);
+
+ const refetchInterval =
+ !intervalSeconds || paused ? false : intervalSeconds * 1000;
+
+ return { paused, toggle, intervalSeconds, refetchInterval };
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/hooks/useNavItems.tsx b/src/meshcore_hub/web/static/js/spa-react/hooks/useNavItems.tsx
new file mode 100644
index 0000000..1f13888
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/hooks/useNavItems.tsx
@@ -0,0 +1,107 @@
+import type { ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+
+import { useAppConfig } from "@/context/AppConfigContext";
+import {
+ IconAdvertisements,
+ IconChannel,
+ IconDashboard,
+ IconHome,
+ IconMap,
+ IconMembers,
+ IconMessages,
+ IconNodes,
+ IconPackets,
+ IconPage,
+ IconPath,
+} from "@/components/icons";
+
+export interface NavItem {
+ href: string;
+ label: string;
+ icon: ReactNode;
+ end?: boolean;
+}
+
+export function useNavItems(sizeClass = "h-5 w-5"): NavItem[] {
+ const { t } = useTranslation();
+ const config = useAppConfig();
+ const features = config.features ?? {};
+ const customPages = config.custom_pages ?? [];
+
+ const items: NavItem[] = [
+ {
+ href: "/",
+ label: t("entities.home"),
+ icon: ,
+ end: true,
+ },
+ ];
+
+ if (features.dashboard !== false)
+ items.push({
+ href: "/dashboard",
+ label: t("entities.dashboard"),
+ icon: ,
+ });
+ if (features.nodes !== false)
+ items.push({
+ href: "/nodes",
+ label: t("entities.nodes"),
+ icon: ,
+ });
+ if (features.advertisements !== false)
+ items.push({
+ href: "/advertisements",
+ label: t("entities.advertisements"),
+ icon: ,
+ });
+ if (features.routes !== false)
+ items.push({
+ href: "/routes",
+ label: t("entities.routes"),
+ icon: ,
+ });
+ if (features.channels !== false)
+ items.push({
+ href: "/channels",
+ label: t("entities.channels"),
+ icon: ,
+ });
+ if (features.messages !== false)
+ items.push({
+ href: "/messages",
+ label: t("entities.messages"),
+ icon: ,
+ });
+ if (features.packets !== false)
+ items.push({
+ href: "/packets",
+ label: t("entities.packets"),
+ icon: ,
+ });
+ if (features.map !== false)
+ items.push({
+ href: "/map",
+ label: t("entities.map"),
+ icon: ,
+ });
+ if (features.members !== false)
+ items.push({
+ href: "/members",
+ label: t("entities.members"),
+ icon: ,
+ });
+
+ if (features.pages !== false) {
+ for (const page of customPages) {
+ items.push({
+ href: page.url,
+ label: page.title,
+ icon: ,
+ });
+ }
+ }
+
+ return items;
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/hooks/usePageTitle.ts b/src/meshcore_hub/web/static/js/spa-react/hooks/usePageTitle.ts
new file mode 100644
index 0000000..7b11b9a
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/hooks/usePageTitle.ts
@@ -0,0 +1,18 @@
+import { useEffect } from "react";
+import { useAppConfig } from "@/context/AppConfigContext";
+
+const titleMap: Record = {};
+
+export function usePageTitle(entityKey?: string) {
+ const config = useAppConfig();
+ const networkName = config.network_name || "MeshCore Network";
+
+ useEffect(() => {
+ if (entityKey) {
+ const entity = window.t(entityKey);
+ document.title = `${entity} - ${networkName}`;
+ } else {
+ document.title = networkName;
+ }
+ }, [entityKey, networkName]);
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/i18n/index.ts b/src/meshcore_hub/web/static/js/spa-react/i18n/index.ts
new file mode 100644
index 0000000..2ead371
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/i18n/index.ts
@@ -0,0 +1,57 @@
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+import LanguageDetector from "i18next-browser-languagedetector";
+
+let initialized = false;
+
+export async function initI18n(): Promise {
+ if (initialized) return i18n;
+
+ const config = window.__APP_CONFIG__;
+ const storedLocale = localStorage.getItem("meshcore-locale");
+ const locale = storedLocale || config?.locale || "en";
+ const version = config?.locale_version || "";
+
+ let resources: Record }> = {};
+ try {
+ const res = await fetch(
+ `/static/locales/${locale}.json${version ? "?v=" + version : ""}`,
+ );
+ if (res.ok) {
+ const data = await res.json();
+ resources = { [locale]: { translation: data } };
+ }
+ } catch (e) {
+ console.warn(`Failed to load locale '${locale}':`, e);
+ }
+
+ await i18n
+ .use(LanguageDetector)
+ .use(initReactI18next)
+ .init({
+ resources,
+ lng: locale,
+ fallbackLng: "en",
+ interpolation: {
+ escapeValue: false,
+ prefix: "{{",
+ suffix: "}}",
+ },
+ detection: {
+ order: ["localStorage", "navigator"],
+ lookupLocalStorage: "meshcore-locale",
+ caches: ["localStorage"],
+ },
+ react: {
+ useSuspense: false,
+ },
+ });
+
+ window.t = (key: string, params?: Record) =>
+ i18n.t(key, params ?? {});
+
+ initialized = true;
+ return i18n;
+}
+
+export { i18n };
diff --git a/src/meshcore_hub/web/static/js/spa-react/index.html b/src/meshcore_hub/web/static/js/spa-react/index.html
new file mode 100644
index 0000000..9101128
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/index.html
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/src/meshcore_hub/web/static/js/spa-react/main.tsx b/src/meshcore_hub/web/static/js/spa-react/main.tsx
new file mode 100644
index 0000000..3aee37a
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/main.tsx
@@ -0,0 +1,31 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { AppConfigProvider } from "@/context/AppConfigContext";
+import { initI18n } from "@/i18n";
+import { App } from "@/App";
+import type { AppConfig } from "@/types/config";
+
+async function bootstrap() {
+ await initI18n();
+
+ const config: AppConfig = window.__APP_CONFIG__;
+
+ try {
+ localStorage.removeItem("meshcore-observers-disabled");
+ } catch {
+ // ignore
+ }
+
+ const appContainer = document.getElementById("app");
+ if (!appContainer) return;
+
+ createRoot(appContainer).render(
+
+
+
+
+ ,
+ );
+}
+
+bootstrap();
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.test.tsx
new file mode 100644
index 0000000..bd27821
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.test.tsx
@@ -0,0 +1,64 @@
+import { screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Advertisements } from "@/pages/Advertisements";
+import { renderWithProviders } from "@/test/renderWithProviders";
+import * as api from "@/utils/api";
+
+const ADVERTS = {
+ items: [
+ {
+ public_key: "c".repeat(64),
+ name: "AdNode",
+ adv_type: "repeater",
+ route_type: "flood",
+ first_seen: "2024-01-01T00:00:00Z",
+ last_seen: "2024-01-01T12:00:00Z",
+ },
+ ],
+ total: 1,
+};
+
+function mockAdvertsApi() {
+ vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
+ if (path.includes("/api/v1/advertisements")) return ADVERTS;
+ if (path.includes("/api/v1/nodes")) return { items: [], total: 0 };
+ throw new Error(`Unexpected: ${path}`);
+ });
+}
+
+describe("Advertisements", () => {
+ it("shows a loading spinner before data resolves", () => {
+ vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
+ renderWithProviders( );
+ expect(document.querySelector(".loading-spinner")).not.toBeNull();
+ });
+
+ it("renders advertisement rows after data resolves", async () => {
+ mockAdvertsApi();
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(screen.getAllByText("AdNode").length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ it("shows an error alert on fetch failure", async () => {
+ vi.spyOn(api, "apiGet").mockRejectedValue(new Error("timeout"));
+ const { container } = renderWithProviders( );
+ await waitFor(() => {
+ expect(container.querySelector('[data-tip="timeout"]')).not.toBeNull();
+ });
+ });
+
+ it("renders an empty state when no adverts exist", async () => {
+ vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
+ if (path.includes("/api/v1/advertisements")) return { items: [], total: 0 };
+ if (path.includes("/api/v1/nodes")) return { items: [], total: 0 };
+ throw new Error(`Unexpected: ${path}`);
+ });
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(screen.queryByText("AdNode")).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx
new file mode 100644
index 0000000..85d03d5
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx
@@ -0,0 +1,494 @@
+import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { Link, useNavigate, useSearchParams } from "react-router";
+import { useTranslation } from "react-i18next";
+import { useAppConfig } from "@/context/AppConfigContext";
+import { apiGet } from "@/utils/api";
+import { qk } from "@/utils/queryKeys";
+import { useFormatDateTime } from "@/utils/format";
+import { usePageTitle } from "@/hooks/usePageTitle";
+import { useAutoRefresh } from "@/hooks/useAutoRefresh";
+import { Pagination } from "@/components/Pagination";
+import {
+ FilterForm,
+ FilterField,
+ FilterSelect,
+ OperatorSelect,
+ autoSubmit,
+ submitOnEnter,
+} from "@/components/FilterForm";
+import { MobileSortSelect, SortableTableHeader } from "@/components/SortableTable";
+import { NodeDisplay } from "@/components/NodeDisplay";
+import { CopyableValue } from "@/components/CopyableValue";
+import {
+ ObserverFilterBadges,
+ ObserverIcons,
+ getDisabledObserverAreas,
+ toggleObserverArea,
+} from "@/components/ObserverBadges";
+import { RouteTypeBadge } from "@/components/RouteTypeBadge";
+import { Loading } from "@/components/Alerts";
+import { ListToolbar } from "@/components/ListToolbar";
+import { PageHeader } from "@/components/PageHeader";
+import { EmptyState, EmptyRow } from "@/components/EmptyState";
+
+interface ObserverInfo {
+ node_id?: string;
+ public_key: string;
+ name?: string;
+ tag_name?: string;
+ snr?: number | null;
+ observed_at?: string;
+}
+
+interface Advertisement {
+ public_key: string;
+ name?: string | null;
+ node_name?: string | null;
+ node_tag_name?: string | null;
+ node_tag_description?: string | null;
+ adv_type?: string | null;
+ route_type?: string | null;
+ received_at: string;
+ packet_hash?: string | null;
+ observed_by?: string | null;
+ observers?: ObserverInfo[];
+}
+
+interface NodeItem {
+ public_key: string;
+ tags?: { key: string; value: string | null }[];
+}
+
+interface OperatorProfile {
+ id: string;
+ user_id: string;
+ name?: string | null;
+ callsign?: string | null;
+ roles: string[];
+}
+
+interface ListResponse {
+ items?: T[];
+ total?: number;
+}
+
+export function Advertisements() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const config = useAppConfig();
+ const { formatDateTime, formatDateTimeShort } = useFormatDateTime();
+ usePageTitle("entities.advertisements");
+
+ const search = searchParams.get("search") ?? "";
+ const adoptedBy = searchParams.get("adopted_by") ?? "";
+ const routeType = searchParams.get("route_type") ?? "flood,transport_flood";
+ const page = parseInt(searchParams.get("page") ?? "", 10) || 1;
+ const limit = parseInt(searchParams.get("limit") ?? "", 10) || 20;
+ const sort = searchParams.get("sort") ?? "time";
+ const order = searchParams.get("order") ?? "desc";
+ const offset = (page - 1) * limit;
+
+ const features = config.features ?? {};
+ const packetsEnabled = features.packets !== false;
+
+ const [disabledAreas, setDisabledAreas] = useState>(() =>
+ getDisabledObserverAreas(),
+ );
+ const [filterOpen, setFilterOpen] = useState(
+ search !== "" ||
+ (config.oidc_enabled && adoptedBy !== "") ||
+ routeType !== "flood,transport_flood",
+ );
+
+ const { paused, toggle, intervalSeconds, refetchInterval } =
+ useAutoRefresh();
+
+ const { data, error: queryError } = useQuery({
+ queryKey: qk.advertisements.list({
+ limit,
+ offset,
+ search,
+ sort,
+ order,
+ routeType,
+ adoptedBy,
+ oidcEnabled: config.oidc_enabled,
+ operatorRole: config.role_names?.operator || "operator",
+ disabledAreas: [...disabledAreas].sort(),
+ }),
+ refetchInterval,
+ queryFn: async ({ signal }) => {
+ const nodesPromise = apiGet>(
+ "/api/v1/nodes",
+ { limit: 500, observer: true },
+ { signal },
+ );
+ const profilesPromise = config.oidc_enabled
+ ? apiGet>(
+ "/api/v1/user/profiles",
+ { limit: 500 },
+ { signal },
+ )
+ : Promise.resolve(null);
+ const [nodesData, profilesData] = await Promise.all([
+ nodesPromise,
+ profilesPromise,
+ ]);
+
+ const operatorRole = config.role_names?.operator || "operator";
+ const operators = (profilesData?.items ?? [])
+ .filter((p) => p.roles?.includes(operatorRole))
+ .sort((a, b) =>
+ (a.name || a.callsign || "").localeCompare(
+ b.name || b.callsign || "",
+ ),
+ );
+
+ const areaMap = new Map();
+ for (const n of nodesData.items ?? []) {
+ const area = n.tags?.find((tg) => tg.key === "area")?.value;
+ if (!area || !area.trim()) continue;
+ const key = area.trim();
+ if (!areaMap.has(key)) areaMap.set(key, []);
+ areaMap.get(key)!.push(n.public_key);
+ }
+ const sortedAreas = [...areaMap.keys()].sort((a, b) =>
+ a.toLowerCase().localeCompare(b.toLowerCase()),
+ );
+
+ const observerFilterActive = sortedAreas.some((a) =>
+ disabledAreas.has(a),
+ );
+ const apiParams: Record = {
+ limit,
+ offset,
+ search,
+ sort,
+ order,
+ route_type: routeType,
+ };
+ if (observerFilterActive) {
+ apiParams.observed_by = sortedAreas
+ .filter((a) => !disabledAreas.has(a))
+ .flatMap((a) => areaMap.get(a) ?? []);
+ }
+ if (adoptedBy) apiParams.adopted_by = adoptedBy;
+
+ const adData = await apiGet>(
+ "/api/v1/advertisements",
+ apiParams,
+ { signal },
+ );
+ return {
+ items: adData.items ?? [],
+ total: adData.total ?? 0,
+ operators,
+ sortedAreas,
+ };
+ },
+ });
+ const error = queryError ? queryError.message : null;
+
+ const items = data?.items ?? null;
+ const total = data?.total ?? null;
+ const operators = data?.operators ?? [];
+ const sortedAreas = data?.sortedAreas ?? [];
+
+ const handleObserverToggle = (area: string) => {
+ const updated = toggleObserverArea(area, sortedAreas.length);
+ setDisabledAreas(new Set(updated));
+ if (page > 1) {
+ const sp = new URLSearchParams(searchParams);
+ sp.delete("page");
+ const qs = sp.toString();
+ navigate(qs ? `/advertisements?${qs}` : "/advertisements");
+ }
+ };
+
+ const totalPages = total !== null ? Math.ceil(total / limit) : 0;
+ const headerParams = {
+ search,
+ adopted_by: adoptedBy,
+ route_type: routeType,
+ limit: String(limit),
+ };
+ const paginationParams = { ...headerParams, sort, order };
+ const emptyMessage = t("common.no_entity_found", {
+ entity: t("entities.advertisements").toLowerCase(),
+ });
+
+ const renderReceivers = (ad: Advertisement, variant: "mobile" | "desktop") => {
+ if (ad.observers && ad.observers.length >= 1) {
+ return ;
+ }
+ if (ad.observed_by) {
+ return (
+
+ {"\u{1F4E1}"}
+
+ );
+ }
+ return variant === "desktop" ? - : null;
+ };
+
+ return (
+ <>
+
+
+ setFilterOpen((o) => !o) }}
+ />
+
+ {filterOpen && (
+
+
+
+
+
+
+
+
+ {config.oidc_enabled && operators.length > 0 && (
+
+
+
+ )}
+
+
+ )}
+
+ {items === null ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+ {items.length === 0 ? (
+ {emptyMessage}
+ ) : (
+ items.map((ad, idx) => {
+ const adName =
+ ad.node_tag_name || ad.node_name || ad.name || null;
+ const detailUrl =
+ packetsEnabled && ad.packet_hash
+ ? `/packets/hash/${ad.packet_hash}`
+ : null;
+ return (
+ navigate(detailUrl) : undefined
+ }
+ >
+
+
+ e.stopPropagation()}
+ >
+
+
+
+
+ {formatDateTimeShort(ad.received_at)}
+
+
+
+ {renderReceivers(ad, "mobile")}
+
+
+
+
+
+ );
+ })
+ )}
+
+
+
+
+
+
+
+
+ {t("advertisements.col_route_type")}
+
+ {t("common.observers")}
+
+
+
+ {items.length === 0 ? (
+ {emptyMessage}
+ ) : (
+ items.map((ad, idx) => {
+ const adName =
+ ad.node_tag_name || ad.node_name || ad.name || null;
+ const detailUrl =
+ packetsEnabled && ad.packet_hash
+ ? `/packets/hash/${ad.packet_hash}`
+ : null;
+ return (
+ navigate(detailUrl) : undefined
+ }
+ >
+
+ e.stopPropagation()}
+ >
+
+
+
+
+
+
+
+
+
+
+ {formatDateTime(ad.received_at)}
+
+ {renderReceivers(ad, "desktop")}
+
+ );
+ })
+ )}
+
+
+
+
+
+ >
+ )}
+ >
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Channels.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Channels.test.tsx
new file mode 100644
index 0000000..12b6857
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Channels.test.tsx
@@ -0,0 +1,72 @@
+import { screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Channels } from "@/pages/Channels";
+import { renderWithProviders } from "@/test/renderWithProviders";
+import { makeConfig } from "@/test/makeConfig";
+import * as api from "@/utils/api";
+
+const CHANNELS = {
+ items: [
+ {
+ id: "1",
+ name: "Public",
+ channel_hash: "11",
+ visibility: "community",
+ enabled: true,
+ masked_key: "***",
+ key_hex: null,
+ created_at: "2024-01-01T00:00:00Z",
+ updated_at: "2024-01-01T00:00:00Z",
+ },
+ {
+ id: "2",
+ name: "Ops",
+ channel_hash: "22",
+ visibility: "operator",
+ enabled: true,
+ masked_key: "***",
+ key_hex: null,
+ created_at: "2024-01-01T00:00:00Z",
+ updated_at: "2024-01-01T00:00:00Z",
+ },
+ ],
+ total: 2,
+};
+
+function mockChannelsApi() {
+ vi.spyOn(api, "apiGet").mockResolvedValue(CHANNELS);
+}
+
+describe("Channels", () => {
+ it("shows a loading spinner before data resolves", () => {
+ vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
+ renderWithProviders( );
+ expect(document.querySelector(".loading-spinner")).not.toBeNull();
+ });
+
+ it("renders channel cards after data resolves", async () => {
+ mockChannelsApi();
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(screen.getByText("Public")).toBeInTheDocument();
+ expect(screen.getByText("Ops")).toBeInTheDocument();
+ });
+ });
+
+ it("shows an error alert on fetch failure", async () => {
+ vi.spyOn(api, "apiGet").mockRejectedValue(new Error("channels down"));
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toHaveTextContent("channels down");
+ });
+ });
+
+ it("renders an empty state when no channels exist", async () => {
+ vi.spyOn(api, "apiGet").mockResolvedValue({ items: [], total: 0 });
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(screen.queryByText("Public")).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx
new file mode 100644
index 0000000..18dff39
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx
@@ -0,0 +1,446 @@
+import { useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router";
+
+import { useAppConfig, hasRole } from "@/context/AppConfigContext";
+import { apiGet, apiPost, apiPut, apiDelete } from "@/utils/api";
+import { qk, invalidate } from "@/utils/queryKeys";
+import { usePageTitle } from "@/hooks/usePageTitle";
+import { Loading, ErrorAlert } from "@/components/Alerts";
+import { ConfirmDialog } from "@/components/ConfirmDialog";
+import { EmptyState } from "@/components/EmptyState";
+import { MeshQrCode } from "@/components/MeshQrCode";
+import { Modal } from "@/components/Modal";
+import { PageHeader } from "@/components/PageHeader";
+import { SectionGroup } from "@/components/SectionGroup";
+import { IconChannel, IconPlus, IconEdit, IconTrash } from "@/components/icons";
+
+interface Channel {
+ id: string;
+ name: string;
+ channel_hash: string;
+ visibility: string;
+ enabled: boolean;
+ masked_key: string;
+ key_hex: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+interface ChannelListResponse {
+ items: Channel[];
+ total: number;
+}
+
+const VISIBILITY_ORDER = ["community", "member", "operator", "admin"];
+
+type ModalState =
+ | { type: "add" }
+ | { type: "edit"; channel: Channel }
+ | { type: "delete"; channel: Channel };
+
+function ChannelQrCode({ channel }: { channel: Channel }) {
+ if (!channel.key_hex) return null;
+ const qrUrl = `meshcore://channel/add?name=${encodeURIComponent(channel.name)}&secret=${channel.key_hex.toLowerCase()}`;
+ return ;
+}
+
+interface ChannelCardProps {
+ channel: Channel;
+ oidcEnabled: boolean;
+ isAdmin: boolean;
+ onEdit: () => void;
+ onDelete: () => void;
+ onNavigate: (channelIdx: number) => void;
+}
+
+function ChannelCard({
+ channel,
+ oidcEnabled,
+ isAdmin,
+ onEdit,
+ onDelete,
+ onNavigate,
+}: ChannelCardProps) {
+ const { t } = useTranslation();
+ const channelIdx = parseInt(channel.channel_hash, 16);
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onNavigate(channelIdx);
+ }
+ };
+
+ return (
+ onNavigate(channelIdx)}
+ onKeyDown={handleKeyDown}
+ >
+
+
+
+ {channel.name}
+ {oidcEnabled && (
+
+ {channel.visibility}
+
+ )}
+ {!channel.enabled && (
+
+ {t("channels.disabled")}
+
+ )}
+
+ {channel.key_hex && (
+
+ {channel.key_hex.toLowerCase()}
+
+ )}
+ {isAdmin && (
+
+
+
+
+ )}
+
+
+ {channel.key_hex && }
+
+
+
+ );
+}
+
+interface ChannelModalProps {
+ isEdit: boolean;
+ channel: Channel | null;
+ saving: boolean;
+ onSave: (body: Record) => void;
+ onCancel: () => void;
+}
+
+function ChannelModal({
+ isEdit,
+ channel,
+ saving,
+ onSave,
+ onCancel,
+}: ChannelModalProps) {
+ const { t } = useTranslation();
+ const [name, setName] = useState(channel?.name ?? "");
+ const [keyHex, setKeyHex] = useState("");
+ const [visibility, setVisibility] = useState(
+ channel?.visibility ?? "community",
+ );
+ const [enabled, setEnabled] = useState(channel?.enabled !== false);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ const body: Record = { visibility, enabled };
+ if (!isEdit) {
+ body.name = name.trim();
+ body.key_hex = keyHex.trim().toUpperCase();
+ }
+ onSave(body);
+ };
+
+ const title = isEdit
+ ? t("channels.edit_channel")
+ : t("channels.add_channel");
+
+ return (
+
+
+
+ );
+}
+
+interface DeleteChannelModalProps {
+ channel: Channel;
+ saving: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+}
+
+function DeleteChannelModal({
+ channel,
+ saving,
+ onConfirm,
+ onCancel,
+}: DeleteChannelModalProps) {
+ const { t } = useTranslation();
+
+ return (
+ {t("channels.delete_confirm", { name: channel.name })}}
+ confirmLabel={t("common.delete")}
+ cancelLabel={t("common.cancel")}
+ saving={saving}
+ onConfirm={onConfirm}
+ onCancel={onCancel}
+ />
+ );
+}
+
+export function Channels() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const config = useAppConfig();
+ const oidcEnabled = config.oidc_enabled;
+ const isAdmin = hasRole("admin");
+ usePageTitle("channels.title");
+
+ const queryClient = useQueryClient();
+
+ const {
+ data,
+ isLoading: loading,
+ error: queryError,
+ } = useQuery({
+ queryKey: qk.channels.list({}),
+ queryFn: async ({ signal }) => {
+ const resp = await apiGet(
+ "/api/v1/channels",
+ {},
+ { signal },
+ );
+ return resp.items || [];
+ },
+ });
+ const channels = data ?? [];
+ const error = queryError ? queryError.message : null;
+ const [modal, setModal] = useState(null);
+
+ const saveMutation = useMutation({
+ mutationFn: async ({
+ id,
+ body,
+ }: {
+ id?: string;
+ body: Record;
+ }) => {
+ if (id) {
+ await apiPut(`/api/v1/channels/${id}`, body);
+ } else {
+ await apiPost("/api/v1/channels", body);
+ }
+ },
+ onSuccess: () => invalidate.channels(queryClient),
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => apiDelete(`/api/v1/channels/${id}`),
+ onSuccess: () => invalidate.channels(queryClient),
+ });
+
+ const saving = saveMutation.isPending || deleteMutation.isPending;
+
+ const handleSave = async (body: Record) => {
+ try {
+ await saveMutation.mutateAsync({
+ id: modal?.type === "edit" ? modal.channel.id : undefined,
+ body,
+ });
+ setModal(null);
+ } catch (e) {
+ alert((e as Error).message || "Failed to save channel");
+ }
+ };
+
+ const handleDeleteConfirm = async () => {
+ if (modal?.type !== "delete") return;
+ try {
+ await deleteMutation.mutateAsync(modal.channel.id);
+ setModal(null);
+ } catch (e) {
+ alert((e as Error).message || "Failed to delete channel");
+ }
+ };
+
+ const handleNavigate = (channelIdx: number) => {
+ navigate(`/messages?channel_idx=${channelIdx}`);
+ };
+
+ const groups = new Map();
+ for (const vis of VISIBILITY_ORDER) {
+ groups.set(vis, []);
+ }
+ for (const ch of channels) {
+ const vis = ch.visibility || "community";
+ if (!groups.has(vis)) groups.set(vis, []);
+ groups.get(vis)!.push(ch);
+ }
+
+ if (loading) return ;
+
+ return (
+
+
+
+ {t("channels.title")}
+
+ }
+ />
+
+ {error && }
+
+ {isAdmin && (
+
+
+
+ )}
+
+ {channels.length === 0 && (
+
+ {t("common.no_entity_found", {
+ entity: t("entities.channels").toLowerCase(),
+ })}
+
+ )}
+
+ {VISIBILITY_ORDER.map((vis) => {
+ const group = groups.get(vis);
+ if (!group || group.length === 0) return null;
+ return (
+
+
+ {group.map((ch) => (
+ setModal({ type: "edit", channel: ch })}
+ onDelete={() => setModal({ type: "delete", channel: ch })}
+ onNavigate={handleNavigate}
+ />
+ ))}
+
+
+ );
+ })}
+
+ {modal && (modal.type === "add" || modal.type === "edit") && (
+ setModal(null)}
+ />
+ )}
+
+ {modal?.type === "delete" && (
+ setModal(null)}
+ />
+ )}
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.test.tsx
new file mode 100644
index 0000000..cb56973
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.test.tsx
@@ -0,0 +1,83 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { MemoryRouter, Route, Routes } from "react-router";
+
+import { CustomPagePage } from "@/pages/CustomPage";
+import { AppConfigProvider } from "@/context/AppConfigContext";
+import { makeConfig } from "@/test/makeConfig";
+import * as api from "@/utils/api";
+
+const originalT = window.t;
+
+function renderPage(entry = "/pages/about") {
+ return render(
+
+
+
+ } />
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ window.t = (key: string) => key;
+ vi.restoreAllMocks();
+});
+
+afterEach(() => {
+ window.t = originalT;
+});
+
+describe("CustomPage", () => {
+ it("shows a loading spinner before the fetch resolves", () => {
+ vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
+ const { container } = renderPage();
+ expect(container.querySelector(".loading-spinner")).not.toBeNull();
+ });
+
+ it("renders markdown content after the fetch resolves", async () => {
+ vi.spyOn(api, "apiGet").mockResolvedValue({
+ slug: "about",
+ title: "About",
+ content_markdown: "# Hello World",
+ });
+ renderPage();
+ await waitFor(() => {
+ expect(screen.getByText("Hello World")).toBeInTheDocument();
+ });
+ });
+
+ it("shows page-not-found on a 404 response", async () => {
+ vi.spyOn(api, "apiGet").mockRejectedValue(
+ new Error("API error: 404 Not Found"),
+ );
+ renderPage();
+ await waitFor(() => {
+ const alert = screen.getByRole("alert");
+ expect(alert).toHaveTextContent(/page_not_found/i);
+ });
+ });
+
+ it("shows a generic error on fetch failure", async () => {
+ vi.spyOn(api, "apiGet").mockRejectedValue(new Error("boom"));
+ renderPage();
+ await waitFor(() => {
+ const alert = screen.getByRole("alert");
+ expect(alert).toHaveTextContent("boom");
+ });
+ });
+
+ it("sets document.title from the page title and network name", async () => {
+ vi.spyOn(api, "apiGet").mockResolvedValue({
+ slug: "about",
+ title: "About",
+ content_markdown: "",
+ });
+ renderPage();
+ await waitFor(() => {
+ expect(document.title).toBe("About - TestNet");
+ });
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx
new file mode 100644
index 0000000..87cffa0
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx
@@ -0,0 +1,83 @@
+import { useEffect, useState } from "react";
+import { useLocation, useParams } from "react-router";
+import { useTranslation } from "react-i18next";
+
+import { ErrorAlert, Loading } from "@/components/Alerts";
+import { Breadcrumbs } from "@/components/Breadcrumbs";
+import { Markdown } from "@/components/Markdown";
+import { useAppConfig } from "@/context/AppConfigContext";
+import { apiGet, isAbortError } from "@/utils/api";
+
+interface CustomPageData {
+ slug: string;
+ title: string;
+ content_markdown: string;
+}
+
+export function CustomPagePage() {
+ const { slug = "" } = useParams();
+ const location = useLocation();
+ const { t } = useTranslation();
+ const config = useAppConfig();
+
+ const [page, setPage] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ setLoading(true);
+ setError(null);
+ apiGet(
+ `/spa/pages/${encodeURIComponent(slug)}`,
+ {},
+ { signal: controller.signal },
+ )
+ .then((data) => {
+ setPage(data);
+ setLoading(false);
+ })
+ .catch((e) => {
+ if (isAbortError(e)) return;
+ const message = e instanceof Error ? e.message : "";
+ setError(
+ message.includes("404")
+ ? t("common.page_not_found")
+ : message || t("custom_page.failed_to_load"),
+ );
+ setLoading(false);
+ });
+ return () => controller.abort();
+ }, [slug, t]);
+
+ useEffect(() => {
+ if (!page) return;
+ const networkName = config.network_name || "MeshCore Network";
+ document.title = `${page.title} - ${networkName}`;
+ }, [page, config.network_name]);
+
+ useEffect(() => {
+ if (!page || !location.hash) return;
+ const id = location.hash.slice(1);
+ requestAnimationFrame(() => {
+ document.getElementById(id)?.scrollIntoView({ behavior: "smooth" });
+ });
+ }, [page, location.hash]);
+
+ if (loading) return ;
+ if (error) return ;
+ if (!page) return null;
+
+ return (
+
+
+
+
+ {page.content_markdown}
+
+
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.test.tsx
new file mode 100644
index 0000000..d5c5f3f
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.test.tsx
@@ -0,0 +1,57 @@
+import { screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("@/components/charts/Charts", () => ({
+ ActivityChart: () => null,
+ TrendLineChart: () => null,
+ StackedBarChart: () => null,
+ RoutesTrendChart: () => null,
+ RouteDetailStrip: () => null,
+}));
+
+import { DashboardPage as Dashboard } from "@/pages/Dashboard";
+import { renderWithProviders } from "@/test/renderWithProviders";
+import { makeConfig } from "@/test/makeConfig";
+import * as api from "@/utils/api";
+
+const STATS = {
+ node_count: 10,
+ message_count: 50,
+ packet_count: 200,
+ channel_count: 3,
+ observer_count: 4,
+ route_count: 1,
+};
+
+function mockDashboardApi() {
+ vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
+ if (path.includes("/dashboard/stats")) return STATS;
+ if (path.includes("/dashboard/")) return { data: [] };
+ if (path.includes("/api/v1/channels")) return { items: [] };
+ throw new Error(`Unexpected: ${path}`);
+ });
+}
+
+describe("Dashboard", () => {
+ it("shows a loading state before data resolves", () => {
+ vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
+ renderWithProviders( );
+ expect(document.querySelector(".loading-spinner")).not.toBeNull();
+ });
+
+ it("renders dashboard content after data resolves", async () => {
+ mockDashboardApi();
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(document.querySelector(".loading-spinner")).toBeNull();
+ });
+ });
+
+ it("shows an error on fetch failure", async () => {
+ vi.spyOn(api, "apiGet").mockRejectedValue(new Error("dash error"));
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toHaveTextContent("dash error");
+ });
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx
new file mode 100644
index 0000000..01da959
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx
@@ -0,0 +1,688 @@
+import { useMemo, type CSSProperties, type ReactNode } from "react";
+import { useQueries } from "@tanstack/react-query";
+import { Link } from "react-router";
+import { useTranslation } from "react-i18next";
+
+import { ErrorAlert, Loading } from "@/components/Alerts";
+import {
+ RoutesTrendChart,
+ StackedBarChart,
+ TrendLineChart,
+} from "@/components/charts/Charts";
+import { ObserverIcons } from "@/components/ObserverBadges";
+import { PageHeader } from "@/components/PageHeader";
+import { RouteTypeBadge } from "@/components/RouteTypeBadge";
+import {
+ IconAdvertisements,
+ IconChannel,
+ IconMessages,
+ IconNodes,
+ IconPackets,
+} from "@/components/icons";
+import {
+ getChannelLabelsMap,
+ resolveChannelLabel,
+ useAppConfig,
+} from "@/context/AppConfigContext";
+import { usePageTitle } from "@/hooks/usePageTitle";
+import { apiGet } from "@/utils/api";
+import { qk } from "@/utils/queryKeys";
+import {
+ averageRouteTier,
+ ChartColors,
+ type ActivitySeries,
+ type BreakdownBucket,
+} from "@/utils/charts";
+import { formatNumber, useFormatDateTime } from "@/utils/format";
+
+interface DashboardStats {
+ total_nodes: number;
+ advertisements_7d: number;
+ messages_7d: number;
+ packets_7d: number;
+}
+
+interface PacketBreakdown {
+ by_event_type: BreakdownBucket[];
+ by_path_width: BreakdownBucket[];
+}
+
+interface RouteHealthEntry {
+ date: string;
+ quality: string | null;
+ matched_count: number;
+}
+
+interface RouteOverviewItem {
+ from_label: string;
+ to_label: string;
+ enabled: boolean;
+ quality?: string | null;
+ matched_count?: number;
+ history?: RouteHealthEntry[];
+}
+
+interface RoutesOverview {
+ days: number;
+ routes: RouteOverviewItem[];
+}
+
+interface ObserverInfo {
+ public_key: string;
+ name?: string;
+ tag_name?: string;
+}
+
+interface RecentAdvertisement {
+ public_key: string;
+ name?: string | null;
+ tag_name?: string | null;
+ route_type?: string | null;
+ received_at: string;
+ observed_by?: string | null;
+ observers?: ObserverInfo[];
+}
+
+interface ChannelMessage {
+ received_at: string;
+ text?: string | null;
+}
+
+interface RecentActivity {
+ recent_advertisements: RecentAdvertisement[];
+ channel_messages: Record;
+}
+
+interface ChannelsResponse {
+ items?: { channel_hash: string; name: string }[];
+}
+
+interface DashboardData {
+ stats: DashboardStats;
+ recentActivity: RecentActivity;
+ advertActivity: ActivitySeries | null;
+ messageActivity: ActivitySeries | null;
+ nodeCount: ActivitySeries | null;
+ packetActivity: ActivitySeries | null;
+ packetBreakdown: PacketBreakdown;
+ routesOverview: RoutesOverview | null;
+ channelsData: ChannelsResponse;
+}
+
+const QUALITY_COLORS: Record = {
+ clear: "oklch(0.72 0.17 145)",
+ marginal: "oklch(0.75 0.18 85)",
+ failing: "oklch(0.62 0.24 25)",
+ no_coverage: "oklch(0.65 0.15 250)",
+ disabled: "oklch(0.55 0 0)",
+};
+
+function qualityColor(quality: string | null): string {
+ return QUALITY_COLORS[quality ?? ""] ?? QUALITY_COLORS.no_coverage;
+}
+
+function gridCols(count: number): string {
+ if (count === 2) return "sm:grid-cols-2";
+ if (count === 3) return "sm:grid-cols-2 lg:grid-cols-3";
+ if (count === 4) return "sm:grid-cols-2 lg:grid-cols-4";
+ return "";
+}
+
+function panelStyle(colorVar: string): CSSProperties {
+ return { "--panel-color": `var(${colorVar})` } as CSSProperties;
+}
+
+function ChartCard({
+ colorVar,
+ icon,
+ title,
+ subtitle,
+ value,
+ children,
+}: {
+ colorVar: string;
+ icon?: ReactNode;
+ title: string;
+ subtitle: string;
+ value?: number;
+ children?: ReactNode;
+}) {
+ return (
+
+
+
+
+
+ {icon}
+ {title}
+
+ {subtitle}
+
+ {value !== undefined && (
+
+ {formatNumber(value)}
+
+ )}
+
+ {children}
+
+
+ );
+}
+
+function RoutesHealth({ routes }: { routes: RouteOverviewItem[] }) {
+ const { t } = useTranslation();
+ if (!routes || routes.length === 0) {
+ return {t("dashboard.routes_empty")}
;
+ }
+
+ const labelFor = (quality: string | null) =>
+ t("routes.quality_" + (quality || "unknown"));
+ const sorted = routes
+ .slice()
+ .sort((a, b) => (b.matched_count || 0) - (a.matched_count || 0));
+ const visible = sorted.slice(0, 6);
+ const hidden = sorted.length - visible.length;
+
+ return (
+
+ {visible.map((route, i) => {
+ const history = route.history || [];
+ const averageTier =
+ history.length > 0 ? averageRouteTier(history) : null;
+ const current =
+ averageTier ||
+ (route.enabled ? route.quality || "no_coverage" : "disabled");
+ return (
+ ${route.to_label}-${i}`}
+ className="flex items-center gap-2"
+ >
+
+ {route.from_label} →{" "}
+ {route.to_label}
+
+
+ {history.map((entry) => (
+
+ ))}
+
+
+
+ );
+ })}
+ {hidden > 0 && (
+
+ {t("dashboard.routes_more", { count: hidden })}
+
+ )}
+
+ );
+}
+
+export function DashboardPage() {
+ const { t } = useTranslation();
+ const config = useAppConfig();
+ const { formatDateTime } = useFormatDateTime();
+ usePageTitle("entities.dashboard");
+
+ const features = config.features ?? {};
+ const showNodes = features.nodes !== false;
+ const showAdverts = features.advertisements !== false;
+ const showMessages = features.messages !== false;
+ const showPackets = features.packets !== false;
+ const showRoutes = features.routes !== false;
+
+ const queries = useQueries({
+ queries: [
+ {
+ queryKey: qk.dashboard.stats(),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ apiGet("/api/v1/dashboard/stats", {}, { signal }),
+ },
+ {
+ queryKey: qk.dashboard.recent({}),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ apiGet(
+ "/api/v1/dashboard/recent-activity",
+ {},
+ { signal },
+ ),
+ },
+ {
+ queryKey: qk.dashboard.series("activity", { days: 7 }),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ apiGet(
+ "/api/v1/dashboard/activity",
+ { days: 7 },
+ { signal },
+ ),
+ },
+ {
+ queryKey: qk.dashboard.series("message-activity", { days: 7 }),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ apiGet(
+ "/api/v1/dashboard/message-activity",
+ { days: 7 },
+ { signal },
+ ),
+ },
+ {
+ queryKey: qk.dashboard.series("node-count", { days: 7 }),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ apiGet(
+ "/api/v1/dashboard/node-count",
+ { days: 7 },
+ { signal },
+ ),
+ },
+ {
+ queryKey: qk.dashboard.series("packet-activity", { days: 7 }),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ apiGet(
+ "/api/v1/dashboard/packet-activity",
+ { days: 7 },
+ { signal },
+ ),
+ },
+ {
+ queryKey: qk.dashboard.series("packet-breakdown", { days: 7 }),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ apiGet(
+ "/api/v1/dashboard/packet-breakdown",
+ { days: 7 },
+ { signal },
+ ),
+ },
+ {
+ queryKey: qk.dashboard.routesOverview(),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ apiGet(
+ "/api/v1/dashboard/routes-overview",
+ { days: 7 },
+ { signal },
+ ),
+ enabled: showRoutes,
+ },
+ {
+ queryKey: qk.channels.list({}),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ apiGet("/api/v1/channels", {}, { signal }),
+ },
+ ],
+ });
+
+ const [
+ statsQ,
+ recentQ,
+ advertQ,
+ messageQ,
+ nodeCountQ,
+ packetActivityQ,
+ packetBreakdownQ,
+ routesOverviewQ,
+ channelsQ,
+ ] = queries;
+
+ const loading = queries.some((q) => q.isLoading);
+ const firstError = queries.find((q) => q.error)?.error ?? null;
+ const error = firstError
+ ? firstError instanceof Error && firstError.message
+ ? firstError.message
+ : t("common.failed_to_load_page")
+ : null;
+
+ const data: DashboardData | null =
+ !loading && !error
+ ? {
+ stats: statsQ.data as DashboardStats,
+ recentActivity: recentQ.data as RecentActivity,
+ advertActivity: (advertQ.data as ActivitySeries | undefined) ?? null,
+ messageActivity:
+ (messageQ.data as ActivitySeries | undefined) ?? null,
+ nodeCount: (nodeCountQ.data as ActivitySeries | undefined) ?? null,
+ packetActivity:
+ (packetActivityQ.data as ActivitySeries | undefined) ?? null,
+ packetBreakdown: packetBreakdownQ.data as PacketBreakdown,
+ routesOverview:
+ (routesOverviewQ.data as RoutesOverview | undefined) ?? null,
+ channelsData: channelsQ.data as ChannelsResponse,
+ }
+ : null;
+
+ const channelLabels = useMemo(() => {
+ if (!data) return new Map();
+ return new Map([
+ ...getChannelLabelsMap(config),
+ ...(data.channelsData.items || [])
+ .map((ch) => [parseInt(ch.channel_hash, 16), ch.name] as [number, string])
+ .filter(([idx]) => Number.isInteger(idx)),
+ ]);
+ }, [config, data]);
+
+ if (loading) return ;
+ if (error) return ;
+ if (!data) return null;
+
+ const { stats, recentActivity, packetBreakdown, routesOverview } = data;
+
+ const formatTimeOnly = (iso: string | null) =>
+ formatDateTime(iso, {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false,
+ });
+ const formatTimeShort = (iso: string | null) =>
+ formatDateTime(iso, {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ });
+ const labelForChannel = (channel: string): string => {
+ const idx = parseInt(String(channel), 10);
+ if (Number.isInteger(idx)) {
+ return resolveChannelLabel(idx, channelLabels) || `Ch ${idx}`;
+ }
+ return String(channel);
+ };
+
+ const eventTypeTotal =
+ packetBreakdown?.by_event_type?.reduce((sum, b) => sum + b.count, 0) ?? 0;
+ const pathWidthTotal =
+ packetBreakdown?.by_path_width?.reduce((sum, b) => sum + b.count, 0) ?? 0;
+ const hasRoutes = !!(
+ routesOverview &&
+ routesOverview.routes &&
+ routesOverview.routes.length
+ );
+ const visibleChartCount =
+ (showNodes ? 1 : 0) +
+ (showAdverts ? 1 : 0) +
+ (showMessages ? 1 : 0) +
+ (showPackets ? 1 : 0);
+ const bottomCount = (showAdverts ? 1 : 0) + (showMessages ? 1 : 0);
+
+ const ads = recentActivity.recent_advertisements ?? [];
+ const channelEntries = Object.entries(recentActivity.channel_messages ?? {});
+
+ return (
+ <>
+
+
+ {visibleChartCount > 0 && (
+ <>
+
+ {showNodes && (
+ }
+ title={t("entities.nodes")}
+ subtitle={t("time.over_time_last_7_days")}
+ value={stats.total_nodes}
+ >
+
+
+ )}
+ {showAdverts && (
+ }
+ title={t("entities.advertisements")}
+ subtitle={t("time.per_day_last_7_days")}
+ value={stats.advertisements_7d}
+ >
+
+
+ )}
+ {showMessages && (
+ }
+ title={t("entities.messages")}
+ subtitle={t("time.per_day_last_7_days")}
+ value={stats.messages_7d}
+ >
+
+
+ )}
+ {showPackets && (
+ }
+ title={t("entities.packets")}
+ subtitle={t("time.per_day_last_7_days")}
+ value={stats.packets_7d}
+ >
+
+
+ )}
+
+
+ {(showPackets || (showRoutes && hasRoutes)) && (
+
+ {showPackets && (
+ }
+ title={t("entities.packet_event_types")}
+ subtitle={t("time.last_7_days")}
+ value={eventTypeTotal}
+ >
+
+
+ )}
+ {showPackets && (
+ }
+ title={t("entities.path_hash_width")}
+ subtitle={t("time.last_7_days")}
+ value={pathWidthTotal}
+ >
+
+
+ )}
+ {showRoutes && hasRoutes && (
+
+
+
+ )}
+ {showRoutes && hasRoutes && (
+
+
+
+ )}
+
+ )}
+ >
+ )}
+
+ {bottomCount > 0 && (
+
+ {showAdverts && (
+
+
+
+
+ {t("common.recent_entity", {
+ entity: t("entities.advertisements"),
+ })}
+
+ {ads.length === 0 ? (
+
+ {t("common.no_entity_yet", {
+ entity: t("entities.advertisements").toLowerCase(),
+ })}
+
+ ) : (
+
+
+
+
+ {t("entities.node")}
+
+ {t("common.type")}
+
+ {t("common.received")}
+ {t("common.observers")}
+
+
+
+ {ads.map((ad, i) => {
+ const friendlyName = ad.tag_name || ad.name;
+ const displayName =
+ friendlyName || ad.public_key.slice(0, 12) + "...";
+ return (
+
+
+
+
+ {displayName}
+
+
+ {friendlyName && (
+
+ {ad.public_key.slice(0, 12)}...
+
+ )}
+
+
+
+
+
+ {formatTimeOnly(ad.received_at)}
+
+
+ {ad.observers && ad.observers.length >= 1 ? (
+
+ ) : ad.observed_by ? (
+ {"\u{1F4E1}"}
+ ) : (
+ -
+ )}
+
+
+ );
+ })}
+
+
+
+ )}
+
+
+ )}
+
+ {showMessages && channelEntries.length > 0 && (
+
+
+
+
+ {t("dashboard.recent_channel_messages")}
+
+
+ {channelEntries.map(([channel, messages]) => (
+
+
+
+ {labelForChannel(channel)}
+
+
+
+ {messages.map((msg, i) => (
+
+
+ {formatTimeShort(msg.received_at)}
+ {" "}
+
+ {msg.text || ""}
+
+
+ ))}
+
+
+ ))}
+
+
+
+ )}
+
+ )}
+ >
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Home.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Home.test.tsx
new file mode 100644
index 0000000..b7fe5c8
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Home.test.tsx
@@ -0,0 +1,59 @@
+import { screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("@/components/charts/Charts", () => ({
+ ActivityChart: () => null,
+ TrendLineChart: () => null,
+ StackedBarChart: () => null,
+ RoutesTrendChart: () => null,
+ RouteDetailStrip: () => null,
+}));
+
+import { HomePage as Home } from "@/pages/Home";
+import { renderWithProviders } from "@/test/renderWithProviders";
+import { makeConfig } from "@/test/makeConfig";
+import * as api from "@/utils/api";
+
+const STATS = {
+ node_count: 42,
+ message_count: 100,
+ packet_count: 500,
+ channel_count: 3,
+ observer_count: 5,
+ route_count: 2,
+};
+
+function mockHomeApi() {
+ vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
+ if (path.includes("/dashboard/stats")) return STATS;
+ if (path.includes("/dashboard/activity")) return { data: [] };
+ if (path.includes("/dashboard/message-activity")) return { data: [] };
+ throw new Error(`Unexpected: ${path}`);
+ });
+}
+
+describe("Home", () => {
+ it("shows a loading state before data resolves", () => {
+ vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
+ renderWithProviders( );
+ expect(document.querySelector(".loading-spinner")).not.toBeNull();
+ });
+
+ it("renders stat cards after data resolves", async () => {
+ mockHomeApi();
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(document.querySelector(".loading-spinner")).toBeNull();
+ });
+ });
+
+ it("renders without error when all features are disabled", async () => {
+ mockHomeApi();
+ renderWithProviders( , {
+ config: makeConfig({ features: { dashboard: false, nodes: false, map: false } }),
+ });
+ await waitFor(() => {
+ expect(document.querySelector(".loading-spinner")).not.toBeNull();
+ });
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx
new file mode 100644
index 0000000..5f6e803
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx
@@ -0,0 +1,443 @@
+import { type ComponentType, type SVGProps } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { Link } from "react-router";
+import { useTranslation } from "react-i18next";
+
+import { ErrorAlert, Loading } from "@/components/Alerts";
+import { ActivityChart } from "@/components/charts/Charts";
+import { StatCard } from "@/components/StatCard";
+import {
+ IconAdvertisements,
+ IconAntenna,
+ IconBandwidth,
+ IconChannel,
+ IconChart,
+ IconCodingRate,
+ IconDashboard,
+ IconFrequency,
+ IconInfo,
+ IconMap,
+ IconMembers,
+ IconMessages,
+ IconNodes,
+ IconPage,
+ IconPackets,
+ IconPath,
+ IconSettings,
+ IconSpreadingFactor,
+ IconTxPower,
+ IconUsers,
+} from "@/components/icons";
+import { useAppConfig, useFeatures } from "@/context/AppConfigContext";
+import { useAutoRefresh } from "@/hooks/useAutoRefresh";
+import { usePageTitle } from "@/hooks/usePageTitle";
+import type { RadioConfigDisplay } from "@/types/config";
+import { apiGet } from "@/utils/api";
+import { qk } from "@/utils/queryKeys";
+import { getPageColor } from "@/utils/format";
+
+interface DashboardStats {
+ total_nodes: number;
+ advertisements_7d: number;
+ messages_7d: number;
+ packets_7d: number;
+ total_operators: number;
+ total_members: number;
+}
+
+interface ActivitySeries {
+ data: { date: string; count: number }[];
+}
+
+type IconComponent = ComponentType>;
+
+function NavCard({
+ href,
+ icon: Icon,
+ label,
+ colorVar,
+}: {
+ href: string;
+ icon: IconComponent;
+ label: string;
+ colorVar: string;
+}) {
+ return (
+
+
+
+
+
+ {label}
+
+
+ );
+}
+
+function RadioTiles({ rc }: { rc?: RadioConfigDisplay }) {
+ const { t } = useTranslation();
+ if (!rc) return null;
+
+ const tiles = [
+ { icon: IconSettings, label: t("links.profile"), value: rc.profile },
+ { icon: IconFrequency, label: t("home.frequency"), value: rc.frequency },
+ { icon: IconBandwidth, label: t("home.bandwidth"), value: rc.bandwidth },
+ {
+ icon: IconSpreadingFactor,
+ label: t("home.spreading_factor"),
+ value: rc.spreading_factor,
+ },
+ {
+ icon: IconCodingRate,
+ label: t("home.coding_rate"),
+ value: rc.coding_rate,
+ },
+ { icon: IconTxPower, label: t("home.tx_power"), value: rc.tx_power },
+ ].filter((tile) => tile.value);
+
+ if (tiles.length === 0) return null;
+
+ return (
+
+ {tiles.map(({ icon: Icon, label, value }) => (
+
+
+
+
+ {label}
+
+ {String(value)}
+
+
+ ))}
+
+ );
+}
+
+export function HomePage() {
+ const { t } = useTranslation();
+ const config = useAppConfig();
+ const features = useFeatures();
+ usePageTitle();
+
+ const networkName = config.network_name || "MeshCore Network";
+ const logoUrl = config.logo_url || "/static/img/logo.svg";
+ const logoInvertLight = config.logo_invert_light !== false;
+ const customPages = config.custom_pages || [];
+
+ const showStats =
+ features.nodes !== false ||
+ features.advertisements !== false ||
+ features.messages !== false ||
+ features.packets !== false;
+ const showAdvertSeries = features.advertisements !== false;
+ const showMessageSeries = features.messages !== false;
+ const showActivityChart = showAdvertSeries || showMessageSeries;
+ const showMembersPanel = features.members !== false;
+ const showRadioPanel = features.radio_config !== false;
+
+ const { refetchInterval } = useAutoRefresh();
+
+ const statsQuery = useQuery({
+ queryKey: qk.dashboard.stats(),
+ queryFn: ({ signal }) =>
+ apiGet("/api/v1/dashboard/stats", {}, { signal }),
+ refetchInterval,
+ });
+ const advertQuery = useQuery({
+ queryKey: qk.dashboard.series("activity", { days: 7 }),
+ queryFn: ({ signal }) =>
+ apiGet(
+ "/api/v1/dashboard/activity",
+ { days: 7 },
+ { signal },
+ ),
+ refetchInterval,
+ });
+ const messageQuery = useQuery({
+ queryKey: qk.dashboard.series("message-activity", { days: 7 }),
+ queryFn: ({ signal }) =>
+ apiGet(
+ "/api/v1/dashboard/message-activity",
+ { days: 7 },
+ { signal },
+ ),
+ refetchInterval,
+ });
+
+ const stats = statsQuery.data ?? null;
+ const advertActivity = advertQuery.data ?? null;
+ const messageActivity = messageQuery.data ?? null;
+ const loading =
+ statsQuery.isLoading || advertQuery.isLoading || messageQuery.isLoading;
+ const firstError =
+ statsQuery.error ?? advertQuery.error ?? messageQuery.error;
+ const error =
+ !stats && firstError
+ ? firstError.message || t("common.failed_to_load_page")
+ : null;
+
+ if (loading) return ;
+ if (error) return ;
+ if (!stats) return null;
+
+ const navItems: {
+ feature: string;
+ href: string;
+ icon: IconComponent;
+ label: string;
+ colorVar: string;
+ }[] = [
+ {
+ feature: "dashboard",
+ href: "/dashboard",
+ icon: IconDashboard,
+ label: t("entities.dashboard"),
+ colorVar: "--color-dashboard",
+ },
+ {
+ feature: "nodes",
+ href: "/nodes",
+ icon: IconNodes,
+ label: t("entities.nodes"),
+ colorVar: "--color-nodes",
+ },
+ {
+ feature: "advertisements",
+ href: "/advertisements",
+ icon: IconAdvertisements,
+ label: t("entities.advertisements"),
+ colorVar: "--color-adverts",
+ },
+ {
+ feature: "routes",
+ href: "/routes",
+ icon: IconPath,
+ label: t("entities.routes"),
+ colorVar: "--color-routes",
+ },
+ {
+ feature: "channels",
+ href: "/channels",
+ icon: IconChannel,
+ label: t("entities.channels"),
+ colorVar: "--color-channels",
+ },
+ {
+ feature: "messages",
+ href: "/messages",
+ icon: IconMessages,
+ label: t("entities.messages"),
+ colorVar: "--color-messages",
+ },
+ {
+ feature: "packets",
+ href: "/packets",
+ icon: IconPackets,
+ label: t("entities.packets"),
+ colorVar: "--color-packets",
+ },
+ {
+ feature: "map",
+ href: "/map",
+ icon: IconMap,
+ label: t("entities.map"),
+ colorVar: "--color-map",
+ },
+ {
+ feature: "members",
+ href: "/members",
+ icon: IconMembers,
+ label: t("entities.members"),
+ colorVar: "--color-members",
+ },
+ ];
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {networkName}
+
+ {config.network_city && config.network_country && (
+
+ {config.network_city}, {config.network_country}
+
+ )}
+
+
+
+
+ {config.network_welcome_text ||
+ t("home.welcome_default", { network_name: networkName })}
+
+
+
+ {navItems
+ .filter((item) => features[item.feature] !== false)
+ .map((item) => (
+
+ ))}
+
+ {features.pages !== false && customPages.length > 0 && (
+
+ {customPages.slice(0, 3).map((page) => (
+
+
+ {page.title}
+
+ ))}
+
+ )}
+
+
+ {showStats && (
+
+ {features.nodes !== false && (
+ }
+ color={getPageColor("nodes")}
+ title={t("entities.nodes")}
+ value={stats.total_nodes}
+ description={t("home.all_discovered_nodes")}
+ />
+ )}
+ {features.advertisements !== false && (
+ }
+ color={getPageColor("adverts")}
+ title={t("entities.advertisements")}
+ value={stats.advertisements_7d}
+ description={t("time.last_7_days")}
+ />
+ )}
+ {features.messages !== false && (
+ }
+ color={getPageColor("messages")}
+ title={t("entities.messages")}
+ value={stats.messages_7d}
+ description={t("time.last_7_days")}
+ />
+ )}
+ {features.packets !== false && (
+ }
+ color={getPageColor("packets")}
+ title={t("entities.packets")}
+ value={stats.packets_7d}
+ description={t("time.last_7_days")}
+ />
+ )}
+
+ )}
+
+
+
+ {showRadioPanel && (
+
+
+
+
+ {t("home.network_info")}
+
+
+
+
+
+
+ )}
+
+ {showMembersPanel && (
+
+
+
+
+ {t("entities.members")}
+
+
+ }
+ color={getPageColor("members")}
+ title={t("members_page.operators")}
+ value={stats.total_operators ?? 0}
+ />
+ }
+ color={getPageColor("members")}
+ title={t("members_page.members")}
+ value={stats.total_members ?? 0}
+ />
+
+
+
+ )}
+
+ {showActivityChart && (
+
+
+
+
+ {t("home.network_activity")}
+
+
+ {t("time.activity_per_day_last_7_days")}
+
+
+
+
+ )}
+
+ >
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Maintenance.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Maintenance.test.tsx
new file mode 100644
index 0000000..9b3305c
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Maintenance.test.tsx
@@ -0,0 +1,14 @@
+import { screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { renderWithProviders } from "@/test/renderWithProviders";
+import { Maintenance } from "@/pages/Maintenance";
+
+describe("Maintenance", () => {
+ it("renders the maintenance hero with title and description", () => {
+ renderWithProviders( );
+ expect(screen.getByText("🔧")).toBeInTheDocument();
+ expect(screen.getByText("maintenance.title")).toBeInTheDocument();
+ expect(screen.getByText("maintenance.description")).toBeInTheDocument();
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Maintenance.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Maintenance.tsx
new file mode 100644
index 0000000..0075793
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Maintenance.tsx
@@ -0,0 +1,21 @@
+import { useTranslation } from "react-i18next";
+import { usePageTitle } from "@/hooks/usePageTitle";
+
+export function Maintenance() {
+ const { t } = useTranslation();
+ usePageTitle();
+
+ return (
+
+
+
+ 🔧
+
+ {t("maintenance.title")}
+
+ {t("maintenance.description")}
+
+
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.test.tsx
new file mode 100644
index 0000000..9a06850
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.test.tsx
@@ -0,0 +1,67 @@
+import { screen, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("react-leaflet", () => ({
+ MapContainer: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ TileLayer: () => null,
+ Marker: () => null,
+ Popup: () => null,
+ useMap: () => ({ fitBounds: () => {}, latLngToContainerPoint: () => ({ x: 0, y: 0 }) }),
+}));
+
+vi.mock("leaflet", () => ({
+ divIcon: () => ({}),
+ latLngBounds: () => ({}),
+ point: () => ({}),
+}));
+
+import { MapPage } from "@/pages/MapPage";
+import { renderWithProviders } from "@/test/renderWithProviders";
+import * as api from "@/utils/api";
+
+const MAP_DATA = {
+ nodes: [
+ {
+ public_key: "a".repeat(64),
+ name: "MapNode",
+ adv_type: "chat",
+ lat: 40.7,
+ lon: -74.0,
+ last_seen: "2024-01-01T00:00:00Z",
+ is_adopted: false,
+ role: null,
+ owner: null,
+ },
+ ],
+ center: null,
+ adopted_center: null,
+ debug: { total_nodes: 1, nodes_with_coords: 1, error: null },
+ profiles: [],
+};
+
+describe("MapPage", () => {
+ it("shows a loading state before data resolves", () => {
+ vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
+ renderWithProviders( );
+ expect(document.querySelector(".loading-spinner")).not.toBeNull();
+ });
+
+ it("renders the map after data resolves", async () => {
+ vi.spyOn(api, "apiGet").mockResolvedValue(MAP_DATA);
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(screen.getByTestId("mock-map")).toBeInTheDocument();
+ });
+ });
+
+ it("shows an error on fetch failure", async () => {
+ vi.spyOn(api, "apiGet").mockRejectedValue(new Error("map error"));
+ const { container } = renderWithProviders( );
+ await waitFor(() => {
+ expect(container.querySelector(".alert-error")).not.toBeNull();
+ });
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx
new file mode 100644
index 0000000..6d8895c
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx
@@ -0,0 +1,525 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import type { TFunction } from "i18next";
+import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet";
+import {
+ divIcon,
+ latLngBounds,
+ type DivIcon,
+ type Map as LeafletMap,
+} from "leaflet";
+import "leaflet/dist/leaflet.css";
+
+import { useAppConfig } from "@/context/AppConfigContext";
+import { usePageTitle } from "@/hooks/usePageTitle";
+import { apiGet } from "@/utils/api";
+import { qk } from "@/utils/queryKeys";
+import { formatNumber, formatRelativeTime, typeEmoji } from "@/utils/format";
+import {
+ getDistanceKm,
+ getNodesWithinRadius,
+ getAnchorPoint,
+ normalizeType,
+ type LatLng,
+} from "@/utils/mapMath";
+import { FilterToggle, OperatorSelect } from "@/components/FilterForm";
+import { ErrorAlert, Loading } from "@/components/Alerts";
+import { PageHeader } from "@/components/PageHeader";
+
+const MAX_BOUNDS_RADIUS_KM = 20;
+
+interface MapNodeOwner {
+ name: string;
+ callsign: string | null;
+}
+
+interface MapNode {
+ public_key: string;
+ name: string | null;
+ adv_type: string | null;
+ lat: number;
+ lon: number;
+ last_seen: string | null;
+ is_adopted?: boolean;
+ role: string | null;
+ owner: MapNodeOwner | null;
+}
+
+interface Profile {
+ id: string;
+ name: string | null;
+ callsign: string | null;
+ roles: string[] | null;
+}
+
+interface MapDebug {
+ total_nodes: number;
+ nodes_with_coords: number;
+ error: string | null;
+}
+
+interface MapData {
+ nodes: MapNode[];
+ center: LatLng | null;
+ adopted_center: LatLng | null;
+ debug: MapDebug | null;
+ profiles: Profile[];
+}
+
+function escapeHtml(str: string | null | undefined): string {
+ if (!str) return "";
+ const div = document.createElement("div");
+ div.textContent = str;
+ return div.innerHTML;
+}
+
+function getBoundsPadding(): [number, number] {
+ if (window.innerWidth < 480) return [50, 50];
+ if (window.innerWidth < 768) return [75, 75];
+ return [100, 100];
+}
+
+function getTypeDisplay(node: MapNode, t: TFunction): string {
+ const type = normalizeType(node.adv_type);
+ if (type === "chat") return t("node_types.chat");
+ if (type === "repeater") return t("node_types.repeater");
+ if (type === "room") return t("node_types.room");
+ return type
+ ? type.charAt(0).toUpperCase() + type.slice(1)
+ : t("node_types.unknown");
+}
+
+function createNodeIcon(node: MapNode, oidcEnabled: boolean): DivIcon {
+ const displayName = node.name || "";
+ const relativeTime = formatRelativeTime(node.last_seen);
+ const timeDisplay = relativeTime ? " (" + relativeTime + ")" : "";
+
+ const iconHtml =
+ oidcEnabled && node.is_adopted
+ ? ''
+ : '';
+
+ return divIcon({
+ className: "custom-div-icon",
+ html:
+ '' +
+ iconHtml +
+ '' +
+ escapeHtml(displayName) +
+ escapeHtml(timeDisplay) +
+ "" +
+ "",
+ iconSize: [120, 50],
+ iconAnchor: [60, 12],
+ });
+}
+
+function NodePopup({
+ node,
+ oidcEnabled,
+}: {
+ node: MapNode;
+ oidcEnabled: boolean;
+}) {
+ const { t } = useTranslation();
+ const typeDisplay = getTypeDisplay(node, t);
+ const nodeTypeEmoji = typeEmoji(node.adv_type);
+ const unknownLabel = t("node_types.unknown");
+ const showInfra = oidcEnabled && typeof node.is_adopted !== "undefined";
+
+ return (
+
+
+ {nodeTypeEmoji} {node.name || unknownLabel}
+ {showInfra && (
+
+ )}
+
+
+ {t("common.type")}
+ {typeDisplay}
+ {node.role && (
+ <>
+ {t("map.role")}
+
+ {node.role}
+
+ >
+ )}
+ {node.owner && (
+ <>
+ {t("map.owner")}
+
+ {node.owner.callsign
+ ? `${node.owner.name} (${node.owner.callsign})`
+ : node.owner.name}
+
+ >
+ )}
+ {t("common.key")}
+
+ {node.public_key.substring(0, 16)}...
+
+ {t("common.location")}
+
+ {node.lat.toFixed(4)}, {node.lon.toFixed(4)}
+
+ {node.last_seen && (
+ <>
+ {t("common.last_seen_label")}
+ {node.last_seen.substring(0, 19).replace("T", " ")}
+ >
+ )}
+
+
+ {t("common.view_details")}
+
+
+ );
+}
+
+function fitInitialBounds(
+ map: LeafletMap,
+ data: MapData,
+ oidcEnabled: boolean,
+): void {
+ const allNodes = data.nodes || [];
+ const padding = getBoundsPadding();
+ if (oidcEnabled) {
+ const adoptedNodes = allNodes.filter((n) => n.is_adopted);
+ if (adoptedNodes.length > 0) {
+ map.fitBounds(
+ latLngBounds(adoptedNodes.map((n) => [n.lat, n.lon])),
+ { padding },
+ );
+ return;
+ }
+ }
+ if (allNodes.length === 0) return;
+ const anchor = getAnchorPoint(
+ allNodes,
+ oidcEnabled ? data.adopted_center : null,
+ );
+ const nearbyNodes = getNodesWithinRadius(
+ allNodes,
+ anchor.lat,
+ anchor.lon,
+ MAX_BOUNDS_RADIUS_KM,
+ );
+ const nodesToFit = nearbyNodes.length > 0 ? nearbyNodes : allNodes;
+ map.fitBounds(
+ latLngBounds(nodesToFit.map((n) => [n.lat, n.lon])),
+ { padding },
+ );
+}
+
+function MapController({
+ mapData,
+ filteredNodes,
+ category,
+ oidcEnabled,
+}: {
+ mapData: MapData;
+ filteredNodes: MapNode[];
+ category: string;
+ oidcEnabled: boolean;
+}) {
+ const map = useMap();
+ const initialFitRef = useRef(false);
+
+ useEffect(() => {
+ if (!mapData) return;
+ if (!initialFitRef.current) {
+ initialFitRef.current = true;
+ fitInitialBounds(map, mapData, oidcEnabled);
+ return;
+ }
+ if (filteredNodes.length > 0) {
+ let nodesToFit = filteredNodes;
+ if (category !== "infra") {
+ const anchor = getAnchorPoint(filteredNodes, mapData.adopted_center);
+ const nearbyNodes = getNodesWithinRadius(
+ filteredNodes,
+ anchor.lat,
+ anchor.lon,
+ MAX_BOUNDS_RADIUS_KM,
+ );
+ if (nearbyNodes.length > 0) nodesToFit = nearbyNodes;
+ }
+ map.fitBounds(
+ latLngBounds(nodesToFit.map((n) => [n.lat, n.lon])),
+ { padding: getBoundsPadding() },
+ );
+ } else {
+ const center = mapData.center;
+ if (center && (center.lat !== 0 || center.lon !== 0)) {
+ map.setView([center.lat, center.lon], 10);
+ }
+ }
+ }, [map, mapData, filteredNodes, category, oidcEnabled]);
+
+ return null;
+}
+
+export function MapPage() {
+ const { t } = useTranslation();
+ const config = useAppConfig();
+ usePageTitle("entities.map");
+
+ const oidcEnabled = config.oidc_enabled;
+ const operatorRole = config.role_names?.operator || "operator";
+
+ const [filterOpen, setFilterOpen] = useState(false);
+ const [category, setCategory] = useState("");
+ const [typeFilter, setTypeFilter] = useState("");
+ const [operatorFilter, setOperatorFilter] = useState("");
+ const [showLabels, setShowLabels] = useState(false);
+
+ const mapQuery = useQuery({
+ queryKey: qk.map.data({ adopted_by: operatorFilter || undefined }),
+ queryFn: ({ signal }) => {
+ const params: Record = {};
+ if (operatorFilter) params.adopted_by = operatorFilter;
+ return apiGet("/map/data", params, { signal });
+ },
+ });
+ const mapData = mapQuery.data ?? null;
+ const loading = mapQuery.isLoading;
+ const error = mapQuery.error
+ ? mapQuery.error.message || t("common.failed_to_load_page")
+ : null;
+
+ const operatorProfiles = useMemo(
+ () =>
+ (mapData?.profiles || [])
+ .filter((p) => p.roles && p.roles.includes(operatorRole))
+ .sort((a, b) => {
+ const na = a.name || a.callsign || "";
+ const nb = b.name || b.callsign || "";
+ return na.localeCompare(nb);
+ }),
+ [mapData, operatorRole],
+ );
+
+ const allNodes = useMemo(() => mapData?.nodes ?? [], [mapData]);
+
+ const filteredNodes = useMemo(
+ () =>
+ allNodes.filter((node) => {
+ if (category === "infra" && !node.is_adopted) return false;
+ if (typeFilter && normalizeType(node.adv_type) !== typeFilter)
+ return false;
+ return true;
+ }),
+ [allNodes, category, typeFilter],
+ );
+
+ const markers = useMemo(
+ () =>
+ filteredNodes.map((node) => (
+
+
+
+
+
+ )),
+ [filteredNodes, oidcEnabled],
+ );
+
+ const clearFilters = () => {
+ setCategory("");
+ setTypeFilter("");
+ setOperatorFilter("");
+ setShowLabels(false);
+ };
+
+ const debug = mapData?.debug ?? null;
+ const nodeCount = allNodes.length;
+ const filteredCount = filteredNodes.length;
+ let countBadgeText: string;
+ if (debug?.error) {
+ countBadgeText = "Error: " + debug.error;
+ } else if (debug && debug.total_nodes === 0) {
+ countBadgeText = t("common.no_entity_in_database", {
+ entity: t("entities.nodes").toLowerCase(),
+ });
+ } else if (debug && debug.nodes_with_coords === 0) {
+ countBadgeText = t("map.nodes_none_have_coordinates", {
+ count: formatNumber(debug.total_nodes),
+ });
+ } else if (filteredCount === nodeCount) {
+ countBadgeText = t("map.nodes_on_map", {
+ count: formatNumber(nodeCount),
+ });
+ } else {
+ countBadgeText = t("common.total", { count: formatNumber(nodeCount) });
+ }
+ const showFilteredBadge = filteredCount !== nodeCount;
+
+ if (loading) return ;
+ if (error) return ;
+
+ return (
+
+
+ {countBadgeText}
+ {showFilteredBadge && (
+
+ {t("common.shown", { count: formatNumber(filteredCount) })}
+
+ )}
+ setFilterOpen((open) => !open)}
+ />
+
+
+ {filterOpen && (
+
+
+
+
+
+
+
+
+
+ {oidcEnabled && operatorProfiles.length > 0 && (
+
+
+ setOperatorFilter(e.currentTarget.value)}
+ profiles={operatorProfiles}
+ />
+
+ )}
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ {markers}
+ {mapData && (
+
+ )}
+
+
+
+
+
+ {oidcEnabled && (
+
+ {t("map.legend")}
+
+
+ {t("map.infrastructure")}
+
+
+
+ {t("map.public")}
+
+
+ )}
+
+
+ {t("map.gps_description")}
+
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Members.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Members.test.tsx
new file mode 100644
index 0000000..1336ff8
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Members.test.tsx
@@ -0,0 +1,54 @@
+import { screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Members } from "@/pages/Members";
+import { renderWithProviders } from "@/test/renderWithProviders";
+import * as api from "@/utils/api";
+
+const PROFILES = {
+ items: [
+ { id: "1", name: "Alice", roles: ["operator"], callsign: "AB1" },
+ { id: "2", name: "Bob", roles: ["member"] },
+ { id: "3", name: "TestUser", roles: ["test"] },
+ ],
+};
+
+function mockProfiles(items = PROFILES) {
+ vi.spyOn(api, "apiGet").mockResolvedValue(items);
+}
+
+describe("Members", () => {
+ it("shows a loading spinner before data resolves", () => {
+ vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
+ renderWithProviders( );
+ expect(document.querySelector(".loading-spinner")).not.toBeNull();
+ });
+
+ it("renders operators and members excluding test profiles", async () => {
+ mockProfiles();
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(screen.getByText("Alice")).toBeInTheDocument();
+ });
+ expect(screen.getByText("Bob")).toBeInTheDocument();
+ expect(screen.queryByText("TestUser")).not.toBeInTheDocument();
+ });
+
+ it("shows an empty state when no visible profiles exist", async () => {
+ mockProfiles({
+ items: [{ id: "9", name: "Hidden", roles: ["test"] }],
+ });
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(screen.getByText("members_page.empty_state")).toBeInTheDocument();
+ });
+ });
+
+ it("shows an error alert on fetch failure", async () => {
+ vi.spyOn(api, "apiGet").mockRejectedValue(new Error("fetch failed"));
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toHaveTextContent("fetch failed");
+ });
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx
new file mode 100644
index 0000000..f858125
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx
@@ -0,0 +1,239 @@
+import {
+ type KeyboardEvent,
+ type MouseEvent,
+ type ReactNode,
+} from "react";
+import { Link, useNavigate } from "react-router";
+import { useQuery } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import { useAppConfig } from "@/context/AppConfigContext";
+import { apiGet } from "@/utils/api";
+import { qk } from "@/utils/queryKeys";
+import { formatNumber, resolveNodeName } from "@/utils/format";
+import { Loading, ErrorAlert } from "@/components/Alerts";
+import { CallsignBadge, RoleBadge } from "@/components/Badges";
+import { EmptyState } from "@/components/EmptyState";
+import { PageHeader } from "@/components/PageHeader";
+import { IconAntenna, IconUsers } from "@/components/icons";
+import { usePageTitle } from "@/hooks/usePageTitle";
+
+interface MemberNode {
+ public_key: string;
+ name?: string | null;
+}
+
+interface MemberProfile {
+ id: string;
+ name?: string | null;
+ callsign?: string | null;
+ description?: string | null;
+ url?: string | null;
+ roles?: string[] | null;
+ node_count?: number | null;
+ adopted_nodes?: MemberNode[] | null;
+}
+
+interface ProfilesResponse {
+ items?: MemberProfile[] | null;
+}
+
+function ProfileTile({ profile }: { profile: MemberProfile }) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ const openNode = (e: MouseEvent | KeyboardEvent, publicKey: string) => {
+ e.preventDefault();
+ e.stopPropagation();
+ navigate(`/nodes/${publicKey}`);
+ };
+
+ const openUrl = (e: MouseEvent | KeyboardEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ window.open(profile.url ?? undefined, "_blank", "noopener,noreferrer");
+ };
+
+ return (
+
+
+
+ {profile.name || t("common.unnamed")}
+ {profile.callsign && }
+
+ {profile.roles && profile.roles.length > 0 && (
+
+ {profile.roles.map((role) => (
+
+ ))}
+
+ )}
+ {profile.description && (
+
+ {profile.description}
+
+ )}
+ {profile.url && (
+ {
+ if (e.key === "Enter") openUrl(e);
+ }}
+ >
+ {profile.url}
+
+ )}
+ {(profile.node_count ?? 0) > 0 && (
+
+ {t("members_page.node_count", {
+ count: formatNumber(profile.node_count),
+ })}
+
+ )}
+ {profile.adopted_nodes && profile.adopted_nodes.length > 0 && (
+
+ {profile.adopted_nodes.map((node) => {
+ const label = resolveNodeName(node);
+ return (
+ openNode(e, node.public_key)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ openNode(e, node.public_key);
+ }
+ }}
+ >
+ {label}
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
+
+function ProfileGroup({
+ title,
+ icon,
+ profiles,
+}: {
+ title: string;
+ icon: ReactNode;
+ profiles: MemberProfile[];
+}) {
+ if (profiles.length === 0) return null;
+ return (
+ <>
+
+ {icon}
+ {title}
+
+
+ {profiles.map((profile) => (
+
+ ))}
+
+ >
+ );
+}
+
+export function Members() {
+ const { t } = useTranslation();
+ const config = useAppConfig();
+ usePageTitle("entities.members");
+
+ const { data, error: queryError } = useQuery({
+ queryKey: qk.profiles.list({ limit: 500 }),
+ queryFn: async ({ signal }) => {
+ const resp = await apiGet(
+ "/api/v1/user/profiles",
+ { limit: 500 },
+ { signal },
+ );
+ return resp.items || [];
+ },
+ });
+ const profiles = data ?? null;
+ const error = queryError ? queryError.message : null;
+
+ if (error) return ;
+ if (profiles === null) return ;
+
+ const roleNames = config.role_names || {};
+ const operatorRole = roleNames.operator || "operator";
+ const memberRole = roleNames.member || "member";
+ const testRole = roleNames.test || "test";
+
+ const visible = profiles.filter((p) => !p.roles || !p.roles.includes(testRole));
+
+ if (visible.length === 0) {
+ return (
+ <>
+
+
+ {t("members_page.empty_state")}
+ {t("members_page.empty_description")}
+
+ >
+ );
+ }
+
+ const byName = (a: MemberProfile, b: MemberProfile) =>
+ (a.name || "").localeCompare(b.name || "");
+ const operators = visible
+ .filter((p) => !!p.roles && p.roles.includes(operatorRole))
+ .sort(byName);
+ const members = visible
+ .filter(
+ (p) =>
+ !!p.roles && p.roles.includes(memberRole) && !p.roles.includes(operatorRole),
+ )
+ .sort(byName);
+
+ return (
+ <>
+
+
+ {t("common.count_entity", {
+ count: formatNumber(operators.length + members.length),
+ entity: t("entities.members").toLowerCase(),
+ })}
+
+
+
+
+
+
+ }
+ profiles={operators}
+ />
+
+
+
+ }
+ profiles={members}
+ />
+ >
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Messages.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Messages.test.tsx
new file mode 100644
index 0000000..627f5f5
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Messages.test.tsx
@@ -0,0 +1,65 @@
+import { screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Messages } from "@/pages/Messages";
+import { renderWithProviders } from "@/test/renderWithProviders";
+import * as api from "@/utils/api";
+
+const MESSAGES = {
+ items: [
+ {
+ message_type: "channel",
+ text: "Hello world",
+ channel_idx: 17,
+ received_at: "2024-01-01T00:00:00Z",
+ signature: null,
+ },
+ ],
+ total: 1,
+};
+
+function mockMessagesApi() {
+ vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
+ if (path.includes("/api/v1/messages")) return MESSAGES;
+ if (path.includes("/api/v1/nodes")) return { items: [], total: 0 };
+ if (path.includes("/api/v1/channels")) return { items: [] };
+ throw new Error(`Unexpected: ${path}`);
+ });
+}
+
+describe("Messages", () => {
+ it("shows a loading spinner before data resolves", () => {
+ vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
+ renderWithProviders( );
+ expect(document.querySelector(".loading-spinner")).not.toBeNull();
+ });
+
+ it("renders messages after data resolves", async () => {
+ mockMessagesApi();
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(screen.getAllByText("Hello world").length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ it("shows an error alert on fetch failure", async () => {
+ vi.spyOn(api, "apiGet").mockRejectedValue(new Error("disconnected"));
+ const { container } = renderWithProviders( );
+ await waitFor(() => {
+ expect(container.querySelector('[data-tip="disconnected"]')).not.toBeNull();
+ });
+ });
+
+ it("renders an empty state when no messages exist", async () => {
+ vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
+ if (path.includes("/api/v1/messages")) return { items: [], total: 0 };
+ if (path.includes("/api/v1/nodes")) return { items: [], total: 0 };
+ if (path.includes("/api/v1/channels")) return { items: [] };
+ throw new Error(`Unexpected: ${path}`);
+ });
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(screen.queryByText("Hello world")).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx
new file mode 100644
index 0000000..8121532
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx
@@ -0,0 +1,577 @@
+import { useState, type ReactNode } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { useNavigate, useSearchParams } from "react-router";
+import { useTranslation } from "react-i18next";
+import {
+ getChannelLabelsMap,
+ useAppConfig,
+} from "@/context/AppConfigContext";
+import { apiGet } from "@/utils/api";
+import { qk } from "@/utils/queryKeys";
+import { useFormatDateTime } from "@/utils/format";
+import {
+ parseSenderFromText,
+ collapseNewlines,
+ channelInfo,
+ messageTextWithSender,
+ dedupeBySignature,
+} from "@/utils/messageHelpers";
+import { usePageTitle } from "@/hooks/usePageTitle";
+import { useAutoRefresh } from "@/hooks/useAutoRefresh";
+import { Pagination } from "@/components/Pagination";
+import {
+ FilterForm,
+ FilterField,
+ FilterSelect,
+ autoSubmit,
+} from "@/components/FilterForm";
+import { MobileSortSelect, SortableTableHeader } from "@/components/SortableTable";
+import {
+ ObserverFilterBadges,
+ ObserverIcons,
+ getDisabledObserverAreas,
+ toggleObserverArea,
+} from "@/components/ObserverBadges";
+import { Loading } from "@/components/Alerts";
+import { ListToolbar } from "@/components/ListToolbar";
+import { PageHeader } from "@/components/PageHeader";
+import { EmptyState, EmptyRow } from "@/components/EmptyState";
+
+interface ObserverInfo {
+ node_id?: string;
+ public_key: string;
+ name?: string;
+ tag_name?: string;
+ snr?: number | null;
+ observed_at?: string;
+}
+
+interface Message {
+ message_type: string;
+ text: string;
+ channel_idx?: number | null;
+ channel_name?: string | null;
+ signature?: string | null;
+ pubkey_prefix?: string | null;
+ sender_name?: string | null;
+ sender_tag_name?: string | null;
+ observed_by?: string | null;
+ observer_name?: string | null;
+ observer_tag_name?: string | null;
+ received_at: string;
+ packet_hash?: string | null;
+ spam_score?: number | null;
+ observers?: ObserverInfo[];
+}
+
+interface NodeItem {
+ public_key: string;
+ tags?: { key: string; value: string | null }[];
+}
+
+interface ChannelItem {
+ channel_hash: string;
+ name: string;
+}
+
+interface ListResponse {
+ items?: T[];
+ total?: number;
+}
+
+export function Messages() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const config = useAppConfig();
+ const { formatDateTime, formatDateTimeShort } = useFormatDateTime();
+ usePageTitle("entities.messages");
+
+ const messageType = searchParams.get("message_type") ?? "";
+ const channelIdx = searchParams.get("channel_idx") ?? "";
+ const includeSpamParam = searchParams.get("include_spam") === "true";
+ const page = parseInt(searchParams.get("page") ?? "", 10) || 1;
+ const limit = parseInt(searchParams.get("limit") ?? "", 10) || 50;
+ const sort = searchParams.get("sort") ?? "time";
+ const order = searchParams.get("order") ?? "desc";
+ const offset = (page - 1) * limit;
+
+ const features = config.features ?? {};
+ const packetsEnabled = features.packets !== false;
+ const spamEnabled = features.spam === true;
+ const includeSpam = spamEnabled && includeSpamParam;
+ const spamThreshold =
+ typeof config.spam_score_threshold === "number"
+ ? config.spam_score_threshold
+ : 0.65;
+
+ const [disabledAreas, setDisabledAreas] = useState>(() =>
+ getDisabledObserverAreas(),
+ );
+ const [filterOpen, setFilterOpen] = useState(
+ messageType !== "" || channelIdx !== "" || includeSpam,
+ );
+
+ const { paused, toggle, intervalSeconds, refetchInterval } =
+ useAutoRefresh();
+
+ const { data, error: queryError } = useQuery({
+ queryKey: qk.messages.list({
+ limit,
+ offset,
+ messageType,
+ channelIdx,
+ includeSpam,
+ sort,
+ order,
+ channelLabels: config.channel_labels,
+ disabledAreas: [...disabledAreas].sort(),
+ }),
+ refetchInterval,
+ queryFn: async ({ signal }) => {
+ const [nodesData, channelsData] = await Promise.all([
+ apiGet>(
+ "/api/v1/nodes",
+ { limit: 500, observer: true },
+ { signal },
+ ),
+ apiGet>("/api/v1/channels", {}, { signal }),
+ ]);
+
+ const builtin = getChannelLabelsMap(config);
+ const custom = new Map(
+ (channelsData.items ?? [])
+ .map((ch): [number, string] => [
+ parseInt(ch.channel_hash, 16),
+ ch.name,
+ ])
+ .filter(([idx]) => Number.isInteger(idx)),
+ );
+ const channelLabels = new Map([...builtin, ...custom]);
+
+ const areaMap = new Map();
+ for (const n of nodesData.items ?? []) {
+ const area = n.tags?.find((tg) => tg.key === "area")?.value;
+ if (!area || !area.trim()) continue;
+ const key = area.trim();
+ if (!areaMap.has(key)) areaMap.set(key, []);
+ areaMap.get(key)!.push(n.public_key);
+ }
+ const sortedAreas = [...areaMap.keys()].sort((a, b) =>
+ a.toLowerCase().localeCompare(b.toLowerCase()),
+ );
+
+ const observerFilterActive = sortedAreas.some((a) =>
+ disabledAreas.has(a),
+ );
+ const apiParams: Record = {
+ limit,
+ offset,
+ message_type: messageType,
+ channel_idx: channelIdx,
+ sort,
+ order,
+ };
+ if (observerFilterActive) {
+ apiParams.observed_by = sortedAreas
+ .filter((a) => !disabledAreas.has(a))
+ .flatMap((a) => areaMap.get(a) ?? []);
+ }
+ if (includeSpam) apiParams.include_spam = true;
+
+ const messagesData = await apiGet>(
+ "/api/v1/messages",
+ apiParams,
+ { signal },
+ );
+ return {
+ items: dedupeBySignature(messagesData.items ?? []),
+ total: messagesData.total ?? 0,
+ sortedAreas,
+ builtinLabels: builtin,
+ customLabels: custom,
+ channelLabels,
+ };
+ },
+ });
+ const error = queryError ? queryError.message : null;
+
+ const items = data?.items ?? null;
+ const total = data?.total ?? null;
+ const sortedAreas = data?.sortedAreas ?? [];
+ const builtinLabels = data?.builtinLabels ?? new Map();
+ const customLabels = data?.customLabels ?? new Map();
+ const channelLabels = data?.channelLabels ?? new Map();
+
+ const handleObserverToggle = (area: string) => {
+ const updated = toggleObserverArea(area, sortedAreas.length);
+ setDisabledAreas(new Set(updated));
+ if (page > 1) {
+ const sp = new URLSearchParams(searchParams);
+ sp.delete("page");
+ const qs = sp.toString();
+ navigate(qs ? `/messages?${qs}` : "/messages");
+ }
+ };
+
+ const senderBlock = (msg: Message, emphasize = false): ReactNode => {
+ const senderName = msg.sender_tag_name || msg.sender_name;
+ if (senderName) {
+ return emphasize ? (
+ {senderName}
+ ) : (
+ <>{senderName}>
+ );
+ }
+ const prefix = (msg.pubkey_prefix || "").slice(0, 12);
+ if (prefix) return {prefix};
+ return -;
+ };
+
+ const spamBadge = (msg: Message): ReactNode => {
+ if (
+ !spamEnabled ||
+ msg.spam_score == null ||
+ msg.spam_score < spamThreshold
+ ) {
+ return null;
+ }
+ return (
+
+ {t("messages.spam.badge")}
+
+ );
+ };
+
+ const renderReceivers = (msg: Message, variant: "mobile" | "desktop") => {
+ if (msg.observers && msg.observers.length >= 1) {
+ return ;
+ }
+ if (msg.observed_by) {
+ return (
+
+ {"\u{1F4E1}"}
+
+ );
+ }
+ return variant === "desktop" ? - : null;
+ };
+
+ const totalPages = total !== null ? Math.ceil(total / limit) : 0;
+ const headerParams: Record = {
+ message_type: messageType,
+ channel_idx: channelIdx,
+ limit: String(limit),
+ };
+ if (includeSpam) headerParams.include_spam = "true";
+ const paginationParams: Record = {
+ ...headerParams,
+ sort,
+ order,
+ };
+ const emptyMessage = t("common.no_entity_found", {
+ entity: t("entities.messages").toLowerCase(),
+ });
+
+ return (
+ <>
+
+
+ setFilterOpen((o) => !o) }}
+ />
+
+ {filterOpen && (
+
+
+
+
+
+
+
+
+ {spamEnabled && (
+
+
+
+ )}
+
+
+ )}
+
+ {items === null ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+ {items.length === 0 ? (
+ {emptyMessage}
+ ) : (
+ items.map((msg, idx) => {
+ const isChannel = msg.message_type === "channel";
+ const typeIcon = isChannel ? "\u{1F4FB}" : "\u{1F464}";
+ const typeTitle = isChannel
+ ? t("messages.type_channel")
+ : t("messages.type_contact");
+ const chInfo = channelInfo(
+ msg,
+ channelLabels,
+ t("messages.type_channel"),
+ );
+ const displayMessage = messageTextWithSender(
+ msg,
+ chInfo.text,
+ );
+ const fromPrimary = isChannel ? (
+
+ {chInfo.label || t("messages.type_channel")}
+
+ ) : (
+ senderBlock(msg)
+ );
+ const detailUrl =
+ packetsEnabled && msg.packet_hash
+ ? `/packets/hash/${msg.packet_hash}`
+ : null;
+ return (
+ navigate(detailUrl) : undefined
+ }
+ >
+
+
+
+
+ {typeIcon}
+
+
+
+ {fromPrimary}
+
+
+ {formatDateTimeShort(msg.received_at)}
+ {spamBadge(msg)}
+
+
+
+
+ {renderReceivers(msg, "mobile")}
+
+
+
+ {displayMessage}
+
+
+
+ );
+ })
+ )}
+
+
+
+
+
+
+
+
+
+
+ {t("common.observers")}
+
+
+
+ {items.length === 0 ? (
+ {emptyMessage}
+ ) : (
+ items.map((msg, idx) => {
+ const isChannel = msg.message_type === "channel";
+ const typeIcon = isChannel ? "\u{1F4FB}" : "\u{1F464}";
+ const typeTitle = isChannel
+ ? t("messages.type_channel")
+ : t("messages.type_contact");
+ const chInfo = channelInfo(
+ msg,
+ channelLabels,
+ t("messages.type_channel"),
+ );
+ const displayMessage = messageTextWithSender(
+ msg,
+ chInfo.text,
+ );
+ const fromPrimary = isChannel ? (
+
+ {chInfo.label || t("messages.type_channel")}
+
+ ) : (
+ senderBlock(msg, true)
+ );
+ const detailUrl =
+ packetsEnabled && msg.packet_hash
+ ? `/packets/hash/${msg.packet_hash}`
+ : null;
+ return (
+ navigate(detailUrl) : undefined
+ }
+ >
+
+ {typeIcon}
+
+
+ {formatDateTime(msg.received_at)}
+
+
+ {fromPrimary}
+
+
+
+
+ {displayMessage}
+
+ {spamBadge(msg)}
+
+
+ {renderReceivers(msg, "desktop")}
+
+ );
+ })
+ )}
+
+
+
+
+
+ >
+ )}
+ >
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.test.tsx
new file mode 100644
index 0000000..c151693
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.test.tsx
@@ -0,0 +1,77 @@
+import { screen, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("react-leaflet", () => ({
+ MapContainer: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ TileLayer: () => null,
+ Marker: () => null,
+ Popup: () => null,
+ useMap: () => ({ fitBounds: () => {} }),
+}));
+
+vi.mock("leaflet", () => ({
+ divIcon: () => ({}),
+ latLngBounds: () => ({}),
+ point: () => ({}),
+}));
+
+vi.mock("@/components/MeshQrCode", () => ({
+ MeshQrCode: () => ,
+}));
+
+import { NodeDetailPage as NodeDetail } from "@/pages/NodeDetail";
+import { renderWithProviders } from "@/test/renderWithProviders";
+import * as api from "@/utils/api";
+
+const KEY = "a".repeat(64);
+const NODE = {
+ public_key: KEY,
+ name: "DetailNode",
+ adv_type: "chat",
+ last_seen: "2024-01-01T00:00:00Z",
+ tags: [],
+};
+
+function mockNodeDetailApi() {
+ vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
+ if (path.includes(`/api/v1/nodes/${KEY}`)) return NODE;
+ if (path.includes("/api/v1/advertisements")) return { items: [], total: 0 };
+ throw new Error(`Unexpected: ${path}`);
+ });
+}
+
+describe("NodeDetail", () => {
+ it("shows a loading state before data resolves", () => {
+ vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
+ renderWithProviders( , {
+ route: `/nodes/${KEY}`,
+ routePath: "/nodes/:publicKey",
+ });
+ expect(document.querySelector(".loading-spinner")).not.toBeNull();
+ });
+
+ it("renders node detail after data resolves", async () => {
+ mockNodeDetailApi();
+ renderWithProviders( , {
+ route: `/nodes/${KEY}`,
+ routePath: "/nodes/:publicKey",
+ });
+ await waitFor(() => {
+ expect(document.querySelector(".loading-spinner")).toBeNull();
+ });
+ });
+
+ it("shows an error on fetch failure", async () => {
+ vi.spyOn(api, "apiGet").mockRejectedValue(new Error("node error"));
+ renderWithProviders( , {
+ route: `/nodes/${KEY}`,
+ routePath: "/nodes/:publicKey",
+ });
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toHaveTextContent("node error");
+ });
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx
new file mode 100644
index 0000000..fdda2d4
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx
@@ -0,0 +1,913 @@
+import {
+ useEffect,
+ useMemo,
+ useState,
+ type FormEvent,
+ type ReactNode,
+} from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import { Link, useNavigate, useParams, useSearchParams } from "react-router";
+import { MapContainer, Marker, TileLayer, useMap } from "react-leaflet";
+import { divIcon, point as leafletPoint } from "leaflet";
+import "leaflet/dist/leaflet.css";
+import { ErrorAlert, Loading, SuccessAlert } from "@/components/Alerts";
+import { Breadcrumbs } from "@/components/Breadcrumbs";
+import { ConfirmDialog } from "@/components/ConfirmDialog";
+import { CopyableValue } from "@/components/CopyableValue";
+import { IconEdit, IconPlus, IconTrash } from "@/components/icons";
+import { MeshQrCode } from "@/components/MeshQrCode";
+import { Modal } from "@/components/Modal";
+import { NotFoundState } from "@/components/NotFoundState";
+import { hasRole, useAppConfig } from "@/context/AppConfigContext";
+import { usePageTitle } from "@/hooks/usePageTitle";
+import { apiDelete, apiGet, apiPost, apiPut, isAbortError } from "@/utils/api";
+import { qk, invalidate } from "@/utils/queryKeys";
+import { typeEmoji, truncateKey, useFormatDateTime } from "@/utils/format";
+
+interface NodeTag {
+ key: string;
+ value: string | null;
+ value_type: string | null;
+}
+
+interface AdoptionInfo {
+ user_id: string;
+ name: string | null;
+ profile_id: string;
+}
+
+interface NodeDetailData {
+ public_key: string;
+ name: string | null;
+ adv_type: string | null;
+ lat: number | null;
+ lon: number | null;
+ first_seen: string | null;
+ last_seen: string | null;
+ tags: NodeTag[] | null;
+ adopted_by: AdoptionInfo | null;
+}
+
+interface AdvertisementItem {
+ received_at: string | null;
+ adv_type: string | null;
+ observed_by: string | null;
+ observer_name: string | null;
+ observer_tag_name: string | null;
+}
+
+interface AdvertisementListResponse {
+ items: AdvertisementItem[];
+}
+
+interface PrefixResolution {
+ public_key: string;
+}
+
+interface FlashState {
+ type: "success" | "error";
+ message: string;
+}
+
+function errorMessage(e: unknown): string {
+ return e instanceof Error ? e.message : String(e);
+}
+
+function OffsetCenter({ lat, lon }: { lat: number; lon: number }) {
+ const map = useMap();
+ useEffect(() => {
+ map.setView([lat, lon], 14);
+ const point = map.latLngToContainerPoint([lat, lon]);
+ const size = map.getSize();
+ const newPoint = leafletPoint(point.x + size.x * 0.17, point.y);
+ const newLatLng = map.containerPointToLatLng(newPoint);
+ map.setView(newLatLng, 14, { animate: false });
+ }, [map, lat, lon]);
+ return null;
+}
+
+export function NodeDetailPage() {
+ const { t } = useTranslation();
+ const config = useAppConfig();
+ const navigate = useNavigate();
+ const { publicKey: publicKeyParam } = useParams();
+ const [searchParams] = useSearchParams();
+ const { formatDateTime } = useFormatDateTime();
+ usePageTitle("entities.node_detail");
+
+ const publicKey = publicKeyParam ?? "";
+ const isFullKey = publicKey.length === 64;
+ const flashMessage = searchParams.get("message") || "";
+ const flashError = searchParams.get("error") || "";
+
+ const queryClient = useQueryClient();
+ const [flash, setFlash] = useState(null);
+ const [prefixNotFound, setPrefixNotFound] = useState(false);
+ const [prefixError, setPrefixError] = useState(null);
+
+ const [addKey, setAddKey] = useState("");
+ const [addValue, setAddValue] = useState("");
+ const [addType, setAddType] = useState("string");
+ const [addError, setAddError] = useState("");
+
+ const [editTag, setEditTag] = useState(null);
+ const [editValue, setEditValue] = useState("");
+ const [editType, setEditType] = useState("string");
+ const [editError, setEditError] = useState("");
+ const [editSaving, setEditSaving] = useState(false);
+
+ const [deleteKey, setDeleteKey] = useState(null);
+ const [deleteSaving, setDeleteSaving] = useState(false);
+ const [confirmRelease, setConfirmRelease] = useState(false);
+
+ useEffect(() => {
+ if (!publicKey || isFullKey) return;
+ const ac = new AbortController();
+ (async () => {
+ try {
+ const resolved = await apiGet(
+ `/api/v1/nodes/prefix/${encodeURIComponent(publicKey)}`,
+ {},
+ { signal: ac.signal },
+ );
+ navigate(`/nodes/${resolved.public_key}`, { replace: true });
+ } catch (e) {
+ if (isAbortError(e)) return;
+ if (errorMessage(e).includes("404")) {
+ setPrefixNotFound(true);
+ } else {
+ setPrefixError(errorMessage(e));
+ }
+ }
+ })();
+ return () => ac.abort();
+ }, [publicKey, isFullKey, navigate]);
+
+ const nodeQuery = useQuery({
+ queryKey: qk.nodes.detail(publicKey),
+ queryFn: ({ signal }) =>
+ apiGet(
+ `/api/v1/nodes/${publicKey}`,
+ {},
+ { signal },
+ ),
+ enabled: isFullKey,
+ });
+ const advertisementsQuery = useQuery({
+ queryKey: qk.advertisements.list({ public_key: publicKey, limit: 10 }),
+ queryFn: ({ signal }) =>
+ apiGet(
+ "/api/v1/advertisements",
+ { public_key: publicKey, limit: 10 },
+ { signal },
+ ),
+ enabled: isFullKey,
+ });
+
+ const node = nodeQuery.data ?? null;
+ const advertisements = advertisementsQuery.data?.items ?? [];
+ const nodeErrorMsg = nodeQuery.error ? errorMessage(nodeQuery.error) : null;
+ const notFound =
+ prefixNotFound ||
+ (nodeErrorMsg?.includes("404") ?? false) ||
+ (isFullKey && !nodeQuery.isPending && nodeQuery.data === null);
+ const error =
+ prefixError ||
+ (nodeErrorMsg && !nodeErrorMsg.includes("404") ? nodeErrorMsg : null);
+ const loading = isFullKey ? nodeQuery.isLoading : !prefixNotFound && !prefixError;
+
+ const adoptMutation = useMutation({
+ mutationFn: (key: string) =>
+ apiPost("/api/v1/adoptions", { public_key: key }),
+ onSuccess: () => invalidate.adoptions(queryClient),
+ });
+ const releaseMutation = useMutation({
+ mutationFn: (key: string) => apiDelete(`/api/v1/adoptions/${key}`),
+ onSuccess: () => invalidate.adoptions(queryClient),
+ });
+
+ let lat: number | null = node?.lat ?? null;
+ let lon: number | null = node?.lon ?? null;
+ if (node) {
+ for (const tag of node.tags || []) {
+ if (tag.key === "lat" && !lat) lat = parseFloat(tag.value ?? "");
+ if (tag.key === "lon" && !lon) lon = parseFloat(tag.value ?? "");
+ }
+ }
+ const hasCoords =
+ lat != null &&
+ lon != null &&
+ !Number.isNaN(lat) &&
+ !Number.isNaN(lon) &&
+ !(lat === 0 && lon === 0);
+
+ const tagName = node?.tags?.find((tag) => tag.key === "name")?.value ?? null;
+ const tagDescription =
+ node?.tags?.find((tag) => tag.key === "description")?.value ?? null;
+ const displayName = tagName || node?.name || t("common.unnamed_node");
+ const emoji = typeEmoji(node?.adv_type ?? null);
+
+ const nodeMapIcon = useMemo(
+ () =>
+ divIcon({
+ html:
+ '' +
+ emoji +
+ "",
+ className: "",
+ iconSize: [32, 32],
+ iconAnchor: [16, 16],
+ }),
+ [emoji],
+ );
+
+ const qrUrl = useMemo(() => {
+ if (!node) return "";
+ const typeMap: Record = {
+ chat: 1,
+ repeater: 2,
+ room: 3,
+ companion: 1,
+ sensor: 4,
+ };
+ const typeNum = typeMap[(node.adv_type || "").toLowerCase()] || 1;
+ return (
+ "meshcore://contact/add?name=" +
+ encodeURIComponent(displayName) +
+ "&public_key=" +
+ node.public_key +
+ "&type=" +
+ typeNum
+ );
+ }, [node, displayName]);
+
+ useEffect(() => {
+ if (!flash) return;
+ const timer = setTimeout(() => setFlash(null), 3000);
+ return () => clearTimeout(timer);
+ }, [flash]);
+
+ const showFlash = (type: "success" | "error", message: string) => {
+ setFlash({ type, message });
+ };
+
+ const invalidateNodeData = () => {
+ invalidate.nodeTags(queryClient);
+ };
+
+ const validateTagValue = (value: string, type: string): string | null => {
+ if (!value || !type) return null;
+ if (type === "number" && isNaN(Number(value))) {
+ return t("common.validation_invalid_number");
+ }
+ if (type === "boolean") {
+ const normalized = value.toLowerCase().trim();
+ if (!["true", "false", "yes", "no", "1", "0"].includes(normalized)) {
+ return t("common.validation_invalid_boolean");
+ }
+ }
+ return null;
+ };
+
+ const handleAdopt = async () => {
+ if (!node) return;
+ try {
+ await adoptMutation.mutateAsync(node.public_key);
+ navigate(
+ `/nodes/${node.public_key}?message=${encodeURIComponent(t("nodes.adopt_success"))}`,
+ { replace: true },
+ );
+ } catch (e) {
+ navigate(
+ `/nodes/${node.public_key}?error=${encodeURIComponent(errorMessage(e))}`,
+ { replace: true },
+ );
+ }
+ };
+
+ const handleRelease = async () => {
+ if (!node) return;
+ setConfirmRelease(false);
+ try {
+ await releaseMutation.mutateAsync(node.public_key);
+ navigate(
+ `/nodes/${node.public_key}?message=${encodeURIComponent(t("nodes.release_success"))}`,
+ { replace: true },
+ );
+ } catch (e) {
+ navigate(
+ `/nodes/${node.public_key}?error=${encodeURIComponent(errorMessage(e))}`,
+ { replace: true },
+ );
+ }
+ };
+
+ const handleAddTag = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!node) return;
+ const validationError = validateTagValue(addValue, addType);
+ if (validationError) {
+ setAddError(validationError);
+ return;
+ }
+ setAddError("");
+ try {
+ await apiPost(`/api/v1/nodes/${node.public_key}/tags`, {
+ key: addKey,
+ value: addValue,
+ value_type: addType,
+ });
+ setAddKey("");
+ setAddValue("");
+ setAddType("string");
+ showFlash(
+ "success",
+ t("common.entity_added_success", { entity: t("entities.tag") }),
+ );
+ invalidateNodeData();
+ } catch (e) {
+ showFlash("error", errorMessage(e));
+ }
+ };
+
+ const openEditTag = (tag: NodeTag) => {
+ setEditTag(tag);
+ setEditValue(tag.value ?? "");
+ setEditType(tag.value_type || "string");
+ setEditError("");
+ };
+
+ const handleEditTag = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!node || !editTag) return;
+ const validationError = validateTagValue(editValue, editType);
+ if (validationError) {
+ setEditError(validationError);
+ return;
+ }
+ setEditError("");
+ setEditSaving(true);
+ try {
+ await apiPut(
+ `/api/v1/nodes/${node.public_key}/tags/${encodeURIComponent(editTag.key)}`,
+ { value: editValue, value_type: editType },
+ );
+ setEditTag(null);
+ showFlash(
+ "success",
+ t("common.entity_updated_success", { entity: t("entities.tag") }),
+ );
+ invalidateNodeData();
+ } catch (e) {
+ setEditError(errorMessage(e));
+ } finally {
+ setEditSaving(false);
+ }
+ };
+
+ const handleDeleteTag = async () => {
+ if (!node || deleteKey === null) return;
+ setDeleteSaving(true);
+ try {
+ await apiDelete(
+ `/api/v1/nodes/${node.public_key}/tags/${encodeURIComponent(deleteKey)}`,
+ );
+ setDeleteKey(null);
+ showFlash(
+ "success",
+ t("common.entity_deleted_success", { entity: t("entities.tag") }),
+ );
+ invalidateNodeData();
+ } catch (e) {
+ setDeleteKey(null);
+ showFlash("error", errorMessage(e));
+ } finally {
+ setDeleteSaving(false);
+ }
+ };
+
+ if (!node) {
+ if (notFound) {
+ return (
+ <>
+
+
+
+ {t("common.view_entity", { entity: t("entities.nodes") })}
+
+ >
+ );
+ }
+ if (error) {
+ return ;
+ }
+ return ;
+ }
+
+ const canEditTags =
+ config.oidc_enabled &&
+ !!config.user &&
+ (hasRole("admin") ||
+ (hasRole("operator") && node.adopted_by?.user_id === config.user.sub));
+
+ const isOperator = hasRole("operator");
+ const isAdmin = hasRole("admin");
+
+ let adoptionCard: ReactNode = null;
+ if (config.oidc_enabled && config.user) {
+ if (node.adopted_by) {
+ const adoptedBy = node.adopted_by;
+ const ownerName = adoptedBy.name || adoptedBy.user_id;
+ const canRelease =
+ (isOperator || isAdmin) &&
+ (adoptedBy.user_id === config.user.sub || isAdmin);
+ adoptionCard = (
+
+
+ {t("nodes.ownership")}
+
+
+ {t("nodes.adopted_by_prefix")}{" "}
+
+ {ownerName}
+
+
+ {canRelease && (
+
+ )}
+
+
+
+ );
+ } else if (isOperator || isAdmin) {
+ adoptionCard = (
+
+
+ {t("nodes.ownership")}
+ {t("nodes.not_adopted")}
+
+
+
+
+
+ );
+ }
+ }
+
+ const publicKeyCard = (
+
+
+
+
+ {t("common.public_key")}
+
+
+
+
+
+ {t("common.first_seen_label")}{" "}
+ {formatDateTime(node.first_seen)}
+
+
+ {t("common.last_seen_label")}{" "}
+ {formatDateTime(node.last_seen)}
+
+ {hasCoords && (
+
+ {t("common.location")}:{" "}
+ {lat}, {lon}
+
+ )}
+
+
+
+ );
+
+ const tags = node.tags || [];
+
+ const tagsTable = canEditTags ? (
+ tags.length > 0 ? (
+
+
+
+
+ {t("common.key")}
+ {t("common.value")}
+ {t("common.type")}
+ {t("common.actions")}
+
+
+
+ {tags.map((tag) => (
+
+
+ {tag.key}
+
+
+ {tag.value || ""}
+
+
+ {tag.value_type || "string"}
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ ) : (
+
+ {t("common.no_entity_defined", {
+ entity: t("entities.tags").toLowerCase(),
+ })}
+
+ )
+ ) : tags.length > 0 ? (
+
+
+
+
+ {t("common.key")}
+ {t("common.value")}
+ {t("common.type")}
+
+
+
+ {tags.map((tag) => (
+
+ {tag.key}
+ {tag.value || ""}
+ {tag.value_type || "string"}
+
+ ))}
+
+
+
+ ) : (
+
+ {t("common.no_entity_defined", {
+ entity: t("entities.tags").toLowerCase(),
+ })}
+
+ );
+
+ return (
+ <>
+
+
+
+
+ {emoji}
+
+
+ {displayName}
+ {tagDescription && (
+ {tagDescription}
+ )}
+
+
+
+ {flashMessage ? (
+
+ ) : flashError ? (
+
+ ) : null}
+
+ {flash &&
+ (flash.type === "success" ? (
+
+ ) : (
+
+ ))}
+
+ {hasCoords && lat != null && lon != null ? (
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+ {t("nodes.scan_to_add")}
+
+
+ )}
+
+ {adoptionCard ? (
+
+ {publicKeyCard}
+ {adoptionCard}
+
+ ) : (
+ {publicKeyCard}
+ )}
+
+
+
+
+
+ {t("common.recent_entity", {
+ entity: t("entities.advertisements"),
+ })}
+
+ {advertisements.length > 0 ? (
+
+
+
+
+ {t("common.time")}
+ {t("common.type")}
+ {t("common.received_by")}
+
+
+
+ {advertisements.map((adv, idx) => {
+ const recvName = adv.observed_by
+ ? (adv.observer_tag_name || adv.observer_name)
+ : null;
+ return (
+
+
+ {formatDateTime(adv.received_at)}
+
+
+ {adv.adv_type ? (
+
+ {typeEmoji(adv.adv_type)}
+
+ ) : (
+ -
+ )}
+
+
+ {!adv.observed_by ? (
+ -
+ ) : recvName ? (
+
+
+ {recvName}
+
+
+ {adv.observed_by.slice(0, 16)}...
+
+
+ ) : (
+
+
+ {adv.observed_by.slice(0, 12)}...
+
+
+ )}
+
+
+ );
+ })}
+
+
+
+ ) : (
+
+ {t("common.no_entity_recorded", {
+ entity: t("entities.advertisements").toLowerCase(),
+ })}
+
+ )}
+
+
+
+
+
+ {t("entities.tags")}
+ {tagsTable}
+ {canEditTags && (
+
+ )}
+
+
+
+
+ {canEditTags && editTag && (
+
+ {t("common.edit_entity", { entity: t("entities.tag") })}:{" "}
+
+ {editTag.key}
+
+ >
+ }
+ onClose={() => {
+ if (!editSaving) setEditTag(null);
+ }}
+ >
+
+
+ )}
+
+ {canEditTags && deleteKey !== null && (
+
+
+
+ {t("common.cannot_be_undone")}
+
+ >
+ }
+ confirmLabel={t("common.delete")}
+ cancelLabel={t("common.cancel")}
+ saving={deleteSaving}
+ onConfirm={handleDeleteTag}
+ onCancel={() => {
+ if (!deleteSaving) setDeleteKey(null);
+ }}
+ />
+ )}
+
+ {confirmRelease && (
+ setConfirmRelease(false)}
+ />
+ )}
+ >
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.test.tsx
new file mode 100644
index 0000000..fddfed3
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.test.tsx
@@ -0,0 +1,78 @@
+import { screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Nodes } from "@/pages/Nodes";
+import { renderWithProviders } from "@/test/renderWithProviders";
+import { makeConfig } from "@/test/makeConfig";
+import * as api from "@/utils/api";
+
+const KEY = "a".repeat(64);
+const NODES = {
+ items: [
+ {
+ public_key: KEY,
+ name: "TestNode",
+ adv_type: "chat",
+ last_seen: "2024-01-01T00:00:00Z",
+ tags: [],
+ },
+ ],
+ total: 1,
+ limit: 50,
+ offset: 0,
+};
+
+function mockNodesApi() {
+ vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
+ if (path.includes("/api/v1/nodes")) return NODES;
+ if (path.includes("/api/v1/user/profiles")) return { items: [] };
+ throw new Error(`Unexpected: ${path}`);
+ });
+}
+
+describe("Nodes", () => {
+ it("shows a loading spinner before data resolves", () => {
+ vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
+ renderWithProviders( );
+ expect(document.querySelector(".loading-spinner")).not.toBeNull();
+ });
+
+ it("renders node rows after data resolves", async () => {
+ mockNodesApi();
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(screen.getAllByText("TestNode").length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ it("shows an error alert on fetch failure", async () => {
+ vi.spyOn(api, "apiGet").mockRejectedValue(new Error("server down"));
+ const { container } = renderWithProviders( );
+ await waitFor(() => {
+ expect(container.querySelector('[data-tip="server down"]')).not.toBeNull();
+ });
+ });
+
+ it("renders an empty state when no nodes exist", async () => {
+ vi.spyOn(api, "apiGet").mockResolvedValue({
+ items: [],
+ total: 0,
+ limit: 50,
+ offset: 0,
+ });
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(screen.queryByText("TestNode")).not.toBeInTheDocument();
+ });
+ });
+
+ it("renders without error when OIDC is enabled", async () => {
+ mockNodesApi();
+ renderWithProviders( , {
+ config: makeConfig({ oidc_enabled: true, user: { sub: "u1", name: "Admin" } }),
+ });
+ await waitFor(() => {
+ expect(screen.getAllByText("TestNode").length).toBeGreaterThanOrEqual(1);
+ });
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx
new file mode 100644
index 0000000..9cc97b4
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx
@@ -0,0 +1,377 @@
+import { useMemo, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import { Link, useSearchParams } from "react-router";
+
+import { useAppConfig } from "@/context/AppConfigContext";
+import { apiGet } from "@/utils/api";
+import { qk } from "@/utils/queryKeys";
+import { useFormatDateTime } from "@/utils/format";
+import { usePageTitle } from "@/hooks/usePageTitle";
+import { useAutoRefresh } from "@/hooks/useAutoRefresh";
+import { Pagination } from "@/components/Pagination";
+import {
+ FilterForm,
+ FilterField,
+ FilterSelect,
+ OperatorSelect,
+ autoSubmit,
+} from "@/components/FilterForm";
+import {
+ SortableTableHeader,
+ MobileSortSelect,
+} from "@/components/SortableTable";
+import { NodeDisplay, NodeLink } from "@/components/NodeDisplay";
+import { CopyableValue } from "@/components/CopyableValue";
+import { Loading } from "@/components/Alerts";
+import { ListToolbar } from "@/components/ListToolbar";
+import { PageHeader } from "@/components/PageHeader";
+import { EmptyState, EmptyRow } from "@/components/EmptyState";
+
+interface NodeTag {
+ key: string;
+ value: string | null;
+}
+
+interface NodeItem {
+ public_key: string;
+ name: string | null;
+ adv_type: string | null;
+ last_seen: string | null;
+ tags: NodeTag[];
+}
+
+interface NodeListResponse {
+ items: NodeItem[];
+ total: number;
+ limit: number;
+ offset: number;
+}
+
+interface Profile {
+ id: string;
+ name: string | null;
+ callsign: string | null;
+ roles: string[];
+ user_id?: string;
+}
+
+interface ProfileListResponse {
+ items: Profile[];
+ total: number;
+}
+
+function tagValue(tags: NodeTag[] | undefined, key: string): string | null {
+ return tags?.find((tag) => tag.key === key)?.value ?? null;
+}
+
+export function Nodes() {
+ const { t } = useTranslation();
+ const config = useAppConfig();
+ const [searchParams] = useSearchParams();
+ const { formatDateTime, formatDateTimeShort } = useFormatDateTime();
+ usePageTitle("entities.nodes");
+
+ const search = searchParams.get("search") || "";
+ const advType = searchParams.get("adv_type") || "";
+ const adoptedBy = searchParams.get("adopted_by") || "";
+ const pubkeyPrefix = searchParams.get("pubkey_prefix") || "";
+ const page = parseInt(searchParams.get("page") || "", 10) || 1;
+ const limit = parseInt(searchParams.get("limit") || "", 10) || 20;
+ const offset = (page - 1) * limit;
+ const sort = searchParams.get("sort") || "last_seen";
+ const order = searchParams.get("order") || "desc";
+
+ const hasActiveFilters =
+ search !== "" ||
+ advType !== "" ||
+ pubkeyPrefix !== "" ||
+ (config.oidc_enabled && adoptedBy !== "");
+
+ const [filterOpen, setFilterOpen] = useState(hasActiveFilters);
+
+ const oidcEnabled = config.oidc_enabled;
+ const operatorRole = config.role_names?.operator || "operator";
+
+ const { paused, toggle, intervalSeconds, refetchInterval } =
+ useAutoRefresh();
+
+ const {
+ data,
+ isLoading: loading,
+ error: queryError,
+ } = useQuery({
+ queryKey: qk.nodes.list({
+ limit,
+ offset,
+ search,
+ advType,
+ sort,
+ order,
+ adoptedBy,
+ pubkeyPrefix,
+ oidcEnabled,
+ operatorRole,
+ }),
+ refetchInterval,
+ queryFn: async ({ signal }) => {
+ const apiParams: Record = {
+ limit,
+ offset,
+ search,
+ adv_type: advType,
+ sort,
+ order,
+ };
+ if (adoptedBy) apiParams.adopted_by = adoptedBy;
+ if (pubkeyPrefix) apiParams.pubkey_prefix = pubkeyPrefix;
+
+ const fetches: Promise[] = [
+ apiGet("/api/v1/nodes", apiParams, { signal }),
+ ];
+ if (oidcEnabled) {
+ fetches.push(
+ apiGet(
+ "/api/v1/user/profiles",
+ { limit: 500 },
+ { signal },
+ ),
+ );
+ }
+ const results = await Promise.all(fetches);
+ const nodeData = results[0] as NodeListResponse;
+ const profs = oidcEnabled
+ ? ((results[1] as ProfileListResponse)?.items || []).filter(
+ (p) => p.roles && p.roles.includes(operatorRole),
+ )
+ : [];
+
+ return {
+ nodes: nodeData.items || [],
+ total: nodeData.total || 0,
+ profiles: profs,
+ };
+ },
+ });
+ const error = queryError ? queryError.message : null;
+
+ const nodes = data?.nodes ?? [];
+ const total = data?.total ?? null;
+ const profiles = data?.profiles ?? [];
+
+ const sortedProfiles = useMemo(
+ () =>
+ [...profiles].sort((a, b) => {
+ const na = a.name || a.callsign || "";
+ const nb = b.name || b.callsign || "";
+ return na.localeCompare(nb);
+ }),
+ [profiles],
+ );
+
+ const totalPages = total !== null ? Math.ceil(total / limit) : 0;
+ const headerParams: Record = {
+ search,
+ adv_type: advType,
+ adopted_by: adoptedBy,
+ pubkey_prefix: pubkeyPrefix,
+ limit: String(limit),
+ };
+
+ const noEntity = t("common.no_entity_found", {
+ entity: t("entities.nodes").toLowerCase(),
+ });
+
+ const mobileCards =
+ nodes.length === 0 ? (
+ {noEntity}
+ ) : (
+ nodes.map((node) => {
+ const displayName = tagValue(node.tags, "name") || node.name;
+ const tagDescription = tagValue(node.tags, "description");
+ const lastSeen = node.last_seen
+ ? formatDateTimeShort(node.last_seen)
+ : "-";
+ return (
+
+
+
+
+
+ {lastSeen}
+
+
+
+
+ );
+ })
+ );
+
+ const tableRows =
+ nodes.length === 0 ? (
+ {noEntity}
+ ) : (
+ nodes.map((node) => {
+ const displayName = tagValue(node.tags, "name") || node.name;
+ const tagDescription = tagValue(node.tags, "description");
+ const lastSeen = node.last_seen ? formatDateTime(node.last_seen) : "-";
+ return (
+
+
+
+
+
+
+
+ {lastSeen}
+
+ );
+ })
+ );
+
+ if (loading) return ;
+
+ return (
+
+
+
+ setFilterOpen((open) => !open),
+ }}
+ />
+
+ {filterOpen && (
+
+
+
+
+
+
+
+
+ {oidcEnabled && sortedProfiles.length > 0 && (
+
+
+
+ )}
+
+
+ )}
+
+
+
+ {mobileCards}
+
+
+
+
+
+
+
+
+
+
+ {tableRows}
+
+
+
+
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/NotFound.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/NotFound.test.tsx
new file mode 100644
index 0000000..70c1b60
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/NotFound.test.tsx
@@ -0,0 +1,23 @@
+import { screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { renderWithProviders } from "@/test/renderWithProviders";
+import { NotFound } from "@/pages/NotFound";
+
+describe("NotFound", () => {
+ it("renders the 404 hero", () => {
+ renderWithProviders( );
+ expect(screen.getByText("404")).toBeInTheDocument();
+ expect(screen.getByText("common.page_not_found")).toBeInTheDocument();
+ });
+
+ it("has links to home and nodes", () => {
+ renderWithProviders( );
+ const homeLink = screen.getByText("common.go_home").closest("a");
+ expect(homeLink).toHaveAttribute("href", "/");
+ const nodesLink = screen
+ .getByText(/common.view_entity/)
+ .closest("a");
+ expect(nodesLink).toHaveAttribute("href", "/nodes");
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/NotFound.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/NotFound.tsx
new file mode 100644
index 0000000..f169fe5
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/NotFound.tsx
@@ -0,0 +1,33 @@
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router";
+import { IconHome, IconNodes } from "@/components/icons";
+import { usePageTitle } from "@/hooks/usePageTitle";
+
+export function NotFound() {
+ const { t } = useTranslation();
+ usePageTitle();
+
+ return (
+
+
+
+ 404
+
+ {t("common.page_not_found")}
+
+ {t("not_found.description")}
+
+
+
+ {t("common.go_home")}
+
+
+
+ {t("common.view_entity", { entity: t("entities.nodes") })}
+
+
+
+
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.test.tsx
new file mode 100644
index 0000000..acbbfd9
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.test.tsx
@@ -0,0 +1,74 @@
+import { screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { PacketDetail } from "@/pages/PacketDetail";
+import { renderWithProviders } from "@/test/renderWithProviders";
+import * as api from "@/utils/api";
+
+const PACKET = {
+ packet_hash: "abc123",
+ event_type: "advert",
+ channel_idx: 17,
+ observed_by: "nodekey1",
+ observer_name: "Observer1",
+ observer_tag_name: null,
+ source_pubkey_prefix: "deadbeef",
+ packet_type: 1,
+ payload_type: 2,
+ route_type: "direct",
+ snr: -5.5,
+ path_len: 3,
+ received_at: "2024-01-01T00:00:00Z",
+ redacted: false,
+ raw_hex: "deadbeef",
+ decoded: { foo: "bar" },
+};
+
+function mockPacketApi(packet?: unknown, error?: Error) {
+ vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
+ if (path.includes("/api/v1/channels")) return { items: [] };
+ if (error) throw error;
+ return packet ?? PACKET;
+ });
+}
+
+function renderPage() {
+ return renderWithProviders( , {
+ route: "/packets/test-id",
+ routePath: "/packets/:id",
+ });
+}
+
+describe("PacketDetail", () => {
+ it("shows a loading spinner before data resolves", () => {
+ vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
+ renderPage();
+ expect(document.querySelector(".loading-spinner")).not.toBeNull();
+ });
+
+ it("renders packet fields after data resolves", async () => {
+ mockPacketApi();
+ renderPage();
+ await waitFor(() => {
+ expect(screen.getAllByText("abc123").length).toBeGreaterThanOrEqual(1);
+ });
+ expect(screen.getByText("Observer1")).toBeInTheDocument();
+ });
+
+ it("shows not-found state on a 404 error", async () => {
+ const err = new Error("API error: 404 Not Found");
+ mockPacketApi(undefined, err);
+ renderPage();
+ await waitFor(() => {
+ expect(screen.getByText(/entity_not_found/)).toBeInTheDocument();
+ });
+ });
+
+ it("shows a warning badge on non-404 errors", async () => {
+ mockPacketApi(undefined, new Error("boom"));
+ const { container } = renderPage();
+ await waitFor(() => {
+ expect(container.querySelector('[data-tip="boom"]')).not.toBeNull();
+ });
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx
new file mode 100644
index 0000000..717e299
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx
@@ -0,0 +1,176 @@
+import { useQuery } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import { Link, useParams } from "react-router";
+import { usePageTitle } from "@/hooks/usePageTitle";
+import { apiGet } from "@/utils/api";
+import { qk } from "@/utils/queryKeys";
+import { useFormatDateTime } from "@/utils/format";
+import { Loading, WarningBadge } from "@/components/Alerts";
+import { Breadcrumbs } from "@/components/Breadcrumbs";
+import { NotFoundState } from "@/components/NotFoundState";
+import { DefinitionGrid } from "@/components/Definition";
+import {
+ buildChannelNames,
+ isNotFoundError,
+ type ChannelItem,
+} from "@/utils/packets";
+import {
+ Field,
+ RedactedNotice,
+ RawHexBlock,
+ DecodedJsonBlock,
+ channelNameDisplay,
+} from "@/components/PacketParts";
+
+interface PacketDetailData {
+ packet_hash: string | null;
+ event_type: string | null;
+ channel_idx: number | null;
+ observed_by: string | null;
+ observer_name: string | null;
+ observer_tag_name: string | null;
+ source_pubkey_prefix: string | null;
+ packet_type: number | null;
+ payload_type: number | null;
+ route_type: string | null;
+ snr: number | null;
+ path_len: number | null;
+ received_at: string | null;
+ redacted: boolean;
+ raw_hex: string | null;
+ decoded: unknown;
+}
+
+interface ChannelsResponse {
+ items: ChannelItem[];
+}
+
+export function PacketDetail() {
+ const { t } = useTranslation();
+ usePageTitle("packets.detail_title");
+ const { id } = useParams();
+ const { formatDateTime } = useFormatDateTime();
+
+ const packetQuery = useQuery({
+ queryKey: qk.packets.detail(id ?? ""),
+ queryFn: ({ signal }) =>
+ apiGet(`/api/v1/packets/${id}`, {}, { signal }),
+ enabled: !!id,
+ });
+ const channelsQuery = useQuery({
+ queryKey: qk.channels.list({ limit: 200 }),
+ queryFn: ({ signal }) =>
+ apiGet("/api/v1/channels", { limit: 200 }, {
+ signal,
+ }).catch(() => ({ items: [] as ChannelItem[] })),
+ });
+
+ const packet = packetQuery.data ?? null;
+ const channelNames = buildChannelNames(channelsQuery.data?.items || []);
+ const notFound = packetQuery.error
+ ? isNotFoundError(packetQuery.error)
+ : false;
+ const error =
+ packetQuery.error && !isNotFoundError(packetQuery.error)
+ ? packetQuery.error instanceof Error
+ ? packetQuery.error.message
+ : String(packetQuery.error)
+ : null;
+
+ const leaf = packet?.packet_hash || packet?.event_type || "";
+ const channelDisplay = channelNameDisplay(channelNames, packet?.channel_idx ?? null);
+
+ return (
+
+
+
+ {notFound && (
+
+ )}
+ {error && }
+ {!packet && !notFound && !error && }
+
+ {packet && (
+ <>
+ {packet.redacted && }
+
+
+
+
+ {formatDateTime(packet.received_at)}
+
+
+ {packet.observed_by ? (
+
+ {packet.observer_tag_name ||
+ packet.observer_name ||
+ packet.observed_by}
+
+ ) : (
+ —
+ )}
+
+
+ {packet.event_type || "—"}
+
+ {channelDisplay}
+
+ {packet.source_pubkey_prefix ? (
+
+ {packet.source_pubkey_prefix}
+
+ ) : (
+ —
+ )}
+
+
+ {packet.packet_hash ? (
+
+ {packet.packet_hash}
+
+ ) : (
+ —
+ )}
+
+
+ {packet.packet_type != null ? packet.packet_type : "—"}
+
+
+ {packet.payload_type != null ? packet.payload_type : "—"}
+
+
+ {packet.route_type || "—"}
+
+
+ {packet.snr != null ? Number(packet.snr).toFixed(1) : "—"}
+
+
+ {packet.path_len != null ? packet.path_len : "—"}
+
+
+
+ {!packet.redacted && }
+
+ {!packet.redacted && packet.decoded != null && (
+
+ )}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.test.tsx
new file mode 100644
index 0000000..09c0aa2
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.test.tsx
@@ -0,0 +1,73 @@
+import { screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { PacketGroupDetail } from "@/pages/PacketGroupDetail";
+import { renderWithProviders } from "@/test/renderWithProviders";
+import * as api from "@/utils/api";
+
+const GROUP = {
+ packet_hash: "grouphash",
+ event_type: "advert",
+ channel_idx: 17,
+ first_seen: "2024-01-01T00:00:00Z",
+ redacted: false,
+ raw_hex: "deadbeef",
+ decoded: { type: "test" },
+ receptions: [
+ {
+ packet_id: "p1",
+ observed_by: "obs1",
+ observer_name: "Observer1",
+ snr: -5.0,
+ observed_at: "2024-01-01T00:00:00Z",
+ path: ["a", "b"],
+ },
+ ],
+};
+
+function mockGroupApi() {
+ vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
+ if (path.includes("/api/v1/packet-groups/")) return GROUP;
+ if (path.includes("/api/v1/channels")) return { items: [] };
+ if (path.includes("/api/v1/nodes")) return { items: [], total: 0 };
+ throw new Error(`Unexpected: ${path}`);
+ });
+}
+
+describe("PacketGroupDetail", () => {
+ it("shows a loading state before data resolves", () => {
+ vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
+ renderWithProviders( , {
+ route: "/packets/hash/abc",
+ routePath: "/packets/hash/:hash",
+ });
+ expect(document.querySelector(".loading-spinner")).not.toBeNull();
+ });
+
+ it("renders group detail after data resolves", async () => {
+ mockGroupApi();
+ renderWithProviders( , {
+ route: "/packets/hash/abc",
+ routePath: "/packets/hash/:hash",
+ });
+ await waitFor(() => {
+ expect(screen.getAllByText("grouphash").length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ it("shows an error on fetch failure", async () => {
+ vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
+ if (path.includes("/api/v1/channels")) return { items: [] };
+ throw new Error("group fetch failed");
+ });
+ const { container } = renderWithProviders( , {
+ route: "/packets/hash/abc",
+ routePath: "/packets/hash/:hash",
+ });
+ await waitFor(() => {
+ expect(
+ container.querySelector('[data-tip="group fetch failed"]'),
+ ).not.toBeNull();
+ });
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx
new file mode 100644
index 0000000..dec9de0
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx
@@ -0,0 +1,628 @@
+import {
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { createPortal } from "react-dom";
+import { useQuery } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import { Link, useParams } from "react-router";
+import { usePageTitle } from "@/hooks/usePageTitle";
+import { apiGet, isAbortError } from "@/utils/api";
+import { qk } from "@/utils/queryKeys";
+import {
+ formatNumber,
+ resolveNodeName,
+ truncateKey,
+ useFormatDateTime,
+} from "@/utils/format";
+import { groupByObserver } from "@/utils/packetGroupHelpers";
+import { Loading, WarningBadge } from "@/components/Alerts";
+import { Breadcrumbs } from "@/components/Breadcrumbs";
+import { IconSatelliteDish } from "@/components/icons";
+import { NotFoundState } from "@/components/NotFoundState";
+import { TimeAgo } from "@/components/TimeAgo";
+import { DefinitionGrid } from "@/components/Definition";
+import {
+ buildChannelNames,
+ isNotFoundError,
+ type ChannelItem,
+} from "@/utils/packets";
+import {
+ Field,
+ RedactedNotice,
+ RawHexBlock,
+ DecodedJsonBlock,
+ channelNameDisplay,
+} from "@/components/PacketParts";
+
+const PATH_MAX_BADGES = 16;
+const PATH_HEAD = 7;
+const PATH_TAIL = 7;
+const PATH_POPOVER_NODE_CAP = 8;
+
+interface Reception {
+ packet_id: string;
+ observed_by: string | null;
+ observer_name: string | null;
+ observer_tag_name: string | null;
+ path_hashes: string[] | null;
+ path_len: number | null;
+ snr: number | null;
+ received_at: string | null;
+}
+
+interface PacketGroupData {
+ packet_hash: string | null;
+ event_type: string | null;
+ channel_idx: number | null;
+ source_pubkey_prefix: string | null;
+ packet_type: number | null;
+ payload_type: number | null;
+ route_type: string | null;
+ reception_count: number;
+ observer_count: number;
+ first_seen: string | null;
+ redacted: boolean;
+ raw_hex: string | null;
+ decoded: unknown;
+ receptions: Reception[];
+}
+
+interface ChannelsResponse {
+ items: ChannelItem[];
+}
+
+interface NodeItem {
+ public_key: string;
+ name: string | null;
+ tags?: { key: string; value: string }[];
+}
+
+interface NodesResponse {
+ items: NodeItem[];
+ total: number;
+}
+
+interface PopoverAnchor {
+ hash: string;
+ left: number;
+ bottom: number;
+ top: number;
+}
+
+function Stat({ label, value }: { label: string; value: ReactNode }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function PathBadge({
+ hash,
+ onOpen,
+}: {
+ hash: string;
+ onOpen: (e: React.MouseEvent, hash: string) => void;
+}) {
+ return (
+ onOpen(e, hash)}
+ >
+ {hash}
+
+ );
+}
+
+function PathFlow({
+ reception,
+ sourcePrefix,
+ onBadgeOpen,
+}: {
+ reception: Reception;
+ sourcePrefix: string | null;
+ onBadgeOpen: (e: React.MouseEvent, hash: string) => void;
+}) {
+ const { t } = useTranslation();
+ const hashes = reception.path_hashes ?? [];
+
+ let middle: ReactNode[];
+ if (hashes.length > 0) {
+ if (hashes.length <= PATH_MAX_BADGES) {
+ middle = hashes.map((h, i) => (
+
+ ));
+ } else {
+ const hidden = hashes.length - PATH_HEAD - PATH_TAIL;
+ middle = [
+ ...hashes
+ .slice(0, PATH_HEAD)
+ .map((h, i) => (
+
+ )),
+
+ …
+ ,
+ ...hashes
+ .slice(-PATH_TAIL)
+ .map((h, i) => (
+
+ )),
+ ];
+ }
+ } else if (reception.path_len != null) {
+ middle = [
+
+ {reception.path_len} {t("common.hops").toLowerCase()}
+ ,
+ ];
+ } else {
+ middle = [
+
+ —
+ ,
+ ];
+ }
+
+ const parts: ReactNode[] = [
+ ,
+ ...middle,
+
+
+ ,
+ ];
+
+ const joined: ReactNode[] = [];
+ parts.forEach((part, i) => {
+ if (i > 0) {
+ joined.push(
+
+ →
+ ,
+ );
+ }
+ joined.push(part);
+ });
+
+ return {joined};
+}
+
+export function PacketGroupDetail() {
+ const { t } = useTranslation();
+ usePageTitle("packets.detail_title");
+ const { hash } = useParams();
+ const { formatDateTime } = useFormatDateTime();
+
+ const [popover, setPopover] = useState(null);
+ const [popoverPos, setPopoverPos] = useState<{
+ left: number;
+ top: number;
+ } | null>(null);
+ const [popoverNodes, setPopoverNodes] = useState(null);
+ const [popoverTotal, setPopoverTotal] = useState(0);
+ const [popoverError, setPopoverError] = useState(null);
+ const popoverRef = useRef(null);
+
+ const groupQuery = useQuery({
+ queryKey: qk.packets.group(hash ?? ""),
+ queryFn: ({ signal }) =>
+ apiGet(`/api/v1/packet-groups/${hash}`, {}, { signal }),
+ enabled: !!hash,
+ });
+ const channelsQuery = useQuery({
+ queryKey: qk.channels.list({ limit: 200 }),
+ queryFn: ({ signal }) =>
+ apiGet("/api/v1/channels", { limit: 200 }, {
+ signal,
+ }).catch(() => ({ items: [] as ChannelItem[] })),
+ });
+
+ const group = groupQuery.data ?? null;
+ const channelNames = buildChannelNames(channelsQuery.data?.items || []);
+ const notFound = groupQuery.error
+ ? isNotFoundError(groupQuery.error)
+ : false;
+ const error =
+ groupQuery.error && !isNotFoundError(groupQuery.error)
+ ? groupQuery.error instanceof Error
+ ? groupQuery.error.message
+ : String(groupQuery.error)
+ : null;
+
+ useEffect(() => {
+ const onDocClick = (ev: MouseEvent) => {
+ if (
+ popoverRef.current &&
+ !popoverRef.current.contains(ev.target as Node)
+ ) {
+ setPopover(null);
+ }
+ };
+ const onKey = (ev: KeyboardEvent) => {
+ if (ev.key === "Escape") setPopover(null);
+ };
+ document.addEventListener("click", onDocClick);
+ document.addEventListener("keydown", onKey);
+ return () => {
+ document.removeEventListener("click", onDocClick);
+ document.removeEventListener("keydown", onKey);
+ };
+ }, []);
+
+ const popoverHash = popover?.hash ?? null;
+ useEffect(() => {
+ if (!popoverHash) return;
+ let cancelled = false;
+ setPopoverNodes(null);
+ setPopoverError(null);
+ apiGet("/api/v1/nodes", {
+ pubkey_prefix: popoverHash,
+ sort: "name",
+ order: "asc",
+ limit: PATH_POPOVER_NODE_CAP,
+ })
+ .then((data) => {
+ if (cancelled) return;
+ const items = (data.items || [])
+ .slice()
+ .sort((a, b) =>
+ resolveNodeName(a).localeCompare(resolveNodeName(b)),
+ );
+ setPopoverNodes(items);
+ setPopoverTotal(data.total || 0);
+ })
+ .catch((e) => {
+ if (cancelled || isAbortError(e)) return;
+ setPopoverError(e instanceof Error ? e.message : String(e));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [popoverHash]);
+
+ useLayoutEffect(() => {
+ if (!popover || !popoverRef.current) return;
+ const el = popoverRef.current;
+ const margin = 8;
+ const pw = el.offsetWidth || 256;
+ const ph = el.offsetHeight || 0;
+ let left = Math.min(popover.left, window.innerWidth - pw - margin);
+ if (left < margin) left = margin;
+ let top = popover.bottom + 4;
+ if (
+ top + ph + margin > window.innerHeight &&
+ popover.top - ph - 4 > margin
+ ) {
+ top = popover.top - ph - 4;
+ }
+ setPopoverPos({
+ left: left + window.scrollX,
+ top: top + window.scrollY,
+ });
+ }, [popover, popoverNodes, popoverError]);
+
+ const openPathPopover = (e: React.MouseEvent, pathHash: string) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
+ setPopoverPos(null);
+ setPopover({
+ hash: pathHash,
+ left: rect.left,
+ bottom: rect.bottom,
+ top: rect.top,
+ });
+ };
+
+ const leaf = group?.packet_hash || group?.event_type || "";
+ const receptions = group?.receptions ?? [];
+ const sourcePrefix = group?.source_pubkey_prefix ?? null;
+ const observerGroups = groupByObserver(receptions);
+ const moreCount = popoverTotal - (popoverNodes?.length ?? 0);
+
+ const channelDisplay = channelNameDisplay(
+ channelNames,
+ group?.channel_idx ?? null,
+ );
+
+ const receptionTime = (r: Reception) => ;
+
+ return (
+
+
+
+ {notFound && (
+
+ )}
+ {error && }
+ {!group && !notFound && !error && }
+
+ {group && (
+ <>
+ {group.redacted && }
+
+
+
+
+ {formatDateTime(group.first_seen)}
+
+
+ {group.event_type || "—"}
+
+ {channelDisplay}
+
+ {group.source_pubkey_prefix ? (
+
+ {group.source_pubkey_prefix}
+
+ ) : (
+ —
+ )}
+
+
+ {group.packet_hash ? (
+
+ {group.packet_hash}
+
+ ) : (
+ —
+ )}
+
+
+ {group.packet_type != null ? group.packet_type : "—"}
+
+
+ {group.payload_type != null ? group.payload_type : "—"}
+
+
+ {group.route_type || "—"}
+
+
+ {formatNumber(group.reception_count)}{" "}
+ {group.reception_count === 1
+ ? t("packets.reception_singular")
+ : t("packets.reception_plural")}{" "}
+ · {formatNumber(group.observer_count)}{" "}
+ {t("common.observers").toLowerCase()}
+
+
+
+ {receptions.length > 0 && (
+
+
+ {t("packets.receptions_title")}
+
+ ({formatNumber(group.reception_count)}{" "}
+ {group.reception_count === 1
+ ? t("packets.reception_singular")
+ : t("packets.reception_plural")}
+ , {formatNumber(group.observer_count)}{" "}
+ {t("common.observers").toLowerCase()})
+
+
+ {[...observerGroups.entries()].map(([key, recs]) => {
+ const first = recs[0];
+ const displayName =
+ first.observer_tag_name ||
+ first.observer_name ||
+ (first.observed_by
+ ? first.observed_by.slice(0, 12) + "…"
+ : "—");
+ return (
+
+
+ {"\u{1F4E1}"}{" "}
+ {first.observed_by ? (
+
+ {displayName}
+
+ ) : (
+ displayName
+ )}
+ {recs.length > 1 && (
+
+ ({formatNumber(recs.length)}{" "}
+ {t("packets.reception_plural")})
+
+ )}
+
+
+
+ {recs.map((r) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+ {t("packets.col_path")}
+
+ {t("common.hops")}
+
+
+ {t("common.snr_db")}
+
+
+ {t("common.time")}
+
+
+
+
+ {recs.map((r) => (
+
+
+
+
+
+ {r.path_len != null ? r.path_len : "—"}
+
+
+ {r.snr != null
+ ? Number(r.snr).toFixed(1)
+ : "—"}
+
+
+ {receptionTime(r)}
+
+
+ ))}
+
+
+
+
+ );
+ })}
+
+ )}
+
+ {!group.redacted && group.raw_hex && (
+
+ )}
+
+ {!group.redacted && group.decoded != null && (
+
+ )}
+
+
+ >
+ )}
+
+ {popover &&
+ createPortal(
+
+
+
+ {t("packets.path_nodes_title", { hash: popover.hash })}
+
+
+
+
+ {popoverError ? (
+
+
+
+ ) : popoverNodes === null ? (
+
+
+
+ ) : popoverNodes.length === 0 ? (
+
+ {t("packets.path_no_nodes")}
+
+ ) : (
+
+ {popoverNodes.map((n) => (
+ -
+ setPopover(null)}
+ data-testid="path-node-link"
+ className="flex flex-col items-start gap-0"
+ >
+ {resolveNodeName(n)}
+
+ {truncateKey(n.public_key, 16)}
+
+
+
+ ))}
+ {moreCount > 0 && (
+ -
+ setPopover(null)}
+ data-testid="path-nodes-view-all"
+ className="text-xs opacity-70"
+ >
+ {t("packets.path_nodes_more", {
+ count: formatNumber(moreCount),
+ })}
+
+
+ )}
+
+ )}
+
+ ,
+ document.body,
+ )}
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Packets.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Packets.test.tsx
new file mode 100644
index 0000000..7124443
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Packets.test.tsx
@@ -0,0 +1,67 @@
+import { screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Packets } from "@/pages/Packets";
+import { renderWithProviders } from "@/test/renderWithProviders";
+import * as api from "@/utils/api";
+
+const GROUPS = {
+ items: [
+ {
+ packet_hash: "hash1",
+ event_type: "advert",
+ channel_idx: 17,
+ path_hash_bytes: null,
+ reception_count: 3,
+ observer_count: 2,
+ first_seen: "2024-01-01T00:00:00Z",
+ redacted: false,
+ receptions: [{ packet_id: "p1" }],
+ },
+ ],
+ total: 1,
+};
+
+function mockPacketsApi() {
+ vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
+ if (path.includes("/api/v1/packet-groups")) return GROUPS;
+ if (path.includes("/api/v1/channels")) return { items: [] };
+ throw new Error(`Unexpected: ${path}`);
+ });
+}
+
+describe("Packets", () => {
+ it("shows a loading spinner before data resolves", () => {
+ vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
+ renderWithProviders( );
+ expect(document.querySelector(".loading-spinner")).not.toBeNull();
+ });
+
+ it("renders packet group rows after data resolves", async () => {
+ mockPacketsApi();
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(screen.getByText("hash1")).toBeInTheDocument();
+ });
+ });
+
+ it("shows an error alert on fetch failure", async () => {
+ vi.spyOn(api, "apiGet").mockRejectedValue(new Error("network error"));
+ const { container } = renderWithProviders( );
+ await waitFor(() => {
+ expect(container.querySelector('[data-tip="network error"]')).not.toBeNull();
+ });
+ });
+
+ it("renders an empty state when no packets exist", async () => {
+ vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
+ if (path.includes("/api/v1/packet-groups")) return { items: [], total: 0 };
+ if (path.includes("/api/v1/channels")) return { items: [] };
+ throw new Error(`Unexpected: ${path}`);
+ });
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(screen.queryByText("hash1")).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx
new file mode 100644
index 0000000..42a3518
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx
@@ -0,0 +1,467 @@
+import { useMemo, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import { Link, useNavigate, useSearchParams } from "react-router";
+import { useAppConfig } from "@/context/AppConfigContext";
+import { usePageTitle } from "@/hooks/usePageTitle";
+import { useAutoRefresh } from "@/hooks/useAutoRefresh";
+import { apiGet } from "@/utils/api";
+import { qk } from "@/utils/queryKeys";
+import { formatNumber, useFormatDateTime } from "@/utils/format";
+import {
+ buildChannelList,
+ packetUrl,
+ type ChannelEntry,
+} from "@/utils/packetHelpers";
+import { Pagination } from "@/components/Pagination";
+import { FilterForm, FilterField } from "@/components/FilterForm";
+import {
+ MobileSortSelect,
+ SortableTableHeader,
+} from "@/components/SortableTable";
+import { Loading } from "@/components/Alerts";
+import { ListToolbar } from "@/components/ListToolbar";
+import { PageHeader } from "@/components/PageHeader";
+import { EmptyState, EmptyRow } from "@/components/EmptyState";
+import { IconPath, IconRuler, IconSatelliteDish } from "@/components/icons";
+
+const EVENT_TYPES = [
+ "advertisement",
+ "channel_msg_recv",
+ "contact_msg_recv",
+ "trace_data",
+ "telemetry_response",
+ "path_updated",
+ "status_response",
+ "req",
+ "response",
+ "ack",
+ "encrypted_direct",
+ "encrypted_channel",
+ "grp_data",
+ "anon_req",
+ "multipart",
+ "control",
+ "raw_custom",
+ "advert",
+ "path",
+ "trace",
+ "letsmesh_packet",
+];
+
+interface PacketGroupItem {
+ packet_hash: string | null;
+ event_type: string | null;
+ channel_idx: number | null;
+ path_hash_bytes: number | null;
+ reception_count: number | null;
+ observer_count: number | null;
+ first_seen: string | null;
+ redacted?: boolean;
+ receptions?: { packet_id: string }[];
+}
+
+interface PacketGroupsResponse {
+ items: PacketGroupItem[];
+ total: number;
+}
+
+interface ChannelItem {
+ name: string;
+ channel_hash: string;
+}
+
+interface ChannelsResponse {
+ items: ChannelItem[];
+}
+
+function ChannelLabel({
+ packet,
+ channelNames,
+}: {
+ packet: PacketGroupItem;
+ channelNames: Map;
+}) {
+ const { t } = useTranslation();
+ if (packet.channel_idx == null) return —;
+ const name = channelNames.get(packet.channel_idx);
+ const text = name ? `${name} (${packet.channel_idx})` : `${packet.channel_idx}`;
+ return (
+ <>
+ {text}
+ {packet.redacted && (
+ <>
+ {" "}
+
+ {"\u{1F512}"}
+
+ >
+ )}
+ >
+ );
+}
+
+function ReceptionBadge({ packet }: { packet: PacketGroupItem }) {
+ const { t } = useTranslation();
+ const rc = packet.reception_count ?? 1;
+ const oc = packet.observer_count ?? 1;
+ const pb = packet.path_hash_bytes;
+ const knownWidth = pb != null && pb > 0;
+ const widthLabel = knownWidth
+ ? t("packets.path_width_bytes", { count: pb })
+ : t("packets.path_width_unknown");
+ return (
+
+
+
+ {formatNumber(oc)}
+
+
+
+
+ {formatNumber(rc)}
+
+
+
+
+ {widthLabel}
+
+
+ );
+}
+
+export function Packets() {
+ const { t } = useTranslation();
+ usePageTitle("entities.packets");
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const config = useAppConfig();
+ const { formatDateTime, formatDateTimeShort } = useFormatDateTime();
+
+ const search = searchParams.get("search") ?? "";
+ const eventType = searchParams.get("event_type") ?? "";
+ const channelIdx = searchParams.get("channel_idx") ?? "";
+ const pathHashBytes = searchParams.get("path_hash_bytes") ?? "";
+ const page = parseInt(searchParams.get("page") ?? "", 10) || 1;
+ const limit = parseInt(searchParams.get("limit") ?? "", 10) || 20;
+ const sort = searchParams.get("sort") ?? "time";
+ const order = searchParams.get("order") ?? "desc";
+ const offset = (page - 1) * limit;
+
+ const hasActiveFilters =
+ search !== "" ||
+ eventType !== "" ||
+ channelIdx !== "" ||
+ pathHashBytes !== "";
+ const [filterOpen, setFilterOpen] = useState(hasActiveFilters);
+
+ const autoRefresh = useAutoRefresh();
+
+ const { data, error: queryError } = useQuery({
+ queryKey: qk.packets.groups({
+ search,
+ eventType,
+ channelIdx,
+ pathHashBytes,
+ limit,
+ offset,
+ sort,
+ order,
+ }),
+ refetchInterval: autoRefresh.refetchInterval,
+ queryFn: async ({ signal }) => {
+ const apiParams: Record = {
+ limit,
+ offset,
+ search,
+ sort,
+ order,
+ };
+ if (eventType) apiParams.event_type = eventType;
+ if (channelIdx !== "") apiParams.channel_idx = channelIdx;
+ if (pathHashBytes !== "") apiParams.path_hash_bytes = pathHashBytes;
+
+ const [groupsData, channelsData] = await Promise.all([
+ apiGet("/api/v1/packet-groups", apiParams, {
+ signal,
+ }),
+ apiGet("/api/v1/channels", { limit: 200 }, {
+ signal,
+ }).catch(() => ({ items: [] as ChannelItem[] })),
+ ]);
+
+ return {
+ packets: groupsData.items || [],
+ total: groupsData.total || 0,
+ channels: buildChannelList(channelsData.items || []),
+ };
+ },
+ });
+ const error = queryError ? queryError.message : null;
+
+ const packets = data?.packets ?? null;
+ const total = data?.total ?? 0;
+ const channels = data?.channels ?? [];
+
+ const channelNames = useMemo(
+ () => new Map(channels.map((c) => [c.idx, c.name])),
+ [channels],
+ );
+
+ const applyFilters = (overrides: Record) => {
+ const next = {
+ search,
+ event_type: eventType,
+ channel_idx: channelIdx,
+ path_hash_bytes: pathHashBytes,
+ ...overrides,
+ };
+ const params = new URLSearchParams();
+ for (const [k, v] of Object.entries(next)) {
+ if (v) params.set(k, v);
+ }
+ const qs = params.toString();
+ navigate(qs ? `/packets?${qs}` : "/packets");
+ };
+
+ const totalPages = Math.ceil(total / limit);
+ const filterParams: Record = {
+ search,
+ event_type: eventType,
+ channel_idx: channelIdx,
+ path_hash_bytes: pathHashBytes,
+ limit: String(limit),
+ };
+ const noneFound = t("common.no_entity_found", {
+ entity: t("entities.packets").toLowerCase(),
+ });
+
+ return (
+
+
+
+ setFilterOpen((o) => !o) }}
+ />
+
+ {filterOpen && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {packets === null ? (
+
+ ) : (
+ <>
+
+
+
+ {packets.length === 0 ? (
+ {noneFound}
+ ) : (
+ packets.map((p, i) => (
+
+
+
+
+
+ {p.event_type || "—"}
+
+
+
+
+
+
+
+ {formatDateTimeShort(p.first_seen)}
+
+
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+ {t("packets.packet_hash")}
+
+ {t("packets.col_receptions")}
+
+
+ {t("entities.channel")}
+
+
+
+ {packets.length === 0 ? (
+ {noneFound}
+ ) : (
+ packets.map((p, i) => (
+ navigate(packetUrl(p))}
+ >
+
+ {formatDateTime(p.first_seen)}
+
+
+ {p.packet_hash ? (
+
+ {p.packet_hash}
+
+ ) : (
+ —
+ )}
+
+
+
+
+
+ {p.event_type || "—"}
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Profile.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Profile.test.tsx
new file mode 100644
index 0000000..87f2dec
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Profile.test.tsx
@@ -0,0 +1,76 @@
+import { screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Profile } from "@/pages/Profile";
+import { renderWithProviders } from "@/test/renderWithProviders";
+import { makeConfig } from "@/test/makeConfig";
+import * as api from "@/utils/api";
+
+const PROFILE_DATA = {
+ id: "p1",
+ user_id: "user-123",
+ name: "Jane Operator",
+ callsign: "AB1CDE",
+ description: "Mesh enthusiast",
+ url: "https://example.com",
+ roles: ["operator"],
+ created_at: "2024-01-01T00:00:00Z",
+ nodes: [],
+};
+
+describe("Profile (public view)", () => {
+ it("shows a loading spinner before data resolves", () => {
+ vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
+ renderWithProviders( , {
+ route: "/profile/p1",
+ routePath: "/profile/:id",
+ });
+ expect(document.querySelector(".loading-spinner")).not.toBeNull();
+ });
+
+ it("renders profile fields after data resolves", async () => {
+ vi.spyOn(api, "apiGet").mockResolvedValue(PROFILE_DATA);
+ renderWithProviders( , {
+ route: "/profile/p1",
+ routePath: "/profile/:id",
+ });
+ await waitFor(() => {
+ expect(screen.getAllByText("Jane Operator").length).toBeGreaterThanOrEqual(1);
+ });
+ expect(screen.getAllByText("AB1CDE").length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("shows an error alert on fetch failure", async () => {
+ vi.spyOn(api, "apiGet").mockRejectedValue(new Error("profile error"));
+ renderWithProviders( , {
+ route: "/profile/p1",
+ routePath: "/profile/:id",
+ });
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toHaveTextContent("profile error");
+ });
+ });
+});
+
+describe("Profile (own view)", () => {
+ it("shows a login prompt when OIDC is disabled", async () => {
+ renderWithProviders( , { route: "/profile" });
+ await waitFor(() => {
+ expect(screen.getByText("auth.login")).toBeInTheDocument();
+ });
+ });
+
+ it("renders the edit form for a logged-in user", async () => {
+ vi.spyOn(api, "apiGet").mockResolvedValue(PROFILE_DATA);
+ renderWithProviders( , {
+ route: "/profile",
+ config: makeConfig({
+ oidc_enabled: true,
+ user: { sub: "user-123", name: "Jane" },
+ }),
+ });
+ await waitFor(() => {
+ expect(screen.getByDisplayValue("Jane Operator")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Profile.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Profile.tsx
new file mode 100644
index 0000000..fd02c17
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Profile.tsx
@@ -0,0 +1,344 @@
+import { type FormEvent } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Link, useNavigate, useParams, useSearchParams } from "react-router";
+import { useTranslation } from "react-i18next";
+import { useAppConfig } from "@/context/AppConfigContext";
+import { apiGet, apiPut } from "@/utils/api";
+import { qk, invalidate } from "@/utils/queryKeys";
+import { resolveNodeName, useFormatDateTime } from "@/utils/format";
+import { hasOperatorOrAdmin } from "@/utils/profileHelpers";
+import { Loading, ErrorAlert, SuccessAlert } from "@/components/Alerts";
+import { CallsignBadge, RoleBadge } from "@/components/Badges";
+import { Breadcrumbs } from "@/components/Breadcrumbs";
+import { PageHeader } from "@/components/PageHeader";
+import { TimeAgo } from "@/components/TimeAgo";
+import { usePageTitle } from "@/hooks/usePageTitle";
+
+interface ProfileNode {
+ public_key: string;
+ name?: string | null;
+ last_seen?: string | null;
+}
+
+interface UserProfileData {
+ id: string;
+ user_id?: string | null;
+ name?: string | null;
+ callsign?: string | null;
+ description?: string | null;
+ url?: string | null;
+ roles?: string[] | null;
+ created_at?: string | null;
+ nodes?: ProfileNode[] | null;
+}
+
+function RoleBadges({ roles }: { roles?: string[] | null }) {
+ if (!roles || roles.length === 0) return null;
+ return (
+
+ {roles.map((role) => (
+
+ ))}
+
+ );
+}
+
+function MemberSince({ createdAt }: { createdAt?: string | null }) {
+ const { t } = useTranslation();
+ const { formatDateTime } = useFormatDateTime();
+ if (!createdAt) return null;
+ return (
+
+ {t("user_profile.member_since", {
+ date: formatDateTime(createdAt, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }),
+ })}
+
+ );
+}
+
+function AdoptedNodeLink({ node }: { node: ProfileNode }) {
+ const displayName = resolveNodeName(node);
+
+ return (
+
+
+ {displayName}
+
+ {node.public_key}
+
+
+ {node.last_seen ? (
+
+ ) : (
+
+ -
+
+ )}
+
+ );
+}
+
+function AdoptedNodesCard({
+ profile,
+ className,
+}: {
+ profile: UserProfileData;
+ className?: string;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+ {t("user_profile.adopted_nodes")}
+ {profile.nodes && profile.nodes.length > 0 ? (
+
+ {profile.nodes.map((node) => (
+
+ ))}
+
+ ) : (
+
+ {t("user_profile.no_adopted_nodes")}
+
+ )}
+
+
+ );
+}
+
+function PublicProfileView({ id }: { id: string }) {
+ const { t } = useTranslation();
+ const config = useAppConfig();
+ const { data: profile, error: queryError } = useQuery({
+ queryKey: qk.profiles.detail(id),
+ queryFn: ({ signal }) =>
+ apiGet(`/api/v1/user/profile/${id}`, {}, { signal }),
+ });
+ const error = queryError ? queryError.message : null;
+
+ if (error) return ;
+ if (!profile) return ;
+
+ const isOwner =
+ !!config.user && !!profile.user_id && config.user.sub === profile.user_id;
+
+ return (
+ <>
+
+
+ {isOwner && (
+
+ {t("user_profile.edit_profile")}
+
+ )}
+
+
+
+
+
+ {profile.name || t("common.unnamed")}
+ {profile.callsign && }
+
+
+ {profile.description && (
+ {profile.description}
+ )}
+ {profile.url && (
+
+ {profile.url}
+
+ )}
+
+ {hasOperatorOrAdmin(profile.roles, config) && (
+
+ )}
+
+
+ >
+ );
+}
+
+function OwnProfileView() {
+ const { t } = useTranslation();
+ const config = useAppConfig();
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const queryClient = useQueryClient();
+ const { data: profile, error: queryError } = useQuery({
+ queryKey: qk.profiles.me(),
+ queryFn: ({ signal }) =>
+ apiGet("/api/v1/user/profile/me", {}, { signal }),
+ });
+ const error = queryError ? queryError.message : null;
+
+ const updateMutation = useMutation({
+ mutationFn: ({
+ id,
+ body,
+ }: {
+ id: string;
+ body: Record;
+ }) => apiPut(`/api/v1/user/profile/${id}`, body),
+ onSuccess: () => invalidate.profiles(queryClient),
+ });
+
+ if (!config.oidc_enabled || !config.user) {
+ return (
+
+ );
+ }
+
+ if (error) return ;
+ if (!profile) return ;
+
+ const flashMessage = searchParams.get("message") || "";
+ const flashError = searchParams.get("error") || "";
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ const data = new FormData(e.currentTarget);
+ const body = {
+ name: String(data.get("name") ?? "").trim() || null,
+ callsign: String(data.get("callsign") ?? "").trim() || null,
+ description: String(data.get("description") ?? "").trim() || null,
+ url: String(data.get("url") ?? "").trim() || null,
+ };
+ try {
+ await updateMutation.mutateAsync({ id: profile.id, body });
+ navigate(
+ "/profile?message=" + encodeURIComponent(t("user_profile.profile_updated")),
+ { replace: true },
+ );
+ } catch (err) {
+ navigate(
+ "/profile?error=" + encodeURIComponent((err as Error).message),
+ { replace: true },
+ );
+ }
+ };
+
+ return (
+ <>
+
+
+
+ {flashMessage ? (
+
+ ) : flashError ? (
+
+ ) : null}
+
+
+
+
+
+ {t("user_profile.your_profile")}
+
+
+
+
+
+
+
+ {hasOperatorOrAdmin(profile.roles, config) && (
+
+ )}
+
+ >
+ );
+}
+
+export function Profile() {
+ const { id } = useParams();
+ usePageTitle("links.profile");
+
+ return id ? : ;
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx
new file mode 100644
index 0000000..3cc7736
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx
@@ -0,0 +1,78 @@
+import { screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("@/components/charts/Charts", () => ({
+ ActivityChart: () => null,
+ TrendLineChart: () => null,
+ StackedBarChart: () => null,
+ RoutesTrendChart: () => null,
+ RouteDetailStrip: () => null,
+}));
+
+import { RoutesPage as Routes } from "@/pages/Routes";
+import { renderWithProviders } from "@/test/renderWithProviders";
+import * as api from "@/utils/api";
+
+const ROUTES = {
+ items: [
+ {
+ id: "r1",
+ from_label: "NodeA",
+ to_label: "NodeB",
+ description: "Primary route",
+ visibility: "community",
+ enabled: true,
+ reversible: false,
+ match_width: 60,
+ window_hours: 24,
+ quality_avg: "clear",
+ route_result: { quality: "clear", state: "healthy" },
+ route_nodes: [],
+ route_observers: [],
+ },
+ ],
+};
+
+const ROUTE_DETAIL = {
+ id: "r1",
+ from_label: "NodeA",
+ to_label: "NodeB",
+ recent_matches: [],
+};
+
+const ROUTE_HISTORY = {
+ buckets: [],
+};
+
+function mockRoutesApi() {
+ vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
+ if (path === "/api/v1/routes") return ROUTES;
+ if (path.match(/\/api\/v1\/routes\/[^/]+$/)) return ROUTE_DETAIL;
+ if (path.includes("/history")) return ROUTE_HISTORY;
+ throw new Error(`Unexpected: ${path}`);
+ });
+}
+
+describe("Routes", () => {
+ it("shows a loading state before data resolves", () => {
+ vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
+ renderWithProviders( );
+ expect(document.querySelector(".loading-spinner")).not.toBeNull();
+ });
+
+ it("renders route cards after data resolves", async () => {
+ mockRoutesApi();
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(screen.getAllByText("NodeA").length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ it("shows an error on fetch failure", async () => {
+ vi.spyOn(api, "apiGet").mockRejectedValue(new Error("routes error"));
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toHaveTextContent("routes error");
+ });
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx
new file mode 100644
index 0000000..4e1ddf4
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx
@@ -0,0 +1,1552 @@
+import {
+ Fragment,
+ useEffect,
+ useRef,
+ useState,
+ type SVGProps,
+} from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router";
+
+import { useAppConfig, hasRole } from "@/context/AppConfigContext";
+import { apiGet, apiPost, apiPut, apiDelete } from "@/utils/api";
+import { qk, invalidate } from "@/utils/queryKeys";
+import { usePageTitle } from "@/hooks/usePageTitle";
+import { Loading, ErrorAlert } from "@/components/Alerts";
+import { ConfirmDialog } from "@/components/ConfirmDialog";
+import { EmptyState } from "@/components/EmptyState";
+import { Modal } from "@/components/Modal";
+import { PageHeader } from "@/components/PageHeader";
+import { SectionGroup } from "@/components/SectionGroup";
+import { RouteDetailStrip } from "@/components/charts/Charts";
+import {
+ qualityOf,
+ qualityBadgeClass,
+ qualityLabel,
+ diagnosisText,
+} from "@/utils/routesHelpers";
+import {
+ IconClock,
+ IconEdit,
+ IconNodes,
+ IconPackets,
+ IconPath,
+ IconPlus,
+ IconRuler,
+ IconSatelliteDish,
+ IconTrash,
+} from "@/components/icons";
+
+interface RouteResultInfo {
+ quality?: string | null;
+ state?: string | null;
+ matched_count?: number | null;
+ threshold?: number | null;
+ effective_clear?: number | null;
+}
+
+interface RouteNodeInfo {
+ node_id?: string | null;
+ public_key?: string | null;
+ name?: string | null;
+ expected_hash?: string | null;
+}
+
+interface RouteObserverInfo {
+ public_key?: string | null;
+ name?: string | null;
+}
+
+interface RouteItem {
+ id: string;
+ from_label?: string | null;
+ to_label?: string | null;
+ description?: string | null;
+ visibility?: string | null;
+ enabled: boolean;
+ reversible?: boolean | null;
+ match_width?: number | null;
+ window_hours?: number | null;
+ packet_count_threshold?: number | null;
+ clear_threshold?: number | null;
+ max_hop_span?: number | null;
+ max_path_length?: number | null;
+ quality_avg?: string | null;
+ route_result?: RouteResultInfo | null;
+ route_nodes?: RouteNodeInfo[];
+ route_observers?: RouteObserverInfo[];
+}
+
+interface RouteListResponse {
+ items: RouteItem[];
+}
+
+interface MatchHop {
+ node_hash?: string | null;
+}
+
+interface RouteMatch {
+ packet_hash?: string | null;
+ received_at?: string | null;
+ hops?: MatchHop[];
+}
+
+interface RouteDetail {
+ recent_matches?: RouteMatch[];
+}
+
+interface HistoryDay {
+ date: string;
+ quality?: string | null;
+ matched_count?: number | null;
+}
+
+interface RouteHistory {
+ data?: HistoryDay[];
+}
+
+interface NodeSearchResult {
+ public_key: string;
+ name?: string | null;
+ adv_type?: string | null;
+}
+
+interface NodeListResponse {
+ items: NodeSearchResult[];
+}
+
+interface SelectedNode {
+ public_key: string;
+ name?: string | null;
+}
+
+
+interface ModalState {
+ type: "add" | "edit" | "delete";
+ route: RouteItem | null;
+ pathNodes: SelectedNode[];
+ observerNodes: SelectedNode[];
+ pathResults: NodeSearchResult[];
+ obsResults: NodeSearchResult[];
+ saving: boolean;
+}
+
+interface RouteFormValues {
+ from_label: string;
+ to_label: string;
+ description: string;
+ visibility: string;
+ match_width: number;
+ window_hours: string;
+ packet_count_threshold: string;
+ clear_threshold: string;
+ max_hop_span: string;
+ max_path_length: string;
+ enabled: boolean;
+ reversible: boolean;
+}
+
+type MatchEntry =
+ | { kind: "hop"; hop: MatchHop }
+ | { kind: "ellipsis"; hidden: number };
+
+type TranslateFn = ReturnType["t"];
+
+const VISIBILITY_ORDER = ["community", "member", "operator", "admin"];
+const PATH_MAX = 5;
+const PATH_HEAD = 2;
+const PATH_TAIL = 2;
+
+function qualityDot(quality: string, enabled: boolean): string {
+ if (!enabled) return "\u25CC";
+ const dots: Record = {
+ clear: "\u25CF",
+ marginal: "\u25CF",
+ failing: "\u25CF",
+ no_coverage: "\u25D0",
+ unknown: "\u25D0",
+ };
+ return dots[quality] || "\u25D0";
+}
+
+function IconRouteFrom(props: SVGProps) {
+ return (
+
+ );
+}
+
+function IconRouteTo(props: SVGProps) {
+ return (
+
+ );
+}
+
+function SummaryStrip({ routes }: { routes: RouteItem[] }) {
+ const { t } = useTranslation();
+ const counts = { clear: 0, marginal: 0, failing: 0, no_coverage: 0, disabled: 0 };
+ for (const r of routes) {
+ if (!r.enabled) {
+ counts.disabled++;
+ continue;
+ }
+ const q = qualityOf(r);
+ if (q === "clear") counts.clear++;
+ else if (q === "marginal") counts.marginal++;
+ else if (q === "failing") counts.failing++;
+ else counts.no_coverage++;
+ }
+ return (
+
+
+ {"\u25CF"} {counts.clear}{" "}
+ {t("routes.quality_clear")}
+
+
+ {"\u25CF"} {counts.marginal}{" "}
+ {t("routes.quality_marginal")}
+
+
+ {"\u25CF"} {counts.failing}{" "}
+ {t("routes.quality_failing")}
+
+
+ {"\u25D0"} {counts.no_coverage}{" "}
+ {t("routes.quality_no_coverage")}
+
+
+ {"\u25CC"} {counts.disabled} {t("routes.disabled")}
+
+
+ );
+}
+
+function PathChips({ route }: { route: RouteItem }) {
+ const nodes = route.route_nodes || [];
+ const arrow = route.reversible !== false ? "\u2194" : "\u2192";
+ const prefixLen = 2 * (route.match_width || 1);
+ return (
+
+ {nodes.map((rn, i) => (
+
+ {i > 0 && {arrow}}
+
+ {rn.name
+ ? `${rn.name} (${rn.public_key?.slice(0, prefixLen)})`
+ : rn.public_key?.slice(0, prefixLen) || rn.node_id?.slice(0, 8)}
+
+
+ ))}
+
+ );
+}
+
+function StatsRow({ route }: { route: RouteItem }) {
+ const { t } = useTranslation();
+ const result = route.route_result;
+ const matched = result?.matched_count ?? "?";
+ const threshold = result?.threshold ?? "?";
+ const degraded = result?.effective_clear ?? "?";
+ const nodeCount = (route.route_nodes || []).length;
+ const obsCount = (route.route_observers || []).length;
+
+ return (
+
+
+
+
+ {matched}/{threshold}
+ {"\u2192"}
+ {degraded}
+
+
+
+
+ {route.window_hours}h
+
+
+
+ {route.match_width}B
+
+
+
+ {nodeCount}
+
+
+
+ {route.max_hop_span || "\u221E"}
+
+
+
+ {route.max_path_length || "\u221E"}
+
+
+
+ {obsCount || "\u221E"}
+
+
+ );
+}
+
+function MatchRow({
+ match,
+ route,
+ packetsEnabled,
+ onNavigate,
+}: {
+ match: RouteMatch;
+ route: RouteItem;
+ packetsEnabled: boolean;
+ onNavigate: (url: string) => void;
+}) {
+ const { t } = useTranslation();
+ const prefixLen = 2 * (route.match_width || 1);
+ const pathLookup = new Map(
+ (route.route_nodes || []).map((rn) => [
+ (rn.expected_hash || "").toLowerCase(),
+ rn,
+ ]),
+ );
+ const detailUrl =
+ packetsEnabled && match.packet_hash
+ ? `/packets/hash/${match.packet_hash}`
+ : null;
+ const hops = match.hops || [];
+ const entries: MatchEntry[] = [];
+ if (hops.length > PATH_MAX) {
+ const hidden = hops.length - PATH_HEAD - PATH_TAIL;
+ for (const h of hops.slice(0, PATH_HEAD)) entries.push({ kind: "hop", hop: h });
+ entries.push({ kind: "ellipsis", hidden });
+ for (const h of hops.slice(-PATH_TAIL)) entries.push({ kind: "hop", hop: h });
+ } else {
+ for (const h of hops) entries.push({ kind: "hop", hop: h });
+ }
+
+ return (
+ {
+ e.stopPropagation();
+ onNavigate(detailUrl);
+ }
+ : undefined
+ }
+ >
+ {entries.map((entry, i) => {
+ if (entry.kind === "ellipsis") {
+ return (
+
+ {i > 0 && {"\u2192"}}
+
+ {"\u2026"}
+
+
+ );
+ }
+ const hash = (entry.hop.node_hash || "").toLowerCase();
+ const inPath = pathLookup.has(hash.slice(0, prefixLen));
+ return (
+
+ {i > 0 && {"\u2192"}}
+ {inPath ? (
+ {hash}
+ ) : (
+ {hash}
+ )}
+
+ );
+ })}
+ {match.received_at && (
+
+ {new Date(match.received_at).toLocaleString()}
+
+ )}
+
+ );
+}
+
+function DetailContent({
+ route,
+ detail,
+ history,
+ packetsEnabled,
+ onNavigate,
+}: {
+ route: RouteItem;
+ detail: RouteDetail;
+ history: RouteHistory | undefined;
+ packetsEnabled: boolean;
+ onNavigate: (url: string) => void;
+}) {
+ const { t } = useTranslation();
+ const matches = detail.recent_matches || [];
+ const historyData = history?.data ?? [];
+
+ return (
+
+ {history && (
+
+
+ {historyData.length > 0 && (
+
+ {historyData.map((d, i) => (
+
+ {i === historyData.length - 1
+ ? t("routes.last_n_hours", { n: route.window_hours })
+ : new Date(`${d.date}T00:00:00`).toLocaleDateString(
+ undefined,
+ { day: "2-digit", month: "2-digit" },
+ )}
+
+ ))}
+
+ )}
+
+ )}
+ {matches.length > 0 && (
+
+ {t("routes.recent_packets")}
+
+ {matches.map((m, i) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+function RouteCard({
+ route,
+ isAdmin,
+ packetsEnabled,
+ onEdit,
+ onDelete,
+ onNavigate,
+}: {
+ route: RouteItem;
+ isAdmin: boolean;
+ packetsEnabled: boolean;
+ onEdit: () => void;
+ onDelete: () => void;
+ onNavigate: (url: string) => void;
+}) {
+ const { t } = useTranslation();
+ const { data: detail } = useQuery({
+ queryKey: qk.routes.detail(route.id),
+ queryFn: ({ signal }) =>
+ apiGet(`/api/v1/routes/${route.id}`, {}, { signal }),
+ });
+ const { data: history } = useQuery({
+ queryKey: qk.routes.history(route.id, 6),
+ queryFn: ({ signal }) =>
+ apiGet(
+ `/api/v1/routes/${route.id}/history`,
+ { days: 6 },
+ { signal },
+ ),
+ });
+ const q = qualityOf(route);
+ const badgeCls = qualityBadgeClass(q, route.enabled);
+ const label = qualityLabel(q, route.enabled, t);
+ const dot = qualityDot(q, route.enabled);
+ const tip = diagnosisText(route, t);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {route.from_label}
+
+
+
+
+
+ {route.to_label}
+
+
+
+ {route.description && (
+ {route.description}
+ )}
+
+
+ {tip ? (
+
+ {dot} {label}
+
+ ) : (
+
+ {dot} {label}
+
+ )}
+
+
+
+
+
+
+ {detail ? (
+
+ ) : (
+
+
+
+ )}
+ {isAdmin && (
+
+
+
+
+ )}
+
+
+ );
+}
+
+function NodeSearchResultRow({
+ node,
+ onSelect,
+}: {
+ node: NodeSearchResult;
+ onSelect: () => void;
+}) {
+ const name = node.name || `${node.public_key.slice(0, 12)}\u2026`;
+ return (
+
+
+
+ );
+}
+
+interface RouteModalProps {
+ route: RouteItem | null;
+ isEdit: boolean;
+ pathNodes: SelectedNode[];
+ observerNodes: SelectedNode[];
+ pathResults: NodeSearchResult[];
+ obsResults: NodeSearchResult[];
+ saving: boolean;
+ onPathSearch: (query: string) => void;
+ onPathSelect: (node: NodeSearchResult) => void;
+ onPathRemove: (index: number) => void;
+ onPathMove: (index: number, dir: number) => void;
+ onPathEnter: (query: string) => void;
+ onObsSearch: (query: string) => void;
+ onObsSelect: (node: NodeSearchResult) => void;
+ onObsRemove: (index: number) => void;
+ onObsEnter: (query: string) => void;
+ onSubmit: (values: RouteFormValues) => void;
+ onCancel: () => void;
+}
+
+function RouteModal({
+ route,
+ isEdit,
+ pathNodes,
+ observerNodes,
+ pathResults,
+ obsResults,
+ saving,
+ onPathSearch,
+ onPathSelect,
+ onPathRemove,
+ onPathMove,
+ onPathEnter,
+ onObsSearch,
+ onObsSelect,
+ onObsRemove,
+ onObsEnter,
+ onSubmit,
+ onCancel,
+}: RouteModalProps) {
+ const { t } = useTranslation();
+ const [fromLabel, setFromLabel] = useState(route?.from_label ?? "");
+ const [toLabel, setToLabel] = useState(route?.to_label ?? "");
+ const [description, setDescription] = useState(route?.description ?? "");
+ const [visibility, setVisibility] = useState(
+ route?.visibility || "community",
+ );
+ const [matchWidth, setMatchWidth] = useState(route?.match_width || 1);
+ const [pathQuery, setPathQuery] = useState("");
+ const [obsQuery, setObsQuery] = useState("");
+ const [windowHours, setWindowHours] = useState(
+ String(route?.window_hours || 48),
+ );
+ const [threshold, setThreshold] = useState(
+ String(route?.packet_count_threshold || 5),
+ );
+ const [clearThreshold, setClearThreshold] = useState(
+ route?.clear_threshold ? String(route.clear_threshold) : "",
+ );
+ const [hopSpan, setHopSpan] = useState(
+ route ? (route.max_hop_span ? String(route.max_hop_span) : "") : "8",
+ );
+ const [pathLength, setPathLength] = useState(
+ route?.max_path_length ? String(route.max_path_length) : "",
+ );
+ const [enabled, setEnabled] = useState(route?.enabled !== false);
+ const [reversible, setReversible] = useState(route?.reversible !== false);
+
+ const selectedPathKeys = new Set(pathNodes.map((n) => n.public_key));
+ const selectedObsKeys = new Set(observerNodes.map((n) => n.public_key));
+ const availPathResults = pathResults.filter(
+ (n) => !selectedPathKeys.has(n.public_key),
+ );
+ const availObsResults = obsResults.filter(
+ (n) => !selectedObsKeys.has(n.public_key),
+ );
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ onSubmit({
+ from_label: fromLabel,
+ to_label: toLabel,
+ description,
+ visibility,
+ match_width: matchWidth,
+ window_hours: windowHours,
+ packet_count_threshold: threshold,
+ clear_threshold: clearThreshold,
+ max_hop_span: hopSpan,
+ max_path_length: pathLength,
+ enabled,
+ reversible,
+ });
+ };
+
+ const handlePathKeydown = (e: React.KeyboardEvent) => {
+ if (e.key !== "Enter") return;
+ e.preventDefault();
+ const first = availPathResults[0];
+ if (first) {
+ onPathSelect(first);
+ setPathQuery("");
+ return;
+ }
+ onPathEnter(pathQuery);
+ setPathQuery("");
+ };
+
+ const handleObsKeydown = (e: React.KeyboardEvent) => {
+ if (e.key !== "Enter") return;
+ e.preventDefault();
+ const first = availObsResults[0];
+ if (first) {
+ onObsSelect(first);
+ setObsQuery("");
+ return;
+ }
+ onObsEnter(obsQuery);
+ setObsQuery("");
+ };
+
+ return (
+
+
+
+ );
+}
+
+function DeleteRouteModal({
+ route,
+ saving,
+ onConfirm,
+ onCancel,
+}: {
+ route: RouteItem;
+ saving: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+}) {
+ const { t } = useTranslation();
+ const arrow = route.reversible !== false ? "\u2194" : "\u2192";
+ const label = `${route.from_label} ${arrow} ${route.to_label}`;
+
+ return (
+ {t("routes.delete_confirm", { label })}}
+ confirmLabel={t("common.delete")}
+ cancelLabel={t("common.cancel")}
+ saving={saving}
+ onConfirm={onConfirm}
+ onCancel={onCancel}
+ />
+ );
+}
+
+export function RoutesPage() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const config = useAppConfig();
+ const packetsEnabled = config.features?.packets !== false;
+ const isAdmin = hasRole("admin");
+ usePageTitle("routes.title");
+
+ const queryClient = useQueryClient();
+
+ const {
+ data: routesData,
+ isLoading: loading,
+ error: queryError,
+ } = useQuery({
+ queryKey: qk.routes.list(),
+ queryFn: async ({ signal }) => {
+ const data = await apiGet(
+ "/api/v1/routes",
+ {},
+ { signal },
+ );
+ return data.items || [];
+ },
+ });
+ const routes = routesData ?? [];
+ const error = queryError ? queryError.message : null;
+ const [modal, setModal] = useState(null);
+
+ const pathTimerRef = useRef | null>(null);
+ const obsTimerRef = useRef | null>(null);
+ const pathSearchIdRef = useRef(0);
+ const obsSearchIdRef = useRef(0);
+
+ const saveMutation = useMutation({
+ mutationFn: async ({
+ id,
+ body,
+ }: {
+ id?: string;
+ body: Record;
+ }) => {
+ if (id) {
+ await apiPut(`/api/v1/routes/${id}`, body);
+ } else {
+ await apiPost("/api/v1/routes", body);
+ }
+ },
+ onSuccess: () => invalidate.routes(queryClient),
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => apiDelete(`/api/v1/routes/${id}`),
+ onSuccess: () => invalidate.routes(queryClient),
+ });
+
+ useEffect(() => {
+ return () => {
+ if (pathTimerRef.current) clearTimeout(pathTimerRef.current);
+ if (obsTimerRef.current) clearTimeout(obsTimerRef.current);
+ };
+ }, []);
+
+ const openAddModal = () => {
+ setModal({
+ type: "add",
+ route: null,
+ pathNodes: [],
+ observerNodes: [],
+ pathResults: [],
+ obsResults: [],
+ saving: false,
+ });
+ };
+
+ const openEditModal = (route: RouteItem) => {
+ setModal({
+ type: "edit",
+ route,
+ pathNodes: (route.route_nodes || []).map((rn) => ({
+ public_key: rn.public_key ?? "",
+ name: rn.name,
+ })),
+ observerNodes: (route.route_observers || []).map((ro) => ({
+ public_key: ro.public_key ?? "",
+ name: ro.name,
+ })),
+ pathResults: [],
+ obsResults: [],
+ saving: false,
+ });
+ };
+
+ const openDeleteModal = (route: RouteItem) => {
+ setModal({
+ type: "delete",
+ route,
+ pathNodes: [],
+ observerNodes: [],
+ pathResults: [],
+ obsResults: [],
+ saving: false,
+ });
+ };
+
+ const handlePathSearch = (query: string) => {
+ if (pathTimerRef.current) clearTimeout(pathTimerRef.current);
+ const q = query.trim();
+ if (q.length < 2) {
+ setModal((m) => (m ? { ...m, pathResults: [] } : m));
+ return;
+ }
+ pathTimerRef.current = setTimeout(async () => {
+ const myId = ++pathSearchIdRef.current;
+ try {
+ const data = await apiGet("/api/v1/nodes", {
+ search: q,
+ limit: 10,
+ });
+ if (myId !== pathSearchIdRef.current) return;
+ setModal((m) => (m ? { ...m, pathResults: data.items || [] } : m));
+ } catch (_) {}
+ }, 300);
+ };
+
+ const handlePathSelect = (node: NodeSearchResult) => {
+ setModal((m) => {
+ if (!m) return m;
+ if (m.pathNodes.some((n) => n.public_key === node.public_key)) return m;
+ return {
+ ...m,
+ pathNodes: [
+ ...m.pathNodes,
+ { public_key: node.public_key, name: node.name },
+ ],
+ pathResults: [],
+ };
+ });
+ };
+
+ const handlePathRemove = (index: number) => {
+ setModal((m) => {
+ if (!m) return m;
+ const next = [...m.pathNodes];
+ next.splice(index, 1);
+ return { ...m, pathNodes: next };
+ });
+ };
+
+ const handlePathMove = (index: number, dir: number) => {
+ setModal((m) => {
+ if (!m) return m;
+ const newIndex = index + dir;
+ if (newIndex < 0 || newIndex >= m.pathNodes.length) return m;
+ const next = [...m.pathNodes];
+ [next[index], next[newIndex]] = [next[newIndex], next[index]];
+ return { ...m, pathNodes: next };
+ });
+ };
+
+ const handlePathEnter = async (query: string) => {
+ const q = query.trim();
+ if (q.length < 2) return;
+ if (pathTimerRef.current) clearTimeout(pathTimerRef.current);
+ const myId = ++pathSearchIdRef.current;
+ try {
+ const data = await apiGet("/api/v1/nodes", {
+ search: q,
+ limit: 10,
+ });
+ if (myId !== pathSearchIdRef.current) return;
+ const items = data.items || [];
+ const selectedKeys = new Set(
+ (modal?.pathNodes ?? []).map((n) => n.public_key),
+ );
+ setModal((m) => (m ? { ...m, pathResults: items } : m));
+ const first = items.find((n) => !selectedKeys.has(n.public_key));
+ if (first) handlePathSelect(first);
+ } catch (_) {}
+ };
+
+ const handleObsSearch = (query: string) => {
+ if (obsTimerRef.current) clearTimeout(obsTimerRef.current);
+ const q = query.trim();
+ if (q.length < 2) {
+ setModal((m) => (m ? { ...m, obsResults: [] } : m));
+ return;
+ }
+ obsTimerRef.current = setTimeout(async () => {
+ const myId = ++obsSearchIdRef.current;
+ try {
+ const data = await apiGet("/api/v1/nodes", {
+ search: q,
+ limit: 10,
+ observer: true,
+ });
+ if (myId !== obsSearchIdRef.current) return;
+ setModal((m) => (m ? { ...m, obsResults: data.items || [] } : m));
+ } catch (_) {}
+ }, 300);
+ };
+
+ const handleObsSelect = (node: NodeSearchResult) => {
+ setModal((m) => {
+ if (!m) return m;
+ if (m.observerNodes.some((n) => n.public_key === node.public_key))
+ return m;
+ return {
+ ...m,
+ observerNodes: [
+ ...m.observerNodes,
+ { public_key: node.public_key, name: node.name },
+ ],
+ obsResults: [],
+ };
+ });
+ };
+
+ const handleObsRemove = (index: number) => {
+ setModal((m) => {
+ if (!m) return m;
+ const next = [...m.observerNodes];
+ next.splice(index, 1);
+ return { ...m, observerNodes: next };
+ });
+ };
+
+ const handleObsEnter = async (query: string) => {
+ const q = query.trim();
+ if (q.length < 2) return;
+ if (obsTimerRef.current) clearTimeout(obsTimerRef.current);
+ const myId = ++obsSearchIdRef.current;
+ try {
+ const data = await apiGet("/api/v1/nodes", {
+ search: q,
+ limit: 10,
+ observer: true,
+ });
+ if (myId !== obsSearchIdRef.current) return;
+ const items = data.items || [];
+ const selectedKeys = new Set(
+ (modal?.observerNodes ?? []).map((n) => n.public_key),
+ );
+ setModal((m) => (m ? { ...m, obsResults: items } : m));
+ const first = items.find((n) => !selectedKeys.has(n.public_key));
+ if (first) handleObsSelect(first);
+ } catch (_) {}
+ };
+
+ const handleSave = async (values: RouteFormValues) => {
+ if (!modal || modal.type === "delete") return;
+ if (modal.pathNodes.length < 2) {
+ alert(t("routes.min_nodes_error"));
+ return;
+ }
+ const isEdit = modal.type === "edit" && modal.route !== null;
+ const body: Record = {
+ from_label: values.from_label.trim(),
+ to_label: values.to_label.trim(),
+ description: values.description.trim() || null,
+ visibility: values.visibility,
+ match_width: values.match_width || 1,
+ window_hours: parseInt(values.window_hours, 10) || 48,
+ packet_count_threshold: parseInt(values.packet_count_threshold, 10) || 5,
+ max_hop_span: values.max_hop_span
+ ? parseInt(values.max_hop_span, 10)
+ : null,
+ max_path_length: values.max_path_length
+ ? parseInt(values.max_path_length, 10)
+ : null,
+ enabled: values.enabled,
+ reversible: values.reversible,
+ node_public_keys: modal.pathNodes.map((n) => n.public_key),
+ observer_public_keys: modal.observerNodes.map((n) => n.public_key),
+ };
+ if (values.clear_threshold.trim()) {
+ body.clear_threshold = parseInt(values.clear_threshold, 10);
+ }
+ setModal((m) => (m ? { ...m, saving: true } : m));
+ try {
+ await saveMutation.mutateAsync({
+ id: isEdit && modal.route ? modal.route.id : undefined,
+ body,
+ });
+ setModal(null);
+ } catch (e) {
+ setModal((m) => (m ? { ...m, saving: false } : m));
+ alert((e as Error).message || "Failed to save route");
+ }
+ };
+
+ const handleDeleteConfirm = async () => {
+ if (!modal || modal.type !== "delete" || !modal.route) return;
+ setModal((m) => (m ? { ...m, saving: true } : m));
+ try {
+ await deleteMutation.mutateAsync(modal.route.id);
+ setModal(null);
+ } catch (e) {
+ setModal((m) => (m ? { ...m, saving: false } : m));
+ alert((e as Error).message || "Failed to delete route");
+ }
+ };
+
+ if (loading) return ;
+
+ const groups = new Map();
+ for (const vis of VISIBILITY_ORDER) groups.set(vis, []);
+ for (const r of routes) {
+ const vis = r.visibility || "community";
+ if (!groups.has(vis)) groups.set(vis, []);
+ groups.get(vis)!.push(r);
+ }
+
+ return (
+
+
+
+ {t("routes.title")}
+
+ }
+ />
+
+
+
+ {error && }
+
+ {isAdmin && (
+
+
+
+ )}
+
+ {routes.length === 0 && (
+
+ {t("common.no_entity_found", {
+ entity: t("entities.routes").toLowerCase(),
+ })}
+
+ )}
+
+ {VISIBILITY_ORDER.map((vis) => {
+ const group = (groups.get(vis) || []).slice().sort((a, b) => {
+ const cmp = (a.from_label || "").localeCompare(b.from_label || "");
+ return cmp !== 0
+ ? cmp
+ : (a.to_label || "").localeCompare(b.to_label || "");
+ });
+ if (group.length === 0) return null;
+ return (
+
+
+ {group.map((r) => (
+ openEditModal(r)}
+ onDelete={() => openDeleteModal(r)}
+ onNavigate={navigate}
+ />
+ ))}
+
+
+ );
+ })}
+
+ {modal && (modal.type === "add" || modal.type === "edit") && (
+ setModal(null)}
+ />
+ )}
+
+ {modal?.type === "delete" && modal.route && (
+ setModal(null)}
+ />
+ )}
+
+ );
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/test/apiMock.ts b/src/meshcore_hub/web/static/js/spa-react/test/apiMock.ts
new file mode 100644
index 0000000..23ca21f
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/test/apiMock.ts
@@ -0,0 +1,29 @@
+import { vi } from "vitest";
+
+import * as api from "@/utils/api";
+
+export type ApiGetMap = Record;
+
+export function mockApiGet(responses: ApiGetMap) {
+ return vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
+ const base = path.split("?")[0];
+ if (base in responses) return responses[base];
+ throw new Error(`Unexpected apiGet path: ${path}`);
+ });
+}
+
+export function mockApiGetError(error: Error) {
+ return vi.spyOn(api, "apiGet").mockRejectedValue(error);
+}
+
+export function mockApiPost(response: unknown = null) {
+ return vi.spyOn(api, "apiPost").mockResolvedValue(response);
+}
+
+export function mockApiPut(response: unknown = null) {
+ return vi.spyOn(api, "apiPut").mockResolvedValue(response);
+}
+
+export function mockApiDelete() {
+ return vi.spyOn(api, "apiDelete").mockResolvedValue(undefined);
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/test/makeConfig.ts b/src/meshcore_hub/web/static/js/spa-react/test/makeConfig.ts
new file mode 100644
index 0000000..e6566e9
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/test/makeConfig.ts
@@ -0,0 +1,28 @@
+import type { AppConfig } from "@/types/config";
+
+export function makeConfig(overrides: Partial = {}): AppConfig {
+ return {
+ network_name: "TestNet",
+ features: {},
+ custom_pages: [],
+ logo_url: "/logo.svg",
+ version: "1.0.0",
+ timezone: "UTC",
+ timezone_iana: "UTC",
+ default_theme: "dark",
+ locale: "en",
+ datetime_locale: "en-US",
+ auto_refresh_seconds: 30,
+ channel_labels: {},
+ logo_invert_light: false,
+ debug: false,
+ locale_version: "",
+ system_maintenance: false,
+ spam_score_threshold: 0,
+ oidc_enabled: false,
+ user: null,
+ roles: [],
+ role_names: {},
+ ...overrides,
+ };
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/test/renderWithProviders.tsx b/src/meshcore_hub/web/static/js/spa-react/test/renderWithProviders.tsx
new file mode 100644
index 0000000..1098b19
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/test/renderWithProviders.tsx
@@ -0,0 +1,57 @@
+import type { ReactElement, ReactNode } from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { MemoryRouter, Route, Routes } from "react-router";
+import { render, type RenderOptions } from "@testing-library/react";
+
+import { AppConfigProvider } from "@/context/AppConfigContext";
+import { makeConfig } from "@/test/makeConfig";
+import type { AppConfig } from "@/types/config";
+
+export function createTestQueryClient(): QueryClient {
+ return new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, staleTime: Infinity, gcTime: Infinity },
+ mutations: { retry: false },
+ },
+ });
+}
+
+interface ProviderOptions {
+ config?: AppConfig;
+ client?: QueryClient;
+ route?: string;
+ routePath?: string;
+ renderOptions?: Omit;
+}
+
+export function renderWithProviders(
+ ui: ReactElement,
+ options: ProviderOptions = {},
+) {
+ const {
+ config = makeConfig(),
+ client = createTestQueryClient(),
+ route = "/",
+ routePath,
+ renderOptions,
+ } = options;
+
+ function Wrapper({ children }: { children: ReactNode }) {
+ const content = routePath ? (
+
+
+
+ ) : (
+ children
+ );
+ return (
+
+
+ {content}
+
+
+ );
+ }
+
+ return { client, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/test/setup.ts b/src/meshcore_hub/web/static/js/spa-react/test/setup.ts
new file mode 100644
index 0000000..bd422d2
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/test/setup.ts
@@ -0,0 +1,31 @@
+import "@testing-library/jest-dom/vitest";
+import { cleanup } from "@testing-library/react";
+import { afterEach, beforeEach, vi } from "vitest";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
+}));
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+window.t = (key: string) => key;
+
+if (!window.matchMedia) {
+ window.matchMedia = (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ });
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/types/config.ts b/src/meshcore_hub/web/static/js/spa-react/types/config.ts
new file mode 100644
index 0000000..5dc9db9
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/types/config.ts
@@ -0,0 +1,65 @@
+export interface RadioConfigDisplay {
+ profile?: string;
+ frequency?: string;
+ bandwidth?: string;
+ spreading_factor?: string;
+ coding_rate?: string;
+ tx_power?: string;
+}
+
+export interface CustomPage {
+ slug: string;
+ title: string;
+ url: string;
+ menu_order: number;
+}
+
+export interface OidcUser {
+ sub: string;
+ name?: string;
+ email?: string;
+ picture?: string;
+}
+
+export interface AppConfig {
+ network_name: string;
+ network_city?: string;
+ network_country?: string;
+ network_radio_config?: RadioConfigDisplay;
+ network_contact_email?: string;
+ network_contact_discord?: string;
+ network_contact_github?: string;
+ network_contact_youtube?: string;
+ network_welcome_text?: string;
+ features: Record;
+ custom_pages: CustomPage[];
+ logo_url: string;
+ version: string;
+ timezone: string;
+ timezone_iana: string;
+ default_theme: string;
+ locale: string;
+ datetime_locale: string;
+ auto_refresh_seconds: number;
+ channel_labels: Record;
+ logo_invert_light: boolean;
+ debug: boolean;
+ locale_version: string;
+ system_maintenance: boolean;
+ spam_score_threshold: number;
+ oidc_enabled: boolean;
+ user: OidcUser | null;
+ roles: string[];
+ role_names: Record;
+ system_announcement?: string | null;
+ network_announcement?: string | null;
+}
+
+declare global {
+ interface Window {
+ __APP_CONFIG__: AppConfig;
+ t: (key: string, params?: Record) => string;
+ }
+}
+
+export {};
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/api.ts b/src/meshcore_hub/web/static/js/spa-react/utils/api.ts
new file mode 100644
index 0000000..1b89017
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/api.ts
@@ -0,0 +1,100 @@
+import type { AppConfig } from "@/types/config";
+
+export function isAbortError(e: unknown): boolean {
+ return e instanceof DOMException && e.name === "AbortError";
+}
+
+function checkAuthResponse(response: Response): void {
+ const config: AppConfig | undefined = window.__APP_CONFIG__;
+ if (config?.oidc_enabled && response.status === 401) {
+ const next = encodeURIComponent(
+ window.location.pathname + window.location.search,
+ );
+ window.location.href = `/auth/login?next=${next}`;
+ }
+}
+
+export async function apiGet(
+ path: string,
+ params: Record = {},
+ { signal }: { signal?: AbortSignal } = {},
+): Promise {
+ const url = new URL(path, window.location.origin);
+ for (const [k, v] of Object.entries(params)) {
+ if (v !== null && v !== undefined && v !== "") {
+ if (Array.isArray(v)) {
+ v.forEach((item) => url.searchParams.append(k, String(item)));
+ } else {
+ url.searchParams.set(k, String(v));
+ }
+ }
+ }
+ const response = await fetch(url, { signal });
+ if (!response.ok) {
+ throw new Error(`API error: ${response.status} ${response.statusText}`);
+ }
+ return response.json();
+}
+
+export async function apiPost(
+ path: string,
+ body: unknown,
+): Promise {
+ const response = await fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ checkAuthResponse(response);
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`API error: ${response.status} - ${text}`);
+ }
+ if (response.status === 204) return null;
+ return response.json();
+}
+
+export async function apiPut(
+ path: string,
+ body: unknown,
+): Promise {
+ const response = await fetch(path, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ checkAuthResponse(response);
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`API error: ${response.status} - ${text}`);
+ }
+ if (response.status === 204) return null;
+ return response.json();
+}
+
+export async function apiDelete(path: string): Promise {
+ const response = await fetch(path, { method: "DELETE" });
+ checkAuthResponse(response);
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`API error: ${response.status} - ${text}`);
+ }
+}
+
+export async function apiPostForm(
+ path: string,
+ data: Record,
+): Promise {
+ const body = new URLSearchParams(data);
+ const response = await fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: body.toString(),
+ });
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`API error: ${response.status} - ${text}`);
+ }
+ if (response.status === 204) return null;
+ return response.json();
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/charts.test.ts b/src/meshcore_hub/web/static/js/spa-react/utils/charts.test.ts
new file mode 100644
index 0000000..08e3e37
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/charts.test.ts
@@ -0,0 +1,255 @@
+import { describe, expect, it } from "vitest";
+import type { TFunction } from "i18next";
+
+import {
+ averageRouteTier,
+ buildActivityChart,
+ buildLineChart,
+ buildRouteDetailStrip,
+ buildRoutesTrend,
+ buildStackedBar,
+ ChartColors,
+ routeQualityToTier,
+ type ActivitySeries,
+ type BreakdownBucket,
+ type RouteOverviewEntry,
+} from "@/utils/charts";
+
+const t = ((key: string) => key) as unknown as TFunction;
+
+const dayLabel = (date: string) =>
+ new Date(date).toLocaleDateString("en-GB", {
+ day: "numeric",
+ month: "short",
+ });
+
+const series = (counts: number[]): ActivitySeries => ({
+ data: counts.map((count, i) => ({
+ date: `2026-02-0${i + 1}`,
+ count,
+ })),
+});
+
+describe("routeQualityToTier", () => {
+ it("maps clear/marginal through and everything else to failing", () => {
+ expect(routeQualityToTier("clear")).toBe("clear");
+ expect(routeQualityToTier("marginal")).toBe("marginal");
+ expect(routeQualityToTier("failing")).toBe("failing");
+ expect(routeQualityToTier("unknown")).toBe("failing");
+ expect(routeQualityToTier("no_coverage")).toBe("failing");
+ expect(routeQualityToTier(null)).toBe("failing");
+ expect(routeQualityToTier(undefined)).toBe("failing");
+ });
+});
+
+describe("averageRouteTier", () => {
+ it("falls back to failing on empty/absent history", () => {
+ expect(averageRouteTier(null)).toBe("failing");
+ expect(averageRouteTier([])).toBe("failing");
+ });
+
+ it("buckets the mean tier (clear=2, marginal=1, failing=0)", () => {
+ const q = (quality: string) => [{ quality }];
+ expect(averageRouteTier(q("clear"))).toBe("clear");
+ expect(averageRouteTier(q("marginal"))).toBe("marginal");
+ expect(averageRouteTier(q("failing"))).toBe("failing");
+
+ // mean (2+1)/2 = 1.5 -> clear
+ expect(averageRouteTier([{ quality: "clear" }, { quality: "marginal" }])).toBe(
+ "clear",
+ );
+ // mean (2+0)/2 = 1.0 -> marginal (>= 0.75)
+ expect(averageRouteTier([{ quality: "clear" }, { quality: "failing" }])).toBe(
+ "marginal",
+ );
+ // mean (1+0)/2 = 0.5 -> failing
+ expect(
+ averageRouteTier([{ quality: "marginal" }, { quality: "failing" }]),
+ ).toBe("failing");
+ });
+});
+
+describe("buildLineChart", () => {
+ it("returns null for missing/empty data", () => {
+ expect(buildLineChart(null, "L", "b", "bg", true)).toBeNull();
+ expect(buildLineChart({ data: [] }, "L", "b", "bg", true)).toBeNull();
+ });
+
+ it("builds a single filled dataset with formatted labels", () => {
+ const cfg = buildLineChart(
+ series([5, 10]),
+ "Nodes",
+ "border",
+ "fill",
+ true,
+ );
+ expect(cfg).not.toBeNull();
+ expect(cfg!.data.labels).toEqual([dayLabel("2026-02-01"), dayLabel("2026-02-02")]);
+ expect(cfg!.data.datasets).toHaveLength(1);
+ const ds = cfg!.data.datasets[0] as { data: number[]; label: string; fill: boolean };
+ expect(ds.label).toBe("Nodes");
+ expect(ds.data).toEqual([5, 10]);
+ expect(ds.fill).toBe(true);
+ });
+});
+
+describe("buildActivityChart", () => {
+ it("returns null when both series are absent", () => {
+ expect(buildActivityChart(null, null, t)).toBeNull();
+ });
+
+ it("builds one dataset when only adverts are provided", () => {
+ const cfg = buildActivityChart(series([3, 4]), null, t);
+ expect(cfg).not.toBeNull();
+ expect(cfg!.data.datasets).toHaveLength(1);
+ expect((cfg!.data.datasets[0] as { label: string }).label).toBe(
+ "entities.advertisements",
+ );
+ });
+
+ it("builds two datasets when both series are provided", () => {
+ const cfg = buildActivityChart(series([3, 4]), series([1, 2]), t);
+ expect(cfg).not.toBeNull();
+ expect(cfg!.data.datasets).toHaveLength(2);
+ const labels = cfg!.data.datasets.map((d) => (d as { label: string }).label);
+ expect(labels).toEqual(["entities.advertisements", "entities.messages"]);
+ });
+});
+
+describe("buildStackedBar", () => {
+ it("returns null for empty buckets or zero total", () => {
+ expect(buildStackedBar(null, ["red"])).toBeNull();
+ expect(buildStackedBar([], ["red"])).toBeNull();
+ const zero: BreakdownBucket[] = [
+ { label: "a", count: 0 },
+ { label: "b", count: 0 },
+ ];
+ expect(buildStackedBar(zero, ["red"])).toBeNull();
+ });
+
+ it("produces percentage datasets that sum to 100 with rawCount preserved", () => {
+ const buckets: BreakdownBucket[] = [
+ { label: "a", count: 30 },
+ { label: "b", count: 70 },
+ ];
+ const cfg = buildStackedBar(buckets, ["red", "blue"]);
+ expect(cfg).not.toBeNull();
+ const datasets = cfg!.data.datasets as {
+ data: number[];
+ rawCount: number;
+ backgroundColor: string;
+ }[];
+ expect(datasets).toHaveLength(2);
+ expect(datasets[0].data[0] + datasets[1].data[0]).toBeCloseTo(100);
+ expect(datasets[0].rawCount).toBe(30);
+ expect(datasets[1].rawCount).toBe(70);
+ expect(datasets[0].backgroundColor).toBe("red");
+ expect(datasets[1].backgroundColor).toBe("blue");
+ });
+});
+
+describe("buildRoutesTrend", () => {
+ it("returns null for empty routes or routes without history", () => {
+ expect(buildRoutesTrend(null, t)).toBeNull();
+ expect(buildRoutesTrend([], t)).toBeNull();
+ expect(
+ buildRoutesTrend([{ from_label: "A", to_label: "B", history: [] }], t),
+ ).toBeNull();
+ });
+
+ it("sorts by matched_count, uses categorical tiers, and colors by average tier", () => {
+ const routes: RouteOverviewEntry[] = [
+ {
+ from_label: "A",
+ to_label: "B",
+ matched_count: 1,
+ history: [
+ { date: "2026-02-01", quality: "clear", matched_count: 1 },
+ { date: "2026-02-02", quality: "clear", matched_count: 1 },
+ ],
+ },
+ {
+ from_label: "C",
+ to_label: "D",
+ matched_count: 9,
+ history: [
+ { date: "2026-02-01", quality: "failing", matched_count: 9 },
+ { date: "2026-02-02", quality: "failing", matched_count: 9 },
+ ],
+ },
+ ];
+ const cfg = buildRoutesTrend(routes, t);
+ expect(cfg).not.toBeNull();
+ const datasets = cfg!.data.datasets as unknown as {
+ label: string;
+ data: string[];
+ borderColor: string;
+ _matched: number[];
+ }[];
+ // Higher matched_count first
+ expect(datasets[0].label).toBe("C \u2192 D");
+ expect(datasets[0].data).toEqual(["failing", "failing"]);
+ expect(datasets[0].borderColor).toBe(ChartColors.quality.failing);
+ expect(datasets[0]._matched).toEqual([9, 9]);
+ expect(datasets[1].data).toEqual(["clear", "clear"]);
+ expect(datasets[1].borderColor).toBe(ChartColors.quality.clear);
+ expect(cfg!.data.labels).toEqual([
+ dayLabel("2026-02-01"),
+ dayLabel("2026-02-02"),
+ ]);
+ });
+
+ it("respects maxRoutes", () => {
+ const routes: RouteOverviewEntry[] = Array.from({ length: 8 }, (_, i) => ({
+ from_label: `A${i}`,
+ to_label: "B",
+ matched_count: i,
+ history: [{ date: "2026-02-01", quality: "clear", matched_count: i }],
+ }));
+ const cfg = buildRoutesTrend(routes, t, 6);
+ expect(cfg!.data.datasets).toHaveLength(6);
+ });
+});
+
+describe("buildRouteDetailStrip", () => {
+ it("returns null for missing/empty history", () => {
+ expect(buildRouteDetailStrip(null, t)).toBeNull();
+ expect(buildRouteDetailStrip({ data: [] }, t)).toBeNull();
+ expect(buildRouteDetailStrip({}, t)).toBeNull();
+ });
+
+ it("produces one colored segment per day", () => {
+ const cfg = buildRouteDetailStrip(
+ {
+ data: [
+ { date: "2026-02-01", quality: "clear", matched_count: 5 },
+ { date: "2026-02-02", quality: "failing", matched_count: 2 },
+ ],
+ },
+ t,
+ );
+ expect(cfg).not.toBeNull();
+ const datasets = cfg!.data.datasets as {
+ data: number[];
+ backgroundColor: string;
+ _quality: string;
+ _matched_count: number;
+ }[];
+ expect(datasets).toHaveLength(2);
+ expect(datasets[0].data).toEqual([1]);
+ expect(datasets[0].backgroundColor).toBe(ChartColors.quality.clear);
+ expect(datasets[0]._quality).toBe("clear");
+ expect(datasets[0]._matched_count).toBe(5);
+ expect(datasets[1].backgroundColor).toBe(ChartColors.quality.failing);
+ expect(datasets[1]._matched_count).toBe(2);
+ });
+
+ it("falls back to no_coverage color for unknown quality", () => {
+ const cfg = buildRouteDetailStrip(
+ { data: [{ date: "2026-02-01", quality: "weird", matched_count: 0 }] },
+ t,
+ );
+ const ds = cfg!.data.datasets[0] as { backgroundColor: string };
+ expect(ds.backgroundColor).toBe(ChartColors.quality.no_coverage);
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/charts.ts b/src/meshcore_hub/web/static/js/spa-react/utils/charts.ts
new file mode 100644
index 0000000..8c60d8f
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/charts.ts
@@ -0,0 +1,493 @@
+import Chart from "chart.js/auto";
+import type {
+ ChartData,
+ ChartDataset,
+ ChartOptions,
+ TooltipItem,
+} from "chart.js";
+import type { TFunction } from "i18next";
+
+Chart.defaults.font.family =
+ '"IBM Plex Sans", ui-sans-serif, system-ui, sans-serif';
+
+export interface ActivityPoint {
+ date: string;
+ count: number;
+}
+
+export interface ActivitySeries {
+ data: ActivityPoint[];
+}
+
+export interface BreakdownBucket {
+ label: string;
+ count: number;
+}
+
+export interface RouteHistoryDay {
+ date: string;
+ quality?: string | null;
+ matched_count?: number | null;
+}
+
+export interface RouteHistory {
+ data?: RouteHistoryDay[];
+}
+
+export interface RouteOverviewEntry {
+ from_label: string;
+ to_label: string;
+ matched_count?: number | null;
+ history?: RouteHistoryDay[];
+}
+
+export interface ChartConfig {
+ data: ChartData;
+ options: ChartOptions;
+}
+
+function formatNumber(v: number): string {
+ return new Intl.NumberFormat().format(v);
+}
+
+function getCSSColor(varName: string, fallback: string): string {
+ return (
+ getComputedStyle(document.documentElement)
+ .getPropertyValue(varName)
+ .trim() || fallback
+ );
+}
+
+function withAlpha(color: string, alpha: number): string {
+ return color.replace(")", " / " + alpha + ")");
+}
+
+export const ChartColors = {
+ get nodes() {
+ return getCSSColor("--color-nodes", "oklch(0.65 0.24 265)");
+ },
+ get nodesFill() {
+ return withAlpha(this.nodes, 0.1);
+ },
+ get adverts() {
+ return getCSSColor("--color-adverts", "oklch(0.7 0.17 330)");
+ },
+ get advertsFill() {
+ return withAlpha(this.adverts, 0.1);
+ },
+ get messages() {
+ return getCSSColor("--color-messages", "oklch(0.75 0.18 180)");
+ },
+ get messagesFill() {
+ return withAlpha(this.messages, 0.1);
+ },
+ get packets() {
+ return getCSSColor("--color-packets", "oklch(0.72 0.17 145)");
+ },
+ get packetsFill() {
+ return withAlpha(this.packets, 0.1);
+ },
+ get routes() {
+ return getCSSColor("--color-routes", "oklch(0.72 0.17 30)");
+ },
+ get routesFill() {
+ return withAlpha(this.routes, 0.1);
+ },
+
+ grid: "oklch(0.4 0 0 / 0.2)",
+ text: "oklch(0.7 0 0)",
+ tooltipBg: "oklch(0.25 0 0)",
+ tooltipText: "oklch(0.9 0 0)",
+ tooltipBorder: "oklch(0.4 0 0)",
+
+ breakdown: [
+ "oklch(0.65 0.24 265)",
+ "oklch(0.7 0.17 330)",
+ "oklch(0.75 0.18 180)",
+ "oklch(0.72 0.17 145)",
+ "oklch(0.7 0.19 80)",
+ "oklch(0.65 0.22 25)",
+ "oklch(0.55 0 0)",
+ ],
+
+ quality: {
+ clear: "oklch(0.72 0.17 145)",
+ marginal: "oklch(0.75 0.18 85)",
+ failing: "oklch(0.62 0.24 25)",
+ no_coverage: "oklch(0.65 0.15 250)",
+ disabled: "oklch(0.55 0 0)",
+ } as Record,
+};
+
+function createChartOptions(showLegend: boolean): ChartOptions<"line"> {
+ return {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ display: showLegend,
+ position: "top",
+ align: "end",
+ labels: {
+ color: ChartColors.text,
+ boxWidth: 12,
+ padding: 8,
+ },
+ },
+ tooltip: {
+ mode: "index",
+ intersect: false,
+ backgroundColor: ChartColors.tooltipBg,
+ titleColor: ChartColors.tooltipText,
+ bodyColor: ChartColors.tooltipText,
+ borderColor: ChartColors.tooltipBorder,
+ borderWidth: 1,
+ callbacks: {
+ label: (ctx: TooltipItem<"line">) => {
+ const label = ctx.dataset.label || "";
+ const value = formatNumber(ctx.parsed.y ?? 0);
+ return label ? label + ": " + value : value;
+ },
+ },
+ },
+ },
+ scales: {
+ x: {
+ grid: { color: ChartColors.grid },
+ ticks: {
+ color: ChartColors.text,
+ maxRotation: 45,
+ minRotation: 45,
+ maxTicksLimit: 10,
+ },
+ },
+ y: {
+ beginAtZero: true,
+ grid: { color: ChartColors.grid },
+ ticks: {
+ color: ChartColors.text,
+ precision: 0,
+ callback: (value) => formatNumber(Number(value)),
+ },
+ },
+ },
+ interaction: {
+ mode: "nearest",
+ axis: "x",
+ intersect: false,
+ },
+ };
+}
+
+function formatDateLabels(data: { date: string }[]): string[] {
+ return data.map((d) => {
+ const date = new Date(d.date);
+ return date.toLocaleDateString("en-GB", {
+ day: "numeric",
+ month: "short",
+ });
+ });
+}
+
+export function routeQualityToTier(q: string | null | undefined): string {
+ if (q === "clear") return "clear";
+ if (q === "marginal") return "marginal";
+ return "failing";
+}
+
+export function averageRouteTier(
+ history: { quality?: string | null }[] | null | undefined,
+): string {
+ if (!history || history.length === 0) return "failing";
+ let sum = 0;
+ for (const entry of history) {
+ const tier = routeQualityToTier(entry.quality);
+ sum += tier === "clear" ? 2 : tier === "marginal" ? 1 : 0;
+ }
+ const mean = sum / history.length;
+ if (mean >= 1.5) return "clear";
+ if (mean >= 0.75) return "marginal";
+ return "failing";
+}
+
+export function buildLineChart(
+ data: ActivitySeries | null | undefined,
+ label: string,
+ borderColor: string,
+ backgroundColor: string,
+ fill: boolean,
+): ChartConfig<"line"> | null {
+ if (!data || !data.data || data.data.length === 0) return null;
+ return {
+ data: {
+ labels: formatDateLabels(data.data),
+ datasets: [
+ {
+ label,
+ data: data.data.map((d) => d.count),
+ borderColor,
+ backgroundColor,
+ fill,
+ tension: 0.3,
+ pointRadius: 2,
+ pointHoverRadius: 5,
+ },
+ ],
+ },
+ options: createChartOptions(false),
+ };
+}
+
+export function buildActivityChart(
+ advertData: ActivitySeries | null | undefined,
+ messageData: ActivitySeries | null | undefined,
+ t: TFunction,
+): ChartConfig<"line"> | null {
+ const datasets: ChartDataset<"line">[] = [];
+ let labels: string[] | null = null;
+
+ if (advertData && advertData.data && advertData.data.length > 0) {
+ if (!labels) labels = formatDateLabels(advertData.data);
+ datasets.push({
+ label: t("entities.advertisements"),
+ data: advertData.data.map((d) => d.count),
+ borderColor: ChartColors.adverts,
+ backgroundColor: ChartColors.advertsFill,
+ fill: true,
+ tension: 0.3,
+ pointRadius: 2,
+ pointHoverRadius: 5,
+ });
+ }
+
+ if (messageData && messageData.data && messageData.data.length > 0) {
+ if (!labels) labels = formatDateLabels(messageData.data);
+ datasets.push({
+ label: t("entities.messages"),
+ data: messageData.data.map((d) => d.count),
+ borderColor: ChartColors.messages,
+ backgroundColor: ChartColors.messagesFill,
+ fill: true,
+ tension: 0.3,
+ pointRadius: 2,
+ pointHoverRadius: 5,
+ });
+ }
+
+ if (datasets.length === 0 || !labels) return null;
+ return { data: { labels, datasets }, options: createChartOptions(true) };
+}
+
+type StackedBarDataset = ChartDataset<"bar"> & { rawCount?: number };
+
+export function buildStackedBar(
+ buckets: BreakdownBucket[] | null | undefined,
+ colors: string[],
+): ChartConfig<"bar"> | null {
+ if (!buckets || buckets.length === 0) return null;
+ const total = buckets.reduce((sum, b) => sum + b.count, 0);
+ if (total === 0) return null;
+
+ const datasets: StackedBarDataset[] = buckets.map((bucket, i) => {
+ const pct = (bucket.count / total) * 100;
+ return {
+ label: bucket.label,
+ data: [pct],
+ backgroundColor: colors[i % colors.length],
+ borderColor: colors[i % colors.length],
+ borderWidth: 1,
+ rawCount: bucket.count,
+ };
+ });
+
+ return {
+ data: { labels: [""], datasets },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ indexAxis: "y",
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ backgroundColor: ChartColors.tooltipBg,
+ titleColor: ChartColors.tooltipText,
+ bodyColor: ChartColors.tooltipText,
+ borderColor: ChartColors.tooltipBorder,
+ borderWidth: 1,
+ callbacks: {
+ label: (ctx: TooltipItem<"bar">) => {
+ const ds = ctx.dataset as StackedBarDataset;
+ const label = ds.label || "";
+ const count = formatNumber(ds.rawCount ?? 0);
+ const pct = (ctx.parsed.x ?? 0).toFixed(1);
+ return label + ": " + count + " (" + pct + "%)";
+ },
+ },
+ },
+ },
+ scales: {
+ x: {
+ max: 100,
+ stacked: true,
+ grid: { color: ChartColors.grid },
+ ticks: {
+ color: ChartColors.text,
+ callback: (value) => value + "%",
+ },
+ },
+ y: {
+ stacked: true,
+ grid: { display: false },
+ ticks: { display: false },
+ },
+ },
+ interaction: {
+ mode: "nearest",
+ intersect: false,
+ },
+ },
+ };
+}
+
+type RouteTrendDataset = ChartDataset<"line"> & { _matched?: number[] };
+
+export function buildRoutesTrend(
+ routes: RouteOverviewEntry[] | null | undefined,
+ t: TFunction,
+ maxRoutes = 6,
+): ChartConfig<"line"> | null {
+ if (!routes || routes.length === 0) return null;
+
+ const tierOrder = ["failing", "marginal", "clear"];
+ const tierColor = (tier: string): string =>
+ ChartColors.quality[tier] || ChartColors.quality.failing;
+
+ const sorted = routes
+ .slice()
+ .sort((a, b) => (b.matched_count || 0) - (a.matched_count || 0));
+ const top = sorted.slice(0, maxRoutes);
+
+ let labels: string[] = [];
+ for (const entry of top) {
+ if (entry.history && entry.history.length > labels.length) {
+ labels = formatDateLabels(entry.history);
+ }
+ }
+ if (labels.length === 0) return null;
+
+ const datasets: RouteTrendDataset[] = top.map((entry) => {
+ const history = entry.history || [];
+ const avgTier = averageRouteTier(history);
+ return {
+ label: entry.from_label + " \u2192 " + entry.to_label,
+ data: history.map((d) => routeQualityToTier(d.quality)),
+ borderColor: tierColor(avgTier),
+ backgroundColor: "transparent",
+ fill: false,
+ tension: 0.3,
+ cubicInterpolationMode: "monotone",
+ pointRadius: 2,
+ pointHoverRadius: 5,
+ spanGaps: true,
+ _matched: history.map((d) => d.matched_count || 0),
+ } as unknown as RouteTrendDataset;
+ });
+
+ const opts = createChartOptions(false);
+ opts.scales = {
+ ...opts.scales,
+ y: {
+ type: "category",
+ labels: tierOrder,
+ reverse: true,
+ grid: { color: ChartColors.grid },
+ ticks: {
+ color: ChartColors.text,
+ callback: (_value, index) => {
+ const tier = tierOrder[index];
+ return t("routes.quality_" + tier);
+ },
+ },
+ },
+ };
+ opts.plugins = {
+ ...opts.plugins,
+ tooltip: {
+ ...opts.plugins?.tooltip,
+ callbacks: {
+ title: (items: TooltipItem<"line">[]) => items[0].label,
+ label: (ctx: TooltipItem<"line">) => {
+ const ds = ctx.dataset as RouteTrendDataset;
+ const tier = tierOrder[ctx.parsed.y as number] || "failing";
+ const tierLabel = t("routes.quality_" + tier);
+ const matched = ds._matched?.[ctx.dataIndex] ?? 0;
+ return (ds.label || "") + ": " + tierLabel + " (" + matched + ")";
+ },
+ },
+ },
+ };
+
+ return {
+ data: { labels, datasets },
+ options: opts as ChartOptions<"line">,
+ };
+}
+
+type StripDataset = ChartDataset<"bar"> & {
+ _quality?: string;
+ _matched_count?: number;
+};
+
+export function buildRouteDetailStrip(
+ routeData: RouteHistory | null | undefined,
+ t: TFunction,
+): ChartConfig<"bar"> | null {
+ if (!routeData || !routeData.data || routeData.data.length === 0) return null;
+
+ const datasets: StripDataset[] = routeData.data.map((day) => {
+ const color =
+ ChartColors.quality[day.quality ?? ""] || ChartColors.quality.no_coverage;
+ return {
+ label: day.date,
+ data: [1],
+ backgroundColor: color,
+ borderColor: color,
+ borderWidth: 1,
+ _quality: day.quality ?? "unknown",
+ _matched_count: day.matched_count || 0,
+ };
+ });
+
+ return {
+ data: { labels: [""], datasets },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ indexAxis: "y",
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ backgroundColor: ChartColors.tooltipBg,
+ titleColor: ChartColors.tooltipText,
+ bodyColor: ChartColors.tooltipText,
+ borderColor: ChartColors.tooltipBorder,
+ borderWidth: 1,
+ callbacks: {
+ title: (ctx: TooltipItem<"bar">[]) => ctx[0].dataset.label || "",
+ label: (ctx: TooltipItem<"bar">) => {
+ const ds = ctx.dataset as StripDataset;
+ const q = ds._quality || "unknown";
+ const label = t("routes.quality_" + q);
+ return label + " (" + (ds._matched_count ?? 0) + ")";
+ },
+ },
+ },
+ },
+ scales: {
+ x: { stacked: true, grid: { display: false }, ticks: { display: false } },
+ y: { stacked: true, grid: { display: false }, ticks: { display: false } },
+ },
+ interaction: { mode: "nearest", intersect: true },
+ },
+ };
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/clipboard.ts b/src/meshcore_hub/web/static/js/spa-react/utils/clipboard.ts
new file mode 100644
index 0000000..fa2cb9b
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/clipboard.ts
@@ -0,0 +1,59 @@
+export function copyToClipboard(
+ e: React.MouseEvent,
+ text: string,
+): void {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const targetElement = e.currentTarget as HTMLElement;
+
+ const showSuccess = (target: HTMLElement) => {
+ const originalText = target.textContent;
+ target.textContent = "Copied!";
+ target.classList.add("text-success");
+ setTimeout(() => {
+ target.textContent = originalText;
+ target.classList.remove("text-success");
+ }, 1500);
+ };
+
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard
+ .writeText(text)
+ .then(() => showSuccess(targetElement))
+ .catch((err) => {
+ console.error("Clipboard API failed:", err);
+ fallbackCopy(text, targetElement);
+ });
+ } else {
+ fallbackCopy(text, targetElement);
+ }
+}
+
+function fallbackCopy(text: string, target: HTMLElement): void {
+ const textArea = document.createElement("textarea");
+ textArea.value = text;
+ textArea.style.position = "fixed";
+ textArea.style.left = "-999999px";
+ textArea.style.top = "-999999px";
+ document.body.appendChild(textArea);
+ textArea.focus();
+ textArea.select();
+ try {
+ document.execCommand("copy");
+ showSuccess(target);
+ } catch (err) {
+ console.error("Fallback copy failed:", err);
+ }
+ document.body.removeChild(textArea);
+}
+
+function showSuccess(target: HTMLElement): void {
+ const originalText = target.textContent;
+ target.textContent = "Copied!";
+ target.classList.add("text-success");
+ setTimeout(() => {
+ target.textContent = originalText;
+ target.classList.remove("text-success");
+ }, 1500);
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/format.test.ts b/src/meshcore_hub/web/static/js/spa-react/utils/format.test.ts
new file mode 100644
index 0000000..663784d
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/format.test.ts
@@ -0,0 +1,171 @@
+import { afterEach, describe, expect, it } from "vitest";
+
+import {
+ extractFirstEmoji,
+ formatNumber,
+ formatRelativeTime,
+ getNodeEmoji,
+ parseAppDate,
+ resolveNodeName,
+ truncateKey,
+ typeEmoji,
+} from "@/utils/format";
+
+const fmt = (n: number) => new Intl.NumberFormat().format(n);
+
+describe("parseAppDate", () => {
+ it("returns null for empty/invalid input", () => {
+ expect(parseAppDate(null)).toBeNull();
+ expect(parseAppDate("")).toBeNull();
+ expect(parseAppDate(" ")).toBeNull();
+ expect(parseAppDate("not a date")).toBeNull();
+ });
+
+ it("treats naive datetimes as UTC", () => {
+ const d = parseAppDate("2026-02-08 12:30:00");
+ expect(d).not.toBeNull();
+ expect(d!.getTime()).toBe(Date.parse("2026-02-08T12:30:00Z"));
+ });
+
+ it("preserves explicit timezone offsets", () => {
+ const d = parseAppDate("2026-02-08T12:30:00+02:00");
+ expect(d!.getTime()).toBe(Date.parse("2026-02-08T12:30:00+02:00"));
+ });
+
+ it("parses date-only strings", () => {
+ const d = parseAppDate("2026-02-08");
+ expect(d).not.toBeNull();
+ expect(d!.getTime()).toBe(Date.parse("2026-02-08"));
+ });
+});
+
+describe("formatNumber", () => {
+ it("returns empty string for null/undefined/empty", () => {
+ expect(formatNumber(null)).toBe("");
+ expect(formatNumber(undefined)).toBe("");
+ expect(formatNumber("")).toBe("");
+ });
+
+ it("returns the raw string for non-numeric input", () => {
+ expect(formatNumber("abc")).toBe("abc");
+ });
+
+ it("formats numbers with locale grouping", () => {
+ expect(formatNumber(1234)).toBe(fmt(1234));
+ expect(formatNumber("1234")).toBe(fmt(1234));
+ expect(formatNumber(0)).toBe(fmt(0));
+ });
+});
+
+describe("truncateKey", () => {
+ it("returns '-' for empty input", () => {
+ expect(truncateKey(null)).toBe("-");
+ });
+
+ it("returns short keys unchanged", () => {
+ expect(truncateKey("short")).toBe("short");
+ });
+
+ it("truncates long keys with an ellipsis", () => {
+ const key = "abcdefghijklmnopqrst";
+ expect(truncateKey(key)).toBe("abcdefghijkl...");
+ expect(truncateKey(key, 4)).toBe("abcd...");
+ });
+});
+
+describe("resolveNodeName", () => {
+ const key = "0123456789abcdef0123456789abcdef";
+
+ it("returns '-' for null/undefined nodes", () => {
+ expect(resolveNodeName(null)).toBe("-");
+ expect(resolveNodeName(undefined)).toBe("-");
+ });
+
+ it("prefers a 'name' tag value", () => {
+ expect(
+ resolveNodeName({
+ name: "Real Name",
+ public_key: key,
+ tags: [{ key: "name", value: "Tag Name" }],
+ }),
+ ).toBe("Tag Name");
+ });
+
+ it("falls back to the node name when there is no name tag", () => {
+ expect(resolveNodeName({ name: "Real Name", public_key: key })).toBe(
+ "Real Name",
+ );
+ });
+
+ it("falls back to a truncated public key when there is no name", () => {
+ expect(resolveNodeName({ name: null, public_key: key })).toBe(
+ "0123456789ab...",
+ );
+ expect(resolveNodeName({ public_key: key })).toBe("0123456789ab...");
+ });
+});
+
+describe("typeEmoji", () => {
+ it("maps node types to emoji (incl. inference from substrings)", () => {
+ expect(typeEmoji("chat")).toBe("\u{1F4AC}");
+ expect(typeEmoji("repeater")).toBe("\u{1F4E1}");
+ expect(typeEmoji("room")).toBe("\u{1FAA7}");
+ expect(typeEmoji("companion")).toBe("\u{1F4F1}");
+ expect(typeEmoji("Chat Node")).toBe("\u{1F4AC}");
+ expect(typeEmoji("My Repeater")).toBe("\u{1F4E1}");
+ });
+
+ it("falls back to a pin for unknown/null types", () => {
+ expect(typeEmoji(null)).toBe("\u{1F4CD}");
+ expect(typeEmoji("sensor")).toBe("\u{1F4CD}");
+ });
+});
+
+describe("extractFirstEmoji", () => {
+ it("returns null when there is no emoji", () => {
+ expect(extractFirstEmoji(null)).toBeNull();
+ expect(extractFirstEmoji("plain text")).toBeNull();
+ });
+
+ it("extracts the first emoji", () => {
+ expect(extractFirstEmoji("\u{1F525} hot node")).toBe("\u{1F525}");
+ });
+});
+
+describe("getNodeEmoji", () => {
+ it("prefers an emoji in the node name", () => {
+ expect(getNodeEmoji("\u{1F680} Rocket", null)).toBe("\u{1F680}");
+ });
+
+ it("infers from type/name when no name emoji", () => {
+ expect(getNodeEmoji("Living Room", null)).toBe("\u{1FAA7}");
+ expect(getNodeEmoji("X", "repeater")).toBe("\u{1F4E1}");
+ });
+});
+
+describe("formatRelativeTime", () => {
+ afterEach(() => {
+ delete (window as { t?: unknown }).t;
+ });
+
+ const withT = () => {
+ window.t = (key: string) => key;
+ };
+
+ const isoAgo = (ms: number) => new Date(Date.now() - ms).toISOString();
+
+ it("returns empty string for empty/invalid input", () => {
+ withT();
+ expect(formatRelativeTime(null)).toBe("");
+ });
+
+ it("buckets elapsed time into relative labels", () => {
+ withT();
+ expect(formatRelativeTime(isoAgo(10 * 1000))).toBe("time.less_than_minute");
+ expect(formatRelativeTime(isoAgo(5 * 60 * 1000))).toBe("time.minutes_ago");
+ expect(formatRelativeTime(isoAgo(3 * 60 * 60 * 1000))).toBe("time.hours_ago");
+ expect(formatRelativeTime(isoAgo(2 * 24 * 60 * 60 * 1000))).toBe(
+ "time.days_ago",
+ );
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/format.ts b/src/meshcore_hub/web/static/js/spa-react/utils/format.ts
new file mode 100644
index 0000000..05eab35
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/format.ts
@@ -0,0 +1,172 @@
+import { useAppConfig } from "@/context/AppConfigContext";
+
+export function parseAppDate(isoString: string | null): Date | null {
+ if (!isoString || typeof isoString !== "string") return null;
+
+ let value = isoString.trim();
+ if (!value) return null;
+
+ if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}/.test(value)) {
+ value = value.replace(/\s+/, "T");
+ }
+
+ const hasTimePart = /T\d{2}:\d{2}/.test(value);
+ const hasTimezoneSuffix = /(Z|[+-]\d{2}:\d{2}|[+-]\d{4})$/i.test(value);
+ if (hasTimePart && !hasTimezoneSuffix) {
+ value += "Z";
+ }
+
+ const parsed = new Date(value);
+ if (isNaN(parsed.getTime())) return null;
+ return parsed;
+}
+
+export function formatNumber(
+ value: number | string | null | undefined,
+): string {
+ if (value === null || value === undefined || value === "") return "";
+ const n = Number(value);
+ if (!Number.isFinite(n)) return String(value);
+ return new Intl.NumberFormat().format(n);
+}
+
+export function useFormatDateTime() {
+ const config = useAppConfig();
+ const tz = config.timezone_iana || "UTC";
+ const locale = config.datetime_locale || "en-US";
+
+ return {
+ formatDateTime(
+ isoString: string | null,
+ options?: Intl.DateTimeFormatOptions,
+ ): string {
+ if (!isoString) return "-";
+ try {
+ const date = parseAppDate(isoString);
+ if (!date) return "-";
+ const opts = options ?? {
+ timeZone: tz,
+ year: "numeric" as const,
+ month: "2-digit" as const,
+ day: "2-digit" as const,
+ hour: "2-digit" as const,
+ minute: "2-digit" as const,
+ second: "2-digit" as const,
+ hour12: false,
+ };
+ if (!opts.timeZone) opts.timeZone = tz;
+ return date.toLocaleString(locale, opts);
+ } catch {
+ return isoString ? isoString.slice(0, 19).replace("T", " ") : "-";
+ }
+ },
+
+ formatDateTimeShort(isoString: string | null): string {
+ if (!isoString) return "-";
+ try {
+ const date = parseAppDate(isoString);
+ if (!date) return "-";
+ return date.toLocaleString(locale, {
+ timeZone: tz,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ });
+ } catch {
+ return isoString ? isoString.slice(0, 16).replace("T", " ") : "-";
+ }
+ },
+ };
+}
+
+export function formatRelativeTime(isoString: string | null): string {
+ if (!isoString) return "";
+ const date = parseAppDate(isoString);
+ if (!date) return "";
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffSec = Math.floor(diffMs / 1000);
+ const diffMin = Math.floor(diffSec / 60);
+ const diffHour = Math.floor(diffMin / 60);
+ const diffDay = Math.floor(diffHour / 24);
+ const t = window.t;
+ if (diffDay > 0) return t("time.days_ago", { count: diffDay });
+ if (diffHour > 0) return t("time.hours_ago", { count: diffHour });
+ if (diffMin > 0) return t("time.minutes_ago", { count: diffMin });
+ return t("time.less_than_minute");
+}
+
+export function truncateKey(key: string | null, length = 12): string {
+ if (!key) return "-";
+ if (key.length <= length) return key;
+ return key.slice(0, length) + "...";
+}
+
+export function resolveNodeName(
+ node: {
+ name?: string | null;
+ public_key?: string | null;
+ tags?: { key: string; value: string | null }[];
+ } | null | undefined,
+ fallbackLength = 12,
+): string {
+ if (!node) return "-";
+ const tagName = node.tags?.find((tag) => tag.key === "name")?.value;
+ return (
+ tagName || node.name || truncateKey(node.public_key ?? null, fallbackLength)
+ );
+}
+
+function inferNodeType(value: string | null): string | null {
+ const normalized = (value ?? "").toLowerCase();
+ if (!normalized) return null;
+ if (normalized.includes("room")) return "room";
+ if (normalized.includes("repeater") || normalized.includes("relay"))
+ return "repeater";
+ if (normalized.includes("companion") || normalized.includes("observer"))
+ return "companion";
+ if (normalized.includes("chat")) return "chat";
+ return null;
+}
+
+export function typeEmoji(advType: string | null): string {
+ switch (inferNodeType(advType) ?? (advType ?? "").toLowerCase()) {
+ case "chat":
+ return "\u{1F4AC}";
+ case "repeater":
+ return "\u{1F4E1}";
+ case "companion":
+ return "\u{1F4F1}";
+ case "room":
+ return "\u{1FAA7}";
+ default:
+ return "\u{1F4CD}";
+ }
+}
+
+export function extractFirstEmoji(str: string | null): string | null {
+ if (!str) return null;
+ const emojiRegex =
+ /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{231A}-\u{231B}\u{23E9}-\u{23FA}\u{25AA}-\u{25AB}\u{25B6}\u{25C0}\u{25FB}-\u{25FE}\u{2B50}\u{2B55}\u{3030}\u{303D}\u{3297}\u{3299}](?:\u{FE0F})?(?:\u{200D}[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}](?:\u{FE0F})?)*|\u{00A9}|\u{00AE}|\u{203C}|\u{2049}|\u{2122}|\u{2139}|\u{2194}-\u{2199}|\u{21A9}-\u{21AA}|\u{24C2}|\u{2934}-\u{2935}|\u{2B05}-\u{2B07}|\u{2B1B}-\u{2B1C}/u;
+ const match = str.match(emojiRegex);
+ return match ? match[0] : null;
+}
+
+export function getNodeEmoji(
+ nodeName: string | null,
+ advType: string | null,
+): string {
+ const nameEmoji = extractFirstEmoji(nodeName);
+ if (nameEmoji) return nameEmoji;
+ const inferred = inferNodeType(advType) ?? inferNodeType(nodeName);
+ return typeEmoji(inferred ?? advType);
+}
+
+export function getPageColor(name: string): string {
+ return getComputedStyle(document.documentElement)
+ .getPropertyValue(`--color-${name}`)
+ .trim();
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/mapMath.test.ts b/src/meshcore_hub/web/static/js/spa-react/utils/mapMath.test.ts
new file mode 100644
index 0000000..e55903a
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/mapMath.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ getDistanceKm,
+ getNodesWithinRadius,
+ getAnchorPoint,
+ normalizeType,
+} from "@/utils/mapMath";
+
+describe("getDistanceKm", () => {
+ it("returns 0 for the same point", () => {
+ expect(getDistanceKm(10, 20, 10, 20)).toBeCloseTo(0);
+ });
+
+ it("calculates distance between two known points", () => {
+ const d = getDistanceKm(51.5074, -0.1278, 48.8566, 2.3522);
+ expect(d).toBeGreaterThan(330);
+ expect(d).toBeLessThan(360);
+ });
+});
+
+describe("getNodesWithinRadius", () => {
+ const nodes = [
+ { lat: 0, lon: 0, adv_type: null },
+ { lat: 0.01, lon: 0.01, adv_type: null },
+ { lat: 10, lon: 10, adv_type: null },
+ ];
+
+ it("filters to only nearby nodes", () => {
+ expect(getNodesWithinRadius(nodes, 0, 0, 100)).toHaveLength(2);
+ });
+
+ it("returns all when the radius is large enough", () => {
+ expect(getNodesWithinRadius(nodes, 0, 0, 2000)).toHaveLength(3);
+ });
+});
+
+describe("getAnchorPoint", () => {
+ it("returns the adopted center when provided", () => {
+ expect(getAnchorPoint([], { lat: 5, lon: 5 })).toEqual({ lat: 5, lon: 5 });
+ });
+
+ it("returns origin for empty nodes with no center", () => {
+ expect(getAnchorPoint([], null)).toEqual({ lat: 0, lon: 0 });
+ });
+
+ it("computes the centroid of multiple nodes", () => {
+ const nodes = [
+ { lat: 0, lon: 0, adv_type: null },
+ { lat: 10, lon: 20, adv_type: null },
+ ];
+ expect(getAnchorPoint(nodes, null)).toEqual({ lat: 5, lon: 10 });
+ });
+});
+
+describe("normalizeType", () => {
+ it("lowercases the type string", () => {
+ expect(normalizeType("CHAT")).toBe("chat");
+ });
+
+ it("returns null for null input", () => {
+ expect(normalizeType(null)).toBeNull();
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/mapMath.ts b/src/meshcore_hub/web/static/js/spa-react/utils/mapMath.ts
new file mode 100644
index 0000000..4bd7f3c
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/mapMath.ts
@@ -0,0 +1,56 @@
+export interface LatLng {
+ lat: number;
+ lon: number;
+}
+
+export interface MapNodeLike {
+ lat: number;
+ lon: number;
+ adv_type?: string | null;
+}
+
+export function getDistanceKm(
+ lat1: number,
+ lon1: number,
+ lat2: number,
+ lon2: number,
+): number {
+ const R = 6371;
+ const dLat = ((lat2 - lat1) * Math.PI) / 180;
+ const dLon = ((lon2 - lon1) * Math.PI) / 180;
+ const a =
+ Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+ Math.cos((lat1 * Math.PI) / 180) *
+ Math.cos((lat2 * Math.PI) / 180) *
+ Math.sin(dLon / 2) *
+ Math.sin(dLon / 2);
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+ return R * c;
+}
+
+export function getNodesWithinRadius(
+ nodes: T[],
+ anchorLat: number,
+ anchorLon: number,
+ radiusKm: number,
+): T[] {
+ return nodes.filter(
+ (n) => getDistanceKm(anchorLat, anchorLon, n.lat, n.lon) <= radiusKm,
+ );
+}
+
+export function getAnchorPoint(
+ nodes: T[],
+ adoptedCenter: LatLng | null,
+): LatLng {
+ if (adoptedCenter) return adoptedCenter;
+ if (nodes.length === 0) return { lat: 0, lon: 0 };
+ return {
+ lat: nodes.reduce((sum, n) => sum + n.lat, 0) / nodes.length,
+ lon: nodes.reduce((sum, n) => sum + n.lon, 0) / nodes.length,
+ };
+}
+
+export function normalizeType(type: string | null): string | null {
+ return type ? type.toLowerCase() : null;
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/messageHelpers.test.ts b/src/meshcore_hub/web/static/js/spa-react/utils/messageHelpers.test.ts
new file mode 100644
index 0000000..e83dcb6
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/messageHelpers.test.ts
@@ -0,0 +1,173 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ parseSenderFromText,
+ collapseNewlines,
+ channelInfo,
+ messageTextWithSender,
+ dedupeBySignature,
+} from "@/utils/messageHelpers";
+
+describe("parseSenderFromText", () => {
+ it("extracts the @[sender] pattern", () => {
+ const result = parseSenderFromText("@[Alice]: Hello world");
+ expect(result.sender).toBe("Alice");
+ expect(result.text).toBe("Hello world");
+ });
+
+ it("extracts the ack @[sender] pattern", () => {
+ const result = parseSenderFromText("ack @[Bob]: Got it");
+ expect(result.sender).toBe("Bob");
+ expect(result.text).toBe("Got it");
+ });
+
+ it("extracts the plain ack sender pattern", () => {
+ const result = parseSenderFromText("ack Carol: Message");
+ expect(result.sender).toBe("Carol");
+ expect(result.text).toBe("Message");
+ });
+
+ it("returns null sender for non-matching text", () => {
+ const result = parseSenderFromText("Just a message");
+ expect(result.sender).toBeNull();
+ expect(result.text).toBe("Just a message");
+ });
+
+ it("returns dash for null input", () => {
+ expect(parseSenderFromText(null).text).toBe("-");
+ });
+});
+
+describe("collapseNewlines", () => {
+ it("replaces newlines with single spaces", () => {
+ expect(collapseNewlines("line1\nline2")).toBe("line1 line2");
+ });
+
+ it("collapses surrounding whitespace", () => {
+ expect(collapseNewlines("a \n b")).toBe("a b");
+ });
+
+ it("returns null for null input", () => {
+ expect(collapseNewlines(null)).toBeNull();
+ });
+});
+
+describe("channelInfo", () => {
+ const base = {
+ message_type: "channel" as const,
+ text: "hello",
+ received_at: "2024-01-01",
+ };
+
+ it("returns null label for non-channel messages", () => {
+ const result = channelInfo(
+ { ...base, message_type: "direct" },
+ new Map(),
+ "Fallback",
+ );
+ expect(result.label).toBeNull();
+ expect(result.text).toBe("hello");
+ });
+
+ it("uses the known channel label from the map", () => {
+ const labels = new Map([[17, "Public"]]);
+ const result = channelInfo(
+ { ...base, text: "[Public] hello", channel_idx: 17 },
+ labels,
+ "Fallback",
+ );
+ expect(result.label).toBe("Public");
+ expect(result.text).toBe("hello");
+ });
+
+ it("falls back to channel_name when no label map match exists", () => {
+ const result = channelInfo(
+ { ...base, channel_name: "Custom" },
+ new Map(),
+ "Fallback",
+ );
+ expect(result.label).toBe("Custom");
+ });
+
+ it("falls back to Ch when only channel_idx is available", () => {
+ const result = channelInfo(
+ { ...base, channel_idx: 5 },
+ new Map(),
+ "Fallback",
+ );
+ expect(result.label).toBe("Ch 5");
+ });
+});
+
+describe("messageTextWithSender", () => {
+ const base = {
+ message_type: "channel" as const,
+ text: "hi",
+ received_at: "2024-01-01",
+ };
+
+ it("prefixes with the sender name from msg fields", () => {
+ expect(
+ messageTextWithSender({ ...base, sender_name: "Alice" }, "hi"),
+ ).toBe("Alice: hi");
+ });
+
+ it("parses the sender from text when no explicit sender exists", () => {
+ expect(
+ messageTextWithSender({ ...base, text: "@[Bob]: hello" }, "@[Bob]: hello"),
+ ).toBe("Bob: hello");
+ });
+
+ it("does not duplicate the sender prefix", () => {
+ expect(
+ messageTextWithSender(
+ { ...base, text: "Alice: hi", sender_name: "Alice" },
+ "Alice: hi",
+ ),
+ ).toBe("Alice: hi");
+ });
+});
+
+describe("dedupeBySignature", () => {
+ const base = {
+ message_type: "channel" as const,
+ text: "hello",
+ received_at: "2024-01-01",
+ };
+
+ it("keeps non-channel messages as-is", () => {
+ const items = [{ ...base, message_type: "direct" as const }];
+ expect(dedupeBySignature(items)).toHaveLength(1);
+ });
+
+ it("merges channel messages with the same long signature", () => {
+ const items = [
+ {
+ ...base,
+ signature: "SIG12345678",
+ observers: [{ public_key: "a" }],
+ },
+ {
+ ...base,
+ signature: "SIG12345678",
+ observers: [{ public_key: "b" }],
+ },
+ ];
+ const result = dedupeBySignature(items);
+ expect(result).toHaveLength(1);
+ expect(result[0].observers).toHaveLength(2);
+ });
+
+ it("keeps messages with different signatures separate", () => {
+ const items = [
+ { ...base, signature: "SIGAAAAAA" },
+ { ...base, signature: "SIGBBBBBB" },
+ ];
+ expect(dedupeBySignature(items)).toHaveLength(2);
+ });
+
+ it("does not dedupe channel messages with short signatures", () => {
+ const items = [{ ...base, signature: "short" }];
+ expect(dedupeBySignature(items)).toHaveLength(1);
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/messageHelpers.ts b/src/meshcore_hub/web/static/js/spa-react/utils/messageHelpers.ts
new file mode 100644
index 0000000..2fb4d43
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/messageHelpers.ts
@@ -0,0 +1,176 @@
+import { resolveChannelLabel } from "@/context/AppConfigContext";
+
+export interface ObserverInfo {
+ public_key?: string | null;
+ node_id?: string | null;
+ observed_at?: string | null;
+ snr?: number | null;
+}
+
+export interface Message {
+ message_type: string;
+ text: string;
+ channel_idx?: number | null;
+ channel_name?: string | null;
+ signature?: string | null;
+ pubkey_prefix?: string | null;
+ sender_name?: string | null;
+ sender_tag_name?: string | null;
+ observed_by?: string | null;
+ observer_name?: string | null;
+ observer_tag_name?: string | null;
+ received_at: string;
+ packet_hash?: string | null;
+ spam_score?: number | null;
+ observers?: ObserverInfo[];
+}
+
+export function parseSenderFromText(text: string | null): {
+ sender: string | null;
+ text: string;
+} {
+ if (!text || typeof text !== "string") {
+ return { sender: null, text: text || "-" };
+ }
+ const patterns = [
+ /^\s*ack\s+@\[(.+?)\]\s*:\s*([\s\S]+)$/i,
+ /^\s*@\[(.+?)\]\s*:\s*([\s\S]+)$/i,
+ /^\s*ack\s+([^:|\n]{1,80})\s*:\s*([\s\S]+)$/i,
+ ];
+ for (const pattern of patterns) {
+ const match = text.match(pattern);
+ if (!match) continue;
+ const sender = (match[1] || "").trim();
+ const remaining = (match[2] || "").trim();
+ if (!sender) continue;
+ return { sender, text: remaining || text };
+ }
+ return { sender: null, text };
+}
+
+export function collapseNewlines(text: string | null): string | null {
+ if (!text || typeof text !== "string") return text;
+ return text.replace(/\s*\n\s*/g, " ");
+}
+
+export function channelInfo(
+ msg: Message,
+ channelLabels: Map,
+ fallbackLabel: string,
+): { label: string | null; text: string } {
+ if (msg.message_type !== "channel") {
+ return { label: null, text: msg.text || "-" };
+ }
+ const rawText = msg.text || "";
+ const match = rawText.match(/^\[([^\]]+)\]\s+([\s\S]*)$/);
+ if (msg.channel_idx !== null && msg.channel_idx !== undefined) {
+ const knownLabel = resolveChannelLabel(msg.channel_idx, channelLabels);
+ if (knownLabel) {
+ return {
+ label: knownLabel,
+ text: match ? match[2] || "-" : rawText || "-",
+ };
+ }
+ }
+ if (msg.channel_name) {
+ return { label: msg.channel_name, text: msg.text || "-" };
+ }
+ if (match) {
+ return { label: match[1], text: match[2] || "-" };
+ }
+ if (msg.channel_idx !== null && msg.channel_idx !== undefined) {
+ const knownLabel = resolveChannelLabel(msg.channel_idx, channelLabels);
+ return { label: knownLabel || `Ch ${msg.channel_idx}`, text: rawText || "-" };
+ }
+ return { label: fallbackLabel, text: rawText || "-" };
+}
+
+export function messageTextWithSender(msg: Message, text: string): string {
+ const parsed = parseSenderFromText(text || "-");
+ const explicitSender =
+ msg.sender_tag_name ||
+ msg.sender_name ||
+ (msg.pubkey_prefix || "").slice(0, 12) ||
+ null;
+ const sender = explicitSender || parsed.sender;
+ const body = collapseNewlines((parsed.text || text || "-").trim()) || "-";
+ if (!sender) return body;
+ if (body.toLowerCase().startsWith(`${sender.toLowerCase()}:`)) return body;
+ return `${sender}: ${body}`;
+}
+
+export function dedupeBySignature(items: T[]): T[] {
+ const deduped: T[] = [];
+ const bySignature = new Map();
+
+ for (const msg of items) {
+ const signature =
+ typeof msg.signature === "string"
+ ? msg.signature.trim().toUpperCase()
+ : "";
+ const canDedupe = msg.message_type === "channel" && signature.length >= 8;
+ if (!canDedupe) {
+ deduped.push(msg);
+ continue;
+ }
+
+ const existing = bySignature.get(signature);
+ if (!existing) {
+ const clone: T = {
+ ...msg,
+ observers: [...(msg.observers ?? [])],
+ } as T;
+ bySignature.set(signature, clone);
+ deduped.push(clone);
+ continue;
+ }
+
+ const combined = [...(existing.observers ?? []), ...(msg.observers ?? [])];
+ const seenReceivers = new Set();
+ existing.observers = combined.filter((recv) => {
+ const key =
+ recv?.public_key ||
+ recv?.node_id ||
+ `${recv?.observed_at ?? ""}:${recv?.snr ?? ""}`;
+ if (seenReceivers.has(key)) return false;
+ seenReceivers.add(key);
+ return true;
+ });
+
+ if (!existing.observed_by && msg.observed_by)
+ existing.observed_by = msg.observed_by;
+ if (!existing.observer_name && msg.observer_name)
+ existing.observer_name = msg.observer_name;
+ if (!existing.observer_tag_name && msg.observer_tag_name)
+ existing.observer_tag_name = msg.observer_tag_name;
+ if (!existing.pubkey_prefix && msg.pubkey_prefix)
+ existing.pubkey_prefix = msg.pubkey_prefix;
+ if (!existing.sender_name && msg.sender_name)
+ existing.sender_name = msg.sender_name;
+ if (!existing.sender_tag_name && msg.sender_tag_name)
+ existing.sender_tag_name = msg.sender_tag_name;
+ if (!existing.channel_name && msg.channel_name)
+ existing.channel_name = msg.channel_name;
+ if (
+ existing.channel_name === "Public" &&
+ msg.channel_name &&
+ msg.channel_name !== "Public"
+ ) {
+ existing.channel_name = msg.channel_name;
+ }
+ if (existing.channel_idx === null || existing.channel_idx === undefined) {
+ if (msg.channel_idx !== null && msg.channel_idx !== undefined) {
+ existing.channel_idx = msg.channel_idx;
+ }
+ } else if (
+ existing.channel_idx === 17 &&
+ msg.channel_idx !== null &&
+ msg.channel_idx !== undefined &&
+ msg.channel_idx !== 17
+ ) {
+ existing.channel_idx = msg.channel_idx;
+ }
+ }
+
+ return deduped;
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/packetGroupHelpers.test.ts b/src/meshcore_hub/web/static/js/spa-react/utils/packetGroupHelpers.test.ts
new file mode 100644
index 0000000..1935b86
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/packetGroupHelpers.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+
+import { groupByObserver } from "@/utils/packetGroupHelpers";
+
+describe("groupByObserver", () => {
+ it("groups receptions by observed_by", () => {
+ const receptions = [
+ { observed_by: "a", snr: 1 },
+ { observed_by: "b", snr: 2 },
+ { observed_by: "a", snr: 3 },
+ ];
+ const groups = groupByObserver(receptions);
+ expect(groups.size).toBe(2);
+ expect(groups.get("a")).toHaveLength(2);
+ expect(groups.get("b")).toHaveLength(1);
+ });
+
+ it("uses __unknown__ key for null observed_by", () => {
+ const groups = groupByObserver([{ observed_by: null }]);
+ expect(groups.has("__unknown__")).toBe(true);
+ });
+
+ it("returns an empty map for empty input", () => {
+ expect(groupByObserver([]).size).toBe(0);
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/packetGroupHelpers.ts b/src/meshcore_hub/web/static/js/spa-react/utils/packetGroupHelpers.ts
new file mode 100644
index 0000000..7cd3b64
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/packetGroupHelpers.ts
@@ -0,0 +1,19 @@
+export interface ReceptionLike {
+ observed_by?: string | null;
+}
+
+export function groupByObserver(
+ receptions: T[],
+): Map {
+ const groups = new Map();
+ for (const r of receptions) {
+ const key = r.observed_by || "__unknown__";
+ const list = groups.get(key);
+ if (list) {
+ list.push(r);
+ } else {
+ groups.set(key, [r]);
+ }
+ }
+ return groups;
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/packetHelpers.test.ts b/src/meshcore_hub/web/static/js/spa-react/utils/packetHelpers.test.ts
new file mode 100644
index 0000000..663fdfe
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/packetHelpers.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+
+import { buildChannelList, packetUrl } from "@/utils/packetHelpers";
+
+describe("buildChannelList", () => {
+ it("parses channel_hash hex into a numeric idx", () => {
+ const result = buildChannelList([
+ { name: "Public", channel_hash: "11" },
+ { name: "Custom", channel_hash: "ff" },
+ ]);
+ expect(result).toEqual([
+ { name: "Public", idx: 17 },
+ { name: "Custom", idx: 255 },
+ ]);
+ });
+
+ it("filters out entries with non-hex hashes", () => {
+ expect(buildChannelList([{ name: "Bad", channel_hash: "xyz" }])).toEqual([]);
+ });
+});
+
+describe("packetUrl", () => {
+ it("uses the hash route when packet_hash exists", () => {
+ expect(packetUrl({ packet_hash: "abc123" })).toBe("/packets/hash/abc123");
+ });
+
+ it("falls back to the first reception packet_id", () => {
+ expect(
+ packetUrl({ receptions: [{ packet_id: "p1" }, { packet_id: "p2" }] }),
+ ).toBe("/packets/p1");
+ });
+
+ it("falls back to /packets when nothing is available", () => {
+ expect(packetUrl({})).toBe("/packets");
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/packetHelpers.ts b/src/meshcore_hub/web/static/js/spa-react/utils/packetHelpers.ts
new file mode 100644
index 0000000..6d75aec
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/packetHelpers.ts
@@ -0,0 +1,24 @@
+import type { ChannelItem } from "@/utils/packets";
+
+export interface ChannelEntry {
+ name: string;
+ idx: number;
+}
+
+export interface PacketGroupItemLike {
+ packet_hash?: string | null;
+ receptions?: { packet_id: string }[];
+}
+
+export function buildChannelList(items: ChannelItem[]): ChannelEntry[] {
+ return items
+ .map((c) => ({ name: c.name, idx: parseInt(c.channel_hash, 16) }))
+ .filter((c) => !Number.isNaN(c.idx));
+}
+
+export function packetUrl(p: PacketGroupItemLike): string {
+ if (p.packet_hash) return `/packets/hash/${p.packet_hash}`;
+ if (p.receptions && p.receptions.length > 0)
+ return `/packets/${p.receptions[0].packet_id}`;
+ return "/packets";
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/packets.test.ts b/src/meshcore_hub/web/static/js/spa-react/utils/packets.test.ts
new file mode 100644
index 0000000..656b5bd
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/packets.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+
+import { buildChannelNames, isNotFoundError } from "@/utils/packets";
+
+describe("buildChannelNames", () => {
+ it("maps hex channel_hash to name by parsed index", () => {
+ const names = buildChannelNames([
+ { name: "General", channel_hash: "0" },
+ { name: "Lobby", channel_hash: "a" },
+ { name: "Ops", channel_hash: "ff" },
+ ]);
+ expect(names.get(0)).toBe("General");
+ expect(names.get(10)).toBe("Lobby");
+ expect(names.get(255)).toBe("Ops");
+ });
+
+ it("skips entries whose hash is not a number", () => {
+ const names = buildChannelNames([
+ { name: "Bad", channel_hash: "zz" },
+ { name: "Good", channel_hash: "1" },
+ ]);
+ expect(names.size).toBe(1);
+ expect(names.get(1)).toBe("Good");
+ });
+
+ it("returns an empty map for no items", () => {
+ expect(buildChannelNames([]).size).toBe(0);
+ });
+});
+
+describe("isNotFoundError", () => {
+ it("detects 404 in the error message", () => {
+ expect(isNotFoundError(new Error("API error: 404 Not Found"))).toBe(true);
+ });
+
+ it("returns false for other errors and non-Error values", () => {
+ expect(isNotFoundError(new Error("API error: 500"))).toBe(false);
+ expect(isNotFoundError("404")).toBe(false);
+ expect(isNotFoundError(null)).toBe(false);
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/packets.ts b/src/meshcore_hub/web/static/js/spa-react/utils/packets.ts
new file mode 100644
index 0000000..37b972c
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/packets.ts
@@ -0,0 +1,17 @@
+export interface ChannelItem {
+ name: string;
+ channel_hash: string;
+}
+
+export function buildChannelNames(items: ChannelItem[]): Map {
+ const names = new Map();
+ for (const c of items) {
+ const idx = parseInt(c.channel_hash, 16);
+ if (!Number.isNaN(idx)) names.set(idx, c.name);
+ }
+ return names;
+}
+
+export function isNotFoundError(e: unknown): boolean {
+ return e instanceof Error && e.message.includes("404");
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/profileHelpers.test.ts b/src/meshcore_hub/web/static/js/spa-react/utils/profileHelpers.test.ts
new file mode 100644
index 0000000..b62f852
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/profileHelpers.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+
+import { hasOperatorOrAdmin } from "@/utils/profileHelpers";
+import { makeConfig } from "@/test/makeConfig";
+
+describe("hasOperatorOrAdmin", () => {
+ it("returns true when roles include operator", () => {
+ expect(hasOperatorOrAdmin(["operator"], makeConfig())).toBe(true);
+ });
+
+ it("returns true when roles include admin", () => {
+ expect(hasOperatorOrAdmin(["admin"], makeConfig())).toBe(true);
+ });
+
+ it("returns false for member-only roles", () => {
+ expect(hasOperatorOrAdmin(["member"], makeConfig())).toBe(false);
+ });
+
+ it("returns false for null roles", () => {
+ expect(hasOperatorOrAdmin(null, makeConfig())).toBe(false);
+ });
+
+ it("respects custom role names from config", () => {
+ const config = makeConfig({ role_names: { operator: "netcop" } });
+ expect(hasOperatorOrAdmin(["netcop"], config)).toBe(true);
+ expect(hasOperatorOrAdmin(["operator"], config)).toBe(false);
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/profileHelpers.ts b/src/meshcore_hub/web/static/js/spa-react/utils/profileHelpers.ts
new file mode 100644
index 0000000..5a7ff3e
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/profileHelpers.ts
@@ -0,0 +1,11 @@
+import type { AppConfig } from "@/types/config";
+
+export function hasOperatorOrAdmin(
+ roles: string[] | null | undefined,
+ config: AppConfig,
+): boolean {
+ const roleNames = config.role_names || {};
+ const operatorRole = roleNames.operator || "operator";
+ const adminRole = roleNames.admin || "admin";
+ return !!roles && (roles.includes(operatorRole) || roles.includes(adminRole));
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/queryClient.ts b/src/meshcore_hub/web/static/js/spa-react/utils/queryClient.ts
new file mode 100644
index 0000000..255796d
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/queryClient.ts
@@ -0,0 +1,15 @@
+import { QueryClient } from "@tanstack/react-query";
+
+export const DEFAULT_STALE_TIME_MS = 30_000;
+
+export function createQueryClient(): QueryClient {
+ return new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: DEFAULT_STALE_TIME_MS,
+ refetchOnWindowFocus: true,
+ retry: 1,
+ },
+ },
+ });
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/queryKeys.ts b/src/meshcore_hub/web/static/js/spa-react/utils/queryKeys.ts
new file mode 100644
index 0000000..7428c8a
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/queryKeys.ts
@@ -0,0 +1,78 @@
+import type { QueryClient } from "@tanstack/react-query";
+
+export const qk = {
+ nodes: {
+ all: ["nodes"] as const,
+ list: (params: unknown) => ["nodes", "list", params] as const,
+ detail: (publicKey: string) => ["nodes", "detail", publicKey] as const,
+ prefix: (prefix: string) => ["nodes", "prefix", prefix] as const,
+ },
+ messages: {
+ all: ["messages"] as const,
+ list: (params: unknown) => ["messages", "list", params] as const,
+ },
+ channels: {
+ all: ["channels"] as const,
+ list: (params: unknown) => ["channels", "list", params] as const,
+ },
+ routes: {
+ all: ["routes"] as const,
+ list: () => ["routes", "list"] as const,
+ detail: (id: string) => ["routes", "detail", id] as const,
+ history: (id: string, days: number) =>
+ ["routes", "history", id, days] as const,
+ },
+ advertisements: {
+ all: ["advertisements"] as const,
+ list: (params: unknown) => ["advertisements", "list", params] as const,
+ },
+ profiles: {
+ all: ["profiles"] as const,
+ list: (params: unknown) => ["profiles", "list", params] as const,
+ detail: (id: string) => ["profiles", "detail", id] as const,
+ me: () => ["profiles", "me"] as const,
+ },
+ dashboard: {
+ all: ["dashboard"] as const,
+ stats: () => ["dashboard", "stats"] as const,
+ series: (kind: string, params: unknown) =>
+ ["dashboard", "series", kind, params] as const,
+ recent: (params: unknown) => ["dashboard", "recent", params] as const,
+ routesOverview: () => ["dashboard", "routes-overview"] as const,
+ },
+ packets: {
+ all: ["packets"] as const,
+ groups: (params: unknown) => ["packets", "groups", params] as const,
+ group: (hash: string) => ["packets", "group", hash] as const,
+ detail: (id: string) => ["packets", "detail", id] as const,
+ },
+ map: {
+ all: ["map"] as const,
+ data: (params: unknown) => ["map", "data", params] as const,
+ },
+};
+
+export const invalidate = {
+ channels: (qc: QueryClient) =>
+ qc.invalidateQueries({ queryKey: qk.channels.all }),
+ routes: (qc: QueryClient) => {
+ qc.invalidateQueries({ queryKey: qk.routes.all });
+ qc.invalidateQueries({ queryKey: qk.dashboard.all });
+ },
+ profiles: (qc: QueryClient) => {
+ qc.invalidateQueries({ queryKey: qk.profiles.all });
+ qc.invalidateQueries({ queryKey: qk.dashboard.all });
+ },
+ nodeTags: (qc: QueryClient) => {
+ qc.invalidateQueries({ queryKey: qk.nodes.all });
+ qc.invalidateQueries({ queryKey: qk.messages.all });
+ qc.invalidateQueries({ queryKey: qk.advertisements.all });
+ qc.invalidateQueries({ queryKey: qk.dashboard.all });
+ },
+ adoptions: (qc: QueryClient) => {
+ qc.invalidateQueries({ queryKey: qk.nodes.all });
+ qc.invalidateQueries({ queryKey: qk.profiles.all });
+ qc.invalidateQueries({ queryKey: qk.advertisements.all });
+ qc.invalidateQueries({ queryKey: qk.dashboard.all });
+ },
+};
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/routesHelpers.test.ts b/src/meshcore_hub/web/static/js/spa-react/utils/routesHelpers.test.ts
new file mode 100644
index 0000000..3b41488
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/routesHelpers.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ qualityOf,
+ qualityBadgeClass,
+ qualityLabel,
+ diagnosisText,
+} from "@/utils/routesHelpers";
+
+const t = (key: string) => key;
+
+describe("qualityOf", () => {
+ it("prefers quality_avg over route_result", () => {
+ expect(
+ qualityOf({ quality_avg: "clear", route_result: { quality: "failing" } }),
+ ).toBe("clear");
+ });
+
+ it("falls back to route_result.quality when quality_avg is empty", () => {
+ expect(
+ qualityOf({ quality_avg: null, route_result: { quality: "marginal" } }),
+ ).toBe("marginal");
+ });
+
+ it("returns unknown when neither is available", () => {
+ expect(qualityOf({})).toBe("unknown");
+ });
+});
+
+describe("qualityBadgeClass", () => {
+ it("returns neutral when disabled", () => {
+ expect(qualityBadgeClass("clear", false)).toBe("badge-neutral");
+ });
+
+ it("returns the correct class for each known quality", () => {
+ expect(qualityBadgeClass("clear", true)).toBe("badge-success");
+ expect(qualityBadgeClass("marginal", true)).toBe("badge-warning");
+ expect(qualityBadgeClass("failing", true)).toBe("badge-error");
+ expect(qualityBadgeClass("no_coverage", true)).toBe("badge-info");
+ expect(qualityBadgeClass("unknown", true)).toBe("badge-ghost");
+ });
+
+ it("returns ghost for unmapped qualities", () => {
+ expect(qualityBadgeClass("bizarre", true)).toBe("badge-ghost");
+ });
+});
+
+describe("qualityLabel", () => {
+ it("returns the disabled label when not enabled", () => {
+ expect(qualityLabel("clear", false, t)).toBe("routes.disabled");
+ });
+
+ it("returns the translated label for a known quality", () => {
+ expect(qualityLabel("clear", true, t)).toBe("routes.quality_clear");
+ expect(qualityLabel("failing", true, t)).toBe("routes.quality_failing");
+ });
+});
+
+describe("diagnosisText", () => {
+ it("returns empty string when there is no route result", () => {
+ expect(diagnosisText({ enabled: true }, t)).toBe("");
+ });
+
+ it("returns empty string when the route is disabled", () => {
+ expect(
+ diagnosisText(
+ { enabled: false, route_result: { state: "healthy" } },
+ t,
+ ),
+ ).toBe("");
+ });
+
+ it("returns the healthy diagnosis", () => {
+ expect(
+ diagnosisText({ enabled: true, route_result: { state: "healthy" } }, t),
+ ).toBe("routes.diagnosis_healthy");
+ });
+
+ it("returns the unhealthy diagnosis", () => {
+ expect(
+ diagnosisText({ enabled: true, route_result: { state: "unhealthy" } }, t),
+ ).toBe("routes.diagnosis_unhealthy");
+ });
+
+ it("returns the no_coverage diagnosis", () => {
+ expect(
+ diagnosisText(
+ { enabled: true, route_result: { state: "no_coverage" } },
+ t,
+ ),
+ ).toBe("routes.diagnosis_no_coverage");
+ });
+});
diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/routesHelpers.ts b/src/meshcore_hub/web/static/js/spa-react/utils/routesHelpers.ts
new file mode 100644
index 0000000..e54ae26
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/utils/routesHelpers.ts
@@ -0,0 +1,56 @@
+export type TranslateFn = (
+ key: string,
+ params?: Record,
+) => string;
+
+export interface RouteResultLike {
+ quality?: string | null;
+ state?: string | null;
+}
+
+export interface RouteItemLike {
+ quality_avg?: string | null;
+ enabled?: boolean;
+ route_result?: RouteResultLike | null;
+}
+
+export function qualityOf(route: RouteItemLike): string {
+ return route.quality_avg || route.route_result?.quality || "unknown";
+}
+
+export function qualityBadgeClass(quality: string, enabled: boolean): string {
+ if (!enabled) return "badge-neutral";
+ const map: Record = {
+ clear: "badge-success",
+ marginal: "badge-warning",
+ failing: "badge-error",
+ no_coverage: "badge-info",
+ unknown: "badge-ghost",
+ };
+ return map[quality] || "badge-ghost";
+}
+
+export function qualityLabel(
+ quality: string,
+ enabled: boolean,
+ t: TranslateFn,
+): string {
+ if (!enabled) return t("routes.disabled");
+ const map: Record = {
+ clear: t("routes.quality_clear"),
+ marginal: t("routes.quality_marginal"),
+ failing: t("routes.quality_failing"),
+ no_coverage: t("routes.quality_no_coverage"),
+ unknown: t("routes.quality_unknown"),
+ };
+ return map[quality] || quality || t("routes.quality_unknown");
+}
+
+export function diagnosisText(route: RouteItemLike, t: TranslateFn): string {
+ const result = route.route_result;
+ if (!result || !route.enabled) return "";
+ if (result.state === "healthy") return t("routes.diagnosis_healthy");
+ if (result.state === "unhealthy") return t("routes.diagnosis_unhealthy");
+ if (result.state === "no_coverage") return t("routes.diagnosis_no_coverage");
+ return "";
+}
diff --git a/src/meshcore_hub/web/static/js/spa-react/vite-env.d.ts b/src/meshcore_hub/web/static/js/spa-react/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/src/meshcore_hub/web/static/js/spa/api.js b/src/meshcore_hub/web/static/js/spa/api.js
deleted file mode 100644
index f572a53..0000000
--- a/src/meshcore_hub/web/static/js/spa/api.js
+++ /dev/null
@@ -1,130 +0,0 @@
-/**
- * MeshCore Hub SPA - API Client
- *
- * Wrapper around fetch() for making API calls to the proxied backend.
- */
-
-/**
- * Returns true if the error is a fetch abort (e.g. the request was cancelled
- * because the user navigated to another page).
- * @param {*} e
- * @returns {boolean}
- */
-export function isAbortError(e) {
- return !!e && e.name === 'AbortError';
-}
-
-/**
- * Make a GET request and return parsed JSON.
- * @param {string} path - URL path (e.g., '/api/v1/nodes')
- * @param {Object} [params] - Query parameters
- * @param {Object} [options] - Extra options
- * @param {AbortSignal} [options.signal] - Signal to cancel the request (e.g. on navigation)
- * @returns {Promise} Parsed JSON response
- */
-export async function apiGet(path, params = {}, { signal } = {}) {
- const url = new URL(path, window.location.origin);
- for (const [k, v] of Object.entries(params)) {
- if (v !== null && v !== undefined && v !== '') {
- if (Array.isArray(v)) {
- v.forEach(item => url.searchParams.append(k, String(item)));
- } else {
- url.searchParams.set(k, String(v));
- }
- }
- }
- const response = await fetch(url, { signal });
- if (!response.ok) {
- throw new Error(`API error: ${response.status} ${response.statusText}`);
- }
- return response.json();
-}
-
-/**
- * Check response for auth errors and redirect to login if needed.
- * @param {Response} response
- */
-function checkAuthResponse(response) {
- const config = window.__APP_CONFIG__ || {};
- if (config.oidc_enabled && response.status === 401) {
- const next = encodeURIComponent(window.location.pathname + window.location.search);
- window.location.href = `/auth/login?next=${next}`;
- }
-}
-
-/**
- * Make a POST request with JSON body.
- * @param {string} path - URL path
- * @param {Object} body - Request body
- * @returns {Promise} Parsed JSON response
- */
-export async function apiPost(path, body) {
- const response = await fetch(path, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
- checkAuthResponse(response);
- if (!response.ok) {
- const text = await response.text();
- throw new Error(`API error: ${response.status} - ${text}`);
- }
- if (response.status === 204) return null;
- return response.json();
-}
-
-/**
- * Make a PUT request with JSON body.
- * @param {string} path - URL path
- * @param {Object} body - Request body
- * @returns {Promise} Parsed JSON response
- */
-export async function apiPut(path, body) {
- const response = await fetch(path, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
- checkAuthResponse(response);
- if (!response.ok) {
- const text = await response.text();
- throw new Error(`API error: ${response.status} - ${text}`);
- }
- if (response.status === 204) return null;
- return response.json();
-}
-
-/**
- * Make a DELETE request.
- * @param {string} path - URL path
- * @returns {Promise}
- */
-export async function apiDelete(path) {
- const response = await fetch(path, { method: 'DELETE' });
- checkAuthResponse(response);
- if (!response.ok) {
- const text = await response.text();
- throw new Error(`API error: ${response.status} - ${text}`);
- }
-}
-
-/**
- * Make a POST request with form-encoded body.
- * @param {string} path - URL path
- * @param {Object} data - Form data as key-value pairs
- * @returns {Promise} Parsed JSON response
- */
-export async function apiPostForm(path, data) {
- const body = new URLSearchParams(data);
- const response = await fetch(path, {
- method: 'POST',
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
- body: body.toString(),
- });
- if (!response.ok) {
- const text = await response.text();
- throw new Error(`API error: ${response.status} - ${text}`);
- }
- if (response.status === 204) return null;
- return response.json();
-}
diff --git a/src/meshcore_hub/web/static/js/spa/app.js b/src/meshcore_hub/web/static/js/spa/app.js
deleted file mode 100644
index d617122..0000000
--- a/src/meshcore_hub/web/static/js/spa/app.js
+++ /dev/null
@@ -1,277 +0,0 @@
-/**
- * MeshCore Hub SPA - Main Application Entry Point
- *
- * Initializes i18n, the router, registers all page routes,
- * and handles navigation.
- */
-
-import { Router } from './router.js';
-import { isAbortError } from './api.js';
-import { html, litRender, getConfig, hasRole, renderAuthSection } from './components.js';
-import { loadLocale, t } from './i18n.js';
-import { iconHome, iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconPackets, iconMap, iconMembers, iconPage, iconChannel, iconPath } from './icons.js';
-
-// Page modules (lazy-loaded)
-const pages = {
- home: () => import('./pages/home.js'),
- dashboard: () => import('./pages/dashboard.js'),
- nodes: () => import('./pages/nodes.js'),
- nodeDetail: () => import('./pages/node-detail.js'),
- messages: () => import('./pages/messages.js'),
- advertisements: () => import('./pages/advertisements.js'),
- packets: () => import('./pages/packets.js'),
- packetDetail: () => import('./pages/packet-detail.js'),
- packetGroupDetail: () => import('./pages/packet-group-detail.js'),
- map: () => import('./pages/map.js'),
- members: () => import('./pages/members.js'),
- channels: () => import('./pages/channels.js'),
- routes: () => import('./pages/routes.js'),
- customPage: () => import('./pages/custom-page.js'),
- notFound: () => import('./pages/not-found.js'),
- profile: () => import('./pages/profile.js'),
- maintenance: () => import('./pages/maintenance.js'),
-};
-
-// Main app container
-const appContainer = document.getElementById('app');
-const router = new Router();
-
-// Read feature flags from config
-const config = getConfig();
-const features = config.features || {};
-
-/**
- * Create a route handler that lazy-loads a page module and calls its render function.
- * @param {Function} loader - Module loader function
- * @returns {Function} Route handler
- */
-function pageHandler(loader) {
- return async (params) => {
- try {
- const module = await loader();
- return await module.render(appContainer, params, router);
- } catch (e) {
- // Navigating away cancels in-flight requests — not a real error.
- if (isAbortError(e)) return;
- console.error('Page load error:', e);
- appContainer.innerHTML = `
-
- ${t('common.error')}
- ${t('common.failed_to_load_page')}
- ${e.message || 'Unknown error'}
- ${t('common.go_home')}
- `;
- }
- };
-}
-
-// Maintenance mode: every route renders the maintenance page and no
-// API-backed page module is ever loaded.
-const maintenanceMode = config.system_maintenance === true;
-
-// Register routes (conditionally based on feature flags)
-if (maintenanceMode) {
- const maintenanceHandler = pageHandler(pages.maintenance);
- router.addRoute('/', maintenanceHandler);
- router.setNotFound(maintenanceHandler);
-} else {
-router.addRoute('/', pageHandler(pages.home));
-
-if (features.dashboard !== false) {
- router.addRoute('/dashboard', pageHandler(pages.dashboard));
-}
-if (features.nodes !== false) {
- router.addRoute('/nodes', pageHandler(pages.nodes));
- router.addRoute('/nodes/:publicKey', pageHandler(pages.nodeDetail));
- router.addRoute('/n/:prefix', async (params) => {
- // Short link redirect
- router.navigate(`/nodes/${params.prefix}`, true);
- });
-}
-if (features.channels !== false) {
- router.addRoute('/channels', pageHandler(pages.channels));
-}
-if (features.routes !== false) {
- router.addRoute('/routes', pageHandler(pages.routes));
-}
-if (features.messages !== false) {
- router.addRoute('/messages', pageHandler(pages.messages));
-}
-if (features.advertisements !== false) {
- router.addRoute('/advertisements', pageHandler(pages.advertisements));
-}
-if (features.packets !== false) {
- router.addRoute('/packets', pageHandler(pages.packets));
- router.addRoute('/packets/hash/:hash', pageHandler(pages.packetGroupDetail));
- router.addRoute('/packets/:id', pageHandler(pages.packetDetail));
-}
-if (features.map !== false) {
- router.addRoute('/map', pageHandler(pages.map));
-}
-if (features.members !== false) {
- router.addRoute('/members', pageHandler(pages.members));
-}
-if (features.pages !== false) {
- router.addRoute('/pages/:slug', pageHandler(pages.customPage));
-}
-
-// Profile route (only register when OIDC enabled)
-if (config.oidc_enabled) {
- router.addRoute('/profile', pageHandler(pages.profile));
- router.addRoute('/profile/:id', pageHandler(pages.profile));
-}
-
-// 404 handler
-router.setNotFound(pageHandler(pages.notFound));
-}
-
-/**
- * Update the active state of navigation links.
- * @param {string} pathname - Current URL path
- */
-function updateNavActiveState(pathname) {
- document.querySelectorAll('[data-nav-link]').forEach(link => {
- const href = link.getAttribute('href');
- let isActive = false;
-
- if (href === '/') {
- isActive = pathname === '/';
- } else if (href === '/nodes') {
- isActive = pathname.startsWith('/nodes');
- } else {
- isActive = pathname === href || pathname.startsWith(href + '/');
- }
-
- if (isActive) {
- link.classList.add('active');
- } else {
- link.classList.remove('active');
- }
- });
-
- // Close mobile dropdown if open (DaisyUI dropdowns stay open while focused)
- if (document.activeElement?.closest('.dropdown')) {
- document.activeElement.blur();
- }
-}
-
-/**
- * Compose a page title from entity name and network name.
- * @param {string} entityKey - Translation key for entity (e.g., 'entities.dashboard')
- * @returns {string}
- */
-function composePageTitle(entityKey) {
- const networkName = config.network_name || 'MeshCore Network';
- const entity = t(entityKey);
- return `${entity} - ${networkName}`;
-}
-
-/**
- * Update the page title based on the current route.
- * @param {string} pathname
- */
-function updatePageTitle(pathname) {
- const networkName = config.network_name || 'MeshCore Network';
- const titles = {
- '/': networkName,
- };
-
- // Add feature-dependent titles
- if (features.dashboard !== false) titles['/dashboard'] = composePageTitle('entities.dashboard');
- if (features.nodes !== false) titles['/nodes'] = composePageTitle('entities.nodes');
- if (features.channels !== false) titles['/channels'] = composePageTitle('entities.channels');
- if (features.routes !== false) titles['/routes'] = composePageTitle('entities.routes');
- if (features.messages !== false) titles['/messages'] = composePageTitle('entities.messages');
- if (features.advertisements !== false) titles['/advertisements'] = composePageTitle('entities.advertisements');
- if (features.packets !== false) titles['/packets'] = composePageTitle('entities.packets');
- if (features.map !== false) titles['/map'] = composePageTitle('entities.map');
- if (features.members !== false) titles['/members'] = composePageTitle('entities.members');
- titles['/profile'] = composePageTitle('links.profile');
-
- if (titles[pathname]) {
- document.title = titles[pathname];
- } else if (pathname.startsWith('/nodes/')) {
- document.title = composePageTitle('entities.node_detail');
- } else if (pathname.startsWith('/pages/')) {
- // Custom pages set their own title in the page module
- document.title = networkName;
- } else {
- document.title = networkName;
- }
-}
-
-// Set up navigation callback
-router.onNavigate((pathname) => {
- updateNavActiveState(pathname);
- updatePageTitle(pathname);
-});
-
-/**
- * Render the mobile navigation dropdown.
- * Populates the #mobile-nav container with nav items based on config features.
- * @param {Object} config - App configuration object
- */
-function renderMobileNav(config) {
- const container = document.getElementById('mobile-nav');
- if (!container) return;
-
- const features = config.features || {};
- const customPages = config.custom_pages || [];
-
- const items = [];
-
- items.push(html`${iconHome('h-5 w-5')} ${t('entities.home')} `);
-
- if (features.dashboard !== false) {
- items.push(html`${iconDashboard('h-5 w-5 nav-icon-dashboard')} ${t('entities.dashboard')} `);
- }
- if (features.nodes !== false) {
- items.push(html`${iconNodes('h-5 w-5 nav-icon-nodes')} ${t('entities.nodes')} `);
- }
- if (features.advertisements !== false) {
- items.push(html`${iconAdvertisements('h-5 w-5 nav-icon-adverts')} ${t('entities.advertisements')} `);
- }
- if (features.routes !== false) {
- items.push(html`${iconPath('h-5 w-5 nav-icon-routes')} ${t('entities.routes')} `);
- }
- if (features.channels !== false) {
- items.push(html`${iconChannel('h-5 w-5 nav-icon-channels')} ${t('entities.channels')} `);
- }
- if (features.messages !== false) {
- items.push(html`${iconMessages('h-5 w-5 nav-icon-messages')} ${t('entities.messages')} `);
- }
- if (features.packets !== false) {
- items.push(html`${iconPackets('h-5 w-5 nav-icon-packets')} ${t('entities.packets')} `);
- }
- if (features.map !== false) {
- items.push(html`${iconMap('h-5 w-5 nav-icon-map')} ${t('entities.map')} `);
- }
- if (features.members !== false) {
- items.push(html`${iconMembers('h-5 w-5 nav-icon-members')} ${t('entities.members')} `);
- }
-
- if (features.pages !== false && customPages.length > 0) {
- for (const page of customPages) {
- items.push(html`${iconPage('h-5 w-5')} ${page.title} `);
- }
- }
-
- litRender(html`${items}`, container);
-}
-
-// Load locale then start the router
-const locale = localStorage.getItem('meshcore-locale') || config.locale || 'en';
-await loadLocale(locale);
-
-// Legacy cleanup: remove the old per-observer localStorage key so stale public
-// keys are never misread as area codes by the new area-based filter.
-try { localStorage.removeItem('meshcore-observers-disabled'); } catch {}
-
-// Render auth section in navbar (after translations are loaded)
-const authSection = document.getElementById('auth-section');
-renderAuthSection(authSection, config);
-
-// Render mobile nav (after translations are loaded)
-renderMobileNav(config);
-
-router.start();
diff --git a/src/meshcore_hub/web/static/js/spa/auto-refresh.js b/src/meshcore_hub/web/static/js/spa/auto-refresh.js
deleted file mode 100644
index d358a79..0000000
--- a/src/meshcore_hub/web/static/js/spa/auto-refresh.js
+++ /dev/null
@@ -1,87 +0,0 @@
-/**
- * Auto-refresh utility for list pages.
- *
- * Reads `auto_refresh_seconds` from the app config. When the interval is > 0
- * it sets up a periodic timer that calls the provided `fetchAndRender` callback
- * and renders a pause/play toggle button into the given container element.
- */
-
-import { html, litRender, getConfig, t } from './components.js';
-import { iconRefresh } from './icons.js';
-
-/**
- * Create an auto-refresh controller.
- *
- * @param {Object} options
- * @param {Function} options.fetchAndRender - Async function that fetches data and re-renders the page.
- * @param {HTMLElement} options.toggleContainer - Element to render the pause/play toggle into.
- * @returns {{ cleanup: Function }} cleanup function to stop the timer.
- */
-export function createAutoRefresh({ fetchAndRender, toggleContainer }) {
- const config = getConfig();
- const intervalSeconds = config.auto_refresh_seconds || 0;
-
- if (!intervalSeconds || !toggleContainer) {
- return { cleanup() {} };
- }
-
- let paused = false;
- let isPending = false;
- let timerId = null;
-
- function renderToggle() {
- const tooltip = paused ? t('auto_refresh.resume') : t('auto_refresh.pause');
-
- litRender(html`
-
- `, toggleContainer);
- }
-
- function onToggle(e) {
- paused = !e.target.checked;
- if (paused) {
- clearInterval(timerId);
- timerId = null;
- } else {
- startTimer();
- }
- renderToggle();
- }
-
- async function tick() {
- if (isPending || paused) return;
- isPending = true;
- try {
- await fetchAndRender();
- } catch (_e) {
- // Errors are handled inside fetchAndRender; don't stop the timer.
- } finally {
- isPending = false;
- }
- }
-
- function startTimer() {
- timerId = setInterval(tick, intervalSeconds * 1000);
- }
-
- // Initial render and start
- renderToggle();
- startTimer();
-
- return {
- cleanup() {
- if (timerId) {
- clearInterval(timerId);
- timerId = null;
- }
- },
- };
-}
diff --git a/src/meshcore_hub/web/static/js/spa/components.js b/src/meshcore_hub/web/static/js/spa/components.js
deleted file mode 100644
index 1b82690..0000000
--- a/src/meshcore_hub/web/static/js/spa/components.js
+++ /dev/null
@@ -1,838 +0,0 @@
-/**
- * MeshCore Hub SPA - Shared UI Components
- *
- * Reusable rendering functions using lit-html.
- *
- * Styling conventions:
- * - Page : `text-3xl font-bold`; header row wrapper: `mb-6`.
- * - Content/detail cards: `card bg-base-100 shadow-xl`; stat cards and
- * table wrappers: `shadow-sm`; mobile list cards: `shadow-sm`.
- * - Muted text: `opacity-70` (primary), `opacity-60` (secondary),
- * `opacity-50` (timestamps/tertiary) — not `text-base-content/N`.
- * - Empty states: `text-center py-8 opacity-70`.
- * - Panel-level surfaces: `rounded-box`; small inline chips: `rounded`.
- * - Buttons: table-row icon actions `btn btn-xs btn-ghost`; card-level
- * labeled actions `btn btn-xs btn-outline` (+ `btn-error` on delete);
- * modal submits full-size `btn btn-primary`, inline/header actions `btn-sm`.
- */
-
-import { html, nothing } from 'lit-html';
-import { render } from 'lit-html';
-import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
-import { t } from './i18n.js';
-import { iconAlert, iconError, iconInfo, iconSuccess, iconUser, iconLogout, iconFilter } from './icons.js';
-
-// Re-export lit-html utilities for page modules
-export { html, nothing, unsafeHTML };
-export { render as litRender } from 'lit-html';
-export { t } from './i18n.js';
-
-function buildSortUrl(basePath, params, nextSort, nextOrder) {
- const sp = new URLSearchParams();
- for (const [key, value] of Object.entries(params)) {
- if (value !== null && value !== undefined && value !== '') {
- if (Array.isArray(value)) {
- value.forEach(item => sp.append(key, String(item)));
- } else {
- sp.set(key, String(value));
- }
- }
- }
- if (nextSort && nextOrder) {
- sp.set('sort', nextSort);
- sp.set('order', nextOrder);
- }
- const qs = sp.toString();
- return qs ? `${basePath}?${qs}` : basePath;
-}
-
-export function sortableTableHeader(label, { sortKey, currentSort, currentOrder, navigate, basePath, params }) {
- let indicator = '';
- let nextOrder;
-
- if (currentSort !== sortKey) {
- nextOrder = 'asc';
- } else if (currentOrder === 'asc') {
- nextOrder = 'desc';
- indicator = ' \u25B4';
- } else {
- nextOrder = 'asc';
- indicator = ' \u25BE';
- }
-
- const url = buildSortUrl(basePath, params, sortKey, nextOrder);
-
- return html`
- { e.preventDefault(); e.stopPropagation(); navigate(url); }}>
- ${label}${indicator}
-
- `;
-}
-
-export function mobileSortSelect({ currentSort, currentOrder, navigate, basePath, params, options }) {
- const currentValue = `${currentSort}:${currentOrder}`;
-
- const sortOptions = options.map(opt =>
- html``
- );
-
- const onChange = (e) => {
- const [sort, order] = e.target.value.split(':');
- const url = buildSortUrl(basePath, params, sort, order);
- navigate(url);
- };
-
- return html`
-
- ${t('common.sort_by')}
-
-
- `;
-}
-
-/**
- * Get app config from the embedded window object.
- * @returns {Object} App configuration
- */
-export function getConfig() {
- return window.__APP_CONFIG__ || {};
-}
-
-/**
- * Check if the current session has a specific role.
- * Returns true when OIDC is disabled (open access).
- * Translates symbolic role names (e.g. "admin") to actual IdP role names
- * via the role_names config mapping.
- * @param {string} roleName - Symbolic role to check
- * @returns {boolean}
- */
-export function hasRole(roleName) {
- const config = getConfig();
- if (!config.oidc_enabled) return false;
- const actualRole = (config.role_names || {})[roleName] || roleName;
- return (config.roles || []).includes(actualRole);
-}
-
-/**
- * Build channel label map from app config.
- * Keys are numeric channel indexes and values are non-empty labels.
- *
- * @param {Object} [config]
- * @returns {Map}
- */
-export function getChannelLabelsMap(config = getConfig()) {
- return new Map(
- Object.entries(config.channel_labels || {})
- .map(([idx, label]) => [parseInt(idx, 10), typeof label === 'string' ? label.trim() : ''])
- .filter(([idx, label]) => Number.isInteger(idx) && label.length > 0),
- );
-}
-
-/**
- * Resolve a channel label from a numeric index.
- *
- * @param {number|string} channelIdx
- * @param {Map} [channelLabels]
- * @returns {string|null}
- */
-export function resolveChannelLabel(channelIdx, channelLabels = getChannelLabelsMap()) {
- const parsed = parseInt(String(channelIdx), 10);
- if (!Number.isInteger(parsed)) return null;
- return channelLabels.get(parsed) || null;
-}
-
-/**
- * Parse API datetime strings reliably.
- * MeshCore API often returns UTC timestamps without an explicit timezone suffix.
- * In that case, treat them as UTC by appending 'Z' before Date parsing.
- *
- * @param {string|null} isoString
- * @returns {Date|null}
- */
-export function parseAppDate(isoString) {
- if (!isoString || typeof isoString !== 'string') return null;
-
- let value = isoString.trim();
- if (!value) return null;
-
- // Normalize "YYYY-MM-DD HH:MM:SS" to ISO separator.
- if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}/.test(value)) {
- value = value.replace(/\s+/, 'T');
- }
-
- // If no timezone suffix is present, treat as UTC.
- const hasTimePart = /T\d{2}:\d{2}/.test(value);
- const hasTimezoneSuffix = /(Z|[+-]\d{2}:\d{2}|[+-]\d{4})$/i.test(value);
- if (hasTimePart && !hasTimezoneSuffix) {
- value += 'Z';
- }
-
- const parsed = new Date(value);
- if (isNaN(parsed.getTime())) return null;
- return parsed;
-}
-
-/**
- * Page color palette - reads from CSS custom properties (defined in app.css :root).
- * Use for inline styles or dynamic coloring in page modules.
- */
-export const pageColors = {
- get dashboard() { return getComputedStyle(document.documentElement).getPropertyValue('--color-dashboard').trim(); },
- get nodes() { return getComputedStyle(document.documentElement).getPropertyValue('--color-nodes').trim(); },
- get adverts() { return getComputedStyle(document.documentElement).getPropertyValue('--color-adverts').trim(); },
- get messages() { return getComputedStyle(document.documentElement).getPropertyValue('--color-messages').trim(); },
- get packets() { return getComputedStyle(document.documentElement).getPropertyValue('--color-packets').trim(); },
- get map() { return getComputedStyle(document.documentElement).getPropertyValue('--color-map').trim(); },
- get members() { return getComputedStyle(document.documentElement).getPropertyValue('--color-members').trim(); },
-};
-
-// --- Formatting Helpers (return strings) ---
-
-/**
- * Format a number with locale-appropriate grouping separators.
- * Uses the visitor's browser locale (no explicit locale argument).
- * @param {number|string|null|undefined} value
- * @returns {string} Grouped number string, or '' for missing values
- */
-export function formatNumber(value) {
- if (value === null || value === undefined || value === '') return '';
- const n = Number(value);
- if (!Number.isFinite(n)) return String(value);
- return new Intl.NumberFormat().format(n);
-}
-window.formatNumber = formatNumber;
-
-/**
- * Get the type emoji for a node advertisement type.
- * @param {string|null} advType
- * @returns {string} Emoji character
- */
-function inferNodeType(value) {
- const normalized = (value || '').toLowerCase();
- if (!normalized) return null;
- if (normalized.includes('room')) return 'room';
- if (normalized.includes('repeater') || normalized.includes('relay')) return 'repeater';
- if (normalized.includes('companion') || normalized.includes('observer')) return 'companion';
- if (normalized.includes('chat')) return 'chat';
- return null;
-}
-
-export function typeEmoji(advType) {
- switch (inferNodeType(advType) || (advType || '').toLowerCase()) {
- case 'chat': return '\u{1F4AC}'; // 💬
- case 'repeater': return '\u{1F4E1}'; // 📡
- case 'companion': return '\u{1F4F1}'; // 📱
- case 'room': return '\u{1FAA7}'; // 🪧
- default: return '\u{1F4CD}'; // 📍
- }
-}
-
-/**
- * Extract the first emoji from a string.
- * Uses a regex pattern that matches emoji characters including compound emojis.
- * @param {string|null} str
- * @returns {string|null} First emoji found, or null if none
- */
-export function extractFirstEmoji(str) {
- if (!str) return null;
- // Match emoji using Unicode ranges and zero-width joiners
- const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{231A}-\u{231B}\u{23E9}-\u{23FA}\u{25AA}-\u{25AB}\u{25B6}\u{25C0}\u{25FB}-\u{25FE}\u{2B50}\u{2B55}\u{3030}\u{303D}\u{3297}\u{3299}](?:\u{FE0F})?(?:\u{200D}[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}](?:\u{FE0F})?)*|\u{00A9}|\u{00AE}|\u{203C}|\u{2049}|\u{2122}|\u{2139}|\u{2194}-\u{2199}|\u{21A9}-\u{21AA}|\u{24C2}|\u{2934}-\u{2935}|\u{2B05}-\u{2B07}|\u{2B1B}-\u{2B1C}/u;
- const match = str.match(emojiRegex);
- return match ? match[0] : null;
-}
-
-/**
- * Get the display emoji for a node.
- * Prefers the first emoji from the node name, falls back to type emoji.
- * @param {string|null} nodeName - Node's display name
- * @param {string|null} advType - Advertisement type
- * @returns {string} Emoji character to display
- */
-export function getNodeEmoji(nodeName, advType) {
- const nameEmoji = extractFirstEmoji(nodeName);
- if (nameEmoji) return nameEmoji;
- const inferred = inferNodeType(advType) || inferNodeType(nodeName);
- return typeEmoji(inferred || advType);
-}
-
-/**
- * Format an ISO datetime string to the configured timezone.
- * @param {string|null} isoString
- * @param {Object} [options] - Intl.DateTimeFormat options override
- * @returns {string} Formatted datetime string
- */
-export function formatDateTime(isoString, options) {
- if (!isoString) return '-';
- try {
- const config = getConfig();
- const tz = config.timezone_iana || 'UTC';
- const locale = config.datetime_locale || 'en-US';
- const date = parseAppDate(isoString);
- if (!date) return '-';
- const opts = options || {
- timeZone: tz,
- year: 'numeric', month: '2-digit', day: '2-digit',
- hour: '2-digit', minute: '2-digit', second: '2-digit',
- hour12: false,
- };
- if (!opts.timeZone) opts.timeZone = tz;
- return date.toLocaleString(locale, opts);
- } catch {
- return isoString ? isoString.slice(0, 19).replace('T', ' ') : '-';
- }
-}
-
-/**
- * Format an ISO datetime string to short format (date + HH:MM).
- * @param {string|null} isoString
- * @returns {string}
- */
-export function formatDateTimeShort(isoString) {
- if (!isoString) return '-';
- try {
- const config = getConfig();
- const tz = config.timezone_iana || 'UTC';
- const locale = config.datetime_locale || 'en-US';
- const date = parseAppDate(isoString);
- if (!date) return '-';
- return date.toLocaleString(locale, {
- timeZone: tz,
- year: 'numeric', month: '2-digit', day: '2-digit',
- hour: '2-digit', minute: '2-digit',
- hour12: false,
- });
- } catch {
- return isoString ? isoString.slice(0, 16).replace('T', ' ') : '-';
- }
-}
-
-/**
- * Format an ISO datetime as relative time (e.g., "2m ago", "1h ago").
- * @param {string|null} isoString
- * @returns {string}
- */
-export function formatRelativeTime(isoString) {
- if (!isoString) return '';
- const date = parseAppDate(isoString);
- if (!date) return '';
- const now = new Date();
- const diffMs = now - date;
- const diffSec = Math.floor(diffMs / 1000);
- const diffMin = Math.floor(diffSec / 60);
- const diffHour = Math.floor(diffMin / 60);
- const diffDay = Math.floor(diffHour / 24);
- if (diffDay > 0) return t('time.days_ago', { count: diffDay });
- if (diffHour > 0) return t('time.hours_ago', { count: diffHour });
- if (diffMin > 0) return t('time.minutes_ago', { count: diffMin });
- return t('time.less_than_minute');
-}
-
-/**
- * Truncate a public key for display.
- * @param {string} key - Full public key
- * @param {number} [length=12] - Characters to show
- * @returns {string} Truncated key with ellipsis
- */
-export function truncateKey(key, length = 12) {
- if (!key) return '-';
- if (key.length <= length) return key;
- return key.slice(0, length) + '...';
-}
-
-/**
- * Escape HTML special characters. Rarely needed with lit-html
- * since template interpolation auto-escapes, but kept for edge cases.
- * @param {string} str
- * @returns {string}
- */
-export function escapeHtml(str) {
- if (!str) return '';
- const div = document.createElement('div');
- div.textContent = str;
- return div.innerHTML;
-}
-
-/**
- * Copy text to clipboard with visual feedback.
- * Updates the target element to show "Copied!" temporarily.
- * Falls back to execCommand for browsers without Clipboard API.
- * @param {Event} e - Click event
- * @param {string} text - Text to copy to clipboard
- */
-export function copyToClipboard(e, text) {
- e.preventDefault();
- e.stopPropagation();
-
- // Capture target element synchronously before async operations
- const targetElement = e.currentTarget;
-
- const showSuccess = (target) => {
- const originalText = target.textContent;
- target.textContent = 'Copied!';
- target.classList.add('text-success');
- setTimeout(() => {
- target.textContent = originalText;
- target.classList.remove('text-success');
- }, 1500);
- };
-
- // Try modern Clipboard API first
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(text).then(() => {
- showSuccess(targetElement);
- }).catch(err => {
- console.error('Clipboard API failed:', err);
- fallbackCopy(text, targetElement);
- });
- } else {
- // Fallback for older browsers or non-secure contexts
- fallbackCopy(text, targetElement);
- }
-
- function fallbackCopy(text, target) {
- const textArea = document.createElement('textarea');
- textArea.value = text;
- textArea.style.position = 'fixed';
- textArea.style.left = '-999999px';
- textArea.style.top = '-999999px';
- document.body.appendChild(textArea);
- textArea.focus();
- textArea.select();
- try {
- document.execCommand('copy');
- showSuccess(target);
- } catch (err) {
- console.error('Fallback copy failed:', err);
- }
- document.body.removeChild(textArea);
- }
-}
-
-// --- UI Components (return lit-html TemplateResult) ---
-
-/**
- * Render a node display with emoji, name, and optional description.
- * Used for consistent node representation across lists (nodes, advertisements, messages, etc.).
- *
- * @param {Object} options - Node display options
- * @param {string|null} options.name - Node display name (from tag or advertised name)
- * @param {string|null} options.description - Node description from tags
- * @param {string} options.publicKey - Node public key (for fallback display)
- * @param {string|null} options.advType - Advertisement type (chat, repeater, room)
- * @param {string} [options.size='base'] - Size variant: 'sm' (small lists) or 'base' (normal)
- * @returns {TemplateResult} lit-html template
- */
-export function renderNodeDisplay({ name, description, publicKey, advType, size = 'base' }) {
- const displayName = name || null;
- const emoji = getNodeEmoji(name, advType);
- const emojiSize = 'text-lg';
- const nameSize = size === 'sm' ? 'text-sm' : 'text-base';
- const descSize = size === 'sm' ? 'text-xs' : 'text-xs';
-
- const nameBlock = displayName
- ? html`${displayName}
- ${description ? html`${description}` : nothing}`
- : html`${publicKey.slice(0, 16)}...`;
-
- return html`
-
- ${emoji}
-
- ${nameBlock}
-
- `;
-}
-
-/**
- * Render a loading spinner.
- * @returns {TemplateResult}
- */
-export function loading() {
- return html``;
-}
-
-/**
- * Render an error alert.
- * @param {string} message
- * @returns {TemplateResult}
- */
-export function errorAlert(message) {
- return html`
- ${iconError('stroke-current shrink-0 h-6 w-6')}
- ${message}
- `;
-}
-
-/**
- * Render an info alert. Use unsafeHTML for HTML content.
- * @param {string} message - Plain text message
- * @returns {TemplateResult}
- */
-export function infoAlert(message) {
- return html`
- ${iconInfo('stroke-current shrink-0 h-6 w-6')}
- ${message}
- `;
-}
-
-/**
- * Render a success alert.
- * @param {string} message
- * @returns {TemplateResult}
- */
-export function successAlert(message) {
- return html`
- ${iconSuccess('stroke-current shrink-0 h-6 w-6')}
- ${message}
- `;
-}
-
-/**
- * Render a warning badge with tooltip for transient API errors.
- * @param {string} message - Error message to display as tooltip
- * @returns {TemplateResult}
- */
-export function warningBadge(message) {
- return html`
- ${iconAlert('h-4 w-4')}
- `;
-}
-
-/**
- * Render pagination controls.
- * @param {number} page - Current page (1-based)
- * @param {number} totalPages - Total number of pages
- * @param {string} basePath - Base URL path (e.g., '/nodes')
- * @param {Object} [params={}] - Extra query parameters to preserve
- * @returns {TemplateResult|nothing}
- */
-export function pagination(page, totalPages, basePath, params = {}) {
- if (totalPages <= 1) return nothing;
-
- const queryParts = [];
- for (const [k, v] of Object.entries(params)) {
- if (k === 'page' || v === null || v === undefined || v === '') continue;
- if (Array.isArray(v)) {
- v.forEach(item => queryParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(item)}`));
- } else {
- queryParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
- }
- }
- const extraQuery = queryParts.length > 0 ? '&' + queryParts.join('&') : '';
-
- function pageUrl(p) {
- return `${basePath}?page=${p}${extraQuery}`;
- }
-
- const pageNumbers = [];
- for (let p = 1; p <= totalPages; p++) {
- if (p === page) {
- pageNumbers.push(html``);
- } else if (p === 1 || p === totalPages || (p >= page - 2 && p <= page + 2)) {
- pageNumbers.push(html`${p}`);
- } else if (p === 2 || p === totalPages - 1) {
- pageNumbers.push(html``);
- }
- }
-
- return html`
- ${page > 1
- ? html`${t('common.previous')}`
- : html``}
- ${pageNumbers}
- ${page < totalPages
- ? html`${t('common.next')}`
- : html``}
- `;
-}
-
-/**
- * Render a timezone indicator for page headers.
- * @returns {TemplateResult|nothing}
- */
-export function timezoneIndicator() {
- const config = getConfig();
- const tz = config.timezone || 'UTC';
- return html`(${tz})`;
-}
-
-/**
- * Render an observer count badge with tooltip listing observer names.
- * @param {Array} observers - Array of observer objects
- * @returns {TemplateResult|nothing}
- */
-export function observerIcons(observers) {
- if (!observers || observers.length === 0) return nothing;
- const names = observers.map(o => o.tag_name || o.name || truncateKey(o.public_key, 8));
- const tooltip = names.join(', ');
- return html`${formatNumber(observers.length)}`;
-}
-
-export function routeTypeBadge(routeType) {
- if (!routeType) {
- return nothing;
- }
- if (routeType === 'flood' || routeType === 'transport_flood') {
- return html`${routeType === 'flood' ? 'Flood' : 'Relay'}`;
- }
- if (routeType === 'direct' || routeType === 'transport_direct') {
- return html`${routeType === 'direct' ? 'Zero-hop' : 'Direct relay'}`;
- }
- return nothing;
-}
-
-// --- Observer filter (localStorage-backed toggle badges) ---
-
-// Shared across the Adverts and Messages pages. We persist the *disabled* set
-// of area codes so any newly-discovered area defaults to enabled automatically.
-const OBSERVER_FILTER_KEY = 'meshcore-observer-areas-disabled';
-
-/**
- * Read the set of disabled (deselected) observer area codes from localStorage.
- * @returns {Set}
- */
-export function getDisabledObserverAreas() {
- try {
- const raw = localStorage.getItem(OBSERVER_FILTER_KEY);
- if (!raw) return new Set();
- const arr = JSON.parse(raw);
- return Array.isArray(arr) ? new Set(arr) : new Set();
- } catch {
- return new Set();
- }
-}
-
-/**
- * Persist the set of disabled observer area codes to localStorage.
- * @param {Set} disabled
- */
-export function setDisabledObserverAreas(disabled) {
- try {
- localStorage.setItem(OBSERVER_FILTER_KEY, JSON.stringify([...disabled]));
- } catch {
- // Ignore quota/availability errors — filtering still works in-memory.
- }
-}
-
-/**
- * Toggle an observer area's enabled state, enforcing that at least one area
- * stays enabled. Returns the updated disabled set (persisted).
- * @param {string} area - Observer area code to toggle
- * @param {number} totalAreaCount - Total number of observer areas
- * @returns {Set}
- */
-export function toggleObserverArea(area, totalAreaCount) {
- const disabled = getDisabledObserverAreas();
- if (disabled.has(area)) {
- disabled.delete(area);
- } else {
- // Block disabling the last enabled area.
- if (totalAreaCount - disabled.size <= 1) {
- return disabled;
- }
- disabled.add(area);
- }
- setDisabledObserverAreas(disabled);
- return disabled;
-}
-
-/**
- * Render a row of clickable observer filter badges, one per area code.
- * @param {Array} options.areas - Observer area codes (already sorted)
- * @param {Set} options.disabled - Currently disabled area codes
- * @param {Function} options.onToggle - Called with an area code when a badge is clicked
- * @param {string} [options.extraClass] - Wrapper classes; must set the display
- * (e.g. 'hidden lg:flex' or 'flex lg:hidden') since the base omits it to avoid conflicts
- * @returns {TemplateResult|nothing}
- */
-export function observerFilterBadges({ areas, disabled, onToggle, extraClass = 'flex' }) {
- if (!areas || areas.length === 0) return nothing;
- return html`
- ${t('common.filter_observer_label')}:
- ${areas.map(area => {
- const enabled = !disabled.has(area);
- const cls = enabled ? 'badge badge-primary' : 'badge badge-ghost opacity-50';
- const title = enabled
- ? t('common.filter_observer_disable')
- : t('common.filter_observer_enable');
- const emoji = extractFirstEmoji(area);
- const label = emoji ? (area.replace(emoji, '').trim() || area) : area;
- return html``;
- })}
- `;
-}
-
-// --- Form Helpers ---
-
-/**
- * Create a submit handler for filter forms that uses SPA navigation.
- * Use as: @submit=${createFilterHandler('/nodes', navigate)}
- * @param {string} basePath - Base URL path for the page
- * @param {Function} navigate - Router navigate function
- * @returns {Function} Event handler
- */
-export function createFilterHandler(basePath, navigate) {
- return (e) => {
- e.preventDefault();
- const formData = new FormData(e.target);
- const params = new URLSearchParams();
- const keys = new Set(formData.keys());
- for (const k of keys) {
- for (const v of formData.getAll(k)) {
- if (v) params.append(k, v);
- }
- }
- const queryStr = params.toString();
- navigate(queryStr ? `${basePath}?${queryStr}` : basePath);
- };
-}
-
-/**
- * Auto-submit handler for select/checkbox elements.
- * Use as: @change=${autoSubmit}
- * @param {Event} e
- */
-export function autoSubmit(e) {
- e.target.closest('form').requestSubmit();
-}
-
-/**
- * Submit form on Enter key in text inputs.
- * Use as: @keydown=${submitOnEnter}
- * @param {KeyboardEvent} e
- */
-export function submitOnEnter(e) {
- if (e.key === 'Enter') {
- e.preventDefault();
- e.target.closest('form').requestSubmit();
- }
-}
-
-/**
- * Render the auth section in the navbar.
- * Shows a login button when not authenticated, or a user dropdown when logged in.
- * @param {HTMLElement} container - The #auth-section element
- * @param {Object} config - App configuration object
- */
-export function renderAuthSection(container, config) {
- if (!container) return;
- if (!config.oidc_enabled) {
- render(nothing, container);
- return;
- }
-
- const user = config.user;
- if (!user) {
- render(html`
- ${t('auth.login')}
- `, container);
- return;
- }
-
- const displayName = user.name || user.email || 'User';
- const initials = displayName.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
- const pictureHtml = user.picture
- ? html`
`
- : html`${initials}`;
-
- const roleBadges = (config.roles || []).map(r => {
- const key = `auth.role_${r}`;
- const label = t(key);
- const name = label !== key ? label : r;
- return html`${name}`;
- });
-
- const profileItem = html`${iconUser('h-5 w-5')} ${t('links.profile')} `;
-
- const debugId = config.debug && user.sub
- ? html`${user.sub}`
- : nothing;
-
- render(html`
-
-
- ${pictureHtml}
-
-
-
- `, container);
-}
-
-/**
- * Render a bare filter form (fields + submit/clear buttons).
- * No surrounding card, border, or collapse wrapper — the caller controls visibility.
- * @param {Array} options.fields - Array of render functions returning lit-html form controls
- * @param {string} options.basePath - Base URL path for the page (e.g., '/nodes')
- * @param {Function} options.navigate - Router navigate function
- * @param {string} [options.submitLabel] - Text for submit button (default: translated "Filter")
- * @param {string} [options.clearLabel] - Text for clear button (default: translated "Clear")
- * @returns {TemplateResult}
- */
-export function renderFilterForm({ fields, basePath, navigate, submitLabel, clearLabel }) {
- return html`
- `;
-}
-
-/**
- * Render the filter toggle control (DaisyUI slider switch + label).
- * Placed at the right of the control row; the native checkbox holds open-state.
- * @param {boolean} options.open - Whether the toggle is checked
- * @param {Function} options.onChange - @change handler on the checkbox
- * @returns {TemplateResult}
- */
-export function renderFilterToggle({ open, onChange }) {
- return html`
- `;
-}
-
-/**
- * Render a single stat card for dashboard/home pages.
- * @param {TemplateResult} options.icon - lit-html icon (from icons.js)
- * @param {string} options.color - CSS color value for glow (e.g., pageColors.dashboard)
- * @param {string} options.title - Stat title
- * @param {string|number} options.value - Stat value
- * @param {string} [options.description] - Optional description
- * @returns {TemplateResult}
- */
-export function renderStatCard({ icon, color, title, value, description }) {
- return html`
-
- ${icon}
- ${title}
- ${formatNumber(value)}
- ${description ? html`${description}` : nothing}
- `;
-}
diff --git a/src/meshcore_hub/web/static/js/spa/i18n.js b/src/meshcore_hub/web/static/js/spa/i18n.js
deleted file mode 100644
index b46a1e7..0000000
--- a/src/meshcore_hub/web/static/js/spa/i18n.js
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * MeshCore Hub SPA - Lightweight i18n Module
- *
- * Loads a JSON translation file and provides a t() lookup function.
- * Shares the same locale JSON files with the Python/Jinja2 server side.
- *
- * Usage:
- * import { t, loadLocale } from './i18n.js';
- * await loadLocale('en');
- * t('entities.home'); // "Home"
- * t('common.total', { count: 42 }); // "42 total"
- */
-
-let _translations = {};
-let _locale = 'en';
-
-/**
- * Load a locale JSON file from the server.
- * @param {string} locale - Language code (e.g. 'en')
- */
-export async function loadLocale(locale) {
- try {
- const config = window.__APP_CONFIG__ || {};
- const v = config.locale_version || '';
- const res = await fetch(`/static/locales/${locale}.json${v ? '?v=' + v : ''}`);
- if (res.ok) {
- _translations = await res.json();
- _locale = locale;
- } else {
- console.warn(`Failed to load locale '${locale}', status ${res.status}`);
- }
- } catch (e) {
- console.warn(`Failed to load locale '${locale}':`, e);
- }
-}
-
-/**
- * Resolve a dot-separated key in the translations object.
- * @param {string} key
- * @returns {*}
- */
-function resolve(key) {
- return key.split('.').reduce(
- (obj, k) => (obj && typeof obj === 'object' ? obj[k] : undefined),
- _translations,
- );
-}
-
-/**
- * Translate a key with optional {{var}} interpolation.
- * Falls back to the key itself if not found.
- * @param {string} key - Dot-separated translation key
- * @param {Object} [params={}] - Interpolation values
- * @returns {string}
- */
-export function t(key, params = {}) {
- let val = resolve(key);
-
- if (typeof val !== 'string') return key;
-
- // Replace {{var}} placeholders
- if (Object.keys(params).length > 0) {
- val = val.replace(/\{\{(\w+)\}\}/g, (_, k) => (k in params ? String(params[k]) : ''));
- }
-
- return val;
-}
-
-/**
- * Get the currently loaded locale code.
- * @returns {string}
- */
-export function getLocale() {
- return _locale;
-}
-
-// Also expose t() globally for non-module scripts (e.g. charts.js)
-window.t = t;
diff --git a/src/meshcore_hub/web/static/js/spa/icons.js b/src/meshcore_hub/web/static/js/spa/icons.js
deleted file mode 100644
index 76cb3b0..0000000
--- a/src/meshcore_hub/web/static/js/spa/icons.js
+++ /dev/null
@@ -1,199 +0,0 @@
-/**
- * MeshCore Hub SPA - SVG Icon Functions
- *
- * Each function returns a lit-html TemplateResult. Pass a CSS class string to customize size.
- */
-
-import { html } from 'lit-html';
-
-export function iconDashboard(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconMap(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconNodes(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconAdvertisements(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconMessages(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconPackets(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconHome(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconMembers(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconPage(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconInfo(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconAlert(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconChart(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconRefresh(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconMenu(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconGithub(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconExternalLink(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconGlobe(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconError(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconChannel(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconSuccess(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconLock(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconUser(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconEmail(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconTag(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconUsers(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconAntenna(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconSatelliteDish(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconPath(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconSettings(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconLogout(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconPlus(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconChevronRight(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconEdit(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconTrash(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconPause(cls = 'w-4 h-4') {
- return html``;
-}
-
-export function iconPlay(cls = 'w-4 h-4') {
- return html``;
-}
-
-export function iconFrequency(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconBandwidth(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconSpreadingFactor(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconCodingRate(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconTxPower(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconRuler(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconClock(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconFilter(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconRouteFrom(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconRouteTo(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconHopSpan(cls = 'h-5 w-5') {
- return html``;
-}
-
-export function iconPathLength(cls = 'h-5 w-5') {
- return html``;
-}
diff --git a/src/meshcore_hub/web/static/js/spa/json-tree.js b/src/meshcore_hub/web/static/js/spa/json-tree.js
deleted file mode 100644
index 637154b..0000000
--- a/src/meshcore_hub/web/static/js/spa/json-tree.js
+++ /dev/null
@@ -1,111 +0,0 @@
-/**
- * MeshCore Hub SPA - JSON Tree
- *
- * Renders an arbitrary JSON value as an expandable/collapsible tree.
- * Imperative toggling (class flips): the host page renders once after load,
- * so no re-render loop is required.
- */
-import { html, nothing } from 'lit-html';
-import { t } from './components.js';
-import { iconChevronRight } from './icons.js';
-
-function toggleNode(e) {
- const btn = e.currentTarget;
- const children = btn.nextElementSibling;
- if (!children) return;
- const nowHidden = children.classList.toggle('hidden');
- btn.querySelector('.json-caret').classList.toggle('rotate-90', !nowHidden);
-}
-
-function expandAll(e) {
- const root = e.currentTarget.closest('.json-tree-root');
- if (!root) return;
- root.querySelectorAll('.json-children').forEach((el) => el.classList.remove('hidden'));
- root.querySelectorAll('.json-caret').forEach((el) => el.classList.add('rotate-90'));
-}
-
-function collapseAll(e) {
- const root = e.currentTarget.closest('.json-tree-root');
- if (!root) return;
- root.querySelectorAll('.json-children').forEach((el) => el.classList.add('hidden'));
- root.querySelectorAll('.json-caret').forEach((el) => el.classList.remove('rotate-90'));
-}
-
-function primitiveClass(val) {
- if (val === null) return 'italic opacity-50';
- switch (typeof val) {
- case 'string': return 'text-success';
- case 'number': return 'text-warning';
- case 'boolean': return 'text-info';
- default: return '';
- }
-}
-
-function formatPrimitive(val) {
- if (val === null) return 'null';
- if (typeof val === 'string') return `"${val}"`;
- return String(val);
-}
-
-function keyLabel(key) {
- if (key == null) return nothing;
- if (typeof key === 'number') {
- return html`${key}:`;
- }
- return html`"${key}":`;
-}
-
-function renderNode(value, key, depth, openDepth) {
- const isContainer = value !== null && typeof value === 'object';
-
- if (!isContainer) {
- return html`
-
- ${keyLabel(key)}
- ${formatPrimitive(value)}
- `;
- }
-
- const isArray = Array.isArray(value);
- const entries = isArray
- ? value.map((v, i) => [i, v])
- : Object.entries(value);
- const open = isArray ? '[' : '{';
- const close = isArray ? ']' : '}';
- const hint = isArray ? `${entries.length}` : `${entries.length}`;
-
- if (entries.length === 0) {
- return html`
-
- ${keyLabel(key)}
- ${open}${close}
- `;
- }
-
- const isExpanded = depth < openDepth;
-
- return html`
-
-
-
- ${entries.map(([k, v]) => renderNode(v, k, depth + 1, openDepth))}
-
- `;
-}
-
-export function jsonTree(value, { openDepth = 1 } = {}) {
- return html`
-
-
-
-
-
-
- ${renderNode(value, null, 0, openDepth)}
-
- `;
-}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js
deleted file mode 100644
index c4f718a..0000000
--- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js
+++ /dev/null
@@ -1,330 +0,0 @@
-import { apiGet, isAbortError } from '../api.js';
-import {
- html, litRender, nothing, t,
- getConfig, formatDateTime, formatDateTimeShort, formatNumber,
- warningBadge,
- pagination, sortableTableHeader, mobileSortSelect,
- renderFilterForm, renderFilterToggle, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay,
- observerIcons, getDisabledObserverAreas, toggleObserverArea, observerFilterBadges, routeTypeBadge
-} from '../components.js';
-import { createAutoRefresh } from '../auto-refresh.js';
-
-export async function render(container, params, router) {
- const { signal } = params || {};
- const query = params.query || {};
- const search = query.search || '';
- const adopted_by = query.adopted_by || '';
- const route_type = query.route_type || 'flood,transport_flood';
- const page = parseInt(query.page, 10) || 1;
- const limit = parseInt(query.limit, 10) || 20;
- const offset = (page - 1) * limit;
- const sort = query.sort || 'time';
- const order = query.order || 'desc';
-
- // Observer filter is sourced from localStorage (shared toggle badges), not the URL.
- let disabledObserverAreas = getDisabledObserverAreas();
-
- const config = getConfig();
- const features = config.features || {};
- const packetsEnabled = features.packets !== false;
- const tz = config.timezone || '';
- const tzBadge = tz && tz !== 'UTC' ? html`${tz}` : nothing;
- const navigate = (url) => router.navigate(url);
- // For links nested inside a row/card whose own @click navigates elsewhere:
- // suppress the row handler and drive SPA navigation explicitly (the router
- // listens on document, so stopPropagation alone would force a full reload).
- const stopAndNavigate = (url) => (e) => {
- e.preventDefault();
- e.stopPropagation();
- navigate(url);
- };
- // Packet-detail target for a row/card, or null when not navigable.
- const packetDetailUrl = (packetHash) =>
- (packetsEnabled && packetHash) ? `/packets/hash/${packetHash}` : null;
-
- let lastContent = nothing;
- let lastTotal = null;
- let currentFilterFields = [];
- const hasActiveFilters = search !== '' || (config.oidc_enabled && adopted_by !== '') || route_type !== 'flood,transport_flood';
-
- function onFilterToggle() { renderPage(lastContent, { total: lastTotal }); }
-
- function renderPage(content, { total = null, error = null } = {}) {
- if (!error) {
- lastContent = content;
- lastTotal = total;
- }
- const displayContent = error ? lastContent : content;
- const displayTotal = error ? lastTotal : total;
- const existingToggle = container.querySelector('#filter-toggle');
- const filterOpen = existingToggle ? existingToggle.checked : hasActiveFilters;
- litRender(html`
-
- ${t('entities.advertisements')}
- ${tzBadge}
-
-
- ${displayTotal !== null
- ? html`${t('common.total', { count: formatNumber(displayTotal) })}`
- : nothing}
- ${error ? warningBadge(error) : nothing}
-
-
-
- ${renderFilterToggle({ open: filterOpen, onChange: onFilterToggle })}
-
-${(filterOpen && currentFilterFields.length > 0)
- ? html`${renderFilterForm({ fields: currentFilterFields, basePath: '/advertisements', navigate })}`
- : nothing}
-${displayContent}`, container);
- }
-
- renderPage(nothing);
-
- async function fetchAndRenderData() {
- try {
- // Phase 1: fetch the observer node list (and operator profiles) first.
- // The advertisements API filters observers by inclusion only, so we need
- // the full observer list to translate the stored "disabled" set into an
- // explicit include-list before fetching the data.
- const metaFetches = [
- apiGet('/api/v1/nodes', { limit: 500, observer: true }, { signal }),
- ];
- if (config.oidc_enabled) {
- metaFetches.push(apiGet('/api/v1/user/profiles', { limit: 500 }, { signal }));
- }
- const metaResults = await Promise.all(metaFetches);
- const nodesData = metaResults[0];
- const operatorRole = config.role_names?.operator || 'operator';
- const profiles = config.oidc_enabled
- ? (metaResults[1]?.items || []).filter(p => p.roles && p.roles.includes(operatorRole))
- : [];
- const allNodes = nodesData.items || [];
-
- const areaMap = new Map(); // area -> public_key[]
- for (const n of allNodes) {
- const area = n.tags?.find(tg => tg.key === 'area')?.value;
- if (!area || !area.trim()) continue;
- const key = area.trim();
- if (!areaMap.has(key)) areaMap.set(key, []);
- areaMap.get(key).push(n.public_key);
- }
- const sortedAreas = [...areaMap.keys()]
- .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
- const enabledObserverKeys = sortedAreas
- .filter(a => !disabledObserverAreas.has(a))
- .flatMap(a => areaMap.get(a));
- // Only constrain when some current area is actually hidden.
- const observerFilterActive = sortedAreas.some(a => disabledObserverAreas.has(a));
-
- const onObserverToggle = (area) => {
- disabledObserverAreas = toggleObserverArea(area, sortedAreas.length);
- if (page > 1) {
- // Re-scoping the data invalidates the current page; reset to page 1.
- const sp = new URLSearchParams(window.location.search);
- sp.delete('page');
- const qs = sp.toString();
- navigate(qs ? `/advertisements?${qs}` : '/advertisements');
- } else {
- fetchAndRenderData();
- }
- };
-
- // Phase 2: fetch the advertisements with the resolved observer filter.
- const apiParams = { limit, offset, search, sort, order, route_type };
- if (observerFilterActive) apiParams.observed_by = enabledObserverKeys;
- if (adopted_by) apiParams.adopted_by = adopted_by;
- const data = await apiGet('/api/v1/advertisements', apiParams, { signal });
-
- const advertisements = data.items || [];
- const total = data.total || 0;
- const totalPages = Math.ceil(total / limit);
-
- const observerBadges = (extraClass) => observerFilterBadges({
- areas: sortedAreas, disabled: disabledObserverAreas, onToggle: onObserverToggle, extraClass,
- });
-
- const mobileCards = advertisements.length === 0
- ? html`${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}`
- : advertisements.map(ad => {
- const adName = ad.node_tag_name || ad.node_name || ad.name;
- const adDescription = ad.node_tag_description;
- let receiversBlock = nothing;
- if (ad.observers && ad.observers.length >= 1) {
- receiversBlock = observerIcons(ad.observers);
- } else if (ad.observed_by) {
- receiversBlock = html`\u{1F4E1}`;
- }
- const detailUrl = packetDetailUrl(ad.packet_hash);
- return html` navigate(detailUrl) : undefined}>
-
-
-
- ${renderNodeDisplay({
- name: adName,
- description: adDescription,
- publicKey: ad.public_key,
- advType: ad.adv_type,
- size: 'sm'
- })}
-
-
- ${formatDateTimeShort(ad.received_at)}
-
- ${routeTypeBadge(ad.route_type)}
- ${receiversBlock}
-
-
-
-
- `;
- });
-
- const tableRows = advertisements.length === 0
- ? html`${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })} `
- : advertisements.map(ad => {
- const adName = ad.node_tag_name || ad.node_name || ad.name;
- const adDescription = ad.node_tag_description;
- let receiversBlock;
- if (ad.observers && ad.observers.length >= 1) {
- receiversBlock = html`${observerIcons(ad.observers)}`;
- } else if (ad.observed_by) {
- receiversBlock = html`\u{1F4E1}`;
- } else {
- receiversBlock = html`-`;
- }
- const detailUrl = packetDetailUrl(ad.packet_hash);
- return html` navigate(detailUrl) : undefined}>
-
-
- ${renderNodeDisplay({
- name: adName,
- description: adDescription,
- publicKey: ad.public_key,
- advType: ad.adv_type,
- size: 'base'
- })}
-
-
-
- copyToClipboard(e, ad.public_key)}
- title="Click to copy">${ad.public_key}
-
- ${routeTypeBadge(ad.route_type)}
- ${formatDateTime(ad.received_at)}
- ${receiversBlock}
- `;
- });
-
- const paginationBlock = pagination(page, totalPages, '/advertisements', {
- search, adopted_by, route_type, limit, sort, order,
- });
-
- const filterFields = [
- () => html`
-
-
-
- `,
- () => html`
-
-
-
- `,
- ];
- if (config.oidc_enabled && profiles.length > 0) {
- filterFields.push(() => html`
-
-
-
- `);
- }
- const headerParams = { search, adopted_by, route_type, limit };
- const sortable = (label, sortKey) => sortableTableHeader(label, {
- sortKey, currentSort: sort, currentOrder: order,
- navigate, basePath: '/advertisements', params: headerParams,
- });
-
- currentFilterFields = filterFields;
-
- renderPage(html`
-
-${observerBadges('hidden lg:flex mb-4')}
-
-${mobileSortSelect({
- currentSort: sort, currentOrder: order,
- navigate, basePath: '/advertisements',
- params: headerParams,
- options: [
- { value: 'time:desc', label: t('advertisements.sort.newest') },
- { value: 'time:asc', label: t('advertisements.sort.oldest') },
- { value: 'node_name:asc', label: t('advertisements.sort.node_az') },
- { value: 'node_name:desc', label: t('advertisements.sort.node_za') },
- { value: 'public_key:asc', label: t('advertisements.sort.key_asc') },
- { value: 'public_key:desc', label: t('advertisements.sort.key_desc') },
- ],
-})}
-
-${observerBadges('flex lg:hidden mb-4')}
-
-
- ${mobileCards}
-
-
-
-
-
-
- ${sortable(t('entities.node'), 'node_name')}
- ${sortable(t('common.public_key'), 'public_key')}
- ${t('advertisements.col_route_type')}
- ${sortable(t('common.time'), 'time')}
- ${t('common.observers')}
-
-
-
- ${tableRows}
-
-
-
-
-${paginationBlock}`, { total });
-
- } catch (e) {
- if (isAbortError(e)) return;
- renderPage(nothing, { error: e.message });
- }
- }
-
- await fetchAndRenderData();
-
- const toggleEl = container.querySelector('#auto-refresh-toggle');
- const { cleanup } = createAutoRefresh({
- fetchAndRender: fetchAndRenderData,
- toggleContainer: toggleEl,
- });
- return cleanup;
-}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/channels.js b/src/meshcore_hub/web/static/js/spa/pages/channels.js
deleted file mode 100644
index d1ec654..0000000
--- a/src/meshcore_hub/web/static/js/spa/pages/channels.js
+++ /dev/null
@@ -1,300 +0,0 @@
-import { apiGet, apiPost, apiPut, apiDelete, isAbortError } from '../api.js';
-import { html, litRender, nothing, t, errorAlert, getConfig, hasRole } from '../components.js';
-import { iconChannel, iconPlus, iconEdit, iconTrash } from '../icons.js';
-
-const VISIBILITY_ORDER = ['community', 'member', 'operator', 'admin'];
-
-function renderVisibilityBadge(visibility, oidcEnabled) {
- if (!oidcEnabled) return nothing;
- return html`${visibility}`;
-}
-
-function renderChannelCard(channel, { oidcEnabled, isAdmin, onDelete, onEdit, onNavigate }) {
- const visibilityBadge = renderVisibilityBadge(channel.visibility, oidcEnabled);
- const enabledBadge = !channel.enabled
- ? html`${t('channels.disabled')}`
- : nothing;
-
- const channelIdx = parseInt(channel.channel_hash, 16);
- const qrId = `qr-${channel.id}`;
-
- const adminButtons = isAdmin
- ? html`
-
-
- `
- : nothing;
-
- const keyDisplay = channel.key_hex
- ? html`${channel.key_hex.toLowerCase()}`
- : nothing;
-
- const qrPlaceholder = channel.key_hex
- ? html``
- : nothing;
-
- return html` onNavigate(channelIdx)}
- @keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onNavigate(channelIdx); } }}>
-
-
-
- ${channel.name}
- ${visibilityBadge}
- ${enabledBadge}
-
- ${keyDisplay}
- ${adminButtons}
-
-
- ${qrPlaceholder}
-
-
- `;
-}
-
-function renderAddButton(onAdd) {
- return html``;
-}
-
-function renderChannelModal({ channel, isEdit, onSave, onCancel, saving }) {
- const title = isEdit ? t('channels.edit_channel') : t('channels.add_channel');
-
- return html``;
-}
-
-function renderDeleteModal({ channel, onConfirm, onCancel, saving }) {
- return html``;
-}
-
-export async function render(container, params, router) {
- const { signal } = params || {};
- try {
- const config = getConfig();
- const oidcEnabled = config.oidc_enabled;
- const isAdmin = hasRole('admin');
-
- const data = await apiGet('/api/v1/channels', {}, { signal });
- const channels = data.items || [];
-
- let modalState = null;
-
- async function refresh() {
- const newData = await apiGet('/api/v1/channels');
- renderPage(newData.items || []);
- }
-
- function renderPage(channelsList) {
- const adminHeader = isAdmin
- ? html`${renderAddButton(handleAdd)}`
- : nothing;
-
- const emptyMessage = channelsList.length === 0
- ? html`
- ${t('common.no_entity_found', { entity: t('entities.channels').toLowerCase() })}
- `
- : nothing;
-
- const groups = new Map();
- for (const vis of VISIBILITY_ORDER) {
- groups.set(vis, []);
- }
- for (const ch of channelsList) {
- const vis = ch.visibility || 'community';
- if (!groups.has(vis)) groups.set(vis, []);
- groups.get(vis).push(ch);
- }
-
- const cardOpts = {
- oidcEnabled,
- isAdmin,
- onDelete: handleDeleteClick,
- onEdit: handleEditClick,
- onNavigate: (idx) => router.navigate(`/messages?channel_idx=${idx}`),
- };
-
- const groupedSections = [];
- for (const vis of VISIBILITY_ORDER) {
- const group = groups.get(vis);
- if (!group || group.length === 0) continue;
- groupedSections.push(html`
- ${t(`channels.visibility_${vis}`)}
-
- ${group.map(ch => renderChannelCard(ch, cardOpts))}
-
- `);
- }
-
- let modalHtml = nothing;
- if (modalState?.type === 'add' || modalState?.type === 'edit') {
- modalHtml = renderChannelModal({
- channel: modalState.channel,
- isEdit: modalState.type === 'edit',
- onSave: handleSave,
- onCancel: () => { modalState = null; renderPage(channelsList); },
- saving: !!modalState.saving,
- });
- } else if (modalState?.type === 'delete') {
- modalHtml = renderDeleteModal({
- channel: modalState.channel,
- onConfirm: handleDeleteConfirm,
- onCancel: () => { modalState = null; renderPage(channelsList); },
- saving: !!modalState.saving,
- });
- }
-
- litRender(html`
-
-
- ${iconChannel('h-8 w-8')}
- ${t('channels.title')}
-
-
- ${adminHeader}
- ${emptyMessage}
- ${groupedSections}
- ${modalHtml}
- `, container);
-
- channelsList.forEach(ch => {
- const qrEl = document.getElementById(`qr-${ch.id}`);
- if (qrEl && !qrEl.hasChildNodes() && ch.key_hex) {
- const qrUrl = `meshcore://channel/add?name=${encodeURIComponent(ch.name)}&secret=${ch.key_hex.toLowerCase()}`;
- new QRCode(qrEl, {
- text: qrUrl,
- width: 128,
- height: 128,
- correctLevel: QRCode.CorrectLevel.M,
- });
- }
- });
- }
-
- function handleAdd() {
- modalState = { type: 'add', channel: { visibility: 'community', enabled: true } };
- renderPage(channels);
- }
-
- function handleEditClick(channel) {
- modalState = { type: 'edit', channel };
- renderPage(channels);
- }
-
- function handleDeleteClick(channel) {
- modalState = { type: 'delete', channel };
- renderPage(channels);
- }
-
- async function handleSave() {
- const nameEl = document.getElementById('channel-modal-name');
- const keyEl = document.getElementById('channel-modal-key');
- const visEl = document.getElementById('channel-modal-visibility');
- const enabledEl = document.getElementById('channel-modal-enabled');
-
- const isEdit = modalState.type === 'edit';
- const body = {
- visibility: visEl.value,
- enabled: enabledEl.checked,
- };
-
- if (!isEdit) {
- body.name = nameEl.value.trim();
- body.key_hex = keyEl.value.trim().toUpperCase();
- } else {
- if (keyEl && keyEl.value) {
- body.key_hex = keyEl.value.trim().toUpperCase();
- }
- }
-
- modalState = { ...modalState, saving: true };
- renderPage(channels);
- try {
- if (isEdit) {
- await apiPut(`/api/v1/channels/${modalState.channel.id}`, body);
- } else {
- await apiPost('/api/v1/channels', body);
- }
- modalState = null;
- await refresh();
- } catch (e) {
- modalState = { ...modalState, saving: false };
- renderPage(channels);
- alert(e.message || 'Failed to save channel');
- }
- }
-
- async function handleDeleteConfirm() {
- modalState = { ...modalState, saving: true };
- renderPage(channels);
- try {
- await apiDelete(`/api/v1/channels/${modalState.channel.id}`);
- modalState = null;
- await refresh();
- } catch (e) {
- modalState = { ...modalState, saving: false };
- renderPage(channels);
- alert(e.message || 'Failed to delete channel');
- }
- }
-
- renderPage(channels);
-
- } catch (e) {
- if (isAbortError(e)) return;
- litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
- }
-}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/custom-page.js b/src/meshcore_hub/web/static/js/spa/pages/custom-page.js
deleted file mode 100644
index 30ab615..0000000
--- a/src/meshcore_hub/web/static/js/spa/pages/custom-page.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import { apiGet, isAbortError } from '../api.js';
-import { html, litRender, unsafeHTML, getConfig, errorAlert, t } from '../components.js';
-
-export async function render(container, params, router) {
- const { signal } = params || {};
- try {
- const page = await apiGet('/spa/pages/' + encodeURIComponent(params.slug), {}, { signal });
-
- const config = getConfig();
- const networkName = config.network_name || 'MeshCore Network';
- document.title = `${page.title} - ${networkName}`;
-
- litRender(html`
-
-
-
- ${unsafeHTML(page.content_html)}
-
-
-`, container);
-
- } catch (e) {
- if (isAbortError(e)) return;
- if (e.message && e.message.includes('404')) {
- litRender(errorAlert(t('common.page_not_found')), container);
- } else {
- litRender(errorAlert(e.message || t('custom_page.failed_to_load')), container);
- }
- }
-}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js
deleted file mode 100644
index 11da706..0000000
--- a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js
+++ /dev/null
@@ -1,435 +0,0 @@
-import { apiGet, isAbortError } from '../api.js';
-import {
- html, litRender, nothing,
- getConfig, getChannelLabelsMap, resolveChannelLabel,
- observerIcons, routeTypeBadge, errorAlert, t, formatDateTime, formatNumber,
-} from '../components.js';
-import {
- iconNodes, iconAdvertisements, iconMessages, iconPackets, iconChannel,
-} from '../icons.js';
-
-function channelLabel(channel, channelLabels) {
- const idx = parseInt(String(channel), 10);
- if (Number.isInteger(idx)) {
- return resolveChannelLabel(idx, channelLabels) || `Ch ${idx}`;
- }
- return String(channel);
-}
-
-function formatTimeOnly(isoString) {
- return formatDateTime(isoString, {
- hour: '2-digit', minute: '2-digit', second: '2-digit',
- hour12: false,
- });
-}
-
-function formatTimeShort(isoString) {
- return formatDateTime(isoString, {
- month: 'short', day: 'numeric',
- hour: '2-digit', minute: '2-digit',
- hour12: false,
- });
-}
-
-function renderRecentAds(ads) {
- if (!ads || ads.length === 0) {
- return html`${t('common.no_entity_yet', { entity: t('entities.advertisements').toLowerCase() })}
`;
- }
- const rows = ads.map(ad => {
- const friendlyName = ad.tag_name || ad.name;
- const displayName = friendlyName || (ad.public_key.slice(0, 12) + '...');
- const keyLine = friendlyName
- ? html`${ad.public_key.slice(0, 12)}...`
- : nothing;
- let observersBlock;
- if (ad.observers && ad.observers.length >= 1) {
- observersBlock = html`${observerIcons(ad.observers)}`;
- } else if (ad.observed_by) {
- observersBlock = html`\u{1F4E1}`;
- } else {
- observersBlock = html`-`;
- }
- return html`
-
-
- ${displayName}
-
- ${keyLine}
-
- ${routeTypeBadge(ad.route_type)}
- ${formatTimeOnly(ad.received_at)}
- ${observersBlock}
- `;
- });
-
- return html`
-
-
-
- ${t('entities.node')}
- ${t('common.type')}
- ${t('common.received')}
- ${t('common.observers')}
-
-
- ${rows}
-
- `;
-}
-
-function renderChannelMessages(channelMessages, channelLabels) {
- if (!channelMessages || Object.keys(channelMessages).length === 0) return nothing;
-
- const channels = Object.entries(channelMessages).map(([channel, messages]) => {
- const label = channelLabel(channel, channelLabels);
- const msgLines = messages.map(msg => html`
-
- ${formatTimeShort(msg.received_at)}
- ${msg.text || ''}
- `);
-
- return html`
-
- ${label}
-
-
- ${msgLines}
-
- `;
- });
-
- return html`
-
-
- ${iconChannel('h-6 w-6')}
- ${t('dashboard.recent_channel_messages')}
-
-
- ${channels}
-
-
- `;
-}
-
-/** Return responsive Tailwind grid-cols classes for the given visible column count. */
-function gridCols(count) {
- if (count === 2) return 'sm:grid-cols-2';
- if (count === 3) return 'sm:grid-cols-2 lg:grid-cols-3';
- if (count === 4) return 'sm:grid-cols-2 lg:grid-cols-4';
- return '';
-}
-
-function renderRoutesHealth(routes) {
- if (!routes || routes.length === 0) {
- return html`${t('dashboard.routes_empty')}
`;
- }
- // Top 6 by current matched_count, mirroring the trend chart cap so the
- // two widgets surface the same routes.
- const sorted = routes.slice().sort((a, b) => (b.matched_count || 0) - (a.matched_count || 0));
- const maxRows = 6;
- const visible = sorted.slice(0, maxRows);
- const hidden = sorted.length - visible.length;
-
- const colorFor = (q) => {
- // Mirrors ChartColors.quality in charts.js but reads CSS vars so the
- // strips stay in sync with the legend.
- const map = {
- clear: 'oklch(0.72 0.17 145)',
- marginal: 'oklch(0.75 0.18 85)',
- failing: 'oklch(0.62 0.24 25)',
- no_coverage: 'oklch(0.65 0.15 250)',
- disabled: 'oklch(0.55 0 0)',
- };
- return map[q] || map.no_coverage;
- };
- const labelFor = (q) => t('routes.quality_' + (q || 'unknown'));
-
- const rows = visible.map(r => {
- const cells = (r.history || []).map(d => html`
- `);
- // Right-most dot = rolling 7-day average tier (same computation as
- // the chart line color and the route-card badge on /routes). Falls
- // back to the snapshot if history is missing (e.g. backend degraded).
- const hist = r.history || [];
- const avgTier = (window.averageRouteTier && hist.length > 0)
- ? window.averageRouteTier(hist)
- : null;
- const current = avgTier
- || (r.enabled ? (r.quality || 'no_coverage') : 'disabled');
- return html`
-
- ${r.from_label} \u2192 ${r.to_label}
-
- ${cells}
-
- `;
- });
-
- return html`
- ${rows}
- ${hidden > 0 ? html`${t('dashboard.routes_more', { count: hidden })}
` : nothing}
- `;
-}
-
-function renderChartCards({ showNodes, showAdverts, showMessages, showPackets, showRoutes, stats, packetBreakdown, routesOverview }) {
- const visibleCount = (showNodes ? 1 : 0) + (showAdverts ? 1 : 0) + (showMessages ? 1 : 0) + (showPackets ? 1 : 0);
- if (visibleCount === 0) return nothing;
-
- const eventTypeTotal = packetBreakdown?.by_event_type?.reduce((s, b) => s + b.count, 0) ?? 0;
- const pathWidthTotal = packetBreakdown?.by_path_width?.reduce((s, b) => s + b.count, 0) ?? 0;
- const hasRoutes = !!(routesOverview && routesOverview.routes && routesOverview.routes.length);
-
- return html`
-
- ${showNodes ? html`
-
-
-
-
-
- ${iconNodes('h-5 w-5')}
- ${t('entities.nodes')}
-
- ${t('time.over_time_last_7_days')}
-
-
- ${formatNumber(stats.total_nodes)}
-
-
-
-
-
-
- ` : nothing}
-
- ${showAdverts ? html`
-
-
-
-
-
- ${iconAdvertisements('h-5 w-5')}
- ${t('entities.advertisements')}
-
- ${t('time.per_day_last_7_days')}
-
-
- ${formatNumber(stats.advertisements_7d)}
-
-
-
-
-
-
- ` : nothing}
-
- ${showMessages ? html`
-
-
-
-
-
- ${iconMessages('h-5 w-5')}
- ${t('entities.messages')}
-
- ${t('time.per_day_last_7_days')}
-
-
- ${formatNumber(stats.messages_7d)}
-
-
-
-
-
-
- ` : nothing}
-
- ${showPackets ? html`
-
-
-
-
-
- ${iconPackets('h-5 w-5')}
- ${t('entities.packets')}
-
- ${t('time.per_day_last_7_days')}
-
-
- ${formatNumber(stats.packets_7d)}
-
-
-
-
-
-
- ` : nothing}
-
-
-${(showPackets || (showRoutes && hasRoutes)) ? html`
-
- ${showPackets ? html`
-
-
-
-
-
- ${iconPackets('h-5 w-5')}
- ${t('entities.packet_event_types')}
-
- ${t('time.last_7_days')}
-
-
- ${formatNumber(eventTypeTotal)}
-
-
-
-
-
-
- ` : nothing}
-
- ${showPackets ? html`
-
-
-
-
-
- ${iconPackets('h-5 w-5')}
- ${t('entities.path_hash_width')}
-
- ${t('time.last_7_days')}
-
-
- ${formatNumber(pathWidthTotal)}
-
-
-
-
-
-
- ` : nothing}
-
- ${(showRoutes && hasRoutes) ? html`
-
-
-
-
-
- ${t('dashboard.route_health')}
-
- ${t('time.last_7_days')}
-
-
- ${renderRoutesHealth(routesOverview.routes)}
-
- ` : nothing}
-
- ${(showRoutes && hasRoutes) ? html`
-
-
-
-
-
- ${t('dashboard.routes_trend')}
-
- ${t('time.routes_over_last_n_days', { n: routesOverview.days })}
-
-
-
-
-
-
- ` : nothing}
-` : nothing}`;
-}
-
-export async function render(container, params, router) {
- const { signal } = params || {};
- try {
- const config = getConfig();
- let channelLabels = new Map();
- const features = config.features || {};
- const showNodes = features.nodes !== false;
- const showAdverts = features.advertisements !== false;
- const showMessages = features.messages !== false;
- const showPackets = features.packets !== false;
- const showRoutes = features.routes !== false;
-
- const [stats, recentActivity, advertActivity, messageActivity, nodeCount, packetActivity, packetBreakdown, routesOverview, channelsData] = await Promise.all([
- apiGet('/api/v1/dashboard/stats', {}, { signal }),
- apiGet('/api/v1/dashboard/recent-activity', {}, { signal }),
- apiGet('/api/v1/dashboard/activity', { days: 7 }, { signal }),
- apiGet('/api/v1/dashboard/message-activity', { days: 7 }, { signal }),
- apiGet('/api/v1/dashboard/node-count', { days: 7 }, { signal }),
- apiGet('/api/v1/dashboard/packet-activity', { days: 7 }, { signal }),
- apiGet('/api/v1/dashboard/packet-breakdown', { days: 7 }, { signal }),
- showRoutes ? apiGet('/api/v1/dashboard/routes-overview', { days: 7 }, { signal }) : Promise.resolve(null),
- apiGet('/api/v1/channels', {}, { signal }),
- ]);
- channelLabels = new Map([
- ...getChannelLabelsMap(config),
- ...(channelsData.items || [])
- .map(ch => [parseInt(ch.channel_hash, 16), ch.name])
- .filter(([idx]) => Number.isInteger(idx)),
- ]);
-
- // Bottom section: recent adverts + recent channel messages
- const bottomCount = (showAdverts ? 1 : 0) + (showMessages ? 1 : 0);
- const bottomGrid = gridCols(bottomCount);
-
- litRender(html`
-
- ${t('entities.dashboard')}
-
-
-${(showNodes || showAdverts || showMessages || showPackets) ? html`
-${renderChartCards({ showNodes, showAdverts, showMessages, showPackets, showRoutes, stats, packetBreakdown, routesOverview })}` : nothing}
-
-${bottomCount > 0 ? html`
-
- ${showAdverts ? html`
-
-
-
- ${iconAdvertisements('h-6 w-6')}
- ${t('common.recent_entity', { entity: t('entities.advertisements') })}
-
- ${renderRecentAds(recentActivity.recent_advertisements)}
-
- ` : nothing}
-
- ${showMessages ? renderChannelMessages(recentActivity.channel_messages, channelLabels) : nothing}
-` : nothing}`, container);
-
- window.initDashboardCharts(
- showNodes ? nodeCount : null,
- showAdverts ? advertActivity : null,
- showMessages ? messageActivity : null,
- showPackets ? packetActivity : null,
- showPackets ? packetBreakdown.by_event_type : null,
- showPackets ? packetBreakdown.by_path_width : null,
- (showRoutes && routesOverview && routesOverview.routes) ? routesOverview.routes : null,
- );
-
- const chartIds = ['nodeChart', 'advertChart', 'messageChart', 'packetChart', 'packetEventTypeChart', 'packetPathWidthChart', 'routesTrendChart'];
- return () => {
- chartIds.forEach(id => {
- const canvas = document.getElementById(id);
- if (canvas) {
- const instance = window.Chart.getChart(canvas);
- if (instance) instance.destroy();
- }
- });
- };
-
- } catch (e) {
- if (isAbortError(e)) return;
- litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
- }
-}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/home.js b/src/meshcore_hub/web/static/js/spa/pages/home.js
deleted file mode 100644
index 17cdfc1..0000000
--- a/src/meshcore_hub/web/static/js/spa/pages/home.js
+++ /dev/null
@@ -1,305 +0,0 @@
-import { apiGet, isAbortError } from '../api.js';
-import {
- html, litRender, nothing,
- getConfig, errorAlert, pageColors, renderStatCard, t,
-} from '../components.js';
-import {
- iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconPackets, iconMembers, iconMap,
- iconPage, iconInfo, iconChart, iconAntenna, iconUsers, iconChannel, iconPath,
- iconSettings, iconFrequency, iconBandwidth, iconSpreadingFactor, iconCodingRate, iconTxPower,
-} from '../icons.js';
-
-function renderRadioTiles(rc) {
- if (!rc) return nothing;
- const tiles = [
- { icon: iconSettings, label: t('links.profile'), value: rc.profile },
- { icon: iconFrequency, label: t('home.frequency'), value: rc.frequency },
- { icon: iconBandwidth, label: t('home.bandwidth'), value: rc.bandwidth },
- { icon: iconSpreadingFactor, label: t('home.spreading_factor'), value: rc.spreading_factor },
- { icon: iconCodingRate, label: t('home.coding_rate'), value: rc.coding_rate },
- { icon: iconTxPower, label: t('home.tx_power'), value: rc.tx_power },
- ];
- const visible = tiles.filter(t => t.value);
- if (visible.length === 0) return nothing;
- return html`
-
- ${visible.map(({ icon, label, value }) => html`
-
-
- ${label}
- ${String(value)}
- `)}
- `;
-}
-
-function renderNavCard({ href, icon, label, colorVar }) {
- return html`
-
-
- ${icon}
-
-
- ${label}
-
- `;
-}
-
-function renderHeroSection({ networkName, logoUrl, logoInvertLight, networkCity, networkCountry, networkWelcomeText, features, customPages }) {
- const cityCountry = (networkCity && networkCountry)
- ? html`${networkCity}, ${networkCountry}
`
- : nothing;
-
- const welcomeText = networkWelcomeText
- ? html`${networkWelcomeText}
`
- : html`
- ${t('home.welcome_default', { network_name: networkName })}
-
`;
-
- return html`
-
-
-
-
- ${networkName}
- ${cityCountry}
-
-
-
- ${welcomeText}
-
-
- ${features.dashboard !== false ? renderNavCard({
- href: '/dashboard',
- icon: iconDashboard('w-full h-full'),
- label: t('entities.dashboard'),
- colorVar: '--color-dashboard',
- }) : nothing}
- ${features.nodes !== false ? renderNavCard({
- href: '/nodes',
- icon: iconNodes('w-full h-full'),
- label: t('entities.nodes'),
- colorVar: '--color-nodes',
- }) : nothing}
- ${features.advertisements !== false ? renderNavCard({
- href: '/advertisements',
- icon: iconAdvertisements('w-full h-full'),
- label: t('entities.advertisements'),
- colorVar: '--color-adverts',
- }) : nothing}
- ${features.routes !== false ? renderNavCard({
- href: '/routes',
- icon: iconPath('w-full h-full'),
- label: t('entities.routes'),
- colorVar: '--color-routes',
- }) : nothing}
- ${features.channels !== false ? renderNavCard({
- href: '/channels',
- icon: iconChannel('w-full h-full'),
- label: t('entities.channels'),
- colorVar: '--color-channels',
- }) : nothing}
- ${features.messages !== false ? renderNavCard({
- href: '/messages',
- icon: iconMessages('w-full h-full'),
- label: t('entities.messages'),
- colorVar: '--color-messages',
- }) : nothing}
- ${features.packets !== false ? renderNavCard({
- href: '/packets',
- icon: iconPackets('w-full h-full'),
- label: t('entities.packets'),
- colorVar: '--color-packets',
- }) : nothing}
- ${features.map !== false ? renderNavCard({
- href: '/map',
- icon: iconMap('w-full h-full'),
- label: t('entities.map'),
- colorVar: '--color-map',
- }) : nothing}
- ${features.members !== false ? renderNavCard({
- href: '/members',
- icon: iconMembers('w-full h-full'),
- label: t('entities.members'),
- colorVar: '--color-members',
- }) : nothing}
-
- ${features.pages !== false && customPages.length > 0 ? html`
-
- ${customPages.slice(0, 3).map(page => html`
-
- ${iconPage('h-5 w-5 mr-2')}
- ${page.title}
- `)}
- ` : nothing}
- `;
-}
-
-function renderStatsPanel({ features, stats }) {
- return html`
-
- ${features.nodes !== false ? renderStatCard({
- icon: iconNodes('h-8 w-8'),
- color: pageColors.nodes,
- title: t('entities.nodes'),
- value: stats.total_nodes,
- description: t('home.all_discovered_nodes'),
- }) : nothing}
- ${features.advertisements !== false ? renderStatCard({
- icon: iconAdvertisements('h-8 w-8'),
- color: pageColors.adverts,
- title: t('entities.advertisements'),
- value: stats.advertisements_7d,
- description: t('time.last_7_days'),
- }) : nothing}
- ${features.messages !== false ? renderStatCard({
- icon: iconMessages('h-8 w-8'),
- color: pageColors.messages,
- title: t('entities.messages'),
- value: stats.messages_7d,
- description: t('time.last_7_days'),
- }) : nothing}
- ${features.packets !== false ? renderStatCard({
- icon: iconPackets('h-8 w-8'),
- color: pageColors.packets,
- title: t('entities.packets'),
- value: stats.packets_7d,
- description: t('time.last_7_days'),
- }) : nothing}
- `;
-}
-
-function renderActivityChartCard({ showAdvertSeries, showMessageSeries }) {
- return html`
-
-
-
- ${iconChart('h-6 w-6')}
- ${t('home.network_activity')}
-
- ${t('time.activity_per_day_last_7_days')}
-
-
-
-
- `;
-}
-
-function renderMembersPanel({ features, stats }) {
- if (features.members === false) return nothing;
- return html`
-
-
-
- ${iconMembers('h-6 w-6')}
- ${t('entities.members')}
-
-
- ${renderStatCard({
- icon: iconAntenna('h-6 w-6'),
- color: pageColors.members,
- title: t('members_page.operators'),
- value: stats.total_operators ?? 0,
- })}
- ${renderStatCard({
- icon: iconUsers('h-6 w-6'),
- color: pageColors.members,
- title: t('members_page.members'),
- value: stats.total_members ?? 0,
- })}
-
-
- `;
-}
-
-export async function render(container, params, router) {
- const { signal } = params || {};
- try {
- const config = getConfig();
- const features = config.features || {};
- const networkName = config.network_name || 'MeshCore Network';
- const logoUrl = config.logo_url || '/static/img/logo.svg';
- const logoInvertLight = config.logo_invert_light !== false;
- const customPages = config.custom_pages || [];
- const rc = config.network_radio_config;
-
- const [stats, advertActivity, messageActivity] = await Promise.all([
- apiGet('/api/v1/dashboard/stats', {}, { signal }),
- apiGet('/api/v1/dashboard/activity', { days: 7 }, { signal }),
- apiGet('/api/v1/dashboard/message-activity', { days: 7 }, { signal }),
- ]);
-
- const showStats = features.nodes !== false || features.advertisements !== false || features.messages !== false || features.packets !== false;
- const showAdvertSeries = features.advertisements !== false;
- const showMessageSeries = features.messages !== false;
- const showActivityChart = showAdvertSeries || showMessageSeries;
- const showMembersPanel = features.members !== false;
- const showRadioPanel = features.radio_config !== false;
-
- const heroSection = renderHeroSection({
- networkName, logoUrl, logoInvertLight,
- networkCity: config.network_city,
- networkCountry: config.network_country,
- networkWelcomeText: config.network_welcome_text,
- features, customPages,
- });
-
- const statsPanel = renderStatsPanel({ features, stats });
-
- const activityChartCard = renderActivityChartCard({ showAdvertSeries, showMessageSeries });
-
- litRender(html`
-
-
- ${heroSection}
-
- ${showStats ? statsPanel : nothing}
-
-
-
- ${showRadioPanel ? html`
-
-
-
- ${iconInfo('h-6 w-6')}
- ${t('home.network_info')}
-
-
- ${renderRadioTiles(rc)}
-
-
-
- ` : nothing}
-
- ${renderMembersPanel({ features, stats })}
-
- ${showActivityChart ? activityChartCard : nothing}
-`, container);
-
- let chart = null;
- if (showActivityChart) {
- chart = window.createActivityChart(
- 'activityChart',
- showAdvertSeries ? advertActivity : null,
- showMessageSeries ? messageActivity : null,
- );
- }
-
- return () => {
- if (chart) chart.destroy();
- };
-
- } catch (e) {
- if (isAbortError(e)) return;
- litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
- }
-}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/maintenance.js b/src/meshcore_hub/web/static/js/spa/pages/maintenance.js
deleted file mode 100644
index c3401c7..0000000
--- a/src/meshcore_hub/web/static/js/spa/pages/maintenance.js
+++ /dev/null
@@ -1,27 +0,0 @@
-/**
- * Maintenance page.
- *
- * Rendered for every route when SYSTEM_MAINTENANCE is enabled. This page makes
- * NO backend API calls — the API service / database may be offline while the
- * web component stays up. Keep it dependency-free (no api.js import, no fetch).
- */
-import { html, litRender, t, getConfig } from '../components.js';
-
-export async function render(container, params, router) {
- const config = getConfig();
- const logoClass = config.logo_invert_light
- ? 'theme-logo theme-logo--invert-light'
- : 'theme-logo';
-
- litRender(html`
-
-
-
-
- ${config.network_name}
- ${t('maintenance.title')}
- ${t('maintenance.message')}
-
-
-`, container);
-}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/map.js b/src/meshcore_hub/web/static/js/spa/pages/map.js
deleted file mode 100644
index 5d86e02..0000000
--- a/src/meshcore_hub/web/static/js/spa/pages/map.js
+++ /dev/null
@@ -1,361 +0,0 @@
-import { apiGet, isAbortError } from '../api.js';
-import {
- html, litRender, nothing, t,
- getConfig, typeEmoji, formatRelativeTime, escapeHtml, errorAlert,
- timezoneIndicator, formatNumber, renderFilterToggle,
-} from '../components.js';
-
-const MAX_BOUNDS_RADIUS_KM = 20;
-
-function getDistanceKm(lat1, lon1, lat2, lon2) {
- const R = 6371;
- const dLat = (lat2 - lat1) * Math.PI / 180;
- const dLon = (lon2 - lon1) * Math.PI / 180;
- const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
- Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
- Math.sin(dLon / 2) * Math.sin(dLon / 2);
- const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
- return R * c;
-}
-
-function getNodesWithinRadius(nodes, anchorLat, anchorLon, radiusKm) {
- return nodes.filter(n => getDistanceKm(anchorLat, anchorLon, n.lat, n.lon) <= radiusKm);
-}
-
-function getAnchorPoint(nodes, adoptedCenter) {
- if (adoptedCenter) return adoptedCenter;
- if (nodes.length === 0) return { lat: 0, lon: 0 };
- return {
- lat: nodes.reduce((sum, n) => sum + n.lat, 0) / nodes.length,
- lon: nodes.reduce((sum, n) => sum + n.lon, 0) / nodes.length,
- };
-}
-
-function normalizeType(type) {
- return type ? type.toLowerCase() : null;
-}
-
-function getTypeDisplay(node) {
- const type = normalizeType(node.adv_type);
- if (type === 'chat') return (window.t && window.t('node_types.chat')) || 'Chat';
- if (type === 'repeater') return (window.t && window.t('node_types.repeater')) || 'Repeater';
- if (type === 'room') return (window.t && window.t('node_types.room')) || 'Room';
- return type ? type.charAt(0).toUpperCase() + type.slice(1) : (window.t && window.t('node_types.unknown')) || 'Unknown';
-}
-
-// Leaflet DivIcon requires plain HTML strings, so keep escapeHtml here
-function createNodeIcon(node, oidcEnabled) {
- const displayName = node.name || '';
- const relativeTime = formatRelativeTime(node.last_seen);
- const timeDisplay = relativeTime ? ' (' + relativeTime + ')' : '';
-
- const iconHtml = (oidcEnabled && node.is_adopted)
- ? ''
- : '';
-
- return L.divIcon({
- className: 'custom-div-icon',
- html: '' +
- iconHtml +
- '' +
- escapeHtml(displayName) + timeDisplay + '' +
- '',
- iconSize: [120, 50],
- iconAnchor: [60, 12],
- });
-}
-
-// Leaflet popup requires plain HTML strings, so keep escapeHtml here
-function createPopupContent(node, oidcEnabled) {
- const typeDisplay = getTypeDisplay(node);
- const nodeTypeEmoji = typeEmoji(node.adv_type);
-
- let infraIndicatorHtml = '';
- if (oidcEnabled && typeof node.is_adopted !== 'undefined') {
- const dotColor = node.is_adopted ? 'var(--color-marker-infra)' : 'var(--color-marker-public)';
- const borderColor = node.is_adopted ? 'var(--color-marker-infra-border)' : 'var(--color-marker-public-border)';
- const title = node.is_adopted ? ((window.t && window.t('map.infrastructure')) || 'Infrastructure') : ((window.t && window.t('map.public')) || 'Public');
- infraIndicatorHtml = ' ';
- }
-
- const typeLabel = (window.t && window.t('common.type')) || 'Type:';
- const keyLabel = (window.t && window.t('common.key')) || 'Key:';
- const locationLabel = (window.t && window.t('common.location')) || 'Location:';
- const lastSeenLabel = (window.t && window.t('common.last_seen_label')) || 'Last seen:';
- const unknownLabel = (window.t && window.t('node_types.unknown')) || 'Unknown';
- const viewDetailsLabel = (window.t && window.t('common.view_details')) || 'View Details';
-
- let rows = '';
- rows += '' + typeLabel + '' + escapeHtml(typeDisplay) + '';
-
- if (node.role) {
- const roleLabel = (window.t && window.t('map.role')) || 'Role:';
- rows += '' + roleLabel + '' + escapeHtml(node.role) + '';
- }
-
- if (node.owner) {
- const ownerLabel = (window.t && window.t('map.owner')) || 'Owner:';
- const ownerDisplay = node.owner.callsign
- ? escapeHtml(node.owner.name) + ' (' + escapeHtml(node.owner.callsign) + ')'
- : escapeHtml(node.owner.name);
- rows += '' + ownerLabel + '' + ownerDisplay + '';
- }
-
- rows += '' + keyLabel + '' + escapeHtml(node.public_key.substring(0, 16)) + '...';
- rows += '' + locationLabel + '' + node.lat.toFixed(4) + ', ' + node.lon.toFixed(4) + '';
-
- if (node.last_seen) {
- rows += '' + lastSeenLabel + '' + node.last_seen.substring(0, 19).replace('T', ' ') + '';
- }
-
- return '' +
- '' + nodeTypeEmoji + ' ' + escapeHtml(node.name || unknownLabel) + infraIndicatorHtml + '
' +
- '' + rows + '' +
- '' + viewDetailsLabel + '' +
- '';
-}
-
-export async function render(container, params, router) {
- const { signal } = params || {};
- try {
- const config = getConfig();
- const data = await apiGet('/map/data', {}, { signal });
- let allNodes = data.nodes || [];
- const mapCenter = data.center || { lat: 0, lon: 0 };
- const adoptedCenter = data.adopted_center || null;
- const debug = data.debug || {};
- const profiles = data.profiles || [];
- const operatorRole = config.role_names?.operator || 'operator';
- const operatorProfiles = profiles.filter(p => p.roles && p.roles.includes(operatorRole));
-
- const isMobilePortrait = window.innerWidth < 480;
- const isMobile = window.innerWidth < 768;
- const BOUNDS_PADDING = isMobilePortrait ? [50, 50] : (isMobile ? [75, 75] : [100, 100]);
-
- let lastMemberFilter = '';
-
- async function applyFilters() {
- const memberFilter = document.getElementById('member-filter')?.value || '';
-
- if (memberFilter !== lastMemberFilter) {
- lastMemberFilter = memberFilter;
- const params = {};
- if (memberFilter) params.adopted_by = memberFilter;
- const newData = await apiGet('/map/data', params, { signal });
- allNodes = newData.nodes || [];
- }
- const filteredNodes = applyFiltersCore();
- const categoryFilter = container.querySelector('#filter-category').value;
-
- if (filteredNodes.length > 0) {
- let nodesToFit = filteredNodes;
-
- if (categoryFilter !== 'infra') {
- const anchor = getAnchorPoint(filteredNodes, adoptedCenter);
- const nearbyNodes = getNodesWithinRadius(filteredNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM);
- if (nearbyNodes.length > 0) {
- nodesToFit = nearbyNodes;
- }
- }
-
- const bounds = L.latLngBounds(nodesToFit.map(n => [n.lat, n.lon]));
- map.fitBounds(bounds, { padding: BOUNDS_PADDING });
- } else if (mapCenter.lat !== 0 || mapCenter.lon !== 0) {
- map.setView([mapCenter.lat, mapCenter.lon], 10);
- }
- }
-
- function updateLabelVisibility() {
- const showLabels = container.querySelector('#show-labels').checked;
- if (showLabels) {
- mapEl.classList.add('show-labels');
- } else {
- mapEl.classList.remove('show-labels');
- }
- }
-
- function clearFiltersHandler() {
- container.querySelector('#filter-category').value = '';
- container.querySelector('#filter-type').value = '';
- container.querySelector('#show-labels').checked = false;
- const memberEl = container.querySelector('#member-filter');
- if (memberEl) memberEl.value = '';
- updateLabelVisibility();
- applyFilters();
- }
-
- function onMapFilterToggle() {
- const filterDiv = container.querySelector('#map-filter-fields');
- if (filterDiv) filterDiv.classList.toggle('hidden');
- }
-
- const existingToggle = container.querySelector('#filter-toggle');
- const isFilterOpen = existingToggle ? existingToggle.checked : false;
-
- litRender(html`
-
- ${t('entities.map')}
-
- ${timezoneIndicator()}
- ${t('common.loading')}
-
- ${renderFilterToggle({ open: isFilterOpen, onChange: onMapFilterToggle })}
-
-
-
-
-
-
-
-
-
-
-
-
- ${config.oidc_enabled && operatorProfiles.length > 0 ? html`
-
-
-
-
- ` : nothing}
-
-
-
-
-
-
-
-
-
-
-
-
-${config.oidc_enabled ? html`
-
- ${t('map.legend')}
-
-
- ${t('map.infrastructure')}
-
-
-
- ${t('map.public')}
-
-
-` : nothing}
-
-
- ${t('map.gps_description')}
-`, container);
-
- const mapEl = container.querySelector('#spa-map');
- const map = L.map(mapEl).setView([0, 0], 2);
-
- L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
- attribution: '© OpenStreetMap contributors',
- }).addTo(map);
-
- let markers = [];
-
- function clearMarkers() {
- markers.forEach(m => map.removeLayer(m));
- markers = [];
- }
-
- function applyFiltersCore() {
- const categoryFilter = container.querySelector('#filter-category').value;
- const typeFilter = container.querySelector('#filter-type').value;
-
- const filteredNodes = allNodes.filter(node => {
- if (categoryFilter === 'infra' && !node.is_adopted) return false;
- const nodeType = normalizeType(node.adv_type);
- if (typeFilter && nodeType !== typeFilter) return false;
- return true;
- });
-
- clearMarkers();
-
- filteredNodes.forEach(node => {
- const marker = L.marker([node.lat, node.lon], { icon: createNodeIcon(node, config.oidc_enabled) }).addTo(map);
- marker.bindPopup(createPopupContent(node, config.oidc_enabled));
- markers.push(marker);
- });
-
- const countEl = container.querySelector('#node-count');
- const filteredEl = container.querySelector('#filtered-count');
-
- if (filteredNodes.length === allNodes.length) {
- countEl.textContent = t('map.nodes_on_map', { count: formatNumber(allNodes.length) });
- filteredEl.classList.add('hidden');
- } else {
- countEl.textContent = t('common.total', { count: formatNumber(allNodes.length) });
- filteredEl.textContent = t('common.shown', { count: formatNumber(filteredNodes.length) });
- filteredEl.classList.remove('hidden');
- }
-
- return filteredNodes;
- }
-
- if (debug.error) {
- container.querySelector('#node-count').textContent = 'Error: ' + debug.error;
- return () => map.remove();
- }
-
- if (debug.total_nodes === 0) {
- container.querySelector('#node-count').textContent = t('common.no_entity_in_database', { entity: t('entities.nodes').toLowerCase() });
- return () => map.remove();
- }
-
- if (debug.nodes_with_coords === 0) {
- container.querySelector('#node-count').textContent = t('map.nodes_none_have_coordinates', { count: formatNumber(debug.total_nodes) });
- return () => map.remove();
- }
-
- if (config.oidc_enabled) {
- const adoptedNodes = allNodes.filter(n => n.is_adopted);
- if (adoptedNodes.length > 0) {
- const bounds = L.latLngBounds(adoptedNodes.map(n => [n.lat, n.lon]));
- map.fitBounds(bounds, { padding: BOUNDS_PADDING });
- } else if (allNodes.length > 0) {
- const anchor = getAnchorPoint(allNodes, adoptedCenter);
- const nearbyNodes = getNodesWithinRadius(allNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM);
- const nodesToFit = nearbyNodes.length > 0 ? nearbyNodes : allNodes;
- const bounds = L.latLngBounds(nodesToFit.map(n => [n.lat, n.lon]));
- map.fitBounds(bounds, { padding: BOUNDS_PADDING });
- }
- } else if (allNodes.length > 0) {
- const anchor = getAnchorPoint(allNodes, null);
- const nearbyNodes = getNodesWithinRadius(allNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM);
- const nodesToFit = nearbyNodes.length > 0 ? nearbyNodes : allNodes;
- const bounds = L.latLngBounds(nodesToFit.map(n => [n.lat, n.lon]));
- map.fitBounds(bounds, { padding: BOUNDS_PADDING });
- }
-
- applyFiltersCore();
-
- return () => map.remove();
-
- } catch (e) {
- if (isAbortError(e)) return;
- litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
- }
-}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/members.js b/src/meshcore_hub/web/static/js/spa/pages/members.js
deleted file mode 100644
index 0522cd7..0000000
--- a/src/meshcore_hub/web/static/js/spa/pages/members.js
+++ /dev/null
@@ -1,117 +0,0 @@
-import { apiGet, isAbortError } from '../api.js';
-import { html, litRender, nothing, t, errorAlert, getConfig, formatNumber } from '../components.js';
-import { iconAntenna, iconUsers } from '../icons.js';
-
-function renderProfileTile(profile, router) {
- const callsignBadge = profile.callsign
- ? html`${profile.callsign}`
- : nothing;
-
- const roleBadges = profile.roles && profile.roles.length > 0
- ? html`${profile.roles.map(role =>
- html`${role}`
- )}`
- : nothing;
-
- const nodeCountLabel = profile.node_count > 0
- ? html`${t('members_page.node_count', { count: formatNumber(profile.node_count) })}`
- : nothing;
-
- const nodeBadges = profile.adopted_nodes && profile.adopted_nodes.length > 0
- ? html`${profile.adopted_nodes.map(node => {
- const label = node.name || node.public_key.slice(0, 12) + '...';
- const handleClick = (e) => {
- e.preventDefault();
- e.stopPropagation();
- router.navigate('/nodes/' + node.public_key);
- };
- return html` { if (e.key === 'Enter' || e.key === ' ') handleClick(e); }}>${label}`;
- })}`
- : nothing;
-
- const descriptionText = profile.description
- ? html`${profile.description}
`
- : nothing;
-
- const openUrl = (e) => { e.preventDefault(); e.stopPropagation(); window.open(profile.url, '_blank', 'noopener,noreferrer'); };
- const urlLink = profile.url
- ? html` { if (e.key === 'Enter') openUrl(e); }}>${profile.url}`
- : nothing;
-
- return html`
-
-
- ${profile.name || t('common.unnamed')}
- ${callsignBadge}
-
- ${roleBadges}
- ${descriptionText}
- ${urlLink}
- ${nodeCountLabel}
- ${nodeBadges}
-
- `;
-}
-
-function renderGroup(title, profiles, icon, router) {
- if (profiles.length === 0) return nothing;
- return html`
-
- ${icon}${title}
-
-
- ${profiles.sort((a, b) => (a.name || '').localeCompare(b.name || '')).map(p => renderProfileTile(p, router))}
-`;
-}
-
-export async function render(container, params, router) {
- const { signal } = params || {};
- try {
- const config = getConfig();
- const roleNames = config.role_names || {};
- const operatorRole = roleNames.operator || 'operator';
- const memberRole = roleNames.member || 'member';
- const testRole = roleNames.test || 'test';
-
- const resp = await apiGet('/api/v1/user/profiles', { limit: 500 }, { signal });
- const allProfiles = resp.items || [];
-
- const profiles = allProfiles.filter(p => !p.roles || !p.roles.includes(testRole));
-
- if (profiles.length === 0) {
- litRender(html`
-
- ${t('entities.members')}
-
-
-
- ${t('members_page.empty_state')}
- ${t('members_page.empty_description')}
-`, container);
- return;
- }
-
- const operators = profiles.filter(p => p.roles && p.roles.includes(operatorRole));
- const members = profiles.filter(p =>
- p.roles && p.roles.includes(memberRole) && !p.roles.includes(operatorRole)
- );
-
- litRender(html`
-
- ${t('entities.members')}
- ${t('common.count_entity', { count: formatNumber(operators.length + members.length), entity: t('entities.members').toLowerCase() })}
-
-
-${renderGroup(t('members_page.operators'), operators, html`${iconAntenna('h-6 w-6')}`, router)}
-${renderGroup(t('members_page.members'), members, html`${iconUsers('h-6 w-6')}`, router)}
-`, container);
-
- } catch (e) {
- if (isAbortError(e)) return;
- litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
- }
-}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/messages.js b/src/meshcore_hub/web/static/js/spa/pages/messages.js
deleted file mode 100644
index 45948b4..0000000
--- a/src/meshcore_hub/web/static/js/spa/pages/messages.js
+++ /dev/null
@@ -1,508 +0,0 @@
-import { apiGet, isAbortError } from '../api.js';
-import {
- html, litRender, nothing, t,
- getConfig, formatDateTime, formatDateTimeShort, formatNumber,
- getChannelLabelsMap, resolveChannelLabel,
- warningBadge,
- pagination, sortableTableHeader, mobileSortSelect,
- renderFilterForm, renderFilterToggle, autoSubmit,
- observerIcons, getDisabledObserverAreas, toggleObserverArea, observerFilterBadges
-} from '../components.js';
-import { createAutoRefresh } from '../auto-refresh.js';
-
-export async function render(container, params, router) {
- const { signal } = params || {};
- const query = params.query || {};
- const message_type = query.message_type || '';
- const channel_idx = query.channel_idx || '';
- const includeSpamParam = query.include_spam === 'true' || query.include_spam === true;
- const page = parseInt(query.page, 10) || 1;
- const limit = parseInt(query.limit, 10) || 50;
- const offset = (page - 1) * limit;
- const sort = query.sort || 'time';
- const order = query.order || 'desc';
-
- // Observer filter is sourced from localStorage (shared toggle badges), not the URL.
- let disabledObserverAreas = getDisabledObserverAreas();
-
- const config = getConfig();
- const features = config.features || {};
- const packetsEnabled = features.packets !== false;
- // Spam toggle is only shown when the feature is enabled; when off the API
- // returns everything anyway, so the include_spam param is a no-op.
- const spamEnabled = features.spam === true;
- const includeSpam = spamEnabled && includeSpamParam;
- // Threshold the API hides on; the badge must use the same value so a row is
- // badged exactly when it would be hidden (see web app config).
- const spamThreshold = typeof config.spam_score_threshold === 'number'
- ? config.spam_score_threshold
- : 0.65;
- let channelLabels = new Map();
- const tz = config.timezone || '';
- const tzBadge = tz && tz !== 'UTC' ? html`${tz}` : nothing;
- const navigate = (url) => router.navigate(url);
- // Packet-detail target for a row/card, or null when not navigable.
- const packetDetailUrl = (packetHash) =>
- (packetsEnabled && packetHash) ? `/packets/hash/${packetHash}` : null;
-
- function channelInfo(msg) {
- if (msg.message_type !== 'channel') {
- return { label: null, text: msg.text || '-' };
- }
- const rawText = msg.text || '';
- const match = rawText.match(/^\[([^\]]+)\]\s+([\s\S]*)$/);
- if (msg.channel_idx !== null && msg.channel_idx !== undefined) {
- const knownLabel = resolveChannelLabel(msg.channel_idx, channelLabels);
- if (knownLabel) {
- return {
- label: knownLabel,
- text: match ? (match[2] || '-') : (rawText || '-'),
- };
- }
- }
- if (msg.channel_name) {
- return { label: msg.channel_name, text: msg.text || '-' };
- }
- if (match) {
- return {
- label: match[1],
- text: match[2] || '-',
- };
- }
- if (msg.channel_idx !== null && msg.channel_idx !== undefined) {
- const knownLabel = resolveChannelLabel(msg.channel_idx, channelLabels);
- return { label: knownLabel || `Ch ${msg.channel_idx}`, text: rawText || '-' };
- }
- return { label: t('messages.type_channel'), text: rawText || '-' };
- }
-
- function senderBlock(msg, emphasize = false) {
- const senderName = msg.sender_tag_name || msg.sender_name;
- if (senderName) {
- return emphasize
- ? html`${senderName}`
- : html`${senderName}`;
- }
- const prefix = (msg.pubkey_prefix || '').slice(0, 12);
- if (prefix) {
- return html`${prefix}`;
- }
- return html`-`;
- }
-
- function parseSenderFromText(text) {
- if (!text || typeof text !== 'string') {
- return { sender: null, text: text || '-' };
- }
- const patterns = [
- /^\s*ack\s+@\[(.+?)\]\s*:\s*([\s\S]+)$/i,
- /^\s*@\[(.+?)\]\s*:\s*([\s\S]+)$/i,
- /^\s*ack\s+([^:|\n]{1,80})\s*:\s*([\s\S]+)$/i,
- ];
- for (const pattern of patterns) {
- const match = text.match(pattern);
- if (!match) continue;
- const sender = (match[1] || '').trim();
- const remaining = (match[2] || '').trim();
- if (!sender) continue;
- return {
- sender,
- text: remaining || text,
- };
- }
- return { sender: null, text };
- }
-
- // Collapse any run of newlines (and the whitespace around them) into a
- // single space so multi-line messages don't blow up the table/card layout.
- function collapseNewlines(text) {
- if (!text || typeof text !== 'string') return text;
- return text.replace(/\s*\n\s*/g, ' ');
- }
-
- function messageTextWithSender(msg, text) {
- const parsed = parseSenderFromText(text || '-');
- const explicitSender = msg.sender_tag_name || msg.sender_name || (msg.pubkey_prefix || '').slice(0, 12) || null;
- const sender = explicitSender || parsed.sender;
- const body = collapseNewlines((parsed.text || text || '-').trim()) || '-';
- if (!sender) {
- return body;
- }
- if (body.toLowerCase().startsWith(`${sender.toLowerCase()}:`)) {
- return body;
- }
- return `${sender}: ${body}`;
- }
-
- // Small badge for rows the scorer flagged as likely spam. Only meaningful
- // when the spam feature is on (otherwise spam_score is null on every row).
- function spamBadge(msg) {
- if (!spamEnabled || msg.spam_score == null || msg.spam_score < spamThreshold) {
- return nothing;
- }
- return html`${t('messages.spam.badge')}`;
- }
-
- function dedupeBySignature(items) {
- const deduped = [];
- const bySignature = new Map();
-
- for (const msg of items) {
- const signature = typeof msg.signature === 'string' ? msg.signature.trim().toUpperCase() : '';
- const canDedupe = msg.message_type === 'channel' && signature.length >= 8;
- if (!canDedupe) {
- deduped.push(msg);
- continue;
- }
-
- const existing = bySignature.get(signature);
- if (!existing) {
- const clone = {
- ...msg,
- observers: [...(msg.observers || [])],
- };
- bySignature.set(signature, clone);
- deduped.push(clone);
- continue;
- }
-
- const combined = [...(existing.observers || []), ...(msg.observers || [])];
- const seenReceivers = new Set();
- existing.observers = combined.filter((recv) => {
- const key = recv?.public_key || recv?.node_id || `${recv?.observed_at || ''}:${recv?.snr || ''}`;
- if (seenReceivers.has(key)) return false;
- seenReceivers.add(key);
- return true;
- });
-
- if (!existing.observed_by && msg.observed_by) existing.observed_by = msg.observed_by;
- if (!existing.observer_name && msg.observer_name) existing.observer_name = msg.observer_name;
- if (!existing.observer_tag_name && msg.observer_tag_name) existing.observer_tag_name = msg.observer_tag_name;
- if (!existing.pubkey_prefix && msg.pubkey_prefix) existing.pubkey_prefix = msg.pubkey_prefix;
- if (!existing.sender_name && msg.sender_name) existing.sender_name = msg.sender_name;
- if (!existing.sender_tag_name && msg.sender_tag_name) existing.sender_tag_name = msg.sender_tag_name;
- if (!existing.channel_name && msg.channel_name) existing.channel_name = msg.channel_name;
- if (
- existing.channel_name === 'Public'
- && msg.channel_name
- && msg.channel_name !== 'Public'
- ) {
- existing.channel_name = msg.channel_name;
- }
- if (existing.channel_idx === null || existing.channel_idx === undefined) {
- if (msg.channel_idx !== null && msg.channel_idx !== undefined) {
- existing.channel_idx = msg.channel_idx;
- }
- } else if (
- existing.channel_idx === 17
- && msg.channel_idx !== null
- && msg.channel_idx !== undefined
- && msg.channel_idx !== 17
- ) {
- existing.channel_idx = msg.channel_idx;
- }
- }
-
- return deduped;
- }
-
- let lastContent = nothing;
- let lastTotal = null;
- let currentFilterFields = [];
- const hasActiveFilters = message_type !== '' || channel_idx !== '' || includeSpam;
-
- function onFilterToggle() { renderPage(lastContent, { total: lastTotal }); }
-
- function renderPage(content, { total = null, error = null } = {}) {
- if (!error) {
- lastContent = content;
- lastTotal = total;
- }
- const displayContent = error ? lastContent : content;
- const displayTotal = error ? lastTotal : total;
- const existingToggle = container.querySelector('#filter-toggle');
- const filterOpen = existingToggle ? existingToggle.checked : hasActiveFilters;
- litRender(html`
-
- ${t('entities.messages')}
- ${tzBadge}
-
-
- ${displayTotal !== null
- ? html`${t('common.total', { count: formatNumber(displayTotal) })}`
- : nothing}
- ${error ? warningBadge(error) : nothing}
-
-
-
- ${renderFilterToggle({ open: filterOpen, onChange: onFilterToggle })}
-
-${(filterOpen && currentFilterFields.length > 0)
- ? html`${renderFilterForm({ fields: currentFilterFields, basePath: '/messages', navigate })}`
- : nothing}
-${displayContent}`, container);
- }
-
- // Render page header immediately (old content stays visible until data loads)
- renderPage(nothing);
-
- async function fetchAndRenderData() {
- try {
- // Phase 1: fetch the observer node list (and channels) first. The messages
- // API filters observers by inclusion only, so we need the full observer list
- // to translate the stored "disabled" set into an explicit include-list.
- const [nodesData, channelsData] = await Promise.all([
- apiGet('/api/v1/nodes', { limit: 500, observer: true }, { signal }),
- apiGet('/api/v1/channels', {}, { signal }),
- ]);
- const builtinLabels = getChannelLabelsMap(config);
- const customLabels = new Map(
- (channelsData.items || [])
- .map(ch => [parseInt(ch.channel_hash, 16), ch.name])
- .filter(([idx]) => Number.isInteger(idx)),
- );
- channelLabels = new Map([...builtinLabels, ...customLabels]);
- const allNodes = nodesData.items || [];
-
- const areaMap = new Map(); // area -> public_key[]
- for (const n of allNodes) {
- const area = n.tags?.find(tg => tg.key === 'area')?.value;
- if (!area || !area.trim()) continue;
- const key = area.trim();
- if (!areaMap.has(key)) areaMap.set(key, []);
- areaMap.get(key).push(n.public_key);
- }
- const sortedAreas = [...areaMap.keys()]
- .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
- const enabledObserverKeys = sortedAreas
- .filter(a => !disabledObserverAreas.has(a))
- .flatMap(a => areaMap.get(a));
- // Only constrain when some current area is actually hidden.
- const observerFilterActive = sortedAreas.some(a => disabledObserverAreas.has(a));
-
- const onObserverToggle = (area) => {
- disabledObserverAreas = toggleObserverArea(area, sortedAreas.length);
- if (page > 1) {
- // Re-scoping the data invalidates the current page; reset to page 1.
- const sp = new URLSearchParams(window.location.search);
- sp.delete('page');
- const qs = sp.toString();
- navigate(qs ? `/messages?${qs}` : '/messages');
- } else {
- fetchAndRenderData();
- }
- };
-
- // Phase 2: fetch the messages with the resolved observer filter.
- const apiParams = { limit, offset, message_type, channel_idx, sort, order };
- if (observerFilterActive) apiParams.observed_by = enabledObserverKeys;
- if (includeSpam) apiParams.include_spam = true;
- const data = await apiGet('/api/v1/messages', apiParams, { signal });
- const messages = dedupeBySignature(data.items || []);
- const total = data.total || 0;
- const totalPages = Math.ceil(total / limit);
-
- const observerBadges = (extraClass) => observerFilterBadges({
- areas: sortedAreas, disabled: disabledObserverAreas, onToggle: onObserverToggle, extraClass,
- });
-
- const mobileCards = messages.length === 0
- ? html`${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}`
- : messages.map(msg => {
- const isChannel = msg.message_type === 'channel';
- const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}';
- const typeTitle = isChannel ? t('messages.type_channel') : t('messages.type_contact');
- const chInfo = channelInfo(msg);
- const sender = senderBlock(msg);
- const displayMessage = messageTextWithSender(msg, chInfo.text);
- const fromPrimary = isChannel
- ? html`${chInfo.label || t('messages.type_channel')}`
- : sender;
- let receiversBlock = nothing;
- if (msg.observers && msg.observers.length >= 1) {
- receiversBlock = observerIcons(msg.observers);
- } else if (msg.observed_by) {
- receiversBlock = html`\u{1F4E1}`;
- }
- const detailUrl = packetDetailUrl(msg.packet_hash);
- return html` navigate(detailUrl) : undefined}>
-
-
-
-
- ${typeIcon}
-
-
-
- ${fromPrimary}
-
-
- ${formatDateTimeShort(msg.received_at)}
- ${spamBadge(msg)}
-
-
-
-
- ${receiversBlock}
-
-
- ${displayMessage}
-
- `;
- });
-
- const tableRows = messages.length === 0
- ? html`${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })} `
- : messages.map(msg => {
- const isChannel = msg.message_type === 'channel';
- const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}';
- const typeTitle = isChannel ? t('messages.type_channel') : t('messages.type_contact');
- const chInfo = channelInfo(msg);
- const sender = senderBlock(msg, true);
- const displayMessage = messageTextWithSender(msg, chInfo.text);
- const fromPrimary = isChannel
- ? html`${chInfo.label || t('messages.type_channel')}`
- : sender;
- let receiversBlock;
- if (msg.observers && msg.observers.length >= 1) {
- receiversBlock = html`${observerIcons(msg.observers)}`;
- } else if (msg.observed_by) {
- receiversBlock = html`\u{1F4E1}`;
- } else {
- receiversBlock = html`-`;
- }
- const detailUrl = packetDetailUrl(msg.packet_hash);
- return html` navigate(detailUrl) : undefined}>
- ${typeIcon}
- ${formatDateTime(msg.received_at)}
-
- ${fromPrimary}
-
-
-
- ${displayMessage}
- ${spamBadge(msg)}
-
-
- ${receiversBlock}
- `;
- });
-
- const spamParam = includeSpam ? { include_spam: 'true' } : {};
- const paginationBlock = pagination(page, totalPages, '/messages', {
- message_type, channel_idx, limit, sort, order, ...spamParam,
- });
-
- const filterFields = [
- () => html`
-
-
-
- `,
- () => html`
-
-
-
- `,
- ];
- if (spamEnabled) {
- filterFields.push(() => html`
-
-
-
- `);
- }
- const headerParams = { message_type, channel_idx, limit, ...spamParam };
- const sortable = (label, sortKey) => sortableTableHeader(label, {
- sortKey, currentSort: sort, currentOrder: order,
- navigate, basePath: '/messages', params: headerParams,
- });
-
- currentFilterFields = filterFields;
-
- renderPage(html`
-
-${observerBadges('hidden lg:flex mb-4')}
-
-${mobileSortSelect({
- currentSort: sort, currentOrder: order,
- navigate, basePath: '/messages',
- params: headerParams,
- options: [
- { value: 'time:desc', label: t('messages.sort.newest') },
- { value: 'time:asc', label: t('messages.sort.oldest') },
- { value: 'type:asc', label: t('messages.sort.type_az') },
- { value: 'type:desc', label: t('messages.sort.type_za') },
- { value: 'from:asc', label: t('messages.sort.from_az') },
- { value: 'from:desc', label: t('messages.sort.from_za') },
- { value: 'message:asc', label: t('messages.sort.message_az') },
- { value: 'message:desc', label: t('messages.sort.message_za') },
- ],
-})}
-
-${observerBadges('flex lg:hidden mb-4')}
-
-
- ${mobileCards}
-
-
-
-
-
-
- ${sortable(t('common.type'), 'type')}
- ${sortable(t('common.time'), 'time')}
- ${sortable(t('common.from'), 'from')}
- ${sortable(t('entities.message'), 'message')}
- ${t('common.observers')}
-
-
-
- ${tableRows}
-
-
-
-
-${paginationBlock}`, { total });
-
- } catch (e) {
- if (isAbortError(e)) return;
- renderPage(nothing, { error: e.message });
- }
- }
-
- await fetchAndRenderData();
-
- const toggleEl = container.querySelector('#auto-refresh-toggle');
- const { cleanup } = createAutoRefresh({
- fetchAndRender: fetchAndRenderData,
- toggleContainer: toggleEl,
- });
- return cleanup;
-}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js
deleted file mode 100644
index bfaed26..0000000
--- a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js
+++ /dev/null
@@ -1,657 +0,0 @@
-import { apiGet, apiPost, apiPut, apiDelete, isAbortError } from '../api.js';
-import {
- html, litRender, nothing,
- getConfig, hasRole, typeEmoji, formatDateTime,
- truncateKey, errorAlert, successAlert, copyToClipboard, t,
-} from '../components.js';
-import { iconError, iconPlus, iconEdit, iconTrash } from '../icons.js';
-
-let _mapInstance = null;
-
-function validateTagValue(value, type) {
- if (!value || !type) return null;
- if (type === 'number' && isNaN(Number(value))) {
- return t('common.validation_invalid_number');
- }
- if (type === 'boolean') {
- const normalized = value.toLowerCase().trim();
- if (!['true', 'false', 'yes', 'no', '1', '0'].includes(normalized)) {
- return t('common.validation_invalid_boolean');
- }
- }
- return null;
-}
-
-function renderDeleteTagModal() {
- return html`
-`;
-}
-
-function renderEditTagModal() {
- return html`
-`;
-}
-
-export async function render(container, params, router) {
- const { signal } = params || {};
- const cleanupFns = [];
- let publicKey = params.publicKey;
-
- try {
- if (publicKey.length !== 64) {
- const resolved = await apiGet('/api/v1/nodes/prefix/' + encodeURIComponent(publicKey), {}, { signal });
- router.navigate('/nodes/' + resolved.public_key, true);
- return;
- }
-
- const [node, adsData, telemetryData] = await Promise.all([
- apiGet('/api/v1/nodes/' + publicKey, {}, { signal }),
- apiGet('/api/v1/advertisements', { public_key: publicKey, limit: 10 }, { signal }),
- apiGet('/api/v1/telemetry', { node_public_key: publicKey, limit: 10 }, { signal }),
- ]);
-
- if (!node) {
- litRender(renderNotFound(publicKey), container);
- return;
- }
-
- const config = getConfig();
- const tagName = node.tags?.find(t => t.key === 'name')?.value;
- const tagDescription = node.tags?.find(t => t.key === 'description')?.value;
- const displayName = tagName || node.name || t('common.unnamed_node');
- const emoji = typeEmoji(node.adv_type);
-
- let lat = node.lat;
- let lon = node.lon;
- if (!lat || !lon) {
- for (const tag of node.tags || []) {
- if (tag.key === 'lat' && !lat) lat = parseFloat(tag.value);
- if (tag.key === 'lon' && !lon) lon = parseFloat(tag.value);
- }
- }
- const hasCoords = lat != null && lon != null && !(lat === 0 && lon === 0);
-
- const advertisements = adsData.items || [];
-
- const heroHtml = hasCoords
- ? html`
-
-
-
-
-
-`
- : html`
-
-
-
- ${t('nodes.scan_to_add')}
-
-`;
-
- const coordsHtml = hasCoords
- ? html`${t('common.location')}: ${lat}, ${lon}`
- : nothing;
-
- const adsTableHtml = advertisements.length > 0
- ? html`
-
-
-
- ${t('common.time')}
- ${t('common.type')}
- ${t('common.received_by')}
-
-
-
- ${advertisements.map(adv => {
- const advEmoji = adv.adv_type ? typeEmoji(adv.adv_type) : '';
- const advTypeHtml = adv.adv_type
- ? html`${advEmoji}`
- : html`-`;
- const recvName = adv.observed_by ? (adv.observer_tag_name || adv.observer_name) : null;
- const receiverHtml = !adv.observed_by
- ? html`-`
- : recvName
- ? html`
- ${recvName}
-
- `
- : html`
- ${adv.observed_by.slice(0, 12)}...
- `;
- return html`
- ${formatDateTime(adv.received_at)}
- ${advTypeHtml}
- ${receiverHtml}
- `;
- })}
-
-
- `
- : html`${t('common.no_entity_recorded', { entity: t('entities.advertisements').toLowerCase() })}
`;
-
- const tags = node.tags || [];
- const canEditTags = config.oidc_enabled && config.user && (
- hasRole('admin') || (hasRole('operator') && node.adopted_by?.user_id === config.user.sub)
- );
-
- const tagsTableHtml = canEditTags
- ? (tags.length > 0
- ? html`
-
-
-
- ${t('common.key')}
- ${t('common.value')}
- ${t('common.type')}
- ${t('common.actions')}
-
-
-
- ${tags.map(tag => html`
- ${tag.key}
- ${tag.value || ''}
- ${tag.value_type || 'string'}
-
-
-
-
-
-
- `)}
-
-
- `
- : html`${t('common.no_entity_defined', { entity: t('entities.tags').toLowerCase() })}
`)
- : (tags.length > 0
- ? html`
-
-
-
- ${t('common.key')}
- ${t('common.value')}
- ${t('common.type')}
-
-
-
- ${tags.map(tag => html`
- ${tag.key}
- ${tag.value || ''}
- ${tag.value_type || 'string'}
- `)}
-
-
- `
- : html`${t('common.no_entity_defined', { entity: t('entities.tags').toLowerCase() })}
`);
-
- const addTagFormHtml = canEditTags
- ? html``
- : nothing;
-
- const adoptionHtml = renderAdoptionSection(node, config);
-
- const flashMessage = (params.query && params.query.message) || '';
- const flashError = (params.query && params.query.error) || '';
- const flashHtml = flashMessage ? successAlert(flashMessage) : flashError ? errorAlert(flashError) : nothing;
-
- const infoGridHtml = adoptionHtml
- ? html`
-
-
-
- ${t('common.public_key')}
- copyToClipboard(e, node.public_key)}
- title="Click to copy">${node.public_key}
-
-
- ${t('common.first_seen_label')} ${formatDateTime(node.first_seen)}
- ${t('common.last_seen_label')} ${formatDateTime(node.last_seen)}
- ${coordsHtml}
-
-
-
- ${adoptionHtml}
-`
- : html`
-
-
- ${t('common.public_key')}
- copyToClipboard(e, node.public_key)}
- title="Click to copy">${node.public_key}
-
-
- ${t('common.first_seen_label')} ${formatDateTime(node.first_seen)}
- ${t('common.last_seen_label')} ${formatDateTime(node.last_seen)}
- ${coordsHtml}
-
-
-`;
-
- litRender(html`
-
-
-
- ${emoji}
-
- ${displayName}
- ${tagDescription ? html`${tagDescription}
` : nothing}
-
-
-
-${flashHtml}
-
-
-
-${heroHtml}
-
-${infoGridHtml}
-
-
-
-
- ${t('common.recent_entity', { entity: t('entities.advertisements') })}
- ${adsTableHtml}
-
-
-
-
-
- ${t('entities.tags')}
- ${tagsTableHtml}
- ${addTagFormHtml}
-
-
-
-
-${canEditTags ? renderDeleteTagModal() : nothing}
-${canEditTags ? renderEditTagModal() : nothing}`, container);
- if (hasCoords && typeof L !== 'undefined') {
- const mapEl = document.getElementById('header-map');
- if (mapEl) {
- if (_mapInstance) {
- try { _mapInstance.remove(); } catch (e) { /* ignore */ }
- _mapInstance = null;
- }
- if (mapEl._leaflet_id != null) {
- delete mapEl._leaflet_id;
- }
- }
- const map = L.map('header-map', {
- zoomControl: false, dragging: false, scrollWheelZoom: false,
- doubleClickZoom: false, boxZoom: false, keyboard: false,
- attributionControl: false,
- });
- L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
- map.setView([lat, lon], 14);
- const point = map.latLngToContainerPoint([lat, lon]);
- const newPoint = L.point(point.x + map.getSize().x * 0.17, point.y);
- const newLatLng = map.containerPointToLatLng(newPoint);
- map.setView(newLatLng, 14, { animate: false });
- const mapIcon = L.divIcon({
- html: '' + emoji + '',
- className: '', iconSize: [32, 32], iconAnchor: [16, 16],
- });
- L.marker([lat, lon], { icon: mapIcon }).addTo(map);
- _mapInstance = map;
- cleanupFns.push(() => { _mapInstance = null; try { map.remove(); } catch (e) { /* ignore */ } });
- }
-
- // Initialize QR code - wait for both DOM element and QRCode library
- const initQr = () => {
- const qrEl = document.getElementById('qr-code');
- if (!qrEl || typeof QRCode === 'undefined') return false;
- const typeMap = { chat: 1, repeater: 2, room: 3, companion: 1, sensor: 4 };
- const typeNum = typeMap[(node.adv_type || '').toLowerCase()] || 1;
- const url = 'meshcore://contact/add?name=' + encodeURIComponent(displayName) + '&public_key=' + node.public_key + '&type=' + typeNum;
- new QRCode(qrEl, {
- text: url, width: 140, height: 140,
- colorDark: '#000000', colorLight: '#ffffff',
- correctLevel: QRCode.CorrectLevel.L,
- });
- return true;
- };
- if (!initQr()) {
- let attempts = 0;
- const qrInterval = setInterval(() => {
- if (initQr() || ++attempts >= 20) clearInterval(qrInterval);
- }, 100);
- cleanupFns.push(() => clearInterval(qrInterval));
- }
-
- // Wire up adoption buttons
- const adoptReleaseAc = new AbortController();
- const adoptReleaseSignal = adoptReleaseAc.signal;
- cleanupFns.push(() => adoptReleaseAc.abort());
-
- const adoptBtn = container.querySelector('.btn-adopt-node');
- if (adoptBtn) {
- adoptBtn.addEventListener('click', async () => {
- try {
- await apiPost('/api/v1/adoptions', { public_key: node.public_key });
- router.navigate('/nodes/' + node.public_key + '?message=' + encodeURIComponent(t('nodes.adopt_success')), true);
- } catch (err) {
- router.navigate('/nodes/' + node.public_key + '?error=' + encodeURIComponent(err.message), true);
- }
- }, { signal: adoptReleaseSignal });
- }
-
- const releaseBtn = container.querySelector('.btn-release-node');
- if (releaseBtn) {
- releaseBtn.addEventListener('click', async () => {
- if (!confirm(t('nodes.release_confirm'))) return;
- try {
- await apiDelete('/api/v1/adoptions/' + node.public_key);
- router.navigate('/nodes/' + node.public_key + '?message=' + encodeURIComponent(t('nodes.release_success')), true);
- } catch (err) {
- router.navigate('/nodes/' + node.public_key + '?error=' + encodeURIComponent(err.message), true);
- }
- }, { signal: adoptReleaseSignal });
- }
-
- // Tag editor event handlers
- if (canEditTags) {
- const ac = new AbortController();
- const { signal } = ac;
- cleanupFns.push(() => ac.abort());
-
- const refreshNode = async () => {
- const fresh = await apiGet('/api/v1/nodes/' + node.public_key);
- return fresh;
- };
-
- const showFlash = (type, message) => {
- const flashContainer = container.querySelector('#flash-container');
- if (!flashContainer) return;
- litRender(type === 'success' ? successAlert(message) : errorAlert(message), flashContainer);
- setTimeout(() => {
- if (flashContainer) litRender(nothing, flashContainer);
- }, 3000);
- };
-
- // Add tag form
- const addForm = container.querySelector('#tag-add-form');
- if (addForm) {
- addForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- const formData = new FormData(addForm);
- const key = formData.get('key');
- const value = formData.get('value') || '';
- const valueType = formData.get('value_type');
- const errEl = container.querySelector('#tagAddError');
-
- const validationError = validateTagValue(value, valueType);
- if (validationError) {
- if (errEl) { errEl.textContent = validationError; errEl.classList.remove('hidden'); }
- return;
- }
- if (errEl) { errEl.textContent = ''; errEl.classList.add('hidden'); }
-
- try {
- await apiPost('/api/v1/nodes/' + node.public_key + '/tags', { key, value, value_type: valueType });
- showFlash('success', t('common.entity_added_success', { entity: t('entities.tag') }));
- addForm.reset();
- router.navigate('/nodes/' + node.public_key, true);
- } catch (err) {
- showFlash('error', err.message);
- }
- }, { signal });
- }
-
- // Edit buttons
- container.querySelectorAll('.tag-edit-btn').forEach(btn => {
- btn.addEventListener('click', () => {
- const modal = container.querySelector('#tagEditModal');
- const keyInput = container.querySelector('#tagEditKey');
- const keyDisplay = container.querySelector('#tagEditKeyDisplay');
- const valueInput = container.querySelector('#tagEditValue');
- const typeSelect = container.querySelector('#tagEditType');
- const errorLabel = container.querySelector('#tagEditError');
-
- keyInput.value = btn.dataset.key;
- if (keyDisplay) keyDisplay.textContent = btn.dataset.key;
- valueInput.value = btn.dataset.value;
- typeSelect.value = btn.dataset.type;
- if (errorLabel) { errorLabel.textContent = ''; errorLabel.classList.add('hidden'); }
- modal.showModal();
- }, { signal });
- });
-
- // Edit form submit
- const editForm = container.querySelector('#tag-edit-form');
- if (editForm) {
- editForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- const key = container.querySelector('#tagEditKey').value;
- const value = container.querySelector('#tagEditValue').value;
- const valueType = container.querySelector('#tagEditType').value;
- const errorLabel = container.querySelector('#tagEditError');
-
- const validationError = validateTagValue(value, valueType);
- if (validationError) {
- if (errorLabel) { errorLabel.textContent = validationError; errorLabel.classList.remove('hidden'); }
- return;
- }
- if (errorLabel) { errorLabel.textContent = ''; errorLabel.classList.add('hidden'); }
-
- const submitBtn = container.querySelector('#tagEditSubmit');
- const cancelBtn = container.querySelector('#tagEditCancel');
- const orig = submitBtn.innerHTML;
- submitBtn.disabled = true;
- cancelBtn.disabled = true;
- submitBtn.innerHTML = ` ${orig}`;
- try {
- await apiPut('/api/v1/nodes/' + node.public_key + '/tags/' + encodeURIComponent(key), { value, value_type: valueType });
- container.querySelector('#tagEditModal').close();
- showFlash('success', t('common.entity_updated_success', { entity: t('entities.tag') }));
- router.navigate('/nodes/' + node.public_key, true);
- } catch (err) {
- if (errorLabel) { errorLabel.textContent = err.message; errorLabel.classList.remove('hidden'); }
- } finally {
- submitBtn.disabled = false;
- cancelBtn.disabled = false;
- submitBtn.innerHTML = orig;
- }
- }, { signal });
- }
-
- // Edit cancel
- const editCancel = container.querySelector('#tagEditCancel');
- if (editCancel) {
- editCancel.addEventListener('click', () => {
- container.querySelector('#tagEditModal').close();
- }, { signal });
- }
-
- // Delete buttons
- container.querySelectorAll('.tag-delete-btn').forEach(btn => {
- btn.addEventListener('click', () => {
- const modal = container.querySelector('#tagDeleteModal');
- const msg = container.querySelector('#tag-delete-msg');
- msg.innerHTML = t('common.delete_entity_confirm', { entity: t('entities.tag'), name: btn.dataset.key });
- modal._tagKey = btn.dataset.key;
- modal.showModal();
- }, { signal });
- });
-
- // Delete confirm
- const deleteConfirm = container.querySelector('#tagDeleteConfirm');
- if (deleteConfirm) {
- deleteConfirm.addEventListener('click', async () => {
- const modal = container.querySelector('#tagDeleteModal');
- const key = modal._tagKey;
- const cancelBtn = container.querySelector('#tagDeleteCancel');
- const orig = deleteConfirm.innerHTML;
- deleteConfirm.disabled = true;
- cancelBtn.disabled = true;
- deleteConfirm.innerHTML = ` ${orig}`;
- try {
- await apiDelete('/api/v1/nodes/' + node.public_key + '/tags/' + encodeURIComponent(key));
- modal.close();
- showFlash('success', t('common.entity_deleted_success', { entity: t('entities.tag') }));
- router.navigate('/nodes/' + node.public_key, true);
- } catch (err) {
- modal.close();
- showFlash('error', err.message);
- } finally {
- deleteConfirm.disabled = false;
- cancelBtn.disabled = false;
- deleteConfirm.innerHTML = orig;
- }
- }, { signal });
- }
-
- // Delete cancel
- const deleteCancel = container.querySelector('#tagDeleteCancel');
- if (deleteCancel) {
- deleteCancel.addEventListener('click', () => {
- container.querySelector('#tagDeleteModal').close();
- }, { signal });
- }
- }
-
- return () => {
- cleanupFns.forEach(fn => fn());
- };
- } catch (e) {
- if (isAbortError(e)) return;
- if (e.message && e.message.includes('404')) {
- litRender(renderNotFound(publicKey), container);
- } else {
- litRender(errorAlert(e.message), container);
- }
- }
-}
-
-function renderAdoptionSection(node, config) {
- if (!config.oidc_enabled || !config.user) return nothing;
-
- const isOperator = hasRole('operator');
- const isAdmin = hasRole('admin');
- if (!isOperator && !isAdmin) {
- if (node.adopted_by) {
- const ownerName = node.adopted_by.name || node.adopted_by.user_id;
- return html`
-
- ${t('nodes.ownership')}
-
- ${t('nodes.adopted_by_prefix')}
- ${ownerName}
-
-
- `;
- }
- return nothing;
- }
-
- if (node.adopted_by) {
- const ownerName = node.adopted_by.name || node.adopted_by.user_id;
- const isOwner = node.adopted_by.user_id === config.user.sub;
- const canRelease = isOwner || isAdmin;
-
- const releaseBtnHtml = canRelease
- ? html``
- : nothing;
-
- return html`
-
- ${t('nodes.ownership')}
-
-
- ${t('nodes.adopted_by_prefix')}
- ${ownerName}
-
- ${releaseBtnHtml}
-
-
- `;
- }
-
- return html`
-
- ${t('nodes.ownership')}
- ${t('nodes.not_adopted')}
-
-
-
-
- `;
-}
-
-function renderNotFound(publicKey) {
- return html`
-
-
- ${iconError('stroke-current shrink-0 h-6 w-6')}
- ${t('common.entity_not_found_details', { entity: t('entities.node'), details: publicKey })}
-
-${t('common.view_entity', { entity: t('entities.nodes') })}`;
-}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/nodes.js b/src/meshcore_hub/web/static/js/spa/pages/nodes.js
deleted file mode 100644
index 8dcbdc0..0000000
--- a/src/meshcore_hub/web/static/js/spa/pages/nodes.js
+++ /dev/null
@@ -1,246 +0,0 @@
-import { apiGet, isAbortError } from '../api.js';
-import {
- html, litRender, nothing,
- getConfig, formatDateTime, formatDateTimeShort, formatNumber,
- warningBadge,
- pagination, sortableTableHeader, mobileSortSelect,
- renderFilterForm, renderFilterToggle, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, t
-} from '../components.js';
-import { createAutoRefresh } from '../auto-refresh.js';
-
-export async function render(container, params, router) {
- const { signal } = params || {};
- const query = params.query || {};
- const search = query.search || '';
- const adv_type = query.adv_type || '';
- const adopted_by = query.adopted_by || '';
- const pubkey_prefix = query.pubkey_prefix || '';
- const page = parseInt(query.page, 10) || 1;
- const limit = parseInt(query.limit, 10) || 20;
- const offset = (page - 1) * limit;
- const sort = query.sort || 'last_seen';
- const order = query.order || 'desc';
-
- const config = getConfig();
- const tz = config.timezone || '';
- const tzBadge = tz && tz !== 'UTC' ? html`${tz}` : nothing;
- const navigate = (url) => router.navigate(url);
-
- let lastContent = nothing;
- let lastTotal = null;
- let currentFilterFields = [];
- const hasActiveFilters = search !== '' || adv_type !== '' || pubkey_prefix !== '' || (config.oidc_enabled && adopted_by !== '');
-
- function onFilterToggle() { renderPage(lastContent, { total: lastTotal }); }
-
- function renderPage(content, { total = null, error = null } = {}) {
- if (!error) {
- lastContent = content;
- lastTotal = total;
- }
- const displayContent = error ? lastContent : content;
- const displayTotal = error ? lastTotal : total;
- const existingToggle = container.querySelector('#filter-toggle');
- const filterOpen = existingToggle ? existingToggle.checked : hasActiveFilters;
- litRender(html`
-
- ${t('entities.nodes')}
- ${tzBadge}
-
-
- ${displayTotal !== null
- ? html`${t('common.total', { count: formatNumber(displayTotal) })}`
- : nothing}
- ${error ? warningBadge(error) : nothing}
-
-
-
- ${renderFilterToggle({ open: filterOpen, onChange: onFilterToggle })}
-
-${(filterOpen && currentFilterFields.length > 0)
- ? html`${renderFilterForm({ fields: currentFilterFields, basePath: '/nodes', navigate })}`
- : nothing}
-${displayContent}`, container);
- }
-
- renderPage(nothing);
-
- async function fetchAndRenderData() {
- try {
- const apiParams = { limit, offset, search, adv_type, sort, order };
- if (adopted_by) apiParams.adopted_by = adopted_by;
- if (pubkey_prefix) apiParams.pubkey_prefix = pubkey_prefix;
- const fetches = [apiGet('/api/v1/nodes', apiParams, { signal })];
- if (config.oidc_enabled) {
- fetches.push(apiGet('/api/v1/user/profiles', { limit: 500 }, { signal }));
- }
- const results = await Promise.all(fetches);
- const data = results[0];
- const operatorRole = config.role_names?.operator || 'operator';
- const profiles = config.oidc_enabled
- ? (results[1]?.items || []).filter(p => p.roles && p.roles.includes(operatorRole))
- : [];
-
- const nodes = data.items || [];
- const total = data.total || 0;
- const totalPages = Math.ceil(total / limit);
-
- const mobileCards = nodes.length === 0
- ? html`${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}`
- : nodes.map(node => {
- const tagName = node.tags?.find(tag => tag.key === 'name')?.value;
- const tagDescription = node.tags?.find(tag => tag.key === 'description')?.value;
- const displayName = tagName || node.name;
- const lastSeen = node.last_seen ? formatDateTimeShort(node.last_seen) : '-';
- return html`
-
-
- ${renderNodeDisplay({
- name: displayName,
- description: tagDescription,
- publicKey: node.public_key,
- advType: node.adv_type,
- size: 'sm'
- })}
-
- ${lastSeen}
-
-
-
- `;
- });
-
- const tableRows = nodes.length === 0
- ? html`${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })} `
- : nodes.map(node => {
- const tagName = node.tags?.find(tag => tag.key === 'name')?.value;
- const tagDescription = node.tags?.find(tag => tag.key === 'description')?.value;
- const displayName = tagName || node.name;
- const lastSeen = node.last_seen ? formatDateTime(node.last_seen) : '-';
- return html`
-
-
- ${renderNodeDisplay({
- name: displayName,
- description: tagDescription,
- publicKey: node.public_key,
- advType: node.adv_type,
- size: 'base'
- })}
-
-
-
- copyToClipboard(e, node.public_key)}
- title="Click to copy">${node.public_key}
-
- ${lastSeen}
- `;
- });
-
- const paginationBlock = pagination(page, totalPages, '/nodes', {
- search, adv_type, adopted_by, pubkey_prefix, limit, sort, order,
- });
-
- const filterFields = [
- () => html`
-
-
-
- `,
- () => html`
-
-
-
- `,
- ];
- if (config.oidc_enabled && profiles.length > 0) {
- filterFields.push(() => html`
-
-
-
- `);
- }
-
- const headerParams = { search, adv_type, adopted_by, pubkey_prefix, limit };
- const sortable = (label, sortKey) => sortableTableHeader(label, {
- sortKey, currentSort: sort, currentOrder: order,
- navigate, basePath: '/nodes', params: headerParams,
- });
-
- currentFilterFields = filterFields;
-
- renderPage(html`
-
-${mobileSortSelect({
- currentSort: sort, currentOrder: order,
- navigate, basePath: '/nodes',
- params: headerParams,
- options: [
- { value: 'last_seen:desc', label: t('nodes.sort.last_seen_newest') },
- { value: 'last_seen:asc', label: t('nodes.sort.last_seen_oldest') },
- { value: 'name:asc', label: t('nodes.sort.name_az') },
- { value: 'name:desc', label: t('nodes.sort.name_za') },
- { value: 'public_key:asc', label: t('nodes.sort.key_asc') },
- { value: 'public_key:desc', label: t('nodes.sort.key_desc') },
- ],
-})}
-
-
- ${mobileCards}
-
-
-
-
-
-
- ${sortable(t('entities.node'), 'name')}
- ${sortable(t('common.public_key'), 'public_key')}
- ${sortable(t('common.last_seen'), 'last_seen')}
-
-
-
- ${tableRows}
-
-
-
-
-${paginationBlock}`, { total });
-
- } catch (e) {
- if (isAbortError(e)) return;
- renderPage(nothing, { error: e.message });
- }
- }
-
- await fetchAndRenderData();
-
- const toggleEl = container.querySelector('#auto-refresh-toggle');
- const { cleanup } = createAutoRefresh({
- fetchAndRender: fetchAndRenderData,
- toggleContainer: toggleEl,
- });
- return cleanup;
-}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/not-found.js b/src/meshcore_hub/web/static/js/spa/pages/not-found.js
deleted file mode 100644
index dcfe612..0000000
--- a/src/meshcore_hub/web/static/js/spa/pages/not-found.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import { html, litRender, t } from '../components.js';
-import { iconHome, iconNodes } from '../icons.js';
-
-export async function render(container, params, router) {
- litRender(html`
-
-
-
- 404
- ${t('common.page_not_found')}
-
- ${t('not_found.description')}
-
-
-
-
-`, container);
-}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/packet-detail.js b/src/meshcore_hub/web/static/js/spa/pages/packet-detail.js
deleted file mode 100644
index 09b9535..0000000
--- a/src/meshcore_hub/web/static/js/spa/pages/packet-detail.js
+++ /dev/null
@@ -1,120 +0,0 @@
-import { apiGet, isAbortError } from '../api.js';
-import {
- html, litRender, nothing, t,
- getConfig, formatDateTime, warningBadge, copyToClipboard
-} from '../components.js';
-import { jsonTree } from '../json-tree.js';
-
-function field(label, value) {
- return html`
-
- ${label}
- ${value}
- `;
-}
-
-export async function render(container, params, router) {
- const { signal } = params || {};
- const id = params.id;
- const config = getConfig();
- const tz = config.timezone || '';
- const tzBadge = tz && tz !== 'UTC' ? html`${tz}` : nothing;
-
- function shell(content, leaf) {
- litRender(html`
-
-
-
- ${t('packets.detail_title')}
- ${tzBadge}
-
-${content}`, container);
- }
-
- shell(html`${t('common.loading')}`);
-
- try {
- const [p, channelsData] = await Promise.all([
- apiGet(`/api/v1/packets/${id}`, {}, { signal }),
- apiGet('/api/v1/channels', { limit: 200 }, { signal }).catch(() => ({ items: [] })),
- ]);
-
- const channelNames = new Map(
- (channelsData.items || [])
- .map(c => [parseInt(c.channel_hash, 16), c.name])
- .filter(([idx]) => !Number.isNaN(idx))
- );
-
- let channelDisplay = html`—`;
- if (p.channel_idx != null) {
- const name = channelNames.get(p.channel_idx);
- channelDisplay = html`${name ? `${name} (${p.channel_idx})` : `${p.channel_idx}`}`;
- }
-
- const observerDisplay = p.observed_by
- ? html`${p.observer_tag_name || p.observer_name || p.observed_by}`
- : html`—`;
-
- const redactedNotice = p.redacted
- ? html`\u{1F512} ${t('packets.redacted_notice')}`
- : nothing;
-
- const rawBlock = p.redacted
- ? nothing
- : html`
-
-
- ${t('packets.col_raw')}
- ${p.raw_hex ? html`` : nothing}
-
- ${p.raw_hex || '—'}
- `;
-
- const decodedBlock = (!p.redacted && p.decoded)
- ? html`
-
- ${t('packets.decoded')}
- ${jsonTree(p.decoded, { openDepth: 1 })}
- `
- : nothing;
-
- shell(html`
-${redactedNotice}
-
-
-
- ${field(t('common.time'), formatDateTime(p.received_at))}
- ${field(t('common.observers'), observerDisplay)}
- ${field(t('packets.col_event_type'), p.event_type || '—')}
- ${field(t('entities.channel'), channelDisplay)}
- ${field(t('packets.col_source'), p.source_pubkey_prefix
- ? html`${p.source_pubkey_prefix}`
- : html`—`)}
- ${field(t('packets.packet_hash'), p.packet_hash
- ? html`${p.packet_hash}`
- : html`—`)}
- ${field(t('packets.packet_type'), p.packet_type != null ? p.packet_type : '—')}
- ${field(t('packets.payload_type'), p.payload_type != null ? p.payload_type : '—')}
- ${field(t('packets.col_route_type'), p.route_type || '—')}
- ${field(t('common.snr_db'), p.snr != null ? Number(p.snr).toFixed(1) : '—')}
- ${field(t('common.hops'), p.path_len != null ? p.path_len : '—')}
-
- ${rawBlock}
- ${decodedBlock}
-
-`, p.packet_hash || p.event_type);
- } catch (e) {
- if (isAbortError(e)) return;
- if (e.status === 404) {
- shell(html`${t('common.entity_not_found_details', { entity: t('entities.packet').toLowerCase() })}`);
- return;
- }
- shell(warningBadge(e.message));
- }
-}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/packet-group-detail.js b/src/meshcore_hub/web/static/js/spa/pages/packet-group-detail.js
deleted file mode 100644
index 2bb76f2..0000000
--- a/src/meshcore_hub/web/static/js/spa/pages/packet-group-detail.js
+++ /dev/null
@@ -1,389 +0,0 @@
-import { apiGet, isAbortError } from '../api.js';
-import { iconSatelliteDish } from '../icons.js';
-import {
- html, litRender, nothing, t,
- getConfig, formatDateTime, formatRelativeTime, formatNumber, warningBadge, copyToClipboard,
- loading, truncateKey
-} from '../components.js';
-import { jsonTree } from '../json-tree.js';
-
-function field(label, value) {
- return html`
-
- ${label}
- ${value}
- `;
-}
-
-// Centre-truncation thresholds for long paths. Counts hops, not characters,
-// so variable-length hashes (1–3 bytes) truncate predictably.
-const PATH_MAX_BADGES = 16;
-const PATH_HEAD = 7;
-const PATH_TAIL = 7;
-
-// Max nodes listed in the path-hash lookup popover before linking out to the
-// full (prefix-filtered) Nodes page.
-const PATH_POPOVER_NODE_CAP = 8;
-
-// Render a single path-hash as a badge. Clicking it opens a popover listing the
-// node(s) whose public key starts with this hash (see openPathPopover in render).
-function pathBadge(hash, onClick) {
- return html` onClick(e, hash)}>${hash}`;
-}
-
-const pathArrow = html`→`;
-
-// Join badges with arrow separators into a flex-wrap container so long paths
-// wrap onto multiple lines (growing in height, not width) on narrow screens.
-function pathRow(badges) {
- const parts = [];
- badges.forEach((b, i) => {
- if (i > 0) parts.push(pathArrow);
- parts.push(b);
- });
- return html`${parts}`;
-}
-
-// Static start-of-path marker: a filled green dot signifying the origin node.
-function senderMarker(prefix) {
- const title = prefix
- ? `${t('packets.col_source')}: ${prefix}`
- : t('packets.col_source');
- return html``;
-}
-
-// Static end-of-path marker: a satellite-dish icon signifying the observer.
-function observerEndMarker() {
- return html`${iconSatelliteDish('h-4 w-4 opacity-70')}`;
-}
-
-// Render the full path as a flow: sender -> [hops] -> observer. The endpoints are
-// static markers; the intermediate hops keep their existing hash badges (with the
-// same truncation + popover-click behaviour), joined together by pathRow.
-function formatPathFlow(pathHashes, pathLen, sourcePrefix, onBadgeClick) {
- const badge = (h) => pathBadge(h, onBadgeClick);
- let middleParts;
- if (pathHashes && pathHashes.length > 0) {
- if (pathHashes.length <= PATH_MAX_BADGES) {
- middleParts = pathHashes.map(badge);
- } else {
- const hidden = pathHashes.length - PATH_HEAD - PATH_TAIL;
- const ellipsis = html`…`;
- middleParts = [
- ...pathHashes.slice(0, PATH_HEAD).map(badge),
- ellipsis,
- ...pathHashes.slice(-PATH_TAIL).map(badge),
- ];
- }
- } else if (pathLen != null) {
- middleParts = [html`${pathLen} ${t('common.hops').toLowerCase()}`];
- } else {
- middleParts = [html`—`];
- }
-
- return pathRow([senderMarker(sourcePrefix), ...middleParts, observerEndMarker()]);
-}
-
-function groupByObserver(receptions) {
- const groups = new Map();
- for (const r of receptions) {
- const key = r.observed_by || '__unknown__';
- if (!groups.has(key)) groups.set(key, []);
- groups.get(key).push(r);
- }
- return groups;
-}
-
-export async function render(container, params, router) {
- const { signal } = params || {};
- const hash = params.hash;
- const config = getConfig();
- const tz = config.timezone || '';
- const tzBadge = tz && tz !== 'UTC' ? html`${tz}` : nothing;
-
- // ── Path-hash → node lookup popover ───────────────────────────────────────
- // Clicking a path badge opens a single floating panel (appended to )
- // that lists the node(s) whose public key starts with that hex prefix. A
- // prefix may match zero or more nodes.
- let popoverEl = null;
- let popoverListeners = null;
-
- function closePopover() {
- if (popoverListeners) {
- document.removeEventListener('click', popoverListeners.onDocClick);
- document.removeEventListener('keydown', popoverListeners.onKey);
- popoverListeners = null;
- }
- if (popoverEl) {
- popoverEl.remove();
- popoverEl = null;
- }
- }
-
- function nodeDisplayName(n) {
- const tagName = n.tags?.find(tag => tag.key === 'name')?.value;
- return tagName || n.name || truncateKey(n.public_key, 12);
- }
-
- function positionPopover(badgeEl) {
- if (!popoverEl) return;
- const rect = badgeEl.getBoundingClientRect();
- const margin = 8;
- const pw = popoverEl.offsetWidth || 256;
- const ph = popoverEl.offsetHeight || 0;
- let left = Math.min(rect.left, window.innerWidth - pw - margin);
- if (left < margin) left = margin;
- let top = rect.bottom + 4;
- if (top + ph + margin > window.innerHeight && rect.top - ph - 4 > margin) {
- top = rect.top - ph - 4;
- }
- // Anchor to the document (position: absolute) so the popover scrolls with the page.
- popoverEl.style.left = `${left + window.scrollX}px`;
- popoverEl.style.top = `${top + window.scrollY}px`;
- }
-
- function popoverShell(hashLabel, body) {
- return html`
-
- ${t('packets.path_nodes_title', { hash: hashLabel })}
-
-
- ${body}`;
- }
-
- async function openPathPopover(e, ph) {
- e.preventDefault();
- e.stopPropagation();
- const badgeEl = e.currentTarget;
- closePopover();
-
- popoverEl = document.createElement('div');
- popoverEl.className = 'path-node-popover absolute z-[1000] w-64 max-w-[90vw] max-h-[60vh] overflow-y-auto bg-base-100 rounded-box shadow-lg border border-base-300';
- document.body.appendChild(popoverEl);
-
- litRender(popoverShell(ph, html`${loading()}`), popoverEl);
- positionPopover(badgeEl);
-
- const onDocClick = (ev) => { if (popoverEl && !popoverEl.contains(ev.target)) closePopover(); };
- const onKey = (ev) => { if (ev.key === 'Escape') closePopover(); };
- popoverListeners = { onDocClick, onKey };
- // Defer so the click that opened the popover doesn't immediately close it.
- setTimeout(() => {
- document.addEventListener('click', onDocClick);
- document.addEventListener('keydown', onKey);
- }, 0);
-
- try {
- const data = await apiGet('/api/v1/nodes',
- { pubkey_prefix: ph, sort: 'name', order: 'asc', limit: PATH_POPOVER_NODE_CAP },
- { signal });
- if (!popoverEl) return; // closed while loading
- const items = (data.items || []).slice()
- .sort((a, b) => nodeDisplayName(a).localeCompare(nodeDisplayName(b)));
- const more = (data.total || 0) - items.length;
- const body = items.length === 0
- ? html`${t('packets.path_no_nodes')}`
- : html``;
- litRender(popoverShell(ph, body), popoverEl);
- positionPopover(badgeEl);
- } catch (err) {
- if (isAbortError(err) || !popoverEl) return;
- litRender(popoverShell(ph, html`${warningBadge(err.message)}`), popoverEl);
- positionPopover(badgeEl);
- }
- }
-
- // ── Per-observer reception rendering ──────────────────────────────────────
- const hopsValue = (r) => (r.path_len != null ? r.path_len : '—');
- const snrValue = (r) => (r.snr != null ? Number(r.snr).toFixed(1) : '—');
- const timeValue = (r) => html`${formatRelativeTime(r.received_at)}`;
-
- function stat(label, value) {
- return html`
- ${label}
- ${value}
- `;
- }
-
- // Mobile (< lg): one card per reception, path full-width on top, stats below.
- function receptionCards(recs, sourcePrefix) {
- return html`
- ${recs.map(r => html`
-
- ${formatPathFlow(r.path_hashes, r.path_len, sourcePrefix, openPathPopover)}
-
- ${stat(t('common.time'), timeValue(r))}
- ${stat(t('common.hops'), hopsValue(r))}
- ${stat(t('common.snr_db'), snrValue(r))}
-
- `)}
- `;
- }
-
- // Desktop (lg+): table-fixed so the right-aligned stat columns line up across
- // every observer block regardless of path length.
- function receptionTable(recs, sourcePrefix) {
- return html`
-
-
-
- ${t('packets.col_path')}
- ${t('common.hops')}
- ${t('common.snr_db')}
- ${t('common.time')}
-
-
-
- ${recs.map(r => html`
-
- ${formatPathFlow(r.path_hashes, r.path_len, sourcePrefix, openPathPopover)}
- ${hopsValue(r)}
- ${snrValue(r)}
- ${timeValue(r)}
- `)}
-
-
- `;
- }
-
- function shell(content, leaf) {
- litRender(html`
-
-
- ${t('packets.detail_title')}
- ${tzBadge}
-
-${content}`, container);
- }
-
- shell(html`${t('common.loading')}`);
-
- try {
- const [g, channelsData] = await Promise.all([
- apiGet(`/api/v1/packet-groups/${hash}`, {}, { signal }),
- apiGet('/api/v1/channels', { limit: 200 }, { signal }).catch(() => ({ items: [] })),
- ]);
-
- const channelNames = new Map(
- (channelsData.items || [])
- .map(c => [parseInt(c.channel_hash, 16), c.name])
- .filter(([idx]) => !Number.isNaN(idx))
- );
-
- let channelDisplay = html`—`;
- if (g.channel_idx != null) {
- const name = channelNames.get(g.channel_idx);
- channelDisplay = html`${name ? `${name} (${g.channel_idx})` : `${g.channel_idx}`}`;
- }
-
- const redactedNotice = g.redacted
- ? html`\u{1F512} ${t('packets.redacted_notice')}`
- : nothing;
-
- const rawBlock = (!g.redacted && g.raw_hex)
- ? html`
-
-
- ${t('packets.col_raw')}
-
-
- ${g.raw_hex}
- `
- : nothing;
-
- const decodedBlock = (!g.redacted && g.decoded)
- ? html`
-
- ${t('packets.decoded')}
- ${jsonTree(g.decoded, { openDepth: 1 })}
- `
- : nothing;
-
- const receptions = g.receptions || [];
- const sourcePrefix = g.source_pubkey_prefix || null;
- const observerGroups = groupByObserver(receptions);
-
- const receptionsSection = receptions.length > 0
- ? html`
-
-
- ${t('packets.receptions_title')}
- (${formatNumber(g.reception_count)} ${g.reception_count === 1 ? t('packets.reception_singular') : t('packets.reception_plural')}, ${formatNumber(g.observer_count)} ${t('common.observers').toLowerCase()})
-
- ${[...observerGroups.entries()].map(([_key, recs]) => {
- const first = recs[0];
- const displayName = first.observer_tag_name || first.observer_name
- || (first.observed_by ? first.observed_by.slice(0, 12) + '…' : '—');
- return html`
-
-
- \u{1F4E1}
- ${first.observed_by
- ? html`${displayName}`
- : html`${displayName}`}
- ${recs.length > 1
- ? html`(${formatNumber(recs.length)} ${t('packets.reception_plural')})`
- : nothing}
-
- ${receptionCards(recs, sourcePrefix)}
- ${receptionTable(recs, sourcePrefix)}
- `;
- })}
- `
- : nothing;
-
- shell(html`
-${redactedNotice}
-
-
-
- ${field(t('common.time'), formatDateTime(g.first_seen))}
- ${field(t('packets.col_event_type'), g.event_type || '—')}
- ${field(t('entities.channel'), channelDisplay)}
- ${field(t('packets.col_source'), g.source_pubkey_prefix
- ? html`${g.source_pubkey_prefix}`
- : html`—`)}
- ${field(t('packets.packet_hash'), g.packet_hash
- ? html`${g.packet_hash}`
- : html`—`)}
- ${field(t('packets.packet_type'), g.packet_type != null ? g.packet_type : '—')}
- ${field(t('packets.payload_type'), g.payload_type != null ? g.payload_type : '—')}
- ${field(t('packets.col_route_type'), g.route_type || '—')}
- ${field(t('packets.receptions_count'),
- html`${formatNumber(g.reception_count)} ${g.reception_count === 1 ? t('packets.reception_singular') : t('packets.reception_plural')} · ${formatNumber(g.observer_count)} ${t('common.observers').toLowerCase()}`)}
-
- ${receptionsSection}
- ${rawBlock}
- ${decodedBlock}
-
-`, g.packet_hash || g.event_type);
-
- } catch (e) {
- if (isAbortError(e)) return closePopover;
- if (e.status === 404) {
- shell(html`${t('packets.not_found_retention')}`, t('entities.packet'));
- return closePopover;
- }
- shell(warningBadge(e.message));
- }
-
- // Tear down any open popover (and its global listeners) on navigation.
- return closePopover;
-}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/packets.js b/src/meshcore_hub/web/static/js/spa/pages/packets.js
deleted file mode 100644
index 25ec70b..0000000
--- a/src/meshcore_hub/web/static/js/spa/pages/packets.js
+++ /dev/null
@@ -1,263 +0,0 @@
-import { apiGet, isAbortError } from '../api.js';
-import {
- html, litRender, nothing, t,
- getConfig, formatDateTime, formatDateTimeShort, formatNumber,
- warningBadge,
- pagination, sortableTableHeader, mobileSortSelect,
- renderFilterForm, renderFilterToggle, autoSubmit, submitOnEnter
-} from '../components.js';
-import { createAutoRefresh } from '../auto-refresh.js';
-import { iconSatelliteDish, iconPath, iconRuler } from '../icons.js';
-
-const EVENT_TYPES = [
- 'advertisement', 'channel_msg_recv', 'contact_msg_recv',
- 'trace_data', 'telemetry_response', 'path_updated', 'status_response',
- 'req', 'response', 'ack', 'encrypted_direct', 'encrypted_channel',
- 'grp_data', 'anon_req', 'multipart', 'control', 'raw_custom',
- 'advert', 'path', 'trace', 'letsmesh_packet',
-];
-
-function lockBadge() {
- return html`\u{1F512}`;
-}
-
-function channelLabel(packet, channelNames) {
- if (packet.channel_idx == null) {
- return html`—`;
- }
- const name = channelNames.get(packet.channel_idx);
- const text = name ? `${name} (${packet.channel_idx})` : `${packet.channel_idx}`;
- return html`${text}${packet.redacted ? html` ${lockBadge()}` : nothing}`;
-}
-
-function receptionBadge(packet) {
- const rc = packet.reception_count ?? 1;
- const oc = packet.observer_count ?? 1;
- const pb = packet.path_hash_bytes;
- const knownWidth = pb != null && pb > 0;
- const widthLabel = knownWidth
- ? t('packets.path_width_bytes', { count: pb })
- : t('packets.path_width_unknown');
- return html`
- ${iconSatelliteDish('h-4 w-4 opacity-70')}
- ${formatNumber(oc)}
-
- ${iconPath('h-4 w-4 opacity-70')}
- ${formatNumber(rc)}
-
- ${iconRuler('h-4 w-4 opacity-70')}
- ${widthLabel}
- `;
-}
-
-export async function render(container, params, router) {
- const { signal } = params || {};
- const query = params.query || {};
- const search = query.search || '';
- const event_type = query.event_type || '';
- const channel_idx = query.channel_idx || '';
- const path_hash_bytes = query.path_hash_bytes || '';
- const page = parseInt(query.page, 10) || 1;
- const limit = parseInt(query.limit, 10) || 20;
- const offset = (page - 1) * limit;
- const sort = query.sort || 'time';
- const order = query.order || 'desc';
-
- const config = getConfig();
- const tz = config.timezone || '';
- const tzBadge = tz && tz !== 'UTC' ? html`${tz}` : nothing;
- const navigate = (url) => router.navigate(url);
-
- let lastContent = nothing;
- let lastTotal = null;
- let currentFilterFields = [];
- const hasActiveFilters = search !== '' || event_type !== '' || channel_idx !== '' || path_hash_bytes !== '';
-
- function onFilterToggle() { renderPage(lastContent, { total: lastTotal }); }
-
- function renderPage(content, { total = null, error = null } = {}) {
- if (!error) {
- lastContent = content;
- lastTotal = total;
- }
- const displayContent = error ? lastContent : content;
- const displayTotal = error ? lastTotal : total;
- const existingToggle = container.querySelector('#filter-toggle');
- const filterOpen = existingToggle ? existingToggle.checked : hasActiveFilters;
- litRender(html`
-
- ${t('entities.packets')}
- ${tzBadge}
-
-
- ${displayTotal !== null
- ? html`${t('common.total', { count: formatNumber(displayTotal) })}`
- : nothing}
- ${error ? warningBadge(error) : nothing}
-
-
-
- ${renderFilterToggle({ open: filterOpen, onChange: onFilterToggle })}
-
-${(filterOpen && currentFilterFields.length > 0)
- ? html`${renderFilterForm({ fields: currentFilterFields, basePath: '/packets', navigate })}`
- : nothing}
-${displayContent}`, container);
- }
-
- renderPage(nothing);
-
- async function fetchAndRenderData() {
- try {
- const apiParams = { limit, offset, search, sort, order };
- if (event_type) apiParams.event_type = event_type;
- if (channel_idx !== '') apiParams.channel_idx = channel_idx;
- if (path_hash_bytes !== '') apiParams.path_hash_bytes = path_hash_bytes;
-
- const [data, channelsData] = await Promise.all([
- apiGet('/api/v1/packet-groups', apiParams, { signal }),
- apiGet('/api/v1/channels', { limit: 200 }, { signal }).catch(() => ({ items: [] })),
- ]);
-
- const packets = data.items || [];
- const total = data.total || 0;
- const totalPages = Math.ceil(total / limit);
-
- const channelList = (channelsData.items || []).map(c => ({
- name: c.name,
- idx: parseInt(c.channel_hash, 16),
- })).filter(c => !Number.isNaN(c.idx));
- const channelNames = new Map(channelList.map(c => [c.idx, c.name]));
-
- const noneFound = html`${t('common.no_entity_found', { entity: t('entities.packets').toLowerCase() })}`;
-
- function packetUrl(p) {
- if (p.packet_hash) return `/packets/hash/${p.packet_hash}`;
- if (p.receptions && p.receptions.length > 0) return `/packets/${p.receptions[0].packet_id}`;
- return '/packets';
- }
-
- const mobileCards = packets.length === 0
- ? noneFound
- : packets.map(p => html`
-
-
-
-
- ${p.event_type || '—'}
- ${channelLabel(p, channelNames)}
-
-
- ${formatDateTimeShort(p.first_seen)}
- ${receptionBadge(p)}
-
-
-
- `);
-
- const tableRows = packets.length === 0
- ? html`${t('common.no_entity_found', { entity: t('entities.packets').toLowerCase() })} `
- : packets.map(p => html` navigate(packetUrl(p))}>
- ${formatDateTime(p.first_seen)}
- ${p.packet_hash ? html`${p.packet_hash}` : html`—`}
- ${receptionBadge(p)}
- ${p.event_type || '—'}
- ${channelLabel(p, channelNames)}
- `);
-
- const paginationBlock = pagination(page, totalPages, '/packets', {
- search, event_type, channel_idx, path_hash_bytes, limit, sort, order,
- });
-
- const filterFields = [
- () => html`
-
-
-
- `,
- () => html`
-
-
-
- `,
- () => html`
-
-
-
- `,
- () => html`
-
-
-
- `,
- ];
-
- const headerParams = { search, event_type, channel_idx, path_hash_bytes, limit };
- const sortable = (label, sortKey) => sortableTableHeader(label, {
- sortKey, currentSort: sort, currentOrder: order,
- navigate, basePath: '/packets', params: headerParams,
- });
-
- currentFilterFields = filterFields;
-
- renderPage(html`
-
-${mobileSortSelect({
- currentSort: sort, currentOrder: order,
- navigate, basePath: '/packets',
- params: headerParams,
- options: [
- { value: 'time:desc', label: t('packets.sort.newest') },
- { value: 'time:asc', label: t('packets.sort.oldest') },
- { value: 'event_type:asc', label: t('packets.sort.event_az') },
- { value: 'reception_count:desc', label: t('packets.sort.receptions_high') },
- ],
-})}
-
-
- ${mobileCards}
-
-
-
-
-
-
- ${sortable(t('common.time'), 'time')}
- ${t('packets.packet_hash')}
- ${t('packets.col_receptions')}
- ${sortable(t('packets.col_event_type'), 'event_type')}
- ${t('entities.channel')}
-
-
-
- ${tableRows}
-
-
-
-
-${paginationBlock}`, { total });
-
- } catch (e) {
- if (isAbortError(e)) return;
- renderPage(nothing, { error: e.message });
- }
- }
-
- await fetchAndRenderData();
-
- const toggleEl = container.querySelector('#auto-refresh-toggle');
- const { cleanup } = createAutoRefresh({
- fetchAndRender: fetchAndRenderData,
- toggleContainer: toggleEl,
- });
- return cleanup;
-}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/profile.js b/src/meshcore_hub/web/static/js/spa/pages/profile.js
deleted file mode 100644
index 12c4f93..0000000
--- a/src/meshcore_hub/web/static/js/spa/pages/profile.js
+++ /dev/null
@@ -1,194 +0,0 @@
-import { apiGet, apiPut, isAbortError } from '../api.js';
-import {
- html, litRender, nothing,
- getConfig, t, errorAlert, successAlert,
- formatRelativeTime, formatDateTime,
-} from '../components.js';
-
-function renderAdoptedNode(node) {
- const displayName = node.name || node.public_key.slice(0, 12) + '...';
- const relTime = node.last_seen ? formatRelativeTime(node.last_seen) : '-';
- const fullTime = node.last_seen ? formatDateTime(node.last_seen) : '-';
-
- return html`
-
- ${displayName}
- ${node.public_key}
-
-
- `;
-}
-
-function renderMemberSince(profile) {
- return profile.created_at
- ? html`${t('user_profile.member_since', { date: formatDateTime(profile.created_at, { year: 'numeric', month: 'long', day: 'numeric' }) })}
`
- : nothing;
-}
-
-function renderRoleBadges(roles) {
- if (!roles || roles.length === 0) return nothing;
- return html`${roles.map(role => html`${role}`)}`;
-}
-
-function hasOperatorOrAdmin(roles, config) {
- const roleNames = config.role_names || {};
- const operatorRole = roleNames.operator || 'operator';
- const adminRole = roleNames.admin || 'admin';
- return roles && (roles.includes(operatorRole) || roles.includes(adminRole));
-}
-
-function renderProfileDetails(profile, config) {
- const adoptedSection = hasOperatorOrAdmin(profile.roles, config)
- ? html`
-
- ${t('user_profile.adopted_nodes')}
- ${profile.nodes && profile.nodes.length > 0
- ? html`${profile.nodes.map(n => renderAdoptedNode(n))}`
- : html`${t('user_profile.no_adopted_nodes')}
`}
-
- `
- : nothing;
-
- return html`${renderMemberSince(profile)}${adoptedSection}`;
-}
-
-function renderPublicProfile(profile, config, target) {
- const isOwner = config.user && profile.user_id && config.user.sub === profile.user_id;
-
- litRender(html`
-
- ${t('user_profile.title')}
- ${isOwner ? html`${t('user_profile.edit_profile')}` : nothing}
-
-
-
-
-
- ${profile.name || t('common.unnamed')}
- ${profile.callsign ? html`${profile.callsign}` : nothing}
-
- ${renderRoleBadges(profile.roles)}
- ${profile.description ? html`${profile.description}
` : nothing}
- ${profile.url ? html`${profile.url}` : nothing}
- ${renderProfileDetails(profile, config)}
-
-`, target);
-}
-
-export async function render(container, params, router) {
- const { signal } = params || {};
- const config = getConfig();
-
- if (params.id) {
- try {
- const profile = await apiGet(`/api/v1/user/profile/${params.id}`, {}, { signal });
- renderPublicProfile(profile, config, container);
- } catch (e) {
- if (isAbortError(e)) return;
- litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
- }
- return;
- }
-
- if (!config.oidc_enabled || !config.user) {
- litRender(html`
-`, container);
- return;
- }
-
- try {
- const profile = await apiGet('/api/v1/user/profile/me', {}, { signal });
- const profilePath = `/api/v1/user/profile/${profile.id}`;
-
- const flashMessage = (params.query && params.query.message) || '';
- const flashError = (params.query && params.query.error) || '';
- const flashHtml = flashMessage ? successAlert(flashMessage) : flashError ? errorAlert(flashError) : nothing;
-
- litRender(html`
-
- ${t('user_profile.title')}
-
-
-${flashHtml}
-
-
-
-
-
-
- ${t('user_profile.your_profile')}
- ${renderRoleBadges(profile.roles)}
-
- ${renderMemberSince(profile)}
-
-
-
-
- ${hasOperatorOrAdmin(profile.roles, config) ? html`
-
-
- ${t('user_profile.adopted_nodes')}
- ${profile.nodes && profile.nodes.length > 0
- ? html`${profile.nodes.map(n => renderAdoptedNode(n))}`
- : html`${t('user_profile.no_adopted_nodes')}
`}
-
- ` : nothing}
-
-`, container);
-
- const ac = new AbortController();
-
- container.querySelector('#profile-form').addEventListener('submit', async (e) => {
- e.preventDefault();
- const form = e.target;
- const body = {
- name: form.name.value.trim() || null,
- callsign: form.callsign.value.trim() || null,
- description: form.description.value.trim() || null,
- url: form.url.value.trim() || null,
- };
- try {
- await apiPut(profilePath, body);
- router.navigate('/profile?message=' + encodeURIComponent(t('user_profile.profile_updated')), true);
- } catch (err) {
- router.navigate('/profile?error=' + encodeURIComponent(err.message), true);
- }
- }, { signal: ac.signal });
-
- return () => ac.abort();
-
- } catch (e) {
- if (isAbortError(e)) return;
- litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
- }
-}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/routes.js b/src/meshcore_hub/web/static/js/spa/pages/routes.js
deleted file mode 100644
index 90e7765..0000000
--- a/src/meshcore_hub/web/static/js/spa/pages/routes.js
+++ /dev/null
@@ -1,876 +0,0 @@
-import { apiGet, apiPost, apiPut, apiDelete, isAbortError } from '../api.js';
-import { html, litRender, nothing, t, errorAlert, getConfig, hasRole } from '../components.js';
-import { iconPath, iconPlus, iconEdit, iconTrash, iconPackets, iconClock, iconRuler, iconNodes, iconSatelliteDish, iconRouteFrom, iconRouteTo, iconHopSpan, iconPathLength } from '../icons.js';
-
-const VISIBILITY_ORDER = ['community', 'member', 'operator', 'admin'];
-
-let _pathSearchTimer = null;
-let _pathSearchId = 0;
-let _obsSearchTimer = null;
-let _obsSearchId = 0;
-
-function qualityBadgeClass(quality, enabled) {
- if (!enabled) return 'badge-neutral';
- const map = {
- clear: 'badge-success',
- marginal: 'badge-warning',
- failing: 'badge-error',
- no_coverage: 'badge-info',
- unknown: 'badge-info',
- };
- return map[quality] || 'badge-ghost';
-}
-
-function qualityLabel(quality, enabled) {
- if (!enabled) return t('routes.disabled');
- const map = {
- clear: t('routes.quality_clear'),
- marginal: t('routes.quality_marginal'),
- failing: t('routes.quality_failing'),
- no_coverage: t('routes.quality_no_coverage'),
- unknown: t('routes.quality_unknown'),
- };
- return map[quality] || quality || t('routes.quality_unknown');
-}
-
-function qualityDot(quality, enabled) {
- if (!enabled) return '\u25CC';
- const dots = { clear: '\u25CF', marginal: '\u25CF', failing: '\u25CF', no_coverage: '\u25D0', unknown: '\u25D0' };
- return dots[quality] || '\u25D0';
-}
-
-function diagnosisText(route) {
- const result = route.route_result;
- if (!result || !route.enabled) return '';
- if (result.state === 'healthy') return t('routes.diagnosis_healthy');
- if (result.state === 'unhealthy') return t('routes.diagnosis_unhealthy');
- if (result.state === 'no_coverage') return t('routes.diagnosis_no_coverage');
- return '';
-}
-
-function renderSummaryStrip(routes) {
- const counts = { clear: 0, marginal: 0, failing: 0, no_coverage: 0, disabled: 0 };
- for (const r of routes) {
- if (!r.enabled) { counts.disabled++; continue; }
- // Prefer the 7-day rolling average so the strip matches the card
- // badges (which also display ``quality_avg``). Falls back to the
- // latest snapshot for brand-new routes that have no history yet.
- const q = r.quality_avg || r.route_result?.quality || 'unknown';
- if (q === 'clear') counts.clear++;
- else if (q === 'marginal') counts.marginal++;
- else if (q === 'failing') counts.failing++;
- else counts.no_coverage++;
- }
- return html`
- \u25CF ${counts.clear} ${t('routes.quality_clear')}
- \u25CF ${counts.marginal} ${t('routes.quality_marginal')}
- \u25CF ${counts.failing} ${t('routes.quality_failing')}
- \u25D0 ${counts.no_coverage} ${t('routes.quality_no_coverage')}
- \u25CC ${counts.disabled} ${t('routes.disabled')}
- `;
-}
-
-function renderPathChips(route) {
- const nodes = route.route_nodes || [];
- const arrow = route.reversible !== false ? '\u2194' : '\u2192';
- const prefixLen = 2 * (route.match_width || 1);
- return html`
- ${nodes.map((rn, i) => html`
- ${i > 0 ? html`${arrow}` : nothing}
- ${rn.name ? html`${rn.name} (${rn.public_key?.slice(0, prefixLen)})` : (rn.public_key?.slice(0, prefixLen) || rn.node_id.slice(0, 8))}
- `)}
- `;
-}
-
-function renderStatsRow(route) {
- const result = route.route_result;
- const matched = result?.matched_count ?? '?';
- const threshold = result?.threshold ?? '?';
- const degraded = result?.effective_clear ?? '?';
- const nodeCount = (route.route_nodes || []).length;
- const obsCount = (route.route_observers || []).length;
-
- return html`
-
- ${iconPackets('h-3.5 w-3.5')}
- ${matched}/${threshold}\u2192${degraded}
-
-
- ${iconClock('h-3.5 w-3.5')}
- ${route.window_hours}h
-
-
- ${iconRuler('h-3.5 w-3.5')}
- ${route.match_width}B
-
-
- ${iconNodes('h-3.5 w-3.5')}
- ${nodeCount}
-
-
- ${iconHopSpan('h-3.5 w-3.5')}
- ${route.max_hop_span || '\u221E'}
-
-
- ${iconPathLength('h-3.5 w-3.5')}
- ${route.max_path_length || '\u221E'}
-
-
- ${iconSatelliteDish('h-3.5 w-3.5')}
- ${obsCount || '\u221E'}
-
- `;
-}
-
-function renderRouteCard(route, { isAdmin, onDelete, onEdit, detail, navigate, packetsEnabled, history }) {
- // Badge reflects the 7-day rolling average (``quality_avg``) rather
- // than the latest snapshot, so a flapping route that's currently up
- // still shows as marginal/failing if it's been mostly down. Falls
- // back to the snapshot for brand-new routes with no history.
- const q = route.quality_avg || route.route_result?.quality || 'unknown';
- const badgeCls = qualityBadgeClass(q, route.enabled);
- const label = qualityLabel(q, route.enabled);
- const dot = qualityDot(q, route.enabled);
- const tip = diagnosisText(route);
- const badge = tip
- ? html`${dot} ${label}`
- : html`${dot} ${label}`;
-
- const adminButtons = isAdmin
- ? html`
-
-
- `
- : nothing;
-
- const expandContent = detail
- ? renderDetailContent(route, detail, { navigate, packetsEnabled, history })
- : html`
-
- `;
-
- return html`
-
-
-
-
-
- ${iconRouteFrom('h-5 w-5')}
- ${route.from_label}
- ${iconRouteTo('h-5 w-5')}
- ${route.to_label}
-
-
- ${route.description ? html`${route.description}
` : nothing}
-
-
- ${badge}
-
-
- ${renderPathChips(route)}
- ${renderStatsRow(route)}
- ${expandContent}
- ${adminButtons}
-
- `;
-}
-
-function renderDetailContent(route, detail, { navigate, packetsEnabled, history }) {
- const matches = detail.recent_matches || [];
- const packetDetailUrl = (packetHash) =>
- (packetsEnabled && packetHash) ? `/packets/hash/${packetHash}` : null;
-
- const historySection = history
- ? html`
-
-
-
- ${history.data && history.data.length > 0 ? html`
- ${history.data.map((d, i) => html`${i === history.data.length - 1 ? t('routes.last_n_hours', { n: route.window_hours }) : new Date(d.date + 'T00:00:00').toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })}`)}
- ` : nothing}
- `
- : nothing;
-
- return html`
- ${historySection}
- ${matches.length > 0 ? html`
- ${t('routes.recent_packets')}
-
- ${matches.map(m => {
- const prefixLen = 2 * (route.match_width || 1);
- const pathLookup = new Map(
- (route.route_nodes || []).map(rn =>
- [rn.expected_hash?.toLowerCase(), rn])
- );
- const detailUrl = packetDetailUrl(m.packet_hash);
- return html` { e.stopPropagation(); navigate(detailUrl); } : undefined}>
- ${(() => {
- const hops = m.hops || [];
- const PATH_MAX = 5;
- const PATH_HEAD = 2;
- const PATH_TAIL = 2;
- let entries;
- if (hops.length > PATH_MAX) {
- const hidden = hops.length - PATH_HEAD - PATH_TAIL;
- const head = hops.slice(0, PATH_HEAD);
- const tail = hops.slice(-PATH_TAIL);
- const ellipsis = html`\u2026`;
- entries = [
- ...head.map(h => [h, true]),
- [ellipsis, false],
- ...tail.map(h => [h, true]),
- ];
- } else {
- entries = hops.map(h => [h, true]);
- }
- return entries.map(([h, isHop], i) => {
- if (!isHop) {
- return html`
- ${i > 0 ? html`\u2192` : nothing}
- ${h}
- `;
- }
- const rn = pathLookup.get((h.node_hash || '').toLowerCase().slice(0, prefixLen));
- return html`
- ${i > 0 ? html`\u2192` : nothing}
- ${rn
- ? html`${(h.node_hash || '').toLowerCase()}`
- : html`${(h.node_hash || '').toLowerCase()}`}
- `;
- });
- })()}
- ${m.received_at ? html`${new Date(m.received_at).toLocaleString()}` : nothing}
- `;
- })}
-
- ` : nothing}
- `;
-}
-
-function renderNodeSearchResult(node, onSelect) {
- const name = node.name || `${node.public_key.slice(0, 12)}\u2026`;
- return html`
-
-
- `;
-}
-
-function renderRouteModal({ modalState, onSave, onCancel, saving }) {
- const route = modalState.route;
- const isEdit = modalState.isEdit;
- const title = isEdit ? t('routes.edit_route') : t('routes.add_route');
-
- const pathNodes = modalState.pathNodes;
- const observerNodes = modalState.observerNodes;
- const pathResults = modalState.pathResults;
- const obsResults = modalState.obsResults;
-
- const selectedPathKeys = new Set(pathNodes.map(n => n.public_key));
- const selectedObsKeys = new Set(observerNodes.map(n => n.public_key));
- const availPathResults = pathResults.filter(n => !selectedPathKeys.has(n.public_key));
- const availObsResults = obsResults.filter(n => !selectedObsKeys.has(n.public_key));
-
- return html``;
-}
-
-function renderDeleteModal({ route, onConfirm, onCancel, saving }) {
- const arrow = route.reversible !== false ? '\u2194' : '\u2192';
- const label = `${route.from_label} ${arrow} ${route.to_label}`;
- return html``;
-}
-
-export async function render(container, params, router) {
- const { signal } = params || {};
- try {
- const config = getConfig();
- const isAdmin = hasRole('admin');
- const navigate = (url) => router.navigate(url);
- const packetsEnabled = config.features?.packets !== false;
-
- const data = await apiGet('/api/v1/routes', {}, { signal });
- const routes = data.items || [];
-
- let modalState = null;
- const detailCache = new Map();
- const historyCache = new Map();
- const chartRegistry = [];
-
- function destroyCharts() {
- chartRegistry.forEach(c => { try { c.destroy(); } catch (_) {} });
- chartRegistry.length = 0;
- }
-
- async function refresh() {
- const newData = await apiGet('/api/v1/routes');
- routes.splice(0, routes.length, ...(newData.items || []));
- renderPage(routes);
- loadAllDetails(routes);
- }
-
- async function loadAllDetails(routesList) {
- const promises = [];
- for (const r of routesList) {
- if (!detailCache.has(r.id)) {
- promises.push(
- apiGet(`/api/v1/routes/${r.id}`, {}, { signal })
- .then(d => detailCache.set(r.id, d))
- .catch(() => {})
- );
- }
- if (!historyCache.has(r.id)) {
- promises.push(
- apiGet(`/api/v1/routes/${r.id}/history`, { days: 6 }, { signal })
- .then(h => historyCache.set(r.id, h))
- .catch(() => {})
- );
- }
- }
- if (promises.length > 0) {
- await Promise.allSettled(promises);
- renderPage(routes);
- }
- }
-
- function renderPage(routesList) {
- const adminHeader = isAdmin
- ? html`
-
- `
- : nothing;
-
- const emptyMessage = routesList.length === 0
- ? html`
- ${t('common.no_entity_found', { entity: t('entities.routes').toLowerCase() })}
- `
- : nothing;
-
- const groups = new Map();
- for (const vis of VISIBILITY_ORDER) groups.set(vis, []);
- for (const r of routesList) {
- const vis = r.visibility || 'community';
- if (!groups.has(vis)) groups.set(vis, []);
- groups.get(vis).push(r);
- }
-
- const cardOpts = {
- isAdmin,
- onDelete: handleDeleteClick,
- onEdit: handleEditClick,
- detail: (r) => detailCache.get(r.id),
- navigate,
- packetsEnabled,
- history: (r) => historyCache.get(r.id),
- };
-
- const groupedSections = [];
- for (const vis of VISIBILITY_ORDER) {
- const group = groups.get(vis);
- if (!group || group.length === 0) continue;
- group.sort((a, b) => {
- const cmp = (a.from_label || '').localeCompare(b.from_label || '');
- return cmp !== 0 ? cmp : (a.to_label || '').localeCompare(b.to_label || '');
- });
- groupedSections.push(html`
- ${t(`routes.visibility_${vis}`)}
-
- ${group.map(r => renderRouteCard(r, {
- ...cardOpts,
- detail: cardOpts.detail(r),
- history: cardOpts.history(r),
- }))}
-
- `);
- }
-
- let modalHtml = nothing;
- if (modalState?.type === 'add' || modalState?.type === 'edit') {
- modalHtml = renderRouteModal({
- modalState,
- onSave: handleSave,
- onCancel: () => { modalState = null; renderPage(routesList); },
- saving: !!modalState.saving,
- });
- } else if (modalState?.type === 'delete') {
- modalHtml = renderDeleteModal({
- route: modalState.route,
- onConfirm: handleDeleteConfirm,
- onCancel: () => { modalState = null; renderPage(routesList); },
- saving: !!modalState.saving,
- });
- }
-
- destroyCharts();
-
- litRender(html`
-
-
- ${iconPath('h-8 w-8')}
- ${t('routes.title')}
-
-
- ${renderSummaryStrip(routesList)}
- ${adminHeader}
- ${emptyMessage}
- ${groupedSections}
- ${modalHtml}
- `, container);
-
- for (const r of routesList) {
- if (historyCache.has(r.id)) {
- const chart = window.createRouteDetailStrip(`routeStripChart-${r.id}`, historyCache.get(r.id));
- if (chart) chartRegistry.push(chart);
- }
- }
- }
-
- function _newModalState(type, route) {
- return {
- type,
- route,
- isEdit: type === 'edit',
- pathNodes: (route.route_nodes || []).map(rn => ({
- public_key: rn.public_key,
- name: rn.name,
- })),
- observerNodes: (route.route_observers || []).map(ro => ({
- public_key: ro.public_key,
- name: ro.name,
- })),
- pathResults: [],
- obsResults: [],
- handlePathSearch,
- handlePathSelect,
- handlePathRemove,
- handlePathMove,
- handlePathKeydown,
- handleObsSearch,
- handleObsSelect,
- handleObsRemove,
- handleObsKeydown,
- };
- }
-
- function handleAdd() {
- modalState = _newModalState('add', { visibility: 'community', enabled: true, match_width: 1, window_hours: 48, max_hop_span: 8, packet_count_threshold: 5 });
- renderPage(routes);
- }
-
- function handleEditClick(route) {
- modalState = _newModalState('edit', route);
- renderPage(routes);
- }
-
- function handleDeleteClick(route) {
- modalState = { type: 'delete', route };
- renderPage(routes);
- }
-
- function handlePathSearch(query) {
- clearTimeout(_pathSearchTimer);
- const q = query.trim();
- if (q.length < 2) {
- modalState.pathResults = [];
- renderPage(routes);
- return;
- }
- _pathSearchTimer = setTimeout(async () => {
- const myId = ++_pathSearchId;
- try {
- const data = await apiGet('/api/v1/nodes', { search: q, limit: 10 });
- if (myId !== _pathSearchId) return;
- modalState.pathResults = data.items || [];
- renderPage(routes);
- } catch (_) { /* ignore */ }
- }, 300);
- }
-
- function handlePathSelect(node) {
- if (modalState.pathNodes.some(n => n.public_key === node.public_key)) return;
- modalState.pathNodes.push({ public_key: node.public_key, name: node.name });
- modalState.pathResults = [];
- renderPage(routes);
- const el = document.getElementById('route-modal-path-search');
- if (el) el.value = '';
- }
-
- function handlePathRemove(index) {
- modalState.pathNodes.splice(index, 1);
- renderPage(routes);
- }
-
- function handlePathMove(index, dir) {
- const newIndex = index + dir;
- if (newIndex < 0 || newIndex >= modalState.pathNodes.length) return;
- const nodes = modalState.pathNodes;
- [nodes[index], nodes[newIndex]] = [nodes[newIndex], nodes[index]];
- renderPage(routes);
- }
-
- async function handlePathKeydown(e, availResults) {
- if (e.key !== 'Enter') return;
- e.preventDefault();
- if (availResults.length === 1) {
- handlePathSelect(availResults[0]);
- return;
- }
- if (availResults.length > 1) {
- handlePathSelect(availResults[0]);
- return;
- }
- const query = e.target.value.trim();
- if (query.length < 2) return;
- clearTimeout(_pathSearchTimer);
- const myId = ++_pathSearchId;
- try {
- const data = await apiGet('/api/v1/nodes', { search: query, limit: 10 });
- if (myId !== _pathSearchId) return;
- modalState.pathResults = data.items || [];
- renderPage(routes);
- const filtered = modalState.pathResults.filter(
- n => !modalState.pathNodes.some(pn => pn.public_key === n.public_key)
- );
- if (filtered.length >= 1) {
- handlePathSelect(filtered[0]);
- }
- } catch (_) { /* ignore */ }
- }
-
- function handleObsSearch(query) {
- clearTimeout(_obsSearchTimer);
- const q = query.trim();
- if (q.length < 2) {
- modalState.obsResults = [];
- renderPage(routes);
- return;
- }
- _obsSearchTimer = setTimeout(async () => {
- const myId = ++_obsSearchId;
- try {
- const data = await apiGet('/api/v1/nodes', { search: q, limit: 10, observer: true });
- if (myId !== _obsSearchId) return;
- modalState.obsResults = data.items || [];
- renderPage(routes);
- } catch (_) { /* ignore */ }
- }, 300);
- }
-
- function handleObsSelect(node) {
- if (modalState.observerNodes.some(n => n.public_key === node.public_key)) return;
- modalState.observerNodes.push({ public_key: node.public_key, name: node.name });
- modalState.obsResults = [];
- renderPage(routes);
- const el = document.getElementById('route-modal-obs-search');
- if (el) el.value = '';
- }
-
- function handleObsRemove(index) {
- modalState.observerNodes.splice(index, 1);
- renderPage(routes);
- }
-
- async function handleObsKeydown(e, availResults) {
- if (e.key !== 'Enter') return;
- e.preventDefault();
- if (availResults.length >= 1) {
- handleObsSelect(availResults[0]);
- return;
- }
- const query = e.target.value.trim();
- if (query.length < 2) return;
- clearTimeout(_obsSearchTimer);
- const myId = ++_obsSearchId;
- try {
- const data = await apiGet('/api/v1/nodes', { search: query, limit: 10, observer: true });
- if (myId !== _obsSearchId) return;
- modalState.obsResults = data.items || [];
- renderPage(routes);
- const filtered = modalState.obsResults.filter(
- n => !modalState.observerNodes.some(on => on.public_key === n.public_key)
- );
- if (filtered.length >= 1) {
- handleObsSelect(filtered[0]);
- }
- } catch (_) { /* ignore */ }
- }
-
- async function handleSave() {
- const fromEl = document.getElementById('route-modal-from');
- const toEl = document.getElementById('route-modal-to');
- const descEl = document.getElementById('route-modal-description');
- const visEl = document.getElementById('route-modal-visibility');
- const widthEl = document.getElementById('route-modal-width');
- const windowEl = document.getElementById('route-modal-window');
- const thresholdEl = document.getElementById('route-modal-threshold');
- const clearEl = document.getElementById('route-modal-clear');
- const spanEl = document.getElementById('route-modal-span');
- const pathLengthEl = document.getElementById('route-modal-path-length');
- const enabledEl = document.getElementById('route-modal-enabled');
- const reversibleEl = document.getElementById('route-modal-reversible');
-
- const isEdit = modalState.isEdit;
- const nodePublicKeys = modalState.pathNodes.map(n => n.public_key);
- const observerPublicKeys = modalState.observerNodes.map(n => n.public_key);
-
- if (nodePublicKeys.length < 2) {
- alert(t('routes.min_nodes_error'));
- return;
- }
-
- const body = {
- from_label: fromEl.value.trim(),
- to_label: toEl.value.trim(),
- description: descEl.value.trim() || null,
- visibility: visEl.value,
- match_width: parseInt(widthEl.value, 10) || 1,
- window_hours: parseInt(windowEl.value, 10) || 48,
- packet_count_threshold: parseInt(thresholdEl.value, 10) || 5,
- max_hop_span: spanEl.value ? parseInt(spanEl.value, 10) : null,
- max_path_length: pathLengthEl.value ? parseInt(pathLengthEl.value, 10) : null,
- enabled: enabledEl.checked,
- reversible: reversibleEl.checked,
- node_public_keys: nodePublicKeys,
- observer_public_keys: observerPublicKeys,
- };
-
- const clearVal = clearEl.value.trim();
- if (clearVal) {
- body.clear_threshold = parseInt(clearVal, 10);
- }
-
- modalState = { ...modalState, saving: true };
- renderPage(routes);
- try {
- if (isEdit) {
- await apiPut(`/api/v1/routes/${modalState.route.id}`, body);
- detailCache.delete(modalState.route.id);
- historyCache.delete(modalState.route.id);
- } else {
- await apiPost('/api/v1/routes', body);
- }
- modalState = null;
- await refresh();
- } catch (e) {
- modalState = { ...modalState, saving: false };
- renderPage(routes);
- alert(e.message || 'Failed to save route');
- }
- }
-
- async function handleDeleteConfirm() {
- modalState = { ...modalState, saving: true };
- renderPage(routes);
- try {
- await apiDelete(`/api/v1/routes/${modalState.route.id}`);
- modalState = null;
- await refresh();
- } catch (e) {
- modalState = { ...modalState, saving: false };
- renderPage(routes);
- alert(e.message || 'Failed to delete route');
- }
- }
-
- renderPage(routes);
- loadAllDetails(routes);
-
- return () => {
- destroyCharts();
- };
-
- } catch (e) {
- if (isAbortError(e)) return;
- litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
- }
-}
diff --git a/src/meshcore_hub/web/static/js/spa/router.js b/src/meshcore_hub/web/static/js/spa/router.js
deleted file mode 100644
index f58b668..0000000
--- a/src/meshcore_hub/web/static/js/spa/router.js
+++ /dev/null
@@ -1,189 +0,0 @@
-/**
- * MeshCore Hub SPA - Client-Side Router
- *
- * Simple History API based router with parameterized routes.
- */
-
-export class Router {
- constructor() {
- this._routes = [];
- this._notFoundHandler = null;
- this._currentCleanup = null;
- this._onNavigate = null;
- this._navAbort = null;
- this._navGen = 0;
- }
-
- /**
- * Register a route.
- * @param {string} path - URL pattern (e.g., '/nodes/:publicKey')
- * @param {Function} handler - async function(params) where params includes route params and query
- */
- addRoute(path, handler) {
- const paramNames = [];
- const regexStr = path
- .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex chars
- .replace(/:([a-zA-Z_]+)/g, (_, name) => {
- paramNames.push(name);
- return '([^/]+)';
- });
- this._routes.push({
- pattern: new RegExp('^' + regexStr + '$'),
- paramNames,
- handler,
- path,
- });
- }
-
- /**
- * Set the 404 handler.
- * @param {Function} handler - async function(params)
- */
- setNotFound(handler) {
- this._notFoundHandler = handler;
- }
-
- /**
- * Set a callback to run on every navigation (for updating navbar, etc.)
- * @param {Function} fn - function(pathname)
- */
- onNavigate(fn) {
- this._onNavigate = fn;
- }
-
- /**
- * Navigate to a URL.
- * @param {string} url - URL path with optional query string
- * @param {boolean} [replace=false] - Use replaceState instead of pushState
- */
- navigate(url, replace = false) {
- // Skip if already on this exact URL
- const current = window.location.pathname + window.location.search;
- if (url === current && !replace) return;
-
- if (replace) {
- history.replaceState(null, '', url);
- } else {
- history.pushState(null, '', url);
- }
- this._handleRoute();
- }
-
- /**
- * Match a pathname against registered routes.
- * @param {string} pathname
- * @returns {{ handler: Function, params: Object } | null}
- */
- _match(pathname) {
- for (const route of this._routes) {
- const match = pathname.match(route.pattern);
- if (match) {
- const params = {};
- route.paramNames.forEach((name, i) => {
- params[name] = decodeURIComponent(match[i + 1]);
- });
- return { handler: route.handler, params };
- }
- }
- return null;
- }
-
- /**
- * Handle the current URL.
- */
- async _handleRoute() {
- // Cancel any in-flight requests from the page we're leaving so they
- // don't hold connections / server resources behind the new page.
- if (this._navAbort) {
- this._navAbort.abort();
- }
- this._navAbort = new AbortController();
- const signal = this._navAbort.signal;
-
- // Track this navigation so a stale (superseded) route can't toggle the
- // shared loading indicator for the navigation that replaced it.
- const navGen = ++this._navGen;
-
- // Clean up previous page
- if (this._currentCleanup) {
- try { this._currentCleanup(); } catch (e) { /* ignore */ }
- this._currentCleanup = null;
- }
-
- const pathname = window.location.pathname;
- const sp = new URLSearchParams(window.location.search);
- const query = {};
- for (const [k, v] of sp.entries()) {
- if (k in query) {
- query[k] = Array.isArray(query[k]) ? [...query[k], v] : [query[k], v];
- } else {
- query[k] = v;
- }
- }
-
- // Notify navigation listener
- if (this._onNavigate) {
- this._onNavigate(pathname);
- }
-
- // Show navbar loading indicator
- const loader = document.getElementById('nav-loading');
- if (loader) loader.classList.remove('hidden');
-
- try {
- const result = this._match(pathname);
- if (result) {
- const cleanup = await result.handler({ ...result.params, query, signal });
- if (typeof cleanup === 'function') {
- this._currentCleanup = cleanup;
- }
- } else if (this._notFoundHandler) {
- await this._notFoundHandler({ query, signal });
- }
- } finally {
- // Only hide the loader if a newer navigation hasn't started.
- if (loader && navGen === this._navGen) loader.classList.add('hidden');
- }
-
- // Reset focus to dismiss any open dropdown after navigation
- document.activeElement?.blur();
-
- // Scroll to top on navigation
- window.scrollTo(0, 0);
- }
-
- /**
- * Start the router - listen for events and handle initial route.
- */
- start() {
- // Handle browser back/forward
- window.addEventListener('popstate', () => this._handleRoute());
-
- // Intercept link clicks for SPA navigation
- document.addEventListener('click', (e) => {
- const link = e.target.closest('a[href]');
- if (!link) return;
-
- const href = link.getAttribute('href');
-
- // Skip external links, anchors, downloads, new tabs
- if (!href || !href.startsWith('/') || href.startsWith('//')) return;
- if (link.hasAttribute('download') || link.target === '_blank') return;
-
- // Skip non-SPA paths (static files, API, media, OAuth, SEO)
- if (href.startsWith('/static/') || href.startsWith('/media/') ||
- href.startsWith('/api/') || href.startsWith('/auth/') ||
- href.startsWith('/health') || href === '/robots.txt' ||
- href === '/sitemap.xml') return;
-
- // Skip mailto and tel links
- if (href.startsWith('mailto:') || href.startsWith('tel:')) return;
-
- e.preventDefault();
- this.navigate(href);
- });
-
- // Handle initial route
- this._handleRoute();
- }
-}
diff --git a/src/meshcore_hub/web/static/locales/en.json b/src/meshcore_hub/web/static/locales/en.json
index 14386a9..7d45834 100644
--- a/src/meshcore_hub/web/static/locales/en.json
+++ b/src/meshcore_hub/web/static/locales/en.json
@@ -414,7 +414,8 @@
"empty_description": "Members will appear here once users log in and adopt nodes."
},
"footer": {
- "powered_by": "Powered by"
+ "powered_by": "Powered by",
+ "tagline": "Off-Grid, Open-Source Encrypted Messaging"
},
"errors": {
"go_home": "Go Home",
diff --git a/src/meshcore_hub/web/static/locales/nl.json b/src/meshcore_hub/web/static/locales/nl.json
index 0c07b52..4d08958 100644
--- a/src/meshcore_hub/web/static/locales/nl.json
+++ b/src/meshcore_hub/web/static/locales/nl.json
@@ -327,6 +327,7 @@
"copied_entities": "{{copied}} label(s) gekopieerd, {{skipped}} overgeslagen"
},
"footer": {
- "powered_by": "Mogelijk gemaakt door"
+ "powered_by": "Mogelijk gemaakt door",
+ "tagline": "Off-Grid, Open-Source Versleutelde Berichten"
}
}
diff --git a/src/meshcore_hub/web/templates/spa.html b/src/meshcore_hub/web/templates/spa.html
index 2cb5655..1467b1c 100644
--- a/src/meshcore_hub/web/templates/spa.html
+++ b/src/meshcore_hub/web/templates/spa.html
@@ -43,184 +43,27 @@
-
-
+
+ {% if asset_app_css %}
+
+ {% endif %}
-
-
-
- {% if system_announcement %}
-
- {% endif %}
-
- {% if network_announcement %}
-
-
- {% endif %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
+
{% if asset_app_js %}
- {% else %}
-
{% endif %}
diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py
deleted file mode 100644
index bd2b902..0000000
--- a/tests/e2e/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""End-to-end tests for MeshCore Hub."""
diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py
deleted file mode 100644
index 459d502..0000000
--- a/tests/e2e/conftest.py
+++ /dev/null
@@ -1,157 +0,0 @@
-"""Fixtures for end-to-end tests.
-
-These tests require Docker Compose services to be running.
-They are disabled by default and can be run with:
-
- pytest -m e2e tests/e2e/
-
-Or with the --e2e flag:
-
- pytest --e2e tests/e2e/
-"""
-
-import os
-import time
-from typing import Generator
-
-import httpx
-import pytest
-
-
-def pytest_configure(config: pytest.Config) -> None:
- """Register the e2e marker."""
- config.addinivalue_line(
- "markers",
- "e2e: mark test as end-to-end test requiring Docker services",
- )
-
-
-def pytest_collection_modifyitems(
- config: pytest.Config, items: list[pytest.Item]
-) -> None:
- """Auto-mark all tests in this directory as e2e and skip if --e2e not provided."""
- # Check if e2e tests should run
- run_e2e = config.getoption("--e2e", default=False)
-
- skip_e2e = pytest.mark.skip(
- reason="E2E tests disabled by default. Use --e2e to run them."
- )
-
- for item in items:
- # Mark all tests in e2e directory
- if "e2e" in str(item.fspath):
- item.add_marker(pytest.mark.e2e)
- if not run_e2e:
- item.add_marker(skip_e2e)
-
-
-def pytest_addoption(parser: pytest.Parser) -> None:
- """Add --e2e option to pytest."""
- parser.addoption(
- "--e2e",
- action="store_true",
- default=False,
- help="Run end-to-end tests (requires Docker services)",
- )
-
-
-# E2E test configuration
-E2E_API_URL = os.environ.get("E2E_API_URL", "http://localhost:18000")
-E2E_WEB_URL = os.environ.get("E2E_WEB_URL", "http://localhost:18080")
-E2E_MQTT_HOST = os.environ.get("E2E_MQTT_HOST", "localhost")
-E2E_MQTT_PORT = int(os.environ.get("E2E_MQTT_PORT", "11883"))
-E2E_READ_KEY = os.environ.get("E2E_READ_KEY", "test-read-key")
-E2E_ADMIN_KEY = os.environ.get("E2E_ADMIN_KEY", "test-admin-key")
-
-
-def wait_for_service(url: str, timeout: int = 60) -> bool:
- """Wait for a service to become available.
-
- Args:
- url: Health check URL
- timeout: Maximum seconds to wait
-
- Returns:
- True if service is available, False if timeout
- """
- start = time.time()
- while time.time() - start < timeout:
- try:
- response = httpx.get(url, timeout=5.0)
- if response.status_code == 200:
- return True
- except httpx.RequestError:
- pass
- time.sleep(1)
- return False
-
-
-@pytest.fixture(scope="session")
-def api_url() -> str:
- """Get API base URL."""
- return E2E_API_URL
-
-
-@pytest.fixture(scope="session")
-def web_url() -> str:
- """Get Web dashboard URL."""
- return E2E_WEB_URL
-
-
-@pytest.fixture(scope="session")
-def read_key() -> str:
- """Get read API key."""
- return E2E_READ_KEY
-
-
-@pytest.fixture(scope="session")
-def admin_key() -> str:
- """Get admin API key."""
- return E2E_ADMIN_KEY
-
-
-@pytest.fixture(scope="session")
-def api_client(api_url: str, read_key: str) -> Generator[httpx.Client, None, None]:
- """Create an API client with read access.
-
- This fixture waits for the API to be available before returning.
- """
- health_url = f"{api_url}/health"
- if not wait_for_service(health_url):
- pytest.skip(f"API not available at {api_url}")
-
- with httpx.Client(
- base_url=api_url,
- headers={"Authorization": f"Bearer {read_key}"},
- timeout=30.0,
- ) as client:
- yield client
-
-
-@pytest.fixture(scope="session")
-def admin_client(api_url: str, admin_key: str) -> Generator[httpx.Client, None, None]:
- """Create an API client with admin access."""
- health_url = f"{api_url}/health"
- if not wait_for_service(health_url):
- pytest.skip(f"API not available at {api_url}")
-
- with httpx.Client(
- base_url=api_url,
- headers={"Authorization": f"Bearer {admin_key}"},
- timeout=30.0,
- ) as client:
- yield client
-
-
-@pytest.fixture(scope="session")
-def web_client(web_url: str) -> Generator[httpx.Client, None, None]:
- """Create a web dashboard client."""
- health_url = f"{web_url}/health"
- if not wait_for_service(health_url):
- pytest.skip(f"Web dashboard not available at {web_url}")
-
- with httpx.Client(
- base_url=web_url,
- timeout=30.0,
- ) as client:
- yield client
diff --git a/tests/e2e/docker-compose.test.yml b/tests/e2e/docker-compose.test.yml
deleted file mode 100644
index 7e47557..0000000
--- a/tests/e2e/docker-compose.test.yml
+++ /dev/null
@@ -1,145 +0,0 @@
-# MeshCore Hub - End-to-End Test Docker Compose
-#
-# This configuration runs all services for integration testing.
-#
-# Usage:
-# docker compose -f tests/e2e/docker-compose.test.yml up -d
-# pytest tests/e2e/
-# docker compose -f tests/e2e/docker-compose.test.yml down -v
-
-services:
- # MQTT Broker
- mqtt:
- image: ghcr.io/ipnet-mesh/meshcore-mqtt-broker:latest
- container_name: meshcore-test-mqtt
- ports:
- - "11883:1883"
- volumes:
- - test_mqtt_data:/data
- environment:
- - MQTT_WS_PORT=1883
- - MQTT_HOST=0.0.0.0
- - AUTH_EXPECTED_AUDIENCE=mqtt.localhost
- - SUBSCRIBER_MAX_CONNECTIONS_DEFAULT=5
- - SUBSCRIBER_1=test-admin:test-password:1
- - ABUSE_ENFORCEMENT_ENABLED=false
- - ABUSE_DUPLICATE_WINDOW_SIZE=100
- - ABUSE_DUPLICATE_WINDOW_MS=300000
- - ABUSE_DUPLICATE_THRESHOLD=10
- - ABUSE_MAX_DUPLICATES_PER_PACKET=5
- - ABUSE_DUPLICATE_RATE_THRESHOLD=0.3
- - ABUSE_DUPLICATE_RATE_WINDOW_MS=300000
- - ABUSE_BUCKET_CAPACITY=20
- - ABUSE_BUCKET_REFILL_RATE=3
- - ABUSE_MAX_PACKET_SIZE=255
- - ABUSE_MAX_TOPICS_PER_DAY=3
- - ABUSE_ANOMALY_THRESHOLD=10
- - ABUSE_MAX_IATA_CHANGES_24H=3
- - ABUSE_TOPIC_HISTORY_SIZE=50
- - ABUSE_TOPIC_HISTORY_WINDOW_MS=86400000
- - ABUSE_PERSISTENCE_PATH=/data/abuse-detection.db
- - ABUSE_PERSISTENCE_INTERVAL_MS=300000
- healthcheck:
- test: ["CMD", "node", "-e", "const net=require('net');const s=net.createConnection(1883,'127.0.0.1',()=>{s.end();process.exit(0)});s.on('error',()=>process.exit(1));setTimeout(()=>process.exit(1),3000)"]
- interval: 5s
- timeout: 5s
- retries: 3
- start_period: 5s
-
- # Collector
- collector:
- build:
- context: ../..
- dockerfile: Dockerfile
- container_name: meshcore-test-collector
- depends_on:
- mqtt:
- condition: service_healthy
- volumes:
- - test_data:/data
- environment:
- - LOG_LEVEL=DEBUG
- - MQTT_HOST=mqtt
- - MQTT_PORT=1883
- - MQTT_PREFIX=test
- - MQTT_TRANSPORT=websockets
- - MQTT_WS_PATH=/
- - MQTT_USERNAME=test-admin
- - MQTT_PASSWORD=test-password
- - DATABASE_URL=sqlite:////data/test.db
- command: ["collector"]
- healthcheck:
- test: ["CMD", "pgrep", "-f", "meshcore-hub"]
- interval: 5s
- timeout: 5s
- retries: 3
- start_period: 10s
-
- # API Server
- api:
- build:
- context: ../..
- dockerfile: Dockerfile
- container_name: meshcore-test-api
- depends_on:
- mqtt:
- condition: service_healthy
- collector:
- condition: service_started
- ports:
- - "18000:8000"
- volumes:
- - test_data:/data
- environment:
- - LOG_LEVEL=DEBUG
- - MQTT_HOST=mqtt
- - MQTT_PORT=1883
- - MQTT_PREFIX=test
- - MQTT_TRANSPORT=websockets
- - MQTT_WS_PATH=/
- - MQTT_USERNAME=test-admin
- - MQTT_PASSWORD=test-password
- - DATABASE_URL=sqlite:////data/test.db
- - API_HOST=0.0.0.0
- - API_PORT=8000
- - API_READ_KEY=test-read-key
- - API_ADMIN_KEY=test-admin-key
- command: ["api"]
- healthcheck:
- test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
- interval: 5s
- timeout: 5s
- retries: 3
- start_period: 10s
-
- # Web Dashboard
- web:
- build:
- context: ../..
- dockerfile: Dockerfile
- container_name: meshcore-test-web
- depends_on:
- api:
- condition: service_healthy
- ports:
- - "18080:8080"
- environment:
- - LOG_LEVEL=DEBUG
- - API_BASE_URL=http://api:8000
- - API_KEY=test-read-key
- - WEB_HOST=0.0.0.0
- - WEB_PORT=8080
- - NETWORK_NAME=Test Network
- command: ["web"]
- healthcheck:
- test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
- interval: 5s
- timeout: 5s
- retries: 3
- start_period: 10s
-
-volumes:
- test_data:
- name: meshcore_test_data
- test_mqtt_data:
- name: meshcore_test_mqtt_data
diff --git a/tests/e2e/test_full_flow.py b/tests/e2e/test_full_flow.py
deleted file mode 100644
index e1f6821..0000000
--- a/tests/e2e/test_full_flow.py
+++ /dev/null
@@ -1,206 +0,0 @@
-"""End-to-end tests for the full MeshCore Hub flow.
-
-These tests require Docker Compose services to be running:
-
- docker compose -f tests/e2e/docker-compose.test.yml up -d
- pytest tests/e2e/
- docker compose -f tests/e2e/docker-compose.test.yml down -v
-
-The tests verify:
-1. API health endpoints
-2. Web dashboard health endpoints
-3. Node listing and retrieval
-4. Message listing
-5. Statistics endpoint
-6. Command sending (admin only)
-"""
-
-import httpx
-
-
-class TestHealthEndpoints:
- """Test health check endpoints."""
-
- def test_api_health(self, api_client: httpx.Client) -> None:
- """Test API basic health endpoint."""
- response = api_client.get("/health")
- assert response.status_code == 200
- data = response.json()
- assert data["status"] == "healthy"
- assert "version" in data
-
- def test_api_ready(self, api_client: httpx.Client) -> None:
- """Test API readiness endpoint with database check."""
- response = api_client.get("/health/ready")
- assert response.status_code == 200
- data = response.json()
- assert data["status"] == "ready"
- assert data["database"] == "connected"
-
- def test_web_health(self, web_client: httpx.Client) -> None:
- """Test Web dashboard health endpoint."""
- response = web_client.get("/health")
- assert response.status_code == 200
- data = response.json()
- assert data["status"] == "healthy"
-
- def test_web_ready(self, web_client: httpx.Client) -> None:
- """Test Web dashboard readiness with API connectivity."""
- response = web_client.get("/health/ready")
- assert response.status_code == 200
- data = response.json()
- assert data["status"] == "ready"
- assert data["api"] == "connected"
-
-
-class TestAPIEndpoints:
- """Test API data endpoints."""
-
- def test_list_nodes(self, api_client: httpx.Client) -> None:
- """Test listing nodes."""
- response = api_client.get("/api/v1/nodes")
- assert response.status_code == 200
- data = response.json()
- assert "items" in data
- assert "total" in data
- assert isinstance(data["items"], list)
-
- def test_list_messages(self, api_client: httpx.Client) -> None:
- """Test listing messages."""
- response = api_client.get("/api/v1/messages")
- assert response.status_code == 200
- data = response.json()
- assert "items" in data
- assert "total" in data
-
- def test_list_advertisements(self, api_client: httpx.Client) -> None:
- """Test listing advertisements."""
- response = api_client.get("/api/v1/advertisements")
- assert response.status_code == 200
- data = response.json()
- assert "items" in data
- assert "total" in data
-
- def test_get_stats(self, api_client: httpx.Client) -> None:
- """Test getting network statistics."""
- response = api_client.get("/api/v1/stats")
- assert response.status_code == 200
- data = response.json()
- assert "total_nodes" in data
- assert "active_nodes" in data
- assert "total_messages" in data
-
- def test_list_telemetry(self, api_client: httpx.Client) -> None:
- """Test listing telemetry records."""
- response = api_client.get("/api/v1/telemetry")
- assert response.status_code == 200
- data = response.json()
- assert "items" in data
-
- def test_list_trace_paths(self, api_client: httpx.Client) -> None:
- """Test listing trace paths."""
- response = api_client.get("/api/v1/trace-paths")
- assert response.status_code == 200
- data = response.json()
- assert "items" in data
-
- def test_list_members(self, api_client: httpx.Client) -> None:
- """Test listing members."""
- response = api_client.get("/api/v1/members")
- assert response.status_code == 200
- data = response.json()
- assert "items" in data
- assert "total" in data
-
-
-class TestWebDashboard:
- """Test web dashboard pages."""
-
- def test_home_page(self, web_client: httpx.Client) -> None:
- """Test home page loads."""
- response = web_client.get("/")
- assert response.status_code == 200
- assert "text/html" in response.headers.get("content-type", "")
-
- def test_dashboard_page(self, web_client: httpx.Client) -> None:
- """Test dashboard page loads."""
- response = web_client.get("/dashboard")
- assert response.status_code == 200
- assert "text/html" in response.headers.get("content-type", "")
-
- def test_nodes_page(self, web_client: httpx.Client) -> None:
- """Test nodes listing page loads."""
- response = web_client.get("/nodes")
- assert response.status_code == 200
- assert "text/html" in response.headers.get("content-type", "")
-
- def test_messages_page(self, web_client: httpx.Client) -> None:
- """Test messages page loads."""
- response = web_client.get("/messages")
- assert response.status_code == 200
- assert "text/html" in response.headers.get("content-type", "")
-
- def test_map_page(self, web_client: httpx.Client) -> None:
- """Test map page loads."""
- response = web_client.get("/map")
- assert response.status_code == 200
- assert "text/html" in response.headers.get("content-type", "")
-
- def test_members_page(self, web_client: httpx.Client) -> None:
- """Test members page loads."""
- response = web_client.get("/members")
- assert response.status_code == 200
- assert "text/html" in response.headers.get("content-type", "")
-
-
-class TestAuthentication:
- """Test API authentication."""
-
- def test_read_access_with_read_key(self, api_client: httpx.Client) -> None:
- """Test read access works with read key."""
- response = api_client.get("/api/v1/nodes")
- assert response.status_code == 200
-
- def test_admin_access_with_admin_key(self, admin_client: httpx.Client) -> None:
- """Test admin access works with admin key."""
- # Admin key should have read access
- response = admin_client.get("/api/v1/nodes")
- assert response.status_code == 200
-
-
-class TestCommands:
- """Test command endpoints (requires admin access)."""
-
- def test_send_message_requires_admin(self, api_client: httpx.Client) -> None:
- """Test that send message requires admin key."""
- response = api_client.post(
- "/api/v1/commands/send-message",
- json={
- "destination": "0" * 64,
- "text": "Test message",
- },
- )
- # Read key should not have admin access
- assert response.status_code == 403
-
- def test_send_channel_message_admin(self, admin_client: httpx.Client) -> None:
- """Test sending channel message with admin key."""
- response = admin_client.post(
- "/api/v1/commands/send-channel-message",
- json={
- "channel_idx": 0,
- "text": "Test channel message",
- },
- )
- # Should succeed with admin key (202 = accepted for processing)
- assert response.status_code == 202
-
- def test_send_advertisement_admin(self, admin_client: httpx.Client) -> None:
- """Test sending advertisement with admin key."""
- response = admin_client.post(
- "/api/v1/commands/send-advertisement",
- json={
- "flood": False,
- },
- )
- assert response.status_code == 202
diff --git a/tests/test_api/test_cache.py b/tests/test_api/test_cache.py
index f563d80..09c1ab0 100644
--- a/tests/test_api/test_cache.py
+++ b/tests/test_api/test_cache.py
@@ -1313,12 +1313,13 @@ class TestKeyBuilders:
):
scope = {
"type": "http",
+ "path": "/api/v1/channels",
"query_string": b"",
"headers": [],
}
request = Request(scope)
key = _channels_key_builder(request)
- assert key == "channels:role=operator:"
+ assert key == "/api/v1/channels:role=operator:"
def test_channels_key_builder_anonymous(self):
from meshcore_hub.api.routes.channels import _channels_key_builder
@@ -1329,6 +1330,7 @@ class TestKeyBuilders:
):
scope = {
"type": "http",
+ "path": "/api/v1/channels",
"query_string": b"",
"headers": [],
}
@@ -1345,14 +1347,54 @@ class TestKeyBuilders:
):
scope = {
"type": "http",
+ "path": "/api/v1/messages",
"query_string": b"limit=10&offset=0",
"headers": [],
}
request = Request(scope)
key = _messages_key_builder(request)
- assert "role=admin" in key
+ assert key.startswith("/api/v1/messages:role=admin:")
assert "limit=10" in key
+ def test_role_aware_keys_match_invalidation_prefixes(self):
+ """The GET cache key must live under the prefix the matching
+ invalidation helper drops, otherwise a mutation never clears the
+ stale cached list (the store path and delete path must agree).
+ """
+ from meshcore_hub.api.routes.channels import _channels_key_builder
+ from meshcore_hub.api.routes.messages import _messages_key_builder
+
+ with (
+ patch(
+ "meshcore_hub.api.routes.channels.resolve_user_role",
+ return_value="admin",
+ ),
+ patch(
+ "meshcore_hub.api.routes.messages.resolve_user_role",
+ return_value="admin",
+ ),
+ ):
+ channels_req = Request(
+ {
+ "type": "http",
+ "path": "/api/v1/channels",
+ "query_string": b"",
+ "headers": [],
+ }
+ )
+ messages_req = Request(
+ {
+ "type": "http",
+ "path": "/api/v1/messages",
+ "query_string": b"",
+ "headers": [],
+ }
+ )
+ # invalidate_channels drops "/api/v1/channels",
+ # invalidate_messages drops "/api/v1/messages".
+ assert _channels_key_builder(channels_req).startswith("/api/v1/channels")
+ assert _messages_key_builder(messages_req).startswith("/api/v1/messages")
+
def _make_request_with_cache(cache):
"""Build a Request whose ``app.state.redis_cache`` is *cache* (or absent)."""
diff --git a/tests/test_collector/test_routes.py b/tests/test_collector/test_routes.py
index 3351095..76157f8 100644
--- a/tests/test_collector/test_routes.py
+++ b/tests/test_collector/test_routes.py
@@ -1125,8 +1125,8 @@ class TestComputeAverageQuality:
"""Rolling-average tier over a history window (server-side badge source).
Mirrors the ``averageRouteTier`` JS helper in
- ``web/static/js/charts.js`` so the route card badge matches the chart
- line color when both render the same window.
+ ``web/static/js/spa-react/utils/charts.ts`` so the route card badge matches
+ the chart line color when both render the same window.
"""
@staticmethod
diff --git a/tests/test_web/conftest.py b/tests/test_web/conftest.py
index 6e340cd..b091806 100644
--- a/tests/test_web/conftest.py
+++ b/tests/test_web/conftest.py
@@ -1,6 +1,7 @@
"""Web dashboard test fixtures."""
-from typing import Any, Generator
+import json
+from typing import Any, Generator, cast
from unittest.mock import MagicMock, patch
import pytest
@@ -22,6 +23,22 @@ ALL_FEATURES_ENABLED = {
}
+def get_app_config(html: str) -> dict[str, Any]:
+ """Extract the embedded ``window.__APP_CONFIG__`` object from SPA shell HTML.
+
+ The navbar, banners, and feature-gated nav are rendered client-side by React
+ from this config, so web tests assert on the config rather than on
+ server-rendered nav HTML.
+ """
+ marker = "window.__APP_CONFIG__ = "
+ start = html.index(marker) + len(marker)
+ script_end = html.index("", start)
+ # Use the last ";" before so semicolons inside JSON string values
+ # (e.g. announcement HTML) don't truncate the object.
+ end = html.rindex(";", start, script_end)
+ return cast(dict[str, Any], json.loads(html[start:end]))
+
+
class MockHttpClient:
"""Mock HTTP client for testing web routes."""
diff --git a/tests/test_web/test_advertisements.py b/tests/test_web/test_advertisements.py
index b8ac12f..70ac438 100644
--- a/tests/test_web/test_advertisements.py
+++ b/tests/test_web/test_advertisements.py
@@ -28,12 +28,10 @@ class TestAdvertisementsPage:
response = client.get("/advertisements")
assert "window.__APP_CONFIG__" in response.text
- def test_advertisements_contains_spa_script(self, client: TestClient) -> None:
- """Test that advertisements page includes SPA application script."""
+ def test_advertisements_contains_spa_mount(self, client: TestClient) -> None:
+ """Test that advertisements page renders the React SPA mount point."""
response = client.get("/advertisements")
- has_bundled = "/static/dist/" in response.text
- has_fallback = "/static/js/spa/app.js" in response.text
- assert has_bundled or has_fallback
+ assert 'id="app"' in response.text
class TestAdvertisementsPageFilters:
diff --git a/tests/test_web/test_app.py b/tests/test_web/test_app.py
index 0fddb1d..370dde6 100644
--- a/tests/test_web/test_app.py
+++ b/tests/test_web/test_app.py
@@ -17,7 +17,7 @@ from meshcore_hub.web.app import (
create_app,
)
-from .conftest import ALL_FEATURES_ENABLED, MockHttpClient
+from .conftest import ALL_FEATURES_ENABLED, MockHttpClient, get_app_config
@pytest.fixture
@@ -330,7 +330,7 @@ class TestFlashBannerVisibility:
def test_banner_present_when_announcement_set(
self, mock_http_client: MockHttpClient
) -> None:
- """Banner HTML is present when network_announcement is set."""
+ """Banner content is exposed in the SPA config when network_announcement is set."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
@@ -340,23 +340,19 @@ class TestFlashBannerVisibility:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
- response = client.get("/")
- assert response.status_code == 200
- html = response.text
- assert 'id="flash-banner"' in html
- assert "Scheduled maintenance at 22:00" in html
+ config = get_app_config(client.get("/").text)
+ assert config["network_announcement"]
+ assert "Scheduled maintenance at 22:00" in config["network_announcement"]
def test_banner_absent_when_announcement_none(self, client: TestClient) -> None:
- """Banner HTML is absent when network_announcement is not set."""
- response = client.get("/")
- assert response.status_code == 200
- html = response.text
- assert 'id="flash-banner"' not in html
+ """Banner content is absent from the config when network_announcement is not set."""
+ config = get_app_config(client.get("/").text)
+ assert not config["network_announcement"]
def test_banner_absent_for_empty_string(
self, mock_http_client: MockHttpClient
) -> None:
- """Banner is not shown when announcement is an empty string."""
+ """Banner is not exposed when announcement is an empty string."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
@@ -366,14 +362,13 @@ class TestFlashBannerVisibility:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
- response = client.get("/")
- assert response.status_code == 200
- assert 'id="flash-banner"' not in response.text
+ config = get_app_config(client.get("/").text)
+ assert not config["network_announcement"]
def test_banner_absent_for_whitespace_only(
self, mock_http_client: MockHttpClient
) -> None:
- """Banner is not shown when announcement is whitespace-only."""
+ """Banner is not exposed when announcement is whitespace-only."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
@@ -383,16 +378,20 @@ class TestFlashBannerVisibility:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
- response = client.get("/")
- assert response.status_code == 200
- assert 'id="flash-banner"' not in response.text
+ config = get_app_config(client.get("/").text)
+ assert not config["network_announcement"]
class TestFlashBannerMarkdown:
- """Tests for Markdown rendering in the flash banner."""
+ """Tests that raw markdown is shipped in the SPA config for client-side rendering.
- def test_bold_rendered(self, mock_http_client: MockHttpClient) -> None:
- """Markdown bold is rendered to ."""
+ The backend no longer converts markdown to HTML; the React ````
+ component (react-markdown + remark-gfm) renders it. Raw HTML in the source
+ is preserved here but escaped by the client renderer (no rehype-raw).
+ """
+
+ def test_bold_source_preserved(self, mock_http_client: MockHttpClient) -> None:
+ """Raw markdown bold syntax is shipped verbatim in the config."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
@@ -402,12 +401,11 @@ class TestFlashBannerMarkdown:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
- response = client.get("/")
- assert response.status_code == 200
- assert "important" in response.text
+ config = get_app_config(client.get("/").text)
+ assert "**important**" in config["network_announcement"]
- def test_link_rendered(self, mock_http_client: MockHttpClient) -> None:
- """Markdown link is rendered to tag."""
+ def test_link_source_preserved(self, mock_http_client: MockHttpClient) -> None:
+ """Raw markdown link syntax is shipped verbatim in the config."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
@@ -417,16 +415,17 @@ class TestFlashBannerMarkdown:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
- response = client.get("/")
- assert response.status_code == 200
- assert 'click here' in response.text
+ config = get_app_config(client.get("/").text)
+ assert "[click here](https://example.com)" in config["network_announcement"]
- def test_raw_html_passed_through(self, mock_http_client: MockHttpClient) -> None:
- """Raw HTML in announcement is passed through by the Markdown library.
+ def test_raw_html_preserved_but_escaped_client_side(
+ self, mock_http_client: MockHttpClient
+ ) -> None:
+ """Raw HTML in announcement is shipped as-is; the client escapes it.
- This is safe because the announcement source is an operator-controlled
- environment variable, not user input — same trust model as custom pages
- in pages.py.
+ The backend trusts the operator-controlled source (same trust model as
+ custom pages). Client-side rendering via react-markdown escapes raw HTML
+ by default (no rehype-raw), so it is displayed as text, not rendered.
"""
app = create_app(
api_url="http://localhost:8000",
@@ -437,9 +436,8 @@ class TestFlashBannerMarkdown:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
- response = client.get("/")
- assert response.status_code == 200
- assert "bold" in response.text
+ config = get_app_config(client.get("/").text)
+ assert "bold" in config["network_announcement"]
class TestSystemAnnouncementBanner:
@@ -448,7 +446,7 @@ class TestSystemAnnouncementBanner:
def test_system_banner_present_when_set(
self, mock_http_client: MockHttpClient
) -> None:
- """System banner HTML is present and Markdown-rendered when set."""
+ """System banner raw markdown is exposed in the config for client rendering."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
@@ -458,18 +456,19 @@ class TestSystemAnnouncementBanner:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
- html = client.get("/").text
- assert 'id="system-banner"' in html
- assert "Outage at 22:00" in html
+ config = get_app_config(client.get("/").text)
+ assert config["system_announcement"]
+ assert "**Outage** at 22:00" in config["system_announcement"]
def test_system_banner_absent_when_none(self, client: TestClient) -> None:
- """System banner HTML is absent when not set."""
- assert 'id="system-banner"' not in client.get("/").text
+ """System banner content is absent from the config when not set."""
+ config = get_app_config(client.get("/").text)
+ assert not config["system_announcement"]
def test_system_banner_absent_for_empty_string(
self, mock_http_client: MockHttpClient
) -> None:
- """System banner is not shown for an empty string."""
+ """System banner is not exposed for an empty string."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
@@ -479,43 +478,12 @@ class TestSystemAnnouncementBanner:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
- assert 'id="system-banner"' not in client.get("/").text
+ config = get_app_config(client.get("/").text)
+ assert not config["system_announcement"]
- def test_system_banner_not_dismissable(
- self, mock_http_client: MockHttpClient
- ) -> None:
- """System banner has no dismiss button or sessionStorage script."""
- app = create_app(
- api_url="http://localhost:8000",
- api_key="test-api-key",
- system_announcement="Heads up",
- features=ALL_FEATURES_ENABLED,
- )
- app.state.http_client = mock_http_client
- client = TestClient(app, raise_server_exceptions=True)
-
- html = client.get("/").text
- banner = html[html.index('id="system-banner"') :]
- banner = banner[: banner.index("")]
- assert "Dismiss" not in banner
- assert "sessionStorage" not in banner
-
- def test_system_banner_stacked_above_network_banner(
- self, mock_http_client: MockHttpClient
- ) -> None:
- """System banner is rendered above the network announcement banner."""
- app = create_app(
- api_url="http://localhost:8000",
- api_key="test-api-key",
- system_announcement="System notice",
- network_announcement="Network notice",
- features=ALL_FEATURES_ENABLED,
- )
- app.state.http_client = mock_http_client
- client = TestClient(app, raise_server_exceptions=True)
-
- html = client.get("/").text
- assert html.index('id="system-banner"') < html.index('id="flash-banner"')
+ # NOTE: system-banner stacking order and the absence of a dismiss control are
+ # now rendering behaviour of the React component, covered by
+ # the frontend test suite (components/Announcements.test.tsx).
class TestSystemMaintenance:
@@ -532,7 +500,7 @@ class TestSystemMaintenance:
assert all(value is False for value in app.state.features.values())
def test_maintenance_nav_only_home(self, mock_http_client: MockHttpClient) -> None:
- """Desktop nav contains only Home (no feature links) in maintenance."""
+ """Config exposes all features off in maintenance, so the React nav shows only Home."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
@@ -542,10 +510,8 @@ class TestSystemMaintenance:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
- html = client.get("/dashboard").text
- assert 'href="/dashboard"' not in html
- assert 'href="/nodes"' not in html
- assert 'href="/messages"' not in html
+ config = get_app_config(client.get("/dashboard").text)
+ assert not any(config["features"].values())
def test_maintenance_flag_in_config_json(
self, mock_http_client: MockHttpClient
@@ -729,3 +695,40 @@ class TestBootstrapHeaderSanitization:
forwarded = mock_http_client.last_get_headers
assert forwarded is not None
assert forwarded["X-User-Name"] == "Matt"
+
+
+class TestSpaShellAndErrorFallback:
+ """Lock the shell-vs-error split.
+
+ The SPA shell (``spa.html``) is pure bootstrap served for every GET route;
+ React renders content (including 404s for unknown routes) client-side.
+ ``error.html`` is the minimal non-React fallback served only when the server
+ itself errors before React can boot.
+ """
+
+ def test_unknown_get_route_serves_spa_shell(self, client: TestClient) -> None:
+ """Unknown GET routes hit the catch-all → SPA shell (React renders 404)."""
+ response = client.get("/this-route-does-not-exist-anywhere")
+ assert response.status_code == 200
+ assert "window.__APP_CONFIG__" in response.text
+ assert 'id="app"' in response.text
+
+ def test_500_serves_error_html_fallback(
+ self, web_app: Any, mock_http_client: MockHttpClient
+ ) -> None:
+ """A server error serves the minimal error.html fallback, not the SPA.
+
+ Triggered by forcing the catch-all's config builder to raise; the generic
+ exception handler then renders ``error.html`` (route-order-independent —
+ a route added after the catch-all would be shadowed by ``/{path:path}``).
+ """
+ web_app.state.http_client = mock_http_client
+ client = TestClient(web_app, raise_server_exceptions=False)
+ with patch(
+ "meshcore_hub.web.app._build_config_json",
+ side_effect=RuntimeError("boom"),
+ ):
+ response = client.get("/")
+ assert response.status_code == 500
+ assert "Internal server error" in response.text
+ assert "Go Home" in response.text
diff --git a/tests/test_web/test_caching.py b/tests/test_web/test_caching.py
index 93f5892..7723709 100644
--- a/tests/test_web/test_caching.py
+++ b/tests/test_web/test_caching.py
@@ -18,18 +18,26 @@ class TestCacheControlHeaders:
)
def test_static_js_with_version(self, client):
- """Static JS with version parameter should have long-term cache."""
- response = client.get(f"/static/js/charts.js?v={__version__}")
- assert response.status_code == 200
+ """Static JS with version parameter should have long-term cache.
+
+ Only the header is asserted (not status): JS source is bundled into
+ static/dist/ and absent from host checkouts, and the middleware sets
+ headers on 404 responses too.
+ """
+ response = client.get(f"/static/js/app.js?v={__version__}")
assert "cache-control" in response.headers
assert (
response.headers["cache-control"] == "public, max-age=31536000, immutable"
)
def test_static_module_with_version(self, client):
- """Static ES module with version parameter should have long-term cache."""
- response = client.get(f"/static/js/spa/app.js?v={__version__}")
- assert response.status_code == 200
+ """Bundled ES modules in static/dist/ use content-hashed immutable cache.
+
+ Only the header is asserted (not status): static/dist/ is a build
+ artifact absent from host checkouts, and the middleware sets headers on
+ 404 responses too.
+ """
+ response = client.get("/static/dist/assets/app.js")
assert "cache-control" in response.headers
assert (
response.headers["cache-control"] == "public, max-age=31536000, immutable"
@@ -58,9 +66,11 @@ class TestCacheControlHeaders:
assert response.headers["cache-control"] == "public, max-age=3600"
def test_static_js_without_version(self, client):
- """Static JS without version should have short fallback cache."""
- response = client.get("/static/js/charts.js")
- assert response.status_code == 200
+ """Static JS without version should have short fallback cache.
+
+ Only the header is asserted (not status): see test_static_js_with_version.
+ """
+ response = client.get("/static/js/app.js")
assert "cache-control" in response.headers
assert response.headers["cache-control"] == "public, max-age=3600"
@@ -152,21 +162,12 @@ class TestVersionParameterInHTML:
assert css_link is not None
assert f"?v={__version__}" in css_link["href"]
- def test_charts_js_has_version(self, client):
- """Charts.js script should include version parameter."""
- response = client.get("/")
- assert response.status_code == 200
-
- soup = BeautifulSoup(response.text, "html.parser")
- charts_script = soup.find(
- "script", {"src": lambda x: x and "/static/js/charts.js" in x}
- )
-
- assert charts_script is not None
- assert f"?v={__version__}" in charts_script["src"]
-
def test_app_js_has_version(self, client):
- """SPA app.js script should include version or content hash."""
+ """SPA bundle script should be served content-hashed from static/dist/.
+
+ The bundle only exists after a frontend build; without one there is no
+ script tag, so the dist/ origin is only asserted when present.
+ """
response = client.get("/")
assert response.status_code == 200
@@ -175,15 +176,9 @@ class TestVersionParameterInHTML:
"script",
{"src": lambda x: x and "/static/dist/" in x and x.endswith(".js")},
)
- fallback_script = soup.find(
- "script", {"src": lambda x: x and "/static/js/spa/app.js" in x}
- )
if bundled_script:
assert "/static/dist/" in bundled_script["src"]
- else:
- assert fallback_script is not None
- assert f"?v={__version__}" in fallback_script["src"]
def test_cdn_resources_unchanged(self, client):
"""CDN resources should not have version parameters."""
diff --git a/tests/test_web/test_dashboard.py b/tests/test_web/test_dashboard.py
index 572155f..403a91f 100644
--- a/tests/test_web/test_dashboard.py
+++ b/tests/test_web/test_dashboard.py
@@ -25,27 +25,19 @@ class TestDashboardPage:
response = client.get("/dashboard")
assert "Test Network" in response.text
- def test_dashboard_displays_stats(
+ def test_dashboard_serves_spa_shell(
self, client: TestClient, mock_http_client: MockHttpClient
) -> None:
- """Test that dashboard page displays statistics."""
- response = client.get("/dashboard")
- # Check for stats from mock response
- assert response.status_code == 200
- # The mock returns total_nodes: 10, active_nodes: 5, etc.
- # These should be displayed in the page
- assert "10" in response.text # total_nodes
- assert "5" in response.text # active_nodes
+ """The dashboard route serves the SPA shell.
- def test_dashboard_displays_message_counts(
- self, client: TestClient, mock_http_client: MockHttpClient
- ) -> None:
- """Test that dashboard page displays message counts."""
+ Dashboard statistics are fetched and rendered client-side by React from
+ the API, so they are not present in the server-rendered shell; we assert
+ the mount point and embedded config instead.
+ """
response = client.get("/dashboard")
assert response.status_code == 200
- # Mock returns total_messages: 100, messages_today: 15
- assert "100" in response.text
- assert "15" in response.text
+ assert 'id="app"' in response.text
+ assert "window.__APP_CONFIG__" in response.text
class TestDashboardPageAPIErrors:
diff --git a/tests/test_web/test_features.py b/tests/test_web/test_features.py
index 9fc29bf..4e20bab 100644
--- a/tests/test_web/test_features.py
+++ b/tests/test_web/test_features.py
@@ -1,12 +1,14 @@
"""Tests for feature flags functionality."""
-import json
-
import pytest
from fastapi.testclient import TestClient
from meshcore_hub.web.app import create_app
-from tests.test_web.conftest import ALL_FEATURES_ENABLED, MockHttpClient
+from tests.test_web.conftest import (
+ ALL_FEATURES_ENABLED,
+ MockHttpClient,
+ get_app_config,
+)
class TestFeatureFlagsConfig:
@@ -16,11 +18,7 @@ class TestFeatureFlagsConfig:
"""All non-OIDC features should be enabled by default in config JSON."""
response = client.get("/")
assert response.status_code == 200
- html = response.text
- # Extract config JSON from script tag
- start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
- end = html.index(";", start)
- config = json.loads(html[start:end])
+ config = get_app_config(response.text)
features = config["features"]
non_oidc_features = {k: v for k, v in features.items() if k != "members"}
assert all(
@@ -30,10 +28,7 @@ class TestFeatureFlagsConfig:
def test_features_dict_has_all_keys(self, client: TestClient) -> None:
"""Features dict should have all 7 expected keys."""
response = client.get("/")
- html = response.text
- start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
- end = html.index(";", start)
- config = json.loads(html[start:end])
+ config = get_app_config(response.text)
features = config["features"]
expected_keys = {
"dashboard",
@@ -49,45 +44,41 @@ class TestFeatureFlagsConfig:
def test_disabled_features_in_config(self, client_no_features: TestClient) -> None:
"""Disabled features should be false in config JSON."""
response = client_no_features.get("/")
- html = response.text
- start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
- end = html.index(";", start)
- config = json.loads(html[start:end])
+ config = get_app_config(response.text)
features = config["features"]
assert all(not v for v in features.values()), "All features should be disabled"
class TestFeatureFlagsNav:
- """Test feature flags affect navigation."""
+ """Test feature flags affect navigation (via the SPA config the nav reads)."""
def test_enabled_features_show_nav_links(self, client: TestClient) -> None:
- """Enabled features should show nav links."""
- response = client.get("/")
- html = response.text
- assert 'href="/dashboard"' in html
- assert 'href="/nodes"' in html
- assert 'href="/advertisements"' in html
- assert 'href="/messages"' in html
- assert 'href="/map"' in html
+ """Enabled features should be true in config so the React nav shows them."""
+ config = get_app_config(client.get("/").text)
+ features = config["features"]
+ for key in ("dashboard", "nodes", "advertisements", "messages", "map"):
+ assert features[key] is True
def test_disabled_features_hide_nav_links(
self, client_no_features: TestClient
) -> None:
- """Disabled features should not show nav links."""
- response = client_no_features.get("/")
- html = response.text
- assert 'href="/dashboard"' not in html
- assert 'href="/nodes"' not in html
- assert 'href="/advertisements"' not in html
- assert 'href="/messages"' not in html
- assert 'href="/map"' not in html
- assert 'href="/members"' not in html
+ """Disabled features should be false in config so the React nav hides them."""
+ config = get_app_config(client_no_features.get("/").text)
+ features = config["features"]
+ for key in (
+ "dashboard",
+ "nodes",
+ "advertisements",
+ "messages",
+ "map",
+ "members",
+ ):
+ assert features[key] is False
def test_home_link_always_present(self, client_no_features: TestClient) -> None:
- """Home link should always be present."""
+ """The SPA mount (where the always-present Home nav renders) is in the shell."""
response = client_no_features.get("/")
- html = response.text
- assert 'href="/"' in html
+ assert 'id="app"' in response.text
class TestFeatureFlagsEndpoints:
@@ -118,11 +109,7 @@ class TestFeatureFlagsEndpoints:
self, client_no_features: TestClient
) -> None:
"""Custom pages should be empty in config when pages feature is disabled."""
- response = client_no_features.get("/")
- html = response.text
- start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
- end = html.index(";", start)
- config = json.loads(html[start:end])
+ config = get_app_config(client_no_features.get("/").text)
assert config["custom_pages"] == []
@@ -213,18 +200,18 @@ class TestPacketsFeatureFlag:
) -> None:
"""The packets nav link is absent when the feature is off."""
client = self._make_app(mock_http_client, packets=False)
- html = client.get("/").text
- assert 'href="/packets"' not in html
+ config = get_app_config(client.get("/").text)
+ assert config["features"]["packets"] is False
# Messages still shows (ordering sanity)
- assert 'href="/messages"' in html
+ assert config["features"]["messages"] is True
def test_packets_nav_shown_when_enabled(
self, mock_http_client: MockHttpClient
) -> None:
"""The packets nav link appears when the feature is on."""
client = self._make_app(mock_http_client, packets=True)
- html = client.get("/").text
- assert 'href="/packets"' in html
+ config = get_app_config(client.get("/").text)
+ assert config["features"]["packets"] is True
def test_packets_enabled_by_default_in_settings(self) -> None:
"""The declared default for feature_packets is True (env-independent)."""
@@ -266,11 +253,10 @@ class TestFeatureFlagsIndividual:
def test_disable_map_only(self, _make_client) -> None:
"""Disabling only map should hide map but show others."""
client = _make_client("map")
- response = client.get("/")
- html = response.text
- assert 'href="/map"' not in html
- assert 'href="/dashboard"' in html
- assert 'href="/nodes"' in html
+ config = get_app_config(client.get("/").text)
+ assert config["features"]["map"] is False
+ assert config["features"]["dashboard"] is True
+ assert config["features"]["nodes"] is True
# Map data endpoint should 404
response = client.get("/map/data")
@@ -279,11 +265,10 @@ class TestFeatureFlagsIndividual:
def test_disable_dashboard_only(self, _make_client) -> None:
"""Disabling only dashboard should hide dashboard but show others."""
client = _make_client("dashboard")
- response = client.get("/")
- html = response.text
- assert 'href="/dashboard"' not in html
- assert 'href="/nodes"' in html
- assert 'href="/map"' in html
+ config = get_app_config(client.get("/").text)
+ assert config["features"]["dashboard"] is False
+ assert config["features"]["nodes"] is True
+ assert config["features"]["map"] is True
class TestDashboardAutoDisable:
@@ -310,12 +295,7 @@ class TestDashboardAutoDisable:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
- response = client.get("/")
- html = response.text
- assert 'href="/dashboard"' not in html
-
- # Check config JSON also reflects it
- config = json.loads(html.split("window.__APP_CONFIG__ = ")[1].split(";")[0])
+ config = get_app_config(client.get("/").text)
assert config["features"]["dashboard"] is False
def test_map_auto_disabled_when_nodes_off(
@@ -339,12 +319,7 @@ class TestDashboardAutoDisable:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
- response = client.get("/")
- html = response.text
- assert 'href="/map"' not in html
-
- # Check config JSON also reflects it
- config = json.loads(html.split("window.__APP_CONFIG__ = ")[1].split(";")[0])
+ config = get_app_config(client.get("/").text)
assert config["features"]["map"] is False
# Map data endpoint should 404
@@ -372,5 +347,5 @@ class TestDashboardAutoDisable:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
- response = client.get("/")
- assert 'href="/dashboard"' in response.text
+ config = get_app_config(client.get("/").text)
+ assert config["features"]["dashboard"] is True
diff --git a/tests/test_web/test_home.py b/tests/test_web/test_home.py
index 676c319..a326535 100644
--- a/tests/test_web/test_home.py
+++ b/tests/test_web/test_home.py
@@ -1,9 +1,9 @@
"""Tests for the home page route (SPA)."""
-import json
-
from fastapi.testclient import TestClient
+from tests.test_web.conftest import get_app_config
+
class TestHomePage:
"""Tests for the home page."""
@@ -40,52 +40,18 @@ class TestHomePage:
def test_home_config_contains_network_info(self, client: TestClient) -> None:
"""Test that SPA config contains network information."""
- response = client.get("/")
- # Extract the config JSON from the HTML
- text = response.text
- config_start = text.find("window.__APP_CONFIG__ = ") + len(
- "window.__APP_CONFIG__ = "
- )
- config_end = text.find(";", config_start)
- config = json.loads(text[config_start:config_end])
-
+ config = get_app_config(client.get("/").text)
assert config["network_name"] == "Test Network"
assert config["network_city"] == "Test City"
assert config["network_country"] == "Test Country"
def test_home_config_contains_contact_info(self, client: TestClient) -> None:
- """Test that SPA config contains contact information."""
- response = client.get("/")
- text = response.text
- config_start = text.find("window.__APP_CONFIG__ = ") + len(
- "window.__APP_CONFIG__ = "
- )
- config_end = text.find(";", config_start)
- config = json.loads(text[config_start:config_end])
-
+ """Test that SPA config contains contact information for the React footer."""
+ config = get_app_config(client.get("/").text)
assert config["network_contact_email"] == "test@example.com"
assert config["network_contact_discord"] == "https://discord.gg/test"
- def test_home_contains_contact_email(self, client: TestClient) -> None:
- """Test that home page contains the contact email in footer."""
+ def test_home_contains_spa_mount(self, client: TestClient) -> None:
+ """Test that home page renders the React SPA mount point."""
response = client.get("/")
- assert "test@example.com" in response.text
-
- def test_home_contains_discord_link(self, client: TestClient) -> None:
- """Test that home page contains the Discord link in footer."""
- response = client.get("/")
- assert "discord.gg/test" in response.text
-
- def test_home_contains_navigation(self, client: TestClient) -> None:
- """Test that home page contains navigation links."""
- response = client.get("/")
- assert 'href="/"' in response.text
- assert 'href="/nodes"' in response.text
- assert 'href="/messages"' in response.text
-
- def test_home_contains_spa_app_script(self, client: TestClient) -> None:
- """Test that home page includes the SPA application script."""
- response = client.get("/")
- has_bundled = "/static/dist/" in response.text
- has_fallback = "/static/js/spa/app.js" in response.text
- assert has_bundled or has_fallback
+ assert 'id="app"' in response.text
diff --git a/tests/test_web/test_messages.py b/tests/test_web/test_messages.py
index 008f7f3..4352d68 100644
--- a/tests/test_web/test_messages.py
+++ b/tests/test_web/test_messages.py
@@ -28,12 +28,10 @@ class TestMessagesPage:
response = client.get("/messages")
assert "window.__APP_CONFIG__" in response.text
- def test_messages_contains_spa_script(self, client: TestClient) -> None:
- """Test that messages page includes SPA application script."""
+ def test_messages_contains_spa_mount(self, client: TestClient) -> None:
+ """Test that messages page renders the React SPA mount point."""
response = client.get("/messages")
- has_bundled = "/static/dist/" in response.text
- has_fallback = "/static/js/spa/app.js" in response.text
- assert has_bundled or has_fallback
+ assert 'id="app"' in response.text
class TestMessagesPageFilters:
diff --git a/tests/test_web/test_nodes.py b/tests/test_web/test_nodes.py
index 1577d23..ec8dd5f 100644
--- a/tests/test_web/test_nodes.py
+++ b/tests/test_web/test_nodes.py
@@ -28,12 +28,10 @@ class TestNodesListPage:
response = client.get("/nodes")
assert "window.__APP_CONFIG__" in response.text
- def test_nodes_contains_spa_script(self, client: TestClient) -> None:
- """Test that nodes page includes SPA application script."""
+ def test_nodes_contains_spa_mount(self, client: TestClient) -> None:
+ """Test that nodes page renders the React SPA mount point."""
response = client.get("/nodes")
- has_bundled = "/static/dist/" in response.text
- has_fallback = "/static/js/spa/app.js" in response.text
- assert has_bundled or has_fallback
+ assert 'id="app"' in response.text
def test_nodes_with_search_param(self, client: TestClient) -> None:
"""Test nodes page with search parameter returns SPA shell."""
diff --git a/tests/test_web/test_pages.py b/tests/test_web/test_pages.py
index 7ab265a..6e27aff 100644
--- a/tests/test_web/test_pages.py
+++ b/tests/test_web/test_pages.py
@@ -1,6 +1,5 @@
"""Tests for custom pages functionality (SPA)."""
-import json
import tempfile
from collections.abc import Generator
from pathlib import Path
@@ -10,6 +9,7 @@ import pytest
from fastapi.testclient import TestClient
from meshcore_hub.web.pages import CustomPage, PageLoader
+from tests.test_web.conftest import get_app_config
class TestCustomPage:
@@ -21,7 +21,7 @@ class TestCustomPage:
slug="about",
title="About Us",
menu_order=10,
- content_html="Content
",
+ content_markdown="# Content",
file_path="/pages/about.md",
)
assert page.url == "/pages/about"
@@ -32,7 +32,7 @@ class TestCustomPage:
slug="terms-of-service",
title="Terms of Service",
menu_order=50,
- content_html="Terms
",
+ content_markdown="# Terms",
file_path="/pages/terms-of-service.md",
)
assert page.url == "/pages/terms-of-service"
@@ -79,8 +79,9 @@ This is the about page.
assert pages[0].slug == "about"
assert pages[0].title == "About Us"
assert pages[0].menu_order == 10
- assert "About
" in pages[0].content_html
- assert "This is the about page.
" in pages[0].content_html
+ # Raw markdown body is preserved verbatim (rendered client-side)
+ assert "# About" in pages[0].content_markdown
+ assert "This is the about page." in pages[0].content_markdown
def test_load_pages_default_slug_from_filename(self) -> None:
"""Test that slug defaults to filename when not specified."""
@@ -272,8 +273,8 @@ New content.
assert len(pages) == 1
assert pages[0].slug == "page"
- def test_markdown_tables_rendered(self) -> None:
- """Test that markdown tables are rendered to HTML."""
+ def test_markdown_tables_preserved(self) -> None:
+ """Test that GFM table markdown is preserved verbatim for client rendering."""
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "tables.md").write_text("""---
title: Tables
@@ -289,11 +290,12 @@ title: Tables
pages = loader.get_menu_pages()
assert len(pages) == 1
- assert "" in pages[0].content_html
- assert "" in pages[0].content_html
+ md = pages[0].content_markdown
+ assert "| Header 1 | Header 2 |" in md
+ assert "| Cell 1" in md
- def test_markdown_fenced_code_rendered(self) -> None:
- """Test that fenced code blocks are rendered."""
+ def test_markdown_fenced_code_preserved(self) -> None:
+ """Test that fenced code blocks are preserved verbatim for client rendering."""
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "code.md").write_text("""---
title: Code
@@ -310,11 +312,12 @@ def hello():
pages = loader.get_menu_pages()
assert len(pages) == 1
- assert "" in pages[0].content_html
- assert "def hello():" in pages[0].content_html
+ md = pages[0].content_markdown
+ assert "```python" in md
+ assert "def hello():" in md
- def test_markdown_nested_unordered_list(self) -> None:
- """Test that nested unordered lists produce nested
elements."""
+ def test_markdown_nested_unordered_list_preserved(self) -> None:
+ """Test that nested unordered list markdown is preserved verbatim."""
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "nested.md").write_text("""---
title: Nested
@@ -332,17 +335,13 @@ title: Nested
pages = loader.get_menu_pages()
assert len(pages) == 1
- html = pages[0].content_html
- assert "" in html
- assert "- Item 1" in html
- assert "
- Sub item A" in html
- assert "
- Deep item" in html
- outer_ul = html.index("
")
- inner_ul = html.index("", outer_ul + 1)
- assert inner_ul > outer_ul
+ md = pages[0].content_markdown
+ assert "- Item 1" in md
+ assert "- Sub item A" in md
+ assert "- Deep item" in md
- def test_markdown_nested_ordered_list(self) -> None:
- """Test that nested ordered lists produce nested elements."""
+ def test_markdown_nested_ordered_list_preserved(self) -> None:
+ """Test that nested ordered list markdown is preserved verbatim."""
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "nested-ol.md").write_text("""---
title: Nested OL
@@ -359,13 +358,9 @@ title: Nested OL
pages = loader.get_menu_pages()
assert len(pages) == 1
- html = pages[0].content_html
- assert "" in html
- assert "- First" in html
- assert "
- Sub first" in html
- outer_ol = html.index("
")
- inner_ol = html.index("", outer_ol + 1)
- assert inner_ol > outer_ol
+ md = pages[0].content_markdown
+ assert "1. First" in md
+ assert "1. Sub first" in md
class TestPagesRoute:
@@ -464,8 +459,8 @@ Here are some answers.
data = response.json()
assert data["slug"] == "about"
assert data["title"] == "About Us"
- assert "About Our Network" in data["content_html"]
- assert "Welcome to the network" in data["content_html"]
+ assert "About Our Network" in data["content_markdown"]
+ assert "Welcome to the network" in data["content_markdown"]
def test_spa_page_api_not_found(self, client_with_pages: TestClient) -> None:
"""Test that /spa/pages/{slug} returns 404 for unknown page."""
@@ -481,35 +476,29 @@ Here are some answers.
data = response.json()
assert data["slug"] == "faq"
assert data["title"] == "FAQ"
- assert "Frequently Asked Questions" in data["content_html"]
+ assert "Frequently Asked Questions" in data["content_markdown"]
def test_pages_in_navigation(self, client_with_pages: TestClient) -> None:
- """Test that custom pages appear in navigation."""
+ """Test that custom pages are exposed for the React navigation."""
response = client_with_pages.get("/")
assert response.status_code == 200
- # Check for navigation links
- assert 'href="/pages/about"' in response.text
- assert 'href="/pages/faq"' in response.text
+ config = get_app_config(response.text)
+ urls = [p["url"] for p in config["custom_pages"]]
+ assert "/pages/about" in urls
+ assert "/pages/faq" in urls
def test_pages_sorted_in_navigation(self, client_with_pages: TestClient) -> None:
- """Test that pages are sorted by menu_order in navigation."""
+ """Test that pages are sorted by menu_order for the React navigation."""
response = client_with_pages.get("/")
assert response.status_code == 200
+ config = get_app_config(response.text)
+ urls = [p["url"] for p in config["custom_pages"]]
# About (order 10) should appear before FAQ (order 20)
- about_pos = response.text.find('href="/pages/about"')
- faq_pos = response.text.find('href="/pages/faq"')
- assert about_pos < faq_pos
+ assert urls.index("/pages/about") < urls.index("/pages/faq")
def test_pages_in_config(self, client_with_pages: TestClient) -> None:
"""Test that custom pages are included in SPA config."""
- response = client_with_pages.get("/")
- text = response.text
- config_start = text.find("window.__APP_CONFIG__ = ") + len(
- "window.__APP_CONFIG__ = "
- )
- config_end = text.find(";", config_start)
- config = json.loads(text[config_start:config_end])
-
+ config = get_app_config(client_with_pages.get("/").text)
custom_pages = config["custom_pages"]
assert len(custom_pages) == 2
slugs = [p["slug"] for p in custom_pages]
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..32770a1
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "allowJs": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/meshcore_hub/web/static/js/spa-react/*"]
+ }
+ },
+ "include": ["src/meshcore_hub/web/static/js/spa-react"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..974ad22
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,33 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import { resolve } from "node:path";
+
+const SPA_REACT = resolve(
+ __dirname,
+ "src/meshcore_hub/web/static/js/spa-react",
+);
+const DIST = resolve(__dirname, "src/meshcore_hub/web/static/dist");
+
+export default defineConfig({
+ base: "/static/dist/",
+ plugins: [react()],
+ resolve: {
+ alias: {
+ "@": SPA_REACT,
+ },
+ },
+ build: {
+ outDir: DIST,
+ emptyOutDir: true,
+ manifest: true,
+ rollupOptions: {
+ input: resolve(SPA_REACT, "index.html"),
+ output: {
+ manualChunks: {
+ vendor: ["react", "react-dom", "react-router"],
+ i18n: ["i18next", "react-i18next", "i18next-browser-languagedetector"],
+ },
+ },
+ },
+ },
+});
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..fa4b433
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from "vitest/config";
+import react from "@vitejs/plugin-react";
+import { resolve } from "node:path";
+
+const SPA_REACT = resolve(
+ __dirname,
+ "src/meshcore_hub/web/static/js/spa-react",
+);
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ "@": SPA_REACT,
+ },
+ },
+ test: {
+ environment: "jsdom",
+ include: ["src/meshcore_hub/web/static/js/spa-react/**/*.test.{ts,tsx}"],
+ setupFiles: ["src/meshcore_hub/web/static/js/spa-react/test/setup.ts"],
+ },
+});