Compare commits

39 Commits

Author SHA1 Message Date
JingleManSweep
8f907edce6 Merge pull request #106 from ipnet-mesh/chore/screenshot
Updated Screenshot
2026-02-11 12:09:05 +00:00
JingleManSweep
95d1b260ab Merge pull request #105 from ipnet-mesh/renovate/docker-build-push-action-6.x
Update docker/build-push-action action to v6
2026-02-11 12:08:35 +00:00
renovate[bot]
fba2656268 Update docker/build-push-action action to v6 2026-02-11 12:08:24 +00:00
JingleManSweep
69adca09e3 Merge pull request #102 from ipnet-mesh/renovate/major-github-artifact-actions
Update actions/upload-artifact action to v6
2026-02-11 12:06:28 +00:00
JingleManSweep
9c2a0527ff Merge pull request #101 from ipnet-mesh/renovate/actions-setup-python-6.x
Update actions/setup-python action to v6
2026-02-11 12:04:56 +00:00
JingleManSweep
c0db5b1da5 Merge pull request #103 from ipnet-mesh/renovate/codecov-codecov-action-5.x
Update codecov/codecov-action action to v5
2026-02-11 12:04:31 +00:00
Louis King
77dcbb77ba Push 2026-02-11 12:02:40 +00:00
renovate[bot]
5bf0265fd9 Update codecov/codecov-action action to v5 2026-02-11 12:01:49 +00:00
renovate[bot]
1adef40fdc Update actions/upload-artifact action to v6 2026-02-11 12:01:21 +00:00
renovate[bot]
c9beb7e801 Update actions/setup-python action to v6 2026-02-11 12:01:18 +00:00
JingleManSweep
cd14c23cf2 Merge pull request #104 from ipnet-mesh/chore/ci-fixes
CI Fixes
2026-02-11 11:54:10 +00:00
Louis King
708bfd1811 CI Fixes 2026-02-11 11:53:21 +00:00
JingleManSweep
afdc76e546 Merge pull request #97 from ipnet-mesh/renovate/python-3.x
Update python Docker tag to v3.14
2026-02-11 11:34:18 +00:00
renovate[bot]
e07b9ee2ab Update python Docker tag to v3.14 2026-02-11 11:33:31 +00:00
JingleManSweep
00851bfcaa Merge pull request #100 from ipnet-mesh/chore/fix-ci
Push
2026-02-11 11:30:44 +00:00
Louis King
6a035e41c0 Push 2026-02-11 11:30:25 +00:00
JingleManSweep
2ffc78fda2 Merge pull request #98 from ipnet-mesh/renovate/actions-checkout-6.x
Update actions/checkout action to v6
2026-02-11 11:26:25 +00:00
renovate[bot]
3f341a4031 Update actions/checkout action to v6 2026-02-11 11:24:17 +00:00
JingleManSweep
1ea729bd51 Merge pull request #96 from ipnet-mesh/renovate/configure
Configure Renovate
2026-02-11 11:23:03 +00:00
renovate[bot]
d329f67ba8 Add renovate.json 2026-02-11 11:22:03 +00:00
JingleManSweep
c42a2deffb Merge pull request #95 from ipnet-mesh/chore/add-sponsorship-badge
Add README badges and workflow path filters
2026-02-11 00:40:57 +00:00
Louis King
dfa4157c9c Fixed funding 2026-02-11 00:36:13 +00:00
Louis King
b52fd32106 Add path filters to CI and Docker workflows
Skip unnecessary workflow runs when only non-code files change (README,
docs, etc). Docker workflow always runs on version tags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 00:32:46 +00:00
Louis King
4bbf43a078 Add CI, Docker, and sponsorship badges to README 2026-02-11 00:29:06 +00:00
JingleManSweep
deae9c67fe Add Buy Me a Coffee funding option
Added Buy Me a Coffee funding option.
2026-02-11 00:25:26 +00:00
JingleManSweep
ceee27a3af Merge pull request #94 from ipnet-mesh/chore/docs-update
Update docs and add Claude Code skills
2026-02-11 00:24:24 +00:00
Louis King
f478096bc2 Add Claude Code skills for git branching, PRs, and releases 2026-02-11 00:01:51 +00:00
Louis King
8ae94a7763 Add Claude Code skills for documentation and quality checks 2026-02-10 23:49:58 +00:00
Louis King
fb6cc6f5a9 Update docs to reflect recent features and config options
- Add contact cleanup, admin UI, content home, and webhook secret
  settings to .env.example and README
- Update AGENTS.md project structure with pages.py, example content
  dirs, and corrected receiver init steps
- Document new API endpoints (prefix lookup, members, dashboard
  activity, send-advertisement) in README
- Fix Docker Compose core profile to include db-migrate service
2026-02-10 23:49:31 +00:00
JingleManSweep
a98b295618 Merge pull request #93 from ipnet-mesh/feat/theme-improvements
Add radial glow and solid tint backgrounds to panels and filter bars
2026-02-10 20:26:50 +00:00
Louis King
da512c0d9f Add radial glow and solid tint backgrounds to panels and filter bars
- Add panel-glow CSS class with radial gradient using section colors
- Add panel-solid CSS class for neutral solid-tinted filter bars
- Apply colored glow to stat cards on home and dashboard pages
- Apply neutral grey glow to dashboard chart and data panels
- Apply neutral solid background to filter panels on list pages
- Add shadow-xl drop shadows to dashboard panels and home hero
- Limit dashboard recent adverts to 5 rows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:23:19 +00:00
JingleManSweep
652486aa15 Merge pull request #92 from ipnet-mesh/fix/network-name-colours
Fix hero title to use black/white per theme
2026-02-10 18:24:16 +00:00
Louis King
947c12bfe1 Fix hero title to use black/white per theme 2026-02-10 18:23:46 +00:00
JingleManSweep
e80cd3a83c Merge pull request #91 from ipnet-mesh/feature/light-mode
Add light mode theme with dark/light toggle
2026-02-10 18:16:07 +00:00
Louis King
70ecb5e4da Add light mode theme with dark/light toggle
- Add sun/moon toggle in navbar (top-right) using DaisyUI swap component
- Store user theme preference in localStorage, default to server config
- Add WEB_THEME env var to configure default theme (dark/light)
- Add light mode color palette with adjusted section colors for contrast
- Use CSS filter to invert white SVG logos in light mode
- Add section-colored hover/active backgrounds for navbar items
- Style hero buttons with thicker outlines and white text on hover
- Soften hero heading color in light mode
- Change member callsign badges from green to neutral
- Update AGENTS.md, .env.example with WEB_THEME documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 18:11:11 +00:00
JingleManSweep
565e0ffc7b Merge pull request #90 from ipnet-mesh/feat/feature-flags
Add feature flags to control web dashboard page visibility
2026-02-10 16:52:31 +00:00
Louis King
bdc3b867ea Fix missing receiver tooltips on advertisements and messages pages
The multi-receiver table view used data-* attributes that were never
read instead of native title attributes. Replace with title= so the
browser shows the receiver node name on hover.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 16:23:40 +00:00
Louis King
48786a18f9 Fix missing profile and tx_power in radio config JSON
The radio_config_dict passed to the frontend was missing the profile
and tx_power fields, causing the Network Info panel to omit them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 15:56:45 +00:00
Louis King
706c32ae01 Add feature flags to control web dashboard page visibility
Operators can now disable specific pages (Dashboard, Nodes, Advertisements,
Messages, Map, Members, Pages) via FEATURE_* environment variables. Disabled
features are fully hidden: removed from navigation, return 404 on routes,
and excluded from sitemap/robots.txt. Dashboard auto-disables when all of
Nodes/Advertisements/Messages are off. Map auto-disables when Nodes is off.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 15:43:23 +00:00
34 changed files with 1441 additions and 236 deletions

View File

@@ -0,0 +1,44 @@
---
name: documentation
description: Audit and update project documentation to accurately reflect the current codebase. Use when documentation may be outdated, after significant code changes, or when the user asks to review or update docs.
---
# Documentation Audit
Audit and update all project documentation so it accurately reflects the current state of the codebase. Documentation must only describe features, options, configurations, and functionality that actually exist in the code.
## Files to Review
- **README.md** - Project overview, setup instructions, usage examples
- **AGENTS.md** - AI coding assistant guidelines, project structure, conventions
- **.env.example** - Example environment variables
Also check for substantial comments or inline instructions within the codebase that may be outdated.
## Process
1. **Read all documentation files** listed above in full.
2. **Cross-reference against the codebase.** For every documented item (features, env vars, CLI commands, routes, models, directory paths, conventions), search the code to verify:
- It actually exists.
- Its described behavior matches the implementation.
- File paths and directory structures are accurate.
3. **Identify and fix discrepancies:**
- **Version updates** — ensure documentation reflects any new/updated/removed versions. Check .python-version, pyproject.toml, etc.
- **Stale/legacy content** — documented but no longer in the code. Remove it.
- **Missing content** — exists in the code but not documented. Add it.
- **Inaccurate descriptions** — documented behavior doesn't match implementation. Correct it.
4. **Apply updates** to each file. Preserve existing style and structure.
5. **Verify consistency** across all documentation files — they must not contradict each other.
## Rules
- Do NOT invent features or options that don't exist in the code.
- Do NOT remove documentation for features that DO exist.
- Do NOT change the fundamental structure or style of the docs.
- Do NOT modify CLAUDE.md.
- Focus on accuracy, not cosmetic changes.
- When in doubt, check the source code.

View File

@@ -0,0 +1,49 @@
---
name: git-branch
description: Create a new branch from latest main with the project's naming convention (feat/fix/chore). Use when starting new work on a feature, bug fix, or chore.
---
# Git Branch
Create a new branch from the latest `main` branch using the project's naming convention.
## Arguments
The user may provide arguments in the format: `<type>/<description>`
- `type` — one of `feat`, `fix`, or `chore`
- `description` — short kebab-case description (e.g., `add-map-clustering`)
If not provided, ask the user for the branch type and description.
## Process
1. **Fetch latest main:**
```bash
git fetch origin main
```
2. **Determine branch name:**
- If the user provided arguments (e.g., `/git-branch feat/add-map-clustering`), use them directly.
- Otherwise, ask the user for:
- **Branch type**: `feat`, `fix`, or `chore`
- **Short description**: a brief kebab-case slug describing the work
- Construct the branch name as `{type}/{slug}` (e.g., `feat/add-map-clustering`).
3. **Create and switch to the new branch:**
```bash
git checkout -b {branch_name} origin/main
```
4. **Confirm** by reporting the new branch name to the user.
## Rules
- Branch names MUST follow the `{type}/{slug}` convention.
- Valid types are `feat`, `fix`, and `chore` only.
- The slug MUST be kebab-case (lowercase, hyphens, no spaces or underscores).
- Always branch from `origin/main`, never from the current branch.
- Do NOT push the branch — just create it locally.

