mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-03-28 17:42:56 +01:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f907edce6 | ||
|
|
95d1b260ab | ||
|
|
fba2656268 | ||
|
|
69adca09e3 | ||
|
|
9c2a0527ff | ||
|
|
c0db5b1da5 | ||
|
|
77dcbb77ba | ||
|
|
5bf0265fd9 | ||
|
|
1adef40fdc | ||
|
|
c9beb7e801 | ||
|
|
cd14c23cf2 | ||
|
|
708bfd1811 | ||
|
|
afdc76e546 | ||
|
|
e07b9ee2ab | ||
|
|
00851bfcaa | ||
|
|
6a035e41c0 | ||
|
|
2ffc78fda2 | ||
|
|
3f341a4031 | ||
|
|
1ea729bd51 | ||
|
|
d329f67ba8 | ||
|
|
c42a2deffb | ||
|
|
dfa4157c9c | ||
|
|
b52fd32106 | ||
|
|
4bbf43a078 | ||
|
|
deae9c67fe | ||
|
|
ceee27a3af | ||
|
|
f478096bc2 | ||
|
|
8ae94a7763 | ||
|
|
fb6cc6f5a9 | ||
|
|
a98b295618 | ||
|
|
da512c0d9f | ||
|
|
652486aa15 | ||
|
|
947c12bfe1 | ||
|
|
e80cd3a83c | ||
|
|
70ecb5e4da | ||
|
|
565e0ffc7b | ||
|
|
bdc3b867ea | ||
|
|
48786a18f9 | ||
|
|
706c32ae01 |
44
.claude/skills/documentation/SKILL.md
Normal file
44
.claude/skills/documentation/SKILL.md
Normal 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.
|
||||
49
.claude/skills/git-branch/SKILL.md
Normal file
49
.claude/skills/git-branch/SKILL.md
Normal 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.
|
||||
94
.claude/skills/git-pr/SKILL.md
Normal file
94
.claude/skills/git-pr/SKILL.md
Normal 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.
|
||||
66
.claude/skills/quality/SKILL.md
Normal file
66
.claude/skills/quality/SKILL.md
Normal 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.
|
||||
114
.claude/skills/release/SKILL.md
Normal file
114
.claude/skills/release/SKILL.md
Normal 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.
|
||||
38
.env.example
38
.env.example
@@ -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
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
buy_me_a_coffee: jinglemansweep
|
||||
41
.github/workflows/ci.yml
vendored
41
.github/workflows/ci.yml
vendored
@@ -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/
|
||||
|
||||
13
.github/workflows/docker.yml
vendored
13
.github/workflows/docker.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.13
|
||||
3.14
|
||||
|
||||
21
AGENTS.md
21
AGENTS.md
@@ -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
|
||||
|
||||
|
||||
@@ -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" \
|
||||
|
||||
42
README.md
42
README.md
@@ -1,6 +1,10 @@
|
||||
# MeshCore Hub
|
||||
|
||||
Python 3.13+ platform for managing and orchestrating MeshCore mesh networks.
|
||||
[](https://github.com/ipnet-mesh/meshcore-hub/actions/workflows/ci.yml)
|
||||
[](https://github.com/ipnet-mesh/meshcore-hub/actions/workflows/docker.yml)
|
||||
[](https://www.buymeacoffee.com/jinglemansweep)
|
||||
|
||||
Python 3.14+ platform for managing and orchestrating MeshCore mesh networks.
|
||||
|
||||

|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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
6
renovate.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
]
|
||||
}
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
========================================================================== */
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/')) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
334
tests/test_web/test_features.py
Normal file
334
tests/test_web/test_features.py
Normal 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
|
||||
Reference in New Issue
Block a user