Merge pull request #324 from ipnet-mesh/feat/react-frontend-phase1

feat(web): migrate SPA from lit-html to React 19 + TS + Vite
This commit is contained in:
JingleManSweep
2026-07-22 23:07:52 +01:00
committed by GitHub
227 changed files with 23590 additions and 9185 deletions
+34
View File
@@ -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
+6
View File
@@ -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/
+12
View File
@@ -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$)'
+73 -4
View File
@@ -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 `<div id="app">`. **Frontend tooling runs on the host**
(not in Docker): `npm install`, `npm run build` (Tailwind → vendor fonts → `vite build` →
`static/dist/` + `assets.json`), `npx tsc --noEmit` (the TS gate — also run by the
`frontend-typecheck` pre-commit hook; there is no JS *linter* in pre-commit), and
`npm run test:frontend` (vitest). The Vite build is required to serve the UI;
there is no fallback bundle.
```bash
npm install # host: install frontend deps
npm run build # host: produce static/dist/ + assets.json
npx tsc --noEmit # host: typecheck (must be clean)
npm run test:frontend # host: vitest unit + component tests
```
- Charts: **react-chartjs-2** — typed config builders in `utils/charts.ts`, wrappers in
`components/charts/Charts.tsx` (imports `chart.js/auto`).
- Maps: **react-leaflet** (`MapPage.tsx`, `NodeDetail.tsx`); both `import "leaflet/dist/leaflet.css"`.
That CSS ships in the Vite bundle, which `spa.html` loads in `<head>` **before** `app.css` so
the dark-mode map overrides win — don't reorder those `<link>`s.
- QR codes: **react-qr-code**.
- Navbar/shell: React (`components/Navbar.tsx`, `ThemeToggle.tsx`, `Announcements.tsx`,
`hooks/useNavItems.tsx`); nav uses react-router `NavLink` (client-side nav). Feature flags,
custom pages, and announcements all come from `window.__APP_CONFIG__`.
- Page conventions: `useSearchParams()` for filters/pagination/sort, typed `apiGet<T>()` with an
`AbortController` in `useEffect`, `usePageTitle('entities.x')`, shared components
(`Pagination`, `FilterForm`, `StatCard`, `NodeDisplay`, etc.).
- Tests: **vitest** + `@testing-library/react` (`*.test.ts(x)` next to code; setup in
`spa-react/test/`). Python web tests assert the embedded `__APP_CONFIG__`
(`tests/test_web/conftest.py::get_app_config`), not server-rendered nav HTML.
- Only **fonts** are vendored (`build.js` copies them); chart/map/QR libs are bundled by Vite.
## Tests & Quality
Coverage is **opt-in**; add `--cov=meshcore_hub` (or `make test-cov`) when you want it. The dev loop defaults to no coverage and parallel across CPU cores.
@@ -49,9 +85,10 @@ Coverage is **opt-in**; add `--cov=meshcore_hub` (or `make test-cov`) when you w
pytest -nauto --no-cov 2>&1 | grep -iE "passed|failed" | tail -3
# Makefile shorthands
make test # pytest -nauto --no-cov (parallel dev loop)
make test-cov # full run with coverage report
make test-unit # parallel, fast unit suites only (skips e2e)
make test # backend (pytest -nauto --no-cov) then frontend vitest
make test-cov # full backend run with coverage report
make test-unit # parallel, fast unit suites only (skips e2e)
make test-frontend # frontend vitest only (npm run test:frontend)
# Targeted by component (run only what you changed)
pytest --no-cov tests/test_web/ # templates, static JS, web routes
@@ -66,6 +103,38 @@ pytest --no-cov
pre-commit run --all-files
```
Browser E2E lives in **`e2e/`** (Playwright, headless Chromium) and replaces the
old Python e2e suite. It runs against a **throwaway stack** (`e2e/docker-compose.test.yml`)
with its own ephemeral Postgres and isolated volumes — it never touches the dev
database. Like the rest of the stack, **the assistant never builds/runs these
images**; the user does.
```bash
npx playwright install chromium # one-time browser binary (host)
make e2e-build && make e2e-up # user: build + start mqtt/pg/migrate/collector/api/web
make e2e-test # user: seeds via e2e/seed_data.py, then runs the suite
make e2e-down # user: tear down (destroys the throwaway DB)
npm run typecheck:e2e # assistant: typecheck the e2e TS (safe to run)
npx playwright test --config=e2e/playwright.config.ts --list # assistant: verify collection
```
Design notes when extending the suite:
- **Auth is forged, not logged in.** No mock IdP exists; the web tier fully trusts
the signed `meshcore-session` cookie. `e2e/mint_session.py` (itsdangerous, run
with `.venv` python) mints admin/member cookies using the stack's
`OIDC_SESSION_SECRET=test-session-secret`; global setup writes them to
`e2e/.auth/*.json` and specs opt in via `test.use({ storageState })`. OIDC is
enabled in the test stack (which also unlocks the Members feature).
- **Data is deterministic.** `e2e/seed_data.py` clears + recreates fixed rows
(nodes/observers with `area` tags, adverts, messages on channel idx 17 + the
"E2E General" custom channel, raw packets + path hops keyed to node prefixes,
a route + health, profiles + adoptions) using recent timestamps (7-day windows).
- **Single shared backend:** `workers: 1`, `fullyParallel: false`; routes/profile
specs are `describe.serial`. `WEB_AUTO_REFRESH_SECONDS=2` makes polling assertable.
- Selectors rely on purposeful `data-testid`s (theme/auto-refresh toggles, observer
area badges, path-hop badge + popover, route modal fields, nav/hero/member/list
rows) added to the React components.
## Database & Ops
The default backend is **SQLite** (zero-config, file at `${DATA_HOME}/collector/meshcore.db`). **PostgreSQL** is also supported via `DATABASE_BACKEND=postgres` — see `docs/database.md` for the full backend reference, production provisioning, and schema-per-instance setup. Migrations are backend-agnostic; the commands below work for both.
+1 -1
View File
@@ -11,7 +11,7 @@ WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY build.js ./
COPY build.js vite.config.ts tsconfig.json ./
COPY src/meshcore_hub/web/static/css/input.css ./src/meshcore_hub/web/static/css/input.css
COPY src/meshcore_hub/web/templates/ ./src/meshcore_hub/web/templates/
COPY src/meshcore_hub/web/static/js/ ./src/meshcore_hub/web/static/js/
+29 -1
View File
@@ -4,7 +4,8 @@ COMPOSE_FILES = -f docker-compose.yml -f docker-compose.dev.yml
VOLUMES = $(COMPOSE_PROJECT_NAME)_data $(COMPOSE_PROJECT_NAME)_mqtt_data \
$(COMPOSE_PROJECT_NAME)_observer_data
.PHONY: build up down logs backup restore test test-cov test-unit
.PHONY: build up down logs backup restore test test-cov test-unit test-frontend \
e2e-build e2e-up e2e-down e2e-seed e2e-test
build:
docker compose $(COMPOSE_FILES) --profile all build --no-cache
@@ -36,11 +37,38 @@ restore:
# --- Tests ---------------------------------------------------------------
# Coverage is opt-in (use test-cov). Dev loop runs in parallel across cores.
# `test` runs the backend suite then the frontend (vitest) suite.
test:
pytest -nauto --no-cov
$(MAKE) test-frontend
test-cov:
pytest --cov=meshcore_hub --cov-report=term-missing
test-unit:
pytest -nauto --no-cov tests/test_common/ tests/test_api/ tests/test_collector/ tests/test_web/
test-frontend:
npm run test:frontend
# --- E2E (Playwright) ---------------------------------------------------
# Self-contained throwaway stack (own ephemeral Postgres, isolated volumes).
# make e2e-build && make e2e-up # start the stack (build first time)
# make e2e-test # seeds data, then runs the Playwright suite
# make e2e-down # tears everything down (destroys the DB)
E2E_COMPOSE = docker compose -f e2e/docker-compose.test.yml
e2e-build:
$(E2E_COMPOSE) build
e2e-up:
$(E2E_COMPOSE) up -d
e2e-down:
$(E2E_COMPOSE) down -v --remove-orphans
e2e-seed:
$(E2E_COMPOSE) exec -T collector python /seed_data.py
e2e-test:
npm run test:e2e
+26 -5
View File
@@ -247,7 +247,7 @@ meshcore-hub api
meshcore-hub web
```
> **Note:** `npm run build` compiles Tailwind CSS and copies vendor libraries (lit-html, Leaflet, Chart.js, QRCode.js) into `src/meshcore_hub/web/static/vendor/`. This step is required before the web dashboard will render correctly. In Docker, this happens automatically during the build.
> **Note:** `npm run build` builds the React SPA via Vite (Tailwind CSS, vendor fonts, TypeScript/React bundle) into `src/meshcore_hub/web/static/dist/`. Chart.js, Leaflet, and QR libraries are bundled by Vite; only fonts are vendored. This step is required before the web dashboard will render correctly. In Docker, this happens automatically during the build.
### Running Tests
@@ -265,6 +265,27 @@ pytest tests/test_api/test_nodes.py
pytest -k "test_list"
```
### End-to-End Tests (Playwright)
Browser E2E tests live in `e2e/` and run against a self-contained throwaway
stack (its own ephemeral Postgres, isolated volumes — never the dev database):
```bash
npx playwright install chromium # one-time: browser binary
make e2e-build # build the images (first time / after changes)
make e2e-up # start mqtt + postgres + migrate + collector + api + web
make e2e-test # seeds deterministic data, then runs the suite
make e2e-down # tear down (destroys the throwaway database)
npm run typecheck:e2e # typecheck the e2e suite
```
The Playwright global setup seeds the database (via `e2e/seed_data.py` inside
the collector container), waits for the web service, and forges signed
`meshcore-session` cookies (admin + member) so authenticated/admin flows can be
tested without a real OIDC provider (`e2e/mint_session.py`).
### Code Quality
```bash
@@ -297,8 +318,8 @@ meshcore-hub/
│ ├── templates/ # Jinja2 templates (SPA shell)
│ └── static/
│ ├── css/ # Stylesheets (app.css, input.css, built tailwind.css)
│ ├── vendor/ # Vendored JS/CSS libraries (built by npm run build)
│ ├── js/spa/ # SPA frontend (ES modules, lit-html)
│ ├── vendor/ # Vendored fonts (copied by npm run build)
│ ├── js/spa-react/ # SPA frontend (React 19 + TypeScript + Vite)
│ └── locales/ # Translation files (en.json)
├── tests/ # Test suite
├── alembic/ # Database migrations
@@ -318,8 +339,8 @@ meshcore-hub/
│ └── images/ # Custom images (logo.svg/png/jpg/jpeg/webp replace default logo)
├── data/ # Runtime data directory (DATA_HOME, created at runtime)
├── Dockerfile # Docker build configuration (multi-stage: Node.js frontend + Python)
├── package.json # Frontend build dependencies (Tailwind, DaisyUI, lit-html, etc.)
├── build.js # Frontend build script (Tailwind CLI + vendor copy)
├── package.json # Frontend build dependencies (React 19, Vite, TypeScript, Tailwind, DaisyUI)
├── build.js # Frontend build script (Tailwind CLI + vendor fonts + Vite build + assets.json)
├── docker-compose.yml # Docker Compose base config
├── docker-compose.dev.yml # Development overrides (port mappings)
├── docker-compose.prod.yml # Production overrides (proxy network)
@@ -531,8 +531,17 @@ def upgrade() -> None:
# there are zero routes, so this is effectively a no-op; the loop is
# retained so a restore from a dev backup that DOES have routes
# backfills correctly.
#
# Runs inside a SAVEPOINT: the backfill imports the live ORM models,
# which can reference columns added by later migrations (e.g.
# routes.max_path_length). On Postgres a failed statement aborts the
# whole transaction, so without the savepoint the swallowed error would
# still kill the subsequent alembic_version stamp. Rolling back to the
# savepoint leaves the outer migration transaction healthy on both
# backends.
try:
_backfill_history()
with conn.begin_nested():
_backfill_history()
except Exception as e: # noqa: BLE001 — never abort the migration
print(f"[route health precompute] backfill skipped: {e}")
+21 -34
View File
@@ -35,16 +35,6 @@ execSync(
console.log("Copying vendor files...");
vendor("leaflet", ["dist/leaflet.css", "dist/leaflet.js", "dist/leaflet.js.map"], "leaflet");
mkdirSync(join(VENDOR, "leaflet", "images"), { recursive: true });
cpSync(
join("node_modules", "leaflet", "dist", "images"),
join(VENDOR, "leaflet", "images"),
{ recursive: true },
);
vendor("chart.js", ["dist/chart.umd.min.js"], "chart.js");
vendor("qrcodejs", ["qrcode.min.js"], "qrcodejs");
vendor(
"@fontsource-variable/ibm-plex-sans",
[
@@ -62,38 +52,35 @@ vendor(
"fonts",
);
console.log("Bundling SPA with esbuild...");
mkdirSync(DIST, { recursive: true });
console.log("Bundling SPA with Vite...");
execSync("npx vite build", { stdio: "inherit" });
const metafilePath = join(DIST, "meta.json");
execSync(
`npx esbuild ${join(STATIC, "js", "spa", "app.js")}` +
` --bundle --format=esm --splitting --minify` +
` --outdir=${DIST}` +
` --entry-names=[name].[hash]` +
` --chunk-names=chunks/[name].[hash]` +
` --metafile=${metafilePath}`,
{ stdio: "inherit" },
);
// Vite emits a copy of the input HTML preserving its path relative to the
// project root (dist/src/…/index.html). The Jinja2 template is the real
// HTML shell, so remove the artifact.
import { rmSync } from "node:fs";
const staleHtmlDir = join(DIST, "src");
if (existsSync(staleHtmlDir)) {
rmSync(staleHtmlDir, { recursive: true, force: true });
}
console.log("Generating assets manifest...");
const meta = JSON.parse(readFileSync(metafilePath, "utf-8"));
const viteManifestPath = join(DIST, ".vite", "manifest.json");
const assets = {};
for (const [outputPath, info] of Object.entries(meta.outputs)) {
if (!info.entryPoint) continue;
const entryName = info.entryPoint.split("/").pop().replace(/\.js$/, ".js");
const fileName = outputPath.split("/").pop();
assets[entryName] = fileName;
if (existsSync(viteManifestPath)) {
const viteManifest = JSON.parse(readFileSync(viteManifestPath, "utf-8"));
for (const [, info] of Object.entries(viteManifest)) {
if (!info.isEntry) continue;
assets["app.js"] = info.file;
if (info.css && info.css.length > 0) {
assets["app.css"] = info.css[0];
}
}
}
const vendorFiles = {
"leaflet.css": join(VENDOR, "leaflet", "leaflet.css"),
"leaflet.js": join(VENDOR, "leaflet", "leaflet.js"),
"chart.umd.min.js": join(VENDOR, "chart.js", "chart.umd.min.js"),
"qrcode.min.js": join(VENDOR, "qrcodejs", "qrcode.min.js"),
};
const vendorFiles = {};
const vendorHashes = {};
for (const [name, path] of Object.entries(vendorFiles)) {
+10 -7
View File
@@ -79,21 +79,24 @@ The markdown content is rendered as-is, so include your own `# Heading` if desir
### Supported Markdown Features
Pages are rendered with [Python-Markdown](https://python-markdown.github.io/) with the following extensions enabled:
Pages are shipped as raw markdown and rendered client-side by the React SPA
(`react-markdown` + `remark-gfm`). Raw HTML in the source is **escaped** (not
rendered) — this is a security choice; use markdown syntax instead of inline HTML.
| Feature | Syntax | Notes |
|---------|--------|-------|
| Headings | `# H1` through `### H3` | Rendered with `.prose` styling |
| Headings | `# H1` through `### H3` | Rendered with `.prose` styling; each heading gets an anchor `id` for deep-linking (e.g. `/pages/about#getting-started`) |
| Bold / Italic | `**bold**`, `*italic*` | Standard Markdown |
| Links | `[text](url)` | Relative paths supported |
| Unordered lists | `- item` or `* item` | Nested lists supported (3 levels) |
| Ordered lists | `1. item` | Nested lists supported (3 levels) |
| Tables | Pipe-delimited (`\| Header \|`) | Auto-generated `<thead>`/`<tbody>` |
| Fenced code blocks | ` ``` ` with optional language | Syntax highlighting via `codehilite` extension |
| Unordered lists | `- item` or `* item` | Nested lists supported |
| Ordered lists | `1. item` | Nested lists supported |
| Tables | Pipe-delimited (`\| Header \|`) | GFM tables (thead/tbody) |
| Fenced code blocks | ` ``` ` with optional language | Rendered as `<pre><code>` |
| Inline code | `` `code` `` | Styled with monospace font |
| Blockquotes | `> quote` | Left border styling |
| Images | `![alt](/media/image.png)` | 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
+1 -1
View File
@@ -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/`
+15
View File
@@ -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 `<head>` 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
+17
View File
@@ -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.
+247
View File
@@ -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
+157
View File
@@ -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<void> {
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<void> {
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<void> {
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<string> {
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<void> {
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<void> {
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"));
}
+53
View File
@@ -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 <secret> <sub> <name> <email> <roles_csv>
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>"
)
secret, sub, name, email, roles_csv = sys.argv[1:6]
print(mint(secret, sub, name, email, roles_csv))
if __name__ == "__main__":
main()
+27
View File
@@ -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,
},
});
+491
View File
@@ -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()
+91
View File
@@ -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);
});
});
+31
View File
@@ -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 <Markdown>.
// **Outage** must become a <strong> 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();
});
});
+55
View File
@@ -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 <a href="#getting-started">.
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);
});
});
+35
View File
@@ -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();
});
});
+79
View File
@@ -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");
});
});
+50
View File
@@ -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("/");
}
});
});
+43
View File
@@ -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}/);
});
});
+50
View File
@@ -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();
});
});
+69
View File
@@ -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);
});
});
+69
View File
@@ -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();
});
});
+91
View File
@@ -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);
});
});
+137
View File
@@ -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);
});
});
+47
View File
@@ -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",
);
});
});
+16
View File
@@ -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"]
}
+39
View File
@@ -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<void> {
await expect(page.getByTestId("list-row").first()).toBeVisible();
}
export async function openFilters(page: Page): Promise<void> {
const toggle = page.locator("#filter-toggle");
if (!(await toggle.isChecked())) {
await toggle.click();
}
}
export async function countApiCalls(
page: Page,
urlFragment: string,
durationMs: number,
): Promise<number> {
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;
}
+4663 -367
View File
File diff suppressed because it is too large Load Diff
+40 -4
View File
@@ -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
}
}
+6 -5
View File
@@ -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",
]
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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]:
+2 -2
View File
@@ -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
+22 -35
View File
@@ -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 <head>, 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,
},
)
+10 -13
View File
@@ -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
``<Markdown>`` 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),
)
+14
View File
@@ -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;
-617
View File
@@ -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<string>} 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 }
}
});
}
@@ -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<string, string> = { "/": 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 <Navigate to={`/nodes/${prefix}`} replace />;
}
function AppRoutes() {
const config = useAppConfig();
const features = config.features ?? {};
const maintenanceMode = config.system_maintenance === true;
useNavActiveState();
if (maintenanceMode) {
return (
<Routes>
<Route path="*" element={<Maintenance />} />
</Routes>
);
}
return (
<Routes>
<Route
path="/"
element={
<ErrorBoundary>
<HomePage />
</ErrorBoundary>
}
/>
{features.dashboard !== false && (
<Route
path="/dashboard"
element={
<ErrorBoundary>
<DashboardPage />
</ErrorBoundary>
}
/>
)}
{features.nodes !== false && (
<>
<Route
path="/nodes"
element={
<ErrorBoundary>
<Nodes />
</ErrorBoundary>
}
/>
<Route
path="/nodes/:publicKey"
element={
<ErrorBoundary>
<NodeDetailPage />
</ErrorBoundary>
}
/>
<Route path="/n/:prefix" element={<ShortLinkRedirect />} />
</>
)}
{features.channels !== false && (
<Route
path="/channels"
element={
<ErrorBoundary>
<Channels />
</ErrorBoundary>
}
/>
)}
{features.routes !== false && (
<Route
path="/routes"
element={
<ErrorBoundary>
<RoutesPage />
</ErrorBoundary>
}
/>
)}
{features.messages !== false && (
<Route
path="/messages"
element={
<ErrorBoundary>
<Messages />
</ErrorBoundary>
}
/>
)}
{features.advertisements !== false && (
<Route
path="/advertisements"
element={
<ErrorBoundary>
<Advertisements />
</ErrorBoundary>
}
/>
)}
{features.packets !== false && (
<>
<Route
path="/packets"
element={
<ErrorBoundary>
<Packets />
</ErrorBoundary>
}
/>
<Route
path="/packets/hash/:hash"
element={
<ErrorBoundary>
<PacketGroupDetail />
</ErrorBoundary>
}
/>
<Route
path="/packets/:id"
element={
<ErrorBoundary>
<PacketDetail />
</ErrorBoundary>
}
/>
</>
)}
{features.map !== false && (
<Route
path="/map"
element={
<ErrorBoundary>
<MapPage />
</ErrorBoundary>
}
/>
)}
{features.members !== false && (
<Route
path="/members"
element={
<ErrorBoundary>
<Members />
</ErrorBoundary>
}
/>
)}
{features.pages !== false && (
<Route
path="/pages/:slug"
element={
<ErrorBoundary>
<CustomPagePage />
</ErrorBoundary>
}
/>
)}
{config.oidc_enabled && (
<>
<Route
path="/profile"
element={
<ErrorBoundary>
<Profile />
</ErrorBoundary>
}
/>
<Route
path="/profile/:id"
element={
<ErrorBoundary>
<Profile />
</ErrorBoundary>
}
/>
</>
)}
<Route path="*" element={<NotFound />} />
</Routes>
);
}
function Shell() {
return (
<>
<Navbar />
<Announcements />
<main className="container mx-auto px-4 py-6 flex-1">
<AppRoutes />
</main>
<Footer />
</>
);
}
export function App() {
const [queryClient] = useState(createQueryClient);
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Shell />
</BrowserRouter>
</QueryClientProvider>
);
}
@@ -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(<Loading />);
expect(container.querySelector(".loading-spinner")).not.toBeNull();
});
it("ErrorAlert renders an error-toned alert with the message", () => {
render(<ErrorAlert message="Something broke" />);
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(<InfoAlert message="FYI" />);
expect(screen.getByRole("alert")).toHaveClass("alert-info");
rerender(<SuccessAlert message="Done" />);
expect(screen.getByRole("alert")).toHaveClass("alert-success");
});
it("WarningBadge renders a tooltip with the message", () => {
const { container } = render(<WarningBadge message="careful" />);
expect(container.querySelector(".badge-warning")).not.toBeNull();
expect(container.querySelector('[data-tip="careful"]')).not.toBeNull();
});
});
@@ -0,0 +1,47 @@
import { useTranslation } from "react-i18next";
import { IconError, IconInfo, IconSuccess, IconAlert } from "@/components/icons";
export function Loading() {
return (
<div className="flex justify-center py-12">
<span className="loading loading-spinner loading-lg"></span>
</div>
);
}
export function ErrorAlert({ message }: { message: string }) {
return (
<div role="alert" className="alert alert-error mb-4">
<IconError className="stroke-current shrink-0 h-6 w-6" />
<span>{message}</span>
</div>
);
}
export function InfoAlert({ message }: { message: string }) {
return (
<div role="alert" className="alert alert-info mb-4">
<IconInfo className="stroke-current shrink-0 h-6 w-6" />
<span>{message}</span>
</div>
);
}
export function SuccessAlert({ message }: { message: string }) {
return (
<div role="alert" className="alert alert-success mb-4">
<IconSuccess className="stroke-current shrink-0 h-6 w-6" />
<span>{message}</span>
</div>
);
}
export function WarningBadge({ message }: { message: string }) {
return (
<span className="tooltip tooltip-bottom" data-tip={message}>
<span className="badge badge-warning badge-sm">
<IconAlert className="h-4 w-4" />
</span>
</span>
);
}
@@ -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(
<AppConfigProvider config={config}>
<Announcements />
</AppConfigProvider>,
);
}
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();
});
});
@@ -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 && (
<div
id="system-banner"
className="alert alert-error rounded-none py-2 px-4 text-center text-sm"
>
<Markdown className="flash-banner-content">
{system}
</Markdown>
</div>
)}
{network && !dismissed && (
<div
id="flash-banner"
className="alert alert-warning rounded-none py-2 px-4 text-center text-sm"
>
<Markdown className="flash-banner-content">
{network}
</Markdown>
<button
aria-label="Dismiss"
onClick={dismiss}
className="btn btn-ghost btn-xs"
>
&times;
</button>
</div>
)}
</>
);
}
@@ -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<AppConfig> = {}) {
return render(
<AppConfigProvider config={makeConfig(config)}>
<AuthSection />
</AppConfigProvider>,
);
}
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();
});
});
@@ -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 (
<a href="/auth/login" className="btn btn-sm btn-outline">
{t("auth.login")}
</a>
);
}
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 (
<span key={r} className="badge badge-primary badge-xs">
{name}
</span>
);
});
return (
<div className="dropdown dropdown-end">
<div
tabIndex={0}
role="button"
data-testid="user-menu"
className="btn btn-ghost btn-circle btn-sm avatar"
>
{user.picture ? (
<img
src={user.picture}
alt={displayName}
className="w-8 h-8 rounded-full"
/>
) : (
<span className="text-sm font-bold">{initials}</span>
)}
</div>
<ul
tabIndex={0}
className="dropdown-content menu z-50 p-2 shadow-sm bg-base-100 rounded-box w-56 mt-3"
>
<li className="menu-title">
<div className="flex flex-col gap-1">
<span className="font-medium">{displayName}</span>
{config.debug && user.sub && (
<span className="text-xs opacity-40 font-mono">{user.sub}</span>
)}
{roleBadges.length > 0 && (
<div className="flex flex-wrap gap-1">{roleBadges}</div>
)}
</div>
</li>
<hr className="my-1 opacity-20" />
<li>
<a href="/profile" data-testid="user-menu-profile">
<IconUser className="h-5 w-5" /> {t("links.profile")}
</a>
</li>
<li>
<a href="/auth/logout" data-testid="user-menu-logout">
<IconLogout className="h-5 w-5" /> {t("auth.logout")}
</a>
</li>
</ul>
</div>
);
}
@@ -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(
<AutoRefreshToggle
paused={false}
onToggle={() => {}}
intervalSeconds={0}
/>,
);
expect(container.firstChild).toBeNull();
});
it("shows the interval and a checked toggle while running", () => {
const onToggle = vi.fn();
render(
<AutoRefreshToggle
paused={false}
onToggle={onToggle}
intervalSeconds={30}
/>,
);
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(
<AutoRefreshToggle paused onToggle={() => {}} intervalSeconds={30} />,
);
expect(screen.getByRole("checkbox")).not.toBeChecked();
});
});
@@ -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 (
<label
className="label cursor-pointer gap-2"
title={paused ? t("auto_refresh.resume") : t("auto_refresh.pause")}
>
<span className="text-sm opacity-80 flex items-center gap-1">
<IconRefresh className="w-4 h-4" />
<span className="text-xs">{intervalSeconds}s</span>
</span>
<input
type="checkbox"
className="toggle toggle-sm toggle-primary"
data-testid="auto-refresh-toggle"
checked={!paused}
onChange={onToggle}
/>
</label>
);
}
@@ -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(<CountBadge>42 things</CountBadge>);
const el = screen.getByText("42 things");
expect(el).toHaveClass("badge");
expect(el).toHaveClass("badge-lg");
});
it("RoleBadge renders a primary small badge", () => {
render(<RoleBadge role="operator" />);
const el = screen.getByText("operator");
expect(el).toHaveClass("badge-primary");
expect(el).toHaveClass("badge-sm");
});
it("CallsignBadge renders a neutral small badge", () => {
render(<CallsignBadge callsign="AB1CDE" />);
const el = screen.getByText("AB1CDE");
expect(el).toHaveClass("badge-neutral");
expect(el).toHaveClass("badge-sm");
});
});
@@ -0,0 +1,13 @@
import type { ReactNode } from "react";
export function CountBadge({ children }: { children: ReactNode }) {
return <span className="badge badge-lg">{children}</span>;
}
export function RoleBadge({ role }: { role: string }) {
return <span className="badge badge-primary badge-sm">{role}</span>;
}
export function CallsignBadge({ callsign }: { callsign: string }) {
return <span className="badge badge-neutral badge-sm">{callsign}</span>;
}
@@ -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(
<MemoryRouter>
<Breadcrumbs items={items} />
</MemoryRouter>,
);
}
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();
});
});
@@ -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 (
<nav aria-label="Breadcrumb">
<div className="breadcrumbs text-sm mb-4">
<ul>
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li key={index} aria-current={isLast ? "page" : undefined}>
{item.to && !isLast ? (
<Link to={item.to}>{item.label}</Link>
) : (
item.label
)}
</li>
);
})}
</ul>
</div>
</nav>
);
}
@@ -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<Parameters<typeof ConfirmDialog>[0]> = {}) {
const onConfirm = vi.fn();
const onCancel = vi.fn();
render(
<ConfirmDialog
title="Delete thing"
message="Are you sure?"
confirmLabel="Delete"
cancelLabel="Cancel"
onConfirm={onConfirm}
onCancel={onCancel}
{...props}
/>,
);
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(
<ConfirmDialog
title="t"
message="m"
confirmLabel="Go"
cancelLabel="No"
onConfirm={() => {}}
onCancel={() => {}}
/>,
);
expect(screen.getByRole("button", { name: "Go" })).toHaveClass(
"btn-error",
);
rerender(
<ConfirmDialog
title="t"
message="m"
confirmLabel="Go"
cancelLabel="No"
tone="primary"
onConfirm={() => {}}
onCancel={() => {}}
/>,
);
expect(screen.getByRole("button", { name: "Go" })).toHaveClass(
"btn-primary",
);
});
it("disables both buttons and shows a spinner while saving", () => {
const { container } = render(
<ConfirmDialog
title="t"
message="m"
confirmLabel="Delete"
cancelLabel="Cancel"
saving
onConfirm={() => {}}
onCancel={() => {}}
/>,
);
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
expect(container.querySelector(".loading-spinner")).not.toBeNull();
});
});
@@ -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 (
<Modal
title={title}
onClose={onCancel}
footer={
<>
<button
type="button"
className="btn btn-ghost"
onClick={onCancel}
disabled={saving}
>
{cancelLabel}
</button>
<button
type="button"
className={tone === "error" ? "btn btn-error" : "btn btn-primary"}
onClick={onConfirm}
disabled={saving}
>
{saving && (
<span className="loading loading-spinner loading-sm" />
)}
{confirmLabel}
</button>
</>
}
>
{message}
</Modal>
);
}
@@ -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(<CopyableValue value="abc123" />);
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(<CopyableValue value="deadbeef" variant="block" />);
const el = screen.getByText("deadbeef");
expect(el).toHaveClass("block");
expect(el).toHaveClass("break-all");
expect(el).not.toHaveClass("font-mono");
});
});
@@ -0,0 +1,23 @@
import { copyToClipboard } from "@/utils/clipboard";
export function CopyableValue({
value,
variant = "inline",
}: {
value: string;
variant?: "inline" | "block";
}) {
return (
<code
className={
variant === "block"
? "text-sm bg-base-200 p-2 rounded block break-all cursor-pointer hover:bg-base-300 select-all"
: "font-mono text-xs cursor-pointer hover:bg-base-200 px-1 py-0.5 rounded select-all"
}
onClick={(e) => copyToClipboard(e, value)}
title="Click to copy"
>
{value}
</code>
);
}
@@ -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(<DefinitionField label="Channel">5 (chan)</DefinitionField>);
expect(screen.getByText("Channel")).toBeInTheDocument();
expect(screen.getByText("5 (chan)")).toBeInTheDocument();
});
});
describe("DefinitionGrid", () => {
it("uses the default two-column grid classes", () => {
const { container } = render(
<DefinitionGrid>
<span>x</span>
</DefinitionGrid>,
);
expect(container.firstChild).toHaveClass("grid");
expect(container.firstChild).toHaveClass("md:grid-cols-2");
});
it("allows a custom className override", () => {
const { container } = render(
<DefinitionGrid className="grid grid-cols-3">
<span>x</span>
</DefinitionGrid>,
);
expect(container.firstChild).toHaveClass("grid-cols-3");
expect(container.firstChild).not.toHaveClass("md:grid-cols-2");
});
});
@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
export function DefinitionField({
label,
children,
}: {
label: ReactNode;
children: ReactNode;
}) {
return (
<div className="flex flex-col gap-0.5 py-2 border-b border-base-200">
<span className="text-xs uppercase opacity-60">{label}</span>
<span className="text-sm">{children}</span>
</div>
);
}
export function DefinitionGrid({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return (
<div className={className ?? "grid grid-cols-1 md:grid-cols-2 gap-x-8"}>
{children}
</div>
);
}
@@ -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(<EmptyState>No nodes found</EmptyState>);
expect(screen.getByText("No nodes found")).toBeInTheDocument();
});
});
describe("EmptyRow", () => {
it("renders a table cell spanning the given columns", () => {
const { container } = render(
<table>
<tbody>
<EmptyRow colSpan={5}>Nothing here</EmptyRow>
</tbody>
</table>,
);
const td = container.querySelector("td");
expect(td).not.toBeNull();
expect(td!.getAttribute("colspan")).toBe("5");
expect(screen.getByText("Nothing here")).toBeInTheDocument();
});
});
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
export function EmptyState({ children }: { children: ReactNode }) {
return <div className="text-center py-8 opacity-70">{children}</div>;
}
export function EmptyRow({
colSpan,
children,
}: {
colSpan: number;
children: ReactNode;
}) {
return (
<tr>
<td colSpan={colSpan} className="text-center py-8 opacity-70">
{children}
</td>
</tr>
);
}
@@ -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(
<ErrorBoundary>
<p>all good</p>
</ErrorBoundary>,
);
expect(screen.getByText("all good")).toBeInTheDocument();
});
it("renders the fallback UI when a child throws", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
render(
<ErrorBoundary>
<Thrower message="kaboom" />
</ErrorBoundary>,
);
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(
<ErrorBoundary>
<Thrower message="logged" />
</ErrorBoundary>,
);
expect(spy).toHaveBeenCalledWith(
"React ErrorBoundary caught:",
expect.any(Error),
expect.objectContaining({ componentStack: expect.any(String) }),
);
spy.mockRestore();
});
});
@@ -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<Props, State> {
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 (
<div className="flex flex-col items-center justify-center py-20">
<h1 className="text-4xl font-bold mb-4">
{window.t("common.error")}
</h1>
<p className="text-lg opacity-70 mb-6">
{window.t("common.failed_to_load_page")}
</p>
<p className="text-sm opacity-50 mb-6">
{this.state.error?.message ?? "Unknown error"}
</p>
<a href="/" className="btn btn-primary">
{window.t("common.go_home")}
</a>
</div>
);
}
return this.props.children;
}
}
@@ -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(<OperatorSelect profiles={profiles} defaultValue="" />);
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(
<OperatorSelect profiles={profiles} value="1" onChange={onChange} />,
);
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(
<FilterField label="Search">
<input data-testid="control" />
</FilterField>,
);
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(
<form>
<input data-testid="inp" onKeyDown={submitOnEnter} />
</form>,
);
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(
<form>
<input data-testid="inp" onKeyDown={submitOnEnter} />
</form>,
);
fireEvent.keyDown(screen.getByTestId("inp"), { key: "a" });
expect(requestSubmit).not.toHaveBeenCalled();
requestSubmit.mockRestore();
});
});
describe("FilterForm clear navigation", () => {
function LocationProbe() {
const location = useLocation();
return (
<div data-testid="loc">{location.pathname + location.search}</div>
);
}
it("clears filters via client-side navigation (no full reload)", () => {
render(
<MemoryRouter initialEntries={["/nodes?search=foo"]}>
<FilterForm basePath="/nodes">
<input name="search" defaultValue="foo" />
</FilterForm>
<LocationProbe />
</MemoryRouter>,
);
expect(screen.getByTestId("loc").textContent).toBe("/nodes?search=foo");
fireEvent.click(screen.getByText("common.clear"));
expect(screen.getByTestId("loc").textContent).toBe("/nodes");
});
});
@@ -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<HTMLFormElement>) => {
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 (
<form
method="GET"
action={basePath}
className="flex flex-col gap-4"
onSubmit={handleSubmit}
>
<div className="flex gap-4 flex-wrap items-start">{children}</div>
<div className="flex gap-2">
<button type="submit" className="btn btn-primary btn-sm">
{submitLabel || t("common.filter")}
</button>
<Link to={basePath} className="btn btn-ghost btn-sm">
{clearLabel || t("common.clear")}
</Link>
</div>
</form>
);
}
interface FilterToggleProps {
open: boolean;
onChange: () => void;
}
export function FilterToggle({ open, onChange }: FilterToggleProps) {
const { t } = useTranslation();
return (
<label className="label cursor-pointer gap-2" title={t("common.filters")}>
<span className="text-sm opacity-80 flex items-center gap-1">
<IconFilter className="w-4 h-4" /> {t("common.filters")}
</span>
<input
type="checkbox"
id="filter-toggle"
className="toggle toggle-sm toggle-primary"
checked={open}
onChange={onChange}
/>
</label>
);
}
export function autoSubmit(
e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>,
) {
e.currentTarget.form?.requestSubmit();
}
export function submitOnEnter(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") e.currentTarget.form?.requestSubmit();
}
export function FilterField({
label,
children,
className,
}: {
label: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={`flex flex-col gap-1 ${className ?? ""}`.trim()}>
<label className="flex items-center py-1">
<span className="opacity-80 text-sm">{label}</span>
</label>
{children}
</div>
);
}
interface FilterSelectOption {
value: string;
label: string;
}
interface FilterSelectProps {
name: string;
options: FilterSelectOption[];
defaultValue?: string;
onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
className?: string;
}
export function FilterSelect({
name,
options,
defaultValue,
onChange,
className,
}: FilterSelectProps) {
return (
<select
name={name}
className={`select select-sm ${className ?? ""}`.trim()}
defaultValue={defaultValue}
onChange={onChange}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
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<HTMLSelectElement>) => void;
className?: string;
}
export function OperatorSelect({
name,
profiles,
value,
defaultValue,
onChange,
className,
}: OperatorSelectProps) {
const { t } = useTranslation();
const controlled = value !== undefined;
return (
<select
name={name}
className={`select select-sm ${className ?? ""}`.trim()}
{...(controlled ? { value } : { defaultValue })}
onChange={onChange}
>
<option value="">{t("common.all_operators")}</option>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.callsign
? `${p.name} (${p.callsign})`
: p.name || p.callsign || p.user_id || p.id}
</option>
))}
</select>
);
}
@@ -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(
<AppConfigProvider config={config}>
<Footer />
</AppConfigProvider>,
);
}
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",
);
});
});
@@ -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 (
<footer className="footer p-4 bg-base-100 text-base-content mt-auto">
<div className="flex flex-col items-center gap-1 order-2 lg:order-1">
<a
href="https://meshcore.io/"
target="_blank"
rel="noopener noreferrer"
className="hover:opacity-80 transition-opacity flex mb-1"
>
<img
src="/static/img/meshcore.svg"
alt="MeshCore"
className="theme-logo theme-logo--invert-light h-4"
/>
</a>
<span className="text-xs opacity-50">{t("footer.tagline")}</span>
<p className="text-sm opacity-70">
<a
href="https://meshcore.io/"
target="_blank"
rel="noopener noreferrer"
className="link link-hover"
>
{t("links.website")}
</a>
<span> | </span>
<a
href="https://github.com/meshcore-dev/MeshCore"
target="_blank"
rel="noopener noreferrer"
className="link link-hover"
>
{t("links.github")}
</a>
</p>
</div>
<div className="flex flex-col items-center gap-1 order-1 lg:order-2">
<p>
{networkName}
{hasLocale ? ` | ${config.network_city}, ${config.network_country}` : ""}
</p>
<p className="text-xs opacity-50">
{t("footer.powered_by")}{" "}
<a
href="https://github.com/ipnet-mesh/meshcore-hub"
target="_blank"
rel="noopener noreferrer"
className="link link-hover"
>
MeshCore Hub
</a>{" "}
{config.version}
</p>
<p className="text-sm opacity-70">
{config.network_contact_email && (
<a href={`mailto:${config.network_contact_email}`} className="link link-hover">
{config.network_contact_email}
</a>
)}
{config.network_contact_email && config.network_contact_discord && " | "}
{config.network_contact_discord && (
<a
href={config.network_contact_discord}
target="_blank"
rel="noopener noreferrer"
className="link link-hover"
>
{t("links.discord")}
</a>
)}
{(config.network_contact_email || config.network_contact_discord) &&
config.network_contact_github &&
" | "}
{config.network_contact_github && (
<a
href={config.network_contact_github}
target="_blank"
rel="noopener noreferrer"
className="link link-hover"
>
{t("links.github")}
</a>
)}
{(config.network_contact_email ||
config.network_contact_discord ||
config.network_contact_github) &&
config.network_contact_youtube &&
" | "}
{config.network_contact_youtube && (
<a
href={config.network_contact_youtube}
target="_blank"
rel="noopener noreferrer"
className="link link-hover"
>
{t("links.youtube")}
</a>
)}
</p>
</div>
</footer>
);
}
@@ -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(<JsonTree value="hello" />);
expect(container.querySelector(".text-success")).not.toBeNull();
expect(container.textContent).toContain('"hello"');
});
it("renders numbers with the warning color", () => {
const { container } = render(<JsonTree value={42} />);
expect(container.querySelector(".text-warning")).not.toBeNull();
expect(container.textContent).toContain("42");
});
it("renders booleans with the info color", () => {
const { container } = render(<JsonTree value={true} />);
expect(container.querySelector(".text-info")).not.toBeNull();
});
it("renders null italicized", () => {
const { container } = render(<JsonTree value={null} />);
expect(container.querySelector(".italic")).not.toBeNull();
expect(container.textContent).toContain("null");
});
});
describe("JsonTree containers", () => {
it("renders empty objects and arrays inline", () => {
const { container } = render(<JsonTree value={{ a: {}, b: [] }} openDepth={2} />);
expect(container.textContent).toContain("{}");
expect(container.textContent).toContain("[]");
});
it("toggles a node via the caret button", () => {
const { container } = render(
<JsonTree value={{ nested: { inner: 1 } }} openDepth={2} />,
);
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(
<JsonTree value={{ a: { b: { c: 1 } } }} openDepth={0} />,
);
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(<JsonTree value={{ a: 1 }} openDepth={1} />);
expect(container.querySelector(".json-children")).not.toHaveClass("hidden");
});
});
@@ -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 <span className="text-primary/50">{k}:</span>;
}
return <span className="text-primary/70">"{k}":</span>;
}
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 (
<div className="flex gap-2 py-0.5">
<KeyLabel k={k} />
<span className={primitiveClass(value)}>
{formatPrimitive(value)}
</span>
</div>
);
}
const isArray = Array.isArray(value);
const entries: [string | number, unknown][] = isArray
? (value as unknown[]).map((v, i) => [i, v])
: Object.entries(value as Record<string, unknown>);
const open = isArray ? "[" : "{";
const close = isArray ? "]" : "}";
if (entries.length === 0) {
return (
<div className="flex gap-2 py-0.5">
<KeyLabel k={k} />
<span className="opacity-60">
{open}
{close}
</span>
</div>
);
}
return (
<div className="json-node">
<button
type="button"
className="json-toggle inline-flex items-center gap-1 hover:opacity-70"
onClick={() => setExpanded(!isExpanded)}
>
<span
className={`json-caret inline-block transition-transform ${isExpanded ? "rotate-90" : ""}`}
>
<IconChevronRight className="h-3 w-3" />
</span>
<KeyLabel k={k} />
<span className="opacity-50 text-[10px]">
{open}
{entries.length}
{close}
</span>
</button>
<div
className={`json-children ml-2 border-l border-base-200 pl-2 ${isExpanded ? "" : "hidden"}`}
>
{entries.map(([ek, ev]) => (
<JsonNode
key={ek}
value={ev}
k={ek}
depth={depth + 1}
openDepth={openDepth}
expandSignal={expandSignal}
/>
))}
</div>
</div>
);
}
export function JsonTree({
value,
openDepth = 1,
}: {
value: unknown;
openDepth?: number;
}) {
const { t } = useTranslation();
const [expandSignal, setExpandSignal] = useState<boolean | null>(null);
const expandAll = useCallback(() => setExpandSignal(true), []);
const collapseAll = useCallback(() => setExpandSignal(false), []);
return (
<div className="json-tree-root font-mono text-xs">
<div className="flex items-center gap-2 mb-2">
<button type="button" className="btn btn-xs btn-ghost" onClick={expandAll}>
{t("packets.expand_all")}
</button>
<button
type="button"
className="btn btn-xs btn-ghost"
onClick={collapseAll}
>
{t("packets.collapse_all")}
</button>
</div>
<div className="overflow-x-auto">
<JsonNode
value={value}
k={null}
depth={0}
openDepth={openDepth}
expandSignal={expandSignal}
/>
</div>
</div>
);
}
@@ -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(<ListToolbar total={42} autoRefresh={autoRefresh} />);
expect(screen.getByText("common.total")).toBeInTheDocument();
});
it("hides the total badge when total is null", () => {
render(<ListToolbar total={null} autoRefresh={autoRefresh} />);
expect(screen.queryByText("common.total")).not.toBeInTheDocument();
});
it("renders a warning badge only when there is an error", () => {
const { container, rerender } = render(
<ListToolbar total={null} autoRefresh={autoRefresh} />,
);
expect(container.querySelector(".badge-warning")).toBeNull();
rerender(
<ListToolbar total={null} error="boom" autoRefresh={autoRefresh} />,
);
expect(container.querySelector(".badge-warning")).not.toBeNull();
});
it("renders the auto-refresh toggle when interval is positive", () => {
const { container } = render(
<ListToolbar total={null} autoRefresh={autoRefresh} />,
);
expect(container.querySelector('input[type="checkbox"]')).not.toBeNull();
});
it("omits the auto-refresh toggle when interval is not positive", () => {
const { container } = render(
<ListToolbar
total={null}
autoRefresh={{ ...autoRefresh, intervalSeconds: 0 }}
/>,
);
expect(container.querySelector('input[type="checkbox"]')).toBeNull();
});
it("renders the filter toggle only when provided", () => {
const { container, rerender } = render(
<ListToolbar total={null} autoRefresh={autoRefresh} />,
);
expect(container.querySelector("#filter-toggle")).toBeNull();
rerender(
<ListToolbar
total={null}
autoRefresh={autoRefresh}
filterToggle={{ open: false, onChange: () => {} }}
/>,
);
expect(container.querySelector("#filter-toggle")).not.toBeNull();
});
});
@@ -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 (
<div className="flex items-center gap-2 mb-4">
{total !== null && (
<CountBadge>{t("common.total", { count: formatNumber(total) })}</CountBadge>
)}
{error && <WarningBadge message={error} />}
<div className="ml-auto flex items-center gap-3">
<AutoRefreshToggle
paused={autoRefresh.paused}
onToggle={autoRefresh.onToggle}
intervalSeconds={autoRefresh.intervalSeconds}
/>
{filterToggle && <FilterToggle {...filterToggle} />}
</div>
</div>
);
}
@@ -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(<Markdown>{"**bold** and *italic*"}</Markdown>);
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(<Markdown>{md}</Markdown>);
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(<Markdown>{md}</Markdown>);
expect(container.querySelector("pre")).not.toBeNull();
expect(container.querySelector("pre code")?.textContent).toContain("def hello():");
});
it("renders links", () => {
const { container } = render(
<Markdown>{"[click](https://example.com)"}</Markdown>,
);
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(<Markdown>{md}</Markdown>);
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(<Markdown>{md}</Markdown>);
const link = container.querySelector("h1 a");
expect(link).toHaveAttribute("href", "#getting-started");
});
it("escapes raw HTML (no rehype-raw) for safety", () => {
const { container } = render(<Markdown>{"<b>bold</b>"}</Markdown>);
// Raw <b> is escaped, not rendered as an element
expect(container.querySelector("b")).toBeNull();
expect(container.textContent).toContain("<b>bold</b>");
});
it("renders external links with safe target and rel attributes", () => {
const { container } = render(
<Markdown>{"[click](https://example.com)"}</Markdown>,
);
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(<Markdown>{md}</Markdown>);
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(
<Markdown>{"[about](/pages/about)"}</Markdown>,
);
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(
<Markdown className="flash-banner-content">{"text"}</Markdown>,
);
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(<Markdown>{md}</Markdown>);
expect(container.querySelector('input[type="checkbox"]')).not.toBeNull();
expect(container.querySelector("del")?.textContent).toBe("old");
});
});
@@ -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 (
<div className={className}>
<MarkdownReact
remarkPlugins={[remarkGfm]}
rehypePlugins={[
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: "wrap" }],
]}
components={{
a({ node: _node, href, children, ...rest }) {
const isExternal = /^https?:\/\//i.test(href ?? "");
return (
<a
href={href}
{...(isExternal
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
{...rest}
>
{children}
</a>
);
},
}}
>
{children}
</MarkdownReact>
</div>
);
});
@@ -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(<MeshQrCode value="meshcore://test" />);
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(
<MeshQrCode value="x" className="bg-white p-2 rounded-box shadow-lg" />,
);
expect(container.firstChild).toHaveClass("shadow-lg");
});
});
@@ -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 (
<div className={className}>
<QRCode
value={value}
size={size}
level={level}
fgColor="#000000"
bgColor="#ffffff"
/>
</div>
);
}
@@ -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(<MobileNav />, { 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");
});
});
@@ -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) => (
<li key={item.href}>
<NavLink
to={item.href}
end={item.end}
data-testid="nav-link"
data-nav-href={item.href}
className={({ isActive }) => (isActive ? "active" : undefined)}
>
{item.icon} {item.label}
</NavLink>
</li>
))}
</>
);
}
@@ -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(
<Modal title="My Title" onClose={() => {}} footer={<span>foot</span>}>
<p>body content</p>
</Modal>,
);
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(
<Modal title="t" onClose={() => {}}>
<p>body</p>
</Modal>,
);
expect(container.querySelector(".modal-action")).toBeNull();
});
it("applies the large size class", () => {
const { container } = render(
<Modal title="t" size="lg" onClose={() => {}}>
<p>body</p>
</Modal>,
);
expect(container.querySelector(".modal-box-lg")).not.toBeNull();
});
it("calls onClose when the backdrop button is clicked", () => {
const onClose = vi.fn();
render(
<Modal title="t" onClose={onClose}>
<p>body</p>
</Modal>,
);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(onClose).toHaveBeenCalledOnce();
});
});
@@ -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 (
<dialog open className="modal modal-open">
<div
className={size === "lg" ? "modal-box modal-box-lg" : "modal-box"}
>
<h3 className="font-bold text-lg mb-4">{title}</h3>
{children}
{footer && <div className="modal-action">{footer}</div>}
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={onClose} aria-label="Close" />
</form>
</dialog>
);
}
@@ -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(
<AppConfigProvider config={config}>
<MemoryRouter>
<Navbar />
</MemoryRouter>
</AppConfigProvider>,
);
}
// 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);
});
});
@@ -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 (
<div className="navbar bg-base-100 shadow-lg">
<div className="navbar-start">
<NavLink to="/" end className="btn btn-ghost text-xl">
<img src={config.logo_url} alt={config.network_name} className={logoClass} />
{config.network_name}
</NavLink>
</div>
<div className="navbar-center hidden lg:flex">
<ul className="menu menu-horizontal px-1">
{items.map((item) => (
<li key={item.href}>
<NavLink
to={item.href}
end={item.end}
data-testid="nav-link"
data-nav-href={item.href}
className={({ isActive }) => (isActive ? "active" : undefined)}
>
{item.icon} {item.label}
</NavLink>
</li>
))}
</ul>
</div>
<div className="navbar-end gap-1 pr-2">
<ThemeToggle />
{config.oidc_enabled && !config.system_maintenance && <AuthSection />}
<div className="dropdown dropdown-end lg:hidden">
<div tabIndex={0} role="button" className="btn btn-ghost">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</div>
<ul
tabIndex={0}
className="dropdown-content menu z-50 p-2 shadow bg-base-100 rounded-box w-56 mt-3"
>
<MobileNav />
</ul>
</div>
</div>
</div>
);
}
@@ -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(
<NodeDisplay name="Hub 🔌" publicKey={PUBKEY} advType="repeater" />,
);
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(<NodeDisplay name={null} publicKey={PUBKEY} advType={null} />);
expect(screen.getByText(`${PUBKEY.slice(0, 16)}...`)).toBeInTheDocument();
});
it("shows description when provided", () => {
render(
<NodeDisplay name="X" description="A node" publicKey={PUBKEY} advType={null} />,
);
expect(screen.getByText("A node")).toBeInTheDocument();
});
it("omits description when not provided", () => {
const { container } = render(
<NodeDisplay name="X" publicKey={PUBKEY} advType={null} />,
);
expect(container.querySelector(".opacity-70")).toBeNull();
});
it("NodeLink wraps display in a router Link to the node", () => {
const { container } = render(
<MemoryRouter>
<NodeLink name="N" publicKey={PUBKEY} advType={null} />
</MemoryRouter>,
);
const link = container.querySelector("a");
expect(link).toHaveAttribute("href", `/nodes/${PUBKEY}`);
});
});
@@ -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 (
<div className="flex items-center gap-2 min-w-0">
<span
className="text-lg flex-shrink-0"
title={advType || t("node_types.unknown")}
>
{emoji}
</span>
<div className="min-w-0">
{name ? (
<>
<div className={`font-medium ${nameSize} truncate`}>{name}</div>
{description && (
<div className="text-xs opacity-70 truncate">{description}</div>
)}
</>
) : (
<div className={`font-mono ${nameSize} truncate`}>
{publicKey.slice(0, 16)}...
</div>
)}
</div>
</div>
);
}
interface NodeLinkProps extends NodeDisplayProps {
className?: string;
}
export function NodeLink({ className, ...display }: NodeLinkProps) {
return (
<Link
to={`/nodes/${display.publicKey}`}
className={className ?? "link link-hover"}
>
<NodeDisplay {...display} />
</Link>
);
}
@@ -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(<NotFoundState message="No such node" />);
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(
<NotFoundState tone="warning" message="Gone after retention" />,
);
const alert = screen.getByRole("alert");
expect(alert).toHaveClass("alert-warning");
expect(alert).toHaveTextContent("Gone after retention");
expect(container.querySelector("svg")).toBeNull();
});
});
@@ -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 (
<div role="alert" className={`alert alert-${tone} mb-4`}>
{tone === "error" && (
<IconError className="stroke-current shrink-0 h-6 w-6" />
)}
<span>{message}</span>
</div>
);
}
@@ -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(<ObserverIcons observers={[]} />);
expect(container.firstChild).toBeNull();
});
it("renders the count and a tooltip with resolved names", () => {
render(
<ObserverIcons
observers={[
{ tag_name: "Alpha", public_key: "aaa" },
{ name: "Beta", public_key: "bbb" },
]}
/>,
);
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(
<ObserverFilterBadges areas={[]} disabled={new Set()} onToggle={() => {}} />,
);
expect(container.firstChild).toBeNull();
});
it("renders enabled and disabled badges and calls onToggle on click", () => {
const onToggle = vi.fn();
render(
<ObserverFilterBadges
areas={["North", "South"]}
disabled={new Set(["South"])}
onToggle={onToggle}
/>,
);
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");
});
});
@@ -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 (
<span
className="badge badge-sm badge-primary observer-badge"
title={tooltip}
>
{formatNumber(observers.length)}
</span>
);
}
const OBSERVER_FILTER_KEY = "meshcore-observer-areas-disabled";
export function getDisabledObserverAreas(): Set<string> {
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<string>): void {
try {
localStorage.setItem(OBSERVER_FILTER_KEY, JSON.stringify([...disabled]));
} catch {
// ignore
}
}
export function toggleObserverArea(
area: string,
totalAreaCount: number,
): Set<string> {
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<string>;
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 (
<div className={`flex-wrap items-center gap-2 ${extraClass}`}>
<span className="opacity-80 text-sm">
{t("common.filter_observer_label")}:
</span>
{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 (
<button
key={area}
type="button"
className={`${cls} cursor-pointer`}
data-testid="observer-area"
data-area={area}
title={title}
onClick={() => onToggle(area)}
>
{emoji && <span className="mr-1">{emoji}</span>}
{label}
</button>
);
})}
</div>
);
}
@@ -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(<Field label="Time">12:00</Field>);
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(<RedactedNotice />);
expect(container.querySelector(".alert-warning")).not.toBeNull();
expect(container.textContent).toContain("packets.redacted_notice");
});
});
@@ -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 (
<div className="alert alert-warning mb-4">
{"\u{1F512}"} {t("packets.redacted_notice")}
</div>
);
}
export function channelNameDisplay(
names: Map<number, string>,
channelIdx: number | null,
): ReactNode {
if (channelIdx == null) return <span className="opacity-50"></span>;
const name = names.get(channelIdx);
return name ? `${name} (${channelIdx})` : `${channelIdx}`;
}
export function RawHexBlock({ hex }: { hex: string | null }) {
const { t } = useTranslation();
return (
<div className="mt-4">
<div className="flex items-center justify-between mb-1">
<span className="text-xs uppercase opacity-60">{t("packets.col_raw")}</span>
{hex && (
<button
className="btn btn-xs btn-ghost"
onClick={(e) => copyToClipboard(e, hex)}
>
{t("packets.copy_raw")}
</button>
)}
</div>
<pre className="bg-base-200 rounded p-3 text-xs overflow-x-auto whitespace-pre-wrap break-all">
{hex || "—"}
</pre>
</div>
);
}
export function DecodedJsonBlock({ value }: { value: unknown }) {
const { t } = useTranslation();
return (
<div className="mt-4">
<span className="text-xs uppercase opacity-60">{t("packets.decoded")}</span>
<div className="bg-base-200 rounded p-3">
<JsonTree value={value} openDepth={1} />
</div>
</div>
);
}
@@ -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(
<AppConfigProvider config={config}>
<PageHeader title="Nodes">{children}</PageHeader>
</AppConfigProvider>,
);
}
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" }),
<span>extra badge</span>,
);
expect(screen.getByText("EST")).toBeInTheDocument();
expect(screen.getByText("extra badge")).toBeInTheDocument();
});
});
@@ -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 (
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{title}</h1>
<div className="flex items-center gap-2">
{tz && tz !== "UTC" && (
<span className="text-sm opacity-60">{tz}</span>
)}
{children}
</div>
</div>
);
}
@@ -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(<MemoryRouter>{ui}</MemoryRouter>);
}
describe("Pagination", () => {
it("renders nothing when totalPages <= 1", () => {
const { container } = renderWithRouter(
<Pagination page={1} totalPages={1} basePath="/nodes" />,
);
expect(container.firstChild).toBeNull();
});
it("disables previous on the first page and enables next", () => {
renderWithRouter(<Pagination page={1} totalPages={3} basePath="/nodes" />);
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(<Pagination page={3} totalPages={3} basePath="/nodes" />);
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(<Pagination page={2} totalPages={3} basePath="/nodes" />);
expect(screen.getByText("2").closest("button")).toHaveClass("btn-active");
});
it("renders ellipsis for far-away pages", () => {
renderWithRouter(<Pagination page={5} totalPages={20} basePath="/nodes" />);
expect(screen.getAllByText("...").length).toBeGreaterThanOrEqual(1);
});
it("preserves extra params in page URLs", () => {
renderWithRouter(
<Pagination
page={2}
totalPages={5}
basePath="/nodes"
params={{ search: "foo", tag: ["a", "b"] }}
/>,
);
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");
});
});
@@ -0,0 +1,93 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
interface PaginationProps {
page: number;
totalPages: number;
basePath: string;
params?: Record<string, string | string[]>;
}
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(
<button key={p} className="join-item btn btn-sm btn-active">
{p}
</button>,
);
} else if (
p === 1 ||
p === totalPages ||
(p >= page - 2 && p <= page + 2)
) {
pageNumbers.push(
<Link key={p} to={pageUrl(p)} className="join-item btn btn-sm">
{p}
</Link>,
);
} else if (p === 2 || p === totalPages - 1) {
pageNumbers.push(
<button
key={p}
className="join-item btn btn-sm btn-disabled"
disabled
>
...
</button>,
);
}
}
return (
<div className="flex justify-center mt-6">
<div className="join">
{page > 1 ? (
<Link to={pageUrl(page - 1)} className="join-item btn btn-sm">
{t("common.previous")}
</Link>
) : (
<button className="join-item btn btn-sm btn-disabled" disabled>
{t("common.previous")}
</button>
)}
{pageNumbers}
{page < totalPages ? (
<Link to={pageUrl(page + 1)} className="join-item btn btn-sm">
{t("common.next")}
</Link>
) : (
<button className="join-item btn btn-sm btn-disabled" disabled>
{t("common.next")}
</button>
)}
</div>
</div>
);
}
@@ -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(<RouteTypeBadge routeType={null} />);
expect(container.firstChild).toBeNull();
});
it("renders Flood badge for flood", () => {
render(<RouteTypeBadge routeType="flood" />);
expect(screen.getByText("Flood")).toHaveClass("badge-info");
});
it("renders Relay badge for transport_flood", () => {
render(<RouteTypeBadge routeType="transport_flood" />);
expect(screen.getByText("Relay")).toHaveClass("badge-info");
});
it("renders Zero-hop for direct", () => {
render(<RouteTypeBadge routeType="direct" />);
expect(screen.getByText("Zero-hop")).toHaveClass("badge-success");
});
it("renders Direct relay for transport_direct", () => {
render(<RouteTypeBadge routeType="transport_direct" />);
expect(screen.getByText("Direct relay")).toHaveClass("badge-success");
});
it("renders nothing for an unknown type", () => {
const { container } = render(<RouteTypeBadge routeType="weird" />);
expect(container.firstChild).toBeNull();
});
});
@@ -0,0 +1,18 @@
export function RouteTypeBadge({ routeType }: { routeType: string | null }) {
if (!routeType) return null;
if (routeType === "flood" || routeType === "transport_flood") {
return (
<span className="badge badge-sm badge-info">
{routeType === "flood" ? "Flood" : "Relay"}
</span>
);
}
if (routeType === "direct" || routeType === "transport_direct") {
return (
<span className="badge badge-sm badge-success">
{routeType === "direct" ? "Zero-hop" : "Direct relay"}
</span>
);
}
return null;
}
@@ -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(
<SectionGroup title="Community">
<span>card</span>
</SectionGroup>,
);
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(
<SectionGroup title="t" className="grid grid-cols-2">
<span>c</span>
</SectionGroup>,
);
expect(container.querySelector(".grid-cols-2")).not.toBeNull();
expect(container.querySelector(".lg\\:grid-cols-3")).toBeNull();
});
});
@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
export function SectionGroup({
title,
className,
children,
}: {
title: ReactNode;
className?: string;
children: ReactNode;
}) {
return (
<>
<h2 className="text-lg font-semibold mt-6 mb-3 opacity-70">{title}</h2>
<div
className={
className ?? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
}
>
{children}
</div>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More