mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-09 10:23:03 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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$)'
|
||||
|
||||
@@ -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
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
@@ -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 | `` | 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
@@ -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/`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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("/");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+4663
-367
File diff suppressed because it is too large
Load Diff
+40
-4
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
×
|
||||
</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
Reference in New Issue
Block a user