View File

@@ -0,0 +1,94 @@
---
name: git-pr
description: Create a pull request to main from the current branch. Runs quality checks, commits changes, pushes, and opens a PR via gh CLI. Use when ready to submit work for review.
---
# Git PR
Create a pull request to `main` from the current feature branch.
## Process
### Phase 1: Pre-flight Checks
1. **Verify branch:**
```bash
git branch --show-current
```
- The current branch must NOT be `main`. If on `main`, tell the user to create a feature branch first (e.g., `/git-branch`).
2. **Check for uncommitted changes:**
```bash
git status
```
- If there are uncommitted changes, ask the user for a commit message and commit them using the `/git-commit` skill conventions (no Claude authoring details).
### Phase 2: Quality Checks
1. **Determine changed components** by comparing against `main`:
```bash
git diff --name-only main...HEAD
```
2. **Run targeted tests** based on changed files:
- `tests/test_web/` for web-only changes (templates, static JS, web routes)
- `tests/test_api/` for API changes
- `tests/test_collector/` for collector changes
- `tests/test_interface/` for interface/sender/receiver changes
- `tests/test_common/` for common models/schemas/config changes
- Run the full `pytest` if changes span multiple components
3. **Run pre-commit checks:**
```bash
pre-commit run --all-files
```
- If checks fail and auto-fix files, commit the fixes and re-run until clean.
4. If tests or checks fail and cannot be auto-fixed, report the issues to the user and stop.
### Phase 3: Push and Create PR
1. **Push the branch to origin:**
```bash
git push -u origin HEAD
```
2. **Generate PR content:**
- **Title**: Derive from the branch name. Convert `feat/add-map-clustering` to `Add map clustering`, `fix/login-error` to `Fix login error`, etc. Keep under 70 characters.
- **Body**: Generate a summary from the commit history:
```bash
git log main..HEAD --oneline
```
3. **Create the PR:**
```bash
gh pr create --title "{title}" --body "$(cat <<'EOF'
## Summary
{bullet points summarizing the changes}
## Test plan
{checklist of testing steps}
EOF
)"
```
4. **Return the PR URL** to the user.
## Rules
- Do NOT create a PR from `main`.
- Do NOT skip quality checks — tests and pre-commit must pass.
- Do NOT force-push.
- Always target `main` as the base branch.
- Keep the PR title concise (under 70 characters).
- If quality checks fail, fix issues or report to the user — do NOT create the PR with failing checks.

View File

@@ -0,0 +1,66 @@
---
name: quality
description: Run the full test suite, pre-commit checks, and re-run tests to ensure code quality. Fixes any issues found. Use after code changes, before commits, or when the user asks to check quality.
---
# Quality Check
Run the full quality pipeline: tests, pre-commit checks, and a verification test run. Fix any issues discovered at each stage.
## Prerequisites
Before running checks, ensure the environment is ready:
1. Check for `.venv` directory — create with `python -m venv .venv` if missing.
2. Activate the virtual environment: `source .venv/bin/activate`
3. Install dependencies: `pip install -e ".[dev]"`
## Process
### Phase 1: Initial Test Run
Run the full test suite to establish a baseline:
```bash
pytest
```
- If tests **pass**, proceed to Phase 2.
- If tests **fail**, investigate and fix the failures before continuing. Re-run the failing tests to confirm fixes. Then proceed to Phase 2.
### Phase 2: Pre-commit Checks
Run all pre-commit hooks against the entire codebase:
```bash
pre-commit run --all-files
```
- If all checks **pass**, proceed to Phase 3.
- If checks **fail**:
- Many hooks (black, trailing whitespace, end-of-file) auto-fix issues. Re-run `pre-commit run --all-files` to confirm auto-fixes resolved the issues.
- For remaining failures (flake8, mypy, etc.), investigate and fix manually.
- Re-run `pre-commit run --all-files` until all checks pass.
- Then proceed to Phase 3.
### Phase 3: Verification Test Run
Run the full test suite again to ensure pre-commit fixes (formatting, import sorting, etc.) haven't broken any functionality:
```bash
pytest
```
- If tests **pass**, the quality check is complete.
- If tests **fail**, the pre-commit fixes introduced a regression. Investigate and fix, then re-run both `pre-commit run --all-files` and `pytest` until both pass cleanly.
## Rules
- Always run the FULL test suite (`pytest`), not targeted tests.
- Always run pre-commit against ALL files (`--all-files`).
- Do NOT skip or ignore failing tests — investigate and fix them.
- Do NOT skip or ignore pre-commit failures — investigate and fix them.
- Do NOT modify test assertions to make tests pass unless the test is genuinely wrong.
- Do NOT disable pre-commit hooks or add noqa/type:ignore unless truly justified.
- Fix root causes, not symptoms.
- If a fix requires changes outside the scope of a simple quality fix (e.g., a design change), report it to the user rather than making the change silently.

View File

@@ -0,0 +1,114 @@
---
name: release
description: Full release workflow — quality gate, semantic version tag, push, and GitHub release. Use when ready to cut a new release from main.
---
# Release
Run the full release workflow: quality checks, version tagging, push, and GitHub release creation.
## Arguments
The user may optionally provide a version number (e.g., `/release 1.2.0`). If not provided, one will be suggested based on commit history.
## Process
### Phase 1: Pre-flight Checks
1. **Verify on `main` branch:**
```bash
git branch --show-current
```
- Must be on `main`. If not, tell the user to switch to `main` first.
2. **Verify working tree is clean:**
```bash
git status --porcelain
```
- If there are uncommitted changes, tell the user to commit or stash them first.
3. **Pull latest:**
```bash
git pull origin main
```
### Phase 2: Quality Gate
1. **Run full test suite:**
```bash
pytest
```
2. **Run pre-commit checks:**
```bash
pre-commit run --all-files
```
3. If either fails, report the issues and stop. Do NOT proceed with a release that has failing checks.
### Phase 3: Determine Version
1. **Get the latest tag:**
```bash
git describe --tags --abbrev=0 2>/dev/null || echo "none"
```
2. **List commits since last tag:**
```bash
git log {last_tag}..HEAD --oneline
```
If no previous tag exists, list the last 20 commits:
```bash
git log --oneline -20
```
3. **Determine next version:**
- If the user provided a version, use it.
- Otherwise, suggest a version based on commit prefixes:
- Any commit starting with `feat` or `Add`**minor** bump
- Only `fix` or `Fix` commits → **patch** bump
- If no previous tag, suggest `0.1.0`
- Present the suggestion and ask the user to confirm or provide a different version.
### Phase 4: Tag and Release
1. **Create annotated tag:**
```bash
git tag -a v{version} -m "Release v{version}"
```
2. **Push tag to origin:**
```bash
git push origin v{version}
```
3. **Create GitHub release:**
```bash
gh release create v{version} --title "v{version}" --generate-notes
```
4. **Report** the release URL to the user.
## Rules
- MUST be on `main` branch with a clean working tree.
- MUST pass all quality checks before tagging.
- Tags MUST follow the `v{major}.{minor}.{patch}` format (e.g., `v1.2.0`).
- Always create an annotated tag, not a lightweight tag.
- Always confirm the version with the user before tagging.
- Do NOT skip quality checks under any circumstances.
- Do NOT force-push tags.

View File

@@ -107,6 +107,17 @@ MESHCORE_DEVICE_NAME=
NODE_ADDRESS=
NODE_ADDRESS_SENDER=
# -------------------
# Contact Cleanup Settings (RECEIVER mode only)
# -------------------
# Automatic removal of stale contacts from the MeshCore companion node
# Enable automatic removal of stale contacts from companion node
CONTACT_CLEANUP_ENABLED=true
# Remove contacts not advertised for this many days
CONTACT_CLEANUP_DAYS=7
# =============================================================================
# COLLECTOR SETTINGS
# =============================================================================
@@ -187,11 +198,24 @@ API_ADMIN_KEY=
# External web port
WEB_PORT=8080
# Default theme for the web dashboard (dark or light)
# Users can override via the theme toggle; their preference is saved in localStorage
# Default: dark
# WEB_THEME=dark
# Enable admin interface at /a/ (requires auth proxy in front)
# Default: false
# WEB_ADMIN_ENABLED=false
# Timezone for displaying dates/times on the web dashboard
# Uses standard IANA timezone names (e.g., America/New_York, Europe/London)
# Default: UTC
TZ=UTC
# Directory containing custom content (pages/, media/)
# Default: ./content
# CONTENT_HOME=./content
# -------------------
# Network Information
# -------------------
@@ -213,6 +237,20 @@ NETWORK_RADIO_CONFIG=
# If not set, a default welcome message is shown
NETWORK_WELCOME_TEXT=
# -------------------
# Feature Flags
# -------------------
# Control which pages are visible in the web dashboard
# Set to false to completely hide a page (nav, routes, sitemap, robots.txt)
# FEATURE_DASHBOARD=true
# FEATURE_NODES=true
# FEATURE_ADVERTISEMENTS=true
# FEATURE_MESSAGES=true
# FEATURE_MAP=true
# FEATURE_MEMBERS=true
# FEATURE_PAGES=true
# -------------------
# Contact Information
# -------------------

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
buy_me_a_coffee: jinglemansweep

View File

@@ -3,37 +3,40 @@ name: CI
on:
pull_request:
branches: [main]
paths:
- "src/**"
- "tests/**"
- "alembic/**"
- ".python-version"
- "pyproject.toml"
- ".pre-commit-config.yaml"
- ".github/workflows/ci.yml"
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.13"
python-version-file: ".python-version"
- name: Run pre-commit
uses: pre-commit/action@v3.0.1
test:
name: Test (Python ${{ matrix.python-version }})
name: Test
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
python-version-file: ".python-version"
- name: Install dependencies
run: |
@@ -45,8 +48,8 @@ jobs:
pytest --cov=meshcore_hub --cov-report=xml --cov-report=term-missing
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
if: matrix.python-version == '3.13'
uses: codecov/codecov-action@v5
if: always()
with:
files: ./coverage.xml
fail_ci_if_error: false
@@ -57,12 +60,12 @@ jobs:
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.13"
python-version-file: ".python-version"
- name: Install build tools
run: |
@@ -73,7 +76,7 @@ jobs:
run: python -m build
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: dist
path: dist/

View File

@@ -3,6 +3,15 @@ name: Docker
on:
push:
branches: [main]
paths:
- "src/**"
- "alembic/**"
- "alembic.ini"
- ".python-version"
- "pyproject.toml"
- "Dockerfile"
- "docker-compose.yml"
- ".github/workflows/docker.yml"
tags:
- "v*"
@@ -19,7 +28,7 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -48,7 +57,7 @@ jobs:
type=sha
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile

View File

