mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-03-28 17:42:56 +01:00
Compare commits
77 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 | ||
|
|
bafc16d746 | ||
|
|
9b09e32d41 | ||
|
|
2b9f83e55e | ||
|
|
75c1966385 | ||
|
|
3089ff46a8 | ||
|
|
f1bceb5780 | ||
|
|
caf88bdba1 | ||
|
|
9eb1acfc02 | ||
|
|
62e0568646 | ||
|
|
b4da93e4f0 | ||
|
|
981402f7aa | ||
|
|
76717179c2 | ||
|
|
f42987347e | ||
|
|
25831f14e6 | ||
|
|
0e6cbc8094 | ||
|
|
76630f0bb0 | ||
|
|
8fbac2cbd6 | ||
|
|
fcac5e01dc | ||
|
|
b6f3b2d864 | ||
|
|
7de6520ae7 | ||
|
|
5b8b2eda10 | ||
|
|
042a1b04fa | ||
|
|
5832cbf53a | ||
|
|
c540e15432 | ||
|
|
6b1b277c6c | ||
|
|
470c374f11 | ||
|
|
71859b2168 | ||
|
|
3d7ed53df3 | ||
|
|
ceaef9178a | ||
|
|
5ccb077188 | ||
|
|
8f660d6b94 | ||
|
|
6e40be6487 | ||
|
|
d79e29bc0a | ||
|
|
2758cf4dd5 | ||
|
|
f37e993ede | ||
|
|
b18b3c9aa4 | ||
|
|
9d99262401 | ||
|
|
adfe5bc503 |
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.
|
||||
44
.env.example
44
.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,6 +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
|
||||
# -------------------
|
||||
@@ -208,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
|
||||
# -------------------
|
||||
@@ -216,3 +259,4 @@ NETWORK_WELCOME_TEXT=
|
||||
NETWORK_CONTACT_EMAIL=
|
||||
NETWORK_CONTACT_DISCORD=
|
||||
NETWORK_CONTACT_GITHUB=
|
||||
NETWORK_CONTACT_YOUTUBE=
|
||||
|
||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
buy_me_a_coffee: jinglemansweep
|
||||
43
.github/workflows/ci.yml
vendored
43
.github/workflows/ci.yml
vendored
@@ -1,41 +1,42 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
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: |
|
||||
@@ -47,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
|
||||
@@ -59,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: |
|
||||
@@ -75,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
|
||||
|
||||
82
AGENTS.md
82
AGENTS.md
@@ -13,7 +13,13 @@ This document provides context and guidelines for AI coding assistants working o
|
||||
* You MUST install all project dependencies using `pip install -e ".[dev]"` command`
|
||||
* You MUST install `pre-commit` for quality checks
|
||||
* Before commiting:
|
||||
- Run tests with `pytest` to ensure recent changes haven't broken anything
|
||||
- Run **targeted tests** for the components you changed, not the full suite:
|
||||
- `pytest tests/test_web/` for web-only changes (templates, static JS, web routes)
|
||||
- `pytest tests/test_api/` for API changes
|
||||
- `pytest tests/test_collector/` for collector changes
|
||||
- `pytest tests/test_interface/` for interface/sender/receiver changes
|
||||
- `pytest tests/test_common/` for common models/schemas/config changes
|
||||
- Only run the full `pytest` if changes span multiple components
|
||||
- Run `pre-commit run --all-files` to perform all quality checks
|
||||
|
||||
## Project Overview
|
||||
@@ -46,7 +52,8 @@ MeshCore Hub is a Python 3.13+ monorepo for managing and orchestrating MeshCore
|
||||
| REST API | FastAPI |
|
||||
| MQTT Client | paho-mqtt |
|
||||
| MeshCore Interface | meshcore |
|
||||
| Templates | Jinja2 |
|
||||
| Templates | Jinja2 (server), lit-html (SPA) |
|
||||
| Frontend | ES Modules SPA with client-side routing |
|
||||
| CSS Framework | Tailwind CSS + DaisyUI |
|
||||
| Testing | pytest, pytest-asyncio |
|
||||
| Formatting | black |
|
||||
@@ -280,11 +287,19 @@ meshcore-hub/
|
||||
│ └── web/
|
||||
│ ├── cli.py
|
||||
│ ├── app.py # FastAPI app
|
||||
│ ├── routes/ # Page routes
|
||||
│ │ ├── members.py # Members page
|
||||
│ │ └── ...
|
||||
│ ├── templates/ # Jinja2 templates
|
||||
│ └── static/ # CSS, JS
|
||||
│ ├── pages.py # Custom markdown page loader
|
||||
│ ├── templates/ # Jinja2 templates (spa.html shell)
|
||||
│ └── static/
|
||||
│ ├── css/app.css # Custom styles
|
||||
│ └── js/spa/ # SPA frontend (ES modules)
|
||||
│ ├── app.js # Entry point, route registration
|
||||
│ ├── router.js # Client-side History API router
|
||||
│ ├── api.js # API fetch helper
|
||||
│ ├── components.js # Shared UI components (lit-html)
|
||||
│ ├── icons.js # SVG icon functions (lit-html)
|
||||
│ └── pages/ # Page modules (lazy-loaded)
|
||||
│ ├── home.js, dashboard.js, nodes.js, ...
|
||||
│ └── admin/ # Admin page modules
|
||||
├── tests/
|
||||
│ ├── conftest.py
|
||||
│ ├── test_common/
|
||||
@@ -298,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
|
||||
@@ -417,6 +435,24 @@ async def client(db_session):
|
||||
5. Add Alembic migration if schema changed
|
||||
6. Add tests in `tests/test_collector/`
|
||||
|
||||
### Adding a New SPA Page
|
||||
|
||||
The web dashboard is a Single Page Application. Pages are ES modules loaded by the client-side router.
|
||||
|
||||
1. Create a page module in `web/static/js/spa/pages/` (e.g., `my-page.js`)
|
||||
2. Export an `async function render(container, params, router)` that renders into `container` using `litRender(html\`...\`, container)`
|
||||
3. Register the route in `web/static/js/spa/app.js` with `router.addRoute('/my-page', pageHandler(pages.myPage))`
|
||||
4. Add the page title to `updatePageTitle()` in `app.js`
|
||||
5. Add a nav link in `web/templates/spa.html` (both mobile and desktop menus)
|
||||
|
||||
**Key patterns:**
|
||||
- Import `html`, `litRender`, `nothing` from `../components.js` (re-exports lit-html)
|
||||
- Use `apiGet()` from `../api.js` for API calls
|
||||
- For list pages with filters, use the `renderPage()` pattern: render the page header immediately, then re-render with the filter form + results after fetch (keeps the form out of the shell to avoid layout shift from data-dependent filter selects)
|
||||
- Old page content stays visible until data is ready (navbar spinner indicates loading)
|
||||
- Use `pageColors` from `components.js` for section-specific colors (reads CSS custom properties from `app.css`)
|
||||
- Return a cleanup function if the page creates resources (e.g., Leaflet maps, Chart.js instances)
|
||||
|
||||
### Adding a New Database Model
|
||||
|
||||
1. Create model in `common/models/`
|
||||
@@ -455,11 +491,14 @@ See [PLAN.md](PLAN.md#configuration-environment-variables) for complete list.
|
||||
Key variables:
|
||||
- `DATA_HOME` - Base directory for runtime data (default: `./data`)
|
||||
- `SEED_HOME` - Directory containing seed data files (default: `./seed`)
|
||||
- `PAGES_HOME` - Directory containing custom markdown pages (default: `./pages`)
|
||||
- `CONTENT_HOME` - Directory containing custom content (pages, media) (default: `./content`)
|
||||
- `MQTT_HOST`, `MQTT_PORT`, `MQTT_PREFIX` - MQTT broker connection
|
||||
- `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.
|
||||
@@ -473,12 +512,16 @@ ${SEED_HOME}/
|
||||
└── members.yaml # Network members list
|
||||
```
|
||||
|
||||
**Custom Pages (`PAGES_HOME`)** - Contains custom markdown pages for the web dashboard:
|
||||
**Custom Content (`CONTENT_HOME`)** - Contains custom pages and media for the web dashboard:
|
||||
```
|
||||
${PAGES_HOME}/
|
||||
├── about.md # Example: About page (/pages/about)
|
||||
├── faq.md # Example: FAQ page (/pages/faq)
|
||||
└── getting-started.md # Example: Getting Started (/pages/getting-started)
|
||||
${CONTENT_HOME}/
|
||||
├── pages/ # Custom markdown pages
|
||||
│ ├── about.md # Example: About page (/pages/about)
|
||||
│ ├── faq.md # Example: FAQ page (/pages/faq)
|
||||
│ └── getting-started.md # Example: Getting Started (/pages/getting-started)
|
||||
└── media/ # Custom media files
|
||||
└── images/
|
||||
└── logo.svg # Custom logo (replaces default favicon and navbar/home logo)
|
||||
```
|
||||
|
||||
Pages use YAML frontmatter for metadata:
|
||||
@@ -676,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" \
|
||||
|
||||
84
README.md
84
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.
|
||||
|
||||

|
||||
|
||||
@@ -13,7 +17,7 @@ MeshCore Hub provides a complete solution for monitoring, collecting, and intera
|
||||
| **Interface** | Connects to MeshCore companion nodes via Serial/USB, bridges events to/from MQTT |
|
||||
| **Collector** | Subscribes to MQTT events and persists them to a database |
|
||||
| **API** | REST API for querying data and sending commands to the network |
|
||||
| **Web Dashboard** | User-friendly web interface for visualizing network status |
|
||||
| **Web Dashboard** | Single Page Application (SPA) for visualizing network status |
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -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,7 +339,9 @@ 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 |
|
||||
| `NETWORK_CITY` | *(none)* | City where network is located |
|
||||
| `NETWORK_COUNTRY` | *(none)* | Country code (ISO 3166-1 alpha-2) |
|
||||
@@ -338,19 +350,45 @@ 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 |
|
||||
| `PAGES_HOME` | `./pages` | Directory containing custom markdown pages |
|
||||
| `NETWORK_CONTACT_YOUTUBE` | *(none)* | YouTube channel URL |
|
||||
| `CONTENT_HOME` | `./content` | Directory containing custom content (pages/, media/) |
|
||||
|
||||
### Custom Pages
|
||||
#### Feature Flags
|
||||
|
||||
The web dashboard supports custom markdown pages for adding static content like "About Us", "Getting Started", or "FAQ" pages. Pages are stored as markdown files with YAML frontmatter.
|
||||
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:
|
||||
|
||||
```
|
||||
content/
|
||||
├── pages/ # Custom markdown pages
|
||||
│ └── about.md
|
||||
└── media/ # Custom media files
|
||||
└── images/
|
||||
└── logo.svg # Custom logo (replaces favicon and navbar/home logo)
|
||||
```
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
# Create pages directory
|
||||
mkdir -p pages
|
||||
# Create content directory structure
|
||||
mkdir -p content/pages content/media
|
||||
|
||||
# Create a custom page
|
||||
cat > pages/about.md << 'EOF'
|
||||
cat > content/pages/about.md << 'EOF'
|
||||
---
|
||||
title: About Us
|
||||
slug: about
|
||||
@@ -378,14 +416,14 @@ EOF
|
||||
|
||||
The markdown content is rendered as-is, so include your own `# Heading` if desired.
|
||||
|
||||
Pages automatically appear in the navigation menu and sitemap. With Docker, mount the pages directory:
|
||||
Pages automatically appear in the navigation menu and sitemap. With Docker, mount the content directory:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (already configured)
|
||||
volumes:
|
||||
- ${PAGES_HOME:-./pages}:/pages:ro
|
||||
- ${CONTENT_HOME:-./content}:/content:ro
|
||||
environment:
|
||||
- PAGES_HOME=/pages
|
||||
- CONTENT_HOME=/content
|
||||
```
|
||||
|
||||
## Seed Data
|
||||
@@ -515,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
|
||||
|
||||
@@ -587,17 +631,25 @@ meshcore-hub/
|
||||
│ ├── collector/ # MQTT event collector
|
||||
│ ├── api/ # REST API
|
||||
│ └── web/ # Web dashboard
|
||||
│ ├── templates/ # Jinja2 templates (SPA shell)
|
||||
│ └── static/js/spa/ # SPA frontend (ES modules, lit-html)
|
||||
├── 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
|
||||
│ └── pages/ # Example custom pages
|
||||
│ └── about.md # Example about page
|
||||
│ └── content/ # Example custom content
|
||||
│ ├── pages/ # Example custom pages
|
||||
│ │ └── join.md # Example join page
|
||||
│ └── media/ # Example media files
|
||||
│ └── images/ # Custom images
|
||||
├── seed/ # Seed data directory (SEED_HOME, copy from example/seed/)
|
||||
├── pages/ # Custom pages directory (PAGES_HOME, optional)
|
||||
├── content/ # Custom content directory (CONTENT_HOME, optional)
|
||||
│ ├── pages/ # Custom markdown pages
|
||||
│ └── media/ # Custom media files
|
||||
│ └── images/ # Custom images (logo.svg replaces default logo)
|
||||
├── data/ # Runtime data directory (DATA_HOME, created at runtime)
|
||||
├── Dockerfile # Docker build configuration
|
||||
├── docker-compose.yml # Docker Compose services
|
||||
|
||||
@@ -242,7 +242,7 @@ services:
|
||||
ports:
|
||||
- "${WEB_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- ${PAGES_HOME:-./pages}:/pages:ro
|
||||
- ${CONTENT_HOME:-./content}:/content:ro
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- API_BASE_URL=http://api:8000
|
||||
@@ -259,8 +259,18 @@ services:
|
||||
- NETWORK_CONTACT_EMAIL=${NETWORK_CONTACT_EMAIL:-}
|
||||
- NETWORK_CONTACT_DISCORD=${NETWORK_CONTACT_DISCORD:-}
|
||||
- NETWORK_CONTACT_GITHUB=${NETWORK_CONTACT_GITHUB:-}
|
||||
- NETWORK_CONTACT_YOUTUBE=${NETWORK_CONTACT_YOUTUBE:-}
|
||||
- NETWORK_WELCOME_TEXT=${NETWORK_WELCOME_TEXT:-}
|
||||
- PAGES_HOME=/pages
|
||||
- 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 |
61
example/content/media/images/logo_ipnet.svg
Normal file
61
example/content/media/images/logo_ipnet.svg
Normal file
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 115 100"
|
||||
width="115"
|
||||
height="100"
|
||||
version="1.1"
|
||||
id="svg4"
|
||||
sodipodi:docname="logo-dark.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs4" />
|
||||
<sodipodi:namedview
|
||||
id="namedview4"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1" />
|
||||
<!-- I letter - muted -->
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="25"
|
||||
height="100"
|
||||
rx="2"
|
||||
fill="#ffffff"
|
||||
opacity="0.5"
|
||||
id="rect1" />
|
||||
<!-- P vertical stem -->
|
||||
<rect
|
||||
x="35"
|
||||
y="0"
|
||||
width="25"
|
||||
height="100"
|
||||
rx="2"
|
||||
fill="#ffffff"
|
||||
id="rect2" />
|
||||
<!-- WiFi arcs: center at mid-stem (90, 60), sweeping from right up to top -->
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="10"
|
||||
stroke-linecap="round"
|
||||
id="g4"
|
||||
transform="translate(-30,-10)">
|
||||
<path
|
||||
d="M 110,65 A 20,20 0 0 0 90,45"
|
||||
id="path2" />
|
||||
<path
|
||||
d="M 125,65 A 35,35 0 0 0 90,30"
|
||||
id="path3" />
|
||||
<path
|
||||
d="M 140,65 A 50,50 0 0 0 90,15"
|
||||
id="path4" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 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"
|
||||
]
|
||||
}
|
||||
@@ -253,6 +253,15 @@ class WebSettings(CommonSettings):
|
||||
web_host: str = Field(default="0.0.0.0", description="Web server host")
|
||||
web_port: int = Field(default=8080, description="Web server port")
|
||||
|
||||
# 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,
|
||||
@@ -291,22 +300,79 @@ class WebSettings(CommonSettings):
|
||||
network_contact_github: Optional[str] = Field(
|
||||
default=None, description="GitHub repository URL"
|
||||
)
|
||||
network_contact_youtube: Optional[str] = Field(
|
||||
default=None, description="YouTube channel URL"
|
||||
)
|
||||
network_welcome_text: Optional[str] = Field(
|
||||
default=None, description="Welcome text for homepage"
|
||||
)
|
||||
|
||||
# Custom pages directory
|
||||
pages_home: Optional[str] = Field(
|
||||
# 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 markdown pages (default: ./pages)",
|
||||
description="Directory containing custom content (pages/, media/) (default: ./content)",
|
||||
)
|
||||
|
||||
@property
|
||||
def effective_pages_home(self) -> str:
|
||||
"""Get the effective pages home directory."""
|
||||
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."""
|
||||
from pathlib import Path
|
||||
|
||||
return str(Path(self.pages_home or "./pages"))
|
||||
return str(Path(self.content_home or "./content"))
|
||||
|
||||
@property
|
||||
def effective_pages_home(self) -> str:
|
||||
"""Get the effective pages directory (content_home/pages)."""
|
||||
from pathlib import Path
|
||||
|
||||
return str(Path(self.effective_content_home) / "pages")
|
||||
|
||||
@property
|
||||
def effective_media_home(self) -> str:
|
||||
"""Get the effective media directory (content_home/media)."""
|
||||
from pathlib import Path
|
||||
|
||||
return str(Path(self.effective_content_home) / "media")
|
||||
|
||||
@property
|
||||
def web_data_dir(self) -> str:
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
"""FastAPI application for MeshCore Hub Web Dashboard."""
|
||||
"""FastAPI application for MeshCore Hub Web Dashboard (SPA)."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
from typing import Any, AsyncGenerator
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, Response
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||
|
||||
from meshcore_hub import __version__
|
||||
from meshcore_hub.common.schemas import RadioConfig
|
||||
@@ -50,6 +53,72 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
logger.info("Web dashboard stopped")
|
||||
|
||||
|
||||
def _build_config_json(app: FastAPI, request: Request) -> str:
|
||||
"""Build the JSON config object to embed in the SPA shell.
|
||||
|
||||
Args:
|
||||
app: The FastAPI application instance.
|
||||
request: The current HTTP request.
|
||||
|
||||
Returns:
|
||||
JSON string with app configuration.
|
||||
"""
|
||||
# Parse radio config
|
||||
radio_config = RadioConfig.from_config_string(app.state.network_radio_config)
|
||||
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 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()
|
||||
]
|
||||
if features.get("pages", True)
|
||||
else []
|
||||
)
|
||||
|
||||
config = {
|
||||
"network_name": app.state.network_name,
|
||||
"network_city": app.state.network_city,
|
||||
"network_country": app.state.network_country,
|
||||
"network_radio_config": radio_config_dict,
|
||||
"network_contact_email": app.state.network_contact_email,
|
||||
"network_contact_discord": app.state.network_contact_discord,
|
||||
"network_contact_github": app.state.network_contact_github,
|
||||
"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)
|
||||
|
||||
|
||||
def create_app(
|
||||
api_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
@@ -61,7 +130,9 @@ def create_app(
|
||||
network_contact_email: str | None = None,
|
||||
network_contact_discord: str | None = None,
|
||||
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.
|
||||
|
||||
@@ -79,7 +150,9 @@ def create_app(
|
||||
network_contact_email: Contact email address
|
||||
network_contact_discord: Discord invite/server info
|
||||
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
|
||||
@@ -98,7 +171,13 @@ def create_app(
|
||||
redoc_url=None,
|
||||
)
|
||||
|
||||
# Trust proxy headers (X-Forwarded-Proto, X-Forwarded-For) for HTTPS detection
|
||||
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 = (
|
||||
@@ -119,29 +198,309 @@ def create_app(
|
||||
app.state.network_contact_github = (
|
||||
network_contact_github or settings.network_contact_github
|
||||
)
|
||||
app.state.network_contact_youtube = (
|
||||
network_contact_youtube or settings.network_contact_youtube
|
||||
)
|
||||
app.state.network_welcome_text = (
|
||||
network_welcome_text or settings.network_welcome_text
|
||||
)
|
||||
|
||||
# Set up templates
|
||||
# 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
|
||||
templates.env.lstrip_blocks = True
|
||||
app.state.templates = templates
|
||||
|
||||
# Compute timezone
|
||||
app.state.timezone = settings.tz
|
||||
try:
|
||||
tz = ZoneInfo(settings.tz)
|
||||
app.state.timezone_abbr = datetime.now(tz).strftime("%Z")
|
||||
except Exception:
|
||||
app.state.timezone_abbr = "UTC"
|
||||
|
||||
# Initialize page loader for custom markdown pages
|
||||
page_loader = PageLoader(settings.effective_pages_home)
|
||||
page_loader.load_pages()
|
||||
app.state.page_loader = page_loader
|
||||
|
||||
# Check for custom logo and store media path
|
||||
media_home = Path(settings.effective_media_home)
|
||||
custom_logo_path = media_home / "images" / "logo.svg"
|
||||
if custom_logo_path.exists():
|
||||
app.state.logo_url = "/media/images/logo.svg"
|
||||
logger.info(f"Using custom logo from {custom_logo_path}")
|
||||
else:
|
||||
app.state.logo_url = "/static/img/logo.svg"
|
||||
|
||||
# Mount static files
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
# Include routers
|
||||
from meshcore_hub.web.routes import web_router
|
||||
# Mount custom media files if directory exists
|
||||
if media_home.exists() and media_home.is_dir():
|
||||
app.mount("/media", StaticFiles(directory=str(media_home)), name="media")
|
||||
|
||||
app.include_router(web_router)
|
||||
# --- API Proxy ---
|
||||
@app.api_route(
|
||||
"/api/{path:path}",
|
||||
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
tags=["API Proxy"],
|
||||
)
|
||||
async def api_proxy(request: Request, path: str) -> Response:
|
||||
"""Proxy API requests to the backend API server."""
|
||||
client: httpx.AsyncClient = request.app.state.http_client
|
||||
url = f"/api/{path}"
|
||||
|
||||
# Health check endpoint
|
||||
# Forward query parameters
|
||||
params = dict(request.query_params)
|
||||
|
||||
# Forward body for write methods
|
||||
body = None
|
||||
if request.method in ("POST", "PUT", "PATCH"):
|
||||
body = await request.body()
|
||||
|
||||
# Forward content-type header
|
||||
headers: dict[str, str] = {}
|
||||
if "content-type" in request.headers:
|
||||
headers["content-type"] = request.headers["content-type"]
|
||||
|
||||
# Forward auth proxy headers for admin operations
|
||||
for h in ("x-forwarded-user", "x-forwarded-email", "x-forwarded-groups"):
|
||||
if h in request.headers:
|
||||
headers[h] = request.headers[h]
|
||||
|
||||
# Block mutating requests from unauthenticated users when admin is
|
||||
# enabled. OAuth2Proxy is expected to set X-Forwarded-User for
|
||||
# authenticated sessions; without it, write operations must be
|
||||
# rejected server-side to prevent auth bypass.
|
||||
if (
|
||||
request.method in ("POST", "PUT", "DELETE", "PATCH")
|
||||
and request.app.state.admin_enabled
|
||||
and not request.headers.get("x-forwarded-user")
|
||||
):
|
||||
return JSONResponse(
|
||||
{"detail": "Authentication required"},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
response = await client.request(
|
||||
method=request.method,
|
||||
url=url,
|
||||
params=params,
|
||||
content=body,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# Filter response headers (remove hop-by-hop headers)
|
||||
resp_headers: dict[str, str] = {}
|
||||
for k, v in response.headers.items():
|
||||
if k.lower() not in (
|
||||
"transfer-encoding",
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"content-encoding",
|
||||
):
|
||||
resp_headers[k] = v
|
||||
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
headers=resp_headers,
|
||||
)
|
||||
except httpx.ConnectError:
|
||||
return JSONResponse(
|
||||
{"detail": "API server unavailable"},
|
||||
status_code=502,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"API proxy error: {e}")
|
||||
return JSONResponse(
|
||||
{"detail": "API proxy error"},
|
||||
status_code=502,
|
||||
)
|
||||
|
||||
# --- Map Data Endpoint (server-side aggregation) ---
|
||||
@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]] = {}
|
||||
error: str | None = None
|
||||
total_nodes = 0
|
||||
nodes_with_coords = 0
|
||||
|
||||
try:
|
||||
# Fetch all members to build lookup by member_id
|
||||
members_response = await request.app.state.http_client.get(
|
||||
"/api/v1/members", params={"limit": 500}
|
||||
)
|
||||
if members_response.status_code == 200:
|
||||
members_data = members_response.json()
|
||||
for member in members_data.get("items", []):
|
||||
member_info = {
|
||||
"member_id": member.get("member_id"),
|
||||
"name": member.get("name"),
|
||||
"callsign": member.get("callsign"),
|
||||
}
|
||||
members_list.append(member_info)
|
||||
if member.get("member_id"):
|
||||
members_by_id[member["member_id"]] = member_info
|
||||
|
||||
# Fetch all nodes from API
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/nodes", params={"limit": 500}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
nodes = data.get("items", [])
|
||||
total_nodes = len(nodes)
|
||||
|
||||
for node in nodes:
|
||||
tags = node.get("tags", [])
|
||||
tag_lat = None
|
||||
tag_lon = None
|
||||
friendly_name = None
|
||||
role = None
|
||||
node_member_id = None
|
||||
|
||||
for tag in tags:
|
||||
key = tag.get("key")
|
||||
if key == "lat":
|
||||
try:
|
||||
tag_lat = float(tag.get("value"))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif key == "lon":
|
||||
try:
|
||||
tag_lon = float(tag.get("value"))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif key == "friendly_name":
|
||||
friendly_name = tag.get("value")
|
||||
elif key == "role":
|
||||
role = tag.get("value")
|
||||
elif key == "member_id":
|
||||
node_member_id = tag.get("value")
|
||||
|
||||
lat = tag_lat if tag_lat is not None else node.get("lat")
|
||||
lon = tag_lon if tag_lon is not None else node.get("lon")
|
||||
|
||||
if lat is None or lon is None:
|
||||
continue
|
||||
if lat == 0.0 and lon == 0.0:
|
||||
continue
|
||||
|
||||
nodes_with_coords += 1
|
||||
display_name = (
|
||||
friendly_name
|
||||
or node.get("name")
|
||||
or node.get("public_key", "")[:12]
|
||||
)
|
||||
public_key = node.get("public_key")
|
||||
owner = (
|
||||
members_by_id.get(node_member_id) if node_member_id else None
|
||||
)
|
||||
|
||||
nodes_with_location.append(
|
||||
{
|
||||
"public_key": public_key,
|
||||
"name": display_name,
|
||||
"adv_type": node.get("adv_type"),
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"last_seen": node.get("last_seen"),
|
||||
"role": role,
|
||||
"is_infra": role == "infra",
|
||||
"member_id": node_member_id,
|
||||
"owner": owner,
|
||||
}
|
||||
)
|
||||
else:
|
||||
error = f"API returned status {response.status_code}"
|
||||
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
logger.warning(f"Failed to fetch nodes for map: {e}")
|
||||
|
||||
infra_nodes = [n for n in nodes_with_location if n.get("is_infra")]
|
||||
infra_count = len(infra_nodes)
|
||||
|
||||
center_lat = 0.0
|
||||
center_lon = 0.0
|
||||
if nodes_with_location:
|
||||
center_lat = sum(n["lat"] for n in nodes_with_location) / len(
|
||||
nodes_with_location
|
||||
)
|
||||
center_lon = sum(n["lon"] for n in nodes_with_location) / len(
|
||||
nodes_with_location
|
||||
)
|
||||
|
||||
infra_center: dict[str, float] | None = None
|
||||
if infra_nodes:
|
||||
infra_center = {
|
||||
"lat": sum(n["lat"] for n in infra_nodes) / len(infra_nodes),
|
||||
"lon": sum(n["lon"] for n in infra_nodes) / len(infra_nodes),
|
||||
}
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"nodes": nodes_with_location,
|
||||
"members": members_list,
|
||||
"center": {"lat": center_lat, "lon": center_lon},
|
||||
"infra_center": infra_center,
|
||||
"debug": {
|
||||
"total_nodes": total_nodes,
|
||||
"nodes_with_coords": nodes_with_coords,
|
||||
"infra_nodes": infra_count,
|
||||
"error": error,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# --- Custom Pages API ---
|
||||
@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:
|
||||
return JSONResponse({"detail": "Page not found"}, status_code=404)
|
||||
return JSONResponse(
|
||||
{
|
||||
"slug": page.slug,
|
||||
"title": page.title,
|
||||
"content_html": page.content_html,
|
||||
}
|
||||
)
|
||||
|
||||
# --- Health Endpoints ---
|
||||
@app.get("/health", tags=["Health"])
|
||||
async def health() -> dict:
|
||||
"""Basic health check."""
|
||||
@@ -158,34 +517,69 @@ def create_app(
|
||||
except Exception as e:
|
||||
return {"status": "not_ready", "api": str(e)}
|
||||
|
||||
# --- SEO Endpoints ---
|
||||
def _get_https_base_url(request: Request) -> str:
|
||||
"""Get base URL, ensuring HTTPS is used for public-facing URLs."""
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
# Ensure HTTPS for sitemaps and robots.txt (SEO requires canonical URLs)
|
||||
if base_url.startswith("http://"):
|
||||
base_url = "https://" + base_url[7:]
|
||||
return base_url
|
||||
|
||||
@app.get("/robots.txt", response_class=PlainTextResponse)
|
||||
async def robots_txt(request: Request) -> str:
|
||||
"""Serve robots.txt to control search engine crawling."""
|
||||
"""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
|
||||
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 = []
|
||||
@@ -198,41 +592,16 @@ def create_app(
|
||||
f" </url>"
|
||||
)
|
||||
|
||||
# Fetch infrastructure nodes for dynamic pages
|
||||
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:
|
||||
# Use 8-char prefix (route handles redirect to full 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>"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to fetch nodes for sitemap: {response.status_code}"
|
||||
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>"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch nodes for sitemap: {e}")
|
||||
|
||||
# Add custom pages to sitemap
|
||||
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'
|
||||
@@ -243,54 +612,39 @@ def create_app(
|
||||
|
||||
return Response(content=xml, media_type="application/xml")
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def http_exception_handler(
|
||||
request: Request, exc: StarletteHTTPException
|
||||
) -> HTMLResponse:
|
||||
"""Handle HTTP exceptions with custom error pages."""
|
||||
if exc.status_code == 404:
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
context["detail"] = exc.detail if exc.detail != "Not Found" else None
|
||||
return templates.TemplateResponse(
|
||||
"errors/404.html", context, status_code=404
|
||||
)
|
||||
# For other errors, return a simple response
|
||||
return HTMLResponse(
|
||||
content=f"<h1>{exc.status_code}</h1><p>{exc.detail}</p>",
|
||||
status_code=exc.status_code,
|
||||
# --- SPA Catch-All (MUST be last) ---
|
||||
@app.api_route("/{path:path}", methods=["GET"], tags=["SPA"])
|
||||
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() if features.get("pages", True) else []
|
||||
)
|
||||
|
||||
config_json = _build_config_json(request.app, request)
|
||||
|
||||
return templates_inst.TemplateResponse(
|
||||
"spa.html",
|
||||
{
|
||||
"request": request,
|
||||
"network_name": request.app.state.network_name,
|
||||
"network_city": request.app.state.network_city,
|
||||
"network_country": request.app.state.network_country,
|
||||
"network_contact_email": request.app.state.network_contact_email,
|
||||
"network_contact_discord": request.app.state.network_contact_discord,
|
||||
"network_contact_github": request.app.state.network_contact_github,
|
||||
"network_contact_youtube": request.app.state.network_contact_youtube,
|
||||
"network_welcome_text": request.app.state.network_welcome_text,
|
||||
"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,
|
||||
},
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def get_templates(request: Request) -> Jinja2Templates:
|
||||
"""Get templates from app state."""
|
||||
templates: Jinja2Templates = request.app.state.templates
|
||||
return templates
|
||||
|
||||
|
||||
def get_network_context(request: Request) -> dict:
|
||||
"""Get network configuration context for templates."""
|
||||
# Parse radio config from comma-delimited string
|
||||
radio_config = RadioConfig.from_config_string(
|
||||
request.app.state.network_radio_config
|
||||
)
|
||||
|
||||
# Get custom pages for navigation
|
||||
page_loader = request.app.state.page_loader
|
||||
custom_pages = page_loader.get_menu_pages()
|
||||
|
||||
return {
|
||||
"network_name": request.app.state.network_name,
|
||||
"network_city": request.app.state.network_city,
|
||||
"network_country": request.app.state.network_country,
|
||||
"network_radio_config": radio_config,
|
||||
"network_contact_email": request.app.state.network_contact_email,
|
||||
"network_contact_discord": request.app.state.network_contact_discord,
|
||||
"network_contact_github": request.app.state.network_contact_github,
|
||||
"network_welcome_text": request.app.state.network_welcome_text,
|
||||
"admin_enabled": request.app.state.admin_enabled,
|
||||
"custom_pages": custom_pages,
|
||||
"version": __version__,
|
||||
}
|
||||
|
||||
@@ -88,6 +88,13 @@ import click
|
||||
envvar="NETWORK_CONTACT_GITHUB",
|
||||
help="GitHub repository URL",
|
||||
)
|
||||
@click.option(
|
||||
"--network-contact-youtube",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="NETWORK_CONTACT_YOUTUBE",
|
||||
help="YouTube channel URL",
|
||||
)
|
||||
@click.option(
|
||||
"--network-welcome-text",
|
||||
type=str,
|
||||
@@ -116,6 +123,7 @@ def web(
|
||||
network_contact_email: str | None,
|
||||
network_contact_discord: str | None,
|
||||
network_contact_github: str | None,
|
||||
network_contact_youtube: str | None,
|
||||
network_welcome_text: str | None,
|
||||
reload: bool,
|
||||
) -> None:
|
||||
@@ -175,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:
|
||||
@@ -201,6 +214,7 @@ def web(
|
||||
network_contact_email=network_contact_email,
|
||||
network_contact_discord=network_contact_discord,
|
||||
network_contact_github=network_contact_github,
|
||||
network_contact_youtube=network_contact_youtube,
|
||||
network_welcome_text=network_welcome_text,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Web routes for MeshCore Hub Dashboard."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from meshcore_hub.web.routes.home import router as home_router
|
||||
from meshcore_hub.web.routes.dashboard import router as dashboard_router
|
||||
from meshcore_hub.web.routes.nodes import router as nodes_router
|
||||
from meshcore_hub.web.routes.messages import router as messages_router
|
||||
from meshcore_hub.web.routes.advertisements import router as advertisements_router
|
||||
from meshcore_hub.web.routes.map import router as map_router
|
||||
from meshcore_hub.web.routes.members import router as members_router
|
||||
from meshcore_hub.web.routes.admin import router as admin_router
|
||||
from meshcore_hub.web.routes.pages import router as pages_router
|
||||
|
||||
# Create main web router
|
||||
web_router = APIRouter()
|
||||
|
||||
# Include all sub-routers
|
||||
web_router.include_router(home_router)
|
||||
web_router.include_router(dashboard_router)
|
||||
web_router.include_router(nodes_router)
|
||||
web_router.include_router(messages_router)
|
||||
web_router.include_router(advertisements_router)
|
||||
web_router.include_router(map_router)
|
||||
web_router.include_router(members_router)
|
||||
web_router.include_router(admin_router)
|
||||
web_router.include_router(pages_router)
|
||||
|
||||
__all__ = ["web_router"]
|
||||
@@ -1,591 +0,0 @@
|
||||
"""Admin page routes."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from httpx import Response
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
|
||||
def _build_redirect_url(
|
||||
public_key: str,
|
||||
message: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Build a properly encoded redirect URL with optional message/error."""
|
||||
params: dict[str, str] = {"public_key": public_key}
|
||||
if message:
|
||||
params["message"] = message
|
||||
if error:
|
||||
params["error"] = error
|
||||
return f"/a/node-tags?{urlencode(params)}"
|
||||
|
||||
|
||||
def _get_error_detail(response: Response) -> str:
|
||||
"""Safely extract error detail from response JSON."""
|
||||
try:
|
||||
data: Any = response.json()
|
||||
detail: str = data.get("detail", "Unknown error")
|
||||
return detail
|
||||
except Exception:
|
||||
return "Unknown error"
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/a", tags=["admin"])
|
||||
|
||||
|
||||
def _check_admin_enabled(request: Request) -> None:
|
||||
"""Check if admin interface is enabled, raise 404 if not."""
|
||||
if not getattr(request.app.state, "admin_enabled", False):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
|
||||
|
||||
def _get_auth_context(request: Request) -> dict:
|
||||
"""Extract OAuth2Proxy authentication headers."""
|
||||
return {
|
||||
"auth_user": request.headers.get("X-Forwarded-User"),
|
||||
"auth_groups": request.headers.get("X-Forwarded-Groups"),
|
||||
"auth_email": request.headers.get("X-Forwarded-Email"),
|
||||
"auth_username": request.headers.get("X-Forwarded-Preferred-Username"),
|
||||
}
|
||||
|
||||
|
||||
def _is_authenticated(request: Request) -> bool:
|
||||
"""Check if user is authenticated via OAuth2Proxy headers."""
|
||||
return bool(
|
||||
request.headers.get("X-Forwarded-User")
|
||||
or request.headers.get("X-Forwarded-Email")
|
||||
)
|
||||
|
||||
|
||||
def _require_auth(request: Request) -> None:
|
||||
"""Require authentication, raise 403 if not authenticated."""
|
||||
if not _is_authenticated(request):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def admin_home(request: Request) -> HTMLResponse:
|
||||
"""Render the admin page with OAuth2Proxy user info."""
|
||||
_check_admin_enabled(request)
|
||||
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
context.update(_get_auth_context(request))
|
||||
|
||||
# Check if user is authenticated
|
||||
if not _is_authenticated(request):
|
||||
return templates.TemplateResponse(
|
||||
"admin/access_denied.html", context, status_code=403
|
||||
)
|
||||
|
||||
return templates.TemplateResponse("admin/index.html", context)
|
||||
|
||||
|
||||
@router.get("/node-tags", response_class=HTMLResponse)
|
||||
async def admin_node_tags(
|
||||
request: Request,
|
||||
public_key: Optional[str] = Query(None),
|
||||
message: Optional[str] = Query(None),
|
||||
error: Optional[str] = Query(None),
|
||||
) -> HTMLResponse:
|
||||
"""Admin page for managing node tags."""
|
||||
_check_admin_enabled(request)
|
||||
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
context.update(_get_auth_context(request))
|
||||
|
||||
# Check if user is authenticated
|
||||
if not _is_authenticated(request):
|
||||
return templates.TemplateResponse(
|
||||
"admin/access_denied.html", context, status_code=403
|
||||
)
|
||||
|
||||
# Flash messages from redirects
|
||||
context["message"] = message
|
||||
context["error"] = error
|
||||
|
||||
# Fetch all nodes for dropdown
|
||||
nodes = []
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/nodes",
|
||||
params={"limit": 100},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
nodes = data.get("items", [])
|
||||
# Sort nodes alphabetically by name (unnamed nodes at the end)
|
||||
nodes.sort(
|
||||
key=lambda n: (n.get("name") is None, (n.get("name") or "").lower())
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to fetch nodes: %s", e)
|
||||
context["error"] = "Failed to fetch nodes"
|
||||
|
||||
context["nodes"] = nodes
|
||||
context["selected_public_key"] = public_key
|
||||
|
||||
# Fetch tags for selected node
|
||||
tags = []
|
||||
selected_node = None
|
||||
if public_key:
|
||||
# Find the selected node in the list
|
||||
for node in nodes:
|
||||
if node.get("public_key") == public_key:
|
||||
selected_node = node
|
||||
break
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
f"/api/v1/nodes/{public_key}/tags",
|
||||
)
|
||||
if response.status_code == 200:
|
||||
tags = response.json()
|
||||
elif response.status_code == 404:
|
||||
context["error"] = "Node not found"
|
||||
except Exception as e:
|
||||
logger.exception("Failed to fetch tags: %s", e)
|
||||
context["error"] = "Failed to fetch tags"
|
||||
|
||||
context["tags"] = tags
|
||||
context["selected_node"] = selected_node
|
||||
|
||||
return templates.TemplateResponse("admin/node_tags.html", context)
|
||||
|
||||
|
||||
@router.post("/node-tags", response_class=RedirectResponse)
|
||||
async def admin_create_node_tag(
|
||||
request: Request,
|
||||
public_key: str = Form(...),
|
||||
key: str = Form(...),
|
||||
value: str = Form(""),
|
||||
value_type: str = Form("string"),
|
||||
) -> RedirectResponse:
|
||||
"""Create a new node tag."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.post(
|
||||
f"/api/v1/nodes/{public_key}/tags",
|
||||
json={
|
||||
"key": key,
|
||||
"value": value or None,
|
||||
"value_type": value_type,
|
||||
},
|
||||
)
|
||||
if response.status_code == 201:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, message=f"Tag '{key}' created successfully"
|
||||
)
|
||||
elif response.status_code == 409:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=f"Tag '{key}' already exists"
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_redirect_url(public_key, error="Node not found")
|
||||
else:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to create tag: %s", e)
|
||||
redirect_url = _build_redirect_url(public_key, error="Failed to create tag")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/node-tags/update", response_class=RedirectResponse)
|
||||
async def admin_update_node_tag(
|
||||
request: Request,
|
||||
public_key: str = Form(...),
|
||||
key: str = Form(...),
|
||||
value: str = Form(""),
|
||||
value_type: str = Form("string"),
|
||||
) -> RedirectResponse:
|
||||
"""Update an existing node tag."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.put(
|
||||
f"/api/v1/nodes/{public_key}/tags/{key}",
|
||||
json={
|
||||
"value": value or None,
|
||||
"value_type": value_type,
|
||||
},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, message=f"Tag '{key}' updated successfully"
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=f"Tag '{key}' not found"
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to update tag: %s", e)
|
||||
redirect_url = _build_redirect_url(public_key, error="Failed to update tag")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/node-tags/move", response_class=RedirectResponse)
|
||||
async def admin_move_node_tag(
|
||||
request: Request,
|
||||
public_key: str = Form(...),
|
||||
key: str = Form(...),
|
||||
new_public_key: str = Form(...),
|
||||
) -> RedirectResponse:
|
||||
"""Move a node tag to a different node."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.put(
|
||||
f"/api/v1/nodes/{public_key}/tags/{key}/move",
|
||||
json={"new_public_key": new_public_key},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
# Redirect to the destination node after successful move
|
||||
redirect_url = _build_redirect_url(
|
||||
new_public_key, message=f"Tag '{key}' moved successfully"
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
# Stay on source node if not found
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
elif response.status_code == 409:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=f"Tag '{key}' already exists on destination node"
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to move tag: %s", e)
|
||||
redirect_url = _build_redirect_url(public_key, error="Failed to move tag")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/node-tags/delete", response_class=RedirectResponse)
|
||||
async def admin_delete_node_tag(
|
||||
request: Request,
|
||||
public_key: str = Form(...),
|
||||
key: str = Form(...),
|
||||
) -> RedirectResponse:
|
||||
"""Delete a node tag."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.delete(
|
||||
f"/api/v1/nodes/{public_key}/tags/{key}",
|
||||
)
|
||||
if response.status_code == 204:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, message=f"Tag '{key}' deleted successfully"
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=f"Tag '{key}' not found"
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to delete tag: %s", e)
|
||||
redirect_url = _build_redirect_url(public_key, error="Failed to delete tag")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/node-tags/copy-all", response_class=RedirectResponse)
|
||||
async def admin_copy_all_tags(
|
||||
request: Request,
|
||||
public_key: str = Form(...),
|
||||
dest_public_key: str = Form(...),
|
||||
) -> RedirectResponse:
|
||||
"""Copy all tags from one node to another."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.post(
|
||||
f"/api/v1/nodes/{public_key}/tags/copy-to/{dest_public_key}",
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
copied = data.get("copied", 0)
|
||||
skipped = data.get("skipped", 0)
|
||||
if skipped > 0:
|
||||
message = f"Copied {copied} tag(s), skipped {skipped} existing"
|
||||
else:
|
||||
message = f"Copied {copied} tag(s) successfully"
|
||||
# Redirect to destination node to show copied tags
|
||||
redirect_url = _build_redirect_url(dest_public_key, message=message)
|
||||
elif response.status_code == 400:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to copy tags: %s", e)
|
||||
redirect_url = _build_redirect_url(public_key, error="Failed to copy tags")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/node-tags/delete-all", response_class=RedirectResponse)
|
||||
async def admin_delete_all_tags(
|
||||
request: Request,
|
||||
public_key: str = Form(...),
|
||||
) -> RedirectResponse:
|
||||
"""Delete all tags from a node."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.delete(
|
||||
f"/api/v1/nodes/{public_key}/tags",
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
deleted = data.get("deleted", 0)
|
||||
message = f"Deleted {deleted} tag(s) successfully"
|
||||
redirect_url = _build_redirect_url(public_key, message=message)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to delete tags: %s", e)
|
||||
redirect_url = _build_redirect_url(public_key, error="Failed to delete tags")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
def _build_members_redirect_url(
|
||||
message: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Build a properly encoded redirect URL for members page with optional message/error."""
|
||||
params: dict[str, str] = {}
|
||||
if message:
|
||||
params["message"] = message
|
||||
if error:
|
||||
params["error"] = error
|
||||
if params:
|
||||
return f"/a/members?{urlencode(params)}"
|
||||
return "/a/members"
|
||||
|
||||
|
||||
@router.get("/members", response_class=HTMLResponse)
|
||||
async def admin_members(
|
||||
request: Request,
|
||||
message: Optional[str] = Query(None),
|
||||
error: Optional[str] = Query(None),
|
||||
) -> HTMLResponse:
|
||||
"""Admin page for managing members."""
|
||||
_check_admin_enabled(request)
|
||||
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
context.update(_get_auth_context(request))
|
||||
|
||||
# Check if user is authenticated
|
||||
if not _is_authenticated(request):
|
||||
return templates.TemplateResponse(
|
||||
"admin/access_denied.html", context, status_code=403
|
||||
)
|
||||
|
||||
# Flash messages from redirects
|
||||
context["message"] = message
|
||||
context["error"] = error
|
||||
|
||||
# Fetch all members
|
||||
members = []
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/members",
|
||||
params={"limit": 500},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
members = data.get("items", [])
|
||||
# Sort members alphabetically by name
|
||||
members.sort(key=lambda m: m.get("name", "").lower())
|
||||
except Exception as e:
|
||||
logger.exception("Failed to fetch members: %s", e)
|
||||
context["error"] = "Failed to fetch members"
|
||||
|
||||
context["members"] = members
|
||||
|
||||
return templates.TemplateResponse("admin/members.html", context)
|
||||
|
||||
|
||||
@router.post("/members", response_class=RedirectResponse)
|
||||
async def admin_create_member(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
member_id: str = Form(...),
|
||||
callsign: Optional[str] = Form(None),
|
||||
role: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
contact: Optional[str] = Form(None),
|
||||
) -> RedirectResponse:
|
||||
"""Create a new member."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
# Build request payload
|
||||
payload = {
|
||||
"name": name,
|
||||
"member_id": member_id,
|
||||
}
|
||||
if callsign:
|
||||
payload["callsign"] = callsign
|
||||
if role:
|
||||
payload["role"] = role
|
||||
if description:
|
||||
payload["description"] = description
|
||||
if contact:
|
||||
payload["contact"] = contact
|
||||
|
||||
response = await request.app.state.http_client.post(
|
||||
"/api/v1/members",
|
||||
json=payload,
|
||||
)
|
||||
if response.status_code == 201:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
message=f"Member '{name}' created successfully"
|
||||
)
|
||||
elif response.status_code == 409:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
error=f"Member ID '{member_id}' already exists"
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to create member: %s", e)
|
||||
redirect_url = _build_members_redirect_url(error="Failed to create member")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/members/update", response_class=RedirectResponse)
|
||||
async def admin_update_member(
|
||||
request: Request,
|
||||
id: str = Form(...),
|
||||
name: Optional[str] = Form(None),
|
||||
member_id: Optional[str] = Form(None),
|
||||
callsign: Optional[str] = Form(None),
|
||||
role: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
contact: Optional[str] = Form(None),
|
||||
) -> RedirectResponse:
|
||||
"""Update an existing member."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
# Build update payload (only include non-None fields)
|
||||
payload: dict[str, str | None] = {}
|
||||
if name is not None:
|
||||
payload["name"] = name
|
||||
if member_id is not None:
|
||||
payload["member_id"] = member_id
|
||||
if callsign is not None:
|
||||
payload["callsign"] = callsign if callsign else None
|
||||
if role is not None:
|
||||
payload["role"] = role if role else None
|
||||
if description is not None:
|
||||
payload["description"] = description if description else None
|
||||
if contact is not None:
|
||||
payload["contact"] = contact if contact else None
|
||||
|
||||
response = await request.app.state.http_client.put(
|
||||
f"/api/v1/members/{id}",
|
||||
json=payload,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
message="Member updated successfully"
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_members_redirect_url(error="Member not found")
|
||||
elif response.status_code == 409:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
error=f"Member ID '{member_id}' already exists"
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to update member: %s", e)
|
||||
redirect_url = _build_members_redirect_url(error="Failed to update member")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/members/delete", response_class=RedirectResponse)
|
||||
async def admin_delete_member(
|
||||
request: Request,
|
||||
id: str = Form(...),
|
||||
) -> RedirectResponse:
|
||||
"""Delete a member."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.delete(
|
||||
f"/api/v1/members/{id}",
|
||||
)
|
||||
if response.status_code == 204:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
message="Member deleted successfully"
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_members_redirect_url(error="Member not found")
|
||||
else:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to delete member: %s", e)
|
||||
redirect_url = _build_members_redirect_url(error="Failed to delete member")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
@@ -1,99 +0,0 @@
|
||||
"""Advertisements page route."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/advertisements", response_class=HTMLResponse)
|
||||
async def advertisements_list(
|
||||
request: Request,
|
||||
search: str | None = Query(None, description="Search term"),
|
||||
member_id: str | None = Query(None, description="Filter by member"),
|
||||
public_key: str | None = Query(None, description="Filter by node public key"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Items per page"),
|
||||
) -> HTMLResponse:
|
||||
"""Render the advertisements list page."""
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
# Calculate offset
|
||||
offset = (page - 1) * limit
|
||||
|
||||
# Build query params
|
||||
params: dict[str, int | str] = {"limit": limit, "offset": offset}
|
||||
if search:
|
||||
params["search"] = search
|
||||
if member_id:
|
||||
params["member_id"] = member_id
|
||||
if public_key:
|
||||
params["public_key"] = public_key
|
||||
|
||||
# Fetch advertisements from API
|
||||
advertisements = []
|
||||
total = 0
|
||||
members = []
|
||||
nodes = []
|
||||
|
||||
try:
|
||||
# Fetch members for dropdown
|
||||
members_response = await request.app.state.http_client.get(
|
||||
"/api/v1/members", params={"limit": 100}
|
||||
)
|
||||
if members_response.status_code == 200:
|
||||
members = members_response.json().get("items", [])
|
||||
|
||||
# Fetch nodes for dropdown
|
||||
nodes_response = await request.app.state.http_client.get(
|
||||
"/api/v1/nodes", params={"limit": 500}
|
||||
)
|
||||
if nodes_response.status_code == 200:
|
||||
nodes = nodes_response.json().get("items", [])
|
||||
|
||||
# Sort nodes alphabetically by display name
|
||||
def get_node_display_name(node: dict) -> str:
|
||||
for tag in node.get("tags") or []:
|
||||
if tag.get("key") == "name":
|
||||
return str(tag.get("value", "")).lower()
|
||||
return str(node.get("name") or node.get("public_key", "")).lower()
|
||||
|
||||
nodes.sort(key=get_node_display_name)
|
||||
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/advertisements", params=params
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
advertisements = data.get("items", [])
|
||||
total = data.get("total", 0)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch advertisements from API: {e}")
|
||||
context["api_error"] = str(e)
|
||||
|
||||
# Calculate pagination
|
||||
total_pages = (total + limit - 1) // limit if total > 0 else 1
|
||||
|
||||
context.update(
|
||||
{
|
||||
"advertisements": advertisements,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total_pages": total_pages,
|
||||
"search": search or "",
|
||||
"member_id": member_id or "",
|
||||
"public_key": public_key or "",
|
||||
"members": members,
|
||||
"nodes": nodes,
|
||||
}
|
||||
)
|
||||
|
||||
return templates.TemplateResponse("advertisements.html", context)
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Dashboard page route."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/dashboard", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request) -> HTMLResponse:
|
||||
"""Render the dashboard page."""
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
# Fetch stats from API
|
||||
stats = {
|
||||
"total_nodes": 0,
|
||||
"active_nodes": 0,
|
||||
"total_messages": 0,
|
||||
"messages_today": 0,
|
||||
"total_advertisements": 0,
|
||||
"advertisements_24h": 0,
|
||||
"recent_advertisements": [],
|
||||
"channel_message_counts": {},
|
||||
}
|
||||
|
||||
# Fetch activity data for charts (7 days)
|
||||
advert_activity = {"days": 7, "data": []}
|
||||
message_activity = {"days": 7, "data": []}
|
||||
node_count = {"days": 7, "data": []}
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.get("/api/v1/dashboard/stats")
|
||||
if response.status_code == 200:
|
||||
stats = response.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch stats from API: {e}")
|
||||
context["api_error"] = str(e)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/dashboard/activity", params={"days": 7}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
advert_activity = response.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch advertisement activity from API: {e}")
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/dashboard/message-activity", params={"days": 7}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
message_activity = response.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch message activity from API: {e}")
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/dashboard/node-count", params={"days": 7}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
node_count = response.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch node count from API: {e}")
|
||||
|
||||
context["stats"] = stats
|
||||
context["advert_activity_json"] = json.dumps(advert_activity)
|
||||
context["message_activity_json"] = json.dumps(message_activity)
|
||||
context["node_count_json"] = json.dumps(node_count)
|
||||
|
||||
return templates.TemplateResponse("dashboard.html", context)
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Home page route."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request) -> HTMLResponse:
|
||||
"""Render the home page."""
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
# Fetch stats from API
|
||||
stats = {
|
||||
"total_nodes": 0,
|
||||
"active_nodes": 0,
|
||||
"total_messages": 0,
|
||||
"messages_today": 0,
|
||||
"total_advertisements": 0,
|
||||
"advertisements_24h": 0,
|
||||
}
|
||||
|
||||
# Fetch activity data for charts
|
||||
advert_activity = {"days": 7, "data": []}
|
||||
message_activity = {"days": 7, "data": []}
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.get("/api/v1/dashboard/stats")
|
||||
if response.status_code == 200:
|
||||
stats = response.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch stats from API: {e}")
|
||||
context["api_error"] = str(e)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/dashboard/activity", params={"days": 7}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
advert_activity = response.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch activity from API: {e}")
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/dashboard/message-activity", params={"days": 7}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
message_activity = response.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch message activity from API: {e}")
|
||||
|
||||
context["stats"] = stats
|
||||
# Pass activity data as JSON strings for the chart
|
||||
context["advert_activity_json"] = json.dumps(advert_activity)
|
||||
context["message_activity_json"] = json.dumps(message_activity)
|
||||
|
||||
return templates.TemplateResponse("home.html", context)
|
||||
@@ -1,162 +0,0 @@
|
||||
"""Map page route."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/map", response_class=HTMLResponse)
|
||||
async def map_page(request: Request) -> HTMLResponse:
|
||||
"""Render the map page."""
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
return templates.TemplateResponse("map.html", context)
|
||||
|
||||
|
||||
@router.get("/map/data")
|
||||
async def map_data(request: Request) -> JSONResponse:
|
||||
"""Return node location data as JSON for the map.
|
||||
|
||||
Includes role tag, member ownership info, and all data needed for filtering.
|
||||
"""
|
||||
nodes_with_location: list[dict[str, Any]] = []
|
||||
members_list: list[dict[str, Any]] = []
|
||||
members_by_id: dict[str, dict[str, Any]] = {}
|
||||
error: str | None = None
|
||||
total_nodes = 0
|
||||
nodes_with_coords = 0
|
||||
|
||||
try:
|
||||
# Fetch all members to build lookup by member_id
|
||||
members_response = await request.app.state.http_client.get(
|
||||
"/api/v1/members", params={"limit": 500}
|
||||
)
|
||||
if members_response.status_code == 200:
|
||||
members_data = members_response.json()
|
||||
for member in members_data.get("items", []):
|
||||
member_info = {
|
||||
"member_id": member.get("member_id"),
|
||||
"name": member.get("name"),
|
||||
"callsign": member.get("callsign"),
|
||||
}
|
||||
members_list.append(member_info)
|
||||
if member.get("member_id"):
|
||||
members_by_id[member["member_id"]] = member_info
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to fetch members: status {members_response.status_code}"
|
||||
)
|
||||
|
||||
# Fetch all nodes from API
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/nodes", params={"limit": 500}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
nodes = data.get("items", [])
|
||||
total_nodes = len(nodes)
|
||||
|
||||
# Filter nodes with location tags
|
||||
for node in nodes:
|
||||
tags = node.get("tags", [])
|
||||
lat = None
|
||||
lon = None
|
||||
friendly_name = None
|
||||
role = None
|
||||
node_member_id = None
|
||||
|
||||
for tag in tags:
|
||||
key = tag.get("key")
|
||||
if key == "lat":
|
||||
try:
|
||||
lat = float(tag.get("value"))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif key == "lon":
|
||||
try:
|
||||
lon = float(tag.get("value"))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif key == "friendly_name":
|
||||
friendly_name = tag.get("value")
|
||||
elif key == "role":
|
||||
role = tag.get("value")
|
||||
elif key == "member_id":
|
||||
node_member_id = tag.get("value")
|
||||
|
||||
if lat is not None and lon is not None:
|
||||
nodes_with_coords += 1
|
||||
# Use friendly_name, then node name, then public key prefix
|
||||
display_name = (
|
||||
friendly_name
|
||||
or node.get("name")
|
||||
or node.get("public_key", "")[:12]
|
||||
)
|
||||
public_key = node.get("public_key")
|
||||
|
||||
# Find owner member by member_id tag
|
||||
owner = (
|
||||
members_by_id.get(node_member_id) if node_member_id else None
|
||||
)
|
||||
|
||||
nodes_with_location.append(
|
||||
{
|
||||
"public_key": public_key,
|
||||
"name": display_name,
|
||||
"adv_type": node.get("adv_type"),
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"last_seen": node.get("last_seen"),
|
||||
"role": role,
|
||||
"is_infra": role == "infra",
|
||||
"member_id": node_member_id,
|
||||
"owner": owner,
|
||||
}
|
||||
)
|
||||
else:
|
||||
error = f"API returned status {response.status_code}"
|
||||
logger.warning(f"Failed to fetch nodes: {error}")
|
||||
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
logger.warning(f"Failed to fetch nodes for map: {e}")
|
||||
|
||||
logger.info(
|
||||
f"Map data: {total_nodes} total nodes, " f"{nodes_with_coords} with coordinates"
|
||||
)
|
||||
|
||||
# Calculate center from nodes, or use default (0, 0)
|
||||
center_lat = 0.0
|
||||
center_lon = 0.0
|
||||
if nodes_with_location:
|
||||
center_lat = sum(n["lat"] for n in nodes_with_location) / len(
|
||||
nodes_with_location
|
||||
)
|
||||
center_lon = sum(n["lon"] for n in nodes_with_location) / len(
|
||||
nodes_with_location
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"nodes": nodes_with_location,
|
||||
"members": members_list,
|
||||
"center": {
|
||||
"lat": center_lat,
|
||||
"lon": center_lon,
|
||||
},
|
||||
"debug": {
|
||||
"total_nodes": total_nodes,
|
||||
"nodes_with_coords": nodes_with_coords,
|
||||
"error": error,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -1,98 +0,0 @@
|
||||
"""Members page route."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/members", response_class=HTMLResponse)
|
||||
async def members_page(request: Request) -> HTMLResponse:
|
||||
"""Render the members page."""
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
# Fetch members from API
|
||||
members = []
|
||||
|
||||
def node_sort_key(node: dict) -> int:
|
||||
"""Sort nodes: repeater first, then chat, then others."""
|
||||
adv_type = (node.get("adv_type") or "").lower()
|
||||
if adv_type == "repeater":
|
||||
return 0
|
||||
if adv_type == "chat":
|
||||
return 1
|
||||
return 2
|
||||
|
||||
try:
|
||||
# Fetch all members
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/members", params={"limit": 100}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
members = data.get("items", [])
|
||||
|
||||
# Fetch all nodes with member_id tags in one query
|
||||
nodes_response = await request.app.state.http_client.get(
|
||||
"/api/v1/nodes", params={"has_tag": "member_id", "limit": 500}
|
||||
)
|
||||
|
||||
# Build a map of member_id -> nodes
|
||||
member_nodes_map: dict[str, list] = {}
|
||||
if nodes_response.status_code == 200:
|
||||
nodes_data = nodes_response.json()
|
||||
all_nodes = nodes_data.get("items", [])
|
||||
|
||||
for node in all_nodes:
|
||||
# Find member_id tag
|
||||
for tag in node.get("tags", []):
|
||||
if tag.get("key") == "member_id":
|
||||
member_id_value = tag.get("value")
|
||||
if member_id_value:
|
||||
if member_id_value not in member_nodes_map:
|
||||
member_nodes_map[member_id_value] = []
|
||||
member_nodes_map[member_id_value].append(node)
|
||||
break
|
||||
|
||||
# Assign nodes to members and sort
|
||||
for member in members:
|
||||
member_id = member.get("member_id")
|
||||
if member_id and member_id in member_nodes_map:
|
||||
# Sort nodes (repeater first, then chat, then by name tag)
|
||||
nodes = member_nodes_map[member_id]
|
||||
|
||||
# Sort by advertisement type first, then by name
|
||||
def full_sort_key(node: dict) -> tuple:
|
||||
adv_type = (node.get("adv_type") or "").lower()
|
||||
type_priority = (
|
||||
0
|
||||
if adv_type == "repeater"
|
||||
else (1 if adv_type == "chat" else 2)
|
||||
)
|
||||
|
||||
# Get name from tags
|
||||
node_name = node.get("name") or ""
|
||||
for tag in node.get("tags", []):
|
||||
if tag.get("key") == "name":
|
||||
node_name = tag.get("value") or node_name
|
||||
break
|
||||
|
||||
return (type_priority, node_name.lower())
|
||||
|
||||
member["nodes"] = sorted(nodes, key=full_sort_key)
|
||||
else:
|
||||
member["nodes"] = []
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch members from API: {e}")
|
||||
context["api_error"] = str(e)
|
||||
|
||||
context["members"] = members
|
||||
|
||||
return templates.TemplateResponse("members.html", context)
|
||||
@@ -1,66 +0,0 @@
|
||||
"""Messages page route."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/messages", response_class=HTMLResponse)
|
||||
async def messages_list(
|
||||
request: Request,
|
||||
message_type: str | None = Query(None, description="Filter by message type"),
|
||||
search: str | None = Query(None, description="Search in message text"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Items per page"),
|
||||
) -> HTMLResponse:
|
||||
"""Render the messages list page."""
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
# Calculate offset
|
||||
offset = (page - 1) * limit
|
||||
|
||||
# Build query params
|
||||
params: dict[str, int | str] = {"limit": limit, "offset": offset}
|
||||
if message_type:
|
||||
params["message_type"] = message_type
|
||||
|
||||
# Fetch messages from API
|
||||
messages = []
|
||||
total = 0
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/messages", params=params
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
messages = data.get("items", [])
|
||||
total = data.get("total", 0)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch messages from API: {e}")
|
||||
context["api_error"] = str(e)
|
||||
|
||||
# Calculate pagination
|
||||
total_pages = (total + limit - 1) // limit if total > 0 else 1
|
||||
|
||||
context.update(
|
||||
{
|
||||
"messages": messages,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total_pages": total_pages,
|
||||
"message_type": message_type or "",
|
||||
"search": search or "",
|
||||
}
|
||||
)
|
||||
|
||||
return templates.TemplateResponse("messages.html", context)
|
||||
@@ -1,156 +0,0 @@
|
||||
"""Nodes page routes."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/nodes", response_class=HTMLResponse)
|
||||
async def nodes_list(
|
||||
request: Request,
|
||||
search: str | None = Query(None, description="Search term"),
|
||||
adv_type: str | None = Query(None, description="Filter by node type"),
|
||||
member_id: str | None = Query(None, description="Filter by member"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
limit: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||
) -> HTMLResponse:
|
||||
"""Render the nodes list page."""
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
# Calculate offset
|
||||
offset = (page - 1) * limit
|
||||
|
||||
# Build query params
|
||||
params: dict[str, int | str] = {"limit": limit, "offset": offset}
|
||||
if search:
|
||||
params["search"] = search
|
||||
if adv_type:
|
||||
params["adv_type"] = adv_type
|
||||
if member_id:
|
||||
params["member_id"] = member_id
|
||||
|
||||
# Fetch nodes from API
|
||||
nodes = []
|
||||
total = 0
|
||||
members = []
|
||||
|
||||
try:
|
||||
# Fetch members for dropdown
|
||||
members_response = await request.app.state.http_client.get(
|
||||
"/api/v1/members", params={"limit": 100}
|
||||
)
|
||||
if members_response.status_code == 200:
|
||||
members = members_response.json().get("items", [])
|
||||
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/nodes", params=params
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
nodes = data.get("items", [])
|
||||
total = data.get("total", 0)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch nodes from API: {e}")
|
||||
context["api_error"] = str(e)
|
||||
|
||||
# Calculate pagination
|
||||
total_pages = (total + limit - 1) // limit if total > 0 else 1
|
||||
|
||||
context.update(
|
||||
{
|
||||
"nodes": nodes,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total_pages": total_pages,
|
||||
"search": search or "",
|
||||
"adv_type": adv_type or "",
|
||||
"member_id": member_id or "",
|
||||
"members": members,
|
||||
}
|
||||
)
|
||||
|
||||
return templates.TemplateResponse("nodes.html", context)
|
||||
|
||||
|
||||
@router.get("/n/{prefix}")
|
||||
async def node_short_link(prefix: str) -> RedirectResponse:
|
||||
"""Redirect short link to nodes page."""
|
||||
return RedirectResponse(url=f"/nodes/{prefix}", status_code=302)
|
||||
|
||||
|
||||
@router.get("/nodes/{public_key}", response_model=None)
|
||||
async def node_detail(
|
||||
request: Request, public_key: str
|
||||
) -> HTMLResponse | RedirectResponse:
|
||||
"""Render the node detail page.
|
||||
|
||||
If the key is not a full 64-character public key, uses the prefix API
|
||||
to resolve it and redirects to the canonical URL.
|
||||
"""
|
||||
# If not a full public key, resolve via prefix API and redirect
|
||||
if len(public_key) != 64:
|
||||
response = await request.app.state.http_client.get(
|
||||
f"/api/v1/nodes/prefix/{public_key}"
|
||||
)
|
||||
if response.status_code == 200:
|
||||
node = response.json()
|
||||
return RedirectResponse(url=f"/nodes/{node['public_key']}", status_code=302)
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
advertisements = []
|
||||
telemetry = []
|
||||
|
||||
# Fetch node details (exact match)
|
||||
response = await request.app.state.http_client.get(f"/api/v1/nodes/{public_key}")
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
node = response.json()
|
||||
|
||||
try:
|
||||
# Fetch recent advertisements for this node
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/advertisements", params={"public_key": public_key, "limit": 10}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
advertisements = response.json().get("items", [])
|
||||
|
||||
# Fetch recent telemetry for this node
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/telemetry", params={"node_public_key": public_key, "limit": 10}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
telemetry = response.json().get("items", [])
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch node details from API: {e}")
|
||||
context["api_error"] = str(e)
|
||||
|
||||
# Check if admin editing is available
|
||||
admin_enabled = getattr(request.app.state, "admin_enabled", False)
|
||||
auth_user = request.headers.get("X-Forwarded-User")
|
||||
|
||||
context.update(
|
||||
{
|
||||
"node": node,
|
||||
"advertisements": advertisements,
|
||||
"telemetry": telemetry,
|
||||
"public_key": public_key,
|
||||
"admin_enabled": admin_enabled,
|
||||
"is_authenticated": bool(auth_user),
|
||||
}
|
||||
)
|
||||
|
||||
return templates.TemplateResponse("node_detail.html", context)
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Custom pages route for MeshCore Hub Web Dashboard."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
router = APIRouter(tags=["Pages"])
|
||||
|
||||
|
||||
@router.get("/pages/{slug}", response_class=HTMLResponse)
|
||||
async def custom_page(request: Request, slug: str) -> HTMLResponse:
|
||||
"""Render a custom markdown page.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
slug: The page slug from the URL.
|
||||
|
||||
Returns:
|
||||
Rendered HTML page.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if page not found.
|
||||
"""
|
||||
page_loader = request.app.state.page_loader
|
||||
page = page_loader.get_page(slug)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail=f"Page '{slug}' not found")
|
||||
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
context["page"] = page
|
||||
|
||||
return templates.TemplateResponse("page.html", context)
|
||||
349
src/meshcore_hub/web/static/css/app.css
Normal file
349
src/meshcore_hub/web/static/css/app.css
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* MeshCore Hub - Custom Application Styles
|
||||
*
|
||||
* This file contains all custom CSS that extends the Tailwind/DaisyUI framework.
|
||||
* Organized in sections:
|
||||
* - Color palette
|
||||
* - Navbar styling
|
||||
* - Scrollbar styling
|
||||
* - Table styling
|
||||
* - Text utilities
|
||||
* - Prose (markdown content) styling
|
||||
* - Leaflet map theming
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
Color Palette
|
||||
Single source of truth for page/section colors used across nav, charts,
|
||||
and page content. All values are OKLCH.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
--color-dashboard: oklch(0.75 0.15 210); /* cyan */
|
||||
--color-nodes: oklch(0.65 0.24 265); /* violet */
|
||||
--color-adverts: oklch(0.7 0.17 330); /* magenta */
|
||||
--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);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Navbar Styling
|
||||
========================================================================== */
|
||||
|
||||
/* 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); }
|
||||
.nav-icon-adverts { color: var(--color-adverts); }
|
||||
.nav-icon-messages { color: var(--color-messages); }
|
||||
.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
|
||||
========================================================================== */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: oklch(var(--b2));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(var(--bc) / 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(var(--bc) / 0.5);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Table Styling
|
||||
========================================================================== */
|
||||
|
||||
.table-compact td,
|
||||
.table-compact th {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Text Utilities
|
||||
========================================================================== */
|
||||
|
||||
.truncate-cell {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Prose Styling (Custom Markdown Pages)
|
||||
========================================================================== */
|
||||
|
||||
.prose h1 {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.prose h4 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.prose ul,
|
||||
.prose ol {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.prose ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: oklch(var(--p));
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.prose a:hover {
|
||||
color: oklch(var(--pf));
|
||||
}
|
||||
|
||||
.prose code {
|
||||
background: oklch(var(--b2));
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
background: oklch(var(--b2));
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.prose pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
border-left: 4px solid oklch(var(--bc) / 0.3);
|
||||
padding-left: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.prose th,
|
||||
.prose td {
|
||||
border: 1px solid oklch(var(--bc) / 0.2);
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.prose th {
|
||||
background: oklch(var(--b2));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose hr {
|
||||
border: none;
|
||||
border-top: 1px solid oklch(var(--bc) / 0.2);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
|
||||
/* ==========================================================================
|
||||
Leaflet Map Theming (Dark Mode)
|
||||
========================================================================== */
|
||||
|
||||
/* Popup styling */
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: oklch(var(--b1));
|
||||
color: oklch(var(--bc));
|
||||
}
|
||||
|
||||
.leaflet-popup-tip {
|
||||
background: oklch(var(--b1));
|
||||
}
|
||||
|
||||
/* Map container defaults */
|
||||
#map,
|
||||
#node-map {
|
||||
border-radius: var(--rounded-box);
|
||||
}
|
||||
|
||||
#map {
|
||||
height: calc(100vh - 350px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
#node-map {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
/* Map label visibility */
|
||||
.map-label {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.map-marker:hover .map-label {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.show-labels .map-label {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Bring hovered marker to front */
|
||||
.leaflet-marker-icon:hover {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Node Header Hero Map Background
|
||||
========================================================================== */
|
||||
|
||||
#header-map {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Ensure Leaflet elements stay within the map layer */
|
||||
#header-map .leaflet-pane,
|
||||
#header-map .leaflet-control {
|
||||
z-index: auto !important;
|
||||
}
|
||||
@@ -1,14 +1,45 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 145 120" width="145" height="120">
|
||||
<!-- I letter - muted -->
|
||||
<rect x="30" y="10" width="25" height="100" rx="2" fill="#ffffff" opacity="0.5"/>
|
||||
|
||||
<!-- P vertical stem -->
|
||||
<rect x="65" y="10" width="25" height="100" rx="2" fill="#ffffff"/>
|
||||
|
||||
<!-- WiFi arcs: center at mid-stem (90, 60), sweeping from right up to top -->
|
||||
<g fill="none" stroke="#ffffff" stroke-width="10" stroke-linecap="round">
|
||||
<path d="M 110,65 A 20,20 0 0,0 90,45"/>
|
||||
<path d="M 125,65 A 35,35 0 0,0 90,30"/>
|
||||
<path d="M 140,65 A 50,50 0 0,0 90,15"/>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 53 53"
|
||||
width="53"
|
||||
height="53"
|
||||
version="1.1"
|
||||
id="svg3"
|
||||
sodipodi:docname="logo_bak.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs3" />
|
||||
<sodipodi:namedview
|
||||
id="namedview3"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1" />
|
||||
<!-- WiFi arcs radiating from bottom-left corner -->
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
id="g3"
|
||||
transform="translate(-1,-16)">
|
||||
<!-- Inner arc: from right to top -->
|
||||
<path
|
||||
d="M 20,65 A 15,15 0 0 0 5,50"
|
||||
id="path1" />
|
||||
<!-- Middle arc -->
|
||||
<path
|
||||
d="M 35,65 A 30,30 0 0 0 5,35"
|
||||
id="path2" />
|
||||
<!-- Outer arc -->
|
||||
<path
|
||||
d="M 50,65 A 45,45 0 0 0 5,20"
|
||||
id="path3" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 605 B After Width: | Height: | Size: 1.2 KiB |
231
src/meshcore_hub/web/static/js/charts.js
Normal file
231
src/meshcore_hub/web/static/js/charts.js
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* MeshCore Hub - Chart.js Helpers
|
||||
*
|
||||
* Provides common chart configuration and initialization helpers
|
||||
* for activity charts used on home and dashboard pages.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Read page colors from CSS custom properties (defined in app.css :root).
|
||||
* Falls back to hardcoded values if CSS vars are unavailable.
|
||||
*/
|
||||
function getCSSColor(varName, fallback) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || fallback;
|
||||
}
|
||||
|
||||
function withAlpha(color, alpha) {
|
||||
// oklch(0.65 0.24 265) -> oklch(0.65 0.24 265 / 0.1)
|
||||
return color.replace(')', ' / ' + alpha + ')');
|
||||
}
|
||||
|
||||
const ChartColors = {
|
||||
get nodes() { return getCSSColor('--color-nodes', 'oklch(0.65 0.24 265)'); },
|
||||
get nodesFill() { return withAlpha(this.nodes, 0.1); },
|
||||
get adverts() { return getCSSColor('--color-adverts', 'oklch(0.7 0.17 330)'); },
|
||||
get advertsFill() { return withAlpha(this.adverts, 0.1); },
|
||||
get messages() { return getCSSColor('--color-messages', 'oklch(0.75 0.18 180)'); },
|
||||
get messagesFill() { return withAlpha(this.messages, 0.1); },
|
||||
|
||||
// Neutral grays (not page-specific)
|
||||
grid: 'oklch(0.4 0 0 / 0.2)',
|
||||
text: 'oklch(0.7 0 0)',
|
||||
tooltipBg: 'oklch(0.25 0 0)',
|
||||
tooltipText: 'oklch(0.9 0 0)',
|
||||
tooltipBorder: 'oklch(0.4 0 0)'
|
||||
};
|
||||
|
||||
/**
|
||||
* Create common chart options with optional legend
|
||||
* @param {boolean} showLegend - Whether to show the legend
|
||||
* @returns {Object} Chart.js options object
|
||||
*/
|
||||
function createChartOptions(showLegend) {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: showLegend,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: ChartColors.text,
|
||||
boxWidth: 12,
|
||||
padding: 8
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
backgroundColor: ChartColors.tooltipBg,
|
||||
titleColor: ChartColors.tooltipText,
|
||||
bodyColor: ChartColors.tooltipText,
|
||||
borderColor: ChartColors.tooltipBorder,
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: ChartColors.grid },
|
||||
ticks: {
|
||||
color: ChartColors.text,
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
maxTicksLimit: 10
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: ChartColors.grid },
|
||||
ticks: {
|
||||
color: ChartColors.text,
|
||||
precision: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
intersect: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date labels for chart display (e.g., "8 Feb")
|
||||
* @param {Array} data - Array of objects with 'date' property
|
||||
* @returns {Array} Formatted date strings
|
||||
*/
|
||||
function formatDateLabels(data) {
|
||||
return data.map(function(d) {
|
||||
var date = new Date(d.date);
|
||||
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single-dataset line chart
|
||||
* @param {string} canvasId - ID of the canvas element
|
||||
* @param {Object} data - Data object with 'data' array containing {date, count} objects
|
||||
* @param {string} label - Dataset label
|
||||
* @param {string} borderColor - Line color
|
||||
* @param {string} backgroundColor - Fill color
|
||||
* @param {boolean} fill - Whether to fill under the line
|
||||
*/
|
||||
function createLineChart(canvasId, data, label, borderColor, backgroundColor, fill) {
|
||||
var ctx = document.getElementById(canvasId);
|
||||
if (!ctx || !data || !data.data || data.data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: formatDateLabels(data.data),
|
||||
datasets: [{
|
||||
label: label,
|
||||
data: data.data.map(function(d) { return d.count; }),
|
||||
borderColor: borderColor,
|
||||
backgroundColor: backgroundColor,
|
||||
fill: fill,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
}]
|
||||
},
|
||||
options: createChartOptions(false)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a multi-dataset activity chart (for home page).
|
||||
* Pass null for advertData or messageData to omit that series.
|
||||
* @param {string} canvasId - ID of the canvas element
|
||||
* @param {Object|null} advertData - Advertisement data with 'data' array, or null to omit
|
||||
* @param {Object|null} messageData - Message data with 'data' array, or null to omit
|
||||
*/
|
||||
function createActivityChart(canvasId, advertData, messageData) {
|
||||
var ctx = document.getElementById(canvasId);
|
||||
if (!ctx) return null;
|
||||
|
||||
// Build datasets from whichever series are provided
|
||||
var datasets = [];
|
||||
var labels = null;
|
||||
|
||||
if (advertData && advertData.data && advertData.data.length > 0) {
|
||||
if (!labels) labels = formatDateLabels(advertData.data);
|
||||
datasets.push({
|
||||
label: '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
|
||||
});
|
||||
}
|
||||
|
||||
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: datasets },
|
||||
options: createChartOptions(true)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
if (nodeData) {
|
||||
createLineChart(
|
||||
'nodeChart',
|
||||
nodeData,
|
||||
'Total Nodes',
|
||||
ChartColors.nodes,
|
||||
ChartColors.nodesFill,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (advertData) {
|
||||
createLineChart(
|
||||
'advertChart',
|
||||
advertData,
|
||||
'Advertisements',
|
||||
ChartColors.adverts,
|
||||
ChartColors.advertsFill,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (messageData) {
|
||||
createLineChart(
|
||||
'messageChart',
|
||||
messageData,
|
||||
'Messages',
|
||||
ChartColors.messages,
|
||||
ChartColors.messagesFill,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
99
src/meshcore_hub/web/static/js/spa/api.js
Normal file
99
src/meshcore_hub/web/static/js/spa/api.js
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* MeshCore Hub SPA - API Client
|
||||
*
|
||||
* Wrapper around fetch() for making API calls to the proxied backend.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Make a GET request and return parsed JSON.
|
||||
* @param {string} path - URL path (e.g., '/api/v1/nodes')
|
||||
* @param {Object} [params] - Query parameters
|
||||
* @returns {Promise<any>} Parsed JSON response
|
||||
*/
|
||||
export async function apiGet(path, params = {}) {
|
||||
const url = new URL(path, window.location.origin);
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== null && v !== undefined && v !== '') {
|
||||
url.searchParams.set(k, String(v));
|
||||
}
|
||||
}
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a POST request with JSON body.
|
||||
* @param {string} path - URL path
|
||||
* @param {Object} body - Request body
|
||||
* @returns {Promise<any>} Parsed JSON response
|
||||
*/
|
||||
export async function apiPost(path, body) {
|
||||
const response = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`API error: ${response.status} - ${text}`);
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a PUT request with JSON body.
|
||||
* @param {string} path - URL path
|
||||
* @param {Object} body - Request body
|
||||
* @returns {Promise<any>} Parsed JSON response
|
||||
*/
|
||||
export async function apiPut(path, body) {
|
||||
const response = await fetch(path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`API error: ${response.status} - ${text}`);
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a DELETE request.
|
||||
* @param {string} path - URL path
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function apiDelete(path) {
|
||||
const response = await fetch(path, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`API error: ${response.status} - ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a POST request with form-encoded body.
|
||||
* @param {string} path - URL path
|
||||
* @param {Object} data - Form data as key-value pairs
|
||||
* @returns {Promise<any>} Parsed JSON response
|
||||
*/
|
||||
export async function apiPostForm(path, data) {
|
||||
const body = new URLSearchParams(data);
|
||||
const response = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`API error: ${response.status} - ${text}`);
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
169
src/meshcore_hub/web/static/js/spa/app.js
Normal file
169
src/meshcore_hub/web/static/js/spa/app.js
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* MeshCore Hub SPA - Main Application Entry Point
|
||||
*
|
||||
* Initializes the router, registers all page routes, and handles navigation.
|
||||
*/
|
||||
|
||||
import { Router } from './router.js';
|
||||
import { getConfig } from './components.js';
|
||||
|
||||
// Page modules (lazy-loaded)
|
||||
const pages = {
|
||||
home: () => import('./pages/home.js'),
|
||||
dashboard: () => import('./pages/dashboard.js'),
|
||||
nodes: () => import('./pages/nodes.js'),
|
||||
nodeDetail: () => import('./pages/node-detail.js'),
|
||||
messages: () => import('./pages/messages.js'),
|
||||
advertisements: () => import('./pages/advertisements.js'),
|
||||
map: () => import('./pages/map.js'),
|
||||
members: () => import('./pages/members.js'),
|
||||
customPage: () => import('./pages/custom-page.js'),
|
||||
notFound: () => import('./pages/not-found.js'),
|
||||
adminIndex: () => import('./pages/admin/index.js'),
|
||||
adminNodeTags: () => import('./pages/admin/node-tags.js'),
|
||||
adminMembers: () => import('./pages/admin/members.js'),
|
||||
};
|
||||
|
||||
// Main app container
|
||||
const appContainer = document.getElementById('app');
|
||||
const router = new Router();
|
||||
|
||||
// Read feature flags from config
|
||||
const config = getConfig();
|
||||
const features = config.features || {};
|
||||
|
||||
/**
|
||||
* Create a route handler that lazy-loads a page module and calls its render function.
|
||||
* @param {Function} loader - Module loader function
|
||||
* @returns {Function} Route handler
|
||||
*/
|
||||
function pageHandler(loader) {
|
||||
return async (params) => {
|
||||
try {
|
||||
const module = await loader();
|
||||
return await module.render(appContainer, params, router);
|
||||
} catch (e) {
|
||||
console.error('Page load error:', e);
|
||||
appContainer.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
<h1 class="text-4xl font-bold mb-4">Error</h1>
|
||||
<p class="text-lg opacity-70 mb-6">Failed to load page</p>
|
||||
<p class="text-sm opacity-50 mb-6">${e.message || 'Unknown error'}</p>
|
||||
<a href="/" class="btn btn-primary">Go Home</a>
|
||||
</div>`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Register routes (conditionally based on feature flags)
|
||||
router.addRoute('/', pageHandler(pages.home));
|
||||
|
||||
if (features.dashboard !== false) {
|
||||
router.addRoute('/dashboard', pageHandler(pages.dashboard));
|
||||
}
|
||||
if (features.nodes !== false) {
|
||||
router.addRoute('/nodes', pageHandler(pages.nodes));
|
||||
router.addRoute('/nodes/:publicKey', pageHandler(pages.nodeDetail));
|
||||
router.addRoute('/n/:prefix', async (params) => {
|
||||
// Short link redirect
|
||||
router.navigate(`/nodes/${params.prefix}`, true);
|
||||
});
|
||||
}
|
||||
if (features.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));
|
||||
router.addRoute('/a/', pageHandler(pages.adminIndex));
|
||||
router.addRoute('/a/node-tags', pageHandler(pages.adminNodeTags));
|
||||
router.addRoute('/a/members', pageHandler(pages.adminMembers));
|
||||
|
||||
// 404 handler
|
||||
router.setNotFound(pageHandler(pages.notFound));
|
||||
|
||||
/**
|
||||
* Update the active state of navigation links.
|
||||
* @param {string} pathname - Current URL path
|
||||
*/
|
||||
function updateNavActiveState(pathname) {
|
||||
document.querySelectorAll('[data-nav-link]').forEach(link => {
|
||||
const href = link.getAttribute('href');
|
||||
let isActive = false;
|
||||
|
||||
if (href === '/') {
|
||||
isActive = pathname === '/';
|
||||
} else if (href === '/nodes') {
|
||||
isActive = pathname.startsWith('/nodes');
|
||||
} else {
|
||||
isActive = pathname === href || pathname.startsWith(href + '/');
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
link.classList.add('active');
|
||||
} else {
|
||||
link.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Close mobile dropdown if open (DaisyUI dropdowns stay open while focused)
|
||||
if (document.activeElement?.closest('.dropdown')) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the page title based on the current route.
|
||||
* @param {string} pathname
|
||||
*/
|
||||
function updatePageTitle(pathname) {
|
||||
const config = getConfig();
|
||||
const networkName = config.network_name || 'MeshCore Network';
|
||||
const titles = {
|
||||
'/': 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/')) {
|
||||
document.title = `Node Detail - ${networkName}`;
|
||||
} else if (pathname.startsWith('/pages/')) {
|
||||
// Custom pages set their own title in the page module
|
||||
document.title = networkName;
|
||||
} else {
|
||||
document.title = networkName;
|
||||
}
|
||||
}
|
||||
|
||||
// Set up navigation callback
|
||||
router.onNavigate((pathname) => {
|
||||
updateNavActiveState(pathname);
|
||||
updatePageTitle(pathname);
|
||||
});
|
||||
|
||||
// Start the router when DOM is ready
|
||||
router.start();
|
||||
304
src/meshcore_hub/web/static/js/spa/components.js
Normal file
304
src/meshcore_hub/web/static/js/spa/components.js
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* MeshCore Hub SPA - Shared UI Components
|
||||
*
|
||||
* Reusable rendering functions using lit-html.
|
||||
*/
|
||||
|
||||
import { html, nothing } from 'lit-html';
|
||||
import { render } from 'lit-html';
|
||||
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
|
||||
|
||||
// Re-export lit-html utilities for page modules
|
||||
export { html, nothing, unsafeHTML };
|
||||
export { render as litRender } from 'lit-html';
|
||||
|
||||
/**
|
||||
* Get app config from the embedded window object.
|
||||
* @returns {Object} App configuration
|
||||
*/
|
||||
export function getConfig() {
|
||||
return window.__APP_CONFIG__ || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Page color palette - reads from CSS custom properties (defined in app.css :root).
|
||||
* Use for inline styles or dynamic coloring in page modules.
|
||||
*/
|
||||
export const pageColors = {
|
||||
get dashboard() { return getComputedStyle(document.documentElement).getPropertyValue('--color-dashboard').trim(); },
|
||||
get nodes() { return getComputedStyle(document.documentElement).getPropertyValue('--color-nodes').trim(); },
|
||||
get adverts() { return getComputedStyle(document.documentElement).getPropertyValue('--color-adverts').trim(); },
|
||||
get messages() { return getComputedStyle(document.documentElement).getPropertyValue('--color-messages').trim(); },
|
||||
get map() { return getComputedStyle(document.documentElement).getPropertyValue('--color-map').trim(); },
|
||||
get members() { return getComputedStyle(document.documentElement).getPropertyValue('--color-members').trim(); },
|
||||
};
|
||||
|
||||
// --- Formatting Helpers (return strings) ---
|
||||
|
||||
/**
|
||||
* Get the type emoji for a node advertisement type.
|
||||
* @param {string|null} advType
|
||||
* @returns {string} Emoji character
|
||||
*/
|
||||
export function typeEmoji(advType) {
|
||||
switch ((advType || '').toLowerCase()) {
|
||||
case 'chat': return '\u{1F4AC}'; // 💬
|
||||
case 'repeater': return '\u{1F4E1}'; // 📡
|
||||
case 'room': return '\u{1FAA7}'; // 🪧
|
||||
default: return '\u{1F4CD}'; // 📍
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO datetime string to the configured timezone.
|
||||
* @param {string|null} isoString
|
||||
* @param {Object} [options] - Intl.DateTimeFormat options override
|
||||
* @returns {string} Formatted datetime string
|
||||
*/
|
||||
export function formatDateTime(isoString, options) {
|
||||
if (!isoString) return '-';
|
||||
try {
|
||||
const config = getConfig();
|
||||
const tz = config.timezone_iana || 'UTC';
|
||||
const date = new Date(isoString);
|
||||
if (isNaN(date.getTime())) return '-';
|
||||
const opts = options || {
|
||||
timeZone: tz,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
};
|
||||
if (!opts.timeZone) opts.timeZone = tz;
|
||||
return date.toLocaleString('en-GB', opts);
|
||||
} catch {
|
||||
return isoString ? isoString.slice(0, 19).replace('T', ' ') : '-';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO datetime string to short format (date + HH:MM).
|
||||
* @param {string|null} isoString
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatDateTimeShort(isoString) {
|
||||
if (!isoString) return '-';
|
||||
try {
|
||||
const config = getConfig();
|
||||
const tz = config.timezone_iana || 'UTC';
|
||||
const date = new Date(isoString);
|
||||
if (isNaN(date.getTime())) return '-';
|
||||
return date.toLocaleString('en-GB', {
|
||||
timeZone: tz,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
} catch {
|
||||
return isoString ? isoString.slice(0, 16).replace('T', ' ') : '-';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO datetime as relative time (e.g., "2m ago", "1h ago").
|
||||
* @param {string|null} isoString
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatRelativeTime(isoString) {
|
||||
if (!isoString) return '';
|
||||
const date = new Date(isoString);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
if (diffDay > 0) return `${diffDay}d ago`;
|
||||
if (diffHour > 0) return `${diffHour}h ago`;
|
||||
if (diffMin > 0) return `${diffMin}m ago`;
|
||||
return '<1m ago';
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a public key for display.
|
||||
* @param {string} key - Full public key
|
||||
* @param {number} [length=12] - Characters to show
|
||||
* @returns {string} Truncated key with ellipsis
|
||||
*/
|
||||
export function truncateKey(key, length = 12) {
|
||||
if (!key) return '-';
|
||||
if (key.length <= length) return key;
|
||||
return key.slice(0, length) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters. Rarely needed with lit-html
|
||||
* since template interpolation auto-escapes, but kept for edge cases.
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
export function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// --- UI Components (return lit-html TemplateResult) ---
|
||||
|
||||
/**
|
||||
* Render a loading spinner.
|
||||
* @returns {TemplateResult}
|
||||
*/
|
||||
export function loading() {
|
||||
return html`<div class="flex justify-center py-12"><span class="loading loading-spinner loading-lg"></span></div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an error alert.
|
||||
* @param {string} message
|
||||
* @returns {TemplateResult}
|
||||
*/
|
||||
export function errorAlert(message) {
|
||||
return html`<div role="alert" class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span>${message}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an info alert. Use unsafeHTML for HTML content.
|
||||
* @param {string} message - Plain text message
|
||||
* @returns {TemplateResult}
|
||||
*/
|
||||
export function infoAlert(message) {
|
||||
return html`<div role="alert" class="alert alert-info mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span>${message}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a success alert.
|
||||
* @param {string} message
|
||||
* @returns {TemplateResult}
|
||||
*/
|
||||
export function successAlert(message) {
|
||||
return html`<div role="alert" class="alert alert-success mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span>${message}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render pagination controls.
|
||||
* @param {number} page - Current page (1-based)
|
||||
* @param {number} totalPages - Total number of pages
|
||||
* @param {string} basePath - Base URL path (e.g., '/nodes')
|
||||
* @param {Object} [params={}] - Extra query parameters to preserve
|
||||
* @returns {TemplateResult|nothing}
|
||||
*/
|
||||
export function pagination(page, totalPages, basePath, params = {}) {
|
||||
if (totalPages <= 1) return nothing;
|
||||
|
||||
const queryParts = [];
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (k !== 'page' && v !== null && v !== undefined && v !== '') {
|
||||
queryParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
|
||||
}
|
||||
}
|
||||
const extraQuery = queryParts.length > 0 ? '&' + queryParts.join('&') : '';
|
||||
|
||||
function pageUrl(p) {
|
||||
return `${basePath}?page=${p}${extraQuery}`;
|
||||
}
|
||||
|
||||
const pageNumbers = [];
|
||||
for (let p = 1; p <= totalPages; p++) {
|
||||
if (p === page) {
|
||||
pageNumbers.push(html`<button class="join-item btn btn-sm btn-active">${p}</button>`);
|
||||
} else if (p === 1 || p === totalPages || (p >= page - 2 && p <= page + 2)) {
|
||||
pageNumbers.push(html`<a href=${pageUrl(p)} class="join-item btn btn-sm">${p}</a>`);
|
||||
} else if (p === 2 || p === totalPages - 1) {
|
||||
pageNumbers.push(html`<button class="join-item btn btn-sm btn-disabled" disabled>...</button>`);
|
||||
}
|
||||
}
|
||||
|
||||
return html`<div class="flex justify-center mt-6"><div class="join">
|
||||
${page > 1
|
||||
? html`<a href=${pageUrl(page - 1)} class="join-item btn btn-sm">Previous</a>`
|
||||
: html`<button class="join-item btn btn-sm btn-disabled" disabled>Previous</button>`}
|
||||
${pageNumbers}
|
||||
${page < totalPages
|
||||
? html`<a href=${pageUrl(page + 1)} class="join-item btn btn-sm">Next</a>`
|
||||
: html`<button class="join-item btn btn-sm btn-disabled" disabled>Next</button>`}
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a timezone indicator for page headers.
|
||||
* @returns {TemplateResult|nothing}
|
||||
*/
|
||||
export function timezoneIndicator() {
|
||||
const config = getConfig();
|
||||
const tz = config.timezone || 'UTC';
|
||||
return html`<span class="text-xs opacity-50 ml-2">(${tz})</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render receiver node icons with tooltips.
|
||||
* @param {Array} receivers
|
||||
* @returns {TemplateResult|nothing}
|
||||
*/
|
||||
export function receiverIcons(receivers) {
|
||||
if (!receivers || receivers.length === 0) return nothing;
|
||||
return html`${receivers.map(r => {
|
||||
const name = r.receiver_node_name || truncateKey(r.receiver_node_public_key || '', 8);
|
||||
const time = formatRelativeTime(r.received_at);
|
||||
const tooltip = time ? `${name} (${time})` : name;
|
||||
return html`<span class="cursor-help" title=${tooltip}>\u{1F4E1}</span>`;
|
||||
})}`;
|
||||
}
|
||||
|
||||
// --- Form Helpers ---
|
||||
|
||||
/**
|
||||
* Create a submit handler for filter forms that uses SPA navigation.
|
||||
* Use as: @submit=${createFilterHandler('/nodes', navigate)}
|
||||
* @param {string} basePath - Base URL path for the page
|
||||
* @param {Function} navigate - Router navigate function
|
||||
* @returns {Function} Event handler
|
||||
*/
|
||||
export function createFilterHandler(basePath, navigate) {
|
||||
return (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const params = new URLSearchParams();
|
||||
for (const [k, v] of formData.entries()) {
|
||||
if (v) params.set(k, v);
|
||||
}
|
||||
const queryStr = params.toString();
|
||||
navigate(queryStr ? `${basePath}?${queryStr}` : basePath);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-submit handler for select/checkbox elements.
|
||||
* Use as: @change=${autoSubmit}
|
||||
* @param {Event} e
|
||||
*/
|
||||
export function autoSubmit(e) {
|
||||
e.target.closest('form').requestSubmit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit form on Enter key in text inputs.
|
||||
* Use as: @keydown=${submitOnEnter}
|
||||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
export function submitOnEnter(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.target.closest('form').requestSubmit();
|
||||
}
|
||||
}
|
||||
103
src/meshcore_hub/web/static/js/spa/icons.js
Normal file
103
src/meshcore_hub/web/static/js/spa/icons.js
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* MeshCore Hub SPA - SVG Icon Functions
|
||||
*
|
||||
* Each function returns a lit-html TemplateResult. Pass a CSS class string to customize size.
|
||||
*/
|
||||
|
||||
import { html } from 'lit-html';
|
||||
|
||||
export function iconDashboard(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} 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>`;
|
||||
}
|
||||
|
||||
export function iconMap(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} 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>`;
|
||||
}
|
||||
|
||||
export function iconNodes(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} 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>`;
|
||||
}
|
||||
|
||||
export function iconAdvertisements(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} 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>`;
|
||||
}
|
||||
|
||||
export function iconMessages(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} 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>`;
|
||||
}
|
||||
|
||||
export function iconHome(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} 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>`;
|
||||
}
|
||||
|
||||
export function iconMembers(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} 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>`;
|
||||
}
|
||||
|
||||
export function iconPage(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} 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>`;
|
||||
}
|
||||
|
||||
export function iconInfo(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>`;
|
||||
}
|
||||
|
||||
export function iconAlert(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>`;
|
||||
}
|
||||
|
||||
export function iconChart(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" /></svg>`;
|
||||
}
|
||||
|
||||
export function iconRefresh(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>`;
|
||||
}
|
||||
|
||||
export function iconMenu(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" /></svg>`;
|
||||
}
|
||||
|
||||
export function iconGithub(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>`;
|
||||
}
|
||||
|
||||
export function iconExternalLink(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>`;
|
||||
}
|
||||
|
||||
export function iconGlobe(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>`;
|
||||
}
|
||||
|
||||
export function iconError(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>`;
|
||||
}
|
||||
|
||||
export function iconChannel(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg>`;
|
||||
}
|
||||
|
||||
export function iconSuccess(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>`;
|
||||
}
|
||||
|
||||
export function iconLock(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>`;
|
||||
}
|
||||
|
||||
export function iconUser(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>`;
|
||||
}
|
||||
|
||||
export function iconEmail(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>`;
|
||||
}
|
||||
|
||||
export function iconTag(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" /></svg>`;
|
||||
}
|
||||
|
||||
export function iconUsers(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg>`;
|
||||
}
|
||||
75
src/meshcore_hub/web/static/js/spa/pages/admin/index.js
Normal file
75
src/meshcore_hub/web/static/js/spa/pages/admin/index.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { html, litRender, getConfig, errorAlert } from '../../components.js';
|
||||
import { iconLock, iconUsers, iconTag } from '../../icons.js';
|
||||
|
||||
export async function render(container, params, router) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
|
||||
if (!config.admin_enabled) {
|
||||
litRender(html`
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
${iconLock('h-16 w-16 opacity-30 mb-4')}
|
||||
<h1 class="text-3xl font-bold mb-2">Access Denied</h1>
|
||||
<p class="opacity-70">The admin interface is not enabled.</p>
|
||||
<p class="text-sm opacity-50 mt-2">Set <code>WEB_ADMIN_ENABLED=true</code> to enable admin features.</p>
|
||||
<a href="/" class="btn btn-primary mt-6">Go Home</a>
|
||||
</div>`, container);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.is_authenticated) {
|
||||
litRender(html`
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
${iconLock('h-16 w-16 opacity-30 mb-4')}
|
||||
<h1 class="text-3xl font-bold mb-2">Authentication Required</h1>
|
||||
<p class="opacity-70">You must sign in to access the admin interface.</p>
|
||||
<a href="/oauth2/start?rd=${encodeURIComponent(window.location.pathname)}" class="btn btn-primary mt-6">Sign In</a>
|
||||
</div>`, container);
|
||||
return;
|
||||
}
|
||||
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Admin</h1>
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li>Admin</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/oauth2/sign_out" target="_blank" class="btn btn-outline btn-sm">Sign Out</a>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm opacity-70 mb-6">
|
||||
<span class="flex items-center gap-1.5">
|
||||
Welcome to the admin panel.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<a href="/a/members" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
${iconUsers('h-6 w-6')}
|
||||
Members
|
||||
</h2>
|
||||
<p>Manage network members and operators.</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/a/node-tags" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
${iconTag('h-6 w-6')}
|
||||
Node Tags
|
||||
</h2>
|
||||
<p>Manage custom tags and metadata for network nodes.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>`, container);
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || 'Failed to load admin page'), container);
|
||||
}
|
||||
}
|
||||
327
src/meshcore_hub/web/static/js/spa/pages/admin/members.js
Normal file
327
src/meshcore_hub/web/static/js/spa/pages/admin/members.js
Normal file
@@ -0,0 +1,327 @@
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, errorAlert, successAlert,
|
||||
} from '../../components.js';
|
||||
import { iconLock } from '../../icons.js';
|
||||
|
||||
export async function render(container, params, router) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
|
||||
if (!config.admin_enabled) {
|
||||
litRender(html`
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
${iconLock('h-16 w-16 opacity-30 mb-4')}
|
||||
<h1 class="text-3xl font-bold mb-2">Access Denied</h1>
|
||||
<p class="opacity-70">The admin interface is not enabled.</p>
|
||||
<a href="/" class="btn btn-primary mt-6">Go Home</a>
|
||||
</div>`, container);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.is_authenticated) {
|
||||
litRender(html`
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
${iconLock('h-16 w-16 opacity-30 mb-4')}
|
||||
<h1 class="text-3xl font-bold mb-2">Authentication Required</h1>
|
||||
<p class="opacity-70">You must sign in to access the admin interface.</p>
|
||||
<a href="/oauth2/start?rd=${encodeURIComponent(window.location.pathname)}" class="btn btn-primary mt-6">Sign In</a>
|
||||
</div>`, container);
|
||||
return;
|
||||
}
|
||||
|
||||
const flashMessage = (params.query && params.query.message) || '';
|
||||
const flashError = (params.query && params.query.error) || '';
|
||||
|
||||
const data = await apiGet('/api/v1/members', { limit: 100 });
|
||||
const members = data.items || [];
|
||||
|
||||
const flashHtml = html`${flashMessage ? successAlert(flashMessage) : nothing}${flashError ? errorAlert(flashError) : nothing}`;
|
||||
|
||||
const tableHtml = members.length > 0
|
||||
? html`
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member ID</th>
|
||||
<th>Name</th>
|
||||
<th>Callsign</th>
|
||||
<th>Contact</th>
|
||||
<th class="w-32">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${members.map(m => html`
|
||||
<tr data-member-id=${m.id}
|
||||
data-member-name=${m.name}
|
||||
data-member-member-id=${m.member_id}
|
||||
data-member-callsign=${m.callsign || ''}
|
||||
data-member-description=${m.description || ''}
|
||||
data-member-contact=${m.contact || ''}>
|
||||
<td class="font-mono font-semibold">${m.member_id}</td>
|
||||
<td>${m.name}</td>
|
||||
<td>
|
||||
${m.callsign
|
||||
? html`<span class="badge badge-primary">${m.callsign}</span>`
|
||||
: html`<span class="text-base-content/40">-</span>`}
|
||||
</td>
|
||||
<td class="max-w-xs truncate" title=${m.contact || ''}>${m.contact || '-'}</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-ghost btn-xs btn-edit">Edit</button>
|
||||
<button class="btn btn-ghost btn-xs text-error btn-delete">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`)}</tbody>
|
||||
</table>
|
||||
</div>`
|
||||
: html`
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<p>No members configured yet.</p>
|
||||
<p class="text-sm mt-2">Click "Add Member" to create the first member.</p>
|
||||
</div>`;
|
||||
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Members</h1>
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/a/">Admin</a></li>
|
||||
<li>Members</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/oauth2/sign_out" target="_blank" class="btn btn-outline btn-sm">Sign Out</a>
|
||||
</div>
|
||||
|
||||
${flashHtml}
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="card-title">Network Members (${members.length})</h2>
|
||||
<button id="btn-add-member" class="btn btn-primary btn-sm">Add Member</button>
|
||||
</div>
|
||||
${tableHtml}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="addModal" class="modal">
|
||||
<div class="modal-box w-11/12 max-w-2xl">
|
||||
<h3 class="font-bold text-lg">Add New Member</h3>
|
||||
<form id="add-member-form" class="py-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Member ID <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input type="text" name="member_id" class="input input-bordered"
|
||||
placeholder="walshie86" required maxlength="50"
|
||||
pattern="[a-zA-Z0-9_]+"
|
||||
title="Letters, numbers, and underscores only">
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Unique identifier (letters, numbers, underscore)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Name <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input type="text" name="name" class="input input-bordered"
|
||||
placeholder="John Smith" required maxlength="255">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Callsign</span></label>
|
||||
<input type="text" name="callsign" class="input input-bordered"
|
||||
placeholder="VK4ABC" maxlength="20">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Contact</span></label>
|
||||
<input type="text" name="contact" class="input input-bordered"
|
||||
placeholder="john@example.com or phone number" maxlength="255">
|
||||
</div>
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label"><span class="label-text">Description</span></label>
|
||||
<textarea name="description" rows="3" class="textarea textarea-bordered"
|
||||
placeholder="Brief description of member's role and responsibilities..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="addCancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Add Member</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="editModal" class="modal">
|
||||
<div class="modal-box w-11/12 max-w-2xl">
|
||||
<h3 class="font-bold text-lg">Edit Member</h3>
|
||||
<form id="edit-member-form" class="py-4">
|
||||
<input type="hidden" name="id" id="edit_id">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Member ID <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input type="text" name="member_id" id="edit_member_id" class="input input-bordered"
|
||||
required maxlength="50" pattern="[a-zA-Z0-9_]+"
|
||||
title="Letters, numbers, and underscores only">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Name <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input type="text" name="name" id="edit_name" class="input input-bordered"
|
||||
required maxlength="255">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Callsign</span></label>
|
||||
<input type="text" name="callsign" id="edit_callsign" class="input input-bordered"
|
||||
maxlength="20">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Contact</span></label>
|
||||
<input type="text" name="contact" id="edit_contact" class="input input-bordered"
|
||||
maxlength="255">
|
||||
</div>
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label"><span class="label-text">Description</span></label>
|
||||
<textarea name="description" id="edit_description" rows="3"
|
||||
class="textarea textarea-bordered"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="editCancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="deleteModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Delete Member</h3>
|
||||
<div class="py-4">
|
||||
<p class="py-4">Are you sure you want to delete member <strong id="delete_member_name"></strong>?</p>
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span>This action cannot be undone.</span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="deleteCancel">Cancel</button>
|
||||
<button type="button" class="btn btn-error" id="deleteConfirm">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>`, container);
|
||||
|
||||
let activeDeleteId = '';
|
||||
|
||||
// Add Member
|
||||
container.querySelector('#btn-add-member').addEventListener('click', () => {
|
||||
const form = container.querySelector('#add-member-form');
|
||||
form.reset();
|
||||
container.querySelector('#addModal').showModal();
|
||||
});
|
||||
|
||||
container.querySelector('#addCancel').addEventListener('click', () => {
|
||||
container.querySelector('#addModal').close();
|
||||
});
|
||||
|
||||
container.querySelector('#add-member-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const body = {
|
||||
member_id: form.member_id.value.trim(),
|
||||
name: form.name.value.trim(),
|
||||
callsign: form.callsign.value.trim() || null,
|
||||
description: form.description.value.trim() || null,
|
||||
contact: form.contact.value.trim() || null,
|
||||
};
|
||||
|
||||
try {
|
||||
await apiPost('/api/v1/members', body);
|
||||
container.querySelector('#addModal').close();
|
||||
router.navigate('/a/members?message=' + encodeURIComponent('Member added successfully'));
|
||||
} catch (err) {
|
||||
container.querySelector('#addModal').close();
|
||||
router.navigate('/a/members?error=' + encodeURIComponent(err.message));
|
||||
}
|
||||
});
|
||||
|
||||
// Edit Member
|
||||
container.querySelectorAll('.btn-edit').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const row = btn.closest('tr');
|
||||
container.querySelector('#edit_id').value = row.dataset.memberId;
|
||||
container.querySelector('#edit_member_id').value = row.dataset.memberMemberId;
|
||||
container.querySelector('#edit_name').value = row.dataset.memberName;
|
||||
container.querySelector('#edit_callsign').value = row.dataset.memberCallsign;
|
||||
container.querySelector('#edit_description').value = row.dataset.memberDescription;
|
||||
container.querySelector('#edit_contact').value = row.dataset.memberContact;
|
||||
container.querySelector('#editModal').showModal();
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelector('#editCancel').addEventListener('click', () => {
|
||||
container.querySelector('#editModal').close();
|
||||
});
|
||||
|
||||
container.querySelector('#edit-member-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const id = form.id.value;
|
||||
const body = {
|
||||
member_id: form.member_id.value.trim(),
|
||||
name: form.name.value.trim(),
|
||||
callsign: form.callsign.value.trim() || null,
|
||||
description: form.description.value.trim() || null,
|
||||
contact: form.contact.value.trim() || null,
|
||||
};
|
||||
|
||||
try {
|
||||
await apiPut('/api/v1/members/' + encodeURIComponent(id), body);
|
||||
container.querySelector('#editModal').close();
|
||||
router.navigate('/a/members?message=' + encodeURIComponent('Member updated successfully'));
|
||||
} catch (err) {
|
||||
container.querySelector('#editModal').close();
|
||||
router.navigate('/a/members?error=' + encodeURIComponent(err.message));
|
||||
}
|
||||
});
|
||||
|
||||
// Delete Member
|
||||
container.querySelectorAll('.btn-delete').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const row = btn.closest('tr');
|
||||
activeDeleteId = row.dataset.memberId;
|
||||
container.querySelector('#delete_member_name').textContent = row.dataset.memberName;
|
||||
container.querySelector('#deleteModal').showModal();
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelector('#deleteCancel').addEventListener('click', () => {
|
||||
container.querySelector('#deleteModal').close();
|
||||
});
|
||||
|
||||
container.querySelector('#deleteConfirm').addEventListener('click', async () => {
|
||||
try {
|
||||
await apiDelete('/api/v1/members/' + encodeURIComponent(activeDeleteId));
|
||||
container.querySelector('#deleteModal').close();
|
||||
router.navigate('/a/members?message=' + encodeURIComponent('Member deleted successfully'));
|
||||
} catch (err) {
|
||||
container.querySelector('#deleteModal').close();
|
||||
router.navigate('/a/members?error=' + encodeURIComponent(err.message));
|
||||
}
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || 'Failed to load members'), container);
|
||||
}
|
||||
}
|
||||
526
src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js
Normal file
526
src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js
Normal file
@@ -0,0 +1,526 @@
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, typeEmoji, formatDateTimeShort, errorAlert,
|
||||
successAlert, truncateKey,
|
||||
} from '../../components.js';
|
||||
import { iconTag, iconLock } from '../../icons.js';
|
||||
|
||||
export async function render(container, params, router) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
|
||||
if (!config.admin_enabled) {
|
||||
litRender(html`
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
${iconLock('h-16 w-16 opacity-30 mb-4')}
|
||||
<h1 class="text-3xl font-bold mb-2">Access Denied</h1>
|
||||
<p class="opacity-70">The admin interface is not enabled.</p>
|
||||
<a href="/" class="btn btn-primary mt-6">Go Home</a>
|
||||
</div>`, container);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.is_authenticated) {
|
||||
litRender(html`
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
${iconLock('h-16 w-16 opacity-30 mb-4')}
|
||||
<h1 class="text-3xl font-bold mb-2">Authentication Required</h1>
|
||||
<p class="opacity-70">You must sign in to access the admin interface.</p>
|
||||
<a href="/oauth2/start?rd=${encodeURIComponent(window.location.pathname)}" class="btn btn-primary mt-6">Sign In</a>
|
||||
</div>`, container);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedPublicKey = (params.query && params.query.public_key) || '';
|
||||
const flashMessage = (params.query && params.query.message) || '';
|
||||
const flashError = (params.query && params.query.error) || '';
|
||||
|
||||
const nodesData = await apiGet('/api/v1/nodes', { limit: 500 });
|
||||
const allNodes = nodesData.items || [];
|
||||
|
||||
let selectedNode = null;
|
||||
let tags = [];
|
||||
|
||||
if (selectedPublicKey) {
|
||||
try {
|
||||
selectedNode = await apiGet('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey));
|
||||
tags = selectedNode.tags || [];
|
||||
} catch {
|
||||
selectedNode = null;
|
||||
}
|
||||
}
|
||||
|
||||
const flashHtml = html`${flashMessage ? successAlert(flashMessage) : nothing}${flashError ? errorAlert(flashError) : nothing}`;
|
||||
|
||||
let contentHtml = nothing;
|
||||
|
||||
if (selectedPublicKey && selectedNode) {
|
||||
const nodeEmoji = typeEmoji(selectedNode.adv_type);
|
||||
const nodeName = selectedNode.name || 'Unnamed Node';
|
||||
const otherNodes = allNodes.filter(n => n.public_key !== selectedPublicKey);
|
||||
|
||||
const tagsTableHtml = tags.length > 0
|
||||
? html`
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
<th>Updated</th>
|
||||
<th class="w-48">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${tags.map(tag => html`
|
||||
<tr data-tag-key=${tag.key} data-tag-value=${tag.value || ''} data-tag-type=${tag.value_type}>
|
||||
<td class="font-mono font-semibold">${tag.key}</td>
|
||||
<td class="max-w-xs truncate" title=${tag.value || ''}>${tag.value || '-'}</td>
|
||||
<td>
|
||||
<span class="badge badge-ghost badge-sm">${tag.value_type}</span>
|
||||
</td>
|
||||
<td class="text-sm opacity-70">${formatDateTimeShort(tag.updated_at)}</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-ghost btn-xs btn-edit">Edit</button>
|
||||
<button class="btn btn-ghost btn-xs btn-move">Move</button>
|
||||
<button class="btn btn-ghost btn-xs text-error btn-delete">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`)}</tbody>
|
||||
</table>
|
||||
</div>`
|
||||
: html`
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<p>No tags found for this node.</p>
|
||||
<p class="text-sm mt-2">Add a new tag below.</p>
|
||||
</div>`;
|
||||
|
||||
const bulkButtons = tags.length > 0
|
||||
? html`
|
||||
<button id="btn-copy-all" class="btn btn-outline btn-sm">Copy All</button>
|
||||
<button id="btn-delete-all" class="btn btn-outline btn-error btn-sm">Delete All</button>`
|
||||
: nothing;
|
||||
|
||||
contentHtml = html`
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-2xl" title=${selectedNode.adv_type || 'Unknown'}>${nodeEmoji}</span>
|
||||
<div>
|
||||
<h2 class="card-title">${nodeName}</h2>
|
||||
<p class="text-sm opacity-70 font-mono">${selectedPublicKey}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
${bulkButtons}
|
||||
<a href="/nodes/${encodeURIComponent(selectedPublicKey)}" class="btn btn-ghost btn-sm">View Node</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Tags (${tags.length})</h2>
|
||||
${tagsTableHtml}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Add New Tag</h2>
|
||||
<form id="add-tag-form" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Key</span></label>
|
||||
<input type="text" name="key" class="input input-bordered" placeholder="tag_name" required maxlength="100">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Value</span></label>
|
||||
<input type="text" name="value" class="input input-bordered" placeholder="tag value">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Type</span></label>
|
||||
<select name="value_type" class="select select-bordered">
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text"> </span></label>
|
||||
<button type="submit" class="btn btn-primary">Add Tag</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="editModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Edit Tag</h3>
|
||||
<form id="edit-tag-form" class="py-4">
|
||||
<div class="form-control mb-4">
|
||||
<label class="label"><span class="label-text">Key</span></label>
|
||||
<input type="text" id="editKeyDisplay" class="input input-bordered" disabled>
|
||||
</div>
|
||||
<div class="form-control mb-4">
|
||||
<label class="label"><span class="label-text">Value</span></label>
|
||||
<input type="text" id="editValue" class="input input-bordered">
|
||||
</div>
|
||||
<div class="form-control mb-4">
|
||||
<label class="label"><span class="label-text">Type</span></label>
|
||||
<select id="editValueType" class="select select-bordered w-full">
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="editCancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="moveModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Move Tag to Another Node</h3>
|
||||
<form id="move-tag-form" class="py-4">
|
||||
<div class="form-control mb-4">
|
||||
<label class="label"><span class="label-text">Tag Key</span></label>
|
||||
<input type="text" id="moveKeyDisplay" class="input input-bordered" disabled>
|
||||
</div>
|
||||
<div class="form-control mb-4">
|
||||
<label class="label"><span class="label-text">Destination Node</span></label>
|
||||
<select id="moveDestination" class="select select-bordered w-full" required>
|
||||
<option value="">-- Select destination node --</option>
|
||||
${otherNodes.map(n => {
|
||||
const name = n.name || 'Unnamed';
|
||||
const keyPreview = n.public_key.slice(0, 8) + '...' + n.public_key.slice(-4);
|
||||
return html`<option value=${n.public_key}>${name} (${keyPreview})</option>`;
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div class="alert alert-warning mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span>This will move the tag from the current node to the destination node.</span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="moveCancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-warning">Move Tag</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="deleteModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Delete Tag</h3>
|
||||
<div class="py-4">
|
||||
<p class="py-4">Are you sure you want to delete the tag "<span id="deleteKeyDisplay" class="font-mono font-semibold"></span>"?</p>
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span>This action cannot be undone.</span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="deleteCancel">Cancel</button>
|
||||
<button type="button" class="btn btn-error" id="deleteConfirm">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="copyAllModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Copy All Tags to Another Node</h3>
|
||||
<form id="copy-all-form" class="py-4">
|
||||
<p class="mb-4">Copy all ${tags.length} tag(s) from <strong>${nodeName}</strong> to another node.</p>
|
||||
<div class="form-control mb-4">
|
||||
<label class="label"><span class="label-text">Destination Node</span></label>
|
||||
<select id="copyAllDestination" class="select select-bordered w-full" required>
|
||||
<option value="">-- Select destination node --</option>
|
||||
${otherNodes.map(n => {
|
||||
const name = n.name || 'Unnamed';
|
||||
const keyPreview = n.public_key.slice(0, 8) + '...' + n.public_key.slice(-4);
|
||||
return html`<option value=${n.public_key}>${name} (${keyPreview})</option>`;
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div class="alert alert-info mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span>Tags that already exist on the destination node will be skipped. Original tags remain on this node.</span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="copyAllCancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Copy Tags</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="deleteAllModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Delete All Tags</h3>
|
||||
<div class="py-4">
|
||||
<p class="mb-4">Are you sure you want to delete all ${tags.length} tag(s) from <strong>${nodeName}</strong>?</p>
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span>This action cannot be undone. All tags will be permanently deleted.</span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="deleteAllCancel">Cancel</button>
|
||||
<button type="button" class="btn btn-error" id="deleteAllConfirm">Delete All Tags</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>`;
|
||||
} else if (selectedPublicKey && !selectedNode) {
|
||||
contentHtml = html`
|
||||
<div class="alert alert-warning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span>Node not found: ${selectedPublicKey}</span>
|
||||
</div>`;
|
||||
} else {
|
||||
contentHtml = html`
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body text-center py-12">
|
||||
${iconTag('h-16 w-16 mx-auto mb-4 opacity-30')}
|
||||
<h2 class="text-xl font-semibold mb-2">Select a Node</h2>
|
||||
<p class="opacity-70">Choose a node from the dropdown above to view and manage its tags.</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Node Tags</h1>
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/a/">Admin</a></li>
|
||||
<li>Node Tags</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/oauth2/sign_out" target="_blank" class="btn btn-outline btn-sm">Sign Out</a>
|
||||
</div>
|
||||
|
||||
${flashHtml}
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Select Node</h2>
|
||||
<div class="flex gap-4 items-end">
|
||||
<div class="form-control flex-1">
|
||||
<label class="label"><span class="label-text">Node</span></label>
|
||||
<select id="node-selector" class="select select-bordered w-full">
|
||||
<option value="">-- Select a node --</option>
|
||||
${allNodes.map(n => {
|
||||
const name = n.name || 'Unnamed';
|
||||
const keyPreview = n.public_key.slice(0, 8) + '...' + n.public_key.slice(-4);
|
||||
return html`<option value=${n.public_key} ?selected=${n.public_key === selectedPublicKey}>${name} (${keyPreview})</option>`;
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<button id="load-tags-btn" class="btn btn-primary">Load Tags</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${contentHtml}`, container);
|
||||
|
||||
// Event: node selector change
|
||||
const nodeSelector = container.querySelector('#node-selector');
|
||||
nodeSelector.addEventListener('change', () => {
|
||||
const pk = nodeSelector.value;
|
||||
if (pk) {
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(pk));
|
||||
} else {
|
||||
router.navigate('/a/node-tags');
|
||||
}
|
||||
});
|
||||
|
||||
container.querySelector('#load-tags-btn').addEventListener('click', () => {
|
||||
const pk = nodeSelector.value;
|
||||
if (pk) {
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(pk));
|
||||
}
|
||||
});
|
||||
|
||||
if (selectedPublicKey && selectedNode) {
|
||||
let activeTagKey = '';
|
||||
|
||||
// Add tag form
|
||||
container.querySelector('#add-tag-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const key = form.key.value.trim();
|
||||
const value = form.value.value;
|
||||
const value_type = form.value_type.value;
|
||||
|
||||
try {
|
||||
await apiPost('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags', {
|
||||
key, value, value_type,
|
||||
});
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent('Tag added successfully'));
|
||||
} catch (err) {
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
}
|
||||
});
|
||||
|
||||
// Edit button handlers
|
||||
container.querySelectorAll('.btn-edit').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const row = btn.closest('tr');
|
||||
activeTagKey = row.dataset.tagKey;
|
||||
container.querySelector('#editKeyDisplay').value = activeTagKey;
|
||||
container.querySelector('#editValue').value = row.dataset.tagValue;
|
||||
container.querySelector('#editValueType').value = row.dataset.tagType;
|
||||
container.querySelector('#editModal').showModal();
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelector('#editCancel').addEventListener('click', () => {
|
||||
container.querySelector('#editModal').close();
|
||||
});
|
||||
|
||||
container.querySelector('#edit-tag-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const value = container.querySelector('#editValue').value;
|
||||
const value_type = container.querySelector('#editValueType').value;
|
||||
|
||||
try {
|
||||
await apiPut('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags/' + encodeURIComponent(activeTagKey), {
|
||||
value, value_type,
|
||||
});
|
||||
container.querySelector('#editModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent('Tag updated successfully'));
|
||||
} catch (err) {
|
||||
container.querySelector('#editModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
}
|
||||
});
|
||||
|
||||
// Move button handlers
|
||||
container.querySelectorAll('.btn-move').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const row = btn.closest('tr');
|
||||
activeTagKey = row.dataset.tagKey;
|
||||
container.querySelector('#moveKeyDisplay').value = activeTagKey;
|
||||
container.querySelector('#moveDestination').selectedIndex = 0;
|
||||
container.querySelector('#moveModal').showModal();
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelector('#moveCancel').addEventListener('click', () => {
|
||||
container.querySelector('#moveModal').close();
|
||||
});
|
||||
|
||||
container.querySelector('#move-tag-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const newPublicKey = container.querySelector('#moveDestination').value;
|
||||
if (!newPublicKey) return;
|
||||
|
||||
try {
|
||||
await apiPut('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags/' + encodeURIComponent(activeTagKey) + '/move', {
|
||||
new_public_key: newPublicKey,
|
||||
});
|
||||
container.querySelector('#moveModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent('Tag moved successfully'));
|
||||
} catch (err) {
|
||||
container.querySelector('#moveModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
}
|
||||
});
|
||||
|
||||
// Delete button handlers
|
||||
container.querySelectorAll('.btn-delete').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const row = btn.closest('tr');
|
||||
activeTagKey = row.dataset.tagKey;
|
||||
container.querySelector('#deleteKeyDisplay').textContent = activeTagKey;
|
||||
container.querySelector('#deleteModal').showModal();
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelector('#deleteCancel').addEventListener('click', () => {
|
||||
container.querySelector('#deleteModal').close();
|
||||
});
|
||||
|
||||
container.querySelector('#deleteConfirm').addEventListener('click', async () => {
|
||||
try {
|
||||
await apiDelete('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags/' + encodeURIComponent(activeTagKey));
|
||||
container.querySelector('#deleteModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent('Tag deleted successfully'));
|
||||
} catch (err) {
|
||||
container.querySelector('#deleteModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
}
|
||||
});
|
||||
|
||||
// Copy All button
|
||||
const copyAllBtn = container.querySelector('#btn-copy-all');
|
||||
if (copyAllBtn) {
|
||||
copyAllBtn.addEventListener('click', () => {
|
||||
container.querySelector('#copyAllDestination').selectedIndex = 0;
|
||||
container.querySelector('#copyAllModal').showModal();
|
||||
});
|
||||
|
||||
container.querySelector('#copyAllCancel').addEventListener('click', () => {
|
||||
container.querySelector('#copyAllModal').close();
|
||||
});
|
||||
|
||||
container.querySelector('#copy-all-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const destKey = container.querySelector('#copyAllDestination').value;
|
||||
if (!destKey) return;
|
||||
|
||||
try {
|
||||
const result = await apiPost('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags/copy-to/' + encodeURIComponent(destKey));
|
||||
container.querySelector('#copyAllModal').close();
|
||||
const msg = `Copied ${result.copied} tag(s), skipped ${result.skipped}`;
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(msg));
|
||||
} catch (err) {
|
||||
container.querySelector('#copyAllModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Delete All button
|
||||
const deleteAllBtn = container.querySelector('#btn-delete-all');
|
||||
if (deleteAllBtn) {
|
||||
deleteAllBtn.addEventListener('click', () => {
|
||||
container.querySelector('#deleteAllModal').showModal();
|
||||
});
|
||||
|
||||
container.querySelector('#deleteAllCancel').addEventListener('click', () => {
|
||||
container.querySelector('#deleteAllModal').close();
|
||||
});
|
||||
|
||||
container.querySelector('#deleteAllConfirm').addEventListener('click', async () => {
|
||||
try {
|
||||
await apiDelete('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags');
|
||||
container.querySelector('#deleteAllModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent('All tags deleted successfully'));
|
||||
} catch (err) {
|
||||
container.querySelector('#deleteAllModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || 'Failed to load node tags'), container);
|
||||
}
|
||||
}
|
||||
206
src/meshcore_hub/web/static/js/spa/pages/advertisements.js
Normal file
206
src/meshcore_hub/web/static/js/spa/pages/advertisements.js
Normal file
@@ -0,0 +1,206 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, typeEmoji, formatDateTime, formatDateTimeShort,
|
||||
truncateKey, errorAlert,
|
||||
pagination, createFilterHandler, autoSubmit, submitOnEnter
|
||||
} from '../components.js';
|
||||
|
||||
export async function render(container, params, router) {
|
||||
const query = params.query || {};
|
||||
const search = query.search || '';
|
||||
const public_key = query.public_key || '';
|
||||
const member_id = query.member_id || '';
|
||||
const page = parseInt(query.page, 10) || 1;
|
||||
const limit = parseInt(query.limit, 10) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const config = getConfig();
|
||||
const tz = config.timezone || '';
|
||||
const tzBadge = tz && tz !== 'UTC' ? html`<span class="text-sm opacity-60">${tz}</span>` : nothing;
|
||||
const navigate = (url) => router.navigate(url);
|
||||
|
||||
function renderPage(content, { total = null } = {}) {
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Advertisements</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
${tzBadge}
|
||||
${total !== null ? html`<span class="badge badge-lg">${total} total</span>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
${content}`, container);
|
||||
}
|
||||
|
||||
// Render page header immediately (old content stays visible until data loads)
|
||||
renderPage(nothing);
|
||||
|
||||
try {
|
||||
const [data, nodesData, membersData] = await Promise.all([
|
||||
apiGet('/api/v1/advertisements', { limit, offset, search, public_key, member_id }),
|
||||
apiGet('/api/v1/nodes', { limit: 500 }),
|
||||
apiGet('/api/v1/members', { limit: 100 }),
|
||||
]);
|
||||
|
||||
const advertisements = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const allNodes = nodesData.items || [];
|
||||
const members = membersData.items || [];
|
||||
|
||||
const sortedNodes = allNodes.map(n => {
|
||||
const tagName = n.tags?.find(t => t.key === 'name')?.value;
|
||||
return { ...n, _sortName: (tagName || n.name || '').toLowerCase(), _displayName: tagName || n.name || n.public_key.slice(0, 12) + '...' };
|
||||
}).sort((a, b) => a._sortName.localeCompare(b._sortName));
|
||||
|
||||
const nodesFilter = sortedNodes.length > 0
|
||||
? html`
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Node</span>
|
||||
</label>
|
||||
<select name="public_key" class="select select-bordered select-sm" @change=${autoSubmit}>
|
||||
<option value="">All Nodes</option>
|
||||
${sortedNodes.map(n => html`<option value=${n.public_key} ?selected=${public_key === n.public_key}>${n._displayName}</option>`)}
|
||||
</select>
|
||||
</div>`
|
||||
: nothing;
|
||||
|
||||
const membersFilter = members.length > 0
|
||||
? html`
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Member</span>
|
||||
</label>
|
||||
<select name="member_id" class="select select-bordered select-sm" @change=${autoSubmit}>
|
||||
<option value="">All Members</option>
|
||||
${members.map(m => html`<option value=${m.member_id} ?selected=${member_id === m.member_id}>${m.name}${m.callsign ? ` (${m.callsign})` : ''}</option>`)}
|
||||
</select>
|
||||
</div>`
|
||||
: nothing;
|
||||
|
||||
const mobileCards = advertisements.length === 0
|
||||
? html`<div class="text-center py-8 opacity-70">No advertisements found.</div>`
|
||||
: advertisements.map(ad => {
|
||||
const emoji = typeEmoji(ad.adv_type);
|
||||
const adName = ad.node_tag_name || ad.node_name || ad.name;
|
||||
const nameBlock = adName
|
||||
? html`<div class="font-medium text-sm truncate">${adName}</div>
|
||||
<div class="text-xs font-mono opacity-60 truncate">${ad.public_key.slice(0, 16)}...</div>`
|
||||
: html`<div class="font-mono text-sm truncate">${ad.public_key.slice(0, 16)}...</div>`;
|
||||
let receiversBlock = nothing;
|
||||
if (ad.receivers && ad.receivers.length >= 1) {
|
||||
receiversBlock = html`<div class="flex gap-0.5 justify-end mt-1">
|
||||
${ad.receivers.map(recv => {
|
||||
const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12);
|
||||
return html`<span class="text-sm" title=${recvName}>\u{1F4E1}</span>`;
|
||||
})}
|
||||
</div>`;
|
||||
} else if (ad.received_by) {
|
||||
const recvTitle = ad.receiver_tag_name || ad.receiver_name || truncateKey(ad.received_by, 12);
|
||||
receiversBlock = html`<span class="text-sm" title=${recvTitle}>\u{1F4E1}</span>`;
|
||||
}
|
||||
return html`<a href="/nodes/${ad.public_key}" class="card bg-base-100 shadow-sm block">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-lg flex-shrink-0" title=${ad.adv_type || 'Unknown'}>${emoji}</span>
|
||||
<div class="min-w-0">
|
||||
${nameBlock}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-xs opacity-60">${formatDateTimeShort(ad.received_at)}</div>
|
||||
${receiversBlock}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>`;
|
||||
});
|
||||
|
||||
const tableRows = advertisements.length === 0
|
||||
? html`<tr><td colspan="3" class="text-center py-8 opacity-70">No advertisements found.</td></tr>`
|
||||
: advertisements.map(ad => {
|
||||
const emoji = typeEmoji(ad.adv_type);
|
||||
const adName = ad.node_tag_name || ad.node_name || ad.name;
|
||||
const nameBlock = adName
|
||||
? html`<div class="font-medium">${adName}</div>
|
||||
<div class="text-xs font-mono opacity-70">${ad.public_key.slice(0, 16)}...</div>`
|
||||
: html`<span class="font-mono text-sm">${ad.public_key.slice(0, 16)}...</span>`;
|
||||
let receiversBlock;
|
||||
if (ad.receivers && ad.receivers.length >= 1) {
|
||||
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" title=${recvName}>\u{1F4E1}</a>`;
|
||||
})}
|
||||
</div>`;
|
||||
} else if (ad.received_by) {
|
||||
const recvTitle = ad.receiver_tag_name || ad.receiver_name || truncateKey(ad.received_by, 12);
|
||||
receiversBlock = html`<a href="/nodes/${ad.received_by}" class="text-lg hover:opacity-70" title=${recvTitle}>\u{1F4E1}</a>`;
|
||||
} else {
|
||||
receiversBlock = html`<span class="opacity-50">-</span>`;
|
||||
}
|
||||
return html`<tr class="hover">
|
||||
<td>
|
||||
<a href="/nodes/${ad.public_key}" class="link link-hover flex items-center gap-2">
|
||||
<span class="text-lg" title=${ad.adv_type || 'Unknown'}>${emoji}</span>
|
||||
<div>
|
||||
${nameBlock}
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">${formatDateTime(ad.received_at)}</td>
|
||||
<td>${receiversBlock}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
const paginationBlock = pagination(page, totalPages, '/advertisements', {
|
||||
search, public_key, member_id, limit,
|
||||
});
|
||||
|
||||
renderPage(html`
|
||||
<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">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Search</span>
|
||||
</label>
|
||||
<input type="text" name="search" .value=${search} placeholder="Search by name, ID, or public key..." class="input input-bordered input-sm w-80" @keydown=${submitOnEnter} />
|
||||
</div>
|
||||
${nodesFilter}
|
||||
${membersFilter}
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/advertisements" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:hidden space-y-3">
|
||||
${mobileCards}
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:block overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Time</th>
|
||||
<th>Receivers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tableRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
${paginationBlock}`, { total });
|
||||
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
}
|
||||
}
|
||||
28
src/meshcore_hub/web/static/js/spa/pages/custom-page.js
Normal file
28
src/meshcore_hub/web/static/js/spa/pages/custom-page.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import { html, litRender, unsafeHTML, getConfig, errorAlert } from '../components.js';
|
||||
|
||||
export async function render(container, params, router) {
|
||||
try {
|
||||
const page = await apiGet('/spa/pages/' + encodeURIComponent(params.slug));
|
||||
|
||||
const config = getConfig();
|
||||
const networkName = config.network_name || 'MeshCore Network';
|
||||
document.title = `${page.title} - ${networkName}`;
|
||||
|
||||
litRender(html`
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body prose prose-lg max-w-none">
|
||||
${unsafeHTML(page.content_html)}
|
||||
</div>
|
||||
</div>
|
||||
</div>`, container);
|
||||
|
||||
} catch (e) {
|
||||
if (e.message && e.message.includes('404')) {
|
||||
litRender(errorAlert('Page not found'), container);
|
||||
} else {
|
||||
litRender(errorAlert(e.message || 'Failed to load page'), container);
|
||||
}
|
||||
}
|
||||
}
|
||||
261
src/meshcore_hub/web/static/js/spa/pages/dashboard.js
Normal file
261
src/meshcore_hub/web/static/js/spa/pages/dashboard.js
Normal file
@@ -0,0 +1,261 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, typeEmoji, errorAlert, pageColors,
|
||||
} from '../components.js';
|
||||
import {
|
||||
iconNodes, iconAdvertisements, iconMessages, iconChannel,
|
||||
} from '../icons.js';
|
||||
|
||||
function formatTimeOnly(isoString) {
|
||||
if (!isoString) return '-';
|
||||
try {
|
||||
const config = getConfig();
|
||||
const tz = config.timezone_iana || 'UTC';
|
||||
const date = new Date(isoString);
|
||||
if (isNaN(date.getTime())) return '-';
|
||||
return date.toLocaleString('en-GB', {
|
||||
timeZone: tz,
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeShort(isoString) {
|
||||
if (!isoString) return '-';
|
||||
try {
|
||||
const config = getConfig();
|
||||
const tz = config.timezone_iana || 'UTC';
|
||||
const date = new Date(isoString);
|
||||
if (isNaN(date.getTime())) return '-';
|
||||
return date.toLocaleString('en-GB', {
|
||||
timeZone: tz,
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
function renderRecentAds(ads) {
|
||||
if (!ads || ads.length === 0) {
|
||||
return html`<p class="text-sm opacity-70">No advertisements recorded yet.</p>`;
|
||||
}
|
||||
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
|
||||
? html`<div class="text-xs opacity-50 font-mono">${ad.public_key.slice(0, 12)}...</div>`
|
||||
: nothing;
|
||||
return html`<tr>
|
||||
<td>
|
||||
<a href="/nodes/${ad.public_key}" class="link link-hover">
|
||||
<div class="font-medium">${displayName}</div>
|
||||
</a>
|
||||
${keyLine}
|
||||
</td>
|
||||
<td>${ad.adv_type ? typeEmoji(ad.adv_type) : html`<span class="opacity-50">-</span>`}</td>
|
||||
<td class="text-right text-sm opacity-70">${formatTimeOnly(ad.received_at)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
return html`<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Type</th>
|
||||
<th class="text-right">Received</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderChannelMessages(channelMessages) {
|
||||
if (!channelMessages || Object.keys(channelMessages).length === 0) return nothing;
|
||||
|
||||
const channels = Object.entries(channelMessages).map(([channel, messages]) => {
|
||||
const msgLines = messages.map(msg => html`
|
||||
<div class="text-sm">
|
||||
<span class="text-xs opacity-50">${formatTimeShort(msg.received_at)}</span>
|
||||
<span class="break-words" style="white-space: pre-wrap;">${msg.text || ''}</span>
|
||||
</div>`);
|
||||
|
||||
return html`<div>
|
||||
<h3 class="font-semibold text-sm mb-2 flex items-center gap-2">
|
||||
<span class="badge badge-info badge-sm">CH${String(channel)}</span>
|
||||
Channel ${String(channel)}
|
||||
</h3>
|
||||
<div class="space-y-1 pl-2 border-l-2 border-base-300">
|
||||
${msgLines}
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
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')}
|
||||
Recent Channel Messages
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
${channels}
|
||||
</div>
|
||||
</div>
|
||||
</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 }),
|
||||
apiGet('/api/v1/dashboard/message-activity', { days: 7 }),
|
||||
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>
|
||||
|
||||
${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>` : nothing}
|
||||
|
||||
${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>` : nothing}
|
||||
|
||||
${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>` : nothing}
|
||||
</div>
|
||||
|
||||
<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')}
|
||||
Total Nodes
|
||||
</h2>
|
||||
<p class="text-xs opacity-70">Over time (last 7 days)</p>
|
||||
<div class="h-32">
|
||||
<canvas id="nodeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : nothing}
|
||||
|
||||
${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')}
|
||||
Advertisements
|
||||
</h2>
|
||||
<p class="text-xs opacity-70">Per day (last 7 days)</p>
|
||||
<div class="h-32">
|
||||
<canvas id="advertChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : nothing}
|
||||
|
||||
${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')}
|
||||
Messages
|
||||
</h2>
|
||||
<p class="text-xs opacity-70">Per day (last 7 days)</p>
|
||||
<div class="h-32">
|
||||
<canvas id="messageChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : nothing}
|
||||
</div>` : nothing}
|
||||
|
||||
${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')}
|
||||
Recent Advertisements
|
||||
</h2>
|
||||
${renderRecentAds(stats.recent_advertisements)}
|
||||
</div>
|
||||
</div>` : nothing}
|
||||
|
||||
${showMessages ? renderChannelMessages(stats.channel_messages) : nothing}
|
||||
</div>` : nothing}`, container);
|
||||
|
||||
window.initDashboardCharts(
|
||||
showNodes ? nodeCount : null,
|
||||
showAdverts ? advertActivity : null,
|
||||
showMessages ? messageActivity : null,
|
||||
);
|
||||
|
||||
const chartIds = ['nodeChart', 'advertChart', 'messageChart'];
|
||||
return () => {
|
||||
chartIds.forEach(id => {
|
||||
const canvas = document.getElementById(id);
|
||||
if (canvas) {
|
||||
const instance = window.Chart.getChart(canvas);
|
||||
if (instance) instance.destroy();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || 'Failed to load dashboard'), container);
|
||||
}
|
||||
}
|
||||
209
src/meshcore_hub/web/static/js/spa/pages/home.js
Normal file
209
src/meshcore_hub/web/static/js/spa/pages/home.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, errorAlert, pageColors,
|
||||
} from '../components.js';
|
||||
import {
|
||||
iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconMap,
|
||||
iconPage, iconInfo, iconChart, iconGlobe, iconGithub,
|
||||
} from '../icons.js';
|
||||
|
||||
function renderRadioConfig(rc) {
|
||||
if (!rc) return nothing;
|
||||
const fields = [
|
||||
['Profile', rc.profile],
|
||||
['Frequency', rc.frequency],
|
||||
['Bandwidth', rc.bandwidth],
|
||||
['Spreading Factor', rc.spreading_factor],
|
||||
['Coding Rate', rc.coding_rate],
|
||||
['TX Power', rc.tx_power],
|
||||
];
|
||||
return fields
|
||||
.filter(([, v]) => v)
|
||||
.map(([label, value]) => html`
|
||||
<div class="flex justify-between">
|
||||
<span class="opacity-70">${label}:</span>
|
||||
<span class="font-mono">${String(value)}</span>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
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 || [];
|
||||
const rc = config.network_radio_config;
|
||||
|
||||
const [stats, advertActivity, messageActivity] = await Promise.all([
|
||||
apiGet('/api/v1/dashboard/stats'),
|
||||
apiGet('/api/v1/dashboard/activity', { days: 7 }),
|
||||
apiGet('/api/v1/dashboard/message-activity', { days: 7 }),
|
||||
]);
|
||||
|
||||
const cityCountry = (config.network_city && config.network_country)
|
||||
? 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
|
||||
? html`<p class="py-4 max-w-[70%]">${config.network_welcome_text}</p>`
|
||||
: html`<p class="py-4 max-w-[70%]">
|
||||
Welcome to the ${networkName} mesh network dashboard.
|
||||
Monitor network activity, view connected nodes, and explore message history.
|
||||
</p>`;
|
||||
|
||||
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="${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="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>` : nothing}
|
||||
${features.nodes !== false ? html`
|
||||
<a href="/nodes" class="btn btn-outline btn-primary">
|
||||
${iconNodes('h-5 w-5 mr-2')}
|
||||
Nodes
|
||||
</a>` : nothing}
|
||||
${features.advertisements !== false ? html`
|
||||
<a href="/advertisements" class="btn btn-outline btn-secondary">
|
||||
${iconAdvertisements('h-5 w-5 mr-2')}
|
||||
Adverts
|
||||
</a>` : nothing}
|
||||
${features.messages !== false ? html`
|
||||
<a href="/messages" class="btn btn-outline btn-accent">
|
||||
${iconMessages('h-5 w-5 mr-2')}
|
||||
Messages
|
||||
</a>` : nothing}
|
||||
${features.map !== false ? html`
|
||||
<a href="/map" class="btn btn-outline btn-warning">
|
||||
${iconMap('h-5 w-5 mr-2')}
|
||||
Map
|
||||
</a>` : nothing}
|
||||
${customPageButtons}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${showStats ? html`
|
||||
<div class="flex flex-col gap-4">
|
||||
${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>` : nothing}
|
||||
|
||||
${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>` : nothing}
|
||||
|
||||
${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>` : nothing}
|
||||
</div>` : nothing}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
${iconInfo('h-6 w-6')}
|
||||
Network Info
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
${renderRadioConfig(rc)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<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="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">
|
||||
<a href="https://meshcore.co.uk/" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
|
||||
${iconGlobe('h-4 w-4 mr-1')}
|
||||
Website
|
||||
</a>
|
||||
<a href="https://github.com/meshcore-dev/MeshCore" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
|
||||
${iconGithub('h-4 w-4 mr-1')}
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${showActivityChart ? html`
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
${iconChart('h-6 w-6')}
|
||||
Network Activity
|
||||
</h2>
|
||||
<p class="text-sm opacity-70 mb-2">Activity per day (last 7 days)</p>
|
||||
<div class="h-48">
|
||||
<canvas id="activityChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : nothing}
|
||||
</div>`, container);
|
||||
|
||||
let chart = null;
|
||||
if (showActivityChart) {
|
||||
chart = window.createActivityChart(
|
||||
'activityChart',
|
||||
showAdvertSeries ? advertActivity : null,
|
||||
showMessageSeries ? messageActivity : null,
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (chart) chart.destroy();
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || 'Failed to load home page'), container);
|
||||
}
|
||||
}
|
||||
333
src/meshcore_hub/web/static/js/spa/pages/map.js
Normal file
333
src/meshcore_hub/web/static/js/spa/pages/map.js
Normal file
@@ -0,0 +1,333 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
typeEmoji, formatRelativeTime, escapeHtml, errorAlert,
|
||||
timezoneIndicator,
|
||||
} from '../components.js';
|
||||
|
||||
const MAX_BOUNDS_RADIUS_KM = 20;
|
||||
|
||||
function getDistanceKm(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371;
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
function getNodesWithinRadius(nodes, anchorLat, anchorLon, radiusKm) {
|
||||
return nodes.filter(n => getDistanceKm(anchorLat, anchorLon, n.lat, n.lon) <= radiusKm);
|
||||
}
|
||||
|
||||
function getAnchorPoint(nodes, infraCenter) {
|
||||
if (infraCenter) return infraCenter;
|
||||
if (nodes.length === 0) return { lat: 0, lon: 0 };
|
||||
return {
|
||||
lat: nodes.reduce((sum, n) => sum + n.lat, 0) / nodes.length,
|
||||
lon: nodes.reduce((sum, n) => sum + n.lon, 0) / nodes.length,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeType(type) {
|
||||
return type ? type.toLowerCase() : null;
|
||||
}
|
||||
|
||||
function getTypeDisplay(node) {
|
||||
const type = normalizeType(node.adv_type);
|
||||
if (type === 'chat') return 'Chat';
|
||||
if (type === 'repeater') return 'Repeater';
|
||||
if (type === 'room') return 'Room';
|
||||
return type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Unknown';
|
||||
}
|
||||
|
||||
// Leaflet DivIcon requires plain HTML strings, so keep escapeHtml here
|
||||
function createNodeIcon(node) {
|
||||
const displayName = node.name || '';
|
||||
const relativeTime = formatRelativeTime(node.last_seen);
|
||||
const timeDisplay = relativeTime ? ' (' + relativeTime + ')' : '';
|
||||
|
||||
const iconHtml = node.is_infra
|
||||
? '<div style="width: 12px; height: 12px; background: #ef4444; border: 2px solid #b91c1c; border-radius: 50%; box-shadow: 0 0 4px rgba(239,68,68,0.6), 0 1px 2px rgba(0,0,0,0.5);"></div>'
|
||||
: '<div style="width: 12px; height: 12px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%; box-shadow: 0 0 4px rgba(59,130,246,0.6), 0 1px 2px rgba(0,0,0,0.5);"></div>';
|
||||
|
||||
return L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: '<div class="map-marker" style="display: flex; flex-direction: column; align-items: center; gap: 2px;">' +
|
||||
iconHtml +
|
||||
'<span class="map-label" style="font-size: 10px; font-weight: bold; color: #fff; background: rgba(0,0,0,0.5); padding: 1px 4px; border-radius: 3px; white-space: nowrap; text-align: center;">' +
|
||||
escapeHtml(displayName) + timeDisplay + '</span>' +
|
||||
'</div>',
|
||||
iconSize: [120, 50],
|
||||
iconAnchor: [60, 12],
|
||||
});
|
||||
}
|
||||
|
||||
// Leaflet popup requires plain HTML strings, so keep escapeHtml here
|
||||
function createPopupContent(node) {
|
||||
let ownerHtml = '';
|
||||
if (node.owner) {
|
||||
const ownerDisplay = node.owner.callsign
|
||||
? escapeHtml(node.owner.name) + ' (' + escapeHtml(node.owner.callsign) + ')'
|
||||
: escapeHtml(node.owner.name);
|
||||
ownerHtml = '<p><span class="opacity-70">Owner:</span> ' + ownerDisplay + '</p>';
|
||||
}
|
||||
|
||||
let roleHtml = '';
|
||||
if (node.role) {
|
||||
roleHtml = '<p><span class="opacity-70">Role:</span> <span class="badge badge-xs badge-ghost">' + escapeHtml(node.role) + '</span></p>';
|
||||
}
|
||||
|
||||
const typeDisplay = getTypeDisplay(node);
|
||||
const nodeTypeEmoji = typeEmoji(node.adv_type);
|
||||
|
||||
let infraIndicatorHtml = '';
|
||||
if (typeof node.is_infra !== 'undefined') {
|
||||
const dotColor = node.is_infra ? '#ef4444' : '#3b82f6';
|
||||
const borderColor = node.is_infra ? '#b91c1c' : '#1e40af';
|
||||
const title = node.is_infra ? 'Infrastructure' : 'Public';
|
||||
infraIndicatorHtml = ' <span style="display: inline-block; width: 10px; height: 10px; background: ' + dotColor + '; border: 2px solid ' + borderColor + '; border-radius: 50%; vertical-align: middle;" title="' + title + '"></span>';
|
||||
}
|
||||
|
||||
const lastSeenHtml = node.last_seen
|
||||
? '<p><span class="opacity-70">Last seen:</span> ' + node.last_seen.substring(0, 19).replace('T', ' ') + '</p>'
|
||||
: '';
|
||||
|
||||
return '<div class="p-2">' +
|
||||
'<h3 class="font-bold text-lg mb-2">' + nodeTypeEmoji + ' ' + escapeHtml(node.name || 'Unknown') + infraIndicatorHtml + '</h3>' +
|
||||
'<div class="space-y-1 text-sm">' +
|
||||
'<p><span class="opacity-70">Type:</span> ' + escapeHtml(typeDisplay) + '</p>' +
|
||||
roleHtml +
|
||||
ownerHtml +
|
||||
'<p><span class="opacity-70">Key:</span> <code class="text-xs">' + escapeHtml(node.public_key.substring(0, 16)) + '...</code></p>' +
|
||||
'<p><span class="opacity-70">Location:</span> ' + node.lat.toFixed(4) + ', ' + node.lon.toFixed(4) + '</p>' +
|
||||
lastSeenHtml +
|
||||
'</div>' +
|
||||
'<a href="/nodes/' + encodeURIComponent(node.public_key) + '" class="btn btn-outline btn-xs mt-3">View Details</a>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
export async function render(container, params, router) {
|
||||
try {
|
||||
const data = await apiGet('/map/data');
|
||||
const allNodes = data.nodes || [];
|
||||
const allMembers = data.members || [];
|
||||
const mapCenter = data.center || { lat: 0, lon: 0 };
|
||||
const infraCenter = data.infra_center || null;
|
||||
const debug = data.debug || {};
|
||||
|
||||
const isMobilePortrait = window.innerWidth < 480;
|
||||
const isMobile = window.innerWidth < 768;
|
||||
const BOUNDS_PADDING = isMobilePortrait ? [50, 50] : (isMobile ? [75, 75] : [100, 100]);
|
||||
|
||||
const sortedMembers = allMembers.slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
function applyFilters() {
|
||||
const filteredNodes = applyFiltersCore();
|
||||
const categoryFilter = container.querySelector('#filter-category').value;
|
||||
|
||||
if (filteredNodes.length > 0) {
|
||||
let nodesToFit = filteredNodes;
|
||||
|
||||
if (categoryFilter !== 'infra') {
|
||||
const anchor = getAnchorPoint(filteredNodes, infraCenter);
|
||||
const nearbyNodes = getNodesWithinRadius(filteredNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM);
|
||||
if (nearbyNodes.length > 0) {
|
||||
nodesToFit = nearbyNodes;
|
||||
}
|
||||
}
|
||||
|
||||
const bounds = L.latLngBounds(nodesToFit.map(n => [n.lat, n.lon]));
|
||||
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
|
||||
} else if (mapCenter.lat !== 0 || mapCenter.lon !== 0) {
|
||||
map.setView([mapCenter.lat, mapCenter.lon], 10);
|
||||
}
|
||||
}
|
||||
|
||||
function updateLabelVisibility() {
|
||||
const showLabels = container.querySelector('#show-labels').checked;
|
||||
if (showLabels) {
|
||||
mapEl.classList.add('show-labels');
|
||||
} else {
|
||||
mapEl.classList.remove('show-labels');
|
||||
}
|
||||
}
|
||||
|
||||
function clearFiltersHandler() {
|
||||
container.querySelector('#filter-category').value = '';
|
||||
container.querySelector('#filter-type').value = '';
|
||||
container.querySelector('#filter-member').value = '';
|
||||
container.querySelector('#show-labels').checked = false;
|
||||
updateLabelVisibility();
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Map</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
${timezoneIndicator()}
|
||||
<span id="node-count" class="badge badge-lg">Loading...</span>
|
||||
<span id="filtered-count" class="badge badge-lg badge-ghost hidden"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Show</span>
|
||||
</label>
|
||||
<select id="filter-category" class="select select-bordered select-sm" @change=${applyFilters}>
|
||||
<option value="">All Nodes</option>
|
||||
<option value="infra">Infrastructure Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Node Type</span>
|
||||
</label>
|
||||
<select id="filter-type" class="select select-bordered select-sm" @change=${applyFilters}>
|
||||
<option value="">All Types</option>
|
||||
<option value="chat">Chat</option>
|
||||
<option value="repeater">Repeater</option>
|
||||
<option value="room">Room</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Member</span>
|
||||
</label>
|
||||
<select id="filter-member" class="select select-bordered select-sm" @change=${applyFilters}>
|
||||
<option value="">All Members</option>
|
||||
${sortedMembers
|
||||
.filter(m => m.member_id)
|
||||
.map(m => {
|
||||
const label = m.callsign
|
||||
? m.name + ' (' + m.callsign + ')'
|
||||
: m.name;
|
||||
return html`<option value=${m.member_id}>${label}</option>`;
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer gap-2 py-1">
|
||||
<span class="label-text">Show Labels</span>
|
||||
<input type="checkbox" id="show-labels" class="checkbox checkbox-sm" @change=${updateLabelVisibility}>
|
||||
</label>
|
||||
</div>
|
||||
<button id="clear-filters" class="btn btn-ghost btn-sm" @click=${clearFiltersHandler}>Clear Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body p-2">
|
||||
<div id="spa-map" style="height: calc(100vh - 300px); min-height: 400px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-4 items-center text-sm">
|
||||
<span class="opacity-70">Legend:</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<div style="width: 10px; height: 10px; background: #ef4444; border: 2px solid #b91c1c; border-radius: 50%;"></div>
|
||||
<span>Infrastructure</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div style="width: 10px; height: 10px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%;"></div>
|
||||
<span>Public</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm opacity-70">
|
||||
<p>Nodes are placed on the map based on GPS coordinates from node reports or manual tags.</p>
|
||||
</div>`, container);
|
||||
|
||||
const mapEl = container.querySelector('#spa-map');
|
||||
const map = L.map(mapEl).setView([0, 0], 2);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
|
||||
let markers = [];
|
||||
|
||||
function clearMarkers() {
|
||||
markers.forEach(m => map.removeLayer(m));
|
||||
markers = [];
|
||||
}
|
||||
|
||||
function applyFiltersCore() {
|
||||
const categoryFilter = container.querySelector('#filter-category').value;
|
||||
const typeFilter = container.querySelector('#filter-type').value;
|
||||
const memberFilter = container.querySelector('#filter-member').value;
|
||||
|
||||
const filteredNodes = allNodes.filter(node => {
|
||||
if (categoryFilter === 'infra' && !node.is_infra) return false;
|
||||
const nodeType = normalizeType(node.adv_type);
|
||||
if (typeFilter && nodeType !== typeFilter) return false;
|
||||
if (memberFilter && node.member_id !== memberFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
clearMarkers();
|
||||
|
||||
filteredNodes.forEach(node => {
|
||||
const marker = L.marker([node.lat, node.lon], { icon: createNodeIcon(node) }).addTo(map);
|
||||
marker.bindPopup(createPopupContent(node));
|
||||
markers.push(marker);
|
||||
});
|
||||
|
||||
const countEl = container.querySelector('#node-count');
|
||||
const filteredEl = container.querySelector('#filtered-count');
|
||||
|
||||
if (filteredNodes.length === allNodes.length) {
|
||||
countEl.textContent = allNodes.length + ' nodes on map';
|
||||
filteredEl.classList.add('hidden');
|
||||
} else {
|
||||
countEl.textContent = allNodes.length + ' total';
|
||||
filteredEl.textContent = filteredNodes.length + ' shown';
|
||||
filteredEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
return filteredNodes;
|
||||
}
|
||||
|
||||
if (debug.error) {
|
||||
container.querySelector('#node-count').textContent = 'Error: ' + debug.error;
|
||||
return () => map.remove();
|
||||
}
|
||||
|
||||
if (debug.total_nodes === 0) {
|
||||
container.querySelector('#node-count').textContent = 'No nodes in database';
|
||||
return () => map.remove();
|
||||
}
|
||||
|
||||
if (debug.nodes_with_coords === 0) {
|
||||
container.querySelector('#node-count').textContent = debug.total_nodes + ' nodes (none have coordinates)';
|
||||
return () => map.remove();
|
||||
}
|
||||
|
||||
const infraNodes = allNodes.filter(n => n.is_infra);
|
||||
if (infraNodes.length > 0) {
|
||||
const bounds = L.latLngBounds(infraNodes.map(n => [n.lat, n.lon]));
|
||||
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
|
||||
} else if (allNodes.length > 0) {
|
||||
const anchor = getAnchorPoint(allNodes, infraCenter);
|
||||
const nearbyNodes = getNodesWithinRadius(allNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM);
|
||||
const nodesToFit = nearbyNodes.length > 0 ? nearbyNodes : allNodes;
|
||||
const bounds = L.latLngBounds(nodesToFit.map(n => [n.lat, n.lon]));
|
||||
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
|
||||
}
|
||||
|
||||
applyFiltersCore();
|
||||
|
||||
return () => map.remove();
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || 'Failed to load map'), container);
|
||||
}
|
||||
}
|
||||
153
src/meshcore_hub/web/static/js/spa/pages/members.js
Normal file
153
src/meshcore_hub/web/static/js/spa/pages/members.js
Normal file
@@ -0,0 +1,153 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
formatRelativeTime, formatDateTime, errorAlert,
|
||||
} from '../components.js';
|
||||
import { iconInfo } from '../icons.js';
|
||||
|
||||
function nodeTypeEmoji(advType) {
|
||||
switch ((advType || '').toLowerCase()) {
|
||||
case 'chat': return '\u{1F4AC}';
|
||||
case 'repeater': return '\u{1F4E1}';
|
||||
case 'room': return '\u{1FAA7}';
|
||||
default: return advType ? '\u{1F4CD}' : '\u{1F4E6}';
|
||||
}
|
||||
}
|
||||
|
||||
function nodeSortKey(node) {
|
||||
const t = (node.adv_type || '').toLowerCase();
|
||||
if (t === 'repeater') return 0;
|
||||
if (t === 'chat') return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function renderNodeCard(node) {
|
||||
const tagName = node.tags ? (node.tags.find(t => t.key === 'name') || {}).value : null;
|
||||
const displayName = tagName || node.name;
|
||||
const emoji = nodeTypeEmoji(node.adv_type);
|
||||
const relTime = formatRelativeTime(node.last_seen);
|
||||
const fullTime = formatDateTime(node.last_seen);
|
||||
|
||||
const nameBlock = displayName
|
||||
? html`<div class="font-medium text-sm">${displayName}</div>
|
||||
<div class="font-mono text-xs opacity-60">${node.public_key.slice(0, 12)}...</div>`
|
||||
: html`<div class="font-mono text-sm">${node.public_key.slice(0, 12)}...</div>`;
|
||||
|
||||
const timeBlock = node.last_seen
|
||||
? html`<time class="text-xs opacity-60 whitespace-nowrap" datetime=${node.last_seen} title=${fullTime} data-relative-time>${relTime}</time>`
|
||||
: nothing;
|
||||
|
||||
return html`<a href="/nodes/${node.public_key}" class="flex items-center gap-3 p-2 bg-base-200 rounded-lg hover:bg-base-300 transition-colors">
|
||||
<span class="text-lg" title=${node.adv_type || 'Unknown'}>${emoji}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
${nameBlock}
|
||||
</div>
|
||||
${timeBlock}
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function renderMemberCard(member, nodes) {
|
||||
const sorted = [...nodes].sort((a, b) => nodeSortKey(a) - nodeSortKey(b));
|
||||
const nodesBlock = sorted.length > 0
|
||||
? html`<div class="mt-4 space-y-2">${sorted.map(renderNodeCard)}</div>`
|
||||
: nothing;
|
||||
|
||||
const callsignBadge = member.callsign
|
||||
? html`<span class="badge badge-neutral">${member.callsign}</span>`
|
||||
: nothing;
|
||||
|
||||
const descBlock = member.description
|
||||
? html`<p class="mt-2">${member.description}</p>`
|
||||
: nothing;
|
||||
|
||||
const contactBlock = member.contact
|
||||
? html`<p class="text-sm mt-2"><span class="opacity-70">Contact:</span> ${member.contact}</p>`
|
||||
: nothing;
|
||||
|
||||
return html`<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
${member.name}
|
||||
${callsignBadge}
|
||||
</h2>
|
||||
${descBlock}
|
||||
${contactBlock}
|
||||
${nodesBlock}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export async function render(container, params, router) {
|
||||
try {
|
||||
const membersResp = await apiGet('/api/v1/members', { limit: 100 });
|
||||
const members = membersResp.items || [];
|
||||
|
||||
if (members.length === 0) {
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Members</h1>
|
||||
<span class="badge badge-lg">0 members</span>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
${iconInfo('stroke-current shrink-0 h-6 w-6')}
|
||||
<div>
|
||||
<h3 class="font-bold">No members configured</h3>
|
||||
<p class="text-sm">To display network members, create a members.yaml file in your seed directory.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Members File Format</h2>
|
||||
<p class="mb-4">Create a YAML file at <code>$SEED_HOME/members.yaml</code> with the following structure:</p>
|
||||
<pre class="bg-base-200 p-4 rounded-box text-sm overflow-x-auto"><code>members:
|
||||
- member_id: johndoe
|
||||
name: John Doe
|
||||
callsign: AB1CD
|
||||
role: Network Admin
|
||||
description: Manages the main repeater node.
|
||||
contact: john@example.com
|
||||
- member_id: janesmith
|
||||
name: Jane Smith
|
||||
role: Member
|
||||
description: Regular user in the downtown area.</code></pre>
|
||||
<p class="mt-4 text-sm opacity-70">
|
||||
Run <code>meshcore-hub collector seed</code> to import members.<br/>
|
||||
To associate nodes with members, add a <code>member_id</code> tag to nodes in <code>node_tags.yaml</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>`, container);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodePromises = members.map(m =>
|
||||
apiGet('/api/v1/nodes', { member_id: m.member_id, limit: 50 })
|
||||
.then(resp => ({ memberId: m.member_id, nodes: resp.items || [] }))
|
||||
.catch(() => ({ memberId: m.member_id, nodes: [] }))
|
||||
);
|
||||
const nodeResults = await Promise.all(nodePromises);
|
||||
|
||||
const nodesByMember = {};
|
||||
for (const r of nodeResults) {
|
||||
nodesByMember[r.memberId] = r.nodes;
|
||||
}
|
||||
|
||||
const cards = members.map(m =>
|
||||
renderMemberCard(m, nodesByMember[m.member_id] || [])
|
||||
);
|
||||
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Members</h1>
|
||||
<span class="badge badge-lg">${members.length} members</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 items-start">
|
||||
${cards}
|
||||
</div>`, container);
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || 'Failed to load members'), container);
|
||||
}
|
||||
}
|
||||
189
src/meshcore_hub/web/static/js/spa/pages/messages.js
Normal file
189
src/meshcore_hub/web/static/js/spa/pages/messages.js
Normal file
@@ -0,0 +1,189 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, formatDateTime, formatDateTimeShort,
|
||||
truncateKey, errorAlert,
|
||||
pagination, timezoneIndicator,
|
||||
createFilterHandler, autoSubmit, submitOnEnter
|
||||
} from '../components.js';
|
||||
|
||||
export async function render(container, params, router) {
|
||||
const query = params.query || {};
|
||||
const message_type = query.message_type || '';
|
||||
const page = parseInt(query.page, 10) || 1;
|
||||
const limit = parseInt(query.limit, 10) || 50;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const config = getConfig();
|
||||
const tz = config.timezone || '';
|
||||
const tzBadge = tz && tz !== 'UTC' ? html`<span class="text-sm opacity-60">${tz}</span>` : nothing;
|
||||
const navigate = (url) => router.navigate(url);
|
||||
|
||||
function renderPage(content, { total = null } = {}) {
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Messages</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
${tzBadge}
|
||||
${total !== null ? html`<span class="badge badge-lg">${total} total</span>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
${content}`, container);
|
||||
}
|
||||
|
||||
// Render page header immediately (old content stays visible until data loads)
|
||||
renderPage(nothing);
|
||||
|
||||
try {
|
||||
const data = await apiGet('/api/v1/messages', { limit, offset, message_type });
|
||||
const messages = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const mobileCards = messages.length === 0
|
||||
? html`<div class="text-center py-8 opacity-70">No messages found.</div>`
|
||||
: messages.map(msg => {
|
||||
const isChannel = msg.message_type === 'channel';
|
||||
const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}';
|
||||
const typeTitle = isChannel ? 'Channel' : 'Contact';
|
||||
let senderBlock;
|
||||
if (isChannel) {
|
||||
senderBlock = html`<span class="opacity-60">Public</span>`;
|
||||
} else {
|
||||
const senderName = msg.sender_tag_name || msg.sender_name;
|
||||
if (senderName) {
|
||||
senderBlock = senderName;
|
||||
} else {
|
||||
senderBlock = html`<span class="font-mono text-xs">${(msg.pubkey_prefix || '-').slice(0, 12)}</span>`;
|
||||
}
|
||||
}
|
||||
let receiversBlock = nothing;
|
||||
if (msg.receivers && msg.receivers.length >= 1) {
|
||||
receiversBlock = html`<div class="flex gap-0.5">
|
||||
${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-sm hover:opacity-70" title=${recvName}>\u{1F4E1}</a>`;
|
||||
})}
|
||||
</div>`;
|
||||
} else if (msg.received_by) {
|
||||
const recvTitle = msg.receiver_tag_name || msg.receiver_name || truncateKey(msg.received_by, 12);
|
||||
receiversBlock = html`<a href="/nodes/${msg.received_by}" class="text-sm hover:opacity-70" title=${recvTitle}>\u{1F4E1}</a>`;
|
||||
}
|
||||
return html`<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-lg flex-shrink-0" title=${typeTitle}>
|
||||
${typeIcon}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-sm truncate">
|
||||
${senderBlock}
|
||||
</div>
|
||||
<div class="text-xs opacity-60">
|
||||
${formatDateTimeShort(msg.received_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
${receiversBlock}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm mt-2 break-words whitespace-pre-wrap">${msg.text || '-'}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
const tableRows = messages.length === 0
|
||||
? html`<tr><td colspan="5" class="text-center py-8 opacity-70">No messages found.</td></tr>`
|
||||
: messages.map(msg => {
|
||||
const isChannel = msg.message_type === 'channel';
|
||||
const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}';
|
||||
const typeTitle = isChannel ? 'Channel' : 'Contact';
|
||||
let senderBlock;
|
||||
if (isChannel) {
|
||||
senderBlock = html`<span class="opacity-60">Public</span>`;
|
||||
} else {
|
||||
const senderName = msg.sender_tag_name || msg.sender_name;
|
||||
if (senderName) {
|
||||
senderBlock = html`<span class="font-medium">${senderName}</span>`;
|
||||
} else {
|
||||
senderBlock = html`<span class="font-mono text-xs">${(msg.pubkey_prefix || '-').slice(0, 12)}</span>`;
|
||||
}
|
||||
}
|
||||
let receiversBlock;
|
||||
if (msg.receivers && msg.receivers.length >= 1) {
|
||||
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" title=${recvName}>\u{1F4E1}</a>`;
|
||||
})}
|
||||
</div>`;
|
||||
} else if (msg.received_by) {
|
||||
const recvTitle = msg.receiver_tag_name || msg.receiver_name || truncateKey(msg.received_by, 12);
|
||||
receiversBlock = html`<a href="/nodes/${msg.received_by}" class="text-lg hover:opacity-70" title=${recvTitle}>\u{1F4E1}</a>`;
|
||||
} else {
|
||||
receiversBlock = html`<span class="opacity-50">-</span>`;
|
||||
}
|
||||
return html`<tr class="hover align-top">
|
||||
<td class="text-lg" title=${typeTitle}>${typeIcon}</td>
|
||||
<td class="text-sm whitespace-nowrap">${formatDateTime(msg.received_at)}</td>
|
||||
<td class="text-sm whitespace-nowrap">${senderBlock}</td>
|
||||
<td class="break-words max-w-md" style="white-space: pre-wrap;">${msg.text || '-'}</td>
|
||||
<td>${receiversBlock}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
const paginationBlock = pagination(page, totalPages, '/messages', {
|
||||
message_type, limit,
|
||||
});
|
||||
|
||||
renderPage(html`
|
||||
<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">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Type</span>
|
||||
</label>
|
||||
<select name="message_type" class="select select-bordered select-sm" @change=${autoSubmit}>
|
||||
<option value="">All Types</option>
|
||||
<option value="contact" ?selected=${message_type === 'contact'}>Direct</option>
|
||||
<option value="channel" ?selected=${message_type === 'channel'}>Channel</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/messages" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:hidden space-y-3">
|
||||
${mobileCards}
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:block overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Time</th>
|
||||
<th>From</th>
|
||||
<th>Message</th>
|
||||
<th>Receivers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tableRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
${paginationBlock}`, { total });
|
||||
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
}
|
||||
}
|
||||
250
src/meshcore_hub/web/static/js/spa/pages/node-detail.js
Normal file
250
src/meshcore_hub/web/static/js/spa/pages/node-detail.js
Normal file
@@ -0,0 +1,250 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, typeEmoji, formatDateTime,
|
||||
truncateKey, errorAlert,
|
||||
} from '../components.js';
|
||||
import { iconError } from '../icons.js';
|
||||
|
||||
export async function render(container, params, router) {
|
||||
const cleanupFns = [];
|
||||
let publicKey = params.publicKey;
|
||||
|
||||
try {
|
||||
if (publicKey.length !== 64) {
|
||||
const resolved = await apiGet('/api/v1/nodes/prefix/' + encodeURIComponent(publicKey));
|
||||
router.navigate('/nodes/' + resolved.public_key, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const [node, adsData, telemetryData] = await Promise.all([
|
||||
apiGet('/api/v1/nodes/' + publicKey),
|
||||
apiGet('/api/v1/advertisements', { public_key: publicKey, limit: 10 }),
|
||||
apiGet('/api/v1/telemetry', { node_public_key: publicKey, limit: 10 }),
|
||||
]);
|
||||
|
||||
if (!node) {
|
||||
litRender(renderNotFound(publicKey), container);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
const tagName = node.tags?.find(t => t.key === 'name')?.value;
|
||||
const displayName = tagName || node.name || 'Unnamed Node';
|
||||
const emoji = typeEmoji(node.adv_type);
|
||||
|
||||
let lat = node.lat;
|
||||
let lon = node.lon;
|
||||
if (!lat || !lon) {
|
||||
for (const tag of node.tags || []) {
|
||||
if (tag.key === 'lat' && !lat) lat = parseFloat(tag.value);
|
||||
if (tag.key === 'lon' && !lon) lon = parseFloat(tag.value);
|
||||
}
|
||||
}
|
||||
const hasCoords = lat != null && lon != null && !(lat === 0 && lon === 0);
|
||||
|
||||
const advertisements = adsData.items || [];
|
||||
|
||||
const heroHtml = hasCoords
|
||||
? html`
|
||||
<div class="relative rounded-box overflow-hidden mb-6 shadow-xl" style="height: 180px;">
|
||||
<div id="header-map" class="absolute inset-0 z-0"></div>
|
||||
<div class="relative z-20 h-full p-3 flex items-center justify-end">
|
||||
<div id="qr-code" class="bg-white p-2 rounded shadow-lg"></div>
|
||||
</div>
|
||||
</div>`
|
||||
: html`
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body flex-row items-center gap-4">
|
||||
<div id="qr-code" class="bg-white p-1 rounded"></div>
|
||||
<p class="text-sm opacity-70">Scan to add as contact</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const coordsHtml = hasCoords
|
||||
? html`<div><span class="opacity-70">Location:</span> ${lat}, ${lon}</div>`
|
||||
: nothing;
|
||||
|
||||
const adsTableHtml = advertisements.length > 0
|
||||
? html`<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Type</th>
|
||||
<th>Received By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${advertisements.map(adv => {
|
||||
const advEmoji = adv.adv_type ? typeEmoji(adv.adv_type) : '';
|
||||
const advTypeHtml = adv.adv_type
|
||||
? html`<span title=${adv.adv_type.charAt(0).toUpperCase() + adv.adv_type.slice(1)}>${advEmoji}</span>`
|
||||
: html`<span class="opacity-50">-</span>`;
|
||||
const recvName = adv.received_by ? (adv.receiver_tag_name || adv.receiver_name) : null;
|
||||
const receiverHtml = !adv.received_by
|
||||
? html`<span class="opacity-50">-</span>`
|
||||
: recvName
|
||||
? html`<a href="/nodes/${adv.received_by}" class="link link-hover">
|
||||
<div class="font-medium text-sm">${recvName}</div>
|
||||
<div class="text-xs font-mono opacity-70">${adv.received_by.slice(0, 16)}...</div>
|
||||
</a>`
|
||||
: html`<a href="/nodes/${adv.received_by}" class="link link-hover">
|
||||
<span class="font-mono text-xs">${adv.received_by.slice(0, 16)}...</span>
|
||||
</a>`;
|
||||
return html`<tr>
|
||||
<td class="text-xs whitespace-nowrap">${formatDateTime(adv.received_at)}</td>
|
||||
<td>${advTypeHtml}</td>
|
||||
<td>${receiverHtml}</td>
|
||||
</tr>`;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`
|
||||
: html`<p class="opacity-70">No advertisements recorded.</p>`;
|
||||
|
||||
const tags = node.tags || [];
|
||||
const tagsTableHtml = tags.length > 0
|
||||
? html`<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tags.map(tag => html`<tr>
|
||||
<td class="font-mono">${tag.key}</td>
|
||||
<td>${tag.value || ''}</td>
|
||||
<td class="opacity-70">${tag.value_type || 'string'}</td>
|
||||
</tr>`)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`
|
||||
: html`<p class="opacity-70">No tags defined.</p>`;
|
||||
|
||||
const adminTagsHtml = (config.admin_enabled && config.is_authenticated)
|
||||
? html`<div class="mt-3">
|
||||
<a href="/a/node-tags?public_key=${node.public_key}" class="btn btn-sm btn-outline">${tags.length > 0 ? 'Edit Tags' : 'Add Tags'}</a>
|
||||
</div>`
|
||||
: nothing;
|
||||
|
||||
litRender(html`
|
||||
<div class="breadcrumbs text-sm mb-4">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/nodes">Nodes</a></li>
|
||||
<li>${tagName || node.name || node.public_key.slice(0, 12) + '...'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-bold mb-6">
|
||||
<span title=${node.adv_type || 'Unknown'}>${emoji}</span>
|
||||
${displayName}
|
||||
</h1>
|
||||
|
||||
${heroHtml}
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Public Key</h3>
|
||||
<code class="text-sm bg-base-200 p-2 rounded block break-all">${node.public_key}</code>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-8 gap-y-2 mt-4 text-sm">
|
||||
<div><span class="opacity-70">First seen:</span> ${formatDateTime(node.first_seen)}</div>
|
||||
<div><span class="opacity-70">Last seen:</span> ${formatDateTime(node.last_seen)}</div>
|
||||
${coordsHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Recent Advertisements</h2>
|
||||
${adsTableHtml}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Tags</h2>
|
||||
${tagsTableHtml}
|
||||
${adminTagsHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>`, container);
|
||||
|
||||
// Initialize map if coordinates exist
|
||||
if (hasCoords && typeof L !== 'undefined') {
|
||||
const map = L.map('header-map', {
|
||||
zoomControl: false, dragging: false, scrollWheelZoom: false,
|
||||
doubleClickZoom: false, boxZoom: false, keyboard: false,
|
||||
attributionControl: false,
|
||||
});
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
|
||||
map.setView([lat, lon], 14);
|
||||
const point = map.latLngToContainerPoint([lat, lon]);
|
||||
const newPoint = L.point(point.x + map.getSize().x * 0.17, point.y);
|
||||
const newLatLng = map.containerPointToLatLng(newPoint);
|
||||
map.setView(newLatLng, 14, { animate: false });
|
||||
const icon = L.divIcon({
|
||||
html: '<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">' + emoji + '</span>',
|
||||
className: '', iconSize: [32, 32], iconAnchor: [16, 16],
|
||||
});
|
||||
L.marker([lat, lon], { icon }).addTo(map);
|
||||
cleanupFns.push(() => map.remove());
|
||||
}
|
||||
|
||||
// Initialize QR code - wait for both DOM element and QRCode library
|
||||
const initQr = () => {
|
||||
const qrEl = document.getElementById('qr-code');
|
||||
if (!qrEl || typeof QRCode === 'undefined') return false;
|
||||
const typeMap = { chat: 1, repeater: 2, room: 3, 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());
|
||||
};
|
||||
} catch (e) {
|
||||
if (e.message && e.message.includes('404')) {
|
||||
litRender(renderNotFound(publicKey), container);
|
||||
} else {
|
||||
litRender(errorAlert(e.message), container);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderNotFound(publicKey) {
|
||||
return html`
|
||||
<div class="breadcrumbs text-sm mb-4">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/nodes">Nodes</a></li>
|
||||
<li>Not Found</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="alert alert-error">
|
||||
${iconError('stroke-current shrink-0 h-6 w-6')}
|
||||
<span>Node not found: ${publicKey}</span>
|
||||
</div>
|
||||
<a href="/nodes" class="btn btn-primary mt-4">Back to Nodes</a>`;
|
||||
}
|
||||
189
src/meshcore_hub/web/static/js/spa/pages/nodes.js
Normal file
189
src/meshcore_hub/web/static/js/spa/pages/nodes.js
Normal file
@@ -0,0 +1,189 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, typeEmoji, formatDateTime, formatDateTimeShort,
|
||||
truncateKey, errorAlert,
|
||||
pagination, timezoneIndicator,
|
||||
createFilterHandler, autoSubmit, submitOnEnter
|
||||
} from '../components.js';
|
||||
|
||||
export async function render(container, params, router) {
|
||||
const query = params.query || {};
|
||||
const search = query.search || '';
|
||||
const adv_type = query.adv_type || '';
|
||||
const member_id = query.member_id || '';
|
||||
const page = parseInt(query.page, 10) || 1;
|
||||
const limit = parseInt(query.limit, 10) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const config = getConfig();
|
||||
const tz = config.timezone || '';
|
||||
const tzBadge = tz && tz !== 'UTC' ? html`<span class="text-sm opacity-60">${tz}</span>` : nothing;
|
||||
const navigate = (url) => router.navigate(url);
|
||||
|
||||
function renderPage(content, { total = null } = {}) {
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Nodes</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
${tzBadge}
|
||||
${total !== null ? html`<span class="badge badge-lg">${total} total</span>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
${content}`, container);
|
||||
}
|
||||
|
||||
// Render page header immediately (old content stays visible until data loads)
|
||||
renderPage(nothing);
|
||||
|
||||
try {
|
||||
const [data, membersData] = await Promise.all([
|
||||
apiGet('/api/v1/nodes', { limit, offset, search, adv_type, member_id }),
|
||||
apiGet('/api/v1/members', { limit: 100 }),
|
||||
]);
|
||||
|
||||
const nodes = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const members = membersData.items || [];
|
||||
|
||||
const membersFilter = members.length > 0
|
||||
? html`
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Member</span>
|
||||
</label>
|
||||
<select name="member_id" class="select select-bordered select-sm" @change=${autoSubmit}>
|
||||
<option value="">All Members</option>
|
||||
${members.map(m => html`<option value=${m.member_id} ?selected=${member_id === m.member_id}>${m.name}${m.callsign ? ` (${m.callsign})` : ''}</option>`)}
|
||||
</select>
|
||||
</div>`
|
||||
: nothing;
|
||||
|
||||
const mobileCards = nodes.length === 0
|
||||
? html`<div class="text-center py-8 opacity-70">No nodes found.</div>`
|
||||
: nodes.map(node => {
|
||||
const tagName = node.tags?.find(t => t.key === 'name')?.value;
|
||||
const displayName = tagName || node.name;
|
||||
const emoji = typeEmoji(node.adv_type);
|
||||
const nameBlock = displayName
|
||||
? html`<div class="font-medium text-sm truncate">${displayName}</div>
|
||||
<div class="text-xs font-mono opacity-60 truncate">${node.public_key.slice(0, 16)}...</div>`
|
||||
: html`<div class="font-mono text-sm truncate">${node.public_key.slice(0, 16)}...</div>`;
|
||||
const lastSeen = node.last_seen ? formatDateTimeShort(node.last_seen) : '-';
|
||||
const tags = node.tags || [];
|
||||
const tagsBlock = tags.length > 0
|
||||
? html`<div class="flex gap-1 justify-end mt-1">
|
||||
${tags.slice(0, 2).map(t => html`<span class="badge badge-ghost badge-xs">${t.key}</span>`)}
|
||||
${tags.length > 2 ? html`<span class="badge badge-ghost badge-xs">+${tags.length - 2}</span>` : nothing}
|
||||
</div>`
|
||||
: nothing;
|
||||
return html`<a href="/nodes/${node.public_key}" class="card bg-base-100 shadow-sm block">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-lg flex-shrink-0" title=${node.adv_type || 'Unknown'}>${emoji}</span>
|
||||
<div class="min-w-0">
|
||||
${nameBlock}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-xs opacity-60">${lastSeen}</div>
|
||||
${tagsBlock}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>`;
|
||||
});
|
||||
|
||||
const tableRows = nodes.length === 0
|
||||
? html`<tr><td colspan="3" class="text-center py-8 opacity-70">No nodes found.</td></tr>`
|
||||
: nodes.map(node => {
|
||||
const tagName = node.tags?.find(t => t.key === 'name')?.value;
|
||||
const displayName = tagName || node.name;
|
||||
const emoji = typeEmoji(node.adv_type);
|
||||
const nameBlock = displayName
|
||||
? html`<div class="font-medium">${displayName}</div>
|
||||
<div class="text-xs font-mono opacity-70">${node.public_key.slice(0, 16)}...</div>`
|
||||
: html`<span class="font-mono text-sm">${node.public_key.slice(0, 16)}...</span>`;
|
||||
const lastSeen = node.last_seen ? formatDateTime(node.last_seen) : '-';
|
||||
const tags = node.tags || [];
|
||||
const tagsBlock = tags.length > 0
|
||||
? html`<div class="flex gap-1 flex-wrap">
|
||||
${tags.slice(0, 3).map(t => html`<span class="badge badge-ghost badge-xs">${t.key}</span>`)}
|
||||
${tags.length > 3 ? html`<span class="badge badge-ghost badge-xs">+${tags.length - 3}</span>` : nothing}
|
||||
</div>`
|
||||
: html`<span class="opacity-50">-</span>`;
|
||||
return html`<tr class="hover">
|
||||
<td>
|
||||
<a href="/nodes/${node.public_key}" class="link link-hover flex items-center gap-2">
|
||||
<span class="text-lg" title=${node.adv_type || 'Unknown'}>${emoji}</span>
|
||||
<div>
|
||||
${nameBlock}
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">${lastSeen}</td>
|
||||
<td>${tagsBlock}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
const paginationBlock = pagination(page, totalPages, '/nodes', {
|
||||
search, adv_type, member_id, limit,
|
||||
});
|
||||
|
||||
renderPage(html`
|
||||
<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">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Search</span>
|
||||
</label>
|
||||
<input type="text" name="search" .value=${search} placeholder="Search by name, ID, or public key..." class="input input-bordered input-sm w-80" @keydown=${submitOnEnter} />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Type</span>
|
||||
</label>
|
||||
<select name="adv_type" class="select select-bordered select-sm" @change=${autoSubmit}>
|
||||
<option value="">All Types</option>
|
||||
<option value="chat" ?selected=${adv_type === 'chat'}>Chat</option>
|
||||
<option value="repeater" ?selected=${adv_type === 'repeater'}>Repeater</option>
|
||||
<option value="room" ?selected=${adv_type === 'room'}>Room</option>
|
||||
</select>
|
||||
</div>
|
||||
${membersFilter}
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/nodes" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:hidden space-y-3">
|
||||
${mobileCards}
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:block overflow-x-auto bg-base-100 rounded-box shadow">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tableRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
${paginationBlock}`, { total });
|
||||
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
}
|
||||
}
|
||||
27
src/meshcore_hub/web/static/js/spa/pages/not-found.js
Normal file
27
src/meshcore_hub/web/static/js/spa/pages/not-found.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { html, litRender } from '../components.js';
|
||||
import { iconHome, iconNodes } from '../icons.js';
|
||||
|
||||
export async function render(container, params, router) {
|
||||
litRender(html`
|
||||
<div class="hero min-h-[60vh]">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<div class="text-9xl font-bold text-primary opacity-20">404</div>
|
||||
<h1 class="text-4xl font-bold -mt-8">Page Not Found</h1>
|
||||
<p class="py-6 text-base-content/70">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<a href="/" class="btn btn-primary">
|
||||
${iconHome('h-5 w-5 mr-2')}
|
||||
Go Home
|
||||
</a>
|
||||
<a href="/nodes" class="btn btn-outline">
|
||||
${iconNodes('h-5 w-5 mr-2')}
|
||||
Browse Nodes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`, container);
|
||||
}
|
||||
163
src/meshcore_hub/web/static/js/spa/router.js
Normal file
163
src/meshcore_hub/web/static/js/spa/router.js
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* MeshCore Hub SPA - Client-Side Router
|
||||
*
|
||||
* Simple History API based router with parameterized routes.
|
||||
*/
|
||||
|
||||
export class Router {
|
||||
constructor() {
|
||||
this._routes = [];
|
||||
this._notFoundHandler = null;
|
||||
this._currentCleanup = null;
|
||||
this._onNavigate = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a route.
|
||||
* @param {string} path - URL pattern (e.g., '/nodes/:publicKey')
|
||||
* @param {Function} handler - async function(params) where params includes route params and query
|
||||
*/
|
||||
addRoute(path, handler) {
|
||||
const paramNames = [];
|
||||
const regexStr = path
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex chars
|
||||
.replace(/:([a-zA-Z_]+)/g, (_, name) => {
|
||||
paramNames.push(name);
|
||||
return '([^/]+)';
|
||||
});
|
||||
this._routes.push({
|
||||
pattern: new RegExp('^' + regexStr + '$'),
|
||||
paramNames,
|
||||
handler,
|
||||
path,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the 404 handler.
|
||||
* @param {Function} handler - async function(params)
|
||||
*/
|
||||
setNotFound(handler) {
|
||||
this._notFoundHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback to run on every navigation (for updating navbar, etc.)
|
||||
* @param {Function} fn - function(pathname)
|
||||
*/
|
||||
onNavigate(fn) {
|
||||
this._onNavigate = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a URL.
|
||||
* @param {string} url - URL path with optional query string
|
||||
* @param {boolean} [replace=false] - Use replaceState instead of pushState
|
||||
*/
|
||||
navigate(url, replace = false) {
|
||||
// Skip if already on this exact URL
|
||||
const current = window.location.pathname + window.location.search;
|
||||
if (url === current && !replace) return;
|
||||
|
||||
if (replace) {
|
||||
history.replaceState(null, '', url);
|
||||
} else {
|
||||
history.pushState(null, '', url);
|
||||
}
|
||||
this._handleRoute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a pathname against registered routes.
|
||||
* @param {string} pathname
|
||||
* @returns {{ handler: Function, params: Object } | null}
|
||||
*/
|
||||
_match(pathname) {
|
||||
for (const route of this._routes) {
|
||||
const match = pathname.match(route.pattern);
|
||||
if (match) {
|
||||
const params = {};
|
||||
route.paramNames.forEach((name, i) => {
|
||||
params[name] = decodeURIComponent(match[i + 1]);
|
||||
});
|
||||
return { handler: route.handler, params };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the current URL.
|
||||
*/
|
||||
async _handleRoute() {
|
||||
// Clean up previous page
|
||||
if (this._currentCleanup) {
|
||||
try { this._currentCleanup(); } catch (e) { /* ignore */ }
|
||||
this._currentCleanup = null;
|
||||
}
|
||||
|
||||
const pathname = window.location.pathname;
|
||||
const query = Object.fromEntries(new URLSearchParams(window.location.search));
|
||||
|
||||
// Notify navigation listener
|
||||
if (this._onNavigate) {
|
||||
this._onNavigate(pathname);
|
||||
}
|
||||
|
||||
// Show navbar loading indicator
|
||||
const loader = document.getElementById('nav-loading');
|
||||
if (loader) loader.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const result = this._match(pathname);
|
||||
if (result) {
|
||||
const cleanup = await result.handler({ ...result.params, query });
|
||||
if (typeof cleanup === 'function') {
|
||||
this._currentCleanup = cleanup;
|
||||
}
|
||||
} else if (this._notFoundHandler) {
|
||||
await this._notFoundHandler({ query });
|
||||
}
|
||||
} finally {
|
||||
if (loader) loader.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Scroll to top on navigation
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the router - listen for events and handle initial route.
|
||||
*/
|
||||
start() {
|
||||
// Handle browser back/forward
|
||||
window.addEventListener('popstate', () => this._handleRoute());
|
||||
|
||||
// Intercept link clicks for SPA navigation
|
||||
document.addEventListener('click', (e) => {
|
||||
const link = e.target.closest('a[href]');
|
||||
if (!link) return;
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
|
||||
// Skip external links, anchors, downloads, new tabs
|
||||
if (!href || !href.startsWith('/') || href.startsWith('//')) return;
|
||||
if (link.hasAttribute('download') || link.target === '_blank') return;
|
||||
|
||||
// Skip non-SPA paths (static files, API, media, OAuth, SEO)
|
||||
if (href.startsWith('/static/') || href.startsWith('/media/') ||
|
||||
href.startsWith('/api/') || href.startsWith('/oauth2/') ||
|
||||
href.startsWith('/health') || href === '/robots.txt' ||
|
||||
href === '/sitemap.xml') return;
|
||||
|
||||
// Skip mailto and tel links
|
||||
if (href.startsWith('mailto:') || href.startsWith('tel:')) return;
|
||||
|
||||
e.preventDefault();
|
||||
this.navigate(href);
|
||||
});
|
||||
|
||||
// Handle initial route
|
||||
this._handleRoute();
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* MeshCore Hub - Common JavaScript Utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format a timestamp as relative time (e.g., "2m", "1h", "2d")
|
||||
* @param {string|Date} timestamp - ISO timestamp string or Date object
|
||||
* @returns {string} Relative time string, or empty string if invalid
|
||||
*/
|
||||
function formatRelativeTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffDay > 0) return `${diffDay}d`;
|
||||
if (diffHour > 0) return `${diffHour}h`;
|
||||
if (diffMin > 0) return `${diffMin}m`;
|
||||
return '<1m';
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate all elements with data-timestamp attribute with relative time
|
||||
*/
|
||||
function populateRelativeTimestamps() {
|
||||
document.querySelectorAll('[data-timestamp]:not([data-receiver-tooltip])').forEach(el => {
|
||||
const timestamp = el.dataset.timestamp;
|
||||
if (timestamp) {
|
||||
el.textContent = formatRelativeTime(timestamp);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate receiver tooltip elements with name and relative time
|
||||
*/
|
||||
function populateReceiverTooltips() {
|
||||
document.querySelectorAll('[data-receiver-tooltip]').forEach(el => {
|
||||
const name = el.dataset.name || '';
|
||||
const timestamp = el.dataset.timestamp;
|
||||
const relTime = timestamp ? formatRelativeTime(timestamp) : '';
|
||||
|
||||
// Build tooltip: "NodeName (2m ago)" or just "NodeName" or just "2m ago"
|
||||
let tooltip = name;
|
||||
if (relTime) {
|
||||
tooltip = name ? `${name} (${relTime} ago)` : `${relTime} ago`;
|
||||
}
|
||||
el.title = tooltip;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate <time> elements with data-relative-time attribute
|
||||
* Uses the datetime attribute as the timestamp source
|
||||
*/
|
||||
function populateRelativeTimeElements() {
|
||||
document.querySelectorAll('time[data-relative-time]').forEach(el => {
|
||||
const timestamp = el.getAttribute('datetime');
|
||||
if (timestamp) {
|
||||
const relTime = formatRelativeTime(timestamp);
|
||||
el.textContent = relTime ? `${relTime} ago` : '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-populate when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
populateRelativeTimestamps();
|
||||
populateReceiverTooltips();
|
||||
populateRelativeTimeElements();
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
{# Reusable macros for templates #}
|
||||
|
||||
{#
|
||||
Pagination macro
|
||||
|
||||
Parameters:
|
||||
- page: Current page number
|
||||
- total_pages: Total number of pages
|
||||
- params: Dict of query parameters to preserve (e.g., {"search": "foo", "limit": 50})
|
||||
#}
|
||||
{% macro pagination(page, total_pages, params={}) %}
|
||||
{% if total_pages > 1 %}
|
||||
{% set query_parts = [] %}
|
||||
{% for key, value in params.items() %}
|
||||
{% if value is not none and value != '' %}
|
||||
{% set _ = query_parts.append(key ~ '=' ~ value) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% set base_query = query_parts|join('&') %}
|
||||
{% set query_prefix = '&' if base_query else '' %}
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="join">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}{{ query_prefix }}{{ base_query }}" class="join-item btn btn-sm">Previous</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Previous</button>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
|
||||
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
|
||||
<a href="?page={{ p }}{{ query_prefix }}{{ base_query }}" class="join-item btn btn-sm">{{ p }}</a>
|
||||
{% elif p == 2 or p == total_pages - 1 %}
|
||||
<button class="join-item btn btn-sm btn-disabled">...</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<a href="?page={{ page + 1 }}{{ query_prefix }}{{ base_query }}" class="join-item btn btn-sm">Next</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Next</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
@@ -1,20 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Access Denied{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col items-center justify-center min-h-[50vh]">
|
||||
<div class="text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 mx-auto text-error opacity-50 mb-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<h1 class="text-3xl font-bold mb-2">Access Denied</h1>
|
||||
<p class="text-lg opacity-70 mb-6">You don't have permission to access the admin area.</p>
|
||||
<p class="text-sm opacity-50 mb-8">Please contact the network administrator if you believe this is an error.</p>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<a href="/" class="btn btn-primary">Return Home</a>
|
||||
<a href="/oauth2/sign_out" class="btn btn-outline">Sign Out</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,70 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Admin</h1>
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li>Admin</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/oauth2/sign_out" class="btn btn-outline btn-sm">Sign Out</a>
|
||||
</div>
|
||||
|
||||
<!-- Authenticated User Info -->
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm opacity-70 mb-6">
|
||||
{% if auth_username or auth_user %}
|
||||
<span class="flex items-center gap-1.5">
|
||||
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
{{ auth_username or auth_user }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if auth_email %}
|
||||
<span class="flex items-center gap-1.5">
|
||||
<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 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{{ auth_email }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Navigation Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<a href="/a/members" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
Members
|
||||
</h2>
|
||||
<p>Manage network members and operators.</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/a/node-tags" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
Node Tags
|
||||
</h2>
|
||||
<p>Manage custom tags and metadata for network nodes.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,282 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Members Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Members</h1>
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/a/">Admin</a></li>
|
||||
<li>Members</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/oauth2/sign_out" class="btn btn-outline btn-sm">Sign Out</a>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% if message %}
|
||||
<div class="alert alert-success mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ message }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Members Table -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="card-title">Network Members ({{ members|length }})</h2>
|
||||
<button class="btn btn-primary btn-sm" onclick="addModal.showModal()">Add Member</button>
|
||||
</div>
|
||||
|
||||
{% if members %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member ID</th>
|
||||
<th>Name</th>
|
||||
<th>Callsign</th>
|
||||
<th>Contact</th>
|
||||
<th class="w-32">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for member in members %}
|
||||
<tr data-member-id="{{ member.id }}"
|
||||
data-member-name="{{ member.name }}"
|
||||
data-member-member-id="{{ member.member_id }}"
|
||||
data-member-callsign="{{ member.callsign or '' }}"
|
||||
data-member-description="{{ member.description or '' }}"
|
||||
data-member-contact="{{ member.contact or '' }}">
|
||||
<td class="font-mono font-semibold">{{ member.member_id }}</td>
|
||||
<td>{{ member.name }}</td>
|
||||
<td>
|
||||
{% if member.callsign %}
|
||||
<span class="badge badge-primary">{{ member.callsign }}</span>
|
||||
{% else %}
|
||||
<span class="text-base-content/40">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="max-w-xs truncate" title="{{ member.contact or '' }}">{{ member.contact or '-' }}</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-ghost btn-xs btn-edit">
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xs text-error btn-delete">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<p>No members configured yet.</p>
|
||||
<p class="text-sm mt-2">Click "Add Member" to create the first member.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Modal -->
|
||||
<dialog id="addModal" class="modal">
|
||||
<div class="modal-box w-11/12 max-w-2xl">
|
||||
<h3 class="font-bold text-lg">Add New Member</h3>
|
||||
<form method="post" action="/a/members" class="py-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Member ID <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input type="text" name="member_id" id="add_member_id" class="input input-bordered"
|
||||
placeholder="walshie86" required maxlength="50"
|
||||
pattern="[a-zA-Z0-9_]+"
|
||||
title="Letters, numbers, and underscores only">
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Unique identifier (letters, numbers, underscore)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Name <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input type="text" name="name" id="add_name" class="input input-bordered"
|
||||
placeholder="John Smith" required maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Callsign</span>
|
||||
</label>
|
||||
<input type="text" name="callsign" id="add_callsign" class="input input-bordered"
|
||||
placeholder="VK4ABC" maxlength="20">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Contact</span>
|
||||
</label>
|
||||
<input type="text" name="contact" id="add_contact" class="input input-bordered"
|
||||
placeholder="john@example.com or phone number" maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text">Description</span>
|
||||
</label>
|
||||
<textarea name="description" id="add_description" rows="3" class="textarea textarea-bordered"
|
||||
placeholder="Brief description of member's role and responsibilities..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="addModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Add Member</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<dialog id="editModal" class="modal">
|
||||
<div class="modal-box w-11/12 max-w-2xl">
|
||||
<h3 class="font-bold text-lg">Edit Member</h3>
|
||||
<form method="post" action="/a/members/update" class="py-4">
|
||||
<input type="hidden" name="id" id="edit_id">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Member ID <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input type="text" name="member_id" id="edit_member_id" class="input input-bordered"
|
||||
required maxlength="50" pattern="[a-zA-Z0-9_]+"
|
||||
title="Letters, numbers, and underscores only">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Name <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input type="text" name="name" id="edit_name" class="input input-bordered"
|
||||
required maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Callsign</span>
|
||||
</label>
|
||||
<input type="text" name="callsign" id="edit_callsign" class="input input-bordered"
|
||||
maxlength="20">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Contact</span>
|
||||
</label>
|
||||
<input type="text" name="contact" id="edit_contact" class="input input-bordered"
|
||||
maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text">Description</span>
|
||||
</label>
|
||||
<textarea name="description" id="edit_description" rows="3"
|
||||
class="textarea textarea-bordered"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="editModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<dialog id="deleteModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Delete Member</h3>
|
||||
<form method="post" action="/a/members/delete" class="py-4">
|
||||
<input type="hidden" name="id" id="delete_id">
|
||||
|
||||
<p class="py-4">Are you sure you want to delete member <strong id="delete_member_name"></strong>?</p>
|
||||
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>This action cannot be undone.</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="deleteModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-error">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Use event delegation to handle button clicks safely
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Edit button handler
|
||||
document.querySelectorAll('.btn-edit').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var row = this.closest('tr');
|
||||
document.getElementById('edit_id').value = row.dataset.memberId;
|
||||
document.getElementById('edit_member_id').value = row.dataset.memberMemberId;
|
||||
document.getElementById('edit_name').value = row.dataset.memberName;
|
||||
document.getElementById('edit_callsign').value = row.dataset.memberCallsign;
|
||||
document.getElementById('edit_description').value = row.dataset.memberDescription;
|
||||
document.getElementById('edit_contact').value = row.dataset.memberContact;
|
||||
editModal.showModal();
|
||||
});
|
||||
});
|
||||
|
||||
// Delete button handler
|
||||
document.querySelectorAll('.btn-delete').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var row = this.closest('tr');
|
||||
document.getElementById('delete_id').value = row.dataset.memberId;
|
||||
document.getElementById('delete_member_name').textContent = row.dataset.memberName;
|
||||
deleteModal.showModal();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,434 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Node Tags Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Node Tags</h1>
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/a/">Admin</a></li>
|
||||
<li>Node Tags</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/oauth2/sign_out" class="btn btn-outline btn-sm">Sign Out</a>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% if message %}
|
||||
<div class="alert alert-success mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ message }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Node Selector -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Select Node</h2>
|
||||
<form method="get" action="/a/node-tags" class="flex gap-4 items-end">
|
||||
<div class="form-control flex-1">
|
||||
<label class="label">
|
||||
<span class="label-text">Node</span>
|
||||
</label>
|
||||
<select name="public_key" class="select select-bordered w-full" onchange="this.form.submit()">
|
||||
<option value="">-- Select a node --</option>
|
||||
{% for node in nodes %}
|
||||
<option value="{{ node.public_key }}" {% if node.public_key == selected_public_key %}selected{% endif %}>
|
||||
{{ node.name or 'Unnamed' }} ({{ node.public_key[:8] }}...{{ node.public_key[-4:] }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Load Tags</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if selected_public_key and selected_node %}
|
||||
<!-- Selected Node Info -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-2xl" title="{{ selected_node.adv_type or 'Unknown' }}">{% if selected_node.adv_type and selected_node.adv_type|lower == 'chat' %}💬{% elif selected_node.adv_type and selected_node.adv_type|lower == 'repeater' %}📡{% elif selected_node.adv_type and selected_node.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
|
||||
<div>
|
||||
<h2 class="card-title">{{ selected_node.name or 'Unnamed Node' }}</h2>
|
||||
<p class="text-sm opacity-70 font-mono">{{ selected_public_key }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{% if tags %}
|
||||
<button class="btn btn-outline btn-sm" onclick="copyAllModal.showModal()">Copy All</button>
|
||||
<button class="btn btn-outline btn-error btn-sm" onclick="deleteAllModal.showModal()">Delete All</button>
|
||||
{% endif %}
|
||||
<a href="/nodes/{{ selected_public_key }}" class="btn btn-ghost btn-sm">View Node</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags Table -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Tags ({{ tags|length }})</h2>
|
||||
|
||||
{% if tags %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
<th>Updated</th>
|
||||
<th class="w-48">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag in tags %}
|
||||
<tr data-tag-key="{{ tag.key }}" data-tag-value="{{ tag.value or '' }}" data-tag-type="{{ tag.value_type }}">
|
||||
<td class="font-mono font-semibold">{{ tag.key }}</td>
|
||||
<td class="max-w-xs truncate" title="{{ tag.value or '' }}">{{ tag.value or '-' }}</td>
|
||||
<td>
|
||||
<span class="badge badge-ghost badge-sm">{{ tag.value_type }}</span>
|
||||
</td>
|
||||
<td class="text-sm opacity-70">{{ tag.updated_at[:10] if tag.updated_at else '-' }}</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-ghost btn-xs btn-edit">
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xs btn-move">
|
||||
Move
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xs text-error btn-delete">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<p>No tags found for this node.</p>
|
||||
<p class="text-sm mt-2">Add a new tag below.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add New Tag Form -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Add New Tag</h2>
|
||||
<form method="post" action="/a/node-tags" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Key</span>
|
||||
</label>
|
||||
<input type="text" name="key" class="input input-bordered" placeholder="tag_name" required maxlength="100">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Value</span>
|
||||
</label>
|
||||
<input type="text" name="value" class="input input-bordered" placeholder="tag value">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Type</span>
|
||||
</label>
|
||||
<select name="value_type" class="select select-bordered">
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text"> </span>
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary">Add Tag</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<dialog id="editModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Edit Tag</h3>
|
||||
<form method="post" action="/a/node-tags/update" class="py-4">
|
||||
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
|
||||
<input type="hidden" name="key" id="editKey">
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Key</span>
|
||||
</label>
|
||||
<input type="text" id="editKeyDisplay" class="input input-bordered" disabled>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Value</span>
|
||||
</label>
|
||||
<input type="text" name="value" id="editValue" class="input input-bordered">
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Type</span>
|
||||
</label>
|
||||
<select name="value_type" id="editValueType" class="select select-bordered w-full">
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="editModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Move Modal -->
|
||||
<dialog id="moveModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Move Tag to Another Node</h3>
|
||||
<form method="post" action="/a/node-tags/move" class="py-4">
|
||||
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
|
||||
<input type="hidden" name="key" id="moveKey">
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Tag Key</span>
|
||||
</label>
|
||||
<input type="text" id="moveKeyDisplay" class="input input-bordered" disabled>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Destination Node</span>
|
||||
</label>
|
||||
<select name="new_public_key" id="moveDestination" class="select select-bordered w-full" required>
|
||||
<option value="">-- Select destination node --</option>
|
||||
{% for node in nodes %}
|
||||
{% if node.public_key != selected_public_key %}
|
||||
<option value="{{ node.public_key }}">
|
||||
{{ node.name or 'Unnamed' }} ({{ node.public_key[:8] }}...{{ node.public_key[-4:] }})
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>This will move the tag from the current node to the destination node.</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="moveModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-warning">Move Tag</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<dialog id="deleteModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Delete Tag</h3>
|
||||
<form method="post" action="/a/node-tags/delete" class="py-4">
|
||||
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
|
||||
<input type="hidden" name="key" id="deleteKey">
|
||||
|
||||
<p class="py-4">Are you sure you want to delete the tag "<span id="deleteKeyDisplay" class="font-mono font-semibold"></span>"?</p>
|
||||
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>This action cannot be undone.</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="deleteModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-error">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Copy All Tags Modal -->
|
||||
<dialog id="copyAllModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Copy All Tags to Another Node</h3>
|
||||
<form method="post" action="/a/node-tags/copy-all" class="py-4">
|
||||
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
|
||||
|
||||
<p class="mb-4">Copy all {{ tags|length }} tag(s) from <strong>{{ selected_node.name or 'Unnamed' }}</strong> to another node.</p>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Destination Node</span>
|
||||
</label>
|
||||
<select name="dest_public_key" class="select select-bordered w-full" required>
|
||||
<option value="">-- Select destination node --</option>
|
||||
{% for node in nodes %}
|
||||
{% if node.public_key != selected_public_key %}
|
||||
<option value="{{ node.public_key }}">
|
||||
{{ node.name or 'Unnamed' }} ({{ node.public_key[:8] }}...{{ node.public_key[-4:] }})
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Tags that already exist on the destination node will be skipped. Original tags remain on this node.</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="copyAllModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Copy Tags</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Delete All Tags Modal -->
|
||||
<dialog id="deleteAllModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Delete All Tags</h3>
|
||||
<form method="post" action="/a/node-tags/delete-all" class="py-4">
|
||||
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
|
||||
|
||||
<p class="mb-4">Are you sure you want to delete all {{ tags|length }} tag(s) from <strong>{{ selected_node.name or 'Unnamed' }}</strong>?</p>
|
||||
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>This action cannot be undone. All tags will be permanently deleted.</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="deleteAllModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-error">Delete All Tags</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
{% elif selected_public_key and not selected_node %}
|
||||
<div class="alert alert-warning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>Node not found: {{ selected_public_key }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body text-center py-12">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto mb-4 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
<h2 class="text-xl font-semibold mb-2">Select a Node</h2>
|
||||
<p class="opacity-70">Choose a node from the dropdown above to view and manage its tags.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Use event delegation to handle button clicks safely
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Edit button handler
|
||||
document.querySelectorAll('.btn-edit').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var row = this.closest('tr');
|
||||
var key = row.dataset.tagKey;
|
||||
var value = row.dataset.tagValue;
|
||||
var valueType = row.dataset.tagType;
|
||||
document.getElementById('editKey').value = key;
|
||||
document.getElementById('editKeyDisplay').value = key;
|
||||
document.getElementById('editValue').value = value;
|
||||
document.getElementById('editValueType').value = valueType;
|
||||
editModal.showModal();
|
||||
});
|
||||
});
|
||||
|
||||
// Move button handler
|
||||
document.querySelectorAll('.btn-move').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var row = this.closest('tr');
|
||||
var key = row.dataset.tagKey;
|
||||
document.getElementById('moveKey').value = key;
|
||||
document.getElementById('moveKeyDisplay').value = key;
|
||||
document.getElementById('moveDestination').selectedIndex = 0;
|
||||
moveModal.showModal();
|
||||
});
|
||||
});
|
||||
|
||||
// Delete button handler
|
||||
document.querySelectorAll('.btn-delete').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var row = this.closest('tr');
|
||||
var key = row.dataset.tagKey;
|
||||
document.getElementById('deleteKey').value = key;
|
||||
document.getElementById('deleteKeyDisplay').textContent = key;
|
||||
deleteModal.showModal();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,163 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import pagination %}
|
||||
|
||||
{% block title %}{{ network_name }} - Advertisements{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Advertisements</h1>
|
||||
<span class="badge badge-lg">{{ total }} total</span>
|
||||
</div>
|
||||
|
||||
{% if api_error %}
|
||||
<div class="alert alert-warning mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>Could not fetch data from API: {{ api_error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card bg-base-100 shadow mb-6">
|
||||
<div class="card-body py-4">
|
||||
<form method="GET" action="/advertisements" class="flex gap-4 flex-wrap items-end">
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Search</span>
|
||||
</label>
|
||||
<input type="text" name="search" value="{{ search }}" placeholder="Search by name, ID, or public key..." class="input input-bordered input-sm w-80" />
|
||||
</div>
|
||||
{% if nodes %}
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Node</span>
|
||||
</label>
|
||||
<select name="public_key" class="select select-bordered select-sm">
|
||||
<option value="">All Nodes</option>
|
||||
{% for node in nodes %}
|
||||
{% set ns = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<option value="{{ node.public_key }}" {% if public_key == node.public_key %}selected{% endif %}>{{ ns.tag_name or node.name or node.public_key[:12] + '...' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if members %}
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Member</span>
|
||||
</label>
|
||||
<select name="member_id" class="select select-bordered select-sm">
|
||||
<option value="">All Members</option>
|
||||
{% for member in members %}
|
||||
<option value="{{ member.member_id }}" {% if member_id == member.member_id %}selected{% endif %}>{{ member.name }}{% if member.callsign %} ({{ member.callsign }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/advertisements" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advertisements List - Mobile Card View -->
|
||||
<div class="lg:hidden space-y-3">
|
||||
{% for ad in advertisements %}
|
||||
<a href="/nodes/{{ ad.public_key }}" class="card bg-base-100 shadow-sm block">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-lg flex-shrink-0" title="{{ ad.adv_type or 'Unknown' }}">{% if ad.adv_type and ad.adv_type|lower == 'chat' %}💬{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}📡{% elif ad.adv_type and ad.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
|
||||
<div class="min-w-0">
|
||||
{% if ad.node_tag_name or ad.node_name or ad.name %}
|
||||
<div class="font-medium text-sm truncate">{{ ad.node_tag_name or ad.node_name or ad.name }}</div>
|
||||
<div class="text-xs font-mono opacity-60 truncate">{{ ad.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<div class="font-mono text-sm truncate">{{ ad.public_key[:16] }}...</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-xs opacity-60">
|
||||
{{ ad.received_at[:16].replace('T', ' ') if ad.received_at else '-' }}
|
||||
</div>
|
||||
{% if ad.receivers and ad.receivers|length >= 1 %}
|
||||
<div class="flex gap-0.5 justify-end mt-1">
|
||||
{% for recv in ad.receivers %}
|
||||
<span class="text-sm" title="{{ recv.tag_name or recv.name or recv.public_key[:12] }}">📡</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif ad.received_by %}
|
||||
<span class="text-sm" title="{{ ad.receiver_tag_name or ad.receiver_name or ad.received_by[:12] }}">📡</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="text-center py-8 opacity-70">No advertisements found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Advertisements Table - Desktop View -->
|
||||
<div class="hidden lg:block overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Time</th>
|
||||
<th>Receivers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ad in advertisements %}
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<a href="/nodes/{{ ad.public_key }}" class="link link-hover flex items-center gap-2">
|
||||
<span class="text-lg" title="{{ ad.adv_type or 'Unknown' }}">{% if ad.adv_type and ad.adv_type|lower == 'chat' %}💬{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}📡{% elif ad.adv_type and ad.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
|
||||
<div>
|
||||
{% if ad.node_tag_name or ad.node_name or ad.name %}
|
||||
<div class="font-medium">{{ ad.node_tag_name or ad.node_name or ad.name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ ad.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ ad.public_key[:16] }}...</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{{ ad.received_at[:19].replace('T', ' ') if ad.received_at else '-' }}
|
||||
</td>
|
||||
<td>
|
||||
{% if ad.receivers and ad.receivers|length >= 1 %}
|
||||
<div class="flex gap-1">
|
||||
{% for recv in ad.receivers %}
|
||||
<a href="/nodes/{{ recv.public_key }}" class="text-lg hover:opacity-70" data-receiver-tooltip data-name="{{ recv.tag_name or recv.name or recv.public_key[:12] }}" data-timestamp="{{ recv.received_at }}">📡</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif ad.received_by %}
|
||||
<a href="/nodes/{{ ad.received_by }}" class="text-lg hover:opacity-70" title="{{ ad.receiver_tag_name or ad.receiver_name or ad.received_by[:12] }}">📡</a>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center py-8 opacity-70">No advertisements found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{ pagination(page, total_pages, {"search": search, "public_key": public_key, "member_id": member_id, "limit": limit}) }}
|
||||
{% endblock %}
|
||||
@@ -1,177 +0,0 @@
|
||||
{% from "macros/icons.html" import icon_home, icon_dashboard, icon_nodes, icon_advertisements, icon_messages, icon_map, icon_members, icon_page %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ network_name }}{% endblock %}</title>
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
{% set default_description = network_name ~ " - MeshCore off-grid LoRa mesh network dashboard. Monitor nodes, messages, and network activity." %}
|
||||
<meta name="description" content="{% block meta_description %}{{ default_description }}{% endblock %}">
|
||||
<meta name="generator" content="MeshCore Hub {{ version }}">
|
||||
<link rel="canonical" href="{{ request.url }}">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ request.url }}">
|
||||
<meta property="og:title" content="{% block og_title %}{{ self.title() }}{% endblock %}">
|
||||
<meta property="og:description" content="{% block og_description %}{{ self.meta_description() }}{% endblock %}">
|
||||
<meta property="og:site_name" content="{{ network_name }}">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="{% block twitter_title %}{{ self.title() }}{% endblock %}">
|
||||
<meta name="twitter:description" content="{% block twitter_description %}{{ self.meta_description() }}{% endblock %}">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/static/img/logo.svg">
|
||||
|
||||
<!-- Tailwind CSS with DaisyUI -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.19/dist/full.min.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Leaflet CSS for maps -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
|
||||
<style>
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: oklch(var(--b2));
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(var(--bc) / 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(var(--bc) / 0.5);
|
||||
}
|
||||
|
||||
/* Table styling */
|
||||
.table-compact td, .table-compact th {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Truncate text in table cells */
|
||||
.truncate-cell {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Prose styling for custom markdown pages */
|
||||
.prose h1 { font-size: 2.25rem; font-weight: 700; margin-top: 1.5rem; margin-bottom: 1rem; }
|
||||
.prose h2 { font-size: 1.875rem; font-weight: 600; margin-top: 1.25rem; margin-bottom: 0.75rem; }
|
||||
.prose h3 { font-size: 1.5rem; font-weight: 600; margin-top: 1rem; margin-bottom: 0.5rem; }
|
||||
.prose h4 { font-size: 1.25rem; font-weight: 600; margin-top: 1rem; margin-bottom: 0.5rem; }
|
||||
.prose p { margin-bottom: 1rem; line-height: 1.75; }
|
||||
.prose ul, .prose ol { margin-bottom: 1rem; padding-left: 1.5rem; }
|
||||
.prose ul { list-style-type: disc; }
|
||||
.prose ol { list-style-type: decimal; }
|
||||
.prose li { margin-bottom: 0.25rem; }
|
||||
.prose a { color: oklch(var(--p)); text-decoration: underline; }
|
||||
.prose a:hover { color: oklch(var(--pf)); }
|
||||
.prose code { background: oklch(var(--b2)); padding: 0.125rem 0.25rem; border-radius: 0.25rem; font-size: 0.875em; }
|
||||
.prose pre { background: oklch(var(--b2)); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-bottom: 1rem; }
|
||||
.prose pre code { background: none; padding: 0; }
|
||||
.prose blockquote { border-left: 4px solid oklch(var(--bc) / 0.3); padding-left: 1rem; margin: 1rem 0; font-style: italic; }
|
||||
.prose table { width: 100%; margin-bottom: 1rem; border-collapse: collapse; }
|
||||
.prose th, .prose td { border: 1px solid oklch(var(--bc) / 0.2); padding: 0.5rem; text-align: left; }
|
||||
.prose th { background: oklch(var(--b2)); font-weight: 600; }
|
||||
.prose hr { border: none; border-top: 1px solid oklch(var(--bc) / 0.2); margin: 2rem 0; }
|
||||
.prose img { max-width: 100%; height: auto; border-radius: 0.5rem; margin: 1rem 0; }
|
||||
</style>
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body class="min-h-screen bg-base-200">
|
||||
<!-- Navbar -->
|
||||
<div class="navbar bg-base-100 shadow-lg">
|
||||
<div class="navbar-start">
|
||||
<div class="dropdown">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" />
|
||||
</svg>
|
||||
</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="/" class="{% if request.url.path == '/' %}active{% endif %}">{{ icon_home("h-4 w-4") }} Home</a></li>
|
||||
<li><a href="/dashboard" class="{% if request.url.path == '/dashboard' %}active{% endif %}">{{ icon_dashboard("h-4 w-4") }} Dashboard</a></li>
|
||||
<li><a href="/nodes" class="{% if '/nodes' in request.url.path %}active{% endif %}">{{ icon_nodes("h-4 w-4") }} Nodes</a></li>
|
||||
<li><a href="/advertisements" class="{% if request.url.path == '/advertisements' %}active{% endif %}">{{ icon_advertisements("h-4 w-4") }} Adverts</a></li>
|
||||
<li><a href="/messages" class="{% if request.url.path == '/messages' %}active{% endif %}">{{ icon_messages("h-4 w-4") }} Messages</a></li>
|
||||
<li><a href="/map" class="{% if request.url.path == '/map' %}active{% endif %}">{{ icon_map("h-4 w-4") }} Map</a></li>
|
||||
<li><a href="/members" class="{% if request.url.path == '/members' %}active{% endif %}">{{ icon_members("h-4 w-4") }} Members</a></li>
|
||||
{% for page in custom_pages %}
|
||||
<li><a href="{{ page.url }}" class="{% if request.url.path == page.url %}active{% endif %}">{{ icon_page("h-4 w-4") }} {{ page.title }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<a href="/" class="btn btn-ghost text-xl">
|
||||
<img src="/static/img/logo.svg" alt="{{ network_name }}" class="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="/" class="{% if request.url.path == '/' %}active{% endif %}">{{ icon_home("h-4 w-4") }} Home</a></li>
|
||||
<li><a href="/dashboard" class="{% if request.url.path == '/dashboard' %}active{% endif %}">{{ icon_dashboard("h-4 w-4") }} Dashboard</a></li>
|
||||
<li><a href="/nodes" class="{% if '/nodes' in request.url.path %}active{% endif %}">{{ icon_nodes("h-4 w-4") }} Nodes</a></li>
|
||||
<li><a href="/advertisements" class="{% if request.url.path == '/advertisements' %}active{% endif %}">{{ icon_advertisements("h-4 w-4") }} Adverts</a></li>
|
||||
<li><a href="/messages" class="{% if request.url.path == '/messages' %}active{% endif %}">{{ icon_messages("h-4 w-4") }} Messages</a></li>
|
||||
<li><a href="/map" class="{% if request.url.path == '/map' %}active{% endif %}">{{ icon_map("h-4 w-4") }} Map</a></li>
|
||||
<li><a href="/members" class="{% if request.url.path == '/members' %}active{% endif %}">{{ icon_members("h-4 w-4") }} Members</a></li>
|
||||
{% for page in custom_pages %}
|
||||
<li><a href="{{ page.url }}" class="{% if request.url.path == page.url %}active{% endif %}">{{ icon_page("h-4 w-4") }} {{ page.title }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="badge badge-outline badge-sm">{{ version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-4 py-6">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer footer-center p-4 bg-base-100 text-base-content mt-auto">
|
||||
<aside>
|
||||
<p>
|
||||
{{ network_name }}
|
||||
{% if network_city and network_country %}
|
||||
- {{ network_city }}, {{ network_country }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-sm opacity-70">
|
||||
{% if network_contact_email %}
|
||||
<a href="mailto:{{ network_contact_email }}" class="link link-hover">{{ network_contact_email }}</a>
|
||||
{% endif %}
|
||||
{% if network_contact_email and network_contact_discord %} | {% endif %}
|
||||
{% if network_contact_discord %}
|
||||
<a href="{{ network_contact_discord }}" target="_blank" rel="noopener noreferrer" class="link link-hover">Discord</a>
|
||||
{% endif %}
|
||||
{% if (network_contact_email or network_contact_discord) and network_contact_github %} | {% endif %}
|
||||
{% if network_contact_github %}
|
||||
<a href="{{ network_contact_github }}" target="_blank" rel="noopener noreferrer" class="link link-hover">GitHub</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-xs opacity-50 mt-2">{% if admin_enabled %}<a href="/a/" class="link link-hover">Admin</a> | {% endif %}Powered by <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
|
||||
</aside>
|
||||
</footer>
|
||||
|
||||
<!-- Leaflet JS for maps -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
|
||||
<!-- Common utilities -->
|
||||
<script src="/static/js/utils.js"></script>
|
||||
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,320 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Network Overview{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Network Overview</h1>
|
||||
<button onclick="location.reload()" class="btn btn-ghost btn-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if api_error %}
|
||||
<div class="alert alert-warning mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>Could not fetch data from API: {{ api_error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<!-- Total Nodes -->
|
||||
<div class="stat bg-base-100 rounded-box shadow">
|
||||
<div class="stat-figure text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" 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>
|
||||
</div>
|
||||
<div class="stat-title">Total Nodes</div>
|
||||
<div class="stat-value text-primary">{{ stats.total_nodes }}</div>
|
||||
<div class="stat-desc">All discovered nodes</div>
|
||||
</div>
|
||||
|
||||
<!-- Advertisements (7 days) -->
|
||||
<div class="stat bg-base-100 rounded-box shadow">
|
||||
<div class="stat-figure text-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" 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>
|
||||
</div>
|
||||
<div class="stat-title">Advertisements</div>
|
||||
<div class="stat-value text-secondary">{{ stats.advertisements_7d }}</div>
|
||||
<div class="stat-desc">Last 7 days</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages (7 days) -->
|
||||
<div class="stat bg-base-100 rounded-box shadow">
|
||||
<div class="stat-figure text-accent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" 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>
|
||||
</div>
|
||||
<div class="stat-title">Messages</div>
|
||||
<div class="stat-value text-accent">{{ stats.messages_7d }}</div>
|
||||
<div class="stat-desc">Last 7 days</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity Charts -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Node Count Chart -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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>
|
||||
Total Nodes
|
||||
</h2>
|
||||
<p class="text-xs opacity-70">Over time (last 7 days)</p>
|
||||
<div class="h-32">
|
||||
<canvas id="nodeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advertisements Chart -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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
|
||||
</h2>
|
||||
<p class="text-xs opacity-70">Per day (last 7 days)</p>
|
||||
<div class="h-32">
|
||||
<canvas id="advertChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages Chart -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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
|
||||
</h2>
|
||||
<p class="text-xs opacity-70">Per day (last 7 days)</p>
|
||||
<div class="h-32">
|
||||
<canvas id="messageChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Recent Advertisements -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" 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>
|
||||
Recent Advertisements
|
||||
</h2>
|
||||
{% if stats.recent_advertisements %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Type</th>
|
||||
<th class="text-right">Received</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ad in stats.recent_advertisements %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/nodes/{{ ad.public_key }}" class="link link-hover">
|
||||
<div class="font-medium">{{ ad.friendly_name or ad.name or ad.public_key[:12] + '...' }}</div>
|
||||
</a>
|
||||
{% if ad.friendly_name or ad.name %}
|
||||
<div class="text-xs opacity-50 font-mono">{{ ad.public_key[:12] }}...</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if ad.adv_type and ad.adv_type|lower == 'chat' %}
|
||||
<span title="Chat">💬</span>
|
||||
{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}
|
||||
<span title="Repeater">📡</span>
|
||||
{% elif ad.adv_type and ad.adv_type|lower == 'room' %}
|
||||
<span title="Room">🪧</span>
|
||||
{% elif ad.adv_type %}
|
||||
<span title="{{ ad.adv_type }}">📍</span>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right text-sm opacity-70">{{ ad.received_at.split('T')[1][:8] if ad.received_at else '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm opacity-70">No advertisements recorded yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channel Messages -->
|
||||
{% if stats.channel_messages %}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
|
||||
</svg>
|
||||
Recent Channel Messages
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
{% for channel, messages in stats.channel_messages.items() %}
|
||||
<div>
|
||||
<h3 class="font-semibold text-sm mb-2 flex items-center gap-2">
|
||||
<span class="badge badge-info badge-sm">CH{{ channel }}</span>
|
||||
Channel {{ channel }}
|
||||
</h3>
|
||||
<div class="space-y-1 pl-2 border-l-2 border-base-300">
|
||||
{% for msg in messages %}
|
||||
<div class="text-sm">
|
||||
<span class="text-xs opacity-50">{{ msg.received_at.split('T')[1][:5] if msg.received_at else '' }}</span>
|
||||
<span class="break-words" style="white-space: pre-wrap;">{{ msg.text }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const advertData = {{ advert_activity_json | safe }};
|
||||
const messageData = {{ message_activity_json | safe }};
|
||||
const nodeData = {{ node_count_json | safe }};
|
||||
|
||||
// Common chart options
|
||||
const commonOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
backgroundColor: 'oklch(0.25 0 0)',
|
||||
titleColor: 'oklch(0.9 0 0)',
|
||||
bodyColor: 'oklch(0.9 0 0)',
|
||||
borderColor: 'oklch(0.4 0 0)',
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: 'oklch(0.4 0 0 / 0.2)' },
|
||||
ticks: { color: 'oklch(0.7 0 0)', maxRotation: 45, minRotation: 45 }
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: 'oklch(0.4 0 0 / 0.2)' },
|
||||
ticks: { color: 'oklch(0.7 0 0)', precision: 0 }
|
||||
}
|
||||
},
|
||||
interaction: { mode: 'nearest', axis: 'x', intersect: false }
|
||||
};
|
||||
|
||||
// Helper to format dates
|
||||
function formatLabels(data) {
|
||||
return data.map(d => {
|
||||
const date = new Date(d.date);
|
||||
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||
});
|
||||
}
|
||||
|
||||
// Advertisements chart (secondary color - pink/magenta)
|
||||
const advertCtx = document.getElementById('advertChart');
|
||||
if (advertCtx && advertData.data && advertData.data.length > 0) {
|
||||
new Chart(advertCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: formatLabels(advertData.data),
|
||||
datasets: [{
|
||||
label: 'Advertisements',
|
||||
data: advertData.data.map(d => d.count),
|
||||
borderColor: 'oklch(0.7 0.17 330)',
|
||||
backgroundColor: 'oklch(0.7 0.17 330 / 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
}]
|
||||
},
|
||||
options: commonOptions
|
||||
});
|
||||
}
|
||||
|
||||
// Messages chart (accent color - teal/cyan)
|
||||
const messageCtx = document.getElementById('messageChart');
|
||||
if (messageCtx && messageData.data && messageData.data.length > 0) {
|
||||
new Chart(messageCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: formatLabels(messageData.data),
|
||||
datasets: [{
|
||||
label: 'Messages',
|
||||
data: messageData.data.map(d => d.count),
|
||||
borderColor: 'oklch(0.75 0.18 180)',
|
||||
backgroundColor: 'oklch(0.75 0.18 180 / 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
}]
|
||||
},
|
||||
options: commonOptions
|
||||
});
|
||||
}
|
||||
|
||||
// Node count chart (primary color - purple/blue)
|
||||
const nodeCtx = document.getElementById('nodeChart');
|
||||
if (nodeCtx && nodeData.data && nodeData.data.length > 0) {
|
||||
new Chart(nodeCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: formatLabels(nodeData.data),
|
||||
datasets: [{
|
||||
label: 'Total Nodes',
|
||||
data: nodeData.data.map(d => d.count),
|
||||
borderColor: 'oklch(0.65 0.24 265)',
|
||||
backgroundColor: 'oklch(0.65 0.24 265 / 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
}]
|
||||
},
|
||||
options: commonOptions
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,35 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Page Not Found - {{ network_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="hero min-h-[60vh]">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<div class="text-9xl font-bold text-primary opacity-20">404</div>
|
||||
<h1 class="text-4xl font-bold -mt-8">Page Not Found</h1>
|
||||
<p class="py-6 text-base-content/70">
|
||||
{% if detail %}
|
||||
{{ detail }}
|
||||
{% else %}
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<a href="/" class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" 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>
|
||||
Go Home
|
||||
</a>
|
||||
<a href="/nodes" class="btn btn-outline">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" 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>
|
||||
Browse Nodes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,273 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/icons.html" import icon_dashboard, icon_map, icon_nodes, icon_advertisements, icon_messages %}
|
||||
|
||||
{% block title %}{{ network_name }} - Home{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Hero Section with Stats -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 bg-base-100 rounded-box p-6">
|
||||
<!-- Hero Content (2 columns) -->
|
||||
<div class="lg:col-span-2 flex flex-col items-center text-center">
|
||||
<img src="/static/img/logo.svg" alt="{{ network_name }}" class="h-32 w-32 mb-4" />
|
||||
<h1 class="text-4xl font-bold">{{ network_name }}</h1>
|
||||
{% if network_city and network_country %}
|
||||
<p class="py-1 text-lg opacity-70">{{ network_city }}, {{ network_country }}</p>
|
||||
{% endif %}
|
||||
{% if network_welcome_text %}
|
||||
<p class="py-4 max-w-[70%]">{{ network_welcome_text }}</p>
|
||||
{% else %}
|
||||
<p class="py-4 max-w-[70%]">
|
||||
Welcome to the {{ network_name }} mesh network dashboard.
|
||||
Monitor network activity, view connected nodes, and explore message history.
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="flex-1"></div>
|
||||
<div class="flex gap-3 mt-auto">
|
||||
<a href="/dashboard" class="btn btn-neutral">
|
||||
{{ icon_dashboard("h-5 w-5 mr-2") }}
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/map" class="btn btn-neutral">
|
||||
{{ icon_map("h-5 w-5 mr-2") }}
|
||||
Map
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Column (stacked vertically) -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Total Nodes -->
|
||||
<div class="stat bg-base-200 rounded-box relative">
|
||||
<div class="stat-figure text-primary">
|
||||
{{ icon_nodes("h-8 w-8") }}
|
||||
</div>
|
||||
<div class="stat-title">Total Nodes</div>
|
||||
<div class="stat-value text-primary">{{ stats.total_nodes }}</div>
|
||||
<div class="stat-desc">All discovered nodes</div>
|
||||
<a href="/nodes" class="link link-primary text-sm absolute bottom-2 right-4">View Nodes</a>
|
||||
</div>
|
||||
|
||||
<!-- Advertisements (7 days) -->
|
||||
<div class="stat bg-base-200 rounded-box relative">
|
||||
<div class="stat-figure text-secondary">
|
||||
{{ icon_advertisements("h-8 w-8") }}
|
||||
</div>
|
||||
<div class="stat-title">Advertisements</div>
|
||||
<div class="stat-value text-secondary">{{ stats.advertisements_7d }}</div>
|
||||
<div class="stat-desc">Last 7 days</div>
|
||||
<a href="/advertisements" class="link link-secondary text-sm absolute bottom-2 right-4">View Adverts</a>
|
||||
</div>
|
||||
|
||||
<!-- Messages (7 days) -->
|
||||
<div class="stat bg-base-200 rounded-box relative">
|
||||
<div class="stat-figure text-accent">
|
||||
{{ icon_messages("h-8 w-8") }}
|
||||
</div>
|
||||
<div class="stat-title">Messages</div>
|
||||
<div class="stat-value text-accent">{{ stats.messages_7d }}</div>
|
||||
<div class="stat-desc">Last 7 days</div>
|
||||
<a href="/messages" class="link link-accent text-sm absolute bottom-2 right-4">View Messages</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-6">
|
||||
<!-- Network Info Card -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Network Info
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
{% if network_radio_config %}
|
||||
{% if network_radio_config.profile %}
|
||||
<div class="flex justify-between">
|
||||
<span class="opacity-70">Profile:</span>
|
||||
<span class="font-mono">{{ network_radio_config.profile }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if network_radio_config.frequency %}
|
||||
<div class="flex justify-between">
|
||||
<span class="opacity-70">Frequency:</span>
|
||||
<span class="font-mono">{{ network_radio_config.frequency }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if network_radio_config.bandwidth %}
|
||||
<div class="flex justify-between">
|
||||
<span class="opacity-70">Bandwidth:</span>
|
||||
<span class="font-mono">{{ network_radio_config.bandwidth }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if network_radio_config.spreading_factor %}
|
||||
<div class="flex justify-between">
|
||||
<span class="opacity-70">Spreading Factor:</span>
|
||||
<span class="font-mono">{{ network_radio_config.spreading_factor }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if network_radio_config.coding_rate %}
|
||||
<div class="flex justify-between">
|
||||
<span class="opacity-70">Coding Rate:</span>
|
||||
<span class="font-mono">{{ network_radio_config.coding_rate }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if network_radio_config.tx_power %}
|
||||
<div class="flex justify-between">
|
||||
<span class="opacity-70">TX Power:</span>
|
||||
<span class="font-mono">{{ network_radio_config.tx_power }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if network_location and network_location != (0.0, 0.0) %}
|
||||
<div class="flex justify-between">
|
||||
<span class="opacity-70">Location:</span>
|
||||
<span class="font-mono">{{ "%.4f"|format(network_location[0]) }}, {{ "%.4f"|format(network_location[1]) }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Powered by MeshCore -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<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" />
|
||||
</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">
|
||||
<a href="https://meshcore.co.uk/" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
Website
|
||||
</a>
|
||||
<a href="https://github.com/meshcore-dev/MeshCore" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Activity Chart -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
|
||||
</svg>
|
||||
Network Activity
|
||||
</h2>
|
||||
<p class="text-sm opacity-70 mb-2">Activity per day (last 7 days)</p>
|
||||
<div class="h-48">
|
||||
<canvas id="activityChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const advertData = {{ advert_activity_json | safe }};
|
||||
const messageData = {{ message_activity_json | safe }};
|
||||
const ctx = document.getElementById('activityChart');
|
||||
|
||||
if (ctx && advertData.data && advertData.data.length > 0) {
|
||||
// Format dates for display (show only day/month)
|
||||
const labels = advertData.data.map(d => {
|
||||
const date = new Date(d.date);
|
||||
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||
});
|
||||
const advertCounts = advertData.data.map(d => d.count);
|
||||
const messageCounts = messageData.data ? messageData.data.map(d => d.count) : [];
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Advertisements',
|
||||
data: advertCounts,
|
||||
borderColor: 'oklch(0.7 0.17 330)',
|
||||
backgroundColor: 'oklch(0.7 0.17 330 / 0.1)',
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
}, {
|
||||
label: 'Messages',
|
||||
data: messageCounts,
|
||||
borderColor: 'oklch(0.7 0.15 200)',
|
||||
backgroundColor: 'oklch(0.7 0.15 200 / 0.1)',
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: 'oklch(0.7 0 0)',
|
||||
boxWidth: 12,
|
||||
padding: 8
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
backgroundColor: 'oklch(0.25 0 0)',
|
||||
titleColor: 'oklch(0.9 0 0)',
|
||||
bodyColor: 'oklch(0.9 0 0)',
|
||||
borderColor: 'oklch(0.4 0 0)',
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: 'oklch(0.4 0 0 / 0.2)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'oklch(0.7 0 0)',
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
maxTicksLimit: 10
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'oklch(0.4 0 0 / 0.2)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'oklch(0.7 0 0)',
|
||||
precision: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
intersect: false
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,47 +0,0 @@
|
||||
{% macro icon_dashboard(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" 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>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_map(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" 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>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_nodes(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" 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>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_advertisements(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" 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>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_messages(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" 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>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_home(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" 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>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_members(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" 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>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_page(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" 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>
|
||||
{% endmacro %}
|
||||
@@ -1,329 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Node Map{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
#map {
|
||||
height: calc(100vh - 350px);
|
||||
min-height: 400px;
|
||||
border-radius: var(--rounded-box);
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: oklch(var(--b1));
|
||||
color: oklch(var(--bc));
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
background: oklch(var(--b1));
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Node Map</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="node-count" class="badge badge-lg">Loading...</span>
|
||||
<span id="filtered-count" class="badge badge-lg badge-ghost hidden"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card bg-base-100 shadow mb-6">
|
||||
<div class="card-body py-4">
|
||||
<div class="flex gap-4 flex-wrap items-end">
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Node Type</span>
|
||||
</label>
|
||||
<select id="filter-type" class="select select-bordered select-sm">
|
||||
<option value="">All Types</option>
|
||||
<option value="chat">Chat</option>
|
||||
<option value="repeater">Repeater</option>
|
||||
<option value="room">Room</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Member</span>
|
||||
</label>
|
||||
<select id="filter-member" class="select select-bordered select-sm">
|
||||
<option value="">All Members</option>
|
||||
<!-- Populated dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
<button id="clear-filters" class="btn btn-ghost btn-sm">Clear Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body p-2">
|
||||
<div id="map"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="mt-4 flex flex-wrap gap-4 items-center text-sm">
|
||||
<span class="opacity-70">Legend:</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-lg">💬</span>
|
||||
<span>Chat</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-lg">📡</span>
|
||||
<span>Repeater</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-lg">🪧</span>
|
||||
<span>Room</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-lg">📍</span>
|
||||
<span>Other</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm opacity-70">
|
||||
<p>Nodes are placed on the map based on their <code>lat</code> and <code>lon</code> tags.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Initialize map with world view (will be centered on nodes once loaded)
|
||||
const map = L.map('map').setView([0, 0], 2);
|
||||
|
||||
// Add tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
|
||||
// Store all nodes and markers
|
||||
let allNodes = [];
|
||||
let allMembers = [];
|
||||
let markers = [];
|
||||
let mapCenter = { lat: 0, lon: 0 };
|
||||
|
||||
// Normalize adv_type to lowercase for consistent comparison
|
||||
function normalizeType(type) {
|
||||
return type ? type.toLowerCase() : null;
|
||||
}
|
||||
|
||||
// formatRelativeTime is provided by /static/js/utils.js
|
||||
|
||||
// Get emoji marker based on node type
|
||||
function getNodeEmoji(node) {
|
||||
const type = normalizeType(node.adv_type);
|
||||
if (type === 'chat') return '💬';
|
||||
if (type === 'repeater') return '📡';
|
||||
if (type === 'room') return '🪧';
|
||||
return '📍';
|
||||
}
|
||||
|
||||
// Get display name for node type
|
||||
function getTypeDisplay(node) {
|
||||
const type = normalizeType(node.adv_type);
|
||||
if (type === 'chat') return 'Chat';
|
||||
if (type === 'repeater') return 'Repeater';
|
||||
if (type === 'room') return 'Room';
|
||||
return type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Unknown';
|
||||
}
|
||||
|
||||
// Create marker icon for a node
|
||||
function createNodeIcon(node) {
|
||||
const emoji = getNodeEmoji(node);
|
||||
const displayName = node.name || '';
|
||||
const relativeTime = formatRelativeTime(node.last_seen);
|
||||
const timeDisplay = relativeTime ? ` (${relativeTime})` : '';
|
||||
return L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `<div style="display: flex; align-items: center; gap: 2px;">
|
||||
<span style="font-size: 24px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>
|
||||
<span style="font-size: 10px; font-weight: bold; color: #000; background: rgba(255,255,255,0.9); padding: 1px 4px; border-radius: 3px; box-shadow: 0 1px 3px rgba(0,0,0,0.3);">${displayName}${timeDisplay}</span>
|
||||
</div>`,
|
||||
iconSize: [82, 28],
|
||||
iconAnchor: [14, 14]
|
||||
});
|
||||
}
|
||||
|
||||
// Create popup content for a node
|
||||
function createPopupContent(node) {
|
||||
let ownerHtml = '';
|
||||
if (node.owner) {
|
||||
const ownerDisplay = node.owner.callsign
|
||||
? `${node.owner.name} (${node.owner.callsign})`
|
||||
: node.owner.name;
|
||||
ownerHtml = `<p><span class="opacity-70">Owner:</span> ${ownerDisplay}</p>`;
|
||||
}
|
||||
|
||||
let roleHtml = '';
|
||||
if (node.role) {
|
||||
roleHtml = `<p><span class="opacity-70">Role:</span> <span class="badge badge-xs badge-ghost">${node.role}</span></p>`;
|
||||
}
|
||||
|
||||
const emoji = getNodeEmoji(node);
|
||||
const typeDisplay = getTypeDisplay(node);
|
||||
|
||||
return `
|
||||
<div class="p-2">
|
||||
<h3 class="font-bold text-lg mb-2">${emoji} ${node.name}</h3>
|
||||
<div class="space-y-1 text-sm">
|
||||
<p><span class="opacity-70">Type:</span> ${typeDisplay}</p>
|
||||
${roleHtml}
|
||||
${ownerHtml}
|
||||
<p><span class="opacity-70">Key:</span> <code class="text-xs">${node.public_key.substring(0, 16)}...</code></p>
|
||||
<p><span class="opacity-70">Location:</span> ${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}</p>
|
||||
${node.last_seen ? `<p><span class="opacity-70">Last seen:</span> ${node.last_seen.substring(0, 19).replace('T', ' ')}</p>` : ''}
|
||||
</div>
|
||||
<a href="/nodes/${node.public_key}" class="btn btn-primary btn-xs mt-3">View Details</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Clear all markers from map
|
||||
function clearMarkers() {
|
||||
markers.forEach(marker => map.removeLayer(marker));
|
||||
markers = [];
|
||||
}
|
||||
|
||||
// Core filter logic - returns filtered nodes and updates markers
|
||||
function applyFiltersCore() {
|
||||
const typeFilter = document.getElementById('filter-type').value;
|
||||
const memberFilter = document.getElementById('filter-member').value;
|
||||
|
||||
// Filter nodes
|
||||
const filteredNodes = allNodes.filter(node => {
|
||||
// Type filter (case-insensitive)
|
||||
if (typeFilter && normalizeType(node.adv_type) !== typeFilter) return false;
|
||||
|
||||
// Member filter - match node's member_id tag to selected member_id
|
||||
if (memberFilter) {
|
||||
if (node.member_id !== memberFilter) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Clear existing markers
|
||||
clearMarkers();
|
||||
|
||||
// Add filtered markers
|
||||
filteredNodes.forEach(node => {
|
||||
const marker = L.marker([node.lat, node.lon], { icon: createNodeIcon(node) }).addTo(map);
|
||||
marker.bindPopup(createPopupContent(node));
|
||||
markers.push(marker);
|
||||
});
|
||||
|
||||
// Update counts
|
||||
const countEl = document.getElementById('node-count');
|
||||
const filteredEl = document.getElementById('filtered-count');
|
||||
|
||||
if (filteredNodes.length === allNodes.length) {
|
||||
countEl.textContent = `${allNodes.length} nodes on map`;
|
||||
filteredEl.classList.add('hidden');
|
||||
} else {
|
||||
countEl.textContent = `${allNodes.length} total`;
|
||||
filteredEl.textContent = `${filteredNodes.length} shown`;
|
||||
filteredEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
return filteredNodes;
|
||||
}
|
||||
|
||||
// Apply filters and recenter map on filtered nodes
|
||||
function applyFilters() {
|
||||
const filteredNodes = applyFiltersCore();
|
||||
|
||||
// Fit bounds if we have filtered nodes
|
||||
if (filteredNodes.length > 0) {
|
||||
const bounds = L.latLngBounds(filteredNodes.map(n => [n.lat, n.lon]));
|
||||
map.fitBounds(bounds, { padding: [50, 50] });
|
||||
} else if (mapCenter.lat !== 0 || mapCenter.lon !== 0) {
|
||||
map.setView([mapCenter.lat, mapCenter.lon], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply filters without recentering (for initial load after manual center)
|
||||
function applyFiltersNoRecenter() {
|
||||
applyFiltersCore();
|
||||
}
|
||||
|
||||
// Populate member filter dropdown
|
||||
function populateMemberFilter() {
|
||||
const select = document.getElementById('filter-member');
|
||||
|
||||
// Sort members by name
|
||||
const sortedMembers = [...allMembers].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Add options for all members
|
||||
sortedMembers.forEach(member => {
|
||||
if (member.member_id) {
|
||||
const option = document.createElement('option');
|
||||
option.value = member.member_id;
|
||||
option.textContent = member.callsign
|
||||
? `${member.name} (${member.callsign})`
|
||||
: member.name;
|
||||
select.appendChild(option);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clear all filters
|
||||
function clearFilters() {
|
||||
document.getElementById('filter-type').value = '';
|
||||
document.getElementById('filter-member').value = '';
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
// Event listeners for filters
|
||||
document.getElementById('filter-type').addEventListener('change', applyFilters);
|
||||
document.getElementById('filter-member').addEventListener('change', applyFilters);
|
||||
document.getElementById('clear-filters').addEventListener('click', clearFilters);
|
||||
|
||||
// Fetch and display nodes
|
||||
fetch('/map/data')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
allNodes = data.nodes;
|
||||
allMembers = data.members || [];
|
||||
mapCenter = data.center;
|
||||
|
||||
// Log debug info
|
||||
const debug = data.debug || {};
|
||||
console.log('Map data loaded:', debug);
|
||||
console.log('Sample node data:', allNodes.length > 0 ? allNodes[0] : 'No nodes');
|
||||
|
||||
if (debug.error) {
|
||||
document.getElementById('node-count').textContent = `Error: ${debug.error}`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (debug.total_nodes === 0) {
|
||||
document.getElementById('node-count').textContent = 'No nodes in database';
|
||||
return;
|
||||
}
|
||||
|
||||
if (debug.nodes_with_coords === 0) {
|
||||
document.getElementById('node-count').textContent = `${debug.total_nodes} nodes (none have coordinates)`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate member filter
|
||||
populateMemberFilter();
|
||||
|
||||
// Initial display - center map on nodes if available
|
||||
if (allNodes.length > 0) {
|
||||
const bounds = L.latLngBounds(allNodes.map(n => [n.lat, n.lon]));
|
||||
map.fitBounds(bounds, { padding: [50, 50] });
|
||||
}
|
||||
|
||||
// Apply filters (won't re-center since we just did above)
|
||||
applyFiltersNoRecenter();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading map data:', error);
|
||||
document.getElementById('node-count').textContent = 'Error loading data';
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,105 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Members{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Network Members</h1>
|
||||
<span class="badge badge-lg">{{ members|length }} members</span>
|
||||
</div>
|
||||
|
||||
{% if members %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 items-start">
|
||||
{% for member in members %}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
{{ member.name }}
|
||||
{% if member.callsign %}
|
||||
<span class="badge badge-success">{{ member.callsign }}</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
{% if member.description %}
|
||||
<p class="mt-2">{{ member.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if member.contact %}
|
||||
<p class="text-sm mt-2">
|
||||
<span class="opacity-70">Contact:</span> {{ member.contact }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if member.nodes %}
|
||||
<div class="mt-4 space-y-2">
|
||||
{% for node in member.nodes %}
|
||||
{% set adv_type = node.adv_type %}
|
||||
{% set node_tag_name = node.tags|selectattr('key', 'equalto', 'name')|map(attribute='value')|first %}
|
||||
{% set display_name = node_tag_name or node.name %}
|
||||
<a href="/nodes/{{ node.public_key }}" class="flex items-center gap-3 p-2 bg-base-200 rounded-lg hover:bg-base-300 transition-colors">
|
||||
<span class="text-lg" title="{{ adv_type or 'Unknown' }}">
|
||||
{% if adv_type and adv_type|lower == 'chat' %}
|
||||
💬
|
||||
{% elif adv_type and adv_type|lower == 'repeater' %}
|
||||
📡
|
||||
{% elif adv_type and adv_type|lower == 'room' %}
|
||||
🪧
|
||||
{% elif adv_type %}
|
||||
📍
|
||||
{% else %}
|
||||
📦
|
||||
{% endif %}
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
{% if display_name %}
|
||||
<div class="font-medium text-sm">{{ display_name }}</div>
|
||||
<div class="font-mono text-xs opacity-60">{{ node.public_key[:12] }}...</div>
|
||||
{% else %}
|
||||
<div class="font-mono text-sm">{{ node.public_key[:12] }}...</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if node.last_seen %}
|
||||
<time class="text-xs opacity-60 whitespace-nowrap" datetime="{{ node.last_seen }}" title="{{ node.last_seen[:19].replace('T', ' ') }}" data-relative-time>-</time>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-bold">No members configured</h3>
|
||||
<p class="text-sm">To display network members, create a members.yaml file in your seed directory.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Members File Format</h2>
|
||||
<p class="mb-4">Create a YAML file at <code>$SEED_HOME/members.yaml</code> with the following structure:</p>
|
||||
<pre class="bg-base-200 p-4 rounded-box text-sm overflow-x-auto"><code>members:
|
||||
- member_id: johndoe
|
||||
name: John Doe
|
||||
callsign: AB1CD
|
||||
role: Network Admin
|
||||
description: Manages the main repeater node.
|
||||
contact: john@example.com
|
||||
- member_id: janesmith
|
||||
name: Jane Smith
|
||||
role: Member
|
||||
description: Regular user in the downtown area.</code></pre>
|
||||
<p class="mt-4 text-sm opacity-70">
|
||||
Run <code>meshcore-hub collector seed</code> to import members.<br/>
|
||||
To associate nodes with members, add a <code>member_id</code> tag to nodes in <code>node_tags.yaml</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,147 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import pagination %}
|
||||
|
||||
{% block title %}{{ network_name }} - Messages{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Messages</h1>
|
||||
<span class="badge badge-lg">{{ total }} total</span>
|
||||
</div>
|
||||
|
||||
{% if api_error %}
|
||||
<div class="alert alert-warning mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>Could not fetch data from API: {{ api_error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card bg-base-100 shadow mb-6">
|
||||
<div class="card-body py-4">
|
||||
<form method="GET" action="/messages" class="flex gap-4 flex-wrap items-end">
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Type</span>
|
||||
</label>
|
||||
<select name="message_type" class="select select-bordered select-sm">
|
||||
<option value="">All Types</option>
|
||||
<option value="contact" {% if message_type == 'contact' %}selected{% endif %}>Direct</option>
|
||||
<option value="channel" {% if message_type == 'channel' %}selected{% endif %}>Channel</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/messages" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages List - Mobile Card View -->
|
||||
<div class="lg:hidden space-y-3">
|
||||
{% for msg in messages %}
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-lg flex-shrink-0" title="{{ msg.message_type|capitalize }}">
|
||||
{% if msg.message_type == 'channel' %}📻{% else %}👤{% endif %}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-sm truncate">
|
||||
{% if msg.message_type == 'channel' %}
|
||||
<span class="opacity-60">Public</span>
|
||||
{% else %}
|
||||
{% if msg.sender_tag_name or msg.sender_name %}
|
||||
{{ msg.sender_tag_name or msg.sender_name }}
|
||||
{% else %}
|
||||
<span class="font-mono text-xs">{{ (msg.pubkey_prefix or '-')[:12] }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-xs opacity-60">
|
||||
{{ msg.received_at[:16].replace('T', ' ') if msg.received_at else '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
{% if msg.receivers and msg.receivers|length >= 1 %}
|
||||
<div class="flex gap-0.5">
|
||||
{% for recv in msg.receivers %}
|
||||
<a href="/nodes/{{ recv.public_key }}" class="text-sm hover:opacity-70" title="{{ recv.tag_name or recv.name or recv.public_key[:12] }}">📡</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif msg.received_by %}
|
||||
<a href="/nodes/{{ msg.received_by }}" class="text-sm hover:opacity-70" title="{{ msg.receiver_tag_name or msg.receiver_name or msg.received_by[:12] }}">📡</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm mt-2 break-words whitespace-pre-wrap">{{ msg.text or '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8 opacity-70">No messages found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Messages Table - Desktop View -->
|
||||
<div class="hidden lg:block overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Time</th>
|
||||
<th>From</th>
|
||||
<th>Message</th>
|
||||
<th>Receivers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for msg in messages %}
|
||||
<tr class="hover align-top">
|
||||
<td class="text-lg" title="{{ msg.message_type|capitalize }}">
|
||||
{% if msg.message_type == 'channel' %}📻{% else %}👤{% endif %}
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{{ msg.received_at[:19].replace('T', ' ') if msg.received_at else '-' }}
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{% if msg.message_type == 'channel' %}
|
||||
<span class="opacity-60">Public</span>
|
||||
{% else %}
|
||||
{% if msg.sender_tag_name or msg.sender_name %}
|
||||
<span class="font-medium">{{ msg.sender_tag_name or msg.sender_name }}</span>
|
||||
{% else %}
|
||||
<span class="font-mono text-xs">{{ (msg.pubkey_prefix or '-')[:12] }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="break-words max-w-md" style="white-space: pre-wrap;">{{ msg.text or '-' }}</td>
|
||||
<td>
|
||||
{% if msg.receivers and msg.receivers|length >= 1 %}
|
||||
<div class="flex gap-1">
|
||||
{% for recv in msg.receivers %}
|
||||
<a href="/nodes/{{ recv.public_key }}" class="text-lg hover:opacity-70" data-receiver-tooltip data-name="{{ recv.tag_name or recv.name or recv.public_key[:12] }}" data-timestamp="{{ recv.received_at }}">📡</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif msg.received_by %}
|
||||
<a href="/nodes/{{ msg.received_by }}" class="text-lg hover:opacity-70" title="{{ msg.receiver_tag_name or msg.receiver_name or msg.received_by[:12] }}">📡</a>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-8 opacity-70">No messages found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{ pagination(page, total_pages, {"message_type": message_type, "limit": limit}) }}
|
||||
{% endblock %}
|
||||
@@ -1,330 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Node Details{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
#node-map {
|
||||
height: 300px;
|
||||
border-radius: var(--rounded-box);
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: oklch(var(--b1));
|
||||
color: oklch(var(--bc));
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
background: oklch(var(--b1));
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="breadcrumbs text-sm mb-4">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/nodes">Nodes</a></li>
|
||||
{% if node %}
|
||||
{% set ns = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<li>{{ ns.tag_name or node.name or public_key[:12] + '...' }}</li>
|
||||
{% else %}
|
||||
<li>Not Found</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% if api_error %}
|
||||
<div class="alert alert-warning mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>Could not fetch data from API: {{ api_error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if node %}
|
||||
{% set ns = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<!-- Node Info Card -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title text-2xl">
|
||||
{% if node.adv_type %}
|
||||
{% if node.adv_type|lower == 'chat' %}
|
||||
<span title="Chat">💬</span>
|
||||
{% elif node.adv_type|lower == 'repeater' %}
|
||||
<span title="Repeater">📡</span>
|
||||
{% elif node.adv_type|lower == 'room' %}
|
||||
<span title="Room">🪧</span>
|
||||
{% else %}
|
||||
<span title="{{ node.adv_type }}">📍</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{{ ns.tag_name or node.name or 'Unnamed Node' }}
|
||||
</h1>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Public Key</h3>
|
||||
<code class="text-sm bg-base-200 p-2 rounded block break-all">{{ node.public_key }}</code>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Activity</h3>
|
||||
<div class="space-y-1 text-sm">
|
||||
<p><span class="opacity-70">First seen:</span> {{ node.first_seen[:19].replace('T', ' ') if node.first_seen else '-' }}</p>
|
||||
<p><span class="opacity-70">Last seen:</span> {{ node.last_seen[:19].replace('T', ' ') if node.last_seen else '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags and Map Grid -->
|
||||
{% set ns_map = namespace(lat=none, lon=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' %}
|
||||
{% set ns_map.lat = tag.value %}
|
||||
{% elif tag.key == 'lon' %}
|
||||
{% set ns_map.lon = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="grid grid-cols-1 {% if ns_map.lat and ns_map.lon %}lg:grid-cols-2{% endif %} gap-6 mt-6">
|
||||
<!-- Tags -->
|
||||
{% if node.tags or (admin_enabled and is_authenticated) %}
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Tags</h3>
|
||||
{% if node.tags %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag in node.tags %}
|
||||
<tr>
|
||||
<td class="font-mono">{{ tag.key }}</td>
|
||||
<td>{{ tag.value }}</td>
|
||||
<td class="opacity-70">{{ tag.value_type or 'string' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm opacity-70 mb-2">No tags defined.</p>
|
||||
{% endif %}
|
||||
{% if admin_enabled and is_authenticated %}
|
||||
<div class="mt-3">
|
||||
<a href="/a/node-tags?public_key={{ node.public_key }}" class="btn btn-sm btn-outline">{% if node.tags %}Edit Tags{% else %}Add Tags{% endif %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Location Map -->
|
||||
{% if ns_map.lat and ns_map.lon %}
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Location</h3>
|
||||
<div id="node-map" class="mb-2"></div>
|
||||
<div class="text-sm opacity-70">
|
||||
<p>Coordinates: {{ ns_map.lat }}, {{ ns_map.lon }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Recent Advertisements -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Recent Advertisements</h2>
|
||||
{% if advertisements %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Type</th>
|
||||
<th>Received By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for adv in advertisements %}
|
||||
<tr>
|
||||
<td class="text-xs whitespace-nowrap">{{ adv.received_at[:19].replace('T', ' ') if adv.received_at else '-' }}</td>
|
||||
<td>
|
||||
{% if adv.adv_type and adv.adv_type|lower == 'chat' %}
|
||||
<span title="Chat">💬</span>
|
||||
{% elif adv.adv_type and adv.adv_type|lower == 'repeater' %}
|
||||
<span title="Repeater">📡</span>
|
||||
{% elif adv.adv_type and adv.adv_type|lower == 'room' %}
|
||||
<span title="Room">🪧</span>
|
||||
{% elif adv.adv_type %}
|
||||
<span title="{{ adv.adv_type }}">📍</span>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if adv.received_by %}
|
||||
<a href="/nodes/{{ adv.received_by }}" class="link link-hover">
|
||||
{% if adv.receiver_tag_name or adv.receiver_name %}
|
||||
<div class="font-medium text-sm">{{ adv.receiver_tag_name or adv.receiver_name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ adv.received_by[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-xs">{{ adv.received_by[:16] }}...</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="opacity-70">No advertisements recorded.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Telemetry -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Recent Telemetry</h2>
|
||||
{% if telemetry %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Data</th>
|
||||
<th>Received By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tel in telemetry %}
|
||||
<tr>
|
||||
<td class="text-xs whitespace-nowrap">{{ tel.received_at[:19].replace('T', ' ') if tel.received_at else '-' }}</td>
|
||||
<td class="text-xs font-mono">
|
||||
{% if tel.parsed_data %}
|
||||
{{ tel.parsed_data | tojson }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if tel.received_by %}
|
||||
<a href="/nodes/{{ tel.received_by }}" class="link link-hover">
|
||||
{% if tel.receiver_tag_name or tel.receiver_name %}
|
||||
<div class="font-medium text-sm">{{ tel.receiver_tag_name or tel.receiver_name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ tel.received_by[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-xs">{{ tel.received_by[:16] }}...</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="opacity-70">No telemetry recorded.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="alert alert-error">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Node not found: {{ public_key }}</span>
|
||||
</div>
|
||||
<a href="/nodes" class="btn btn-primary mt-4">Back to Nodes</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
{% if node %}
|
||||
{% set ns_map = namespace(lat=none, lon=none, name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' %}
|
||||
{% set ns_map.lat = tag.value %}
|
||||
{% elif tag.key == 'lon' %}
|
||||
{% set ns_map.lon = tag.value %}
|
||||
{% elif tag.key == 'name' %}
|
||||
{% set ns_map.name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if ns_map.lat and ns_map.lon %}
|
||||
<script>
|
||||
// Initialize map centered on the node's location
|
||||
const nodeLat = {{ ns_map.lat }};
|
||||
const nodeLon = {{ ns_map.lon }};
|
||||
const nodeName = {{ (ns_map.name or node.name or 'Unnamed Node') | tojson }};
|
||||
const nodeType = {{ (node.adv_type or '') | tojson }};
|
||||
const publicKey = {{ node.public_key | tojson }};
|
||||
|
||||
const map = L.map('node-map').setView([nodeLat, nodeLon], 15);
|
||||
|
||||
// Add tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
|
||||
// Get emoji marker based on node type
|
||||
function getNodeEmoji(type) {
|
||||
const normalizedType = type ? type.toLowerCase() : null;
|
||||
if (normalizedType === 'chat') return '💬';
|
||||
if (normalizedType === 'repeater') return '📡';
|
||||
if (normalizedType === 'room') return '🪧';
|
||||
return '📍';
|
||||
}
|
||||
|
||||
// Create marker icon (just the emoji, no label)
|
||||
const emoji = getNodeEmoji(nodeType);
|
||||
const icon = L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
});
|
||||
|
||||
// Add marker
|
||||
const marker = L.marker([nodeLat, nodeLon], { icon: icon }).addTo(map);
|
||||
|
||||
// Add popup (shown on click, not by default)
|
||||
marker.bindPopup(`
|
||||
<div class="p-2">
|
||||
<h3 class="font-bold text-lg mb-2">${emoji} ${nodeName}</h3>
|
||||
<div class="space-y-1 text-sm">
|
||||
${nodeType ? `<p><span class="opacity-70">Type:</span> ${nodeType}</p>` : ''}
|
||||
<p><span class="opacity-70">Coordinates:</span> ${nodeLat.toFixed(4)}, ${nodeLon.toFixed(4)}</p>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,177 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import pagination %}
|
||||
|
||||
{% block title %}{{ network_name }} - Nodes{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Nodes</h1>
|
||||
<span class="badge badge-lg">{{ total }} total</span>
|
||||
</div>
|
||||
|
||||
{% if api_error %}
|
||||
<div class="alert alert-warning mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>Could not fetch data from API: {{ api_error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card bg-base-100 shadow mb-6">
|
||||
<div class="card-body py-4">
|
||||
<form method="GET" action="/nodes" class="flex gap-4 flex-wrap items-end">
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Search</span>
|
||||
</label>
|
||||
<input type="text" name="search" value="{{ search }}" placeholder="Search by name, ID, or public key..." class="input input-bordered input-sm w-80" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Type</span>
|
||||
</label>
|
||||
<select name="adv_type" class="select select-bordered select-sm">
|
||||
<option value="">All Types</option>
|
||||
<option value="chat" {% if adv_type == 'chat' %}selected{% endif %}>Chat</option>
|
||||
<option value="repeater" {% if adv_type == 'repeater' %}selected{% endif %}>Repeater</option>
|
||||
<option value="room" {% if adv_type == 'room' %}selected{% endif %}>Room</option>
|
||||
</select>
|
||||
</div>
|
||||
{% if members %}
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Member</span>
|
||||
</label>
|
||||
<select name="member_id" class="select select-bordered select-sm">
|
||||
<option value="">All Members</option>
|
||||
{% for member in members %}
|
||||
<option value="{{ member.member_id }}" {% if member_id == member.member_id %}selected{% endif %}>{{ member.name }}{% if member.callsign %} ({{ member.callsign }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/nodes" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nodes List - Mobile Card View -->
|
||||
<div class="lg:hidden space-y-3">
|
||||
{% for node in nodes %}
|
||||
{% set ns = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<a href="/nodes/{{ node.public_key }}" class="card bg-base-100 shadow-sm block">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-lg flex-shrink-0" title="{{ node.adv_type or 'Unknown' }}">{% if node.adv_type and node.adv_type|lower == 'chat' %}💬{% elif node.adv_type and node.adv_type|lower == 'repeater' %}📡{% elif node.adv_type and node.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
|
||||
<div class="min-w-0">
|
||||
{% if ns.tag_name or node.name %}
|
||||
<div class="font-medium text-sm truncate">{{ ns.tag_name or node.name }}</div>
|
||||
<div class="text-xs font-mono opacity-60 truncate">{{ node.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<div class="font-mono text-sm truncate">{{ node.public_key[:16] }}...</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-xs opacity-60">
|
||||
{% if node.last_seen %}
|
||||
{{ node.last_seen[:10] }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if node.tags %}
|
||||
<div class="flex gap-1 justify-end mt-1">
|
||||
{% for tag in node.tags[:2] %}
|
||||
<span class="badge badge-ghost badge-xs">{{ tag.key }}</span>
|
||||
{% endfor %}
|
||||
{% if node.tags|length > 2 %}
|
||||
<span class="badge badge-ghost badge-xs">+{{ node.tags|length - 2 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="text-center py-8 opacity-70">No nodes found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Nodes Table - Desktop View -->
|
||||
<div class="hidden lg:block overflow-x-auto bg-base-100 rounded-box shadow">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for node in nodes %}
|
||||
{% set ns = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<a href="/nodes/{{ node.public_key }}" class="link link-hover flex items-center gap-2">
|
||||
<span class="text-lg" title="{{ node.adv_type or 'Unknown' }}">{% if node.adv_type and node.adv_type|lower == 'chat' %}💬{% elif node.adv_type and node.adv_type|lower == 'repeater' %}📡{% elif node.adv_type and node.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
|
||||
<div>
|
||||
{% if ns.tag_name or node.name %}
|
||||
<div class="font-medium">{{ ns.tag_name or node.name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ node.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ node.public_key[:16] }}...</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{% if node.last_seen %}
|
||||
{{ node.last_seen[:19].replace('T', ' ') }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if node.tags %}
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
{% for tag in node.tags[:3] %}
|
||||
<span class="badge badge-ghost badge-xs">{{ tag.key }}</span>
|
||||
{% endfor %}
|
||||
{% if node.tags|length > 3 %}
|
||||
<span class="badge badge-ghost badge-xs">+{{ node.tags|length - 3 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center py-8 opacity-70">No nodes found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{ pagination(page, total_pages, {"search": search, "adv_type": adv_type, "member_id": member_id, "limit": limit}) }}
|
||||
{% endblock %}
|
||||
@@ -1,15 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ page.title }} - {{ network_name }}{% endblock %}
|
||||
|
||||
{% block meta_description %}{{ page.title }} - {{ network_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body prose prose-lg max-w-none">
|
||||
{{ page.content_html | safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
204
src/meshcore_hub/web/templates/spa.html
Normal file
204
src/meshcore_hub/web/templates/spa.html
Normal file
@@ -0,0 +1,204 @@
|
||||
<!DOCTYPE html>
|
||||
<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 }}">
|
||||
<link rel="canonical" href="{{ request.url }}">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="{{ network_name }}">
|
||||
<meta property="og:description" content="{{ network_name }}{% if network_welcome_text %} - {{ network_welcome_text }}{% else %} - MeshCore off-grid LoRa mesh network dashboard.{% endif %}">
|
||||
<meta property="og:site_name" content="{{ network_name }}">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="{{ network_name }}">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="{{ logo_url }}">
|
||||
|
||||
<!-- Tailwind CSS with DaisyUI -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.19/dist/full.min.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Leaflet CSS for maps -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
|
||||
<!-- Custom application styles -->
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
|
||||
<!-- Import map for ES module dependencies -->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"lit-html": "https://esm.sh/lit-html@3",
|
||||
"lit-html/": "https://esm.sh/lit-html@3/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-base-200 flex flex-col">
|
||||
<!-- Navbar -->
|
||||
<div class="navbar bg-base-100 shadow-lg">
|
||||
<div class="navbar-start">
|
||||
<div class="dropdown">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" /></svg>
|
||||
</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="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 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>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-4 py-6 flex-1" id="app">
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer footer-center p-4 bg-base-100 text-base-content mt-auto">
|
||||
<aside>
|
||||
<p>
|
||||
{{ network_name }}
|
||||
{% if network_city and network_country %}
|
||||
- {{ network_city }}, {{ network_country }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-sm opacity-70">
|
||||
{% if network_contact_email %}
|
||||
<a href="mailto:{{ network_contact_email }}" class="link link-hover">{{ network_contact_email }}</a>
|
||||
{% endif %}
|
||||
{% if network_contact_email and network_contact_discord %} | {% endif %}
|
||||
{% if network_contact_discord %}
|
||||
<a href="{{ network_contact_discord }}" target="_blank" rel="noopener noreferrer" class="link link-hover">Discord</a>
|
||||
{% endif %}
|
||||
{% if (network_contact_email or network_contact_discord) and network_contact_github %} | {% endif %}
|
||||
{% if network_contact_github %}
|
||||
<a href="{{ network_contact_github }}" target="_blank" rel="noopener noreferrer" class="link link-hover">GitHub</a>
|
||||
{% endif %}
|
||||
{% if (network_contact_email or network_contact_discord or network_contact_github) and network_contact_youtube %} | {% endif %}
|
||||
{% if network_contact_youtube %}
|
||||
<a href="{{ network_contact_youtube }}" target="_blank" rel="noopener noreferrer" class="link link-hover">YouTube</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-xs opacity-50 mt-2">{% if admin_enabled %}<a href="/a/" class="link link-hover">Admin</a> | {% endif %}Powered by <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
|
||||
</aside>
|
||||
</footer>
|
||||
|
||||
<!-- Leaflet JS for maps -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
|
||||
<!-- Chart.js for charts -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
|
||||
<!-- QR Code library -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
||||
|
||||
<!-- Chart helper functions -->
|
||||
<script src="/static/js/charts.js"></script>
|
||||
|
||||
<!-- Embedded app configuration -->
|
||||
<script>
|
||||
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>
|
||||
</html>
|
||||
@@ -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."""
|
||||
@@ -225,19 +237,42 @@ class MockHttpClient:
|
||||
|
||||
def _create_response(self, key: str) -> Response:
|
||||
"""Create a mock response for a given key."""
|
||||
import json as _json
|
||||
|
||||
response_data = self._responses.get(key)
|
||||
if response_data is None:
|
||||
# Return 404 for unknown endpoints
|
||||
response = MagicMock(spec=Response)
|
||||
response.status_code = 404
|
||||
response.json.return_value = {"detail": "Not found"}
|
||||
response.content = b'{"detail": "Not found"}'
|
||||
response.headers = {"content-type": "application/json"}
|
||||
return response
|
||||
|
||||
response = MagicMock(spec=Response)
|
||||
response.status_code = response_data["status_code"]
|
||||
response.json.return_value = response_data["json"]
|
||||
response.content = _json.dumps(response_data["json"]).encode()
|
||||
response.headers = {"content-type": "application/json"}
|
||||
return response
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
params: dict | None = None,
|
||||
content: bytes | None = None,
|
||||
headers: dict | None = None,
|
||||
) -> Response:
|
||||
"""Mock generic request (used by API proxy)."""
|
||||
key = f"{method.upper()}:{url}"
|
||||
if key in self._responses:
|
||||
return self._create_response(key)
|
||||
# Try base path without query params
|
||||
base_path = url.split("?")[0]
|
||||
key = f"{method.upper()}:{base_path}"
|
||||
return self._create_response(key)
|
||||
|
||||
async def get(self, path: str, params: dict | None = None) -> Response:
|
||||
"""Mock GET request."""
|
||||
# Try exact match first
|
||||
@@ -292,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
|
||||
@@ -312,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."""
|
||||
@@ -406,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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for admin web routes."""
|
||||
"""Tests for admin web routes (SPA)."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -11,122 +12,7 @@ from .conftest import MockHttpClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_http_client_admin() -> MockHttpClient:
|
||||
"""Create a mock HTTP client for admin tests."""
|
||||
client = MockHttpClient()
|
||||
|
||||
# Mock the nodes API response for admin dropdown
|
||||
client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"name": "Node One",
|
||||
"adv_type": "REPEATER",
|
||||
"first_seen": "2024-01-01T00:00:00Z",
|
||||
"last_seen": "2024-01-01T12:00:00Z",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
"tags": [],
|
||||
},
|
||||
{
|
||||
"public_key": "xyz789xyz789xyz789xyz789xyz789xy",
|
||||
"name": "Node Two",
|
||||
"adv_type": "CHAT",
|
||||
"first_seen": "2024-01-01T00:00:00Z",
|
||||
"last_seen": "2024-01-01T11:00:00Z",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
"tags": [],
|
||||
},
|
||||
],
|
||||
"total": 2,
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
},
|
||||
)
|
||||
|
||||
# Mock node tags response
|
||||
client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes/abc123def456abc123def456abc123de/tags",
|
||||
200,
|
||||
[
|
||||
{
|
||||
"key": "environment",
|
||||
"value": "production",
|
||||
"value_type": "string",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"key": "location",
|
||||
"value": "building-a",
|
||||
"value_type": "string",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
# Mock create tag response
|
||||
client.set_response(
|
||||
"POST",
|
||||
"/api/v1/nodes/abc123def456abc123def456abc123de/tags",
|
||||
201,
|
||||
{
|
||||
"key": "new_tag",
|
||||
"value": "new_value",
|
||||
"value_type": "string",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# Mock update tag response
|
||||
client.set_response(
|
||||
"PUT",
|
||||
"/api/v1/nodes/abc123def456abc123def456abc123de/tags/environment",
|
||||
200,
|
||||
{
|
||||
"key": "environment",
|
||||
"value": "staging",
|
||||
"value_type": "string",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T12:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# Mock move tag response
|
||||
client.set_response(
|
||||
"PUT",
|
||||
"/api/v1/nodes/abc123def456abc123def456abc123de/tags/environment/move",
|
||||
200,
|
||||
{
|
||||
"key": "environment",
|
||||
"value": "production",
|
||||
"value_type": "string",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T12:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# Mock delete tag response
|
||||
client.set_response(
|
||||
"DELETE",
|
||||
"/api/v1/nodes/abc123def456abc123def456abc123de/tags/environment",
|
||||
204,
|
||||
None,
|
||||
)
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_app(mock_http_client_admin: MockHttpClient) -> Any:
|
||||
def admin_app(mock_http_client: MockHttpClient) -> Any:
|
||||
"""Create a web app with admin enabled."""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
@@ -139,13 +25,13 @@ def admin_app(mock_http_client_admin: MockHttpClient) -> Any:
|
||||
admin_enabled=True,
|
||||
)
|
||||
|
||||
app.state.http_client = mock_http_client_admin
|
||||
app.state.http_client = mock_http_client
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_app_disabled(mock_http_client_admin: MockHttpClient) -> Any:
|
||||
def admin_app_disabled(mock_http_client: MockHttpClient) -> Any:
|
||||
"""Create a web app with admin disabled."""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
@@ -158,7 +44,7 @@ def admin_app_disabled(mock_http_client_admin: MockHttpClient) -> Any:
|
||||
admin_enabled=False,
|
||||
)
|
||||
|
||||
app.state.http_client = mock_http_client_admin
|
||||
app.state.http_client = mock_http_client
|
||||
|
||||
return app
|
||||
|
||||
@@ -174,253 +60,249 @@ def auth_headers() -> dict:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(admin_app: Any, mock_http_client_admin: MockHttpClient) -> TestClient:
|
||||
def admin_client(admin_app: Any, mock_http_client: MockHttpClient) -> TestClient:
|
||||
"""Create a test client with admin enabled."""
|
||||
admin_app.state.http_client = mock_http_client_admin
|
||||
admin_app.state.http_client = mock_http_client
|
||||
return TestClient(admin_app, raise_server_exceptions=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client_disabled(
|
||||
admin_app_disabled: Any, mock_http_client_admin: MockHttpClient
|
||||
admin_app_disabled: Any, mock_http_client: MockHttpClient
|
||||
) -> TestClient:
|
||||
"""Create a test client with admin disabled."""
|
||||
admin_app_disabled.state.http_client = mock_http_client_admin
|
||||
admin_app_disabled.state.http_client = mock_http_client
|
||||
return TestClient(admin_app_disabled, raise_server_exceptions=True)
|
||||
|
||||
|
||||
class TestAdminHome:
|
||||
"""Tests for admin home page."""
|
||||
"""Tests for admin home page (SPA).
|
||||
|
||||
def test_admin_home_enabled(self, admin_client, auth_headers):
|
||||
"""Test admin home page when enabled."""
|
||||
In the SPA architecture, admin routes serve the same shell HTML.
|
||||
Admin access control is handled client-side based on
|
||||
window.__APP_CONFIG__.admin_enabled and is_authenticated.
|
||||
"""
|
||||
|
||||
def test_admin_home_returns_spa_shell(self, admin_client, auth_headers):
|
||||
"""Test admin home page returns the SPA shell."""
|
||||
response = admin_client.get("/a/", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert "Admin" in response.text
|
||||
assert "Node Tags" in response.text
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
def test_admin_home_disabled(self, admin_client_disabled, auth_headers):
|
||||
"""Test admin home page when disabled."""
|
||||
def test_admin_home_config_admin_enabled(self, admin_client, auth_headers):
|
||||
"""Test admin config shows admin_enabled: true."""
|
||||
response = admin_client.get("/a/", headers=auth_headers)
|
||||
text = response.text
|
||||
config_start = text.find("window.__APP_CONFIG__ = ") + len(
|
||||
"window.__APP_CONFIG__ = "
|
||||
)
|
||||
config_end = text.find(";", config_start)
|
||||
config = json.loads(text[config_start:config_end])
|
||||
|
||||
assert config["admin_enabled"] is True
|
||||
|
||||
def test_admin_home_config_authenticated(self, admin_client, auth_headers):
|
||||
"""Test admin config shows is_authenticated: true with auth headers."""
|
||||
response = admin_client.get("/a/", headers=auth_headers)
|
||||
text = response.text
|
||||
config_start = text.find("window.__APP_CONFIG__ = ") + len(
|
||||
"window.__APP_CONFIG__ = "
|
||||
)
|
||||
config_end = text.find(";", config_start)
|
||||
config = json.loads(text[config_start:config_end])
|
||||
|
||||
assert config["is_authenticated"] is True
|
||||
|
||||
def test_admin_home_disabled_returns_spa_shell(
|
||||
self, admin_client_disabled, auth_headers
|
||||
):
|
||||
"""Test admin page returns SPA shell even when disabled.
|
||||
|
||||
The SPA catch-all serves the shell for all routes.
|
||||
Client-side code checks admin_enabled to show/hide admin UI.
|
||||
"""
|
||||
response = admin_client_disabled.get("/a/", headers=auth_headers)
|
||||
assert response.status_code == 404
|
||||
assert response.status_code == 200
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
def test_admin_home_unauthenticated(self, admin_client):
|
||||
"""Test admin home page without authentication."""
|
||||
def test_admin_home_disabled_config(self, admin_client_disabled, auth_headers):
|
||||
"""Test admin config shows admin_enabled: false when disabled."""
|
||||
response = admin_client_disabled.get("/a/", headers=auth_headers)
|
||||
text = response.text
|
||||
config_start = text.find("window.__APP_CONFIG__ = ") + len(
|
||||
"window.__APP_CONFIG__ = "
|
||||
)
|
||||
config_end = text.find(";", config_start)
|
||||
config = json.loads(text[config_start:config_end])
|
||||
|
||||
assert config["admin_enabled"] is False
|
||||
|
||||
def test_admin_home_unauthenticated_returns_spa_shell(self, admin_client):
|
||||
"""Test admin page returns SPA shell without authentication.
|
||||
|
||||
The SPA catch-all serves the shell for all routes.
|
||||
Client-side code checks is_authenticated to show access denied.
|
||||
"""
|
||||
response = admin_client.get("/a/")
|
||||
assert response.status_code == 403
|
||||
assert "Access Denied" in response.text
|
||||
assert response.status_code == 200
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
def test_admin_home_unauthenticated_config(self, admin_client):
|
||||
"""Test admin config shows is_authenticated: false without auth headers."""
|
||||
response = admin_client.get("/a/")
|
||||
text = response.text
|
||||
config_start = text.find("window.__APP_CONFIG__ = ") + len(
|
||||
"window.__APP_CONFIG__ = "
|
||||
)
|
||||
config_end = text.find(";", config_start)
|
||||
config = json.loads(text[config_start:config_end])
|
||||
|
||||
assert config["is_authenticated"] is False
|
||||
|
||||
|
||||
class TestAdminNodeTags:
|
||||
"""Tests for admin node tags page."""
|
||||
"""Tests for admin node tags page (SPA)."""
|
||||
|
||||
def test_node_tags_page_no_selection(self, admin_client, auth_headers):
|
||||
"""Test node tags page without selecting a node."""
|
||||
def test_node_tags_page_returns_spa_shell(self, admin_client, auth_headers):
|
||||
"""Test node tags page returns the SPA shell."""
|
||||
response = admin_client.get("/a/node-tags", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert "Node Tags" in response.text
|
||||
assert "Select a Node" in response.text
|
||||
# Should show node dropdown
|
||||
assert "Node One" in response.text
|
||||
assert "Node Two" in response.text
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
def test_node_tags_page_with_selection(self, admin_client, auth_headers):
|
||||
"""Test node tags page with a node selected."""
|
||||
def test_node_tags_page_with_public_key(self, admin_client, auth_headers):
|
||||
"""Test node tags page with public_key param returns SPA shell."""
|
||||
response = admin_client.get(
|
||||
"/a/node-tags?public_key=abc123def456abc123def456abc123de",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Node Tags" in response.text
|
||||
# Should show the selected node's tags
|
||||
assert "environment" in response.text
|
||||
assert "production" in response.text
|
||||
assert "location" in response.text
|
||||
assert "building-a" in response.text
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
def test_node_tags_page_disabled(self, admin_client_disabled, auth_headers):
|
||||
"""Test node tags page when admin is disabled."""
|
||||
def test_node_tags_page_disabled_returns_spa_shell(
|
||||
self, admin_client_disabled, auth_headers
|
||||
):
|
||||
"""Test node tags page returns SPA shell even when admin is disabled."""
|
||||
response = admin_client_disabled.get("/a/node-tags", headers=auth_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_node_tags_page_with_message(self, admin_client, auth_headers):
|
||||
"""Test node tags page displays success message."""
|
||||
response = admin_client.get(
|
||||
"/a/node-tags?public_key=abc123def456abc123def456abc123de"
|
||||
"&message=Tag%20created%20successfully",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Tag created successfully" in response.text
|
||||
|
||||
def test_node_tags_page_with_error(self, admin_client, auth_headers):
|
||||
"""Test node tags page displays error message."""
|
||||
response = admin_client.get(
|
||||
"/a/node-tags?public_key=abc123def456abc123def456abc123de"
|
||||
"&error=Tag%20already%20exists",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Tag already exists" in response.text
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
def test_node_tags_page_unauthenticated(self, admin_client):
|
||||
"""Test node tags page without authentication."""
|
||||
"""Test node tags page returns SPA shell without authentication."""
|
||||
response = admin_client.get("/a/node-tags")
|
||||
assert response.status_code == 403
|
||||
assert "Access Denied" in response.text
|
||||
assert response.status_code == 200
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
|
||||
class TestAdminCreateTag:
|
||||
"""Tests for creating node tags."""
|
||||
class TestAdminApiProxyAuth:
|
||||
"""Tests for admin API proxy authentication enforcement.
|
||||
|
||||
def test_create_tag_success(self, admin_client, auth_headers):
|
||||
"""Test creating a new tag."""
|
||||
When admin is enabled, mutating requests (POST/PUT/DELETE/PATCH) through
|
||||
the API proxy must require authentication via X-Forwarded-User header.
|
||||
This prevents unauthenticated users from performing admin operations
|
||||
even though the web app's HTTP client has a service-level API key.
|
||||
"""
|
||||
|
||||
def test_proxy_post_blocked_without_auth(self, admin_client, mock_http_client):
|
||||
"""POST to API proxy returns 401 without auth headers."""
|
||||
mock_http_client.set_response("POST", "/api/v1/members", 201, {"id": "new"})
|
||||
response = admin_client.post(
|
||||
"/a/node-tags",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "new_tag",
|
||||
"value": "new_value",
|
||||
"value_type": "string",
|
||||
},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
"/api/v1/members",
|
||||
json={"name": "Test", "member_id": "test"},
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert "message=" in response.headers["location"]
|
||||
assert "created" in response.headers["location"]
|
||||
assert response.status_code == 401
|
||||
assert "Authentication required" in response.json()["detail"]
|
||||
|
||||
def test_create_tag_disabled(self, admin_client_disabled, auth_headers):
|
||||
"""Test creating tag when admin is disabled."""
|
||||
response = admin_client_disabled.post(
|
||||
"/a/node-tags",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "new_tag",
|
||||
"value": "new_value",
|
||||
"value_type": "string",
|
||||
},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
def test_proxy_put_blocked_without_auth(self, admin_client, mock_http_client):
|
||||
"""PUT to API proxy returns 401 without auth headers."""
|
||||
mock_http_client.set_response("PUT", "/api/v1/members/1", 200, {"id": "1"})
|
||||
response = admin_client.put(
|
||||
"/api/v1/members/1",
|
||||
json={"name": "Updated"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_create_tag_unauthenticated(self, admin_client):
|
||||
"""Test creating tag without authentication."""
|
||||
response = admin_client.post(
|
||||
"/a/node-tags",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "new_tag",
|
||||
"value": "new_value",
|
||||
"value_type": "string",
|
||||
},
|
||||
follow_redirects=False,
|
||||
def test_proxy_delete_blocked_without_auth(self, admin_client, mock_http_client):
|
||||
"""DELETE to API proxy returns 401 without auth headers."""
|
||||
mock_http_client.set_response("DELETE", "/api/v1/members/1", 204, None)
|
||||
response = admin_client.delete("/api/v1/members/1")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_proxy_patch_blocked_without_auth(self, admin_client, mock_http_client):
|
||||
"""PATCH to API proxy returns 401 without auth headers."""
|
||||
mock_http_client.set_response("PATCH", "/api/v1/members/1", 200, {"id": "1"})
|
||||
response = admin_client.patch(
|
||||
"/api/v1/members/1",
|
||||
json={"name": "Patched"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestAdminUpdateTag:
|
||||
"""Tests for updating node tags."""
|
||||
|
||||
def test_update_tag_success(self, admin_client, auth_headers):
|
||||
"""Test updating a tag."""
|
||||
response = admin_client.post(
|
||||
"/a/node-tags/update",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "environment",
|
||||
"value": "staging",
|
||||
"value_type": "string",
|
||||
},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert "message=" in response.headers["location"]
|
||||
assert "updated" in response.headers["location"]
|
||||
|
||||
def test_update_tag_not_found(
|
||||
self, admin_app, mock_http_client_admin: MockHttpClient, auth_headers
|
||||
def test_proxy_post_allowed_with_auth(
|
||||
self, admin_client, auth_headers, mock_http_client
|
||||
):
|
||||
"""Test updating a non-existent tag returns error."""
|
||||
# Set up 404 response for this specific tag
|
||||
mock_http_client_admin.set_response(
|
||||
"PUT",
|
||||
"/api/v1/nodes/abc123def456abc123def456abc123de/tags/nonexistent",
|
||||
404,
|
||||
{"detail": "Tag not found"},
|
||||
)
|
||||
admin_app.state.http_client = mock_http_client_admin
|
||||
client = TestClient(admin_app, raise_server_exceptions=True)
|
||||
|
||||
response = client.post(
|
||||
"/a/node-tags/update",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "nonexistent",
|
||||
"value": "value",
|
||||
"value_type": "string",
|
||||
},
|
||||
"""POST to API proxy succeeds with auth headers."""
|
||||
mock_http_client.set_response("POST", "/api/v1/members", 201, {"id": "new"})
|
||||
response = admin_client.post(
|
||||
"/api/v1/members",
|
||||
json={"name": "Test", "member_id": "test"},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert "error=" in response.headers["location"]
|
||||
assert "not+found" in response.headers["location"].lower()
|
||||
assert response.status_code == 201
|
||||
|
||||
def test_update_tag_disabled(self, admin_client_disabled, auth_headers):
|
||||
"""Test updating tag when admin is disabled."""
|
||||
def test_proxy_put_allowed_with_auth(
|
||||
self, admin_client, auth_headers, mock_http_client
|
||||
):
|
||||
"""PUT to API proxy succeeds with auth headers."""
|
||||
mock_http_client.set_response("PUT", "/api/v1/members/1", 200, {"id": "1"})
|
||||
response = admin_client.put(
|
||||
"/api/v1/members/1",
|
||||
json={"name": "Updated"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_proxy_delete_allowed_with_auth(
|
||||
self, admin_client, auth_headers, mock_http_client
|
||||
):
|
||||
"""DELETE to API proxy succeeds with auth headers."""
|
||||
mock_http_client.set_response("DELETE", "/api/v1/members/1", 204, None)
|
||||
response = admin_client.delete(
|
||||
"/api/v1/members/1",
|
||||
headers=auth_headers,
|
||||
)
|
||||
# 204 from the mock API
|
||||
assert response.status_code == 204
|
||||
|
||||
def test_proxy_get_allowed_without_auth(self, admin_client, mock_http_client):
|
||||
"""GET to API proxy is allowed without auth (read-only)."""
|
||||
response = admin_client.get("/api/v1/nodes")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_proxy_post_allowed_when_admin_disabled(
|
||||
self, admin_client_disabled, mock_http_client
|
||||
):
|
||||
"""POST to API proxy allowed when admin is disabled (no proxy auth)."""
|
||||
mock_http_client.set_response("POST", "/api/v1/members", 201, {"id": "new"})
|
||||
response = admin_client_disabled.post(
|
||||
"/a/node-tags/update",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "environment",
|
||||
"value": "staging",
|
||||
"value_type": "string",
|
||||
},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
"/api/v1/members",
|
||||
json={"name": "Test", "member_id": "test"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
# Should reach the API (which may return its own auth error, but
|
||||
# the proxy itself should not block it)
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
class TestAdminMoveTag:
|
||||
"""Tests for moving node tags."""
|
||||
class TestAdminFooterLink:
|
||||
"""Tests for admin link in footer."""
|
||||
|
||||
def test_move_tag_success(self, admin_client, auth_headers):
|
||||
"""Test moving a tag to another node."""
|
||||
response = admin_client.post(
|
||||
"/a/node-tags/move",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "environment",
|
||||
"new_public_key": "xyz789xyz789xyz789xyz789xyz789xy",
|
||||
},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
# Should redirect to destination node
|
||||
assert "xyz789xyz789xyz789xyz789xyz789xy" in response.headers["location"]
|
||||
assert "message=" in response.headers["location"]
|
||||
assert "moved" in response.headers["location"]
|
||||
def test_admin_link_visible_when_enabled(self, admin_client):
|
||||
"""Test that admin link appears in footer when enabled."""
|
||||
response = admin_client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert 'href="/a/"' in response.text
|
||||
assert "Admin" in response.text
|
||||
|
||||
|
||||
class TestAdminDeleteTag:
|
||||
"""Tests for deleting node tags."""
|
||||
|
||||
def test_delete_tag_success(self, admin_client, auth_headers):
|
||||
"""Test deleting a tag."""
|
||||
response = admin_client.post(
|
||||
"/a/node-tags/delete",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "environment",
|
||||
},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert "message=" in response.headers["location"]
|
||||
assert "deleted" in response.headers["location"]
|
||||
def test_admin_link_hidden_when_disabled(self, admin_client_disabled):
|
||||
"""Test that admin link does not appear in footer when disabled."""
|
||||
response = admin_client_disabled.get("/")
|
||||
assert response.status_code == 200
|
||||
assert 'href="/a/"' not in response.text
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Tests for the advertisements page route."""
|
||||
"""Tests for the advertisements page route (SPA)."""
|
||||
|
||||
from typing import Any
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.test_web.conftest import MockHttpClient
|
||||
|
||||
|
||||
class TestAdvertisementsPage:
|
||||
"""Tests for the advertisements page."""
|
||||
@@ -25,340 +23,82 @@ class TestAdvertisementsPage:
|
||||
response = client.get("/advertisements")
|
||||
assert "Test Network" in response.text
|
||||
|
||||
def test_advertisements_displays_advertisement_list(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that advertisements page displays advertisements from API."""
|
||||
def test_advertisements_contains_app_config(self, client: TestClient) -> None:
|
||||
"""Test that advertisements page contains SPA config."""
|
||||
response = client.get("/advertisements")
|
||||
assert response.status_code == 200
|
||||
# Check for advertisement data from mock
|
||||
assert "Node One" in response.text
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
def test_advertisements_displays_adv_type(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that advertisements page displays advertisement types."""
|
||||
def test_advertisements_contains_spa_script(self, client: TestClient) -> None:
|
||||
"""Test that advertisements page includes SPA application script."""
|
||||
response = client.get("/advertisements")
|
||||
# Should show adv type from mock data
|
||||
assert "REPEATER" in response.text
|
||||
assert "/static/js/spa/app.js" in response.text
|
||||
|
||||
|
||||
class TestAdvertisementsPageFilters:
|
||||
"""Tests for advertisements page filtering."""
|
||||
"""Tests for advertisements page with query parameters.
|
||||
|
||||
In the SPA architecture, all routes return the same shell.
|
||||
Query parameters are handled client-side.
|
||||
"""
|
||||
|
||||
def test_advertisements_with_search(self, client: TestClient) -> None:
|
||||
"""Test advertisements page with search parameter."""
|
||||
"""Test advertisements page with search parameter returns SPA shell."""
|
||||
response = client.get("/advertisements?search=node")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_with_member_filter(self, client: TestClient) -> None:
|
||||
"""Test advertisements page with member_id filter."""
|
||||
"""Test advertisements page with member_id filter returns SPA shell."""
|
||||
response = client.get("/advertisements?member_id=alice")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_with_public_key_filter(self, client: TestClient) -> None:
|
||||
"""Test advertisements page with public_key filter."""
|
||||
"""Test advertisements page with public_key filter returns SPA shell."""
|
||||
response = client.get(
|
||||
"/advertisements?public_key=abc123def456abc123def456abc123de"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_with_pagination(self, client: TestClient) -> None:
|
||||
"""Test advertisements page with pagination parameters."""
|
||||
"""Test advertisements page with pagination parameters returns SPA shell."""
|
||||
response = client.get("/advertisements?page=1&limit=25")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_page_2(self, client: TestClient) -> None:
|
||||
"""Test advertisements page 2."""
|
||||
"""Test advertisements page 2 returns SPA shell."""
|
||||
response = client.get("/advertisements?page=2")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_with_all_filters(self, client: TestClient) -> None:
|
||||
"""Test advertisements page with multiple filters."""
|
||||
"""Test advertisements page with multiple filters returns SPA shell."""
|
||||
response = client.get(
|
||||
"/advertisements?search=test&member_id=alice&page=1&limit=10"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestAdvertisementsPageDropdowns:
|
||||
"""Tests for advertisements page dropdown data."""
|
||||
class TestAdvertisementsConfig:
|
||||
"""Tests for advertisements page SPA config content."""
|
||||
|
||||
def test_advertisements_loads_members_for_dropdown(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that advertisements page loads members for filter dropdown."""
|
||||
# Set up members response
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/members",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{"id": "m1", "member_id": "alice", "name": "Alice"},
|
||||
{"id": "m2", "member_id": "bob", "name": "Bob"},
|
||||
],
|
||||
"total": 2,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
def test_advertisements_config_has_network_name(self, client: TestClient) -> None:
|
||||
"""Test that SPA config includes network name."""
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
# Members should be available for dropdown
|
||||
assert "Alice" in response.text or "alice" in response.text
|
||||
|
||||
def test_advertisements_loads_nodes_for_dropdown(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that advertisements page loads nodes for filter dropdown."""
|
||||
# Set up nodes response with tags
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "n1",
|
||||
"public_key": "abc123",
|
||||
"name": "Node Alpha",
|
||||
"tags": [{"key": "name", "value": "Custom Name"}],
|
||||
},
|
||||
{
|
||||
"id": "n2",
|
||||
"public_key": "def456",
|
||||
"name": "Node Beta",
|
||||
"tags": [],
|
||||
},
|
||||
],
|
||||
"total": 2,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestAdvertisementsNodeSorting:
|
||||
"""Tests for node sorting in advertisements dropdown."""
|
||||
|
||||
def test_nodes_sorted_by_display_name(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that nodes are sorted alphabetically by display name."""
|
||||
# Set up nodes with tags - "Zebra" should come after "Alpha"
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "n1",
|
||||
"public_key": "abc123",
|
||||
"name": "Zebra Node",
|
||||
"tags": [],
|
||||
},
|
||||
{
|
||||
"id": "n2",
|
||||
"public_key": "def456",
|
||||
"name": "Alpha Node",
|
||||
"tags": [],
|
||||
},
|
||||
],
|
||||
"total": 2,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
# Both nodes should appear
|
||||
text = response.text
|
||||
assert "Alpha Node" in text or "alpha" in text.lower()
|
||||
assert "Zebra Node" in text or "zebra" in text.lower()
|
||||
|
||||
def test_nodes_sorted_by_tag_name_when_present(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that nodes use tag name for sorting when available."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "n1",
|
||||
"public_key": "abc123",
|
||||
"name": "Zebra",
|
||||
"tags": [{"key": "name", "value": "Alpha Custom"}],
|
||||
},
|
||||
],
|
||||
"total": 1,
|
||||
},
|
||||
config_start = text.find("window.__APP_CONFIG__ = ") + len(
|
||||
"window.__APP_CONFIG__ = "
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
config_end = text.find(";", config_start)
|
||||
config = json.loads(text[config_start:config_end])
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
assert config["network_name"] == "Test Network"
|
||||
|
||||
def test_advertisements_config_unauthenticated(self, client: TestClient) -> None:
|
||||
"""Test that SPA config shows unauthenticated without auth header."""
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_nodes_fallback_to_public_key_when_no_name(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that nodes fall back to public_key when no name."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "n1",
|
||||
"public_key": "abc123def456",
|
||||
"name": None,
|
||||
"tags": [],
|
||||
},
|
||||
],
|
||||
"total": 1,
|
||||
},
|
||||
text = response.text
|
||||
config_start = text.find("window.__APP_CONFIG__ = ") + len(
|
||||
"window.__APP_CONFIG__ = "
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
config_end = text.find(";", config_start)
|
||||
config = json.loads(text[config_start:config_end])
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestAdvertisementsPageAPIErrors:
|
||||
"""Tests for advertisements page handling API errors."""
|
||||
|
||||
def test_advertisements_handles_api_error(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that advertisements page handles API errors gracefully."""
|
||||
mock_http_client.set_response(
|
||||
"GET", "/api/v1/advertisements", status_code=500, json_data=None
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
# Should still return 200 (page renders with empty list)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_handles_api_not_found(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that advertisements page handles API 404 gracefully."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/advertisements",
|
||||
status_code=404,
|
||||
json_data={"detail": "Not found"},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
# Should still return 200 (page renders with empty list)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_handles_members_api_error(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that page handles members API error gracefully."""
|
||||
mock_http_client.set_response(
|
||||
"GET", "/api/v1/members", status_code=500, json_data=None
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
# Should still return 200 (page renders without member dropdown)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_handles_nodes_api_error(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that page handles nodes API error gracefully."""
|
||||
mock_http_client.set_response(
|
||||
"GET", "/api/v1/nodes", status_code=500, json_data=None
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
# Should still return 200 (page renders without node dropdown)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_handles_empty_response(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that page handles empty advertisements list."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/advertisements",
|
||||
200,
|
||||
{"items": [], "total": 0},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestAdvertisementsPagination:
|
||||
"""Tests for advertisements pagination calculations."""
|
||||
|
||||
def test_pagination_calculates_total_pages(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that pagination correctly calculates total pages."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/advertisements",
|
||||
200,
|
||||
{"items": [], "total": 150},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
# With limit=50 and total=150, should have 3 pages
|
||||
response = client.get("/advertisements?limit=50")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_pagination_with_zero_total(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test pagination with zero results shows at least 1 page."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/advertisements",
|
||||
200,
|
||||
{"items": [], "total": 0},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert config["is_authenticated"] is False
|
||||
|
||||
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
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Tests for the home page route."""
|
||||
"""Tests for the home page route (SPA)."""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -31,25 +33,81 @@ class TestHomePage:
|
||||
response = client.get("/")
|
||||
assert "Test Country" in response.text
|
||||
|
||||
def test_home_contains_radio_config(self, client: TestClient) -> None:
|
||||
"""Test that home page contains the radio configuration."""
|
||||
def test_home_contains_app_config(self, client: TestClient) -> None:
|
||||
"""Test that home page contains the SPA config JSON."""
|
||||
response = client.get("/")
|
||||
assert "Test Radio Config" in response.text
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
def test_home_config_contains_network_info(self, client: TestClient) -> None:
|
||||
"""Test that SPA config contains network information."""
|
||||
response = client.get("/")
|
||||
# Extract the config JSON from the HTML
|
||||
text = response.text
|
||||
config_start = text.find("window.__APP_CONFIG__ = ") + len(
|
||||
"window.__APP_CONFIG__ = "
|
||||
)
|
||||
config_end = text.find(";", config_start)
|
||||
config = json.loads(text[config_start:config_end])
|
||||
|
||||
assert config["network_name"] == "Test Network"
|
||||
assert config["network_city"] == "Test City"
|
||||
assert config["network_country"] == "Test Country"
|
||||
|
||||
def test_home_config_contains_contact_info(self, client: TestClient) -> None:
|
||||
"""Test that SPA config contains contact information."""
|
||||
response = client.get("/")
|
||||
text = response.text
|
||||
config_start = text.find("window.__APP_CONFIG__ = ") + len(
|
||||
"window.__APP_CONFIG__ = "
|
||||
)
|
||||
config_end = text.find(";", config_start)
|
||||
config = json.loads(text[config_start:config_end])
|
||||
|
||||
assert config["network_contact_email"] == "test@example.com"
|
||||
assert config["network_contact_discord"] == "https://discord.gg/test"
|
||||
|
||||
def test_home_contains_contact_email(self, client: TestClient) -> None:
|
||||
"""Test that home page contains the contact email."""
|
||||
"""Test that home page contains the contact email in footer."""
|
||||
response = client.get("/")
|
||||
assert "test@example.com" in response.text
|
||||
|
||||
def test_home_contains_discord_link(self, client: TestClient) -> None:
|
||||
"""Test that home page contains the Discord link."""
|
||||
"""Test that home page contains the Discord link in footer."""
|
||||
response = client.get("/")
|
||||
assert "discord.gg/test" in response.text
|
||||
|
||||
def test_home_contains_navigation(self, client: TestClient) -> None:
|
||||
"""Test that home page contains navigation links."""
|
||||
response = client.get("/")
|
||||
# Check for navigation links to other pages
|
||||
assert 'href="/"' in response.text or 'href=""' in response.text
|
||||
assert 'href="/nodes"' in response.text or "/nodes" in response.text
|
||||
assert 'href="/messages"' in response.text or "/messages" in response.text
|
||||
assert 'href="/"' in response.text
|
||||
assert 'href="/nodes"' in response.text
|
||||
assert 'href="/messages"' in response.text
|
||||
|
||||
def test_home_contains_spa_app_script(self, client: TestClient) -> None:
|
||||
"""Test that home page includes the SPA application script."""
|
||||
response = client.get("/")
|
||||
assert "/static/js/spa/app.js" in response.text
|
||||
|
||||
def test_home_unauthenticated(self, client: TestClient) -> None:
|
||||
"""Test that home page config shows unauthenticated by default."""
|
||||
response = client.get("/")
|
||||
text = response.text
|
||||
config_start = text.find("window.__APP_CONFIG__ = ") + len(
|
||||
"window.__APP_CONFIG__ = "
|
||||
)
|
||||
config_end = text.find(";", config_start)
|
||||
config = json.loads(text[config_start:config_end])
|
||||
|
||||
assert config["is_authenticated"] is False
|
||||
|
||||
def test_home_authenticated(self, client: TestClient) -> None:
|
||||
"""Test that home page config shows authenticated with auth header."""
|
||||
response = client.get("/", headers={"X-Forwarded-User": "test-user"})
|
||||
text = response.text
|
||||
config_start = text.find("window.__APP_CONFIG__ = ") + len(
|
||||
"window.__APP_CONFIG__ = "
|
||||
)
|
||||
config_end = text.find(";", config_start)
|
||||
config = json.loads(text[config_start:config_end])
|
||||
|
||||
assert config["is_authenticated"] is True
|
||||
|
||||
@@ -173,3 +173,248 @@ class TestMapDataFiltering:
|
||||
|
||||
# Node with only lat should be excluded
|
||||
assert len(data["nodes"]) == 0
|
||||
|
||||
def test_map_data_filters_zero_coordinates(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that map data filters nodes with (0, 0) coordinates."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
status_code=200,
|
||||
json_data={
|
||||
"items": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"public_key": "abc123",
|
||||
"name": "Zero Coord Node",
|
||||
"lat": 0.0,
|
||||
"lon": 0.0,
|
||||
"tags": [],
|
||||
},
|
||||
],
|
||||
"total": 1,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/map/data")
|
||||
data = response.json()
|
||||
|
||||
# Node at (0, 0) should be excluded
|
||||
assert len(data["nodes"]) == 0
|
||||
|
||||
def test_map_data_uses_model_coordinates_as_fallback(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that map data uses model lat/lon when tags are not present."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
status_code=200,
|
||||
json_data={
|
||||
"items": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"public_key": "abc123",
|
||||
"name": "Model Coords Node",
|
||||
"lat": 51.5074,
|
||||
"lon": -0.1278,
|
||||
"tags": [],
|
||||
},
|
||||
],
|
||||
"total": 1,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/map/data")
|
||||
data = response.json()
|
||||
|
||||
# Node should use model coordinates
|
||||
assert len(data["nodes"]) == 1
|
||||
assert data["nodes"][0]["lat"] == 51.5074
|
||||
assert data["nodes"][0]["lon"] == -0.1278
|
||||
|
||||
def test_map_data_prefers_tag_coordinates_over_model(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that tag coordinates take priority over model coordinates."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
status_code=200,
|
||||
json_data={
|
||||
"items": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"public_key": "abc123",
|
||||
"name": "Both Coords Node",
|
||||
"lat": 51.5074,
|
||||
"lon": -0.1278,
|
||||
"tags": [
|
||||
{"key": "lat", "value": "40.7128"},
|
||||
{"key": "lon", "value": "-74.0060"},
|
||||
],
|
||||
},
|
||||
],
|
||||
"total": 1,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/map/data")
|
||||
data = response.json()
|
||||
|
||||
# Node should use tag coordinates, not model
|
||||
assert len(data["nodes"]) == 1
|
||||
assert data["nodes"][0]["lat"] == 40.7128
|
||||
assert data["nodes"][0]["lon"] == -74.0060
|
||||
|
||||
|
||||
class TestMapDataInfrastructure:
|
||||
"""Tests for infrastructure node handling in map data."""
|
||||
|
||||
def test_map_data_includes_infra_center(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that map data includes infrastructure center when infra nodes exist."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
status_code=200,
|
||||
json_data={
|
||||
"items": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"public_key": "abc123",
|
||||
"name": "Infra Node",
|
||||
"lat": 40.0,
|
||||
"lon": -74.0,
|
||||
"tags": [{"key": "role", "value": "infra"}],
|
||||
},
|
||||
{
|
||||
"id": "node-2",
|
||||
"public_key": "def456",
|
||||
"name": "Regular Node",
|
||||
"lat": 41.0,
|
||||
"lon": -75.0,
|
||||
"tags": [],
|
||||
},
|
||||
],
|
||||
"total": 2,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/map/data")
|
||||
data = response.json()
|
||||
|
||||
# Should have infra_center based on infra node only
|
||||
assert data["infra_center"] is not None
|
||||
assert data["infra_center"]["lat"] == 40.0
|
||||
assert data["infra_center"]["lon"] == -74.0
|
||||
|
||||
def test_map_data_infra_center_null_when_no_infra(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that infra_center is null when no infrastructure nodes exist."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
status_code=200,
|
||||
json_data={
|
||||
"items": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"public_key": "abc123",
|
||||
"name": "Regular Node",
|
||||
"lat": 40.0,
|
||||
"lon": -74.0,
|
||||
"tags": [],
|
||||
},
|
||||
],
|
||||
"total": 1,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/map/data")
|
||||
data = response.json()
|
||||
|
||||
assert data["infra_center"] is None
|
||||
|
||||
def test_map_data_sets_is_infra_flag(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that nodes have correct is_infra flag based on role tag."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
status_code=200,
|
||||
json_data={
|
||||
"items": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"public_key": "abc123",
|
||||
"name": "Infra Node",
|
||||
"lat": 40.0,
|
||||
"lon": -74.0,
|
||||
"tags": [{"key": "role", "value": "infra"}],
|
||||
},
|
||||
{
|
||||
"id": "node-2",
|
||||
"public_key": "def456",
|
||||
"name": "Regular Node",
|
||||
"lat": 41.0,
|
||||
"lon": -75.0,
|
||||
"tags": [{"key": "role", "value": "other"}],
|
||||
},
|
||||
],
|
||||
"total": 2,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/map/data")
|
||||
data = response.json()
|
||||
|
||||
nodes_by_name = {n["name"]: n for n in data["nodes"]}
|
||||
assert nodes_by_name["Infra Node"]["is_infra"] is True
|
||||
assert nodes_by_name["Regular Node"]["is_infra"] is False
|
||||
|
||||
def test_map_data_debug_includes_infra_count(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that debug info includes infrastructure node count."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
status_code=200,
|
||||
json_data={
|
||||
"items": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"public_key": "abc123",
|
||||
"name": "Infra Node",
|
||||
"lat": 40.0,
|
||||
"lon": -74.0,
|
||||
"tags": [{"key": "role", "value": "infra"}],
|
||||
},
|
||||
],
|
||||
"total": 1,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/map/data")
|
||||
data = response.json()
|
||||
|
||||
assert data["debug"]["infra_nodes"] == 1
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Tests for the members page route."""
|
||||
"""Tests for the members page route (SPA)."""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -21,36 +23,24 @@ class TestMembersPage:
|
||||
response = client.get("/members")
|
||||
assert "Test Network" in response.text
|
||||
|
||||
def test_members_without_data_shows_empty(self, client: TestClient) -> None:
|
||||
"""Test that members page with no API data shows no members."""
|
||||
def test_members_contains_app_config(self, client: TestClient) -> None:
|
||||
"""Test that members page contains SPA config."""
|
||||
response = client.get("/members")
|
||||
# Should still render successfully
|
||||
assert response.status_code == 200
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
def test_members_with_api_data_shows_members(
|
||||
self, client_with_members: TestClient
|
||||
) -> None:
|
||||
"""Test that members page with API data shows member data."""
|
||||
response = client_with_members.get("/members")
|
||||
assert response.status_code == 200
|
||||
# Check for member data from mock API response
|
||||
assert "Alice" in response.text
|
||||
assert "Bob" in response.text
|
||||
assert "W1ABC" in response.text
|
||||
assert "W2XYZ" in response.text
|
||||
def test_members_contains_spa_script(self, client: TestClient) -> None:
|
||||
"""Test that members page includes SPA application script."""
|
||||
response = client.get("/members")
|
||||
assert "/static/js/spa/app.js" in response.text
|
||||
|
||||
def test_members_with_nodes_shows_node_links(
|
||||
self, client_with_members: TestClient
|
||||
) -> None:
|
||||
"""Test that members page shows associated nodes with links."""
|
||||
response = client_with_members.get("/members")
|
||||
assert response.status_code == 200
|
||||
# Alice has a node associated - check for friendly name display
|
||||
assert "Alice Chat" in response.text
|
||||
# Check for partial public key underneath
|
||||
assert "abc123def456" in response.text
|
||||
# Check for link to node detail page (full public key)
|
||||
assert (
|
||||
"/nodes/abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
|
||||
in response.text
|
||||
def test_members_config_has_network_name(self, client: TestClient) -> None:
|
||||
"""Test that SPA config includes network name."""
|
||||
response = client.get("/members")
|
||||
text = response.text
|
||||
config_start = text.find("window.__APP_CONFIG__ = ") + len(
|
||||
"window.__APP_CONFIG__ = "
|
||||
)
|
||||
config_end = text.find(";", config_start)
|
||||
config = json.loads(text[config_start:config_end])
|
||||
|
||||
assert config["network_name"] == "Test Network"
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Tests for the messages page route."""
|
||||
"""Tests for the messages page route (SPA)."""
|
||||
|
||||
from typing import Any
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.test_web.conftest import MockHttpClient
|
||||
|
||||
|
||||
class TestMessagesPage:
|
||||
"""Tests for the messages page."""
|
||||
@@ -25,94 +23,68 @@ class TestMessagesPage:
|
||||
response = client.get("/messages")
|
||||
assert "Test Network" in response.text
|
||||
|
||||
def test_messages_displays_message_list(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that messages page displays messages from API."""
|
||||
def test_messages_contains_app_config(self, client: TestClient) -> None:
|
||||
"""Test that messages page contains SPA config."""
|
||||
response = client.get("/messages")
|
||||
assert response.status_code == 200
|
||||
# Check for message data from mock
|
||||
assert "Hello World" in response.text
|
||||
assert "Channel message" in response.text
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
def test_messages_displays_message_types(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that messages page displays message types."""
|
||||
def test_messages_contains_spa_script(self, client: TestClient) -> None:
|
||||
"""Test that messages page includes SPA application script."""
|
||||
response = client.get("/messages")
|
||||
# Should show message types
|
||||
assert "direct" in response.text.lower() or "contact" in response.text.lower()
|
||||
assert "channel" in response.text.lower()
|
||||
assert "/static/js/spa/app.js" in response.text
|
||||
|
||||
|
||||
class TestMessagesPageFilters:
|
||||
"""Tests for messages page filtering."""
|
||||
"""Tests for messages page with query parameters.
|
||||
|
||||
In the SPA architecture, all routes return the same shell.
|
||||
Query parameters are handled client-side.
|
||||
"""
|
||||
|
||||
def test_messages_with_type_filter(self, client: TestClient) -> None:
|
||||
"""Test messages page with message type filter."""
|
||||
"""Test messages page with message type filter returns SPA shell."""
|
||||
response = client.get("/messages?message_type=direct")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_messages_with_channel_filter(self, client: TestClient) -> None:
|
||||
"""Test messages page with channel filter."""
|
||||
"""Test messages page with channel filter returns SPA shell."""
|
||||
response = client.get("/messages?channel_idx=0")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_messages_with_search(self, client: TestClient) -> None:
|
||||
"""Test messages page with search parameter."""
|
||||
"""Test messages page with search parameter returns SPA shell."""
|
||||
response = client.get("/messages?search=hello")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_messages_with_pagination(self, client: TestClient) -> None:
|
||||
"""Test messages page with pagination parameters."""
|
||||
"""Test messages page with pagination parameters returns SPA shell."""
|
||||
response = client.get("/messages?page=1&limit=25")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_messages_page_2(self, client: TestClient) -> None:
|
||||
"""Test messages page 2."""
|
||||
"""Test messages page 2 returns SPA shell."""
|
||||
response = client.get("/messages?page=2")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_messages_with_all_filters(self, client: TestClient) -> None:
|
||||
"""Test messages page with multiple filters."""
|
||||
"""Test messages page with multiple filters returns SPA shell."""
|
||||
response = client.get(
|
||||
"/messages?message_type=channel&channel_idx=1&page=1&limit=10"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestMessagesPageAPIErrors:
|
||||
"""Tests for messages page handling API errors."""
|
||||
class TestMessagesConfig:
|
||||
"""Tests for messages page SPA config content."""
|
||||
|
||||
def test_messages_handles_api_error(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that messages page handles API errors gracefully."""
|
||||
mock_http_client.set_response(
|
||||
"GET", "/api/v1/messages", status_code=500, json_data=None
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
def test_messages_config_has_network_name(self, client: TestClient) -> None:
|
||||
"""Test that SPA config includes network name."""
|
||||
response = client.get("/messages")
|
||||
|
||||
# Should still return 200 (page renders with empty list)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_messages_handles_api_not_found(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that messages page handles API 404 gracefully."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/messages",
|
||||
status_code=404,
|
||||
json_data={"detail": "Not found"},
|
||||
text = response.text
|
||||
config_start = text.find("window.__APP_CONFIG__ = ") + len(
|
||||
"window.__APP_CONFIG__ = "
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
config_end = text.find(";", config_start)
|
||||
config = json.loads(text[config_start:config_end])
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/messages")
|
||||
|
||||
# Should still return 200 (page renders with empty list)
|
||||
assert response.status_code == 200
|
||||
assert config["network_name"] == "Test Network"
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Tests for the nodes page routes."""
|
||||
"""Tests for the nodes page routes (SPA)."""
|
||||
|
||||
from typing import Any
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.test_web.conftest import MockHttpClient
|
||||
|
||||
|
||||
class TestNodesListPage:
|
||||
"""Tests for the nodes list page."""
|
||||
@@ -25,43 +23,33 @@ class TestNodesListPage:
|
||||
response = client.get("/nodes")
|
||||
assert "Test Network" in response.text
|
||||
|
||||
def test_nodes_displays_node_list(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that nodes page displays node data from API."""
|
||||
def test_nodes_contains_app_config(self, client: TestClient) -> None:
|
||||
"""Test that nodes page contains SPA config."""
|
||||
response = client.get("/nodes")
|
||||
assert response.status_code == 200
|
||||
# Check for node data from mock (names and public key prefixes)
|
||||
assert "Node One" in response.text
|
||||
assert "Node Two" in response.text
|
||||
assert "abc123" in response.text
|
||||
assert "def456" in response.text
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
def test_nodes_displays_public_keys(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that nodes page displays public keys."""
|
||||
def test_nodes_contains_spa_script(self, client: TestClient) -> None:
|
||||
"""Test that nodes page includes SPA application script."""
|
||||
response = client.get("/nodes")
|
||||
# Should show truncated or full public keys
|
||||
assert "abc123" in response.text or "def456" in response.text
|
||||
assert "/static/js/spa/app.js" in response.text
|
||||
|
||||
def test_nodes_with_search_param(self, client: TestClient) -> None:
|
||||
"""Test nodes page with search parameter."""
|
||||
"""Test nodes page with search parameter returns SPA shell."""
|
||||
response = client.get("/nodes?search=test")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_nodes_with_adv_type_filter(self, client: TestClient) -> None:
|
||||
"""Test nodes page with adv_type filter."""
|
||||
"""Test nodes page with adv_type filter returns SPA shell."""
|
||||
response = client.get("/nodes?adv_type=REPEATER")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_nodes_with_pagination(self, client: TestClient) -> None:
|
||||
"""Test nodes page with pagination parameters."""
|
||||
"""Test nodes page with pagination parameters returns SPA shell."""
|
||||
response = client.get("/nodes?page=1&limit=10")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_nodes_page_2(self, client: TestClient) -> None:
|
||||
"""Test nodes page 2."""
|
||||
"""Test nodes page 2 returns SPA shell."""
|
||||
response = client.get("/nodes?page=2")
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -69,83 +57,51 @@ class TestNodesListPage:
|
||||
class TestNodeDetailPage:
|
||||
"""Tests for the node detail page."""
|
||||
|
||||
def test_node_detail_returns_200(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
def test_node_detail_returns_200(self, client: TestClient) -> None:
|
||||
"""Test that node detail page returns 200 status code."""
|
||||
response = client.get(
|
||||
"/nodes/abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_node_detail_returns_html(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
def test_node_detail_returns_html(self, client: TestClient) -> None:
|
||||
"""Test that node detail page returns HTML content."""
|
||||
response = client.get(
|
||||
"/nodes/abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
|
||||
)
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
|
||||
def test_node_detail_displays_node_info(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that node detail page displays node information."""
|
||||
def test_node_detail_contains_app_config(self, client: TestClient) -> None:
|
||||
"""Test that node detail page contains SPA config."""
|
||||
response = client.get(
|
||||
"/nodes/abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
# Should display node details
|
||||
assert "Node One" in response.text
|
||||
# Node type is shown as emoji with title attribute
|
||||
assert 'title="Repeater"' in response.text
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
def test_node_detail_displays_public_key(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
def test_node_detail_nonexistent_returns_spa_shell(
|
||||
self, client: TestClient
|
||||
) -> None:
|
||||
"""Test that node detail page displays the full public key."""
|
||||
response = client.get(
|
||||
"/nodes/abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
|
||||
)
|
||||
assert (
|
||||
"abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
|
||||
in response.text
|
||||
)
|
||||
"""Test that node detail for nonexistent node returns SPA shell.
|
||||
|
||||
|
||||
class TestNodesPageAPIErrors:
|
||||
"""Tests for nodes pages handling API errors."""
|
||||
|
||||
def test_nodes_handles_api_error(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that nodes page handles API errors gracefully."""
|
||||
mock_http_client.set_response(
|
||||
"GET", "/api/v1/nodes", status_code=500, json_data=None
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/nodes")
|
||||
|
||||
# Should still return 200 (page renders with empty list)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_node_detail_handles_not_found(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that node detail page returns 404 when node not found."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes/nonexistent",
|
||||
status_code=404,
|
||||
json_data={"detail": "Node not found"},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=False)
|
||||
In the SPA architecture, all routes return the same shell.
|
||||
The SPA client handles 404 display when the API returns not found.
|
||||
"""
|
||||
response = client.get("/nodes/nonexistent")
|
||||
assert response.status_code == 200
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
# Should return 404 with custom error page
|
||||
assert response.status_code == 404
|
||||
assert "Page Not Found" in response.text
|
||||
|
||||
class TestNodesConfig:
|
||||
"""Tests for nodes page SPA config content."""
|
||||
|
||||
def test_nodes_config_has_network_name(self, client: TestClient) -> None:
|
||||
"""Test that SPA config includes network name."""
|
||||
response = client.get("/nodes")
|
||||
text = response.text
|
||||
config_start = text.find("window.__APP_CONFIG__ = ") + len(
|
||||
"window.__APP_CONFIG__ = "
|
||||
)
|
||||
config_end = text.find(";", config_start)
|
||||
config = json.loads(text[config_start:config_end])
|
||||
|
||||
assert config["network_name"] == "Test Network"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for custom pages functionality."""
|
||||
"""Tests for custom pages functionality (SPA)."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
@@ -342,13 +343,21 @@ def hello():
|
||||
|
||||
|
||||
class TestPagesRoute:
|
||||
"""Tests for the custom pages route."""
|
||||
"""Tests for the custom pages routes (SPA).
|
||||
|
||||
In the SPA architecture:
|
||||
- /pages/{slug} returns the SPA shell HTML (catch-all)
|
||||
- /spa/pages/{slug} returns page content as JSON
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def pages_dir(self) -> Generator[str, None, None]:
|
||||
"""Create a temporary directory with test pages."""
|
||||
"""Create a temporary content directory with test pages."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
(Path(tmpdir) / "about.md").write_text(
|
||||
# Create pages subdirectory (CONTENT_HOME/pages)
|
||||
pages_subdir = Path(tmpdir) / "pages"
|
||||
pages_subdir.mkdir()
|
||||
(pages_subdir / "about.md").write_text(
|
||||
"""---
|
||||
title: About Us
|
||||
slug: about
|
||||
@@ -360,7 +369,7 @@ menu_order: 10
|
||||
Welcome to the network.
|
||||
"""
|
||||
)
|
||||
(Path(tmpdir) / "faq.md").write_text(
|
||||
(pages_subdir / "faq.md").write_text(
|
||||
"""---
|
||||
title: FAQ
|
||||
slug: faq
|
||||
@@ -381,8 +390,8 @@ Here are some answers.
|
||||
"""Create a web app with custom pages configured."""
|
||||
import os
|
||||
|
||||
# Temporarily set PAGES_HOME environment variable
|
||||
os.environ["PAGES_HOME"] = pages_dir
|
||||
# Temporarily set CONTENT_HOME environment variable
|
||||
os.environ["CONTENT_HOME"] = pages_dir
|
||||
|
||||
from meshcore_hub.web.app import create_app
|
||||
|
||||
@@ -396,7 +405,7 @@ Here are some answers.
|
||||
yield app
|
||||
|
||||
# Cleanup
|
||||
del os.environ["PAGES_HOME"]
|
||||
del os.environ["CONTENT_HOME"]
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_pages(
|
||||
@@ -406,22 +415,55 @@ Here are some answers.
|
||||
web_app_with_pages.state.http_client = mock_http_client
|
||||
return TestClient(web_app_with_pages, raise_server_exceptions=True)
|
||||
|
||||
def test_get_page_success(self, client_with_pages: TestClient) -> None:
|
||||
"""Test successfully retrieving a custom page."""
|
||||
def test_page_route_returns_spa_shell(self, client_with_pages: TestClient) -> None:
|
||||
"""Test that /pages/{slug} returns the SPA shell HTML."""
|
||||
response = client_with_pages.get("/pages/about")
|
||||
assert response.status_code == 200
|
||||
assert "About Us" in response.text
|
||||
assert "About Our Network" in response.text
|
||||
assert "Welcome to the network" in response.text
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
def test_get_page_not_found(self, client_with_pages: TestClient) -> None:
|
||||
"""Test 404 for unknown page slug."""
|
||||
def test_page_route_nonexistent_returns_spa_shell(
|
||||
self, client_with_pages: TestClient
|
||||
) -> None:
|
||||
"""Test that /pages/{slug} returns SPA shell even for nonexistent pages.
|
||||
|
||||
The SPA catch-all serves the shell for all routes.
|
||||
Client-side code fetches page content via /spa/pages/{slug}.
|
||||
"""
|
||||
response = client_with_pages.get("/pages/nonexistent")
|
||||
assert response.status_code == 200
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
def test_spa_page_api_returns_json(self, client_with_pages: TestClient) -> None:
|
||||
"""Test that /spa/pages/{slug} returns page content as JSON."""
|
||||
response = client_with_pages.get("/spa/pages/about")
|
||||
assert response.status_code == 200
|
||||
assert "application/json" in response.headers["content-type"]
|
||||
|
||||
data = response.json()
|
||||
assert data["slug"] == "about"
|
||||
assert data["title"] == "About Us"
|
||||
assert "About Our Network" in data["content_html"]
|
||||
assert "Welcome to the network" in data["content_html"]
|
||||
|
||||
def test_spa_page_api_not_found(self, client_with_pages: TestClient) -> None:
|
||||
"""Test that /spa/pages/{slug} returns 404 for unknown page."""
|
||||
response = client_with_pages.get("/spa/pages/nonexistent")
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert data["detail"] == "Page not found"
|
||||
|
||||
def test_spa_page_api_faq(self, client_with_pages: TestClient) -> None:
|
||||
"""Test that /spa/pages/faq returns FAQ page content."""
|
||||
response = client_with_pages.get("/spa/pages/faq")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["slug"] == "faq"
|
||||
assert data["title"] == "FAQ"
|
||||
assert "Frequently Asked Questions" in data["content_html"]
|
||||
|
||||
def test_pages_in_navigation(self, client_with_pages: TestClient) -> None:
|
||||
"""Test that custom pages appear in navigation."""
|
||||
response = client_with_pages.get("/pages/about")
|
||||
response = client_with_pages.get("/")
|
||||
assert response.status_code == 200
|
||||
# Check for navigation links
|
||||
assert 'href="/pages/about"' in response.text
|
||||
@@ -429,22 +471,41 @@ Here are some answers.
|
||||
|
||||
def test_pages_sorted_in_navigation(self, client_with_pages: TestClient) -> None:
|
||||
"""Test that pages are sorted by menu_order in navigation."""
|
||||
response = client_with_pages.get("/pages/about")
|
||||
response = client_with_pages.get("/")
|
||||
assert response.status_code == 200
|
||||
# About (order 10) should appear before FAQ (order 20)
|
||||
about_pos = response.text.find('href="/pages/about"')
|
||||
faq_pos = response.text.find('href="/pages/faq"')
|
||||
assert about_pos < faq_pos
|
||||
|
||||
def test_pages_in_config(self, client_with_pages: TestClient) -> None:
|
||||
"""Test that custom pages are included in SPA config."""
|
||||
response = client_with_pages.get("/")
|
||||
text = response.text
|
||||
config_start = text.find("window.__APP_CONFIG__ = ") + len(
|
||||
"window.__APP_CONFIG__ = "
|
||||
)
|
||||
config_end = text.find(";", config_start)
|
||||
config = json.loads(text[config_start:config_end])
|
||||
|
||||
custom_pages = config["custom_pages"]
|
||||
assert len(custom_pages) == 2
|
||||
slugs = [p["slug"] for p in custom_pages]
|
||||
assert "about" in slugs
|
||||
assert "faq" in slugs
|
||||
|
||||
|
||||
class TestPagesInSitemap:
|
||||
"""Tests for custom pages in sitemap."""
|
||||
|
||||
@pytest.fixture
|
||||
def pages_dir(self) -> Generator[str, None, None]:
|
||||
"""Create a temporary directory with test pages."""
|
||||
"""Create a temporary content directory with test pages."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
(Path(tmpdir) / "about.md").write_text(
|
||||
# Create pages subdirectory (CONTENT_HOME/pages)
|
||||
pages_subdir = Path(tmpdir) / "pages"
|
||||
pages_subdir.mkdir()
|
||||
(pages_subdir / "about.md").write_text(
|
||||
"""---
|
||||
title: About
|
||||
slug: about
|
||||
@@ -462,7 +523,7 @@ About page.
|
||||
"""Create a test client with custom pages for sitemap testing."""
|
||||
import os
|
||||
|
||||
os.environ["PAGES_HOME"] = pages_dir
|
||||
os.environ["CONTENT_HOME"] = pages_dir
|
||||
|
||||
from meshcore_hub.web.app import create_app
|
||||
|
||||
@@ -476,7 +537,7 @@ About page.
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
yield client
|
||||
|
||||
del os.environ["PAGES_HOME"]
|
||||
del os.environ["CONTENT_HOME"]
|
||||
|
||||
def test_pages_included_in_sitemap(
|
||||
self, client_with_pages_for_sitemap: TestClient
|
||||
|
||||
Reference in New Issue
Block a user