@@ -1,3 +1,6 @@
default_language_version:
python: python3
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
@@ -14,7 +17,6 @@ repos:
rev: 24.3.0
hooks:
- id: black
language_version: python3.13
args: ["--line-length=88"]
- repo: https://github.com/pycqa/flake8

View File

@@ -1 +1 @@
3.13
3.14

View File

@@ -287,7 +287,8 @@ meshcore-hub/
│ └── web/
│ ├── cli.py
│ ├── app.py # FastAPI app
│ ├── templates/ # Jinja2 templates (spa.html shell, base.html)
│ ├── pages.py # Custom markdown page loader
│ ├── templates/ # Jinja2 templates (spa.html shell)
│ └── static/
│ ├── css/app.css # Custom styles
│ └── js/spa/ # SPA frontend (ES modules)
@@ -312,9 +313,12 @@ meshcore-hub/
├── etc/
│ └── mosquitto.conf # MQTT broker configuration
├── example/
── seed/ # Example seed data files
├── node_tags.yaml # Example node tags
└── members.yaml # Example network members
── seed/ # Example seed data files
├── node_tags.yaml # Example node tags
└── members.yaml # Example network members
│ └── content/ # Example custom content
│ ├── pages/ # Example custom pages
│ └── media/ # Example media files
├── seed/ # Seed data directory (SEED_HOME)
│ ├── node_tags.yaml # Node tags for import
│ └── members.yaml # Network members for import
@@ -492,7 +496,9 @@ Key variables:
- `MQTT_TLS` - Enable TLS/SSL for MQTT (default: `false`)
- `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys
- `WEB_ADMIN_ENABLED` - Enable admin interface at /a/ (default: `false`, requires auth proxy)
- `WEB_THEME` - Default theme for the web dashboard (default: `dark`, options: `dark`, `light`). Users can override via the theme toggle in the navbar, which persists their preference in browser localStorage.
- `TZ` - Timezone for web dashboard date/time display (default: `UTC`, e.g., `America/New_York`, `Europe/London`)
- `FEATURE_DASHBOARD`, `FEATURE_NODES`, `FEATURE_ADVERTISEMENTS`, `FEATURE_MESSAGES`, `FEATURE_MAP`, `FEATURE_MEMBERS`, `FEATURE_PAGES` - Feature flags to enable/disable specific web dashboard pages (default: all `true`). Dependencies: Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled.
- `LOG_LEVEL` - Logging verbosity
The database defaults to `sqlite:///{DATA_HOME}/collector/meshcore.db` and does not typically need to be configured.
@@ -713,9 +719,10 @@ await mc.start_auto_message_fetching()
On startup, the receiver performs these initialization steps:
1. Set device clock to current Unix timestamp
2. Send a local (non-flood) advertisement
3. Start automatic message fetching
4. Sync the device's contact database
2. Optionally set the device name (if `MESHCORE_DEVICE_NAME` is configured)
3. Send a flood advertisement (broadcasts device name to the mesh)
4. Start automatic message fetching
5. Sync the device's contact database
### Contact Sync Behavior

View File

@@ -4,7 +4,7 @@
# =============================================================================
# Stage 1: Builder - Install dependencies and build package
# =============================================================================
FROM python:3.13-slim AS builder
FROM python:3.14-slim AS builder
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
@@ -39,7 +39,7 @@ RUN sed -i "s|__version__ = \"dev\"|__version__ = \"${BUILD_VERSION}\"|" src/mes
# =============================================================================
# Stage 2: Runtime - Final production image
# =============================================================================
FROM python:3.13-slim AS runtime
FROM python:3.14-slim AS runtime
# Labels
LABEL org.opencontainers.image.title="MeshCore Hub" \

View File

@@ -1,6 +1,10 @@
# MeshCore Hub
Python 3.13+ platform for managing and orchestrating MeshCore mesh networks.
[![CI](https://github.com/ipnet-mesh/meshcore-hub/actions/workflows/ci.yml/badge.svg)](https://github.com/ipnet-mesh/meshcore-hub/actions/workflows/ci.yml)
[![Docker](https://github.com/ipnet-mesh/meshcore-hub/actions/workflows/docker.yml/badge.svg)](https://github.com/ipnet-mesh/meshcore-hub/actions/workflows/docker.yml)
[![BuyMeACoffee](https://raw.githubusercontent.com/pachadotdev/buymeacoffee-badges/main/bmc-donate-yellow.svg)](https://www.buymeacoffee.com/jinglemansweep)
Python 3.14+ platform for managing and orchestrating MeshCore mesh networks.
![MeshCore Hub Web Dashboard](docs/images/web.png)
@@ -168,7 +172,7 @@ Docker Compose uses **profiles** to select which services to run:
| Profile | Services | Use Case |
|---------|----------|----------|
| `core` | collector, api, web | Central server infrastructure |
| `core` | db-migrate, collector, api, web | Central server infrastructure |
| `receiver` | interface-receiver | Receiver node (events to MQTT) |
| `sender` | interface-sender | Sender node (MQTT to device) |
| `mqtt` | mosquitto broker | Local MQTT broker (optional) |
@@ -275,6 +279,10 @@ All components are configured via environment variables. Create a `.env` file or
| `SERIAL_PORT` | `/dev/ttyUSB0` | Serial port for MeshCore device |
| `SERIAL_BAUD` | `115200` | Serial baud rate |
| `MESHCORE_DEVICE_NAME` | *(none)* | Device/node name set on startup (broadcast in advertisements) |
| `NODE_ADDRESS` | *(none)* | Override for device public key (64-char hex string) |
| `NODE_ADDRESS_SENDER` | *(none)* | Override for sender device public key |
| `CONTACT_CLEANUP_ENABLED` | `true` | Enable automatic removal of stale contacts from companion node |
| `CONTACT_CLEANUP_DAYS` | `7` | Remove contacts not advertised for this many days |
### Webhooks
@@ -287,7 +295,9 @@ The collector can forward certain events to external HTTP endpoints:
| `WEBHOOK_MESSAGE_URL` | *(none)* | Webhook URL for all message events |
| `WEBHOOK_MESSAGE_SECRET` | *(none)* | Secret for message webhook |
| `WEBHOOK_CHANNEL_MESSAGE_URL` | *(none)* | Override URL for channel messages only |
| `WEBHOOK_CHANNEL_MESSAGE_SECRET` | *(none)* | Secret for channel message webhook |
| `WEBHOOK_DIRECT_MESSAGE_URL` | *(none)* | Override URL for direct messages only |
| `WEBHOOK_DIRECT_MESSAGE_SECRET` | *(none)* | Secret for direct message webhook |
| `WEBHOOK_TIMEOUT` | `10.0` | Request timeout in seconds |
| `WEBHOOK_MAX_RETRIES` | `3` | Max retry attempts on failure |
| `WEBHOOK_RETRY_BACKOFF` | `2.0` | Exponential backoff multiplier |
@@ -329,6 +339,7 @@ The collector automatically cleans up old event data and inactive nodes:
| `WEB_HOST` | `0.0.0.0` | Web server bind address |
| `WEB_PORT` | `8080` | Web server port |
| `API_BASE_URL` | `http://localhost:8000` | API endpoint URL |
| `WEB_THEME` | `dark` | Default theme (`dark` or `light`). Users can override via theme toggle in navbar. |
| `WEB_ADMIN_ENABLED` | `false` | Enable admin interface at /a/ (requires auth proxy) |
| `TZ` | `UTC` | Timezone for displaying dates/times (e.g., `America/New_York`, `Europe/London`) |
| `NETWORK_NAME` | `MeshCore Network` | Display name for the network |
@@ -339,8 +350,25 @@ The collector automatically cleans up old event data and inactive nodes:
| `NETWORK_CONTACT_EMAIL` | *(none)* | Contact email address |
| `NETWORK_CONTACT_DISCORD` | *(none)* | Discord server link |
| `NETWORK_CONTACT_GITHUB` | *(none)* | GitHub repository URL |
| `NETWORK_CONTACT_YOUTUBE` | *(none)* | YouTube channel URL |
| `CONTENT_HOME` | `./content` | Directory containing custom content (pages/, media/) |
#### Feature Flags
Control which pages are visible in the web dashboard. Disabled features are fully hidden: removed from navigation, return 404 on their routes, and excluded from sitemap/robots.txt.
| Variable | Default | Description |
|----------|---------|-------------|
| `FEATURE_DASHBOARD` | `true` | Enable the `/dashboard` page |
| `FEATURE_NODES` | `true` | Enable the `/nodes` pages (list, detail, short links) |
| `FEATURE_ADVERTISEMENTS` | `true` | Enable the `/advertisements` page |
| `FEATURE_MESSAGES` | `true` | Enable the `/messages` page |
| `FEATURE_MAP` | `true` | Enable the `/map` page and `/map/data` endpoint |
| `FEATURE_MEMBERS` | `true` | Enable the `/members` page |
| `FEATURE_PAGES` | `true` | Enable custom markdown pages |
**Dependencies:** Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled.
### Custom Content
The web dashboard supports custom content including markdown pages and media files. Content is organized in subdirectories:
@@ -525,15 +553,21 @@ curl -X POST \
|--------|----------|-------------|
| GET | `/api/v1/nodes` | List all known nodes |
| GET | `/api/v1/nodes/{public_key}` | Get node details |
| GET | `/api/v1/nodes/prefix/{prefix}` | Get node by public key prefix |
| GET | `/api/v1/nodes/{public_key}/tags` | Get node tags |
| POST | `/api/v1/nodes/{public_key}/tags` | Create node tag |
| GET | `/api/v1/messages` | List messages with filters |
| GET | `/api/v1/advertisements` | List advertisements |
| GET | `/api/v1/telemetry` | List telemetry data |
| GET | `/api/v1/trace-paths` | List trace paths |
| GET | `/api/v1/members` | List network members |
| POST | `/api/v1/commands/send-message` | Send direct message |
| POST | `/api/v1/commands/send-channel-message` | Send channel message |
| POST | `/api/v1/commands/send-advertisement` | Send advertisement |
| GET | `/api/v1/dashboard/stats` | Get network statistics |
| GET | `/api/v1/dashboard/activity` | Get daily advertisement activity |
| GET | `/api/v1/dashboard/message-activity` | Get daily message activity |
| GET | `/api/v1/dashboard/node-count` | Get cumulative node count history |
## Development
@@ -602,13 +636,13 @@ meshcore-hub/
├── tests/ # Test suite
├── alembic/ # Database migrations
├── etc/ # Configuration files (mosquitto.conf)
├── example/ # Example files for testing
├── example/ # Example files for reference
│ ├── seed/ # Example seed data files
│ │ ├── node_tags.yaml # Example node tags
│ │ └── members.yaml # Example network members
│ └── content/ # Example custom content
│ ├── pages/ # Example custom pages
│ │ └── about.md # Example about page
│ │ └── join.md # Example join page
│ └── media/ # Example media files
│ └── images/ # Custom images
├── seed/ # Seed data directory (SEED_HOME, copy from example/seed/)

View File

@@ -263,6 +263,14 @@ services:
- NETWORK_WELCOME_TEXT=${NETWORK_WELCOME_TEXT:-}
- CONTENT_HOME=/content
- TZ=${TZ:-UTC}
# Feature flags (set to false to disable specific pages)
- FEATURE_DASHBOARD=${FEATURE_DASHBOARD:-true}
- FEATURE_NODES=${FEATURE_NODES:-true}
- FEATURE_ADVERTISEMENTS=${FEATURE_ADVERTISEMENTS:-true}
- FEATURE_MESSAGES=${FEATURE_MESSAGES:-true}
- FEATURE_MAP=${FEATURE_MAP:-true}
- FEATURE_MEMBERS=${FEATURE_MEMBERS:-true}
- FEATURE_PAGES=${FEATURE_PAGES:-true}
command: ["web"]
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 238 KiB

6
renovate.json Normal file
View File

@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}

View File

@@ -256,6 +256,12 @@ class WebSettings(CommonSettings):
# Timezone for date/time display (uses standard TZ environment variable)
tz: str = Field(default="UTC", description="Timezone for displaying dates/times")
# Theme (dark or light, default dark)
web_theme: str = Field(
default="dark",
description="Default theme for the web dashboard (dark or light)",
)
# Admin interface (disabled by default for security)
web_admin_enabled: bool = Field(
default=False,
@@ -301,12 +307,52 @@ class WebSettings(CommonSettings):
default=None, description="Welcome text for homepage"
)
# Feature flags (control which pages are visible in the web dashboard)
feature_dashboard: bool = Field(
default=True, description="Enable the /dashboard page"
)
feature_nodes: bool = Field(default=True, description="Enable the /nodes pages")
feature_advertisements: bool = Field(
default=True, description="Enable the /advertisements page"
)
feature_messages: bool = Field(
default=True, description="Enable the /messages page"
)
feature_map: bool = Field(
default=True, description="Enable the /map page and /map/data endpoint"
)
feature_members: bool = Field(default=True, description="Enable the /members page")
feature_pages: bool = Field(
default=True, description="Enable custom markdown pages"
)
# Content directory (contains pages/ and media/ subdirectories)
content_home: Optional[str] = Field(
default=None,
description="Directory containing custom content (pages/, media/) (default: ./content)",
)
@property
def features(self) -> dict[str, bool]:
"""Get feature flags as a dictionary.
Automatic dependencies:
- Dashboard requires at least one of nodes/advertisements/messages.
- Map requires nodes (map displays node locations).
"""
has_dashboard_content = (
self.feature_nodes or self.feature_advertisements or self.feature_messages
)
return {
"dashboard": self.feature_dashboard and has_dashboard_content,
"nodes": self.feature_nodes,
"advertisements": self.feature_advertisements,
"messages": self.feature_messages,
"map": self.feature_map and self.feature_nodes,
"members": self.feature_members,
"pages": self.feature_pages,
}
@property
def effective_content_home(self) -> str:
"""Get the effective content home directory."""

View File

@@ -68,23 +68,32 @@ def _build_config_json(app: FastAPI, request: Request) -> str:
radio_config_dict = None
if radio_config:
radio_config_dict = {
"profile": radio_config.profile,
"frequency": radio_config.frequency,
"bandwidth": radio_config.bandwidth,
"spreading_factor": radio_config.spreading_factor,
"coding_rate": radio_config.coding_rate,
"tx_power": radio_config.tx_power,
}
# Get custom pages for navigation
# Get feature flags
features = app.state.features
# Get custom pages for navigation (empty when pages feature is disabled)
page_loader = app.state.page_loader
custom_pages = [
{
"slug": p.slug,
"title": p.title,
"url": p.url,
"menu_order": p.menu_order,
}
for p in page_loader.get_menu_pages()
]
custom_pages = (
[
{
"slug": p.slug,
"title": p.title,
"url": p.url,
"menu_order": p.menu_order,
}
for p in page_loader.get_menu_pages()
]
if features.get("pages", True)
else []
)
config = {
"network_name": app.state.network_name,
@@ -97,12 +106,14 @@ def _build_config_json(app: FastAPI, request: Request) -> str:
"network_contact_youtube": app.state.network_contact_youtube,
"network_welcome_text": app.state.network_welcome_text,
"admin_enabled": app.state.admin_enabled,
"features": features,
"custom_pages": custom_pages,
"logo_url": app.state.logo_url,
"version": __version__,
"timezone": app.state.timezone_abbr,
"timezone_iana": app.state.timezone,
"is_authenticated": bool(request.headers.get("X-Forwarded-User")),
"default_theme": app.state.web_theme,
}
return json.dumps(config)
@@ -121,6 +132,7 @@ def create_app(
network_contact_github: str | None = None,
network_contact_youtube: str | None = None,
network_welcome_text: str | None = None,
features: dict[str, bool] | None = None,
) -> FastAPI:
"""Create and configure the web dashboard application.
@@ -140,6 +152,7 @@ def create_app(
network_contact_github: GitHub repository URL
network_contact_youtube: YouTube channel URL
network_welcome_text: Welcome text for homepage
features: Feature flags dict (default: all enabled from settings)
Returns:
Configured FastAPI application
@@ -162,6 +175,9 @@ def create_app(
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
# Store configuration in app state (use args if provided, else settings)
app.state.web_theme = (
settings.web_theme if settings.web_theme in ("dark", "light") else "dark"
)
app.state.api_url = api_url or settings.api_base_url
app.state.api_key = api_key or settings.api_key
app.state.admin_enabled = (
@@ -189,6 +205,24 @@ def create_app(
network_welcome_text or settings.network_welcome_text
)
# Store feature flags with automatic dependencies:
# - Dashboard requires at least one of nodes/advertisements/messages
# - Map requires nodes (map displays node locations)
effective_features = features if features is not None else settings.features
overrides: dict[str, bool] = {}
has_dashboard_content = (
effective_features.get("nodes", True)
or effective_features.get("advertisements", True)
or effective_features.get("messages", True)
)
if not has_dashboard_content:
overrides["dashboard"] = False
if not effective_features.get("nodes", True):
overrides["map"] = False
if overrides:
effective_features = {**effective_features, **overrides}
app.state.features = effective_features
# Set up templates (for SPA shell only)
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
templates.env.trim_blocks = True
@@ -309,6 +343,8 @@ def create_app(
@app.get("/map/data", tags=["Map"])
async def map_data(request: Request) -> JSONResponse:
"""Return node location data as JSON for the map."""
if not request.app.state.features.get("map", True):
return JSONResponse({"detail": "Map feature is disabled"}, status_code=404)
nodes_with_location: list[dict[str, Any]] = []
members_list: list[dict[str, Any]] = []
members_by_id: dict[str, dict[str, Any]] = {}
@@ -448,6 +484,10 @@ def create_app(
@app.get("/spa/pages/{slug}", tags=["SPA"])
async def get_custom_page(request: Request, slug: str) -> JSONResponse:
"""Get a custom page by slug."""
if not request.app.state.features.get("pages", True):
return JSONResponse(
{"detail": "Pages feature is disabled"}, status_code=404
)
page_loader = request.app.state.page_loader
page = page_loader.get_page(slug)
if not page:
@@ -489,21 +529,57 @@ def create_app(
async def robots_txt(request: Request) -> str:
"""Serve robots.txt."""
base_url = _get_https_base_url(request)
return f"User-agent: *\nDisallow:\n\nSitemap: {base_url}/sitemap.xml\n"
features = request.app.state.features
# Always disallow message and node detail pages
disallow_lines = [
"Disallow: /messages",
"Disallow: /nodes/",
]
# Add disallow for disabled features
feature_paths = {
"dashboard": "/dashboard",
"nodes": "/nodes",
"advertisements": "/advertisements",
"map": "/map",
"members": "/members",
"pages": "/pages",
}
for feature, path in feature_paths.items():
if not features.get(feature, True):
line = f"Disallow: {path}"
if line not in disallow_lines:
disallow_lines.append(line)
disallow_block = "\n".join(disallow_lines)
return (
f"User-agent: *\n"
f"{disallow_block}\n"
f"\n"
f"Sitemap: {base_url}/sitemap.xml\n"
)
@app.get("/sitemap.xml")
async def sitemap_xml(request: Request) -> Response:
"""Generate dynamic sitemap including all node pages."""
"""Generate dynamic sitemap."""
base_url = _get_https_base_url(request)
features = request.app.state.features
# Home is always included; other pages depend on feature flags
all_static_pages = [
("", "daily", "1.0", None),
("/dashboard", "hourly", "0.9", "dashboard"),
("/nodes", "hourly", "0.9", "nodes"),
("/advertisements", "hourly", "0.8", "advertisements"),
("/map", "daily", "0.7", "map"),
("/members", "weekly", "0.6", "members"),
]
static_pages = [
("", "daily", "1.0"),
("/dashboard", "hourly", "0.9"),
("/nodes", "hourly", "0.9"),
("/advertisements", "hourly", "0.8"),
("/messages", "hourly", "0.8"),
("/map", "daily", "0.7"),
("/members", "weekly", "0.6"),
(path, freq, prio)
for path, freq, prio, feature in all_static_pages
if feature is None or features.get(feature, True)
]
urls = []
@@ -516,34 +592,16 @@ def create_app(
f" </url>"
)
try:
response = await request.app.state.http_client.get(
"/api/v1/nodes", params={"limit": 500, "role": "infra"}
)
if response.status_code == 200:
nodes = response.json().get("items", [])
for node in nodes:
public_key = node.get("public_key")
if public_key:
urls.append(
f" <url>\n"
f" <loc>{base_url}/nodes/{public_key[:8]}</loc>\n"
f" <changefreq>daily</changefreq>\n"
f" <priority>0.5</priority>\n"
f" </url>"
)
except Exception as e:
logger.warning(f"Failed to fetch nodes for sitemap: {e}")
page_loader = request.app.state.page_loader
for page in page_loader.get_menu_pages():
urls.append(
f" <url>\n"
f" <loc>{base_url}{page.url}</loc>\n"
f" <changefreq>weekly</changefreq>\n"
f" <priority>0.6</priority>\n"
f" </url>"
)
if features.get("pages", True):
page_loader = request.app.state.page_loader
for page in page_loader.get_menu_pages():
urls.append(
f" <url>\n"
f" <loc>{base_url}{page.url}</loc>\n"
f" <changefreq>weekly</changefreq>\n"
f" <priority>0.6</priority>\n"
f" </url>"
)
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
@@ -559,8 +617,11 @@ def create_app(
async def spa_catchall(request: Request, path: str = "") -> HTMLResponse:
"""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()
custom_pages = (
page_loader.get_menu_pages() if features.get("pages", True) else []
)
config_json = _build_config_json(request.app, request)
@@ -577,9 +638,11 @@ def create_app(
"network_contact_youtube": request.app.state.network_contact_youtube,
"network_welcome_text": request.app.state.network_welcome_text,
"admin_enabled": request.app.state.admin_enabled,
"features": features,
"custom_pages": custom_pages,
"logo_url": request.app.state.logo_url,
"version": __version__,
"default_theme": request.app.state.web_theme,
"config_json": config_json,
},
)

View File

@@ -183,6 +183,11 @@ def web(
if effective_city and effective_country:
click.echo(f"Location: {effective_city}, {effective_country}")
click.echo(f"Reload mode: {reload}")
disabled_features = [
name for name, enabled in settings.features.items() if not enabled
]
if disabled_features:
click.echo(f"Disabled features: {', '.join(disabled_features)}")
click.echo("=" * 50)
if reload:

View File

@@ -25,6 +25,18 @@
--color-messages: oklch(0.75 0.18 180); /* teal */
--color-map: oklch(0.8471 0.199 83.87); /* yellow (matches btn-warning) */
--color-members: oklch(0.72 0.17 50); /* orange */
--color-neutral: oklch(0.3 0.01 250); /* subtle dark grey */
}
/* Light mode: darker section colors for contrast on light backgrounds */
[data-theme="light"] {
--color-dashboard: oklch(0.55 0.15 210);
--color-nodes: oklch(0.50 0.24 265);
--color-adverts: oklch(0.55 0.17 330);
--color-messages: oklch(0.55 0.18 180);
--color-map: oklch(0.58 0.16 45);
--color-members: oklch(0.55 0.18 25);
--color-neutral: oklch(0.85 0.01 250);
}
/* ==========================================================================
@@ -34,6 +46,19 @@
/* Spacing between horizontal nav items */
.menu-horizontal { gap: 0.125rem; }
/* Invert white logos/images to dark for light mode */
[data-theme="light"] .theme-logo {
filter: brightness(0.15);
}
/* Ensure hero heading is pure black/white per theme */
.hero-title {
color: #fff;
}
[data-theme="light"] .hero-title {
color: #000;
}
/* Nav icon colors */
.nav-icon-dashboard { color: var(--color-dashboard); }
.nav-icon-nodes { color: var(--color-nodes); }
@@ -42,6 +67,59 @@
.nav-icon-map { color: var(--color-map); }
.nav-icon-members { color: var(--color-members); }
/* Propagate section color to parent li for hover/active backgrounds */
.navbar .menu li:has(.nav-icon-dashboard) { --nav-color: var(--color-dashboard); }
.navbar .menu li:has(.nav-icon-nodes) { --nav-color: var(--color-nodes); }
.navbar .menu li:has(.nav-icon-adverts) { --nav-color: var(--color-adverts); }
.navbar .menu li:has(.nav-icon-messages) { --nav-color: var(--color-messages); }
.navbar .menu li:has(.nav-icon-map) { --nav-color: var(--color-map); }
.navbar .menu li:has(.nav-icon-members) { --nav-color: var(--color-members); }
/* Section-tinted hover and active backgrounds (!important to override DaisyUI CDN) */
.navbar .menu li > a:hover {
background-color: color-mix(in oklch, var(--nav-color, oklch(var(--bc))) 12%, transparent) !important;
}
.navbar .menu li > a.active {
background-color: color-mix(in oklch, var(--nav-color, oklch(var(--bc))) 20%, transparent) !important;
color: inherit !important;
}
/* Homepage hero buttons: slightly thicker outline, white text on hover */
#app .btn-outline {
border-width: 2px;
}
#app .btn-outline:hover {
color: #fff !important;
}
/* ==========================================================================
Panel Glow
Radial color glow from bottom-right corner.
Set --panel-color on the element for a section-tinted glow.
========================================================================== */
.panel-glow {
background-image:
radial-gradient(
ellipse at 80% 80%,
color-mix(in oklch, var(--panel-color, transparent) 15%, transparent),
transparent 70%
);
}
.panel-glow.panel-glow-tl {
background-image:
radial-gradient(
ellipse at 20% 20%,
color-mix(in oklch, var(--panel-color, transparent) 15%, transparent),
transparent 70%
);
}
.panel-solid {
background-color: color-mix(in oklch, var(--panel-color, transparent) 10%, oklch(var(--b1)));
}
/* ==========================================================================
Scrollbar Styling
========================================================================== */

View File

@@ -137,82 +137,95 @@ function createLineChart(canvasId, data, label, borderColor, backgroundColor, fi
}
/**
* Create a multi-dataset activity chart (for home page)
* 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} advertData - Advertisement data with 'data' array
* @param {Object} messageData - Message data with 'data' array
* @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 || !advertData || !advertData.data || advertData.data.length === 0) {
return null;
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: 'Advertisements',
data: advertData.data.map(function(d) { return d.count; }),
borderColor: ChartColors.adverts,
backgroundColor: ChartColors.advertsFill,
fill: false,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
});
}
var labels = formatDateLabels(advertData.data);
var advertCounts = advertData.data.map(function(d) { return d.count; });
var messageCounts = messageData && messageData.data
? messageData.data.map(function(d) { return d.count; })
: [];
if (messageData && messageData.data && messageData.data.length > 0) {
if (!labels) labels = formatDateLabels(messageData.data);
datasets.push({
label: 'Messages',
data: messageData.data.map(function(d) { return d.count; }),
borderColor: ChartColors.messages,
backgroundColor: ChartColors.messagesFill,
fill: false,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
});
}
if (datasets.length === 0 || !labels) return null;
return new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Advertisements',
data: advertCounts,
borderColor: ChartColors.adverts,
backgroundColor: ChartColors.advertsFill,
fill: false,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}, {
label: 'Messages',
data: messageCounts,
borderColor: ChartColors.messages,
backgroundColor: ChartColors.messagesFill,
fill: false,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}]
},
data: { labels: labels, datasets: datasets },
options: createChartOptions(true)
});
}
/**
* Initialize dashboard charts (nodes, advertisements, messages)
* @param {Object} nodeData - Node count data
* @param {Object} advertData - Advertisement data
* @param {Object} messageData - Message data
* Initialize dashboard charts (nodes, advertisements, messages).
* 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
*/
function initDashboardCharts(nodeData, advertData, messageData) {
createLineChart(
'nodeChart',
nodeData,
'Total Nodes',
ChartColors.nodes,
ChartColors.nodesFill,
true
);
if (nodeData) {
createLineChart(
'nodeChart',
nodeData,
'Total Nodes',
ChartColors.nodes,
ChartColors.nodesFill,
true
);
}
createLineChart(
'advertChart',
advertData,
'Advertisements',
ChartColors.adverts,
ChartColors.advertsFill,
true
);
if (advertData) {
createLineChart(
'advertChart',
advertData,
'Advertisements',
ChartColors.adverts,
ChartColors.advertsFill,
true
);
}
createLineChart(
'messageChart',
messageData,
'Messages',
ChartColors.messages,
ChartColors.messagesFill,
true
);
if (messageData) {
createLineChart(
'messageChart',
messageData,
'Messages',
ChartColors.messages,
ChartColors.messagesFill,
true
);
}
}

View File

@@ -28,6 +28,10 @@ const pages = {
const appContainer = document.getElementById('app');
const router = new Router();
// Read feature flags from config
const config = getConfig();
const features = config.features || {};
/**
* Create a route handler that lazy-loads a page module and calls its render function.
* @param {Function} loader - Module loader function
@@ -51,20 +55,35 @@ function pageHandler(loader) {
};
}
// Register routes
// Register routes (conditionally based on feature flags)
router.addRoute('/', pageHandler(pages.home));
router.addRoute('/dashboard', pageHandler(pages.dashboard));
router.addRoute('/nodes', pageHandler(pages.nodes));
router.addRoute('/nodes/:publicKey', pageHandler(pages.nodeDetail));
router.addRoute('/n/:prefix', async (params) => {
// Short link redirect
router.navigate(`/nodes/${params.prefix}`, true);
});
router.addRoute('/messages', pageHandler(pages.messages));
router.addRoute('/advertisements', pageHandler(pages.advertisements));
router.addRoute('/map', pageHandler(pages.map));
router.addRoute('/members', pageHandler(pages.members));
router.addRoute('/pages/:slug', pageHandler(pages.customPage));
if (features.dashboard !== false) {
router.addRoute('/dashboard', pageHandler(pages.dashboard));
}
if (features.nodes !== false) {
router.addRoute('/nodes', pageHandler(pages.nodes));
router.addRoute('/nodes/:publicKey', pageHandler(pages.nodeDetail));
router.addRoute('/n/:prefix', async (params) => {
// Short link redirect
router.navigate(`/nodes/${params.prefix}`, true);
});
}
if (features.messages !== false) {
router.addRoute('/messages', pageHandler(pages.messages));
}
if (features.advertisements !== false) {
router.addRoute('/advertisements', pageHandler(pages.advertisements));
}
if (features.map !== false) {
router.addRoute('/map', pageHandler(pages.map));
}
if (features.members !== false) {
router.addRoute('/members', pageHandler(pages.members));
}
if (features.pages !== false) {
router.addRoute('/pages/:slug', pageHandler(pages.customPage));
}
// Admin routes
router.addRoute('/a', pageHandler(pages.adminIndex));
@@ -114,18 +133,20 @@ function updatePageTitle(pathname) {
const networkName = config.network_name || 'MeshCore Network';
const titles = {
'/': networkName,
'/dashboard': `Dashboard - ${networkName}`,
'/nodes': `Nodes - ${networkName}`,
'/messages': `Messages - ${networkName}`,
'/advertisements': `Advertisements - ${networkName}`,
'/map': `Map - ${networkName}`,
'/members': `Members - ${networkName}`,
'/a': `Admin - ${networkName}`,
'/a/': `Admin - ${networkName}`,
'/a/node-tags': `Node Tags - Admin - ${networkName}`,
'/a/members': `Members - Admin - ${networkName}`,
};
// Add feature-dependent titles
if (features.dashboard !== false) titles['/dashboard'] = `Dashboard - ${networkName}`;
if (features.nodes !== false) titles['/nodes'] = `Nodes - ${networkName}`;
if (features.messages !== false) titles['/messages'] = `Messages - ${networkName}`;
if (features.advertisements !== false) titles['/advertisements'] = `Advertisements - ${networkName}`;
if (features.map !== false) titles['/map'] = `Map - ${networkName}`;
if (features.members !== false) titles['/members'] = `Members - ${networkName}`;
if (titles[pathname]) {
document.title = titles[pathname];
} else if (pathname.startsWith('/nodes/')) {

View File

@@ -132,7 +132,7 @@ ${content}`, container);
receiversBlock = html`<div class="flex gap-1">
${ad.receivers.map(recv => {
const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12);
return html`<a href="/nodes/${recv.public_key}" class="text-lg hover:opacity-70" data-receiver-tooltip data-name=${recvName} data-timestamp=${recv.received_at || ''}>\u{1F4E1}</a>`;
return html`<a href="/nodes/${recv.public_key}" class="text-lg hover:opacity-70" title=${recvName}>\u{1F4E1}</a>`;
})}
</div>`;
} else if (ad.received_by) {
@@ -160,7 +160,7 @@ ${content}`, container);
});
renderPage(html`
<div class="card bg-base-100 shadow mb-6">
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
<div class="card-body py-4">
<form method="GET" action="/advertisements" class="flex gap-4 flex-wrap items-end" @submit=${createFilterHandler('/advertisements', navigate)}>
<div class="form-control">

View File

@@ -45,7 +45,7 @@ function renderRecentAds(ads) {
if (!ads || ads.length === 0) {
return html`<p class="text-sm opacity-70">No advertisements recorded yet.</p>`;
}
const rows = ads.map(ad => {
const rows = ads.slice(0, 5).map(ad => {
const friendlyName = ad.tag_name || ad.name;
const displayName = friendlyName || (ad.public_key.slice(0, 12) + '...');
const keyLine = friendlyName
@@ -98,7 +98,7 @@ function renderChannelMessages(channelMessages) {
</div>`;
});
return html`<div class="card bg-base-100 shadow-xl">
return html`<div class="card bg-base-100 shadow-xl panel-glow" style="--panel-color: var(--color-neutral)">
<div class="card-body">
<h2 class="card-title">
${iconChannel('h-6 w-6')}
@@ -111,8 +111,20 @@ function renderChannelMessages(channelMessages) {
</div>`;
}
/** Return a Tailwind grid-cols class for the given visible column count. */
function gridCols(count) {
if (count <= 1) return '';
return `md:grid-cols-${count}`;
}
export async function render(container, params, router) {
try {
const config = getConfig();
const features = config.features || {};
const showNodes = features.nodes !== false;
const showAdverts = features.advertisements !== false;
const showMessages = features.messages !== false;
const [stats, advertActivity, messageActivity, nodeCount] = await Promise.all([
apiGet('/api/v1/dashboard/stats'),
apiGet('/api/v1/dashboard/activity', { days: 7 }),
@@ -120,42 +132,55 @@ export async function render(container, params, router) {
apiGet('/api/v1/dashboard/node-count', { days: 7 }),
]);
// Top section: stats + charts
const topCount = (showNodes ? 1 : 0) + (showAdverts ? 1 : 0) + (showMessages ? 1 : 0);
const topGrid = gridCols(topCount);
// Bottom section: recent adverts + recent channel messages
const bottomCount = (showAdverts ? 1 : 0) + (showMessages ? 1 : 0);
const bottomGrid = gridCols(bottomCount);
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Dashboard</h1>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div class="stat bg-base-100 rounded-box shadow">
${topCount > 0 ? html`
<div class="grid grid-cols-1 ${topGrid} gap-6 mb-6">
${showNodes ? html`
<div class="stat bg-base-100 rounded-box shadow-xl panel-glow" style="--panel-color: ${pageColors.nodes}">
<div class="stat-figure" style="color: ${pageColors.nodes}">
${iconNodes('h-8 w-8')}
</div>
<div class="stat-title">Total Nodes</div>
<div class="stat-value" style="color: ${pageColors.nodes}">${stats.total_nodes}</div>
<div class="stat-desc">All discovered nodes</div>
</div>
</div>` : nothing}
<div class="stat bg-base-100 rounded-box shadow">
${showAdverts ? html`
<div class="stat bg-base-100 rounded-box shadow-xl panel-glow" style="--panel-color: ${pageColors.adverts}">
<div class="stat-figure" style="color: ${pageColors.adverts}">
${iconAdvertisements('h-8 w-8')}
</div>
<div class="stat-title">Advertisements</div>
<div class="stat-value" style="color: ${pageColors.adverts}">${stats.advertisements_7d}</div>
<div class="stat-desc">Last 7 days</div>
</div>
</div>` : nothing}
<div class="stat bg-base-100 rounded-box shadow">
${showMessages ? html`
<div class="stat bg-base-100 rounded-box shadow-xl panel-glow" style="--panel-color: ${pageColors.messages}">
<div class="stat-figure" style="color: ${pageColors.messages}">
${iconMessages('h-8 w-8')}
</div>
<div class="stat-title">Messages</div>
<div class="stat-value" style="color: ${pageColors.messages}">${stats.messages_7d}</div>
<div class="stat-desc">Last 7 days</div>
</div>
</div>` : nothing}
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="card bg-base-100 shadow-xl">
<div class="grid grid-cols-1 ${topGrid} gap-6 mb-8">
${showNodes ? html`
<div class="card bg-base-100 shadow-xl panel-glow" style="--panel-color: var(--color-neutral)">
<div class="card-body">
<h2 class="card-title text-base">
${iconNodes('h-5 w-5')}
@@ -166,9 +191,10 @@ export async function render(container, params, router) {
<canvas id="nodeChart"></canvas>
</div>
</div>
</div>
</div>` : nothing}
<div class="card bg-base-100 shadow-xl">
${showAdverts ? html`
<div class="card bg-base-100 shadow-xl panel-glow" style="--panel-color: var(--color-neutral)">
<div class="card-body">
<h2 class="card-title text-base">
${iconAdvertisements('h-5 w-5')}
@@ -179,9 +205,10 @@ export async function render(container, params, router) {
<canvas id="advertChart"></canvas>
</div>
</div>
</div>
</div>` : nothing}
<div class="card bg-base-100 shadow-xl">
${showMessages ? html`
<div class="card bg-base-100 shadow-xl panel-glow" style="--panel-color: var(--color-neutral)">
<div class="card-body">
<h2 class="card-title text-base">
${iconMessages('h-5 w-5')}
@@ -192,11 +219,13 @@ export async function render(container, params, router) {
<canvas id="messageChart"></canvas>
</div>
</div>
</div>
</div>
</div>` : nothing}
</div>` : nothing}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="card bg-base-100 shadow-xl">
${bottomCount > 0 ? html`
<div class="grid grid-cols-1 ${bottomGrid} gap-6">
${showAdverts ? html`
<div class="card bg-base-100 shadow-xl panel-glow" style="--panel-color: var(--color-neutral)">
<div class="card-body">
<h2 class="card-title">
${iconAdvertisements('h-6 w-6')}
@@ -204,12 +233,16 @@ export async function render(container, params, router) {
</h2>
${renderRecentAds(stats.recent_advertisements)}
</div>
</div>
</div>` : nothing}
${renderChannelMessages(stats.channel_messages)}
</div>`, container);
${showMessages ? renderChannelMessages(stats.channel_messages) : nothing}
</div>` : nothing}`, container);
window.initDashboardCharts(nodeCount, advertActivity, messageActivity);
window.initDashboardCharts(
showNodes ? nodeCount : null,
showAdverts ? advertActivity : null,
showMessages ? messageActivity : null,
);
const chartIds = ['nodeChart', 'advertChart', 'messageChart'];
return () => {

View File

@@ -30,6 +30,7 @@ function renderRadioConfig(rc) {
export async function render(container, params, router) {
try {
const config = getConfig();
const features = config.features || {};
const networkName = config.network_name || 'MeshCore Network';
const logoUrl = config.logo_url || '/static/img/logo.svg';
const customPages = config.custom_pages || [];
@@ -42,7 +43,7 @@ export async function render(container, params, router) {
]);
const cityCountry = (config.network_city && config.network_country)
? html`<p class="text-2xl opacity-70 mt-2">${config.network_city}, ${config.network_country}</p>`
? html`<p class="text-lg sm:text-2xl opacity-70 mt-2">${config.network_city}, ${config.network_country}</p>`
: nothing;
const welcomeText = config.network_welcome_text
@@ -52,80 +53,96 @@ export async function render(container, params, router) {
Monitor network activity, view connected nodes, and explore message history.
</p>`;
const customPageButtons = customPages.slice(0, 3).map(page => html`
<a href="${page.url}" class="btn btn-outline btn-neutral">
${iconPage('h-5 w-5 mr-2')}
${page.title}
</a>`);
const customPageButtons = features.pages !== false
? customPages.slice(0, 3).map(page => html`
<a href="${page.url}" class="btn btn-outline btn-neutral">
${iconPage('h-5 w-5 mr-2')}
${page.title}
</a>`)
: [];
const showStats = features.nodes !== false || features.advertisements !== false || features.messages !== false;
const showAdvertSeries = features.advertisements !== false;
const showMessageSeries = features.messages !== false;
const showActivityChart = showAdvertSeries || showMessageSeries;
litRender(html`
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 bg-base-100 rounded-box p-6">
<div class="lg:col-span-2 flex flex-col items-center text-center">
<div class="flex items-center gap-8 mb-4">
<img src="${logoUrl}" alt="${networkName}" class="h-36 w-36" />
<div class="${showStats ? 'grid grid-cols-1 lg:grid-cols-3 gap-6' : ''} bg-base-100 rounded-box shadow-xl p-6">
<div class="${showStats ? 'lg:col-span-2' : ''} flex flex-col items-center text-center">
<div class="flex flex-col sm:flex-row items-center gap-4 sm:gap-8 mb-4">
<img src="${logoUrl}" alt="${networkName}" class="theme-logo h-24 w-24 sm:h-36 sm:w-36" />
<div class="flex flex-col justify-center">
<h1 class="text-6xl font-black tracking-tight">${networkName}</h1>
<h1 class="hero-title text-3xl sm:text-5xl lg:text-6xl font-black tracking-tight">${networkName}</h1>
${cityCountry}
</div>
</div>
${welcomeText}
<div class="flex-1"></div>
<div class="flex flex-wrap justify-center gap-3 mt-auto">
${features.dashboard !== false ? html`
<a href="/dashboard" class="btn btn-outline btn-info">
${iconDashboard('h-5 w-5 mr-2')}
Dashboard
</a>
</a>` : nothing}
${features.nodes !== false ? html`
<a href="/nodes" class="btn btn-outline btn-primary">
${iconNodes('h-5 w-5 mr-2')}
Nodes
</a>
</a>` : nothing}
${features.advertisements !== false ? html`
<a href="/advertisements" class="btn btn-outline btn-secondary">
${iconAdvertisements('h-5 w-5 mr-2')}
Adverts
</a>
</a>` : nothing}
${features.messages !== false ? html`
<a href="/messages" class="btn btn-outline btn-accent">
${iconMessages('h-5 w-5 mr-2')}
Messages
</a>
</a>` : nothing}
${features.map !== false ? html`
<a href="/map" class="btn btn-outline btn-warning">
${iconMap('h-5 w-5 mr-2')}
Map
</a>
</a>` : nothing}
${customPageButtons}
</div>
</div>
${showStats ? html`
<div class="flex flex-col gap-4">
<div class="stat bg-base-200 rounded-box">
${features.nodes !== false ? html`
<div class="stat bg-base-200 rounded-box shadow panel-glow" style="--panel-color: ${pageColors.nodes}">
<div class="stat-figure" style="color: ${pageColors.nodes}">
${iconNodes('h-8 w-8')}
</div>
<div class="stat-title">Total Nodes</div>
<div class="stat-value" style="color: ${pageColors.nodes}">${stats.total_nodes}</div>
<div class="stat-desc">All discovered nodes</div>
</div>
</div>` : nothing}
<div class="stat bg-base-200 rounded-box">
${features.advertisements !== false ? html`
<div class="stat bg-base-200 rounded-box shadow panel-glow" style="--panel-color: ${pageColors.adverts}">
<div class="stat-figure" style="color: ${pageColors.adverts}">
${iconAdvertisements('h-8 w-8')}
</div>
<div class="stat-title">Advertisements</div>
<div class="stat-value" style="color: ${pageColors.adverts}">${stats.advertisements_7d}</div>
<div class="stat-desc">Last 7 days</div>
</div>
</div>` : nothing}
<div class="stat bg-base-200 rounded-box">
${features.messages !== false ? html`
<div class="stat bg-base-200 rounded-box shadow panel-glow" style="--panel-color: ${pageColors.messages}">
<div class="stat-figure" style="color: ${pageColors.messages}">
${iconMessages('h-8 w-8')}
</div>
<div class="stat-title">Messages</div>
<div class="stat-value" style="color: ${pageColors.messages}">${stats.messages_7d}</div>
<div class="stat-desc">Last 7 days</div>
</div>
</div>
</div>` : nothing}
</div>` : nothing}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-6">
<div class="grid grid-cols-1 md:grid-cols-2 ${showActivityChart ? 'lg:grid-cols-3' : ''} gap-6 mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
@@ -142,7 +159,7 @@ export async function render(container, params, router) {
<div class="card-body flex flex-col items-center justify-center">
<p class="text-sm opacity-70 mb-4 text-center">Our local off-grid mesh network is made possible by</p>
<a href="https://meshcore.co.uk/" target="_blank" rel="noopener noreferrer" class="hover:opacity-80 transition-opacity">
<img src="/static/img/meshcore.svg" alt="MeshCore" class="h-8" />
<img src="/static/img/meshcore.svg" alt="MeshCore" class="theme-logo h-8" />
</a>
<p class="text-xs opacity-50 mt-4 text-center">Connecting people and things, without using the internet</p>
<div class="flex gap-2 mt-4">
@@ -158,6 +175,7 @@ export async function render(container, params, router) {
</div>
</div>
${showActivityChart ? html`
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
@@ -169,10 +187,17 @@ export async function render(container, params, router) {
<canvas id="activityChart"></canvas>
</div>
</div>
</div>
</div>` : nothing}
</div>`, container);
const chart = window.createActivityChart('activityChart', advertActivity, messageActivity);
let chart = null;
if (showActivityChart) {
chart = window.createActivityChart(
'activityChart',
showAdvertSeries ? advertActivity : null,
showMessageSeries ? messageActivity : null,
);
}
return () => {
if (chart) chart.destroy();

View File

@@ -174,7 +174,7 @@ export async function render(container, params, router) {
</div>
</div>
<div class="card bg-base-100 shadow mb-6">
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
<div class="card-body py-4">
<div class="flex gap-4 flex-wrap items-end">
<div class="form-control">

View File

@@ -53,7 +53,7 @@ function renderMemberCard(member, nodes) {
: nothing;
const callsignBadge = member.callsign
? html`<span class="badge badge-success">${member.callsign}</span>`
? html`<span class="badge badge-neutral">${member.callsign}</span>`
: nothing;
const descBlock = member.description

View File

@@ -116,7 +116,7 @@ ${content}`, container);
receiversBlock = html`<div class="flex gap-1">
${msg.receivers.map(recv => {
const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12);
return html`<a href="/nodes/${recv.public_key}" class="text-lg hover:opacity-70" data-receiver-tooltip data-name=${recvName} data-timestamp=${recv.received_at || ''}>\u{1F4E1}</a>`;
return html`<a href="/nodes/${recv.public_key}" class="text-lg hover:opacity-70" title=${recvName}>\u{1F4E1}</a>`;
})}
</div>`;
} else if (msg.received_by) {
@@ -139,7 +139,7 @@ ${content}`, container);
});
renderPage(html`
<div class="card bg-base-100 shadow mb-6">
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
<div class="card-body py-4">
<form method="GET" action="/messages" class="flex gap-4 flex-wrap items-end" @submit=${createFilterHandler('/messages', navigate)}>
<div class="form-control">

View File

@@ -199,20 +199,27 @@ ${heroHtml}
cleanupFns.push(() => map.remove());
}
// Initialize QR code (defer to next frame so layout settles after map init)
requestAnimationFrame(() => {
// Initialize QR code - wait for both DOM element and QRCode library
const initQr = () => {
const qrEl = document.getElementById('qr-code');
if (qrEl && typeof QRCode !== 'undefined') {
const typeMap = { chat: 1, repeater: 2, room: 3, sensor: 4 };
const typeNum = typeMap[(node.adv_type || '').toLowerCase()] || 1;
const url = 'meshcore://contact/add?name=' + encodeURIComponent(displayName) + '&public_key=' + node.public_key + '&type=' + typeNum;
new QRCode(qrEl, {
text: url, width: 140, height: 140,
colorDark: '#000000', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.L,
});
}
});
if (!qrEl || typeof QRCode === 'undefined') return false;
const typeMap = { chat: 1, repeater: 2, room: 3, sensor: 4 };
const typeNum = typeMap[(node.adv_type || '').toLowerCase()] || 1;
const url = 'meshcore://contact/add?name=' + encodeURIComponent(displayName) + '&public_key=' + node.public_key + '&type=' + typeNum;
new QRCode(qrEl, {
text: url, width: 140, height: 140,
colorDark: '#000000', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.L,
});
return true;
};
if (!initQr()) {
let attempts = 0;
const qrInterval = setInterval(() => {
if (initQr() || ++attempts >= 20) clearInterval(qrInterval);
}, 100);
cleanupFns.push(() => clearInterval(qrInterval));
}
return () => {
cleanupFns.forEach(fn => fn());

View File

@@ -133,7 +133,7 @@ ${content}`, container);
});
renderPage(html`
<div class="card bg-base-100 shadow mb-6">
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
<div class="card-body py-4">
<form method="GET" action="/nodes" class="flex gap-4 flex-wrap items-end" @submit=${createFilterHandler('/nodes', navigate)}>
<div class="form-control">

View File

@@ -1,10 +1,18 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<html lang="en" data-theme="{{ default_theme }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ network_name }}</title>
<!-- Theme initialization (before CSS to prevent flash) -->
<script>
(function() {
var theme = localStorage.getItem('meshcore-theme');
if (theme) document.documentElement.setAttribute('data-theme', theme);
})();
</script>
<!-- SEO Meta Tags -->
<meta name="description" content="{{ network_name }}{% if network_welcome_text %} - {{ network_welcome_text }}{% else %} - MeshCore off-grid LoRa mesh network dashboard.{% endif %}">
<meta name="generator" content="MeshCore Hub {{ version }}">
@@ -53,38 +61,73 @@
</div>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
<li><a href="/" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg> Home</a></li>
{% if features.dashboard %}
<li><a href="/dashboard" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-dashboard" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> Dashboard</a></li>
{% endif %}
{% if features.nodes %}
<li><a href="/nodes" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-nodes" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> Nodes</a></li>
{% endif %}
{% if features.advertisements %}
<li><a href="/advertisements" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-adverts" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg> Advertisements</a></li>
{% endif %}
{% if features.messages %}
<li><a href="/messages" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-messages" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> Messages</a></li>
{% endif %}
{% if features.map %}
<li><a href="/map" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-map" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" /></svg> Map</a></li>
{% endif %}
{% if features.members %}
<li><a href="/members" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-members" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg> Members</a></li>
{% endif %}
{% if features.pages %}
{% for page in custom_pages %}
<li><a href="{{ page.url }}" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> {{ page.title }}</a></li>
{% endfor %}
{% endif %}
</ul>
</div>
<a href="/" class="btn btn-ghost text-xl">
<img src="{{ logo_url }}" alt="{{ network_name }}" class="h-6 w-6 mr-2" />
<img src="{{ logo_url }}" alt="{{ network_name }}" class="theme-logo h-6 w-6 mr-2" />
{{ network_name }}
</a>
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<li><a href="/" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg> Home</a></li>
{% if features.dashboard %}
<li><a href="/dashboard" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-dashboard" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> Dashboard</a></li>
{% endif %}
{% if features.nodes %}
<li><a href="/nodes" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-nodes" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> Nodes</a></li>
{% endif %}
{% if features.advertisements %}
<li><a href="/advertisements" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-adverts" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg> Advertisements</a></li>
{% endif %}
{% if features.messages %}
<li><a href="/messages" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-messages" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> Messages</a></li>
{% endif %}
{% if features.map %}
<li><a href="/map" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-map" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" /></svg> Map</a></li>
{% endif %}
{% if features.members %}
<li><a href="/members" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-members" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg> Members</a></li>
{% endif %}
{% if features.pages %}
{% for page in custom_pages %}
<li><a href="{{ page.url }}" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> {{ page.title }}</a></li>
{% endfor %}
{% endif %}
</ul>
</div>
<div class="navbar-end">
<div class="navbar-end gap-1 pr-2">
<span id="nav-loading" class="loading loading-spinner loading-sm hidden"></span>
<label class="swap swap-rotate btn btn-ghost btn-circle btn-sm">
<input type="checkbox" id="theme-toggle" />
<!-- sun icon - shown in dark mode (click to switch to light) -->
<svg class="swap-off fill-current w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/></svg>
<!-- moon icon - shown in light mode (click to switch to dark) -->
<svg class="swap-on fill-current w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/></svg>
</label>
</div>
</div>
@@ -139,6 +182,22 @@
window.__APP_CONFIG__ = {{ config_json|safe }};
</script>
<!-- Theme toggle initialization -->
<script>
(function() {
var toggle = document.getElementById('theme-toggle');
if (toggle) {
var current = document.documentElement.getAttribute('data-theme');
toggle.checked = current === 'light';
toggle.addEventListener('change', function() {
var theme = this.checked ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('meshcore-theme', theme);
});
}
})();
</script>
<!-- SPA Application (ES Module) -->
<script type="module" src="/static/js/spa/app.js"></script>
</body>

View File

@@ -9,6 +9,18 @@ from httpx import Response
from meshcore_hub.web.app import create_app
# Explicit all-enabled features dict so tests are not affected by the user's
# local .env file (pydantic-settings loads .env by default).
ALL_FEATURES_ENABLED = {
"dashboard": True,
"nodes": True,
"advertisements": True,
"messages": True,
"map": True,
"members": True,
"pages": True,
}
class MockHttpClient:
"""Mock HTTP client for testing web routes."""
@@ -315,6 +327,7 @@ def web_app(mock_http_client: MockHttpClient) -> Any:
network_radio_config="Test Radio Config",
network_contact_email="test@example.com",
network_contact_discord="https://discord.gg/test",
features=ALL_FEATURES_ENABLED,
)
# Override the lifespan to use our mock client
@@ -335,6 +348,38 @@ def client(web_app: Any, mock_http_client: MockHttpClient) -> TestClient:
return TestClient(web_app, raise_server_exceptions=True)
@pytest.fixture
def web_app_no_features(mock_http_client: MockHttpClient) -> Any:
"""Create a web app with all features disabled."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
network_name="Test Network",
network_city="Test City",
network_country="Test Country",
features={
"dashboard": False,
"nodes": False,
"advertisements": False,
"messages": False,
"map": False,
"members": False,
"pages": False,
},
)
app.state.http_client = mock_http_client
return app
@pytest.fixture
def client_no_features(
web_app_no_features: Any, mock_http_client: MockHttpClient
) -> TestClient:
"""Create a test client with all features disabled."""
web_app_no_features.state.http_client = mock_http_client
return TestClient(web_app_no_features, raise_server_exceptions=True)
@pytest.fixture
def mock_http_client_with_members() -> MockHttpClient:
"""Create a mock HTTP client with members data."""
@@ -429,6 +474,7 @@ def web_app_with_members(mock_http_client_with_members: MockHttpClient) -> Any:
network_radio_config="Test Radio Config",
network_contact_email="test@example.com",
network_contact_discord="https://discord.gg/test",
features=ALL_FEATURES_ENABLED,
)
app.state.http_client = mock_http_client_with_members

View File

@@ -0,0 +1,334 @@
"""Tests for feature flags functionality."""
import json
import pytest
from fastapi.testclient import TestClient
from meshcore_hub.web.app import create_app
from tests.test_web.conftest import MockHttpClient
class TestFeatureFlagsConfig:
"""Test feature flags in config."""
def test_all_features_enabled_by_default(self, client: TestClient) -> None:
"""All features should be enabled by default in config JSON."""
response = client.get("/")
assert response.status_code == 200
html = response.text
# Extract config JSON from script tag
start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
end = html.index(";", start)
config = json.loads(html[start:end])
features = config["features"]
assert all(features.values()), "All features should be enabled by default"
def test_features_dict_has_all_keys(self, client: TestClient) -> None:
"""Features dict should have all 7 expected keys."""
response = client.get("/")
html = response.text
start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
end = html.index(";", start)
config = json.loads(html[start:end])
features = config["features"]
expected_keys = {
"dashboard",
"nodes",
"advertisements",
"messages",
"map",
"members",
"pages",
}
assert set(features.keys()) == expected_keys
def test_disabled_features_in_config(self, client_no_features: TestClient) -> None:
"""Disabled features should be false in config JSON."""
response = client_no_features.get("/")
html = response.text
start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
end = html.index(";", start)
config = json.loads(html[start:end])
features = config["features"]
assert all(not v for v in features.values()), "All features should be disabled"
class TestFeatureFlagsNav:
"""Test feature flags affect navigation."""
def test_enabled_features_show_nav_links(self, client: TestClient) -> None:
"""Enabled features should show nav links."""
response = client.get("/")
html = response.text
assert 'href="/dashboard"' in html
assert 'href="/nodes"' in html
assert 'href="/advertisements"' in html
assert 'href="/messages"' in html
assert 'href="/map"' in html
assert 'href="/members"' in html
def test_disabled_features_hide_nav_links(
self, client_no_features: TestClient
) -> None:
"""Disabled features should not show nav links."""
response = client_no_features.get("/")
html = response.text
assert 'href="/dashboard"' not in html
assert 'href="/nodes"' not in html
assert 'href="/advertisements"' not in html
assert 'href="/messages"' not in html
assert 'href="/map"' not in html
assert 'href="/members"' not in html
def test_home_link_always_present(self, client_no_features: TestClient) -> None:
"""Home link should always be present."""
response = client_no_features.get("/")
html = response.text
assert 'href="/"' in html
class TestFeatureFlagsEndpoints:
"""Test feature flags affect endpoints."""
def test_map_data_returns_404_when_disabled(
self, client_no_features: TestClient
) -> None:
"""/map/data should return 404 when map feature is disabled."""
response = client_no_features.get("/map/data")
assert response.status_code == 404
assert response.json()["detail"] == "Map feature is disabled"
def test_map_data_returns_200_when_enabled(self, client: TestClient) -> None:
"""/map/data should return 200 when map feature is enabled."""
response = client.get("/map/data")
assert response.status_code == 200
def test_custom_page_returns_404_when_disabled(
self, client_no_features: TestClient
) -> None:
"""/spa/pages/{slug} should return 404 when pages feature is disabled."""
response = client_no_features.get("/spa/pages/about")
assert response.status_code == 404
assert response.json()["detail"] == "Pages feature is disabled"
def test_custom_pages_empty_when_disabled(
self, client_no_features: TestClient
) -> None:
"""Custom pages should be empty in config when pages feature is disabled."""
response = client_no_features.get("/")
html = response.text
start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
end = html.index(";", start)
config = json.loads(html[start:end])
assert config["custom_pages"] == []
class TestFeatureFlagsSEO:
"""Test feature flags affect SEO endpoints."""
def test_sitemap_includes_all_when_enabled(self, client: TestClient) -> None:
"""Sitemap should include all pages when all features are enabled."""
response = client.get("/sitemap.xml")
assert response.status_code == 200
content = response.text
assert "/dashboard" in content
assert "/nodes" in content
assert "/advertisements" in content
assert "/map" in content
assert "/members" in content
def test_sitemap_excludes_disabled_features(
self, client_no_features: TestClient
) -> None:
"""Sitemap should exclude disabled features."""
response = client_no_features.get("/sitemap.xml")
assert response.status_code == 200
content = response.text
assert "/dashboard" not in content
assert "/nodes" not in content
assert "/advertisements" not in content
assert "/map" not in content
assert "/members" not in content
def test_sitemap_always_includes_home(self, client_no_features: TestClient) -> None:
"""Sitemap should always include the home page."""
response = client_no_features.get("/sitemap.xml")
assert response.status_code == 200
content = response.text
# Home page has an empty path, so check for base URL loc
assert "<loc>" in content
def test_robots_txt_adds_disallow_for_disabled(
self, client_no_features: TestClient
) -> None:
"""Robots.txt should add Disallow for disabled features."""
response = client_no_features.get("/robots.txt")
assert response.status_code == 200
content = response.text
assert "Disallow: /dashboard" in content
assert "Disallow: /nodes" in content
assert "Disallow: /advertisements" in content
assert "Disallow: /map" in content
assert "Disallow: /members" in content
assert "Disallow: /pages" in content
def test_robots_txt_default_disallows_when_enabled(
self, client: TestClient
) -> None:
"""Robots.txt should only disallow messages and nodes/ when all enabled."""
response = client.get("/robots.txt")
assert response.status_code == 200
content = response.text
assert "Disallow: /messages" in content
assert "Disallow: /nodes/" in content
# Should not disallow the full /nodes path (only /nodes/ for detail pages)
lines = content.strip().split("\n")
disallow_lines = [
line.strip() for line in lines if line.startswith("Disallow:")
]
assert "Disallow: /nodes" not in disallow_lines or any(
line == "Disallow: /nodes/" for line in disallow_lines
)
class TestFeatureFlagsIndividual:
"""Test individual feature flags."""
@pytest.fixture
def _make_client(self, mock_http_client: MockHttpClient):
"""Factory to create a client with specific features disabled."""
def _create(disabled_feature: str) -> TestClient:
features = {
"dashboard": True,
"nodes": True,
"advertisements": True,
"messages": True,
"map": True,
"members": True,
"pages": True,
}
features[disabled_feature] = False
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
network_name="Test Network",
features=features,
)
app.state.http_client = mock_http_client
return TestClient(app, raise_server_exceptions=True)
return _create
def test_disable_map_only(self, _make_client) -> None:
"""Disabling only map should hide map but show others."""
client = _make_client("map")
response = client.get("/")
html = response.text
assert 'href="/map"' not in html
assert 'href="/dashboard"' in html
assert 'href="/nodes"' in html
# Map data endpoint should 404
response = client.get("/map/data")
assert response.status_code == 404
def test_disable_dashboard_only(self, _make_client) -> None:
"""Disabling only dashboard should hide dashboard but show others."""
client = _make_client("dashboard")
response = client.get("/")
html = response.text
assert 'href="/dashboard"' not in html
assert 'href="/nodes"' in html
assert 'href="/map"' in html
class TestDashboardAutoDisable:
"""Test that dashboard is automatically disabled when it has no content."""
def test_dashboard_auto_disabled_when_all_stats_off(
self, mock_http_client: MockHttpClient
) -> None:
"""Dashboard should auto-disable when nodes, adverts, messages all off."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
network_name="Test Network",
features={
"dashboard": True,
"nodes": False,
"advertisements": False,
"messages": False,
"map": True,
"members": True,
"pages": True,
},
)
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
response = client.get("/")
html = response.text
assert 'href="/dashboard"' not in html
# Check config JSON also reflects it
config = json.loads(html.split("window.__APP_CONFIG__ = ")[1].split(";")[0])
assert config["features"]["dashboard"] is False
def test_map_auto_disabled_when_nodes_off(
self, mock_http_client: MockHttpClient
) -> None:
"""Map should auto-disable when nodes is off (map depends on nodes)."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
network_name="Test Network",
features={
"dashboard": True,
"nodes": False,
"advertisements": True,
"messages": True,
"map": True,
"members": True,
"pages": True,
},
)
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
response = client.get("/")
html = response.text
assert 'href="/map"' not in html
# Check config JSON also reflects it
config = json.loads(html.split("window.__APP_CONFIG__ = ")[1].split(";")[0])
assert config["features"]["map"] is False
# Map data endpoint should 404
response = client.get("/map/data")
assert response.status_code == 404
def test_dashboard_stays_enabled_with_one_stat(
self, mock_http_client: MockHttpClient
) -> None:
"""Dashboard should stay enabled when at least one stat feature is on."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
network_name="Test Network",
features={
"dashboard": True,
"nodes": True,
"advertisements": False,
"messages": False,
"map": True,
"members": True,
"pages": True,
},
)
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
response = client.get("/")
assert 'href="/dashboard"' in response.text