mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-03-28 17:42:56 +01:00
Compare commits
218 Commits
v0.2.0
...
patch/test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31d591723d | ||
|
|
3eff7f03db | ||
|
|
905ea0190b | ||
|
|
86cc7edca3 | ||
|
|
eb3f8508b7 | ||
|
|
74a34fdcba | ||
|
|
175fc8c524 | ||
|
|
2a153a5239 | ||
|
|
de85e0cd7a | ||
|
|
5a20da3afa | ||
|
|
dcd33711db | ||
|
|
a8cb20fea5 | ||
|
|
3ac5667d7a | ||
|
|
c8c53b25bd | ||
|
|
e4a1b005dc | ||
|
|
27adc6e2de | ||
|
|
835fb1c094 | ||
|
|
d7a351a803 | ||
|
|
317627833c | ||
|
|
f4514d1150 | ||
|
|
7be5f6afdf | ||
|
|
54695ab9e2 | ||
|
|
189eb3a139 | ||
|
|
96ca6190db | ||
|
|
baf08a9545 | ||
|
|
1d3e649ce0 | ||
|
|
45abc66816 | ||
|
|
9c8eb27455 | ||
|
|
e6c6d4aecc | ||
|
|
19bb06953e | ||
|
|
1f55d912ea | ||
|
|
5272a72647 | ||
|
|
b2f8e18f13 | ||
|
|
a15e91c754 | ||
|
|
85129e528e | ||
|
|
127cd7adf6 | ||
|
|
91b3f1926f | ||
|
|
3ef94a21df | ||
|
|
19e724fcc8 | ||
|
|
7b7910b42e | ||
|
|
c711a0eb9b | ||
|
|
dcd7ed248d | ||
|
|
b0ea6bcc0e | ||
|
|
7ef41a3671 | ||
|
|
a7611dd8d4 | ||
|
|
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 | ||
|
|
deaab9b9de | ||
|
|
95636ef580 | ||
|
|
5831592f88 | ||
|
|
bc7bff8b82 | ||
|
|
9445d2150c | ||
|
|
3e9f478a65 | ||
|
|
6656bd8214 | ||
|
|
0f50bf4a41 | ||
|
|
99206f7467 | ||
|
|
3a89daa9c0 | ||
|
|
86c5ff8f1c | ||
|
|
59d0edc96f | ||
|
|
b01611e0e8 | ||
|
|
1e077f50f7 | ||
|
|
09146a2e94 | ||
|
|
56487597b7 | ||
|
|
de968f397d | ||
|
|
3ca5284c11 | ||
|
|
75d7e5bdfa | ||
|
|
927fcd6efb | ||
|
|
3132d296bb | ||
|
|
96e4215c29 | ||
|
|
fd3c3171ce | ||
|
|
345ffd219b | ||
|
|
9661b22390 | ||
|
|
31aa48c9a0 | ||
|
|
1a3649b3be | ||
|
|
33649a065b | ||
|
|
fd582bda35 | ||
|
|
c42b26c8f3 | ||
|
|
d52163949a | ||
|
|
ca101583f0 | ||
|
|
4af0f2ea80 | ||
|
|
0b3ac64845 | ||
|
|
3c7a8981ee | ||
|
|
238e28ae41 | ||
|
|
68d5049963 | ||
|
|
624fa458ac | ||
|
|
309d575fc0 | ||
|
|
f7b4df13a7 | ||
|
|
13bae5c8d7 | ||
|
|
8a6b4d8e88 | ||
|
|
b67e1b5b2b | ||
|
|
d4e3dc0399 | ||
|
|
7f0adfa6a7 | ||
|
|
94b03b49d9 | ||
|
|
20d75fe041 | ||
|
|
307f3935e0 | ||
|
|
6901bafb02 | ||
|
|
e595dc2b27 | ||
|
|
ed2cf09ff3 | ||
|
|
bec736a894 | ||
|
|
1457360703 | ||
|
|
d8a0f2abb8 | ||
|
|
367f838371 | ||
|
|
741dd3ce84 | ||
|
|
0a12f389df | ||
|
|
8240c2fd57 | ||
|
|
38f7fe291e | ||
|
|
e4087efbf0 | ||
|
|
3051984fb9 | ||
|
|
eea2c90ea4 | ||
|
|
d52c23fc29 | ||
|
|
a1fb71ce65 | ||
|
|
6a5549081f | ||
|
|
68e24ee886 | ||
|
|
61d6b6287e | ||
|
|
7007c84577 | ||
|
|
fd928d9fea | ||
|
|
68b6aa85cd | ||
|
|
abbc07edb3 | ||
|
|
b42add310e | ||
|
|
98a5526e80 | ||
|
|
db86b3198e | ||
|
|
cd4f0b91dc | ||
|
|
a290db0491 | ||
|
|
92b0b883e6 | ||
|
|
9e621c0029 | ||
|
|
a251f3a09f | ||
|
|
0fdedfe5ba | ||
|
|
243a3e8521 | ||
|
|
b24a6f0894 | ||
|
|
57f51c741c | ||
|
|
65b8418af4 | ||
|
|
89ceee8741 | ||
|
|
64ec1a7135 | ||
|
|
3d632a94b1 | ||
|
|
fbd29ff78e | ||
|
|
86bff07f7d | ||
|
|
3abd5ce3ea | ||
|
|
0bf2086f16 | ||
|
|
40dc6647e9 | ||
|
|
f4e95a254e | ||
|
|
ba43be9e62 | ||
|
|
5b22ab29cf | ||
|
|
278d102064 |
60
.claude/commands/label-issue.md
Normal file
60
.claude/commands/label-issue.md
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
allowed-tools: Bash(gh label list:*),Bash(gh issue view:*),Bash(gh issue edit:*),Bash(gh search:*)
|
||||
description: Apply labels to GitHub issues
|
||||
---
|
||||
|
||||
You're an issue triage assistant for GitHub issues. Your task is to analyze the issue and select appropriate labels from the provided list.
|
||||
|
||||
IMPORTANT: Don't post any comments or messages to the issue. Your only action should be to apply labels.
|
||||
|
||||
Issue Information:
|
||||
|
||||
- REPO: ${{ github.repository }}
|
||||
- ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
|
||||
TASK OVERVIEW:
|
||||
|
||||
1. First, fetch the list of labels available in this repository by running: `gh label list`. Run exactly this command with nothing else.
|
||||
|
||||
2. Next, use gh commands to get context about the issue:
|
||||
|
||||
- Use `gh issue view ${{ github.event.issue.number }}` to retrieve the current issue's details
|
||||
- Use `gh search issues` to find similar issues that might provide context for proper categorization
|
||||
- You have access to these Bash commands:
|
||||
- Bash(gh label list:\*) - to get available labels
|
||||
- Bash(gh issue view:\*) - to view issue details
|
||||
- Bash(gh issue edit:\*) - to apply labels to the issue
|
||||
- Bash(gh search:\*) - to search for similar issues
|
||||
|
||||
3. Analyze the issue content, considering:
|
||||
|
||||
- The issue title and description
|
||||
- The type of issue (bug report, feature request, question, etc.)
|
||||
- Technical areas mentioned
|
||||
- Severity or priority indicators
|
||||
- User impact
|
||||
- Components affected
|
||||
|
||||
4. Select appropriate labels from the available labels list provided above:
|
||||
|
||||
- Choose labels that accurately reflect the issue's nature
|
||||
- Be specific but comprehensive
|
||||
- IMPORTANT: Add a priority label (P1, P2, or P3) based on the label descriptions from gh label list
|
||||
- Consider platform labels (android, ios) if applicable
|
||||
- If you find similar issues using gh search, consider using a "duplicate" label if appropriate. Only do so if the issue is a duplicate of another OPEN issue.
|
||||
|
||||
5. Apply the selected labels:
|
||||
- Use `gh issue edit` to apply your selected labels
|
||||
- DO NOT post any comments explaining your decision
|
||||
- DO NOT communicate directly with users
|
||||
- If no labels are clearly applicable, do not apply any labels
|
||||
|
||||
IMPORTANT GUIDELINES:
|
||||
|
||||
- Be thorough in your analysis
|
||||
- Only select labels from the provided list above
|
||||
- DO NOT post any comments to the issue
|
||||
- Your ONLY action should be to apply labels using gh issue edit
|
||||
- It's okay to not add any labels if none are clearly applicable
|
||||
|
||||
---
|
||||
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.
|
||||
276
.env.example
276
.env.example
@@ -1,17 +1,40 @@
|
||||
# MeshCore Hub - Docker Compose Environment Configuration
|
||||
# MeshCore Hub - Environment Configuration
|
||||
# Copy this file to .env and customize values
|
||||
#
|
||||
# Configuration is grouped by service. Most deployments only need:
|
||||
# - Common Settings (always required)
|
||||
# - MQTT Settings (always required)
|
||||
# - Interface Settings (for receiver/sender services)
|
||||
#
|
||||
# The Collector, API, and Web services typically run as a combined "core"
|
||||
# profile and share the same data directory.
|
||||
#
|
||||
# -----------------------------------------------------------------------------
|
||||
# QUICK START: Receiver/Sender Only
|
||||
# -----------------------------------------------------------------------------
|
||||
# For a minimal receiver or sender setup, you only need these settings:
|
||||
#
|
||||
# MQTT_HOST=your-mqtt-broker.example.com
|
||||
# MQTT_PORT=1883
|
||||
# MQTT_USERNAME=your_username
|
||||
# MQTT_PASSWORD=your_password
|
||||
# MQTT_TLS=false
|
||||
# SERIAL_PORT=/dev/ttyUSB0
|
||||
#
|
||||
# Serial ports are typically /dev/ttyUSB[0-9] or /dev/ttyACM[0-9] on Linux.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# ===================
|
||||
# Docker Image
|
||||
# ===================
|
||||
# =============================================================================
|
||||
# COMMON SETTINGS
|
||||
# =============================================================================
|
||||
# These settings apply to all services
|
||||
|
||||
# Docker image version tag to use
|
||||
# Options: latest, main, v1.0.0, etc.
|
||||
IMAGE_VERSION=latest
|
||||
|
||||
# ===================
|
||||
# Data & Seed Directories
|
||||
# ===================
|
||||
# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Base directory for runtime data (database, etc.)
|
||||
# Default: ./data (relative to docker-compose.yml location)
|
||||
@@ -19,7 +42,8 @@ IMAGE_VERSION=latest
|
||||
#
|
||||
# Structure:
|
||||
# ${DATA_HOME}/
|
||||
# └── meshcore.db # SQLite database
|
||||
# └── collector/
|
||||
# └── meshcore.db # SQLite database
|
||||
DATA_HOME=./data
|
||||
|
||||
# Directory containing seed data files for import
|
||||
@@ -32,43 +56,43 @@ DATA_HOME=./data
|
||||
# └── members.yaml # Network members for import
|
||||
SEED_HOME=./seed
|
||||
|
||||
# ===================
|
||||
# Common Settings
|
||||
# ===================
|
||||
# =============================================================================
|
||||
# MQTT SETTINGS
|
||||
# =============================================================================
|
||||
# MQTT broker connection settings for interface, collector, and API services
|
||||
|
||||
# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# ===================
|
||||
# MQTT Settings
|
||||
# ===================
|
||||
|
||||
# MQTT Broker connection (for interface/collector/api services)
|
||||
# When using the local MQTT broker (--profile mqtt), use "mqtt" as host
|
||||
# MQTT Broker host
|
||||
# When using the local MQTT broker (--profile mqtt), use "mqtt"
|
||||
# When using an external broker, set the hostname/IP
|
||||
MQTT_HOST=mqtt
|
||||
|
||||
# MQTT Broker port (default: 1883, or 8883 for TLS)
|
||||
MQTT_PORT=1883
|
||||
|
||||
# MQTT authentication (optional)
|
||||
MQTT_USERNAME=
|
||||
MQTT_PASSWORD=
|
||||
|
||||
# MQTT topic prefix for all MeshCore messages
|
||||
MQTT_PREFIX=meshcore
|
||||
|
||||
# Enable TLS/SSL for MQTT connection (default: false)
|
||||
# Enable TLS/SSL for MQTT connection
|
||||
# When enabled, uses TLS with system CA certificates (e.g., for Let's Encrypt)
|
||||
# Set to true for secure MQTT connections (port 8883)
|
||||
MQTT_TLS=false
|
||||
|
||||
# External port mappings for local MQTT broker (--profile mqtt only)
|
||||
MQTT_EXTERNAL_PORT=1883
|
||||
MQTT_WS_PORT=9001
|
||||
|
||||
# ===================
|
||||
# Interface Settings
|
||||
# ===================
|
||||
# =============================================================================
|
||||
# INTERFACE SETTINGS (Receiver/Sender)
|
||||
# =============================================================================
|
||||
# Settings for the MeshCore device interface services
|
||||
|
||||
# Serial port for receiver device
|
||||
SERIAL_PORT=/dev/ttyUSB0
|
||||
|
||||
# Serial port for sender device (if separate)
|
||||
# Serial port for sender device (if using separate device)
|
||||
SERIAL_PORT_SENDER=/dev/ttyUSB1
|
||||
|
||||
# Baud rate for serial communication
|
||||
@@ -83,55 +107,32 @@ MESHCORE_DEVICE_NAME=
|
||||
NODE_ADDRESS=
|
||||
NODE_ADDRESS_SENDER=
|
||||
|
||||
# ===================
|
||||
# API Settings
|
||||
# ===================
|
||||
# -------------------
|
||||
# Contact Cleanup Settings (RECEIVER mode only)
|
||||
# -------------------
|
||||
# Automatic removal of stale contacts from the MeshCore companion node
|
||||
|
||||
# External API port
|
||||
API_PORT=8000
|
||||
# Enable automatic removal of stale contacts from companion node
|
||||
CONTACT_CLEANUP_ENABLED=true
|
||||
|
||||
# API Keys for authentication (generate secure keys for production!)
|
||||
# Example: openssl rand -hex 32
|
||||
API_READ_KEY=
|
||||
API_ADMIN_KEY=
|
||||
# Remove contacts not advertised for this many days
|
||||
CONTACT_CLEANUP_DAYS=7
|
||||
|
||||
# ===================
|
||||
# Web Dashboard Settings
|
||||
# ===================
|
||||
# =============================================================================
|
||||
# COLLECTOR SETTINGS
|
||||
# =============================================================================
|
||||
# The collector subscribes to MQTT events and stores them in the database
|
||||
|
||||
# External web port
|
||||
WEB_PORT=8080
|
||||
|
||||
# Network Information (displayed on web dashboard)
|
||||
NETWORK_NAME=MeshCore Network
|
||||
NETWORK_CITY=
|
||||
NETWORK_COUNTRY=
|
||||
|
||||
# Radio configuration (comma-delimited)
|
||||
# Format: <profile>,<frequency>,<bandwidth>,<spreading_factor>,<coding_rate>,<tx_power>
|
||||
# Example: EU/UK Narrow,869.618MHz,62.5kHz,8,8,22dBm
|
||||
NETWORK_RADIO_CONFIG=
|
||||
|
||||
# Contact information
|
||||
NETWORK_CONTACT_EMAIL=
|
||||
NETWORK_CONTACT_DISCORD=
|
||||
NETWORK_CONTACT_GITHUB=
|
||||
|
||||
# Welcome text displayed on the homepage (plain text, optional)
|
||||
# If not set, a default welcome message is shown
|
||||
NETWORK_WELCOME_TEXT=
|
||||
|
||||
# ===================
|
||||
# -------------------
|
||||
# Webhook Settings
|
||||
# ===================
|
||||
# -------------------
|
||||
# Webhooks forward mesh events to external HTTP endpoints as POST requests
|
||||
|
||||
# Webhook for advertisement events (node discovery)
|
||||
# Events are sent as POST requests with JSON payload
|
||||
WEBHOOK_ADVERTISEMENT_URL=
|
||||
WEBHOOK_ADVERTISEMENT_SECRET=
|
||||
|
||||
# Webhook for all message events (channel and direct messages)
|
||||
# Use this for a single endpoint handling all messages
|
||||
WEBHOOK_MESSAGE_URL=
|
||||
WEBHOOK_MESSAGE_SECRET=
|
||||
|
||||
@@ -147,34 +148,155 @@ WEBHOOK_TIMEOUT=10.0
|
||||
WEBHOOK_MAX_RETRIES=3
|
||||
WEBHOOK_RETRY_BACKOFF=2.0
|
||||
|
||||
# ===================
|
||||
# -------------------
|
||||
# Data Retention Settings
|
||||
# ===================
|
||||
# -------------------
|
||||
# Automatic cleanup of old event data (advertisements, messages, telemetry, etc.)
|
||||
|
||||
# Enable automatic cleanup of old event data
|
||||
# When enabled, the collector runs periodic cleanup to delete old events
|
||||
# Default: true
|
||||
DATA_RETENTION_ENABLED=true
|
||||
|
||||
# Number of days to retain event data (advertisements, messages, telemetry, etc.)
|
||||
# Number of days to retain event data
|
||||
# Events older than this are deleted during cleanup
|
||||
# Default: 30 days
|
||||
DATA_RETENTION_DAYS=30
|
||||
|
||||
# Hours between automatic cleanup runs (applies to both events and nodes)
|
||||
# Default: 24 hours (once per day)
|
||||
# Hours between automatic cleanup runs
|
||||
# Applies to both event data and node cleanup
|
||||
DATA_RETENTION_INTERVAL_HOURS=24
|
||||
|
||||
# ===================
|
||||
# -------------------
|
||||
# Node Cleanup Settings
|
||||
# ===================
|
||||
# -------------------
|
||||
# Automatic removal of inactive nodes
|
||||
|
||||
# Enable automatic cleanup of inactive nodes
|
||||
# Nodes that haven't been seen (last_seen) for the specified period are removed
|
||||
# Nodes with last_seen=NULL (never seen on network) are NOT removed
|
||||
# Default: true
|
||||
NODE_CLEANUP_ENABLED=true
|
||||
|
||||
# Remove nodes not seen for this many days (based on last_seen field)
|
||||
# Default: 7 days
|
||||
NODE_CLEANUP_DAYS=7
|
||||
|
||||
# =============================================================================
|
||||
# API SETTINGS
|
||||
# =============================================================================
|
||||
# REST API for querying data and sending commands
|
||||
|
||||
# External API port
|
||||
API_PORT=8000
|
||||
|
||||
# API Keys for authentication
|
||||
# Generate secure keys for production: openssl rand -hex 32
|
||||
# Leave empty to disable authentication (not recommended for production)
|
||||
API_READ_KEY=
|
||||
API_ADMIN_KEY=
|
||||
|
||||
# -------------------
|
||||
# Prometheus Metrics
|
||||
# -------------------
|
||||
# Prometheus metrics endpoint exposed at /metrics on the API service
|
||||
|
||||
# Enable Prometheus metrics endpoint
|
||||
# Default: true
|
||||
METRICS_ENABLED=true
|
||||
|
||||
# Seconds to cache metrics output (reduces database load)
|
||||
# Default: 60
|
||||
METRICS_CACHE_TTL=60
|
||||
|
||||
# External Prometheus port (when using --profile metrics)
|
||||
PROMETHEUS_PORT=9090
|
||||
|
||||
# External Alertmanager port (when using --profile metrics)
|
||||
ALERTMANAGER_PORT=9093
|
||||
|
||||
# =============================================================================
|
||||
# WEB DASHBOARD SETTINGS
|
||||
# =============================================================================
|
||||
# Web interface for visualizing network status
|
||||
|
||||
# External web port
|
||||
WEB_PORT=8080
|
||||
|
||||
# API endpoint URL for the web dashboard
|
||||
# Default: http://localhost:8000
|
||||
# API_BASE_URL=http://localhost:8000
|
||||
|
||||
# API key for web dashboard queries (optional)
|
||||
# If API_READ_KEY is set on the API, provide it here
|
||||
# API_KEY=
|
||||
|
||||
# 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
|
||||
|
||||
# Locale/language for the web dashboard
|
||||
# Default: en
|
||||
# Supported: en (see src/meshcore_hub/web/static/locales/ for available translations)
|
||||
# WEB_LOCALE=en
|
||||
|
||||
# Auto-refresh interval in seconds for list pages (nodes, advertisements, messages)
|
||||
# Set to 0 to disable auto-refresh
|
||||
# Default: 30
|
||||
# WEB_AUTO_REFRESH_SECONDS=30
|
||||
|
||||
# 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
|
||||
# -------------------
|
||||
# Displayed on the web dashboard homepage
|
||||
|
||||
# Network domain name (optional)
|
||||
# NETWORK_DOMAIN=
|
||||
|
||||
# Network display name
|
||||
NETWORK_NAME=MeshCore Network
|
||||
|
||||
# Network location
|
||||
NETWORK_CITY=
|
||||
NETWORK_COUNTRY=
|
||||
|
||||
# Radio configuration (comma-delimited)
|
||||
# Format: <profile>,<frequency>,<bandwidth>,<spreading_factor>,<coding_rate>,<tx_power>
|
||||
# Example: EU/UK Narrow,869.618MHz,62.5kHz,SF8,CR8,22dBm
|
||||
NETWORK_RADIO_CONFIG=
|
||||
|
||||
# Welcome text displayed on the homepage (optional, plain text)
|
||||
# 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
|
||||
# -------------------
|
||||
# Contact links displayed in the footer
|
||||
|
||||
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
|
||||
61
.github/workflows/ci.yml
vendored
61
.github/workflows/ci.yml
vendored
@@ -1,53 +1,42 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
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.11"
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install black flake8 mypy
|
||||
pip install -e ".[dev]"
|
||||
|
||||
- name: Check formatting with black
|
||||
run: black --check src/ tests/
|
||||
|
||||
- name: Lint with flake8
|
||||
run: flake8 src/ tests/
|
||||
|
||||
- name: Type check with mypy
|
||||
run: mypy src/
|
||||
- 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.11"]
|
||||
|
||||
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: |
|
||||
@@ -59,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.11'
|
||||
uses: codecov/codecov-action@v5
|
||||
if: always()
|
||||
with:
|
||||
files: ./coverage.xml
|
||||
fail_ci_if_error: false
|
||||
@@ -71,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.11"
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
@@ -87,7 +76,7 @@ jobs:
|
||||
run: python -m build
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
8
.github/workflows/claude.yml
vendored
8
.github/workflows/claude.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -35,15 +35,9 @@ jobs:
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
21
.github/workflows/docker.yml
vendored
21
.github/workflows/docker.yml
vendored
@@ -2,11 +2,18 @@ name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
branches: [main]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "alembic/**"
|
||||
- "alembic.ini"
|
||||
- ".python-version"
|
||||
- "pyproject.toml"
|
||||
- "Dockerfile"
|
||||
- "docker-compose.yml"
|
||||
- ".github/workflows/docker.yml"
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
@@ -21,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
|
||||
@@ -50,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
|
||||
@@ -59,13 +66,13 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
SETUPTOOLS_SCM_PRETEND_VERSION=${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || format('0.0.0.dev0+g{0}', github.sha) }}
|
||||
BUILD_VERSION=${{ github.ref_name }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Test Docker image
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
docker build -t meshcore-hub-test --build-arg SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0.dev0+g${{ github.sha }} -f Dockerfile .
|
||||
docker build -t meshcore-hub-test --build-arg BUILD_VERSION=${{ github.ref_name }} -f Dockerfile .
|
||||
docker run --rm meshcore-hub-test --version
|
||||
docker run --rm meshcore-hub-test --help
|
||||
|
||||
27
.github/workflows/issue-triage.yml
vendored
Normal file
27
.github/workflows/issue-triage.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Claude Issue Triage
|
||||
description: Run Claude Code for issue triage in GitHub Actions
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
triage-issue:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Claude Code for Issue Triage
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: "/label-issue REPO: ${{ github.repository }} ISSUE_NUMBER${{ github.event.issue.number }}"
|
||||
allowed_non_write_users: "*"
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -218,4 +218,3 @@ __marimo__/
|
||||
# MeshCore Hub specific
|
||||
*.db
|
||||
meshcore.db
|
||||
src/meshcore_hub/_version.py
|
||||
|
||||
@@ -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.11
|
||||
args: ["--line-length=88"]
|
||||
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.11
|
||||
3.14
|
||||
|
||||
286
AGENTS.md
286
AGENTS.md
@@ -13,12 +13,18 @@ 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
|
||||
|
||||
MeshCore Hub is a Python 3.11+ monorepo for managing and orchestrating MeshCore mesh networks. It consists of five main components:
|
||||
MeshCore Hub is a Python 3.13+ monorepo for managing and orchestrating MeshCore mesh networks. It consists of five main components:
|
||||
|
||||
- **meshcore_interface**: Serial/USB interface to MeshCore companion nodes, publishes/subscribes to MQTT
|
||||
- **meshcore_collector**: Collects MeshCore events from MQTT and stores them in a database
|
||||
@@ -37,7 +43,7 @@ MeshCore Hub is a Python 3.11+ monorepo for managing and orchestrating MeshCore
|
||||
|
||||
| Category | Technology |
|
||||
|----------|------------|
|
||||
| Language | Python 3.11+ |
|
||||
| Language | Python 3.13+ |
|
||||
| Package Management | pip with pyproject.toml |
|
||||
| CLI Framework | Click |
|
||||
| Configuration | Pydantic Settings |
|
||||
@@ -46,7 +52,8 @@ MeshCore Hub is a Python 3.11+ 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 |
|
||||
@@ -274,18 +281,26 @@ meshcore-hub/
|
||||
│ │ ├── app.py # FastAPI app
|
||||
│ │ ├── auth.py # Authentication
|
||||
│ │ ├── dependencies.py
|
||||
│ │ ├── routes/ # API routes
|
||||
│ │ │ ├── members.py # Member CRUD endpoints
|
||||
│ │ │ └── ...
|
||||
│ │ └── templates/ # Dashboard HTML
|
||||
│ │ ├── metrics.py # Prometheus metrics endpoint
|
||||
│ │ └── routes/ # API routes
|
||||
│ │ ├── members.py # Member CRUD endpoints
|
||||
│ │ └── ...
|
||||
│ └── 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/
|
||||
@@ -297,11 +312,19 @@ meshcore-hub/
|
||||
│ ├── env.py
|
||||
│ └── versions/
|
||||
├── etc/
|
||||
│ └── mosquitto.conf # MQTT broker configuration
|
||||
│ ├── mosquitto.conf # MQTT broker configuration
|
||||
│ ├── prometheus/ # Prometheus configuration
|
||||
│ │ ├── prometheus.yml # Scrape and alerting config
|
||||
│ │ └── alerts.yml # Alert rules
|
||||
│ └── alertmanager/ # Alertmanager configuration
|
||||
│ └── alertmanager.yml # Routing and receiver config
|
||||
├── 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
|
||||
@@ -342,6 +365,25 @@ Examples:
|
||||
- JSON columns for flexible data (path_hashes, parsed_data, etc.)
|
||||
- Foreign keys reference nodes by UUID, not public_key
|
||||
|
||||
## Standard Node Tags
|
||||
|
||||
Node tags are flexible key-value pairs that allow custom metadata to be attached to nodes. While tags are completely optional and freeform, the following standard tag keys are recommended for consistent use across the web dashboard:
|
||||
|
||||
| Tag Key | Description | Usage |
|
||||
|---------|-------------|-------|
|
||||
| `name` | Node display name | Used as the primary display name throughout the UI (overrides the advertised name) |
|
||||
| `description` | Short description | Displayed as supplementary text under the node name |
|
||||
| `member_id` | Member identifier reference | Links the node to a network member (matches `member_id` in Members table) |
|
||||
| `lat` | GPS latitude override | Overrides node-reported latitude for map display |
|
||||
| `lon` | GPS longitude override | Overrides node-reported longitude for map display |
|
||||
| `elevation` | GPS elevation override | Overrides node-reported elevation |
|
||||
| `role` | Node role/purpose | Used for website presentation and filtering (e.g., "gateway", "repeater", "sensor") |
|
||||
|
||||
**Important Notes:**
|
||||
- All tags are optional - nodes can function without any tags
|
||||
- Tag keys are case-sensitive
|
||||
- The `member_id` tag should reference a valid `member_id` from the Members table
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
### Unit Tests
|
||||
@@ -418,13 +460,121 @@ 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)
|
||||
|
||||
### Internationalization (i18n)
|
||||
|
||||
The web dashboard supports internationalization via JSON translation files. The default language is English.
|
||||
|
||||
**Translation files location:** `src/meshcore_hub/web/static/locales/`
|
||||
|
||||
**Key files:**
|
||||
- `en.json` - English translations (reference implementation)
|
||||
- `languages.md` - Comprehensive translation reference guide for translators
|
||||
|
||||
**Using translations in JavaScript:**
|
||||
|
||||
Import the `t()` function from `components.js`:
|
||||
|
||||
```javascript
|
||||
import { t } from '../components.js';
|
||||
|
||||
// Simple translation
|
||||
const label = t('common.save'); // "Save"
|
||||
|
||||
// Translation with variable interpolation
|
||||
const title = t('common.add_entity', { entity: t('entities.node') }); // "Add Node"
|
||||
|
||||
// Composed patterns for consistency
|
||||
const emptyMsg = t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() }); // "No nodes found"
|
||||
```
|
||||
|
||||
**Translation architecture:**
|
||||
|
||||
1. **Entity-based composition:** Core entity names (`entities.*`) are referenced by composite patterns for consistency
|
||||
2. **Reusable patterns:** Common UI patterns (`common.*`) use `{{variable}}` interpolation for dynamic content
|
||||
3. **Separation of concerns:**
|
||||
- Keys without `_label` suffix = table headers (title case, no colon)
|
||||
- Keys with `_label` suffix = inline labels (sentence case, with colon)
|
||||
|
||||
**When adding/modifying translations:**
|
||||
|
||||
1. **Add new keys** to `en.json` following existing patterns:
|
||||
- Use composition when possible (reference `entities.*` in `common.*` patterns)
|
||||
- Group related keys by section (e.g., `admin_members.*`, `admin_node_tags.*`)
|
||||
- Use `{{variable}}` syntax for dynamic content
|
||||
|
||||
2. **Update `languages.md`** with:
|
||||
- Key name, English value, and usage context
|
||||
- Variable descriptions if using interpolation
|
||||
- Notes about HTML content or special formatting
|
||||
|
||||
3. **Add tests** in `tests/test_common/test_i18n.py`:
|
||||
- Test new interpolation patterns
|
||||
- Test required sections if adding new top-level sections
|
||||
- Test composed patterns with entity references
|
||||
|
||||
4. **Run i18n tests:**
|
||||
```bash
|
||||
pytest tests/test_common/test_i18n.py -v
|
||||
```
|
||||
|
||||
**Best practices:**
|
||||
|
||||
- **Avoid duplication:** Use `common.*` patterns instead of duplicating similar strings
|
||||
- **Compose with entities:** Reference `entities.*` keys in patterns rather than hardcoding entity names
|
||||
- **Preserve variables:** Keep `{{variable}}` placeholders unchanged when translating
|
||||
- **Test composition:** Verify patterns work with all entity types (singular/plural, lowercase/uppercase)
|
||||
- **Document context:** Always update `languages.md` so translators understand usage
|
||||
|
||||
**Example - adding a new entity and patterns:**
|
||||
|
||||
```javascript
|
||||
// 1. Add entity to en.json
|
||||
"entities": {
|
||||
"sensor": "Sensor"
|
||||
}
|
||||
|
||||
// 2. Use with existing common patterns
|
||||
t('common.add_entity', { entity: t('entities.sensor') }) // "Add Sensor"
|
||||
t('common.no_entity_found', { entity: t('entities.sensors').toLowerCase() }) // "No sensors found"
|
||||
|
||||
// 3. Update languages.md with context
|
||||
// 4. Add test to test_i18n.py
|
||||
```
|
||||
|
||||
**Translation loading:**
|
||||
|
||||
The i18n system (`src/meshcore_hub/common/i18n.py`) loads translations on startup:
|
||||
- Defaults to English (`en`)
|
||||
- Falls back to English for missing keys
|
||||
- Returns the key itself if translation not found
|
||||
|
||||
For full translation guidelines, see `src/meshcore_hub/web/static/locales/languages.md`.
|
||||
|
||||
### Adding a New Database Model
|
||||
|
||||
1. Create model in `common/models/`
|
||||
2. Export in `common/models/__init__.py`
|
||||
3. Create Alembic migration: `alembic revision --autogenerate -m "description"`
|
||||
3. Create Alembic migration: `meshcore-hub db revision --autogenerate -m "description"`
|
||||
4. Review and adjust migration file
|
||||
5. Test migration: `alembic upgrade head`
|
||||
5. Test migration: `meshcore-hub db upgrade`
|
||||
|
||||
### Running the Development Environment
|
||||
|
||||
@@ -446,7 +596,7 @@ pytest
|
||||
# Run specific component
|
||||
meshcore-hub api --reload
|
||||
meshcore-hub collector
|
||||
meshcore-hub interface --mode receiver --mock
|
||||
meshcore-hub interface receiver --mock
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
@@ -456,11 +606,21 @@ 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`)
|
||||
- `CONTENT_HOME` - Directory containing custom content (pages, media) (default: `./content`)
|
||||
- `MQTT_HOST`, `MQTT_PORT`, `MQTT_PREFIX` - MQTT broker connection
|
||||
- `DATABASE_URL` - SQLAlchemy database URL (default: `sqlite:///{DATA_HOME}/collector/meshcore.db`)
|
||||
- `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.
|
||||
- `WEB_AUTO_REFRESH_SECONDS` - Auto-refresh interval in seconds for list pages (default: `30`, `0` to disable)
|
||||
- `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.
|
||||
- `METRICS_ENABLED` - Enable Prometheus metrics endpoint at /metrics (default: `true`)
|
||||
- `METRICS_CACHE_TTL` - Seconds to cache metrics output (default: `60`)
|
||||
- `LOG_LEVEL` - Logging verbosity
|
||||
|
||||
The database defaults to `sqlite:///{DATA_HOME}/collector/meshcore.db` and does not typically need to be configured.
|
||||
|
||||
### Directory Structure
|
||||
|
||||
**Seed Data (`SEED_HOME`)** - Contains initial data files for database seeding:
|
||||
@@ -470,6 +630,31 @@ ${SEED_HOME}/
|
||||
└── members.yaml # Network members list
|
||||
```
|
||||
|
||||
**Custom Content (`CONTENT_HOME`)** - Contains custom pages and media for the web dashboard:
|
||||
```
|
||||
${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:
|
||||
```markdown
|
||||
---
|
||||
title: About Us # Browser tab title and nav link (not rendered on page)
|
||||
slug: about # URL path (default: filename without .md)
|
||||
menu_order: 10 # Nav sort order (default: 100, lower = earlier)
|
||||
---
|
||||
|
||||
# About Our Network
|
||||
|
||||
Markdown content here (include your own heading)...
|
||||
```
|
||||
|
||||
**Runtime Data (`DATA_HOME`)** - Contains runtime data (gitignored):
|
||||
```
|
||||
${DATA_HOME}/
|
||||
@@ -479,13 +664,23 @@ ${DATA_HOME}/
|
||||
|
||||
Services automatically create their subdirectories if they don't exist.
|
||||
|
||||
### Automatic Seeding
|
||||
### Seeding
|
||||
|
||||
The collector automatically imports seed data on startup if YAML files exist in `SEED_HOME`:
|
||||
The database can be seeded with node tags and network members from YAML files in `SEED_HOME`:
|
||||
- `node_tags.yaml` - Node tag definitions (keyed by public_key)
|
||||
- `members.yaml` - Network member definitions
|
||||
|
||||
Manual seeding can be triggered with: `meshcore-hub collector seed`
|
||||
**Important:** Seeding is NOT automatic and must be run explicitly. This prevents seed files from overwriting user changes made via the admin UI.
|
||||
|
||||
```bash
|
||||
# Native CLI
|
||||
meshcore-hub collector seed
|
||||
|
||||
# With Docker Compose
|
||||
docker compose --profile seed up
|
||||
```
|
||||
|
||||
**Note:** Once the admin UI is enabled (`WEB_ADMIN_ENABLED=true`), tags should be managed through the web interface rather than seed files.
|
||||
|
||||
### Webhook Configuration
|
||||
|
||||
@@ -536,6 +731,22 @@ When enabled, the collector automatically removes nodes where:
|
||||
|
||||
**Note:** Both event data and node cleanup run on the same schedule (DATA_RETENTION_INTERVAL_HOURS).
|
||||
|
||||
**Contact Cleanup (Interface RECEIVER):**
|
||||
|
||||
The interface RECEIVER mode can automatically remove stale contacts from the MeshCore companion node's contact database. This prevents the companion node from resyncing old/dead contacts back to the collector, freeing up memory on the device (typically limited to ~100 contacts).
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CONTACT_CLEANUP_ENABLED` | Enable automatic removal of stale contacts (default: true) |
|
||||
| `CONTACT_CLEANUP_DAYS` | Remove contacts not advertised for this many days (default: 7) |
|
||||
|
||||
When enabled, during each contact sync the receiver checks each contact's `last_advert` timestamp:
|
||||
- Contacts with `last_advert` older than `CONTACT_CLEANUP_DAYS` are removed from the device
|
||||
- Stale contacts are not published to MQTT (preventing collector database pollution)
|
||||
- Contacts without a `last_advert` timestamp are preserved (no removal without data)
|
||||
|
||||
This cleanup runs automatically whenever the receiver syncs contacts (on startup and after each advertisement event).
|
||||
|
||||
Manual cleanup can be triggered at any time with:
|
||||
```bash
|
||||
# Dry run to see what would be deleted
|
||||
@@ -559,9 +770,13 @@ Webhook payload structure:
|
||||
### Common Issues
|
||||
|
||||
1. **MQTT Connection Failed**: Check broker is running and `MQTT_HOST`/`MQTT_PORT` are correct
|
||||
2. **Database Migration Errors**: Ensure `DATABASE_URL` is correct, run `alembic upgrade head`
|
||||
2. **Database Migration Errors**: Ensure `DATA_HOME` is writable, run `meshcore-hub db upgrade`
|
||||
3. **Import Errors**: Ensure package is installed with `pip install -e .`
|
||||
4. **Type Errors**: Run `mypy src/` to check type annotations
|
||||
4. **Type Errors**: Run `pre-commit run --all-files` to check type annotations and other issues
|
||||
5. **NixOS greenlet errors**: On NixOS, the pre-built greenlet wheel may fail with `libstdc++.so.6` errors. Rebuild from source:
|
||||
```bash
|
||||
pip install --no-binary greenlet greenlet
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
@@ -622,8 +837,23 @@ 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
|
||||
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
|
||||
|
||||
The receiver syncs the device's contact database in two scenarios:
|
||||
|
||||
1. **Startup**: Initial sync when receiver starts
|
||||
2. **Advertisement Events**: Automatic sync triggered whenever an advertisement is received from the mesh
|
||||
|
||||
Since advertisements are typically received every ~20 minutes, contact sync happens automatically without manual intervention. Each contact from the device is published individually to MQTT:
|
||||
- Topic: `{prefix}/{device_public_key}/event/contact`
|
||||
- Payload: `{public_key, adv_name, type}`
|
||||
|
||||
This ensures the collector's database stays current with all nodes discovered on the mesh network.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
17
Dockerfile
17
Dockerfile
@@ -4,7 +4,7 @@
|
||||
# =============================================================================
|
||||
# Stage 1: Builder - Install dependencies and build package
|
||||
# =============================================================================
|
||||
FROM python:3.11-slim AS builder
|
||||
FROM python:3.14-slim AS builder
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
@@ -21,9 +21,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Build argument for version (set via CI or manually)
|
||||
ARG SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0+docker
|
||||
|
||||
# Copy project files
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
@@ -31,14 +28,18 @@ COPY src/ ./src/
|
||||
COPY alembic/ ./alembic/
|
||||
COPY alembic.ini ./
|
||||
|
||||
# Install the package with version from build arg
|
||||
RUN pip install --upgrade pip && \
|
||||
SETUPTOOLS_SCM_PRETEND_VERSION=${SETUPTOOLS_SCM_PRETEND_VERSION} pip install .
|
||||
# Build argument for version (set via CI or manually)
|
||||
ARG BUILD_VERSION=dev
|
||||
|
||||
# Set version in _version.py and install the package
|
||||
RUN sed -i "s|__version__ = \"dev\"|__version__ = \"${BUILD_VERSION}\"|" src/meshcore_hub/_version.py && \
|
||||
pip install --upgrade pip && \
|
||||
pip install .
|
||||
|
||||
# =============================================================================
|
||||
# Stage 2: Runtime - Final production image
|
||||
# =============================================================================
|
||||
FROM python:3.11-slim AS runtime
|
||||
FROM python:3.14-slim AS runtime
|
||||
|
||||
# Labels
|
||||
LABEL org.opencontainers.image.title="MeshCore Hub" \
|
||||
|
||||
540
README.md
540
README.md
@@ -1,6 +1,17 @@
|
||||
# MeshCore Hub
|
||||
|
||||
Python 3.11+ 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.13+ platform for managing and orchestrating MeshCore mesh networks.
|
||||
|
||||

|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Help Translate MeshCore Hub** 🌍
|
||||
>
|
||||
> We need volunteers to translate the web dashboard! Currently only English is available. Check out the [Translation Guide](src/meshcore_hub/web/static/locales/languages.md) to contribute a language pack. Partial translations welcome!
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -11,45 +22,49 @@ 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
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ MeshCore │ │ MeshCore │ │ MeshCore │
|
||||
│ Device 1 │ │ Device 2 │ │ Device 3 │
|
||||
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||
│ │ │
|
||||
│ Serial/USB │ Serial/USB │ Serial/USB
|
||||
│ │ │
|
||||
┌────────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐
|
||||
│ Interface │ │ Interface │ │ Interface │
|
||||
│ (RECEIVER) │ │ (RECEIVER) │ │ (SENDER) │
|
||||
└────────┬────────┘ └────────┬────────┘ └────────▲────────┘
|
||||
│ │ │
|
||||
│ Publish │ Publish │ Subscribe
|
||||
│ │ │
|
||||
└───────────┬───────────┴───────────────────────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ MQTT │
|
||||
│ Broker │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ Collector │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ Database │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ │
|
||||
┌──────▼──────┐ ┌───────▼───────┐
|
||||
│ API │◄──────│ Web Dashboard │
|
||||
└─────────────┘ └───────────────┘
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Devices["MeshCore Devices"]
|
||||
D1["Device 1"]
|
||||
D2["Device 2"]
|
||||
D3["Device 3"]
|
||||
end
|
||||
|
||||
subgraph Interfaces["Interface Layer"]
|
||||
I1["RECEIVER"]
|
||||
I2["RECEIVER"]
|
||||
I3["SENDER"]
|
||||
end
|
||||
|
||||
D1 -->|Serial| I1
|
||||
D2 -->|Serial| I2
|
||||
D3 -->|Serial| I3
|
||||
|
||||
I1 -->|Publish| MQTT
|
||||
I2 -->|Publish| MQTT
|
||||
MQTT -->|Subscribe| I3
|
||||
|
||||
MQTT["MQTT Broker"]
|
||||
|
||||
subgraph Backend["Backend Services"]
|
||||
Collector --> Database --> API
|
||||
end
|
||||
|
||||
MQTT --> Collector
|
||||
API --> Web["Web Dashboard"]
|
||||
|
||||
style Devices fill:none,stroke:#0288d1,stroke-width:2px
|
||||
style Interfaces fill:none,stroke:#f57c00,stroke-width:2px
|
||||
style Backend fill:none,stroke:#388e3c,stroke-width:2px
|
||||
style MQTT fill:none,stroke:#7b1fa2,stroke-width:3px
|
||||
style Collector fill:none,stroke:#388e3c,stroke-width:2px
|
||||
style Database fill:none,stroke:#c2185b,stroke-width:2px
|
||||
style API fill:none,stroke:#1976d2,stroke-width:2px
|
||||
style Web fill:none,stroke:#ffa000,stroke-width:2px
|
||||
```
|
||||
|
||||
## Features
|
||||
@@ -60,6 +75,7 @@ MeshCore Hub provides a complete solution for monitoring, collecting, and intera
|
||||
- **Command Dispatch**: Send messages and advertisements via the API
|
||||
- **Node Tagging**: Add custom metadata to nodes for organization
|
||||
- **Web Dashboard**: Visualize network status, node locations, and message history
|
||||
- **Internationalization**: Full i18n support with composable translation patterns
|
||||
- **Docker Ready**: Single image with all components, easy deployment
|
||||
|
||||
## Getting Started
|
||||
@@ -74,9 +90,13 @@ The quickest way to get started is running the entire stack on a single machine
|
||||
|
||||
**Steps:**
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/ipnet-mesh/meshcore-hub.git
|
||||
# Create a directory, download the Docker Compose file and
|
||||
# example environment configuration file
|
||||
|
||||
mkdir meshcore-hub
|
||||
cd meshcore-hub
|
||||
wget https://raw.githubusercontent.com/ipnet-mesh/meshcore-hub/refs/heads/main/docker-compose.yml
|
||||
wget https://raw.githubusercontent.com/ipnet-mesh/meshcore-hub/refs/heads/main/.env.example
|
||||
|
||||
# Copy and configure environment
|
||||
cp .env.example .env
|
||||
@@ -95,33 +115,34 @@ This starts all services: MQTT broker, collector, API, web dashboard, and the in
|
||||
|
||||
For larger deployments, you can separate receiver nodes from the central infrastructure. This allows multiple community members to contribute receiver coverage while hosting the backend centrally.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Community Members │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Raspberry Pi │ │ Raspberry Pi │ │ Any Linux │ │
|
||||
│ │ + MeshCore │ │ + MeshCore │ │ + MeshCore │ │
|
||||
│ │ Device │ │ Device │ │ Device │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ │ │ │
|
||||
│ │ receiver profile only │ │
|
||||
│ └──────────────────┼──────────────────┘ │
|
||||
│ │ │
|
||||
│ MQTT (port 1883) │
|
||||
│ │ │
|
||||
└────────────────────────────┼─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Community VPS / Server │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌───────────┐ ┌─────────┐ ┌──────────────┐ │
|
||||
│ │ MQTT │──▶│ Collector │──▶│ API │◀──│ Web Dashboard│ │
|
||||
│ │ Broker │ │ │ │ │ │ (public) │ │
|
||||
│ └──────────┘ └───────────┘ └─────────┘ └──────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Community["Community Members"]
|
||||
R1["Raspberry Pi + MeshCore"]
|
||||
R2["Raspberry Pi + MeshCore"]
|
||||
R3["Any Linux + MeshCore"]
|
||||
end
|
||||
|
||||
subgraph Server["Community VPS / Server"]
|
||||
MQTT["MQTT Broker"]
|
||||
Collector
|
||||
API
|
||||
Web["Web Dashboard (public)"]
|
||||
|
||||
MQTT --> Collector --> API
|
||||
API <--- Web
|
||||
end
|
||||
|
||||
R1 -->|MQTT port 1883| MQTT
|
||||
R2 -->|MQTT port 1883| MQTT
|
||||
R3 -->|MQTT port 1883| MQTT
|
||||
|
||||
style Community fill:none,stroke:#0288d1,stroke-width:2px
|
||||
style Server fill:none,stroke:#388e3c,stroke-width:2px
|
||||
style MQTT fill:none,stroke:#7b1fa2,stroke-width:3px
|
||||
style Collector fill:none,stroke:#388e3c,stroke-width:2px
|
||||
style API fill:none,stroke:#1976d2,stroke-width:2px
|
||||
style Web fill:none,stroke:#ffa000,stroke-width:2px
|
||||
```
|
||||
|
||||
**On each receiver node (Raspberry Pi, etc.):**
|
||||
@@ -149,33 +170,26 @@ This architecture allows:
|
||||
- Community members to contribute coverage with minimal setup
|
||||
- The central server to be hosted anywhere with internet access
|
||||
|
||||
## Quick Start
|
||||
## Deployment
|
||||
|
||||
### Using Docker Compose (Recommended)
|
||||
### Docker Compose Profiles
|
||||
|
||||
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) |
|
||||
| `mock` | interface-mock-receiver | Testing without hardware |
|
||||
| `migrate` | db-migrate | One-time database migration |
|
||||
| `seed` | seed | One-time seed data import |
|
||||
| `metrics` | prometheus, alertmanager | Prometheus metrics and alerting |
|
||||
|
||||
**Note:** Most deployments connect to an external MQTT broker. Add `--profile mqtt` only if you need a local broker.
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/ipnet-mesh/meshcore-hub.git
|
||||
cd meshcore-hub
|
||||
|
||||
# Copy and configure environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings (API keys, serial port, network info)
|
||||
|
||||
# Create database schema
|
||||
docker compose --profile migrate run --rm db-migrate
|
||||
|
||||
@@ -198,7 +212,7 @@ docker compose logs -f
|
||||
docker compose down
|
||||
```
|
||||
|
||||
#### Serial Device Access
|
||||
### Serial Device Access
|
||||
|
||||
For production with real MeshCore devices, ensure the serial port is accessible:
|
||||
|
||||
@@ -214,13 +228,25 @@ SERIAL_PORT=/dev/ttyUSB0
|
||||
SERIAL_PORT_SENDER=/dev/ttyUSB1 # If using separate sender device
|
||||
```
|
||||
|
||||
**Tip:** If USB devices reconnect as different numeric IDs (e.g., `/dev/ttyUSB0` becomes `/dev/ttyUSB1`), use the stable `/dev/serial/by-id/` path instead:
|
||||
|
||||
```bash
|
||||
# List available devices by ID
|
||||
ls -la /dev/serial/by-id/
|
||||
|
||||
# Example output:
|
||||
# usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_abc123-if00-port0 -> ../../ttyUSB0
|
||||
|
||||
# Configure using the stable ID
|
||||
SERIAL_PORT=/dev/serial/by-id/usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_abc123-if00-port0
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
|
||||
```bash
|
||||
# Create virtual environment
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # Linux/macOS
|
||||
# .venv\Scripts\activate # Windows
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install the package
|
||||
pip install -e ".[dev]"
|
||||
@@ -229,63 +255,12 @@ pip install -e ".[dev]"
|
||||
meshcore-hub db upgrade
|
||||
|
||||
# Start components (in separate terminals)
|
||||
meshcore-hub interface --mode receiver --port /dev/ttyUSB0
|
||||
meshcore-hub interface receiver --port /dev/ttyUSB0
|
||||
meshcore-hub collector
|
||||
meshcore-hub api
|
||||
meshcore-hub web
|
||||
```
|
||||
|
||||
## Updating an Existing Installation
|
||||
|
||||
To update MeshCore Hub to the latest version:
|
||||
|
||||
```bash
|
||||
# Navigate to your installation directory
|
||||
cd meshcore-hub
|
||||
|
||||
# Pull the latest code
|
||||
git pull
|
||||
|
||||
# Pull latest Docker images
|
||||
docker compose --profile all pull
|
||||
|
||||
# Recreate and restart services
|
||||
# For receiver/sender only installs:
|
||||
docker compose --profile receiver up -d --force-recreate
|
||||
|
||||
# For core services with MQTT:
|
||||
docker compose --profile mqtt --profile core up -d --force-recreate
|
||||
|
||||
# For core services without local MQTT:
|
||||
docker compose --profile core up -d --force-recreate
|
||||
|
||||
# For complete stack (all services):
|
||||
docker compose --profile mqtt --profile core --profile receiver up -d --force-recreate
|
||||
|
||||
# View logs to verify update
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
**Note:** Database migrations run automatically on collector startup, so no manual migration step is needed when using Docker.
|
||||
|
||||
For manual installations:
|
||||
|
||||
```bash
|
||||
# Pull latest code
|
||||
git pull
|
||||
|
||||
# Activate virtual environment
|
||||
source .venv/bin/activate
|
||||
|
||||
# Update dependencies
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run database migrations
|
||||
meshcore-hub db upgrade
|
||||
|
||||
# Restart your services
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All components are configured via environment variables. Create a `.env` file or export variables:
|
||||
@@ -295,30 +270,30 @@ All components are configured via environment variables. Create a `.env` file or
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `LOG_LEVEL` | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
|
||||
| `DATA_HOME` | `./data` | Base directory for runtime data |
|
||||
| `SEED_HOME` | `./seed` | Directory containing seed data files |
|
||||
| `MQTT_HOST` | `localhost` | MQTT broker hostname |
|
||||
| `MQTT_PORT` | `1883` | MQTT broker port |
|
||||
| `MQTT_USERNAME` | *(none)* | MQTT username (optional) |
|
||||
| `MQTT_PASSWORD` | *(none)* | MQTT password (optional) |
|
||||
| `MQTT_PREFIX` | `meshcore` | Topic prefix for all MQTT messages |
|
||||
| `MQTT_TLS` | `false` | Enable TLS/SSL for MQTT connection |
|
||||
|
||||
### Interface Settings
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `INTERFACE_MODE` | `RECEIVER` | Operating mode (RECEIVER or SENDER) |
|
||||
| `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) |
|
||||
| `MOCK_DEVICE` | `false` | Use mock device for testing |
|
||||
| `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 |
|
||||
|
||||
### Collector Settings
|
||||
### Webhooks
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | `sqlite:///{data_home}/collector/meshcore.db` | SQLAlchemy database URL |
|
||||
| `SEED_HOME` | `./seed` | Directory containing seed data files (node_tags.yaml, members.yaml) |
|
||||
|
||||
#### Webhook Configuration
|
||||
|
||||
The collector can forward events to external HTTP endpoints:
|
||||
The collector can forward certain events to external HTTP endpoints:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
@@ -327,7 +302,9 @@ The collector can forward 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 |
|
||||
@@ -341,6 +318,18 @@ Webhook payload format:
|
||||
}
|
||||
```
|
||||
|
||||
### Data Retention
|
||||
|
||||
The collector automatically cleans up old event data and inactive nodes:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATA_RETENTION_ENABLED` | `true` | Enable automatic cleanup of old events |
|
||||
| `DATA_RETENTION_DAYS` | `30` | Days to retain event data |
|
||||
| `DATA_RETENTION_INTERVAL_HOURS` | `24` | Hours between cleanup runs |
|
||||
| `NODE_CLEANUP_ENABLED` | `true` | Enable removal of inactive nodes |
|
||||
| `NODE_CLEANUP_DAYS` | `7` | Remove nodes not seen for this many days |
|
||||
|
||||
### API Settings
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -349,6 +338,8 @@ Webhook payload format:
|
||||
| `API_PORT` | `8000` | API port |
|
||||
| `API_READ_KEY` | *(none)* | Read-only API key |
|
||||
| `API_ADMIN_KEY` | *(none)* | Admin API key (required for commands) |
|
||||
| `METRICS_ENABLED` | `true` | Enable Prometheus metrics endpoint at `/metrics` |
|
||||
| `METRICS_CACHE_TTL` | `60` | Seconds to cache metrics output (reduces database load) |
|
||||
|
||||
### Web Dashboard Settings
|
||||
|
||||
@@ -357,62 +348,114 @@ Webhook payload format:
|
||||
| `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 |
|
||||
| `API_KEY` | *(none)* | API key for web dashboard queries (optional) |
|
||||
| `WEB_THEME` | `dark` | Default theme (`dark` or `light`). Users can override via theme toggle in navbar. |
|
||||
| `WEB_LOCALE` | `en` | Locale/language for the web dashboard (e.g., `en`, `es`, `fr`) |
|
||||
| `WEB_AUTO_REFRESH_SECONDS` | `30` | Auto-refresh interval in seconds for list pages (0 to disable) |
|
||||
| `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_DOMAIN` | *(none)* | Network domain name (optional) |
|
||||
| `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) |
|
||||
| `NETWORK_RADIO_CONFIG` | *(none)* | Radio config (comma-delimited: profile,freq,bw,sf,cr,power) |
|
||||
| `NETWORK_WELCOME_TEXT` | *(none)* | Custom welcome text for homepage |
|
||||
| `NETWORK_CONTACT_EMAIL` | *(none)* | Contact email address |
|
||||
| `NETWORK_CONTACT_DISCORD` | *(none)* | Discord server link |
|
||||
| `NETWORK_CONTACT_GITHUB` | *(none)* | GitHub repository URL |
|
||||
| `NETWORK_CONTACT_YOUTUBE` | *(none)* | YouTube channel URL |
|
||||
| `CONTENT_HOME` | `./content` | Directory containing custom content (pages/, media/) |
|
||||
|
||||
## CLI Reference
|
||||
#### Feature Flags
|
||||
|
||||
Control which pages are visible in the web dashboard. Disabled features are fully hidden: removed from navigation, return 404 on their routes, and excluded from sitemap/robots.txt.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `FEATURE_DASHBOARD` | `true` | Enable the `/dashboard` page |
|
||||
| `FEATURE_NODES` | `true` | Enable the `/nodes` pages (list, detail, short links) |
|
||||
| `FEATURE_ADVERTISEMENTS` | `true` | Enable the `/advertisements` page |
|
||||
| `FEATURE_MESSAGES` | `true` | Enable the `/messages` page |
|
||||
| `FEATURE_MAP` | `true` | Enable the `/map` page and `/map/data` endpoint |
|
||||
| `FEATURE_MEMBERS` | `true` | Enable the `/members` page |
|
||||
| `FEATURE_PAGES` | `true` | Enable custom markdown pages |
|
||||
|
||||
**Dependencies:** Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled.
|
||||
|
||||
### Custom Content
|
||||
|
||||
The web dashboard supports custom content including markdown pages and media files. Content is organized in subdirectories:
|
||||
|
||||
```
|
||||
content/
|
||||
├── pages/ # Custom markdown pages
|
||||
│ └── about.md
|
||||
└── media/ # Custom media files
|
||||
└── images/
|
||||
└── logo.svg # Custom logo (replaces favicon and navbar/home logo)
|
||||
```
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
# Show help
|
||||
meshcore-hub --help
|
||||
# Create content directory structure
|
||||
mkdir -p content/pages content/media
|
||||
|
||||
# Interface component
|
||||
meshcore-hub interface --mode receiver --port /dev/ttyUSB0
|
||||
meshcore-hub interface --mode receiver --device-name "Gateway Node" # Set device name
|
||||
meshcore-hub interface --mode sender --mock # Use mock device
|
||||
# Create a custom page
|
||||
cat > content/pages/about.md << 'EOF'
|
||||
---
|
||||
title: About Us
|
||||
slug: about
|
||||
menu_order: 10
|
||||
---
|
||||
|
||||
# Collector component
|
||||
meshcore-hub collector # Run collector (auto-seeds on startup)
|
||||
meshcore-hub collector seed # Import all seed data from SEED_HOME
|
||||
meshcore-hub collector import-tags # Import node tags from SEED_HOME/node_tags.yaml
|
||||
meshcore-hub collector import-tags /path/to/file.yaml # Import from specific file
|
||||
meshcore-hub collector import-members # Import members from SEED_HOME/members.yaml
|
||||
meshcore-hub collector import-members /path/to/file.yaml # Import from specific file
|
||||
# About Our Network
|
||||
|
||||
# API component
|
||||
meshcore-hub api --host 0.0.0.0 --port 8000
|
||||
Welcome to our MeshCore mesh network!
|
||||
|
||||
# Web dashboard
|
||||
meshcore-hub web --port 8080 --network-name "My Network"
|
||||
## Getting Started
|
||||
|
||||
# Database management
|
||||
meshcore-hub db upgrade # Run migrations
|
||||
meshcore-hub db downgrade # Rollback one migration
|
||||
meshcore-hub db current # Show current revision
|
||||
1. Get a compatible LoRa device
|
||||
2. Flash MeshCore firmware
|
||||
3. Configure your radio settings
|
||||
EOF
|
||||
```
|
||||
|
||||
**Frontmatter fields:**
|
||||
| Field | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `title` | Filename titlecased | Browser tab title and navigation link text (not rendered on page) |
|
||||
| `slug` | Filename without `.md` | URL path (e.g., `about` → `/pages/about`) |
|
||||
| `menu_order` | `100` | Sort order in navigation (lower = earlier) |
|
||||
|
||||
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 content directory:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (already configured)
|
||||
volumes:
|
||||
- ${CONTENT_HOME:-./content}:/content:ro
|
||||
environment:
|
||||
- CONTENT_HOME=/content
|
||||
```
|
||||
|
||||
## Seed Data
|
||||
|
||||
The collector supports seeding the database with node tags and network members on startup. Seed files are read from the `SEED_HOME` directory (default: `./seed`).
|
||||
The database can be seeded with node tags and network members from YAML files in the `SEED_HOME` directory (default: `./seed`).
|
||||
|
||||
### Automatic Seeding
|
||||
#### Running the Seed Process
|
||||
|
||||
When the collector starts, it automatically imports seed data from YAML files if they exist:
|
||||
- `{SEED_HOME}/node_tags.yaml` - Node tag definitions
|
||||
- `{SEED_HOME}/members.yaml` - Network member definitions
|
||||
|
||||
### Manual Seeding
|
||||
Seeding is a separate process and must be run explicitly:
|
||||
|
||||
```bash
|
||||
# Native CLI
|
||||
meshcore-hub collector seed
|
||||
|
||||
# With Docker Compose
|
||||
docker compose --profile seed up
|
||||
```
|
||||
|
||||
### Directory Structure
|
||||
This imports data from the following files (if they exist):
|
||||
- `{SEED_HOME}/node_tags.yaml` - Node tag definitions
|
||||
- `{SEED_HOME}/members.yaml` - Network member definitions
|
||||
|
||||
#### Directory Structure
|
||||
|
||||
```
|
||||
seed/ # SEED_HOME (seed data files)
|
||||
@@ -426,73 +469,62 @@ data/ # DATA_HOME (runtime data)
|
||||
|
||||
Example seed files are provided in `example/seed/`.
|
||||
|
||||
## Node Tags
|
||||
### Node Tags
|
||||
|
||||
Node tags allow you to attach custom metadata to nodes (e.g., location, role, owner). Tags are stored in the database and returned with node data via the API.
|
||||
|
||||
### Node Tags YAML Format
|
||||
#### Node Tags YAML Format
|
||||
|
||||
Tags are keyed by public key in YAML format:
|
||||
|
||||
```yaml
|
||||
# Each key is a 64-character hex public key
|
||||
0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef:
|
||||
friendly_name: Gateway Node
|
||||
name: Gateway Node
|
||||
description: Main network gateway
|
||||
role: gateway
|
||||
lat: 37.7749
|
||||
lon: -122.4194
|
||||
is_online: true
|
||||
member_id: alice
|
||||
|
||||
fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210:
|
||||
friendly_name: Oakland Repeater
|
||||
altitude: 150
|
||||
location:
|
||||
value: "37.8044,-122.2712"
|
||||
type: coordinate
|
||||
name: Oakland Repeater
|
||||
elevation: 150
|
||||
```
|
||||
|
||||
Tag values can be:
|
||||
- **YAML primitives** (auto-detected type): strings, numbers, booleans
|
||||
- **Explicit type** (for special types like coordinate):
|
||||
- **Explicit type** (when you need to force a specific type):
|
||||
```yaml
|
||||
location:
|
||||
value: "37.7749,-122.4194"
|
||||
type: coordinate
|
||||
altitude:
|
||||
value: "150"
|
||||
type: number
|
||||
```
|
||||
|
||||
Supported types: `string`, `number`, `boolean`, `coordinate`
|
||||
Supported types: `string`, `number`, `boolean`
|
||||
|
||||
### Import Tags Manually
|
||||
|
||||
```bash
|
||||
# Import from default location ({SEED_HOME}/node_tags.yaml)
|
||||
meshcore-hub collector import-tags
|
||||
|
||||
# Import from specific file
|
||||
meshcore-hub collector import-tags /path/to/node_tags.yaml
|
||||
|
||||
# Skip tags for nodes that don't exist
|
||||
meshcore-hub collector import-tags --no-create-nodes
|
||||
```
|
||||
|
||||
## Network Members
|
||||
### Network Members
|
||||
|
||||
Network members represent the people operating nodes in your network. Members can optionally be linked to nodes via their public key.
|
||||
|
||||
### Members YAML Format
|
||||
#### Members YAML Format
|
||||
|
||||
```yaml
|
||||
members:
|
||||
- name: John Doe
|
||||
callsign: N0CALL
|
||||
role: Network Operator
|
||||
description: Example member entry
|
||||
contact: john@example.com
|
||||
public_key: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
- member_id: walshie86
|
||||
name: Walshie
|
||||
callsign: Walshie86
|
||||
role: member
|
||||
description: IPNet Member
|
||||
- member_id: craig
|
||||
name: Craig
|
||||
callsign: M7XCN
|
||||
role: member
|
||||
description: IPNet Member
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `member_id` | Yes | Unique identifier for the member |
|
||||
| `name` | Yes | Member's display name |
|
||||
| `callsign` | No | Amateur radio callsign |
|
||||
| `role` | No | Member's role in the network |
|
||||
@@ -500,44 +532,6 @@ members:
|
||||
| `contact` | No | Contact information |
|
||||
| `public_key` | No | Associated node public key (64-char hex) |
|
||||
|
||||
### Import Members Manually
|
||||
|
||||
```bash
|
||||
# Import from default location ({SEED_HOME}/members.yaml)
|
||||
meshcore-hub collector import-members
|
||||
|
||||
# Import from specific file
|
||||
meshcore-hub collector import-members /path/to/members.yaml
|
||||
```
|
||||
|
||||
### Managing Tags via API
|
||||
|
||||
Tags can also be managed via the REST API:
|
||||
|
||||
```bash
|
||||
# List tags for a node
|
||||
curl http://localhost:8000/api/v1/nodes/{public_key}/tags
|
||||
|
||||
# Create a tag (requires admin key)
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer <API_ADMIN_KEY>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"key": "location", "value": "Building A"}' \
|
||||
http://localhost:8000/api/v1/nodes/{public_key}/tags
|
||||
|
||||
# Update a tag
|
||||
curl -X PUT \
|
||||
-H "Authorization: Bearer <API_ADMIN_KEY>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"value": "Building B"}' \
|
||||
http://localhost:8000/api/v1/nodes/{public_key}/tags/location
|
||||
|
||||
# Delete a tag
|
||||
curl -X DELETE \
|
||||
-H "Authorization: Bearer <API_ADMIN_KEY>" \
|
||||
http://localhost:8000/api/v1/nodes/{public_key}/tags/location
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
When running, the API provides interactive documentation at:
|
||||
@@ -550,6 +544,7 @@ Health check endpoints are also available:
|
||||
|
||||
- **Health**: http://localhost:8000/health
|
||||
- **Ready**: http://localhost:8000/health/ready (includes database check)
|
||||
- **Metrics**: http://localhost:8000/metrics (Prometheus format)
|
||||
|
||||
### Authentication
|
||||
|
||||
@@ -573,15 +568,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
|
||||
|
||||
@@ -618,14 +619,8 @@ pytest -k "test_list"
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
black src/ tests/
|
||||
|
||||
# Lint
|
||||
flake8 src/ tests/
|
||||
|
||||
# Type check
|
||||
mypy src/
|
||||
# Run all code quality checks (formatting, linting, type checking)
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
### Creating Database Migrations
|
||||
@@ -651,14 +646,27 @@ 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)
|
||||
│ └── locales/ # Translation files (en.json, languages.md)
|
||||
├── tests/ # Test suite
|
||||
├── alembic/ # Database migrations
|
||||
├── etc/ # Configuration files (mosquitto.conf)
|
||||
├── example/ # Example files for testing
|
||||
│ └── seed/ # Example seed data files
|
||||
│ ├── node_tags.yaml # Example node tags
|
||||
│ └── members.yaml # Example network members
|
||||
├── etc/ # Configuration files (MQTT, Prometheus, Alertmanager)
|
||||
├── example/ # Example files for reference
|
||||
│ ├── seed/ # Example seed data files
|
||||
│ │ ├── node_tags.yaml # Example node tags
|
||||
│ │ └── members.yaml # Example network members
|
||||
│ └── content/ # Example custom content
|
||||
│ ├── pages/ # Example custom pages
|
||||
│ │ └── join.md # Example join page
|
||||
│ └── media/ # Example media files
|
||||
│ └── images/ # Custom images
|
||||
├── seed/ # Seed data directory (SEED_HOME, copy from example/seed/)
|
||||
├── 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
|
||||
@@ -682,7 +690,7 @@ meshcore-hub/
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Make your changes
|
||||
4. Run tests and linting (`pytest && black . && flake8`)
|
||||
4. Run tests and quality checks (`pytest && pre-commit run --all-files`)
|
||||
5. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
6. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
7. Open a Pull Request
|
||||
@@ -691,6 +699,8 @@ meshcore-hub/
|
||||
|
||||
This project is licensed under the GNU General Public License v3.0 or later (GPL-3.0-or-later). See [LICENSE](LICENSE) for details.
|
||||
|
||||
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [MeshCore](https://meshcore.dev/) - The mesh networking protocol
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Make Node.last_seen nullable
|
||||
|
||||
Revision ID: 0b944542ccd8
|
||||
Revises: 005
|
||||
Create Date: 2025-12-08 00:07:49.891245+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0b944542ccd8"
|
||||
down_revision: Union[str, None] = "005"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# Make Node.last_seen nullable since nodes from contact sync
|
||||
# haven't actually been "seen" on the mesh yet
|
||||
with op.batch_alter_table("nodes", schema=None) as batch_op:
|
||||
batch_op.alter_column("last_seen", existing_type=sa.DATETIME(), nullable=True)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# Revert Node.last_seen to non-nullable
|
||||
# Note: This will fail if there are NULL values in last_seen
|
||||
with op.batch_alter_table("nodes", schema=None) as batch_op:
|
||||
batch_op.alter_column("last_seen", existing_type=sa.DATETIME(), nullable=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Add member_id field to members table
|
||||
|
||||
Revision ID: 03b9b2451bd9
|
||||
Revises: 0b944542ccd8
|
||||
Create Date: 2025-12-08 14:34:30.337799+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "03b9b2451bd9"
|
||||
down_revision: Union[str, None] = "0b944542ccd8"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("advertisements", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_advertisements_event_hash_unique"))
|
||||
batch_op.create_unique_constraint(
|
||||
"uq_advertisements_event_hash", ["event_hash"]
|
||||
)
|
||||
|
||||
with op.batch_alter_table("members", schema=None) as batch_op:
|
||||
# Add member_id as nullable first to handle existing data
|
||||
batch_op.add_column(
|
||||
sa.Column("member_id", sa.String(length=100), nullable=True)
|
||||
)
|
||||
|
||||
# Generate member_id for existing members based on their name
|
||||
# Convert name to lowercase and replace spaces with underscores
|
||||
connection = op.get_bind()
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"UPDATE members SET member_id = LOWER(REPLACE(name, ' ', '_')) WHERE member_id IS NULL"
|
||||
)
|
||||
)
|
||||
|
||||
with op.batch_alter_table("members", schema=None) as batch_op:
|
||||
# Now make it non-nullable and add unique index
|
||||
batch_op.alter_column("member_id", nullable=False)
|
||||
batch_op.drop_index(batch_op.f("ix_members_name"))
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_members_member_id"), ["member_id"], unique=True
|
||||
)
|
||||
|
||||
with op.batch_alter_table("messages", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_messages_event_hash_unique"))
|
||||
batch_op.create_unique_constraint("uq_messages_event_hash", ["event_hash"])
|
||||
|
||||
with op.batch_alter_table("nodes", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_nodes_public_key"))
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_nodes_public_key"), ["public_key"], unique=True
|
||||
)
|
||||
|
||||
with op.batch_alter_table("telemetry", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_telemetry_event_hash_unique"))
|
||||
batch_op.create_unique_constraint("uq_telemetry_event_hash", ["event_hash"])
|
||||
|
||||
with op.batch_alter_table("trace_paths", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_trace_paths_event_hash_unique"))
|
||||
batch_op.create_unique_constraint("uq_trace_paths_event_hash", ["event_hash"])
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("trace_paths", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("uq_trace_paths_event_hash", type_="unique")
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_trace_paths_event_hash_unique"), ["event_hash"], unique=1
|
||||
)
|
||||
|
||||
with op.batch_alter_table("telemetry", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("uq_telemetry_event_hash", type_="unique")
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_telemetry_event_hash_unique"), ["event_hash"], unique=1
|
||||
)
|
||||
|
||||
with op.batch_alter_table("nodes", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_nodes_public_key"))
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_nodes_public_key"), ["public_key"], unique=False
|
||||
)
|
||||
|
||||
with op.batch_alter_table("messages", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("uq_messages_event_hash", type_="unique")
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_messages_event_hash_unique"), ["event_hash"], unique=1
|
||||
)
|
||||
|
||||
with op.batch_alter_table("members", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_members_member_id"))
|
||||
batch_op.create_index(batch_op.f("ix_members_name"), ["name"], unique=False)
|
||||
batch_op.drop_column("member_id")
|
||||
|
||||
with op.batch_alter_table("advertisements", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("uq_advertisements_event_hash", type_="unique")
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_advertisements_event_hash_unique"), ["event_hash"], unique=1
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Remove member_nodes table
|
||||
|
||||
Revision ID: aa1162502616
|
||||
Revises: 03b9b2451bd9
|
||||
Create Date: 2025-12-08 15:04:37.260923+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "aa1162502616"
|
||||
down_revision: Union[str, None] = "03b9b2451bd9"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop the member_nodes table
|
||||
# Nodes are now associated with members via a 'member_id' tag on the node
|
||||
op.drop_table("member_nodes")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreate the member_nodes table if needed for rollback
|
||||
op.create_table(
|
||||
"member_nodes",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("member_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("public_key", sa.String(length=64), nullable=False),
|
||||
sa.Column("node_role", sa.String(length=50), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["member_id"],
|
||||
["members.id"],
|
||||
name=op.f("fk_member_nodes_member_id_members"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_member_nodes")),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_member_nodes_member_id"), "member_nodes", ["member_id"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_member_nodes_public_key"), "member_nodes", ["public_key"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
"ix_member_nodes_member_public_key",
|
||||
"member_nodes",
|
||||
["member_id", "public_key"],
|
||||
unique=False,
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""add lat lon columns to nodes
|
||||
|
||||
Revision ID: 4e2e787a1660
|
||||
Revises: aa1162502616
|
||||
Create Date: 2026-01-09 20:04:04.273741+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "4e2e787a1660"
|
||||
down_revision: Union[str, None] = "aa1162502616"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("nodes", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("lat", sa.Float(), nullable=True))
|
||||
batch_op.add_column(sa.Column("lon", sa.Float(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("nodes", schema=None) as batch_op:
|
||||
batch_op.drop_column("lon")
|
||||
batch_op.drop_column("lat")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,70 +0,0 @@
|
||||
# IPNet Network Members
|
||||
members:
|
||||
- name: Louis
|
||||
callsign: Louis
|
||||
role: admin
|
||||
description: IPNet Founder
|
||||
nodes:
|
||||
# ip2-rep01
|
||||
- public_key: 2337484665ced7e210007e9fd9db98ced0a24a6eab8b4cbe3a06b3a1cea33ca1
|
||||
node_role: repeater
|
||||
# ip2-rep02
|
||||
- public_key: 8cb01fff1afc099055af418ce5fc5e60384df9ff763c25dd7e6a5e0922e8df90
|
||||
node_role: repeater
|
||||
# ip2-rep03
|
||||
- public_key: 5b565df747913358e24d890b2227de9c35d09763746b6ec326c15ebbf9b8be3b
|
||||
node_role: repeater
|
||||
# ip2-sol01
|
||||
- public_key: 87eb9487a1a4351e986e55627b2d09c4da61f94d080eaf4d7129caef89886e25
|
||||
node_role: repeater
|
||||
# personal chat node
|
||||
- public_key: c6e0d85528b4b5d7f53aa7dded2b7e0b9c8f8a5c00acfaad47476ef5f3c7dc47
|
||||
node_role: chat
|
||||
- name: Mark
|
||||
callsign: Mark
|
||||
role: member
|
||||
description: IPNet Member
|
||||
nodes:
|
||||
- public_key: 22309435fbd9dd1f14870a1895dc854779f6b2af72b08542f6105d264a493ebe
|
||||
node_role: repeater
|
||||
- public_key: 9135986b83815ada92883358435cc6528c7db60cb647f9b6547739a1ce5eb1c8
|
||||
node_role: repeater
|
||||
- public_key: 2a4f89e766dfa1758e35a69962c1f6d352b206a5e3562a589155a3ebfe7fc2bb
|
||||
node_role: repeater
|
||||
- public_key: e790b73b2d6e377dd0f575c847f3ef42232f610eb9a19af57083fc4f647309ac
|
||||
node_role: repeater
|
||||
- public_key: d3c20d962f7384c111fbafad6fbc1c1dc0e5c3ce802fb3ee11020e8d8207ed3a
|
||||
node_role: repeater
|
||||
- public_key: cd4497d3c2fa2a1df565ae9d1cb8bf87aeaded34059421b63abeaec203f9eda8
|
||||
node_role: repeater
|
||||
- public_key: b00ce9d218203e96d8557a4d59e06f5de59bbc4dcc4df9c870079d2cb8b5bd80
|
||||
node_role: repeater
|
||||
- public_key: 69fb8431e7ab307513797544fab99ce53ce24c46ec2d3a11767fe70f2ca37b23
|
||||
node_role: repeater
|
||||
- name: CCZ
|
||||
callsign: CCZ
|
||||
role: member
|
||||
nodes:
|
||||
- public_key: e334ec5475789d542ed9e692fbeef7444a371fcc05adcbda1f47ba6a3191b459
|
||||
node_role: repeater
|
||||
- public_key: cc15fb33e98f2e098a543f516f770dc3061a1a6b30f79b84780663bf68ae6b53
|
||||
node_role: repeater
|
||||
- public_key: 20ed75ffc0f9777951716bb3d308d7f041fd2ad32fe2e998e600d0361e1fe2ac
|
||||
node_role: repeater
|
||||
description: IPNet Member
|
||||
- name: Walshie
|
||||
callsign: Walshie86
|
||||
role: member
|
||||
description: IPNet Member
|
||||
nodes:
|
||||
- public_key: bd7b5ac75f660675b39f368e1dbb6d1dbcefd8bd7a170e21a942954f67c8bf52
|
||||
node_role: repeater
|
||||
- public_key: 9cf300c40112ea34d0a59858270948b27ab6cd87e840de338f3ca782c17537b2
|
||||
node_role: repeater
|
||||
- name: Craig
|
||||
callsign: M7XCN
|
||||
role: member
|
||||
description: IPNet Member
|
||||
nodes:
|
||||
- public_key: 8accb6d0189ccaffb745ba54793e7fe3edd515edb45554325d957e48c1b9f3b3
|
||||
node_role: repeater
|
||||
@@ -1,247 +0,0 @@
|
||||
# IPNet Network Node Tags
|
||||
# Uses YAML primitives: numbers, booleans, and strings are auto-detected
|
||||
|
||||
# IP2 Area Nodes
|
||||
2337484665ced7e210007e9fd9db98ced0a24a6eab8b4cbe3a06b3a1cea33ca1:
|
||||
friendly_name: IP2 Repeater 1
|
||||
node_id: ip2-rep01.ipnt.uk
|
||||
member_id: louis
|
||||
area: IP2
|
||||
lat: 52.0357627
|
||||
lon: 1.132079
|
||||
location_description: Fountains Road
|
||||
hardware: Heltec V3
|
||||
antenna: Paradar 8.5dBi Omni
|
||||
elevation: 31
|
||||
role: infra
|
||||
|
||||
8cb01fff1afc099055af418ce5fc5e60384df9ff763c25dd7e6a5e0922e8df90:
|
||||
friendly_name: IP2 Repeater 2
|
||||
node_id: ip2-rep02.ipnt.uk
|
||||
member_id: louis
|
||||
area: IP2
|
||||
lat: 52.0390682
|
||||
lon: 1.1304141
|
||||
location_description: Belstead Road
|
||||
hardware: Heltec V3
|
||||
antenna: McGill 6dBi Omni
|
||||
elevation: 44
|
||||
role: infra
|
||||
|
||||
5b565df747913358e24d890b2227de9c35d09763746b6ec326c15ebbf9b8be3b:
|
||||
friendly_name: IP2 Repeater 3
|
||||
node_id: ip2-rep03.ipnt.uk
|
||||
member_id: louis
|
||||
area: IP2
|
||||
lat: 52.046356
|
||||
lon: 1.134661
|
||||
location_description: Birkfield Drive
|
||||
hardware: Heltec V3
|
||||
antenna: Paradar 8.5dBi Omni
|
||||
elevation: 52
|
||||
role: infra
|
||||
|
||||
780d0939f90b22d3bd7cbedcaf4e8d468a12c01886ab24b8cfa11eab2f5516c5:
|
||||
friendly_name: IP2 Integration 1
|
||||
node_id: ip2-int01.ipnt.uk
|
||||
member_id: louis
|
||||
area: IP2
|
||||
lat: 52.0354539
|
||||
lon: 1.1295338
|
||||
location_description: Fountains Road
|
||||
hardware: Heltec V3
|
||||
antenna: Generic 5dBi Whip
|
||||
elevation: 25
|
||||
role: infra
|
||||
|
||||
30121dc60362c633c457ffa18f49b3e1d6823402c33709f32d7df70612250b96:
|
||||
friendly_name: MeshBot
|
||||
node_id: bot.ipnt.uk
|
||||
member_id: louis
|
||||
area: IP2
|
||||
lat: 52.0354539
|
||||
lon: 1.1295338
|
||||
location_description: Fountains Road
|
||||
hardware: Heltec V3
|
||||
antenna: Generic 5dBi Whip
|
||||
elevation: 25
|
||||
role: infra
|
||||
|
||||
# IP3 Area Nodes
|
||||
9135986b83815ada92883358435cc6528c7db60cb647f9b6547739a1ce5eb1c8:
|
||||
friendly_name: IP3 Repeater 1
|
||||
node_id: ip3-rep01.ipnt.uk
|
||||
member_id: markab
|
||||
area: IP3
|
||||
lat: 52.045803
|
||||
lon: 1.204416
|
||||
location_description: Brokehall
|
||||
hardware: Heltec V3
|
||||
antenna: Paradar 8.5dBi Omni
|
||||
elevation: 42
|
||||
role: infra
|
||||
|
||||
e334ec5475789d542ed9e692fbeef7444a371fcc05adcbda1f47ba6a3191b459:
|
||||
friendly_name: IP3 Repeater 2
|
||||
node_id: ip3-rep02.ipnt.uk
|
||||
member_id: ccz
|
||||
area: IP3
|
||||
lat: 52.03297
|
||||
lon: 1.17543
|
||||
location_description: Morland Road Allotments
|
||||
hardware: Heltec T114
|
||||
antenna: Unknown
|
||||
elevation: 39
|
||||
role: infra
|
||||
|
||||
cc15fb33e98f2e098a543f516f770dc3061a1a6b30f79b84780663bf68ae6b53:
|
||||
friendly_name: IP3 Repeater 3
|
||||
node_id: ip3-rep03.ipnt.uk
|
||||
member_id: ccz
|
||||
area: IP3
|
||||
lat: 52.04499
|
||||
lon: 1.18149
|
||||
location_description: Hatfield Road
|
||||
hardware: Heltec V3
|
||||
antenna: Unknown
|
||||
elevation: 39
|
||||
role: infra
|
||||
|
||||
22309435fbd9dd1f14870a1895dc854779f6b2af72b08542f6105d264a493ebe:
|
||||
friendly_name: IP3 Integration 1
|
||||
node_id: ip3-int01.ipnt.uk
|
||||
member_id: markab
|
||||
area: IP3
|
||||
lat: 52.045773
|
||||
lon: 1.212808
|
||||
location_description: Brokehall
|
||||
hardware: Heltec V3
|
||||
antenna: Generic 3dBi Whip
|
||||
elevation: 37
|
||||
role: infra
|
||||
|
||||
2a4f89e766dfa1758e35a69962c1f6d352b206a5e3562a589155a3ebfe7fc2bb:
|
||||
friendly_name: IP3 Repeater 4
|
||||
node_id: ip3-rep04.ipnt.uk
|
||||
member_id: markab
|
||||
area: IP3
|
||||
lat: 52.046383
|
||||
lon: 1.174542
|
||||
location_description: Holywells
|
||||
hardware: Sensecap Solar
|
||||
antenna: Paradar 6.5dbi Omni
|
||||
elevation: 21
|
||||
role: infra
|
||||
|
||||
e790b73b2d6e377dd0f575c847f3ef42232f610eb9a19af57083fc4f647309ac:
|
||||
friendly_name: IP3 Repeater 5
|
||||
node_id: ip3-rep05.ipnt.uk
|
||||
member_id: markab
|
||||
area: IP3
|
||||
lat: 52.05252
|
||||
lon: 1.17034
|
||||
location_description: Back Hamlet
|
||||
hardware: Heltec T114
|
||||
antenna: Paradar 6.5dBi Omni
|
||||
elevation: 38
|
||||
role: infra
|
||||
|
||||
20ed75ffc0f9777951716bb3d308d7f041fd2ad32fe2e998e600d0361e1fe2ac:
|
||||
friendly_name: IP3 Repeater 6
|
||||
node_id: ip3-rep06.ipnt.uk
|
||||
member_id: ccz
|
||||
area: IP3
|
||||
lat: 52.04893
|
||||
lon: 1.18965
|
||||
location_description: Dover Road
|
||||
hardware: Unknown
|
||||
antenna: Generic 5dBi Whip
|
||||
elevation: 38
|
||||
role: infra
|
||||
|
||||
69fb8431e7ab307513797544fab99ce53ce24c46ec2d3a11767fe70f2ca37b23:
|
||||
friendly_name: IP3 Test Repeater 1
|
||||
node_id: ip3-tst01.ipnt.uk
|
||||
member_id: markab
|
||||
area: IP3
|
||||
lat: 52.041869
|
||||
lon: 1.204789
|
||||
location_description: Brokehall
|
||||
hardware: Station G2
|
||||
antenna: McGill 10dBi Panel
|
||||
elevation: 37
|
||||
role: infra
|
||||
|
||||
ebb16e6c328b3f2fa5bc46a8c3efc8e9ad1960ee49a76dfa85abddbf7911e2ca:
|
||||
friendly_name: IP3 Integration 1
|
||||
node_id: ip3-int01.ipnt.uk
|
||||
member_id: markab
|
||||
area: IP3
|
||||
location_description: Morland Road Allotments
|
||||
role: infra
|
||||
|
||||
# IP4 Area Nodes
|
||||
c464e725906e956b0cc113f4eb3ae320db66209d0b7cf1924e258b0f86147cae:
|
||||
friendly_name: IP4 Repeater 1
|
||||
node_id: ip4-rep01.ipnt.uk
|
||||
member_id: markab
|
||||
area: IP4
|
||||
lat: 52.052445
|
||||
lon: 1.156882
|
||||
location_description: Wine Rack
|
||||
hardware: Heltec T114
|
||||
antenna: Generic 5dbi Whip
|
||||
elevation: 50
|
||||
role: infra
|
||||
|
||||
cd4497d3c2fa2a1df565ae9d1cb8bf87aeaded34059421b63abeaec203f9eda8:
|
||||
friendly_name: IP4 Repeater 2
|
||||
node_id: ip4-rep02.ipnt.uk
|
||||
member_id: markab
|
||||
area: IP4
|
||||
lat: 52.06217
|
||||
lon: 1.18332
|
||||
location_description: Rushmere Road
|
||||
hardware: Heltec V3
|
||||
antenna: Paradar 5dbi Whip
|
||||
elevation: 35
|
||||
role: infra
|
||||
|
||||
8accb6d0189ccaffb745ba54793e7fe3edd515edb45554325d957e48c1b9f3b3:
|
||||
friendly_name: IP4 Repeater 3
|
||||
node_id: ip4-rep03.ipnt.uk
|
||||
member_id: craig
|
||||
area: IP4
|
||||
lat: 52.058
|
||||
lon: 1.165
|
||||
location_description: IP4 Area
|
||||
hardware: Heltec v3
|
||||
antenna: Generic Whip
|
||||
elevation: 30
|
||||
role: infra
|
||||
|
||||
# IP8 Area Nodes
|
||||
bd7b5ac75f660675b39f368e1dbb6d1dbcefd8bd7a170e21a942954f67c8bf52:
|
||||
friendly_name: IP8 Repeater 1
|
||||
node_id: rep01.ip8.ipnt.uk
|
||||
member_id: walshie86
|
||||
area: IP8
|
||||
lat: 52.033684
|
||||
lon: 1.118384
|
||||
location_description: Grove Hill
|
||||
hardware: Heltec V3
|
||||
antenna: McGill 3dBi Omni
|
||||
elevation: 13
|
||||
role: infra
|
||||
|
||||
9cf300c40112ea34d0a59858270948b27ab6cd87e840de338f3ca782c17537b2:
|
||||
friendly_name: IP8 Repeater 2
|
||||
node_id: rep02.ip8.ipnt.uk
|
||||
member_id: walshie86
|
||||
area: IP8
|
||||
lat: 52.035648
|
||||
lon: 1.073271
|
||||
location_description: Washbrook
|
||||
hardware: Sensecap Solar
|
||||
elevation: 13
|
||||
role: infra
|
||||
@@ -14,7 +14,7 @@ services:
|
||||
- "${MQTT_EXTERNAL_PORT:-1883}:1883"
|
||||
- "${MQTT_WS_PORT:-9001}:9001"
|
||||
volumes:
|
||||
- ./etc/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
|
||||
# - ./etc/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
|
||||
- mosquitto_data:/mosquitto/data
|
||||
- mosquitto_log:/mosquitto/log
|
||||
healthcheck:
|
||||
@@ -138,8 +138,11 @@ services:
|
||||
- all
|
||||
- core
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db-migrate:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
- hub_data:/data
|
||||
- ${SEED_HOME:-./seed}:/seed
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
@@ -151,8 +154,6 @@ services:
|
||||
- MQTT_TLS=${MQTT_TLS:-false}
|
||||
- DATA_HOME=/data
|
||||
- SEED_HOME=/seed
|
||||
# Explicitly unset to use DATA_HOME-based default path
|
||||
- DATABASE_URL=
|
||||
# Webhook configuration
|
||||
- WEBHOOK_ADVERTISEMENT_URL=${WEBHOOK_ADVERTISEMENT_URL:-}
|
||||
- WEBHOOK_ADVERTISEMENT_SECRET=${WEBHOOK_ADVERTISEMENT_SECRET:-}
|
||||
@@ -193,13 +194,14 @@ services:
|
||||
- core
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db-migrate:
|
||||
condition: service_completed_successfully
|
||||
collector:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "${API_PORT:-8000}:8000"
|
||||
volumes:
|
||||
# Mount data directory (uses collector/meshcore.db)
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
- hub_data:/data
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- MQTT_HOST=${MQTT_HOST:-mqtt}
|
||||
@@ -209,12 +211,12 @@ services:
|
||||
- MQTT_PREFIX=${MQTT_PREFIX:-meshcore}
|
||||
- MQTT_TLS=${MQTT_TLS:-false}
|
||||
- DATA_HOME=/data
|
||||
# Explicitly unset to use DATA_HOME-based default path
|
||||
- DATABASE_URL=
|
||||
- API_HOST=0.0.0.0
|
||||
- API_PORT=8000
|
||||
- API_READ_KEY=${API_READ_KEY:-}
|
||||
- API_ADMIN_KEY=${API_ADMIN_KEY:-}
|
||||
- METRICS_ENABLED=${METRICS_ENABLED:-true}
|
||||
- METRICS_CACHE_TTL=${METRICS_CACHE_TTL:-60}
|
||||
command: ["api"]
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
@@ -241,12 +243,19 @@ services:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${WEB_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- ${CONTENT_HOME:-./content}:/content:ro
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- API_BASE_URL=http://api:8000
|
||||
- API_KEY=${API_READ_KEY:-}
|
||||
# Use ADMIN key to allow write operations from admin interface
|
||||
# Falls back to READ key if ADMIN key is not set
|
||||
- API_KEY=${API_ADMIN_KEY:-${API_READ_KEY:-}}
|
||||
- WEB_HOST=0.0.0.0
|
||||
- WEB_PORT=8080
|
||||
- WEB_THEME=${WEB_THEME:-dark}
|
||||
- WEB_LOCALE=${WEB_LOCALE:-en}
|
||||
- WEB_ADMIN_ENABLED=${WEB_ADMIN_ENABLED:-false}
|
||||
- NETWORK_NAME=${NETWORK_NAME:-MeshCore Network}
|
||||
- NETWORK_CITY=${NETWORK_CITY:-}
|
||||
- NETWORK_COUNTRY=${NETWORK_COUNTRY:-}
|
||||
@@ -254,7 +263,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:-}
|
||||
- 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')"]
|
||||
@@ -274,18 +294,20 @@ services:
|
||||
container_name: meshcore-db-migrate
|
||||
profiles:
|
||||
- all
|
||||
- core
|
||||
- migrate
|
||||
restart: "no"
|
||||
volumes:
|
||||
# Mount data directory (uses collector/meshcore.db)
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
- hub_data:/data
|
||||
environment:
|
||||
- DATA_HOME=/data
|
||||
# Explicitly unset to use DATA_HOME-based default path
|
||||
- DATABASE_URL=
|
||||
command: ["db", "upgrade"]
|
||||
|
||||
# ==========================================================================
|
||||
# Seed Data - Import node_tags.json and members.json from SEED_HOME
|
||||
# Seed Data - Import node_tags.yaml and members.yaml from SEED_HOME
|
||||
# NOTE: This is NOT run automatically. Use --profile seed to run explicitly.
|
||||
# Since tags are now managed via the admin UI, automatic seeding would
|
||||
# overwrite user changes.
|
||||
# ==========================================================================
|
||||
seed:
|
||||
image: ghcr.io/ipnet-mesh/meshcore-hub:${IMAGE_VERSION:-latest}
|
||||
@@ -294,27 +316,71 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: meshcore-seed
|
||||
profiles:
|
||||
- all
|
||||
- seed
|
||||
restart: "no"
|
||||
volumes:
|
||||
# Mount data directory for database (read-write)
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
# Mount seed directory for seed files (read-only)
|
||||
- hub_data:/data
|
||||
- ${SEED_HOME:-./seed}:/seed:ro
|
||||
environment:
|
||||
- DATA_HOME=/data
|
||||
- SEED_HOME=/seed
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
# Explicitly unset to use DATA_HOME-based default path
|
||||
- DATABASE_URL=
|
||||
# Imports both node_tags.json and members.json if they exist
|
||||
# Imports both node_tags.yaml and members.yaml if they exist
|
||||
command: ["collector", "seed"]
|
||||
|
||||
# ==========================================================================
|
||||
# Prometheus - Metrics collection and monitoring (optional, use --profile metrics)
|
||||
# ==========================================================================
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
container_name: meshcore-prometheus
|
||||
profiles:
|
||||
- all
|
||||
- metrics
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${PROMETHEUS_PORT:-9090}:9090"
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.retention.time=30d'
|
||||
volumes:
|
||||
- ./etc/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- ./etc/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
|
||||
- prometheus_data:/prometheus
|
||||
|
||||
# ==========================================================================
|
||||
# Alertmanager - Alert routing and notifications (optional, use --profile metrics)
|
||||
# ==========================================================================
|
||||
alertmanager:
|
||||
image: prom/alertmanager:latest
|
||||
container_name: meshcore-alertmanager
|
||||
profiles:
|
||||
- all
|
||||
- metrics
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${ALERTMANAGER_PORT:-9093}:9093"
|
||||
volumes:
|
||||
- ./etc/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
|
||||
- alertmanager_data:/alertmanager
|
||||
command:
|
||||
- '--config.file=/etc/alertmanager/alertmanager.yml'
|
||||
- '--storage.path=/alertmanager'
|
||||
|
||||
# ==========================================================================
|
||||
# Volumes
|
||||
# ==========================================================================
|
||||
volumes:
|
||||
hub_data:
|
||||
name: meshcore_hub_data
|
||||
mosquitto_data:
|
||||
name: meshcore_mosquitto_data
|
||||
mosquitto_log:
|
||||
name: meshcore_mosquitto_log
|
||||
prometheus_data:
|
||||
name: meshcore_prometheus_data
|
||||
alertmanager_data:
|
||||
name: meshcore_alertmanager_data
|
||||
|
||||
BIN
docs/images/web.png
Normal file
BIN
docs/images/web.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 238 KiB |
35
etc/alertmanager/alertmanager.yml
Normal file
35
etc/alertmanager/alertmanager.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
# Alertmanager configuration for MeshCore Hub
|
||||
#
|
||||
# Default configuration routes all alerts to a "blackhole" receiver
|
||||
# (logs only, no external notifications).
|
||||
#
|
||||
# To receive notifications, configure a receiver below.
|
||||
# See: https://prometheus.io/docs/alerting/latest/configuration/
|
||||
#
|
||||
# Examples:
|
||||
#
|
||||
# Email:
|
||||
# receivers:
|
||||
# - name: 'email'
|
||||
# email_configs:
|
||||
# - to: 'admin@example.com'
|
||||
# from: 'alertmanager@example.com'
|
||||
# smarthost: 'smtp.example.com:587'
|
||||
# auth_username: 'alertmanager@example.com'
|
||||
# auth_password: 'password'
|
||||
#
|
||||
# Webhook (e.g. Slack incoming webhook, ntfy, Gotify):
|
||||
# receivers:
|
||||
# - name: 'webhook'
|
||||
# webhook_configs:
|
||||
# - url: 'https://example.com/webhook'
|
||||
|
||||
route:
|
||||
receiver: 'default'
|
||||
group_by: ['alertname']
|
||||
group_wait: 30s
|
||||
group_interval: 5m
|
||||
repeat_interval: 4h
|
||||
|
||||
receivers:
|
||||
- name: 'default'
|
||||
16
etc/prometheus/alerts.yml
Normal file
16
etc/prometheus/alerts.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Prometheus alert rules for MeshCore Hub
|
||||
#
|
||||
# These rules are evaluated by Prometheus and fired alerts are sent
|
||||
# to Alertmanager for routing and notification.
|
||||
|
||||
groups:
|
||||
- name: meshcore
|
||||
rules:
|
||||
- alert: NodeNotSeen
|
||||
expr: time() - meshcore_node_last_seen_timestamp_seconds{role="infra"} > 48 * 3600
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Node {{ $labels.node_name }} ({{ $labels.role }}) not seen for 48+ hours"
|
||||
description: "Node {{ $labels.public_key }} ({{ $labels.adv_type }}, role={{ $labels.role }}) last seen {{ $value | humanizeDuration }} ago."
|
||||
29
etc/prometheus/prometheus.yml
Normal file
29
etc/prometheus/prometheus.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
# Prometheus scrape configuration for MeshCore Hub
|
||||
#
|
||||
# This file is used when running Prometheus via Docker Compose:
|
||||
# docker compose --profile core --profile metrics up -d
|
||||
#
|
||||
# The scrape interval matches the default metrics cache TTL (60s)
|
||||
# to avoid unnecessary database queries.
|
||||
|
||||
global:
|
||||
scrape_interval: 60s
|
||||
evaluation_interval: 60s
|
||||
|
||||
alerting:
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets: ['alertmanager:9093']
|
||||
|
||||
rule_files:
|
||||
- 'alerts.yml'
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'meshcore-hub'
|
||||
metrics_path: '/metrics'
|
||||
# Uncomment basic_auth if API_READ_KEY is configured
|
||||
# basic_auth:
|
||||
# username: 'metrics'
|
||||
# password: '<API_READ_KEY>'
|
||||
static_configs:
|
||||
- targets: ['api:8000']
|
||||
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 |
87
example/content/pages/join.md
Normal file
87
example/content/pages/join.md
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: Join
|
||||
slug: join
|
||||
menu_order: 10
|
||||
---
|
||||
|
||||
# Getting Started with MeshCore
|
||||
|
||||
MeshCore is an open-source off-grid LoRa mesh networking platform. This guide will help you get connected to the network.
|
||||
|
||||
For detailed documentation, see the [MeshCore FAQ](https://github.com/meshcore-dev/MeshCore/blob/main/docs/faq.md).
|
||||
|
||||
## Node Types
|
||||
|
||||
MeshCore devices operate in different modes:
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| **Companion** | Connects to your phone via Bluetooth. Use this for messaging and interacting with the network. |
|
||||
| **Repeater** | Standalone node that extends network coverage. Place these in elevated locations for best results. |
|
||||
| **Room Server** | Hosts chat rooms that persist messages for offline users. |
|
||||
|
||||
Most users start with a **Companion** node paired to their phone.
|
||||
|
||||
## Frequency Regulations
|
||||
|
||||
MeshCore uses LoRa radio, which operates on unlicensed ISM bands. You **must** use the correct frequency for your region:
|
||||
|
||||
| Region | Frequency | Notes |
|
||||
|--------|-----------|-------|
|
||||
| Europe (EU) | 868 MHz | EU868 band |
|
||||
| United Kingdom | 868 MHz | Same as EU |
|
||||
| North America | 915 MHz | US915 band |
|
||||
| Australia | 915 MHz | AU915 band |
|
||||
|
||||
Using the wrong frequency is illegal and may cause interference. Check your local regulations.
|
||||
|
||||
## Compatible Hardware
|
||||
|
||||
MeshCore runs on inexpensive low-power LoRa devices. Popular options include:
|
||||
|
||||
### Recommended Devices
|
||||
|
||||
| Device | Manufacturer | Features |
|
||||
|--------|--------------|----------|
|
||||
| [Heltec V3](https://heltec.org/project/wifi-lora-32-v3/) | Heltec | Budget-friendly, OLED display |
|
||||
| [T114](https://heltec.org/project/mesh-node-t114/) | Heltec | Compact, GPS, colour display |
|
||||
| [T1000-E](https://www.seeedstudio.com/SenseCAP-Card-Tracker-T1000-E-for-Meshtastic-p-5913.html) | Seeed Studio | Credit-card sized, GPS, weatherproof |
|
||||
| [T-Deck Plus](https://www.lilygo.cc/products/t-deck-plus) | LilyGO | Built-in keyboard, touchscreen, GPS |
|
||||
|
||||
Ensure you purchase the correct frequency variant (868MHz for EU/UK, 915MHz for US/AU).
|
||||
|
||||
### Where to Buy
|
||||
|
||||
- **Heltec**: [Official Store](https://heltec.org/) or AliExpress
|
||||
- **LilyGO**: [Official Store](https://lilygo.cc/) or AliExpress
|
||||
- **Seeed Studio**: [Official Store](https://www.seeedstudio.com/)
|
||||
- **Amazon**: Search for device name + "LoRa 868" (or 915 for US)
|
||||
|
||||
## Mobile Apps
|
||||
|
||||
Connect to your Companion node using the official MeshCore apps:
|
||||
|
||||
| Platform | App | Link |
|
||||
|----------|-----|------|
|
||||
| Android | MeshCore | [Google Play](https://play.google.com/store/apps/details?id=com.liamcottle.meshcore.android) |
|
||||
| iOS | MeshCore | [App Store](https://apps.apple.com/us/app/meshcore/id6742354151) |
|
||||
|
||||
The app connects via Bluetooth to your Companion node, allowing you to send messages, view the network, and configure your device.
|
||||
|
||||
## Flashing Firmware
|
||||
|
||||
1. Use the [MeshCore Web Flasher](https://flasher.meshcore.co.uk/) for easy browser-based flashing
|
||||
2. Select your device type and region (frequency)
|
||||
3. Connect via USB and flash
|
||||
|
||||
## Next Steps
|
||||
|
||||
Once your device is flashed and paired:
|
||||
|
||||
1. Open the MeshCore app on your phone
|
||||
2. Enable Bluetooth and pair with your device
|
||||
3. Set your node name in the app settings
|
||||
4. Configure your radio settings/profile for your region
|
||||
4. You should start seeing other nodes on the network
|
||||
|
||||
Welcome to the mesh!
|
||||
@@ -1,16 +1,14 @@
|
||||
# Example members seed file
|
||||
# Each member can have multiple nodes with different roles (chat, repeater, etc.)
|
||||
# Note: Nodes are associated with members via a 'member_id' tag on the node.
|
||||
# Use node_tags.yaml to set member_id tags on nodes.
|
||||
members:
|
||||
- name: Example Member
|
||||
- member_id: example_member
|
||||
name: Example Member
|
||||
callsign: N0CALL
|
||||
role: Network Operator
|
||||
description: Example member entry with multiple nodes
|
||||
nodes:
|
||||
- public_key: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
node_role: chat
|
||||
- public_key: fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210
|
||||
node_role: repeater
|
||||
- name: Simple Member
|
||||
description: Example network operator member
|
||||
- member_id: simple_member
|
||||
name: Simple Member
|
||||
callsign: N0CALL2
|
||||
role: Observer
|
||||
description: Member without any nodes
|
||||
description: Example observer member
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
# elevation: 150 # number
|
||||
# is_online: true # boolean
|
||||
#
|
||||
# - Explicit type (for special types like coordinate):
|
||||
# location:
|
||||
# value: "37.7749,-122.4194"
|
||||
# type: coordinate
|
||||
# - Explicit type (when you need to force a specific type):
|
||||
# altitude:
|
||||
# value: "150"
|
||||
# type: number
|
||||
#
|
||||
# Supported types: string, number, boolean, coordinate
|
||||
# Supported types: string, number, boolean
|
||||
|
||||
0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef:
|
||||
friendly_name: Gateway Node
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0", "wheel", "setuptools-scm>=8.0"]
|
||||
requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meshcore-hub"
|
||||
dynamic = ["version"]
|
||||
version = "0.0.0"
|
||||
description = "Python monorepo for managing and orchestrating MeshCore mesh networks"
|
||||
readme = "README.md"
|
||||
license = {text = "GPL-3.0-or-later"}
|
||||
requires-python = ">=3.11"
|
||||
requires-python = ">=3.13"
|
||||
authors = [
|
||||
{name = "MeshCore Hub Contributors"}
|
||||
]
|
||||
@@ -18,8 +18,7 @@ classifiers = [
|
||||
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Communications",
|
||||
"Topic :: System :: Networking",
|
||||
]
|
||||
@@ -40,6 +39,9 @@ dependencies = [
|
||||
"aiosqlite>=0.19.0",
|
||||
"meshcore>=2.2.0",
|
||||
"pyyaml>=6.0.0",
|
||||
"python-frontmatter>=1.0.0",
|
||||
"markdown>=3.5.0",
|
||||
"prometheus-client>=0.20.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -51,6 +53,7 @@ dev = [
|
||||
"flake8>=6.1.0",
|
||||
"mypy>=1.5.0",
|
||||
"pre-commit>=3.4.0",
|
||||
"beautifulsoup4>=4.12.0",
|
||||
"types-paho-mqtt>=1.6.0",
|
||||
"types-PyYAML>=6.0.0",
|
||||
]
|
||||
@@ -68,10 +71,6 @@ Documentation = "https://github.com/ipnet-mesh/meshcore-hub#readme"
|
||||
Repository = "https://github.com/ipnet-mesh/meshcore-hub"
|
||||
Issues = "https://github.com/ipnet-mesh/meshcore-hub/issues"
|
||||
|
||||
[tool.setuptools_scm]
|
||||
version_file = "src/meshcore_hub/_version.py"
|
||||
fallback_version = "0.0.0+unknown"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
@@ -82,7 +81,7 @@ meshcore_hub = ["py.typed"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ["py311"]
|
||||
target-version = ["py312"]
|
||||
include = '\.pyi?$'
|
||||
extend-exclude = '''
|
||||
/(
|
||||
@@ -101,7 +100,7 @@ extend-exclude = '''
|
||||
'''
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
python_version = "3.13"
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
disallow_untyped_defs = true
|
||||
@@ -116,6 +115,9 @@ module = [
|
||||
"uvicorn.*",
|
||||
"alembic.*",
|
||||
"meshcore.*",
|
||||
"frontmatter.*",
|
||||
"markdown.*",
|
||||
"prometheus_client.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
||||
|
||||
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"
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
"""MeshCore Hub - Python monorepo for managing MeshCore mesh networks."""
|
||||
|
||||
from meshcore_hub._version import __version__, __version_tuple__
|
||||
from meshcore_hub._version import __version__
|
||||
|
||||
__all__ = ["__version__", "__version_tuple__"]
|
||||
__all__ = ["__version__"]
|
||||
|
||||
8
src/meshcore_hub/_version.py
Normal file
8
src/meshcore_hub/_version.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""MeshCore Hub version information.
|
||||
|
||||
This file contains the version string for the package.
|
||||
It can be overridden at build time by setting BUILD_VERSION environment variable.
|
||||
"""
|
||||
|
||||
__version__ = "dev"
|
||||
__all__ = ["__version__"]
|
||||
@@ -54,6 +54,8 @@ def create_app(
|
||||
mqtt_prefix: str = "meshcore",
|
||||
mqtt_tls: bool = False,
|
||||
cors_origins: list[str] | None = None,
|
||||
metrics_enabled: bool = True,
|
||||
metrics_cache_ttl: int = 60,
|
||||
) -> FastAPI:
|
||||
"""Create and configure the FastAPI application.
|
||||
|
||||
@@ -66,6 +68,8 @@ def create_app(
|
||||
mqtt_prefix: MQTT topic prefix
|
||||
mqtt_tls: Enable TLS/SSL for MQTT connection
|
||||
cors_origins: Allowed CORS origins
|
||||
metrics_enabled: Enable Prometheus metrics endpoint at /metrics
|
||||
metrics_cache_ttl: Seconds to cache metrics output
|
||||
|
||||
Returns:
|
||||
Configured FastAPI application
|
||||
@@ -88,6 +92,7 @@ def create_app(
|
||||
app.state.mqtt_port = mqtt_port
|
||||
app.state.mqtt_prefix = mqtt_prefix
|
||||
app.state.mqtt_tls = mqtt_tls
|
||||
app.state.metrics_cache_ttl = metrics_cache_ttl
|
||||
|
||||
# Configure CORS
|
||||
if cors_origins is None:
|
||||
@@ -106,6 +111,12 @@ def create_app(
|
||||
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
# Include Prometheus metrics endpoint
|
||||
if metrics_enabled:
|
||||
from meshcore_hub.api.metrics import router as metrics_router
|
||||
|
||||
app.include_router(metrics_router)
|
||||
|
||||
# Health check endpoints
|
||||
@app.get("/health", tags=["Health"])
|
||||
async def health() -> dict:
|
||||
|
||||
@@ -81,6 +81,19 @@ import click
|
||||
envvar="CORS_ORIGINS",
|
||||
help="Comma-separated list of allowed CORS origins",
|
||||
)
|
||||
@click.option(
|
||||
"--metrics-enabled/--no-metrics",
|
||||
default=True,
|
||||
envvar="METRICS_ENABLED",
|
||||
help="Enable Prometheus metrics endpoint at /metrics",
|
||||
)
|
||||
@click.option(
|
||||
"--metrics-cache-ttl",
|
||||
type=int,
|
||||
default=60,
|
||||
envvar="METRICS_CACHE_TTL",
|
||||
help="Seconds to cache metrics output (reduces database load)",
|
||||
)
|
||||
@click.option(
|
||||
"--reload",
|
||||
is_flag=True,
|
||||
@@ -101,6 +114,8 @@ def api(
|
||||
mqtt_prefix: str,
|
||||
mqtt_tls: bool,
|
||||
cors_origins: str | None,
|
||||
metrics_enabled: bool,
|
||||
metrics_cache_ttl: int,
|
||||
reload: bool,
|
||||
) -> None:
|
||||
"""Run the REST API server.
|
||||
@@ -149,6 +164,8 @@ def api(
|
||||
click.echo(f"Read key configured: {read_key is not None}")
|
||||
click.echo(f"Admin key configured: {admin_key is not None}")
|
||||
click.echo(f"CORS origins: {cors_origins or 'none'}")
|
||||
click.echo(f"Metrics enabled: {metrics_enabled}")
|
||||
click.echo(f"Metrics cache TTL: {metrics_cache_ttl}s")
|
||||
click.echo(f"Reload mode: {reload}")
|
||||
click.echo("=" * 50)
|
||||
|
||||
@@ -181,6 +198,8 @@ def api(
|
||||
mqtt_prefix=mqtt_prefix,
|
||||
mqtt_tls=mqtt_tls,
|
||||
cors_origins=origins_list,
|
||||
metrics_enabled=metrics_enabled,
|
||||
metrics_cache_ttl=metrics_cache_ttl,
|
||||
)
|
||||
|
||||
click.echo("\nStarting API server...")
|
||||
|
||||
331
src/meshcore_hub/api/metrics.py
Normal file
331
src/meshcore_hub/api/metrics.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""Prometheus metrics endpoint for MeshCore Hub API."""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from prometheus_client import CollectorRegistry, Gauge, generate_latest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from meshcore_hub.common.models import (
|
||||
Advertisement,
|
||||
EventLog,
|
||||
Member,
|
||||
Message,
|
||||
Node,
|
||||
NodeTag,
|
||||
Telemetry,
|
||||
TracePath,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Module-level cache
|
||||
_cache: dict[str, Any] = {"output": b"", "expires_at": 0.0}
|
||||
|
||||
|
||||
def verify_basic_auth(request: Request) -> bool:
|
||||
"""Verify HTTP Basic Auth credentials for metrics endpoint.
|
||||
|
||||
Uses username 'metrics' and the API read key as password.
|
||||
Returns True if no read key is configured (public access).
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
True if authentication passes
|
||||
"""
|
||||
read_key = getattr(request.app.state, "read_key", None)
|
||||
|
||||
# No read key configured = public access
|
||||
if not read_key:
|
||||
return True
|
||||
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Basic "):
|
||||
return False
|
||||
|
||||
try:
|
||||
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
|
||||
username, password = decoded.split(":", 1)
|
||||
return username == "metrics" and password == read_key
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def collect_metrics(session: Any) -> bytes:
|
||||
"""Collect all metrics from the database and generate Prometheus output.
|
||||
|
||||
Creates a fresh CollectorRegistry per call to avoid global state issues.
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy database session
|
||||
|
||||
Returns:
|
||||
Prometheus text exposition format as bytes
|
||||
"""
|
||||
from meshcore_hub import __version__
|
||||
|
||||
registry = CollectorRegistry()
|
||||
|
||||
# -- Info gauge --
|
||||
info_gauge = Gauge(
|
||||
"meshcore_info",
|
||||
"MeshCore Hub application info",
|
||||
["version"],
|
||||
registry=registry,
|
||||
)
|
||||
info_gauge.labels(version=__version__).set(1)
|
||||
|
||||
# -- Nodes total --
|
||||
nodes_total = Gauge(
|
||||
"meshcore_nodes_total",
|
||||
"Total number of nodes",
|
||||
registry=registry,
|
||||
)
|
||||
count = session.execute(select(func.count(Node.id))).scalar() or 0
|
||||
nodes_total.set(count)
|
||||
|
||||
# -- Nodes active by time window --
|
||||
nodes_active = Gauge(
|
||||
"meshcore_nodes_active",
|
||||
"Number of active nodes in time window",
|
||||
["window"],
|
||||
registry=registry,
|
||||
)
|
||||
for window, hours in [("1h", 1), ("24h", 24), ("7d", 168), ("30d", 720)]:
|
||||
cutoff = time.time() - (hours * 3600)
|
||||
from datetime import datetime, timezone
|
||||
|
||||
cutoff_dt = datetime.fromtimestamp(cutoff, tz=timezone.utc)
|
||||
count = (
|
||||
session.execute(
|
||||
select(func.count(Node.id)).where(Node.last_seen >= cutoff_dt)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
nodes_active.labels(window=window).set(count)
|
||||
|
||||
# -- Nodes by type --
|
||||
nodes_by_type = Gauge(
|
||||
"meshcore_nodes_by_type",
|
||||
"Number of nodes by advertisement type",
|
||||
["adv_type"],
|
||||
registry=registry,
|
||||
)
|
||||
type_counts = session.execute(
|
||||
select(Node.adv_type, func.count(Node.id)).group_by(Node.adv_type)
|
||||
).all()
|
||||
for adv_type, count in type_counts:
|
||||
nodes_by_type.labels(adv_type=adv_type or "unknown").set(count)
|
||||
|
||||
# -- Nodes with location --
|
||||
nodes_with_location = Gauge(
|
||||
"meshcore_nodes_with_location",
|
||||
"Number of nodes with GPS coordinates",
|
||||
registry=registry,
|
||||
)
|
||||
count = (
|
||||
session.execute(
|
||||
select(func.count(Node.id)).where(
|
||||
Node.lat.isnot(None), Node.lon.isnot(None)
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
nodes_with_location.set(count)
|
||||
|
||||
# -- Node last seen timestamp --
|
||||
node_last_seen = Gauge(
|
||||
"meshcore_node_last_seen_timestamp_seconds",
|
||||
"Unix timestamp of when the node was last seen",
|
||||
["public_key", "node_name", "adv_type", "role"],
|
||||
registry=registry,
|
||||
)
|
||||
role_subq = (
|
||||
select(NodeTag.node_id, NodeTag.value.label("role"))
|
||||
.where(NodeTag.key == "role")
|
||||
.subquery()
|
||||
)
|
||||
nodes_with_last_seen = session.execute(
|
||||
select(
|
||||
Node.public_key,
|
||||
Node.name,
|
||||
Node.adv_type,
|
||||
Node.last_seen,
|
||||
role_subq.c.role,
|
||||
)
|
||||
.outerjoin(role_subq, Node.id == role_subq.c.node_id)
|
||||
.where(Node.last_seen.isnot(None))
|
||||
).all()
|
||||
for public_key, name, adv_type, last_seen, role in nodes_with_last_seen:
|
||||
node_last_seen.labels(
|
||||
public_key=public_key,
|
||||
node_name=name or "",
|
||||
adv_type=adv_type or "unknown",
|
||||
role=role or "",
|
||||
).set(last_seen.timestamp())
|
||||
|
||||
# -- Messages total by type --
|
||||
messages_total = Gauge(
|
||||
"meshcore_messages_total",
|
||||
"Total number of messages by type",
|
||||
["type"],
|
||||
registry=registry,
|
||||
)
|
||||
msg_type_counts = session.execute(
|
||||
select(Message.message_type, func.count(Message.id)).group_by(
|
||||
Message.message_type
|
||||
)
|
||||
).all()
|
||||
for msg_type, count in msg_type_counts:
|
||||
messages_total.labels(type=msg_type).set(count)
|
||||
|
||||
# -- Messages received by type and window --
|
||||
messages_received = Gauge(
|
||||
"meshcore_messages_received",
|
||||
"Messages received in time window by type",
|
||||
["type", "window"],
|
||||
registry=registry,
|
||||
)
|
||||
for window, hours in [("1h", 1), ("24h", 24), ("7d", 168), ("30d", 720)]:
|
||||
cutoff = time.time() - (hours * 3600)
|
||||
cutoff_dt = datetime.fromtimestamp(cutoff, tz=timezone.utc)
|
||||
window_counts = session.execute(
|
||||
select(Message.message_type, func.count(Message.id))
|
||||
.where(Message.received_at >= cutoff_dt)
|
||||
.group_by(Message.message_type)
|
||||
).all()
|
||||
for msg_type, count in window_counts:
|
||||
messages_received.labels(type=msg_type, window=window).set(count)
|
||||
|
||||
# -- Advertisements total --
|
||||
advertisements_total = Gauge(
|
||||
"meshcore_advertisements_total",
|
||||
"Total number of advertisements",
|
||||
registry=registry,
|
||||
)
|
||||
count = session.execute(select(func.count(Advertisement.id))).scalar() or 0
|
||||
advertisements_total.set(count)
|
||||
|
||||
# -- Advertisements received by window --
|
||||
advertisements_received = Gauge(
|
||||
"meshcore_advertisements_received",
|
||||
"Advertisements received in time window",
|
||||
["window"],
|
||||
registry=registry,
|
||||
)
|
||||
for window, hours in [("1h", 1), ("24h", 24), ("7d", 168), ("30d", 720)]:
|
||||
cutoff = time.time() - (hours * 3600)
|
||||
cutoff_dt = datetime.fromtimestamp(cutoff, tz=timezone.utc)
|
||||
count = (
|
||||
session.execute(
|
||||
select(func.count(Advertisement.id)).where(
|
||||
Advertisement.received_at >= cutoff_dt
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
advertisements_received.labels(window=window).set(count)
|
||||
|
||||
# -- Telemetry total --
|
||||
telemetry_total = Gauge(
|
||||
"meshcore_telemetry_total",
|
||||
"Total number of telemetry records",
|
||||
registry=registry,
|
||||
)
|
||||
count = session.execute(select(func.count(Telemetry.id))).scalar() or 0
|
||||
telemetry_total.set(count)
|
||||
|
||||
# -- Trace paths total --
|
||||
trace_paths_total = Gauge(
|
||||
"meshcore_trace_paths_total",
|
||||
"Total number of trace path records",
|
||||
registry=registry,
|
||||
)
|
||||
count = session.execute(select(func.count(TracePath.id))).scalar() or 0
|
||||
trace_paths_total.set(count)
|
||||
|
||||
# -- Events by type --
|
||||
events_total = Gauge(
|
||||
"meshcore_events_total",
|
||||
"Total events by type from event log",
|
||||
["event_type"],
|
||||
registry=registry,
|
||||
)
|
||||
event_counts = session.execute(
|
||||
select(EventLog.event_type, func.count(EventLog.id)).group_by(
|
||||
EventLog.event_type
|
||||
)
|
||||
).all()
|
||||
for event_type, count in event_counts:
|
||||
events_total.labels(event_type=event_type).set(count)
|
||||
|
||||
# -- Members total --
|
||||
members_total = Gauge(
|
||||
"meshcore_members_total",
|
||||
"Total number of network members",
|
||||
registry=registry,
|
||||
)
|
||||
count = session.execute(select(func.count(Member.id))).scalar() or 0
|
||||
members_total.set(count)
|
||||
|
||||
output: bytes = generate_latest(registry)
|
||||
return output
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
async def metrics(request: Request) -> Response:
|
||||
"""Prometheus metrics endpoint.
|
||||
|
||||
Returns metrics in Prometheus text exposition format.
|
||||
Supports HTTP Basic Auth with username 'metrics' and API read key as password.
|
||||
Results are cached with a configurable TTL to reduce database load.
|
||||
"""
|
||||
# Check authentication
|
||||
if not verify_basic_auth(request):
|
||||
return PlainTextResponse(
|
||||
"Unauthorized",
|
||||
status_code=401,
|
||||
headers={"WWW-Authenticate": 'Basic realm="metrics"'},
|
||||
)
|
||||
|
||||
# Check cache
|
||||
cache_ttl = getattr(request.app.state, "metrics_cache_ttl", 60)
|
||||
now = time.time()
|
||||
|
||||
if _cache["output"] and now < _cache["expires_at"]:
|
||||
return Response(
|
||||
content=_cache["output"],
|
||||
media_type="text/plain; version=0.0.4; charset=utf-8",
|
||||
)
|
||||
|
||||
# Collect fresh metrics
|
||||
try:
|
||||
from meshcore_hub.api.app import get_db_manager
|
||||
|
||||
db_manager = get_db_manager()
|
||||
with db_manager.session_scope() as session:
|
||||
output = collect_metrics(session)
|
||||
|
||||
# Update cache
|
||||
_cache["output"] = output
|
||||
_cache["expires_at"] = now + cache_ttl
|
||||
|
||||
return Response(
|
||||
content=output,
|
||||
media_type="text/plain; version=0.0.4; charset=utf-8",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to collect metrics: %s", e)
|
||||
return PlainTextResponse(
|
||||
f"# Error collecting metrics: {e}\n",
|
||||
status_code=500,
|
||||
media_type="text/plain; version=0.0.4; charset=utf-8",
|
||||
)
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import aliased, selectinload
|
||||
|
||||
from meshcore_hub.api.auth import RequireRead
|
||||
@@ -19,12 +19,22 @@ from meshcore_hub.common.schemas.messages import (
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_friendly_name(node: Optional[Node]) -> Optional[str]:
|
||||
"""Extract friendly_name tag from a node's tags."""
|
||||
def _get_tag_name(node: Optional[Node]) -> Optional[str]:
|
||||
"""Extract name tag from a node's tags."""
|
||||
if not node or not node.tags:
|
||||
return None
|
||||
for tag in node.tags:
|
||||
if tag.key == "friendly_name":
|
||||
if tag.key == "name":
|
||||
return tag.value
|
||||
return None
|
||||
|
||||
|
||||
def _get_tag_description(node: Optional[Node]) -> Optional[str]:
|
||||
"""Extract description tag from a node's tags."""
|
||||
if not node or not node.tags:
|
||||
return None
|
||||
for tag in node.tags:
|
||||
if tag.key == "description":
|
||||
return tag.value
|
||||
return None
|
||||
|
||||
@@ -57,15 +67,15 @@ def _fetch_receivers_for_events(
|
||||
receivers_by_hash: dict[str, list[ReceiverInfo]] = {}
|
||||
|
||||
node_ids = [r.node_id for r in results]
|
||||
friendly_names: dict[str, str] = {}
|
||||
tag_names: dict[str, str] = {}
|
||||
if node_ids:
|
||||
fn_query = (
|
||||
tag_query = (
|
||||
select(NodeTag.node_id, NodeTag.value)
|
||||
.where(NodeTag.node_id.in_(node_ids))
|
||||
.where(NodeTag.key == "friendly_name")
|
||||
.where(NodeTag.key == "name")
|
||||
)
|
||||
for node_id, value in session.execute(fn_query).all():
|
||||
friendly_names[node_id] = value
|
||||
for node_id, value in session.execute(tag_query).all():
|
||||
tag_names[node_id] = value
|
||||
|
||||
for row in results:
|
||||
if row.event_hash not in receivers_by_hash:
|
||||
@@ -76,7 +86,7 @@ def _fetch_receivers_for_events(
|
||||
node_id=row.node_id,
|
||||
public_key=row.public_key,
|
||||
name=row.name,
|
||||
friendly_name=friendly_names.get(row.node_id),
|
||||
tag_name=tag_names.get(row.node_id),
|
||||
snr=row.snr,
|
||||
received_at=row.received_at,
|
||||
)
|
||||
@@ -89,10 +99,16 @@ def _fetch_receivers_for_events(
|
||||
async def list_advertisements(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
search: Optional[str] = Query(
|
||||
None, description="Search in name tag, node name, or public key"
|
||||
),
|
||||
public_key: Optional[str] = Query(None, description="Filter by public key"),
|
||||
received_by: Optional[str] = Query(
|
||||
None, description="Filter by receiver node public key"
|
||||
),
|
||||
member_id: Optional[str] = Query(
|
||||
None, description="Filter by member_id tag value of source node"
|
||||
),
|
||||
since: Optional[datetime] = Query(None, description="Start timestamp"),
|
||||
until: Optional[datetime] = Query(None, description="End timestamp"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Page size"),
|
||||
@@ -118,12 +134,38 @@ async def list_advertisements(
|
||||
.outerjoin(SourceNode, Advertisement.node_id == SourceNode.id)
|
||||
)
|
||||
|
||||
if search:
|
||||
# Search in public key, advertisement name, node name, or name tag
|
||||
search_pattern = f"%{search}%"
|
||||
query = query.where(
|
||||
or_(
|
||||
Advertisement.public_key.ilike(search_pattern),
|
||||
Advertisement.name.ilike(search_pattern),
|
||||
SourceNode.name.ilike(search_pattern),
|
||||
SourceNode.id.in_(
|
||||
select(NodeTag.node_id).where(
|
||||
NodeTag.key == "name", NodeTag.value.ilike(search_pattern)
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if public_key:
|
||||
query = query.where(Advertisement.public_key == public_key)
|
||||
|
||||
if received_by:
|
||||
query = query.where(ReceiverNode.public_key == received_by)
|
||||
|
||||
if member_id:
|
||||
# Filter advertisements from nodes that have a member_id tag with the specified value
|
||||
query = query.where(
|
||||
SourceNode.id.in_(
|
||||
select(NodeTag.node_id).where(
|
||||
NodeTag.key == "member_id", NodeTag.value == member_id
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if since:
|
||||
query = query.where(Advertisement.received_at >= since)
|
||||
|
||||
@@ -173,11 +215,12 @@ async def list_advertisements(
|
||||
data = {
|
||||
"received_by": row.receiver_pk,
|
||||
"receiver_name": row.receiver_name,
|
||||
"receiver_friendly_name": _get_friendly_name(receiver_node),
|
||||
"receiver_tag_name": _get_tag_name(receiver_node),
|
||||
"public_key": adv.public_key,
|
||||
"name": adv.name,
|
||||
"node_name": row.source_name,
|
||||
"node_friendly_name": _get_friendly_name(source_node),
|
||||
"node_tag_name": _get_tag_name(source_node),
|
||||
"node_tag_description": _get_tag_description(source_node),
|
||||
"adv_type": adv.adv_type or row.source_adv_type,
|
||||
"flags": adv.flags,
|
||||
"received_at": adv.received_at,
|
||||
@@ -255,11 +298,12 @@ async def get_advertisement(
|
||||
data = {
|
||||
"received_by": result.receiver_pk,
|
||||
"receiver_name": result.receiver_name,
|
||||
"receiver_friendly_name": _get_friendly_name(receiver_node),
|
||||
"receiver_tag_name": _get_tag_name(receiver_node),
|
||||
"public_key": adv.public_key,
|
||||
"name": adv.name,
|
||||
"node_name": result.source_name,
|
||||
"node_friendly_name": _get_friendly_name(source_node),
|
||||
"node_tag_name": _get_tag_name(source_node),
|
||||
"node_tag_description": _get_tag_description(source_node),
|
||||
"adv_type": adv.adv_type or result.source_adv_type,
|
||||
"flags": adv.flags,
|
||||
"received_at": adv.received_at,
|
||||
|
||||
@@ -31,6 +31,7 @@ async def get_stats(
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
yesterday = now - timedelta(days=1)
|
||||
seven_days_ago = now - timedelta(days=7)
|
||||
|
||||
# Total nodes
|
||||
total_nodes = session.execute(select(func.count()).select_from(Node)).scalar() or 0
|
||||
@@ -73,6 +74,26 @@ async def get_stats(
|
||||
or 0
|
||||
)
|
||||
|
||||
# Advertisements in last 7 days
|
||||
advertisements_7d = (
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(Advertisement)
|
||||
.where(Advertisement.received_at >= seven_days_ago)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Messages in last 7 days
|
||||
messages_7d = (
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(Message)
|
||||
.where(Message.received_at >= seven_days_ago)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Recent advertisements (last 10)
|
||||
recent_ads = (
|
||||
session.execute(
|
||||
@@ -82,11 +103,11 @@ async def get_stats(
|
||||
.all()
|
||||
)
|
||||
|
||||
# Get node names, adv_types, and friendly_name tags for the advertised nodes
|
||||
# Get node names, adv_types, and name tags for the advertised nodes
|
||||
ad_public_keys = [ad.public_key for ad in recent_ads]
|
||||
node_names: dict[str, str] = {}
|
||||
node_adv_types: dict[str, str] = {}
|
||||
friendly_names: dict[str, str] = {}
|
||||
tag_names: dict[str, str] = {}
|
||||
if ad_public_keys:
|
||||
# Get node names and adv_types from Node table
|
||||
node_query = select(Node.public_key, Node.name, Node.adv_type).where(
|
||||
@@ -98,21 +119,21 @@ async def get_stats(
|
||||
if adv_type:
|
||||
node_adv_types[public_key] = adv_type
|
||||
|
||||
# Get friendly_name tags
|
||||
friendly_name_query = (
|
||||
# Get name tags
|
||||
tag_name_query = (
|
||||
select(Node.public_key, NodeTag.value)
|
||||
.join(NodeTag, Node.id == NodeTag.node_id)
|
||||
.where(Node.public_key.in_(ad_public_keys))
|
||||
.where(NodeTag.key == "friendly_name")
|
||||
.where(NodeTag.key == "name")
|
||||
)
|
||||
for public_key, value in session.execute(friendly_name_query).all():
|
||||
friendly_names[public_key] = value
|
||||
for public_key, value in session.execute(tag_name_query).all():
|
||||
tag_names[public_key] = value
|
||||
|
||||
recent_advertisements = [
|
||||
RecentAdvertisement(
|
||||
public_key=ad.public_key,
|
||||
name=ad.name or node_names.get(ad.public_key),
|
||||
friendly_name=friendly_names.get(ad.public_key),
|
||||
tag_name=tag_names.get(ad.public_key),
|
||||
adv_type=ad.adv_type or node_adv_types.get(ad.public_key),
|
||||
received_at=ad.received_at,
|
||||
)
|
||||
@@ -146,7 +167,7 @@ async def get_stats(
|
||||
# Look up sender names for these messages
|
||||
msg_prefixes = [m.pubkey_prefix for m in channel_msgs if m.pubkey_prefix]
|
||||
msg_sender_names: dict[str, str] = {}
|
||||
msg_friendly_names: dict[str, str] = {}
|
||||
msg_tag_names: dict[str, str] = {}
|
||||
if msg_prefixes:
|
||||
for prefix in set(msg_prefixes):
|
||||
sender_node_query = select(Node.public_key, Node.name).where(
|
||||
@@ -156,14 +177,14 @@ async def get_stats(
|
||||
if name:
|
||||
msg_sender_names[public_key[:12]] = name
|
||||
|
||||
sender_friendly_query = (
|
||||
sender_tag_query = (
|
||||
select(Node.public_key, NodeTag.value)
|
||||
.join(NodeTag, Node.id == NodeTag.node_id)
|
||||
.where(Node.public_key.startswith(prefix))
|
||||
.where(NodeTag.key == "friendly_name")
|
||||
.where(NodeTag.key == "name")
|
||||
)
|
||||
for public_key, value in session.execute(sender_friendly_query).all():
|
||||
msg_friendly_names[public_key[:12]] = value
|
||||
for public_key, value in session.execute(sender_tag_query).all():
|
||||
msg_tag_names[public_key[:12]] = value
|
||||
|
||||
channel_messages[int(channel_idx)] = [
|
||||
ChannelMessage(
|
||||
@@ -171,8 +192,8 @@ async def get_stats(
|
||||
sender_name=(
|
||||
msg_sender_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
|
||||
),
|
||||
sender_friendly_name=(
|
||||
msg_friendly_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
|
||||
sender_tag_name=(
|
||||
msg_tag_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
|
||||
),
|
||||
pubkey_prefix=m.pubkey_prefix,
|
||||
received_at=m.received_at,
|
||||
@@ -185,8 +206,10 @@ async def get_stats(
|
||||
active_nodes=active_nodes,
|
||||
total_messages=total_messages,
|
||||
messages_today=messages_today,
|
||||
messages_7d=messages_7d,
|
||||
total_advertisements=total_advertisements,
|
||||
advertisements_24h=advertisements_24h,
|
||||
advertisements_7d=advertisements_7d,
|
||||
recent_advertisements=recent_advertisements,
|
||||
channel_message_counts=channel_message_counts,
|
||||
channel_messages=channel_messages,
|
||||
@@ -205,15 +228,15 @@ async def get_activity(
|
||||
days: Number of days to include (default 30, max 90)
|
||||
|
||||
Returns:
|
||||
Daily advertisement counts for each day in the period
|
||||
Daily advertisement counts for each day in the period (excluding today)
|
||||
"""
|
||||
# Limit to max 90 days
|
||||
days = min(days, 90)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
start_date = (now - timedelta(days=days - 1)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
# End at start of today (exclude today's incomplete data)
|
||||
end_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# Query advertisement counts grouped by date
|
||||
# Use SQLite's date() function for grouping (returns string 'YYYY-MM-DD')
|
||||
@@ -225,6 +248,7 @@ async def get_activity(
|
||||
func.count().label("count"),
|
||||
)
|
||||
.where(Advertisement.received_at >= start_date)
|
||||
.where(Advertisement.received_at < end_date)
|
||||
.group_by(date_expr)
|
||||
.order_by(date_expr)
|
||||
)
|
||||
@@ -257,14 +281,14 @@ async def get_message_activity(
|
||||
days: Number of days to include (default 30, max 90)
|
||||
|
||||
Returns:
|
||||
Daily message counts for each day in the period
|
||||
Daily message counts for each day in the period (excluding today)
|
||||
"""
|
||||
days = min(days, 90)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
start_date = (now - timedelta(days=days - 1)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
# End at start of today (exclude today's incomplete data)
|
||||
end_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# Query message counts grouped by date
|
||||
date_expr = func.date(Message.received_at)
|
||||
@@ -275,6 +299,7 @@ async def get_message_activity(
|
||||
func.count().label("count"),
|
||||
)
|
||||
.where(Message.received_at >= start_date)
|
||||
.where(Message.received_at < end_date)
|
||||
.group_by(date_expr)
|
||||
.order_by(date_expr)
|
||||
)
|
||||
@@ -308,14 +333,14 @@ async def get_node_count_history(
|
||||
days: Number of days to include (default 30, max 90)
|
||||
|
||||
Returns:
|
||||
Cumulative node count for each day in the period
|
||||
Cumulative node count for each day in the period (excluding today)
|
||||
"""
|
||||
days = min(days, 90)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
start_date = (now - timedelta(days=days - 1)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
# End at start of today (exclude today's incomplete data)
|
||||
end_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# Get all nodes with their creation dates
|
||||
# Count nodes created on or before each date
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from meshcore_hub.api.auth import RequireAdmin, RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Member, MemberNode, Node
|
||||
from meshcore_hub.common.models import Member
|
||||
from meshcore_hub.common.schemas.members import (
|
||||
MemberCreate,
|
||||
MemberList,
|
||||
MemberNodeRead,
|
||||
MemberRead,
|
||||
MemberUpdate,
|
||||
)
|
||||
@@ -18,50 +16,6 @@ from meshcore_hub.common.schemas.members import (
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _enrich_member_nodes(
|
||||
member: Member, node_info: dict[str, dict]
|
||||
) -> list[MemberNodeRead]:
|
||||
"""Enrich member nodes with node details from the database.
|
||||
|
||||
Args:
|
||||
member: The member with nodes to enrich
|
||||
node_info: Dict mapping public_key to node details
|
||||
|
||||
Returns:
|
||||
List of MemberNodeRead with node details populated
|
||||
"""
|
||||
enriched_nodes = []
|
||||
for mn in member.nodes:
|
||||
info = node_info.get(mn.public_key, {})
|
||||
enriched_nodes.append(
|
||||
MemberNodeRead(
|
||||
public_key=mn.public_key,
|
||||
node_role=mn.node_role,
|
||||
created_at=mn.created_at,
|
||||
updated_at=mn.updated_at,
|
||||
node_name=info.get("name"),
|
||||
node_adv_type=info.get("adv_type"),
|
||||
friendly_name=info.get("friendly_name"),
|
||||
)
|
||||
)
|
||||
return enriched_nodes
|
||||
|
||||
|
||||
def _member_to_read(member: Member, node_info: dict[str, dict]) -> MemberRead:
|
||||
"""Convert a Member model to MemberRead with enriched node data."""
|
||||
return MemberRead(
|
||||
id=member.id,
|
||||
name=member.name,
|
||||
callsign=member.callsign,
|
||||
role=member.role,
|
||||
description=member.description,
|
||||
contact=member.contact,
|
||||
nodes=_enrich_member_nodes(member, node_info),
|
||||
created_at=member.created_at,
|
||||
updated_at=member.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=MemberList)
|
||||
async def list_members(
|
||||
_: RequireRead,
|
||||
@@ -74,45 +28,12 @@ async def list_members(
|
||||
count_query = select(func.count()).select_from(Member)
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
|
||||
# Get members with nodes eagerly loaded
|
||||
query = (
|
||||
select(Member)
|
||||
.options(selectinload(Member.nodes))
|
||||
.order_by(Member.name)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
# Get members ordered by name
|
||||
query = select(Member).order_by(Member.name).limit(limit).offset(offset)
|
||||
members = list(session.execute(query).scalars().all())
|
||||
|
||||
# Collect all public keys from member nodes
|
||||
all_public_keys = set()
|
||||
for m in members:
|
||||
for mn in m.nodes:
|
||||
all_public_keys.add(mn.public_key)
|
||||
|
||||
# Fetch node info for all public keys in one query
|
||||
node_info: dict[str, dict] = {}
|
||||
if all_public_keys:
|
||||
node_query = (
|
||||
select(Node)
|
||||
.options(selectinload(Node.tags))
|
||||
.where(Node.public_key.in_(all_public_keys))
|
||||
)
|
||||
nodes = session.execute(node_query).scalars().all()
|
||||
for node in nodes:
|
||||
friendly_name = None
|
||||
for tag in node.tags:
|
||||
if tag.key == "friendly_name":
|
||||
friendly_name = tag.value
|
||||
break
|
||||
node_info[node.public_key] = {
|
||||
"name": node.name,
|
||||
"adv_type": node.adv_type,
|
||||
"friendly_name": friendly_name,
|
||||
}
|
||||
|
||||
return MemberList(
|
||||
items=[_member_to_read(m, node_info) for m in members],
|
||||
items=[MemberRead.model_validate(m) for m in members],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
@@ -126,37 +47,13 @@ async def get_member(
|
||||
member_id: str,
|
||||
) -> MemberRead:
|
||||
"""Get a specific member by ID."""
|
||||
query = (
|
||||
select(Member).options(selectinload(Member.nodes)).where(Member.id == member_id)
|
||||
)
|
||||
query = select(Member).where(Member.id == member_id)
|
||||
member = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
# Fetch node info for member's nodes
|
||||
node_info: dict[str, dict] = {}
|
||||
public_keys = [mn.public_key for mn in member.nodes]
|
||||
if public_keys:
|
||||
node_query = (
|
||||
select(Node)
|
||||
.options(selectinload(Node.tags))
|
||||
.where(Node.public_key.in_(public_keys))
|
||||
)
|
||||
nodes = session.execute(node_query).scalars().all()
|
||||
for node in nodes:
|
||||
friendly_name = None
|
||||
for tag in node.tags:
|
||||
if tag.key == "friendly_name":
|
||||
friendly_name = tag.value
|
||||
break
|
||||
node_info[node.public_key] = {
|
||||
"name": node.name,
|
||||
"adv_type": node.adv_type,
|
||||
"friendly_name": friendly_name,
|
||||
}
|
||||
|
||||
return _member_to_read(member, node_info)
|
||||
return MemberRead.model_validate(member)
|
||||
|
||||
|
||||
@router.post("", response_model=MemberRead, status_code=201)
|
||||
@@ -166,8 +63,18 @@ async def create_member(
|
||||
member: MemberCreate,
|
||||
) -> MemberRead:
|
||||
"""Create a new member."""
|
||||
# Check if member_id already exists
|
||||
query = select(Member).where(Member.member_id == member.member_id)
|
||||
existing = session.execute(query).scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Member with member_id '{member.member_id}' already exists",
|
||||
)
|
||||
|
||||
# Create member
|
||||
new_member = Member(
|
||||
member_id=member.member_id,
|
||||
name=member.name,
|
||||
callsign=member.callsign,
|
||||
role=member.role,
|
||||
@@ -175,18 +82,6 @@ async def create_member(
|
||||
contact=member.contact,
|
||||
)
|
||||
session.add(new_member)
|
||||
session.flush() # Get the ID for the member
|
||||
|
||||
# Add nodes if provided
|
||||
if member.nodes:
|
||||
for node_data in member.nodes:
|
||||
node = MemberNode(
|
||||
member_id=new_member.id,
|
||||
public_key=node_data.public_key.lower(),
|
||||
node_role=node_data.node_role,
|
||||
)
|
||||
session.add(node)
|
||||
|
||||
session.commit()
|
||||
session.refresh(new_member)
|
||||
|
||||
@@ -201,15 +96,25 @@ async def update_member(
|
||||
member: MemberUpdate,
|
||||
) -> MemberRead:
|
||||
"""Update a member."""
|
||||
query = (
|
||||
select(Member).options(selectinload(Member.nodes)).where(Member.id == member_id)
|
||||
)
|
||||
query = select(Member).where(Member.id == member_id)
|
||||
existing = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
# Update fields
|
||||
if member.member_id is not None:
|
||||
# Check if new member_id is already taken by another member
|
||||
check_query = select(Member).where(
|
||||
Member.member_id == member.member_id, Member.id != member_id
|
||||
)
|
||||
collision = session.execute(check_query).scalar_one_or_none()
|
||||
if collision:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Member with member_id '{member.member_id}' already exists",
|
||||
)
|
||||
existing.member_id = member.member_id
|
||||
if member.name is not None:
|
||||
existing.name = member.name
|
||||
if member.callsign is not None:
|
||||
@@ -221,20 +126,6 @@ async def update_member(
|
||||
if member.contact is not None:
|
||||
existing.contact = member.contact
|
||||
|
||||
# Update nodes if provided (replaces existing nodes)
|
||||
if member.nodes is not None:
|
||||
# Clear existing nodes
|
||||
existing.nodes.clear()
|
||||
|
||||
# Add new nodes
|
||||
for node_data in member.nodes:
|
||||
node = MemberNode(
|
||||
member_id=existing.id,
|
||||
public_key=node_data.public_key.lower(),
|
||||
node_role=node_data.node_role,
|
||||
)
|
||||
existing.nodes.append(node)
|
||||
|
||||
session.commit()
|
||||
session.refresh(existing)
|
||||
|
||||
|
||||
@@ -15,12 +15,12 @@ from meshcore_hub.common.schemas.messages import MessageList, MessageRead, Recei
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_friendly_name(node: Optional[Node]) -> Optional[str]:
|
||||
"""Extract friendly_name tag from a node's tags."""
|
||||
def _get_tag_name(node: Optional[Node]) -> Optional[str]:
|
||||
"""Extract name tag from a node's tags."""
|
||||
if not node or not node.tags:
|
||||
return None
|
||||
for tag in node.tags:
|
||||
if tag.key == "friendly_name":
|
||||
if tag.key == "name":
|
||||
return tag.value
|
||||
return None
|
||||
|
||||
@@ -64,17 +64,17 @@ def _fetch_receivers_for_events(
|
||||
# Group by event_hash
|
||||
receivers_by_hash: dict[str, list[ReceiverInfo]] = {}
|
||||
|
||||
# Get friendly names for receiver nodes
|
||||
# Get tag names for receiver nodes
|
||||
node_ids = [r.node_id for r in results]
|
||||
friendly_names: dict[str, str] = {}
|
||||
tag_names: dict[str, str] = {}
|
||||
if node_ids:
|
||||
fn_query = (
|
||||
tag_query = (
|
||||
select(NodeTag.node_id, NodeTag.value)
|
||||
.where(NodeTag.node_id.in_(node_ids))
|
||||
.where(NodeTag.key == "friendly_name")
|
||||
.where(NodeTag.key == "name")
|
||||
)
|
||||
for node_id, value in session.execute(fn_query).all():
|
||||
friendly_names[node_id] = value
|
||||
for node_id, value in session.execute(tag_query).all():
|
||||
tag_names[node_id] = value
|
||||
|
||||
for row in results:
|
||||
if row.event_hash not in receivers_by_hash:
|
||||
@@ -85,7 +85,7 @@ def _fetch_receivers_for_events(
|
||||
node_id=row.node_id,
|
||||
public_key=row.public_key,
|
||||
name=row.name,
|
||||
friendly_name=friendly_names.get(row.node_id),
|
||||
tag_name=tag_names.get(row.node_id),
|
||||
snr=row.snr,
|
||||
received_at=row.received_at,
|
||||
)
|
||||
@@ -153,10 +153,10 @@ async def list_messages(
|
||||
# Execute
|
||||
results = session.execute(query).all()
|
||||
|
||||
# Look up sender names and friendly_names for senders with pubkey_prefix
|
||||
# Look up sender names and tag names for senders with pubkey_prefix
|
||||
pubkey_prefixes = [r[0].pubkey_prefix for r in results if r[0].pubkey_prefix]
|
||||
sender_names: dict[str, str] = {}
|
||||
friendly_names: dict[str, str] = {}
|
||||
sender_tag_names: dict[str, str] = {}
|
||||
if pubkey_prefixes:
|
||||
# Find nodes whose public_key starts with any of these prefixes
|
||||
for prefix in set(pubkey_prefixes):
|
||||
@@ -168,15 +168,15 @@ async def list_messages(
|
||||
if name:
|
||||
sender_names[public_key[:12]] = name
|
||||
|
||||
# Get friendly_name tag
|
||||
friendly_name_query = (
|
||||
# Get name tag
|
||||
tag_name_query = (
|
||||
select(Node.public_key, NodeTag.value)
|
||||
.join(NodeTag, Node.id == NodeTag.node_id)
|
||||
.where(Node.public_key.startswith(prefix))
|
||||
.where(NodeTag.key == "friendly_name")
|
||||
.where(NodeTag.key == "name")
|
||||
)
|
||||
for public_key, value in session.execute(friendly_name_query).all():
|
||||
friendly_names[public_key[:12]] = value
|
||||
for public_key, value in session.execute(tag_name_query).all():
|
||||
sender_tag_names[public_key[:12]] = value
|
||||
|
||||
# Collect receiver node IDs to fetch tags
|
||||
receiver_ids = set()
|
||||
@@ -214,14 +214,14 @@ async def list_messages(
|
||||
"receiver_node_id": m.receiver_node_id,
|
||||
"received_by": receiver_pk,
|
||||
"receiver_name": receiver_name,
|
||||
"receiver_friendly_name": _get_friendly_name(receiver_node),
|
||||
"receiver_tag_name": _get_tag_name(receiver_node),
|
||||
"message_type": m.message_type,
|
||||
"pubkey_prefix": m.pubkey_prefix,
|
||||
"sender_name": (
|
||||
sender_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
|
||||
),
|
||||
"sender_friendly_name": (
|
||||
friendly_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
|
||||
"sender_tag_name": (
|
||||
sender_tag_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
|
||||
),
|
||||
"channel_idx": m.channel_idx,
|
||||
"text": m.text,
|
||||
|
||||
@@ -6,7 +6,13 @@ from sqlalchemy import select
|
||||
from meshcore_hub.api.auth import RequireAdmin, RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Node, NodeTag
|
||||
from meshcore_hub.common.schemas.nodes import NodeTagCreate, NodeTagRead, NodeTagUpdate
|
||||
from meshcore_hub.common.schemas.nodes import (
|
||||
NodeTagCreate,
|
||||
NodeTagMove,
|
||||
NodeTagRead,
|
||||
NodeTagsCopyResult,
|
||||
NodeTagUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -130,6 +136,131 @@ async def update_node_tag(
|
||||
return NodeTagRead.model_validate(node_tag)
|
||||
|
||||
|
||||
@router.put("/nodes/{public_key}/tags/{key}/move", response_model=NodeTagRead)
|
||||
async def move_node_tag(
|
||||
_: RequireAdmin,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
key: str,
|
||||
data: NodeTagMove,
|
||||
) -> NodeTagRead:
|
||||
"""Move a node tag to a different node."""
|
||||
# Check if source and destination are the same
|
||||
if public_key == data.new_public_key:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Source and destination nodes are the same",
|
||||
)
|
||||
|
||||
# Find source node
|
||||
source_query = select(Node).where(Node.public_key == public_key)
|
||||
source_node = session.execute(source_query).scalar_one_or_none()
|
||||
|
||||
if not source_node:
|
||||
raise HTTPException(status_code=404, detail="Source node not found")
|
||||
|
||||
# Find tag
|
||||
tag_query = select(NodeTag).where(
|
||||
(NodeTag.node_id == source_node.id) & (NodeTag.key == key)
|
||||
)
|
||||
node_tag = session.execute(tag_query).scalar_one_or_none()
|
||||
|
||||
if not node_tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
# Find destination node
|
||||
dest_query = select(Node).where(Node.public_key == data.new_public_key)
|
||||
dest_node = session.execute(dest_query).scalar_one_or_none()
|
||||
|
||||
if not dest_node:
|
||||
raise HTTPException(status_code=404, detail="Destination node not found")
|
||||
|
||||
# Check if tag already exists on destination node
|
||||
conflict_query = select(NodeTag).where(
|
||||
(NodeTag.node_id == dest_node.id) & (NodeTag.key == key)
|
||||
)
|
||||
conflict = session.execute(conflict_query).scalar_one_or_none()
|
||||
|
||||
if conflict:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Tag '{key}' already exists on destination node",
|
||||
)
|
||||
|
||||
# Move tag to destination node
|
||||
node_tag.node_id = dest_node.id
|
||||
session.commit()
|
||||
session.refresh(node_tag)
|
||||
|
||||
return NodeTagRead.model_validate(node_tag)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/nodes/{public_key}/tags/copy-to/{dest_public_key}",
|
||||
response_model=NodeTagsCopyResult,
|
||||
)
|
||||
async def copy_all_tags(
|
||||
_: RequireAdmin,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
dest_public_key: str,
|
||||
) -> NodeTagsCopyResult:
|
||||
"""Copy all tags from one node to another.
|
||||
|
||||
Tags that already exist on the destination node are skipped.
|
||||
"""
|
||||
# Check if source and destination are the same
|
||||
if public_key == dest_public_key:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Source and destination nodes are the same",
|
||||
)
|
||||
|
||||
# Find source node
|
||||
source_query = select(Node).where(Node.public_key == public_key)
|
||||
source_node = session.execute(source_query).scalar_one_or_none()
|
||||
|
||||
if not source_node:
|
||||
raise HTTPException(status_code=404, detail="Source node not found")
|
||||
|
||||
# Find destination node
|
||||
dest_query = select(Node).where(Node.public_key == dest_public_key)
|
||||
dest_node = session.execute(dest_query).scalar_one_or_none()
|
||||
|
||||
if not dest_node:
|
||||
raise HTTPException(status_code=404, detail="Destination node not found")
|
||||
|
||||
# Get existing tags on destination node
|
||||
existing_query = select(NodeTag.key).where(NodeTag.node_id == dest_node.id)
|
||||
existing_keys = set(session.execute(existing_query).scalars().all())
|
||||
|
||||
# Copy tags
|
||||
copied = 0
|
||||
skipped_keys = []
|
||||
|
||||
for tag in source_node.tags:
|
||||
if tag.key in existing_keys:
|
||||
skipped_keys.append(tag.key)
|
||||
continue
|
||||
|
||||
new_tag = NodeTag(
|
||||
node_id=dest_node.id,
|
||||
key=tag.key,
|
||||
value=tag.value,
|
||||
value_type=tag.value_type,
|
||||
)
|
||||
session.add(new_tag)
|
||||
copied += 1
|
||||
|
||||
session.commit()
|
||||
|
||||
return NodeTagsCopyResult(
|
||||
copied=copied,
|
||||
skipped=len(skipped_keys),
|
||||
skipped_keys=skipped_keys,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/nodes/{public_key}/tags/{key}", status_code=204)
|
||||
async def delete_node_tag(
|
||||
_: RequireAdmin,
|
||||
@@ -156,3 +287,27 @@ async def delete_node_tag(
|
||||
|
||||
session.delete(node_tag)
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.delete("/nodes/{public_key}/tags")
|
||||
async def delete_all_node_tags(
|
||||
_: RequireAdmin,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
) -> dict:
|
||||
"""Delete all tags for a node."""
|
||||
# Find node
|
||||
node_query = select(Node).where(Node.public_key == public_key)
|
||||
node = session.execute(node_query).scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
# Count and delete all tags
|
||||
count = len(node.tags)
|
||||
for tag in node.tags:
|
||||
session.delete(tag)
|
||||
|
||||
session.commit()
|
||||
|
||||
return {"deleted": count}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from fastapi import APIRouter, HTTPException, Path, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from meshcore_hub.api.auth import RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Node
|
||||
from meshcore_hub.common.models import Node, NodeTag
|
||||
from meshcore_hub.common.schemas.nodes import NodeList, NodeRead
|
||||
|
||||
router = APIRouter()
|
||||
@@ -17,28 +18,63 @@ router = APIRouter()
|
||||
async def list_nodes(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
search: Optional[str] = Query(None, description="Search in name or public key"),
|
||||
search: Optional[str] = Query(
|
||||
None, description="Search in name tag, node name, or public key"
|
||||
),
|
||||
adv_type: Optional[str] = Query(None, description="Filter by advertisement type"),
|
||||
member_id: Optional[str] = Query(None, description="Filter by member_id tag value"),
|
||||
role: Optional[str] = Query(None, description="Filter by role tag value"),
|
||||
limit: int = Query(50, ge=1, le=500, description="Page size"),
|
||||
offset: int = Query(0, ge=0, description="Page offset"),
|
||||
) -> NodeList:
|
||||
"""List all nodes with pagination and filtering."""
|
||||
# Build query
|
||||
query = select(Node)
|
||||
# Build base query with tags loaded
|
||||
query = select(Node).options(selectinload(Node.tags))
|
||||
|
||||
if search:
|
||||
# Search in public key, node name, or name tag
|
||||
# For name tag search, we need to join with NodeTag
|
||||
search_pattern = f"%{search}%"
|
||||
query = query.where(
|
||||
(Node.name.ilike(f"%{search}%")) | (Node.public_key.ilike(f"%{search}%"))
|
||||
or_(
|
||||
Node.public_key.ilike(search_pattern),
|
||||
Node.name.ilike(search_pattern),
|
||||
Node.id.in_(
|
||||
select(NodeTag.node_id).where(
|
||||
NodeTag.key == "name", NodeTag.value.ilike(search_pattern)
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if adv_type:
|
||||
query = query.where(Node.adv_type == adv_type)
|
||||
|
||||
if member_id:
|
||||
# Filter nodes that have a member_id tag with the specified value
|
||||
query = query.where(
|
||||
Node.id.in_(
|
||||
select(NodeTag.node_id).where(
|
||||
NodeTag.key == "member_id", NodeTag.value == member_id
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if role:
|
||||
# Filter nodes that have a role tag with the specified value
|
||||
query = query.where(
|
||||
Node.id.in_(
|
||||
select(NodeTag.node_id).where(
|
||||
NodeTag.key == "role", NodeTag.value == role
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
|
||||
# Apply pagination
|
||||
# Apply pagination and ordering
|
||||
query = query.order_by(Node.last_seen.desc()).offset(offset).limit(limit)
|
||||
|
||||
# Execute
|
||||
@@ -52,14 +88,43 @@ async def list_nodes(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{public_key}", response_model=NodeRead)
|
||||
async def get_node(
|
||||
@router.get("/prefix/{prefix}", response_model=NodeRead)
|
||||
async def get_node_by_prefix(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
prefix: str = Path(description="Public key prefix to search for"),
|
||||
) -> NodeRead:
|
||||
"""Get a single node by public key."""
|
||||
query = select(Node).where(Node.public_key == public_key)
|
||||
"""Get a single node by public key prefix.
|
||||
|
||||
Returns the first node (alphabetically by public_key) that matches the prefix.
|
||||
"""
|
||||
query = (
|
||||
select(Node)
|
||||
.options(selectinload(Node.tags))
|
||||
.where(Node.public_key.startswith(prefix))
|
||||
.order_by(Node.public_key)
|
||||
.limit(1)
|
||||
)
|
||||
node = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
return NodeRead.model_validate(node)
|
||||
|
||||
|
||||
@router.get("/{public_key}", response_model=NodeRead)
|
||||
async def get_node(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
public_key: str = Path(description="Full 64-character public key"),
|
||||
) -> NodeRead:
|
||||
"""Get a single node by exact public key match."""
|
||||
query = (
|
||||
select(Node)
|
||||
.options(selectinload(Node.tags))
|
||||
.where(Node.public_key == public_key)
|
||||
)
|
||||
node = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
|
||||
@@ -170,8 +170,8 @@ def _run_collector_service(
|
||||
) -> None:
|
||||
"""Run the collector service.
|
||||
|
||||
On startup, automatically seeds the database from YAML files in seed_home
|
||||
if they exist.
|
||||
Note: Seed data import should be done using the 'meshcore-hub collector seed'
|
||||
command or the dedicated seed container before starting the collector service.
|
||||
|
||||
Webhooks can be configured via environment variables:
|
||||
- WEBHOOK_ADVERTISEMENT_URL: Webhook for advertisement events
|
||||
@@ -193,31 +193,6 @@ def _run_collector_service(
|
||||
click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {prefix})")
|
||||
click.echo(f"Database: {database_url}")
|
||||
|
||||
# Initialize database (schema managed by Alembic migrations)
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
|
||||
db = DatabaseManager(database_url)
|
||||
|
||||
# Auto-seed from seed files on startup
|
||||
click.echo("")
|
||||
click.echo("Checking for seed files...")
|
||||
seed_home_path = Path(seed_home)
|
||||
node_tags_exists = (seed_home_path / "node_tags.yaml").exists()
|
||||
members_exists = (seed_home_path / "members.yaml").exists()
|
||||
|
||||
if node_tags_exists or members_exists:
|
||||
click.echo("Running seed import...")
|
||||
_run_seed_import(
|
||||
seed_home=seed_home,
|
||||
db=db,
|
||||
create_nodes=True,
|
||||
verbose=True,
|
||||
)
|
||||
else:
|
||||
click.echo(f"No seed files found in {seed_home}")
|
||||
|
||||
db.dispose()
|
||||
|
||||
# Load webhook configuration from settings
|
||||
from meshcore_hub.collector.webhook import (
|
||||
WebhookDispatcher,
|
||||
@@ -383,8 +358,11 @@ def _run_seed_import(
|
||||
file_path=str(node_tags_file),
|
||||
db=db,
|
||||
create_nodes=create_nodes,
|
||||
clear_existing=True,
|
||||
)
|
||||
if verbose:
|
||||
if stats["deleted"]:
|
||||
click.echo(f" Deleted {stats['deleted']} existing tags")
|
||||
click.echo(
|
||||
f" Tags: {stats['created']} created, {stats['updated']} updated"
|
||||
)
|
||||
@@ -428,16 +406,24 @@ def _run_seed_import(
|
||||
default=False,
|
||||
help="Skip tags for nodes that don't exist (default: create nodes)",
|
||||
)
|
||||
@click.option(
|
||||
"--clear-existing",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Delete all existing tags before importing",
|
||||
)
|
||||
@click.pass_context
|
||||
def import_tags_cmd(
|
||||
ctx: click.Context,
|
||||
file: str | None,
|
||||
no_create_nodes: bool,
|
||||
clear_existing: bool,
|
||||
) -> None:
|
||||
"""Import node tags from a YAML file.
|
||||
|
||||
Reads a YAML file containing tag definitions and upserts them
|
||||
into the database. Existing tags are updated, new tags are created.
|
||||
into the database. By default, existing tags are updated and new tags are created.
|
||||
Use --clear-existing to delete all tags before importing.
|
||||
|
||||
FILE is the path to the YAML file containing tags.
|
||||
If not provided, defaults to {SEED_HOME}/node_tags.yaml.
|
||||
@@ -447,12 +433,12 @@ def import_tags_cmd(
|
||||
\b
|
||||
0123456789abcdef...:
|
||||
friendly_name: My Node
|
||||
location:
|
||||
value: "52.0,1.0"
|
||||
type: coordinate
|
||||
altitude:
|
||||
value: "150"
|
||||
type: number
|
||||
active:
|
||||
value: "true"
|
||||
type: boolean
|
||||
|
||||
Shorthand is also supported (string values with default type):
|
||||
|
||||
@@ -461,7 +447,7 @@ def import_tags_cmd(
|
||||
friendly_name: My Node
|
||||
role: gateway
|
||||
|
||||
Supported types: string, number, boolean, coordinate
|
||||
Supported types: string, number, boolean
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
@@ -492,11 +478,14 @@ def import_tags_cmd(
|
||||
file_path=tags_file,
|
||||
db=db,
|
||||
create_nodes=not no_create_nodes,
|
||||
clear_existing=clear_existing,
|
||||
)
|
||||
|
||||
# Report results
|
||||
click.echo("")
|
||||
click.echo("Import complete:")
|
||||
if stats["deleted"]:
|
||||
click.echo(f" Tags deleted: {stats['deleted']}")
|
||||
click.echo(f" Total tags in file: {stats['total']}")
|
||||
click.echo(f" Tags created: {stats['created']}")
|
||||
click.echo(f" Tags updated: {stats['updated']}")
|
||||
@@ -674,3 +663,212 @@ def cleanup_cmd(
|
||||
db.dispose()
|
||||
click.echo("")
|
||||
click.echo("Cleanup complete." if not dry_run else "Dry run complete.")
|
||||
|
||||
|
||||
@collector.command("truncate")
|
||||
@click.option(
|
||||
"--members",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate members table",
|
||||
)
|
||||
@click.option(
|
||||
"--nodes",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate nodes table (also clears tags, advertisements, messages, telemetry, trace paths)",
|
||||
)
|
||||
@click.option(
|
||||
"--messages",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate messages table",
|
||||
)
|
||||
@click.option(
|
||||
"--advertisements",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate advertisements table",
|
||||
)
|
||||
@click.option(
|
||||
"--telemetry",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate telemetry table",
|
||||
)
|
||||
@click.option(
|
||||
"--trace-paths",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate trace_paths table",
|
||||
)
|
||||
@click.option(
|
||||
"--event-logs",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate event_logs table",
|
||||
)
|
||||
@click.option(
|
||||
"--all",
|
||||
"truncate_all",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate ALL tables (use with caution!)",
|
||||
)
|
||||
@click.option(
|
||||
"--yes",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip confirmation prompt",
|
||||
)
|
||||
@click.pass_context
|
||||
def truncate_cmd(
|
||||
ctx: click.Context,
|
||||
members: bool,
|
||||
nodes: bool,
|
||||
messages: bool,
|
||||
advertisements: bool,
|
||||
telemetry: bool,
|
||||
trace_paths: bool,
|
||||
event_logs: bool,
|
||||
truncate_all: bool,
|
||||
yes: bool,
|
||||
) -> None:
|
||||
"""Truncate (clear) data tables.
|
||||
|
||||
WARNING: This permanently deletes data! Use with caution.
|
||||
|
||||
Examples:
|
||||
# Clear members table
|
||||
meshcore-hub collector truncate --members
|
||||
|
||||
# Clear messages and advertisements
|
||||
meshcore-hub collector truncate --messages --advertisements
|
||||
|
||||
# Clear everything (requires confirmation)
|
||||
meshcore-hub collector truncate --all
|
||||
|
||||
Note: Clearing nodes also clears all related data (tags, advertisements,
|
||||
messages, telemetry, trace paths) due to foreign key constraints.
|
||||
"""
|
||||
configure_logging(level=ctx.obj["log_level"])
|
||||
|
||||
# Determine what to truncate
|
||||
if truncate_all:
|
||||
tables_to_clear = {
|
||||
"members": True,
|
||||
"nodes": True,
|
||||
"messages": True,
|
||||
"advertisements": True,
|
||||
"telemetry": True,
|
||||
"trace_paths": True,
|
||||
"event_logs": True,
|
||||
}
|
||||
else:
|
||||
tables_to_clear = {
|
||||
"members": members,
|
||||
"nodes": nodes,
|
||||
"messages": messages,
|
||||
"advertisements": advertisements,
|
||||
"telemetry": telemetry,
|
||||
"trace_paths": trace_paths,
|
||||
"event_logs": event_logs,
|
||||
}
|
||||
|
||||
# Check if any tables selected
|
||||
if not any(tables_to_clear.values()):
|
||||
click.echo("No tables specified. Use --help to see available options.")
|
||||
return
|
||||
|
||||
# Show what will be cleared
|
||||
click.echo("Database: " + ctx.obj["database_url"])
|
||||
click.echo("")
|
||||
click.echo("The following tables will be PERMANENTLY CLEARED:")
|
||||
for table, should_clear in tables_to_clear.items():
|
||||
if should_clear:
|
||||
click.echo(f" - {table}")
|
||||
|
||||
if tables_to_clear.get("nodes"):
|
||||
click.echo("")
|
||||
click.echo(
|
||||
"WARNING: Clearing nodes will also clear all related data due to foreign keys:"
|
||||
)
|
||||
click.echo(" - node_tags")
|
||||
click.echo(" - advertisements")
|
||||
click.echo(" - messages")
|
||||
click.echo(" - telemetry")
|
||||
click.echo(" - trace_paths")
|
||||
|
||||
click.echo("")
|
||||
|
||||
# Confirm
|
||||
if not yes:
|
||||
if not click.confirm(
|
||||
"Are you sure you want to permanently delete this data?", default=False
|
||||
):
|
||||
click.echo("Aborted.")
|
||||
return
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import (
|
||||
Advertisement,
|
||||
EventLog,
|
||||
Member,
|
||||
Message,
|
||||
Node,
|
||||
NodeTag,
|
||||
Telemetry,
|
||||
TracePath,
|
||||
)
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.engine import CursorResult
|
||||
|
||||
db = DatabaseManager(ctx.obj["database_url"])
|
||||
|
||||
with db.session_scope() as session:
|
||||
# Truncate in correct order to respect foreign keys
|
||||
cleared: list[str] = []
|
||||
|
||||
# Clear members (no dependencies)
|
||||
if tables_to_clear.get("members"):
|
||||
result: CursorResult = session.execute(delete(Member)) # type: ignore
|
||||
cleared.append(f"members: {result.rowcount} rows")
|
||||
|
||||
# Clear event-specific tables first (they depend on nodes)
|
||||
if tables_to_clear.get("messages"):
|
||||
result = session.execute(delete(Message)) # type: ignore
|
||||
cleared.append(f"messages: {result.rowcount} rows")
|
||||
|
||||
if tables_to_clear.get("advertisements"):
|
||||
result = session.execute(delete(Advertisement)) # type: ignore
|
||||
cleared.append(f"advertisements: {result.rowcount} rows")
|
||||
|
||||
if tables_to_clear.get("telemetry"):
|
||||
result = session.execute(delete(Telemetry)) # type: ignore
|
||||
cleared.append(f"telemetry: {result.rowcount} rows")
|
||||
|
||||
if tables_to_clear.get("trace_paths"):
|
||||
result = session.execute(delete(TracePath)) # type: ignore
|
||||
cleared.append(f"trace_paths: {result.rowcount} rows")
|
||||
|
||||
if tables_to_clear.get("event_logs"):
|
||||
result = session.execute(delete(EventLog)) # type: ignore
|
||||
cleared.append(f"event_logs: {result.rowcount} rows")
|
||||
|
||||
# Clear nodes last (this will cascade delete tags and any remaining events)
|
||||
if tables_to_clear.get("nodes"):
|
||||
# Delete tags first (they depend on nodes)
|
||||
tag_result: CursorResult = session.execute(delete(NodeTag)) # type: ignore
|
||||
cleared.append(f"node_tags: {tag_result.rowcount} rows (cascade)")
|
||||
|
||||
# Delete nodes (will cascade to remaining related tables)
|
||||
node_result: CursorResult = session.execute(delete(Node)) # type: ignore
|
||||
cleared.append(f"nodes: {node_result.rowcount} rows")
|
||||
|
||||
db.dispose()
|
||||
|
||||
click.echo("")
|
||||
click.echo("Truncate complete. Cleared:")
|
||||
for item in cleared:
|
||||
click.echo(f" - {item}")
|
||||
click.echo("")
|
||||
|
||||
@@ -47,6 +47,10 @@ def handle_contact(
|
||||
# Device uses 'adv_name' for the advertised name
|
||||
name = payload.get("adv_name") or payload.get("name")
|
||||
|
||||
# GPS coordinates (optional)
|
||||
lat = payload.get("adv_lat")
|
||||
lon = payload.get("adv_lon")
|
||||
|
||||
logger.info(f"Processing contact: {contact_key[:12]}... adv_name={name}")
|
||||
|
||||
# Device uses numeric 'type' field, convert to string
|
||||
@@ -73,15 +77,24 @@ def handle_contact(
|
||||
node.name = name
|
||||
if node_type and not node.adv_type:
|
||||
node.adv_type = node_type
|
||||
node.last_seen = now
|
||||
# Update GPS coordinates if provided
|
||||
if lat is not None:
|
||||
node.lat = lat
|
||||
if lon is not None:
|
||||
node.lon = lon
|
||||
# Do NOT update last_seen for contact sync - only advertisement events
|
||||
# should update last_seen since that's when the node was actually seen
|
||||
else:
|
||||
# Create new node
|
||||
# Create new node from contact database
|
||||
# Set last_seen=None since we haven't actually seen this node advertise yet
|
||||
node = Node(
|
||||
public_key=contact_key,
|
||||
name=name,
|
||||
adv_type=node_type,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
last_seen=None, # Will be set when we receive an advertisement
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
)
|
||||
session.add(node)
|
||||
logger.info(f"Created node from contact: {contact_key[:12]}... ({name})")
|
||||
|
||||
@@ -5,41 +5,28 @@ from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import Member, MemberNode
|
||||
from meshcore_hub.common.models import Member
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NodeData(BaseModel):
|
||||
"""Schema for a node entry in the member import file."""
|
||||
|
||||
public_key: str = Field(..., min_length=64, max_length=64)
|
||||
node_role: Optional[str] = Field(default=None, max_length=50)
|
||||
|
||||
@field_validator("public_key")
|
||||
@classmethod
|
||||
def validate_public_key(cls, v: str) -> str:
|
||||
"""Validate and normalize public key."""
|
||||
if len(v) != 64:
|
||||
raise ValueError(f"public_key must be 64 characters, got {len(v)}")
|
||||
if not all(c in "0123456789abcdefABCDEF" for c in v):
|
||||
raise ValueError("public_key must be a valid hex string")
|
||||
return v.lower()
|
||||
|
||||
|
||||
class MemberData(BaseModel):
|
||||
"""Schema for a member entry in the import file."""
|
||||
"""Schema for a member entry in the import file.
|
||||
|
||||
Note: Nodes are associated with members via a 'member_id' tag on the node,
|
||||
not through this schema.
|
||||
"""
|
||||
|
||||
member_id: str = Field(..., min_length=1, max_length=100)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
callsign: Optional[str] = Field(default=None, max_length=20)
|
||||
role: Optional[str] = Field(default=None, max_length=100)
|
||||
description: Optional[str] = Field(default=None)
|
||||
contact: Optional[str] = Field(default=None, max_length=255)
|
||||
nodes: Optional[list[NodeData]] = Field(default=None)
|
||||
|
||||
|
||||
def load_members_file(file_path: str | Path) -> list[dict[str, Any]]:
|
||||
@@ -48,20 +35,16 @@ def load_members_file(file_path: str | Path) -> list[dict[str, Any]]:
|
||||
Supports two formats:
|
||||
1. List of member objects:
|
||||
|
||||
- name: Member 1
|
||||
- member_id: member1
|
||||
name: Member 1
|
||||
callsign: M1
|
||||
nodes:
|
||||
- public_key: abc123...
|
||||
node_role: chat
|
||||
|
||||
2. Object with "members" key:
|
||||
|
||||
members:
|
||||
- name: Member 1
|
||||
- member_id: member1
|
||||
name: Member 1
|
||||
callsign: M1
|
||||
nodes:
|
||||
- public_key: abc123...
|
||||
node_role: chat
|
||||
|
||||
Args:
|
||||
file_path: Path to the members YAML file
|
||||
@@ -96,6 +79,8 @@ def load_members_file(file_path: str | Path) -> list[dict[str, Any]]:
|
||||
for i, member in enumerate(members_list):
|
||||
if not isinstance(member, dict):
|
||||
raise ValueError(f"Member at index {i} must be an object")
|
||||
if "member_id" not in member:
|
||||
raise ValueError(f"Member at index {i} must have a 'member_id' field")
|
||||
if "name" not in member:
|
||||
raise ValueError(f"Member at index {i} must have a 'name' field")
|
||||
|
||||
@@ -115,9 +100,11 @@ def import_members(
|
||||
) -> dict[str, Any]:
|
||||
"""Import members from a YAML file into the database.
|
||||
|
||||
Performs upsert operations based on name - existing members are updated,
|
||||
new members are created. Nodes are synced (existing nodes removed and
|
||||
replaced with new ones from the file).
|
||||
Performs upsert operations based on member_id - existing members are updated,
|
||||
new members are created.
|
||||
|
||||
Note: Nodes are associated with members via a 'member_id' tag on the node.
|
||||
This import does not manage node associations.
|
||||
|
||||
Args:
|
||||
file_path: Path to the members YAML file
|
||||
@@ -149,14 +136,17 @@ def import_members(
|
||||
with db.session_scope() as session:
|
||||
for member_data in members_data:
|
||||
try:
|
||||
member_id = member_data["member_id"]
|
||||
name = member_data["name"]
|
||||
|
||||
# Find existing member by name
|
||||
query = select(Member).where(Member.name == name)
|
||||
# Find existing member by member_id
|
||||
query = select(Member).where(Member.member_id == member_id)
|
||||
existing = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
# Update existing member
|
||||
if member_data.get("name") is not None:
|
||||
existing.name = member_data["name"]
|
||||
if member_data.get("callsign") is not None:
|
||||
existing.callsign = member_data["callsign"]
|
||||
if member_data.get("role") is not None:
|
||||
@@ -166,25 +156,12 @@ def import_members(
|
||||
if member_data.get("contact") is not None:
|
||||
existing.contact = member_data["contact"]
|
||||
|
||||
# Sync nodes if provided
|
||||
if member_data.get("nodes") is not None:
|
||||
# Remove existing nodes
|
||||
existing.nodes.clear()
|
||||
|
||||
# Add new nodes
|
||||
for node_data in member_data["nodes"]:
|
||||
node = MemberNode(
|
||||
member_id=existing.id,
|
||||
public_key=node_data["public_key"],
|
||||
node_role=node_data.get("node_role"),
|
||||
)
|
||||
existing.nodes.append(node)
|
||||
|
||||
stats["updated"] += 1
|
||||
logger.debug(f"Updated member: {name}")
|
||||
logger.debug(f"Updated member: {member_id} ({name})")
|
||||
else:
|
||||
# Create new member
|
||||
new_member = Member(
|
||||
member_id=member_id,
|
||||
name=name,
|
||||
callsign=member_data.get("callsign"),
|
||||
role=member_data.get("role"),
|
||||
@@ -192,23 +169,12 @@ def import_members(
|
||||
contact=member_data.get("contact"),
|
||||
)
|
||||
session.add(new_member)
|
||||
session.flush() # Get the ID for the member
|
||||
|
||||
# Add nodes if provided
|
||||
if member_data.get("nodes"):
|
||||
for node_data in member_data["nodes"]:
|
||||
node = MemberNode(
|
||||
member_id=new_member.id,
|
||||
public_key=node_data["public_key"],
|
||||
node_role=node_data.get("node_role"),
|
||||
)
|
||||
session.add(node)
|
||||
|
||||
stats["created"] += 1
|
||||
logger.debug(f"Created member: {name}")
|
||||
logger.debug(f"Created member: {member_id} ({name})")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing member '{member_data.get('name', 'unknown')}': {e}"
|
||||
error_msg = f"Error processing member '{member_data.get('member_id', 'unknown')}' ({member_data.get('name', 'unknown')}): {e}"
|
||||
stats["errors"].append(error_msg)
|
||||
logger.error(error_msg)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import Node, NodeTag
|
||||
@@ -19,7 +19,7 @@ class TagValue(BaseModel):
|
||||
"""Schema for a tag value with type."""
|
||||
|
||||
value: str | None = None
|
||||
type: str = Field(default="string", pattern=r"^(string|number|boolean|coordinate)$")
|
||||
type: str = Field(default="string", pattern=r"^(string|number|boolean)$")
|
||||
|
||||
|
||||
class NodeTags(BaseModel):
|
||||
@@ -151,16 +151,19 @@ def import_tags(
|
||||
file_path: str | Path,
|
||||
db: DatabaseManager,
|
||||
create_nodes: bool = True,
|
||||
clear_existing: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Import tags from a YAML file into the database.
|
||||
|
||||
Performs upsert operations - existing tags are updated, new tags are created.
|
||||
Optionally clears all existing tags before import.
|
||||
|
||||
Args:
|
||||
file_path: Path to the tags YAML file
|
||||
db: Database manager instance
|
||||
create_nodes: If True, create nodes that don't exist. If False, skip tags
|
||||
for non-existent nodes.
|
||||
clear_existing: If True, delete all existing tags before importing.
|
||||
|
||||
Returns:
|
||||
Dictionary with import statistics:
|
||||
@@ -169,6 +172,7 @@ def import_tags(
|
||||
- updated: Number of existing tags updated
|
||||
- skipped: Number of tags skipped (node not found and create_nodes=False)
|
||||
- nodes_created: Number of new nodes created
|
||||
- deleted: Number of existing tags deleted (if clear_existing=True)
|
||||
- errors: List of error messages
|
||||
"""
|
||||
stats: dict[str, Any] = {
|
||||
@@ -177,6 +181,7 @@ def import_tags(
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"nodes_created": 0,
|
||||
"deleted": 0,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
@@ -194,6 +199,15 @@ def import_tags(
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
with db.session_scope() as session:
|
||||
# Clear all existing tags if requested
|
||||
if clear_existing:
|
||||
delete_count = (
|
||||
session.execute(select(func.count()).select_from(NodeTag)).scalar() or 0
|
||||
)
|
||||
session.execute(delete(NodeTag))
|
||||
stats["deleted"] = delete_count
|
||||
logger.info(f"Deleted {delete_count} existing tags")
|
||||
|
||||
# Cache nodes by public_key to reduce queries
|
||||
node_cache: dict[str, Node] = {}
|
||||
|
||||
@@ -232,24 +246,8 @@ def import_tags(
|
||||
tag_value = tag_data.get("value")
|
||||
tag_type = tag_data.get("type", "string")
|
||||
|
||||
# Find or create tag
|
||||
tag_query = select(NodeTag).where(
|
||||
NodeTag.node_id == node.id,
|
||||
NodeTag.key == tag_key,
|
||||
)
|
||||
existing_tag = session.execute(tag_query).scalar_one_or_none()
|
||||
|
||||
if existing_tag:
|
||||
# Update existing tag
|
||||
existing_tag.value = tag_value
|
||||
existing_tag.value_type = tag_type
|
||||
stats["updated"] += 1
|
||||
logger.debug(
|
||||
f"Updated tag {tag_key}={tag_value} "
|
||||
f"for {public_key[:12]}..."
|
||||
)
|
||||
else:
|
||||
# Create new tag
|
||||
if clear_existing:
|
||||
# When clearing, always create new tags
|
||||
new_tag = NodeTag(
|
||||
node_id=node.id,
|
||||
key=tag_key,
|
||||
@@ -262,6 +260,39 @@ def import_tags(
|
||||
f"Created tag {tag_key}={tag_value} "
|
||||
f"for {public_key[:12]}..."
|
||||
)
|
||||
else:
|
||||
# Find or create tag
|
||||
tag_query = select(NodeTag).where(
|
||||
NodeTag.node_id == node.id,
|
||||
NodeTag.key == tag_key,
|
||||
)
|
||||
existing_tag = session.execute(
|
||||
tag_query
|
||||
).scalar_one_or_none()
|
||||
|
||||
if existing_tag:
|
||||
# Update existing tag
|
||||
existing_tag.value = tag_value
|
||||
existing_tag.value_type = tag_type
|
||||
stats["updated"] += 1
|
||||
logger.debug(
|
||||
f"Updated tag {tag_key}={tag_value} "
|
||||
f"for {public_key[:12]}..."
|
||||
)
|
||||
else:
|
||||
# Create new tag
|
||||
new_tag = NodeTag(
|
||||
node_id=node.id,
|
||||
key=tag_key,
|
||||
value=tag_value,
|
||||
value_type=tag_type,
|
||||
)
|
||||
session.add(new_tag)
|
||||
stats["created"] += 1
|
||||
logger.debug(
|
||||
f"Created tag {tag_key}={tag_value} "
|
||||
f"for {public_key[:12]}..."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing tag {tag_key} for {public_key[:12]}...: {e}"
|
||||
|
||||
@@ -78,6 +78,17 @@ class InterfaceSettings(CommonSettings):
|
||||
default=None, description="Device/node name (optional)"
|
||||
)
|
||||
|
||||
# Contact cleanup settings
|
||||
contact_cleanup_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Enable automatic removal of stale contacts from companion node",
|
||||
)
|
||||
contact_cleanup_days: int = Field(
|
||||
default=7,
|
||||
description="Remove contacts not advertised for this many days",
|
||||
ge=1,
|
||||
)
|
||||
|
||||
|
||||
class CollectorSettings(CommonSettings):
|
||||
"""Settings for the Collector component."""
|
||||
@@ -242,6 +253,34 @@ 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)",
|
||||
)
|
||||
|
||||
# Locale / language (default: English)
|
||||
web_locale: str = Field(
|
||||
default="en",
|
||||
description="Locale/language for the web dashboard (e.g. 'en')",
|
||||
)
|
||||
|
||||
# Auto-refresh interval for list pages
|
||||
web_auto_refresh_seconds: int = Field(
|
||||
default=30,
|
||||
description="Auto-refresh interval in seconds for list pages (0 to disable)",
|
||||
ge=0,
|
||||
)
|
||||
|
||||
# Admin interface (disabled by default for security)
|
||||
web_admin_enabled: bool = Field(
|
||||
default=False,
|
||||
description="Enable admin interface at /a/ (requires OAuth2Proxy in front)",
|
||||
)
|
||||
|
||||
# API connection
|
||||
api_base_url: str = Field(
|
||||
default="http://localhost:8000",
|
||||
@@ -274,10 +313,80 @@ 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"
|
||||
)
|
||||
|
||||
# Feature flags (control which pages are visible in the web dashboard)
|
||||
feature_dashboard: bool = Field(
|
||||
default=True, description="Enable the /dashboard page"
|
||||
)
|
||||
feature_nodes: bool = Field(default=True, description="Enable the /nodes pages")
|
||||
feature_advertisements: bool = Field(
|
||||
default=True, description="Enable the /advertisements page"
|
||||
)
|
||||
feature_messages: bool = Field(
|
||||
default=True, description="Enable the /messages page"
|
||||
)
|
||||
feature_map: bool = Field(
|
||||
default=True, description="Enable the /map page and /map/data endpoint"
|
||||
)
|
||||
feature_members: bool = Field(default=True, description="Enable the /members page")
|
||||
feature_pages: bool = Field(
|
||||
default=True, description="Enable custom markdown pages"
|
||||
)
|
||||
|
||||
# Content directory (contains pages/ and media/ subdirectories)
|
||||
content_home: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Directory containing custom content (pages/, media/) (default: ./content)",
|
||||
)
|
||||
|
||||
@property
|
||||
def features(self) -> dict[str, bool]:
|
||||
"""Get feature flags as a dictionary.
|
||||
|
||||
Automatic dependencies:
|
||||
- Dashboard requires at least one of nodes/advertisements/messages.
|
||||
- Map requires nodes (map displays node locations).
|
||||
"""
|
||||
has_dashboard_content = (
|
||||
self.feature_nodes or self.feature_advertisements or self.feature_messages
|
||||
)
|
||||
return {
|
||||
"dashboard": self.feature_dashboard and has_dashboard_content,
|
||||
"nodes": self.feature_nodes,
|
||||
"advertisements": self.feature_advertisements,
|
||||
"messages": self.feature_messages,
|
||||
"map": self.feature_map and self.feature_nodes,
|
||||
"members": self.feature_members,
|
||||
"pages": self.feature_pages,
|
||||
}
|
||||
|
||||
@property
|
||||
def effective_content_home(self) -> str:
|
||||
"""Get the effective content home directory."""
|
||||
from pathlib import Path
|
||||
|
||||
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:
|
||||
"""Get the web data directory path."""
|
||||
|
||||
@@ -98,6 +98,15 @@ class DatabaseManager:
|
||||
echo: Enable SQL query logging
|
||||
"""
|
||||
self.database_url = database_url
|
||||
|
||||
# Ensure parent directory exists for SQLite databases
|
||||
if database_url.startswith("sqlite:///"):
|
||||
from pathlib import Path
|
||||
|
||||
# Extract path from sqlite:///path/to/db.sqlite
|
||||
db_path = Path(database_url.replace("sqlite:///", ""))
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.engine = create_database_engine(database_url, echo=echo)
|
||||
self.session_factory = create_session_factory(self.engine)
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ def compute_advertisement_hash(
|
||||
adv_type: Optional[str] = None,
|
||||
flags: Optional[int] = None,
|
||||
received_at: Optional[datetime] = None,
|
||||
bucket_seconds: int = 30,
|
||||
bucket_seconds: int = 120,
|
||||
) -> str:
|
||||
"""Compute a deterministic hash for an advertisement.
|
||||
|
||||
@@ -104,7 +104,7 @@ def compute_telemetry_hash(
|
||||
node_public_key: str,
|
||||
parsed_data: Optional[dict] = None,
|
||||
received_at: Optional[datetime] = None,
|
||||
bucket_seconds: int = 30,
|
||||
bucket_seconds: int = 120,
|
||||
) -> str:
|
||||
"""Compute a deterministic hash for a telemetry record.
|
||||
|
||||
|
||||
81
src/meshcore_hub/common/i18n.py
Normal file
81
src/meshcore_hub/common/i18n.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Lightweight i18n support for MeshCore Hub.
|
||||
|
||||
Loads JSON translation files and provides a ``t()`` lookup function
|
||||
that is shared between the Python (Jinja2) and JavaScript (SPA) sides.
|
||||
The same ``en.json`` file is served as a static asset for the client and
|
||||
read from disk for server-side template rendering.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_translations: dict[str, Any] = {}
|
||||
_locale: str = "en"
|
||||
|
||||
# Directory where locale JSON files live (web/static/locales/)
|
||||
LOCALES_DIR = Path(__file__).parent.parent / "web" / "static" / "locales"
|
||||
|
||||
|
||||
def load_locale(locale: str = "en", locales_dir: Path | None = None) -> None:
|
||||
"""Load a locale's translation file into memory.
|
||||
|
||||
Args:
|
||||
locale: Language code (e.g. ``"en"``).
|
||||
locales_dir: Override directory containing ``<locale>.json`` files.
|
||||
"""
|
||||
global _translations, _locale
|
||||
directory = locales_dir or LOCALES_DIR
|
||||
path = directory / f"{locale}.json"
|
||||
if not path.exists():
|
||||
logger.warning("Locale file not found: %s – falling back to 'en'", path)
|
||||
path = directory / "en.json"
|
||||
if path.exists():
|
||||
_translations = json.loads(path.read_text(encoding="utf-8"))
|
||||
_locale = locale
|
||||
logger.info("Loaded locale '%s' from %s", locale, path)
|
||||
else:
|
||||
logger.error("No locale files found in %s", directory)
|
||||
|
||||
|
||||
def _resolve(key: str) -> Any:
|
||||
"""Walk a dot-separated key through the nested translation dict."""
|
||||
value: Any = _translations
|
||||
for part in key.split("."):
|
||||
if isinstance(value, dict):
|
||||
value = value.get(part)
|
||||
else:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def t(key: str, **kwargs: Any) -> str:
|
||||
"""Translate a key with optional interpolation.
|
||||
|
||||
Supports ``{{var}}`` placeholders in translation strings.
|
||||
|
||||
Args:
|
||||
key: Dot-separated translation key (e.g. ``"nav.home"``).
|
||||
**kwargs: Interpolation values.
|
||||
|
||||
Returns:
|
||||
Translated string, or the key itself as fallback.
|
||||
"""
|
||||
val = _resolve(key)
|
||||
|
||||
if not isinstance(val, str):
|
||||
return key
|
||||
|
||||
# Interpolation: replace {{var}} placeholders
|
||||
for k, v in kwargs.items():
|
||||
val = val.replace("{{" + k + "}}", str(v))
|
||||
|
||||
return val
|
||||
|
||||
|
||||
def get_locale() -> str:
|
||||
"""Return the currently loaded locale code."""
|
||||
return _locale
|
||||
@@ -9,7 +9,6 @@ from meshcore_hub.common.models.trace_path import TracePath
|
||||
from meshcore_hub.common.models.telemetry import Telemetry
|
||||
from meshcore_hub.common.models.event_log import EventLog
|
||||
from meshcore_hub.common.models.member import Member
|
||||
from meshcore_hub.common.models.member_node import MemberNode
|
||||
from meshcore_hub.common.models.event_receiver import EventReceiver, add_event_receiver
|
||||
|
||||
__all__ = [
|
||||
@@ -23,7 +22,6 @@ __all__ = [
|
||||
"Telemetry",
|
||||
"EventLog",
|
||||
"Member",
|
||||
"MemberNode",
|
||||
"EventReceiver",
|
||||
"add_event_receiver",
|
||||
]
|
||||
|
||||
@@ -1,36 +1,39 @@
|
||||
"""Member model for network member information."""
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore_hub.common.models.member_node import MemberNode
|
||||
|
||||
|
||||
class Member(Base, UUIDMixin, TimestampMixin):
|
||||
"""Member model for network member information.
|
||||
|
||||
Stores information about network members/operators.
|
||||
Members can have multiple associated nodes (chat, repeater, etc.).
|
||||
Nodes are associated with members via a 'member_id' tag on the node.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key
|
||||
member_id: Unique member identifier (e.g., 'walshie86')
|
||||
name: Member's display name
|
||||
callsign: Amateur radio callsign (optional)
|
||||
role: Member's role in the network (optional)
|
||||
description: Additional description (optional)
|
||||
contact: Contact information (optional)
|
||||
nodes: List of associated MemberNode records
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
"""
|
||||
|
||||
__tablename__ = "members"
|
||||
|
||||
member_id: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
@@ -52,11 +55,5 @@ class Member(Base, UUIDMixin, TimestampMixin):
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Relationship to member nodes
|
||||
nodes: Mapped[list["MemberNode"]] = relationship(
|
||||
back_populates="member",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Member(id={self.id}, name={self.name}, callsign={self.callsign})>"
|
||||
return f"<Member(id={self.id}, member_id={self.member_id}, name={self.name}, callsign={self.callsign})>"
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"""MemberNode model for associating nodes with members."""
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore_hub.common.models.member import Member
|
||||
|
||||
|
||||
class MemberNode(Base, UUIDMixin, TimestampMixin):
|
||||
"""Association model linking members to their nodes.
|
||||
|
||||
A member can have multiple nodes (e.g., chat node, repeater).
|
||||
Each node is identified by its public_key and has a role.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key
|
||||
member_id: Foreign key to the member
|
||||
public_key: Node's public key (64-char hex)
|
||||
node_role: Role of the node (e.g., 'chat', 'repeater')
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
"""
|
||||
|
||||
__tablename__ = "member_nodes"
|
||||
|
||||
member_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("members.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
public_key: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
node_role: Mapped[Optional[str]] = mapped_column(
|
||||
String(50),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Relationship back to member
|
||||
member: Mapped["Member"] = relationship(back_populates="nodes")
|
||||
|
||||
# Composite index for efficient lookups
|
||||
__table_args__ = (
|
||||
Index("ix_member_nodes_member_public_key", "member_id", "public_key"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<MemberNode(member_id={self.member_id}, public_key={self.public_key[:8]}..., role={self.node_role})>"
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, String
|
||||
from sqlalchemy import DateTime, Float, Index, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now
|
||||
@@ -23,6 +23,8 @@ class Node(Base, UUIDMixin, TimestampMixin):
|
||||
flags: Capability/status flags bitmask
|
||||
first_seen: Timestamp of first advertisement
|
||||
last_seen: Timestamp of most recent activity
|
||||
lat: GPS latitude coordinate (if available)
|
||||
lon: GPS longitude coordinate (if available)
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
"""
|
||||
@@ -52,10 +54,18 @@ class Node(Base, UUIDMixin, TimestampMixin):
|
||||
default=utc_now,
|
||||
nullable=False,
|
||||
)
|
||||
last_seen: Mapped[datetime] = mapped_column(
|
||||
last_seen: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utc_now,
|
||||
nullable=False,
|
||||
default=None,
|
||||
nullable=True,
|
||||
)
|
||||
lat: Mapped[Optional[float]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
)
|
||||
lon: Mapped[Optional[float]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
|
||||
@@ -21,7 +21,7 @@ class NodeTag(Base, UUIDMixin, TimestampMixin):
|
||||
node_id: Foreign key to nodes table
|
||||
key: Tag name/key
|
||||
value: Tag value (stored as text, can be JSON for typed values)
|
||||
value_type: Type hint (string, number, boolean, coordinate)
|
||||
value_type: Type hint (string, number, boolean)
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
"""
|
||||
|
||||
@@ -6,46 +6,19 @@ from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MemberNodeCreate(BaseModel):
|
||||
"""Schema for creating a member node association."""
|
||||
|
||||
public_key: str = Field(
|
||||
...,
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
pattern=r"^[0-9a-fA-F]{64}$",
|
||||
description="Node's public key (64-char hex)",
|
||||
)
|
||||
node_role: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=50,
|
||||
description="Role of the node (e.g., 'chat', 'repeater')",
|
||||
)
|
||||
|
||||
|
||||
class MemberNodeRead(BaseModel):
|
||||
"""Schema for reading a member node association."""
|
||||
|
||||
public_key: str = Field(..., description="Node's public key")
|
||||
node_role: Optional[str] = Field(default=None, description="Role of the node")
|
||||
created_at: datetime = Field(..., description="Creation timestamp")
|
||||
updated_at: datetime = Field(..., description="Last update timestamp")
|
||||
# Node details (populated from nodes table if available)
|
||||
node_name: Optional[str] = Field(default=None, description="Node's name from DB")
|
||||
node_adv_type: Optional[str] = Field(
|
||||
default=None, description="Node's advertisement type"
|
||||
)
|
||||
friendly_name: Optional[str] = Field(
|
||||
default=None, description="Node's friendly name tag"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MemberCreate(BaseModel):
|
||||
"""Schema for creating a member."""
|
||||
"""Schema for creating a member.
|
||||
|
||||
Note: Nodes are associated with members via a 'member_id' tag on the node,
|
||||
not through this schema.
|
||||
"""
|
||||
|
||||
member_id: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="Unique member identifier (e.g., 'walshie86')",
|
||||
)
|
||||
name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
@@ -71,15 +44,21 @@ class MemberCreate(BaseModel):
|
||||
max_length=255,
|
||||
description="Contact information",
|
||||
)
|
||||
nodes: Optional[list[MemberNodeCreate]] = Field(
|
||||
default=None,
|
||||
description="List of associated nodes",
|
||||
)
|
||||
|
||||
|
||||
class MemberUpdate(BaseModel):
|
||||
"""Schema for updating a member."""
|
||||
"""Schema for updating a member.
|
||||
|
||||
Note: Nodes are associated with members via a 'member_id' tag on the node,
|
||||
not through this schema.
|
||||
"""
|
||||
|
||||
member_id: Optional[str] = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="Unique member identifier (e.g., 'walshie86')",
|
||||
)
|
||||
name: Optional[str] = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
@@ -105,22 +84,22 @@ class MemberUpdate(BaseModel):
|
||||
max_length=255,
|
||||
description="Contact information",
|
||||
)
|
||||
nodes: Optional[list[MemberNodeCreate]] = Field(
|
||||
default=None,
|
||||
description="List of associated nodes (replaces existing nodes)",
|
||||
)
|
||||
|
||||
|
||||
class MemberRead(BaseModel):
|
||||
"""Schema for reading a member."""
|
||||
"""Schema for reading a member.
|
||||
|
||||
Note: Nodes are associated with members via a 'member_id' tag on the node.
|
||||
To find nodes for a member, query nodes with a 'member_id' tag matching this member.
|
||||
"""
|
||||
|
||||
id: str = Field(..., description="Member UUID")
|
||||
member_id: str = Field(..., description="Unique member identifier")
|
||||
name: str = Field(..., description="Member's display name")
|
||||
callsign: Optional[str] = Field(default=None, description="Amateur radio callsign")
|
||||
role: Optional[str] = Field(default=None, description="Member's role")
|
||||
description: Optional[str] = Field(default=None, description="Description")
|
||||
contact: Optional[str] = Field(default=None, description="Contact information")
|
||||
nodes: list[MemberNodeRead] = Field(default=[], description="Associated nodes")
|
||||
created_at: datetime = Field(..., description="Creation timestamp")
|
||||
updated_at: datetime = Field(..., description="Last update timestamp")
|
||||
|
||||
|
||||
@@ -12,9 +12,7 @@ class ReceiverInfo(BaseModel):
|
||||
node_id: str = Field(..., description="Receiver node UUID")
|
||||
public_key: str = Field(..., description="Receiver node public key")
|
||||
name: Optional[str] = Field(default=None, description="Receiver node name")
|
||||
friendly_name: Optional[str] = Field(
|
||||
default=None, description="Receiver friendly name from tags"
|
||||
)
|
||||
tag_name: Optional[str] = Field(default=None, description="Receiver name from tags")
|
||||
snr: Optional[float] = Field(
|
||||
default=None, description="Signal-to-noise ratio at this receiver"
|
||||
)
|
||||
@@ -31,8 +29,8 @@ class MessageRead(BaseModel):
|
||||
default=None, description="Receiving interface node public key"
|
||||
)
|
||||
receiver_name: Optional[str] = Field(default=None, description="Receiver node name")
|
||||
receiver_friendly_name: Optional[str] = Field(
|
||||
default=None, description="Receiver friendly name from tags"
|
||||
receiver_tag_name: Optional[str] = Field(
|
||||
default=None, description="Receiver name from tags"
|
||||
)
|
||||
message_type: str = Field(..., description="Message type (contact, channel)")
|
||||
pubkey_prefix: Optional[str] = Field(
|
||||
@@ -41,8 +39,8 @@ class MessageRead(BaseModel):
|
||||
sender_name: Optional[str] = Field(
|
||||
default=None, description="Sender's advertised node name"
|
||||
)
|
||||
sender_friendly_name: Optional[str] = Field(
|
||||
default=None, description="Sender's friendly name from node tags"
|
||||
sender_tag_name: Optional[str] = Field(
|
||||
default=None, description="Sender's name from node tags"
|
||||
)
|
||||
channel_idx: Optional[int] = Field(default=None, description="Channel index")
|
||||
text: str = Field(..., description="Message content")
|
||||
@@ -110,16 +108,19 @@ class AdvertisementRead(BaseModel):
|
||||
default=None, description="Receiving interface node public key"
|
||||
)
|
||||
receiver_name: Optional[str] = Field(default=None, description="Receiver node name")
|
||||
receiver_friendly_name: Optional[str] = Field(
|
||||
default=None, description="Receiver friendly name from tags"
|
||||
receiver_tag_name: Optional[str] = Field(
|
||||
default=None, description="Receiver name from tags"
|
||||
)
|
||||
public_key: str = Field(..., description="Advertised public key")
|
||||
name: Optional[str] = Field(default=None, description="Advertised name")
|
||||
node_name: Optional[str] = Field(
|
||||
default=None, description="Node name from nodes table"
|
||||
)
|
||||
node_friendly_name: Optional[str] = Field(
|
||||
default=None, description="Node friendly name from tags"
|
||||
node_tag_name: Optional[str] = Field(
|
||||
default=None, description="Node name from tags"
|
||||
)
|
||||
node_tag_description: Optional[str] = Field(
|
||||
default=None, description="Node description from tags"
|
||||
)
|
||||
adv_type: Optional[str] = Field(default=None, description="Node type")
|
||||
flags: Optional[int] = Field(default=None, description="Capability flags")
|
||||
@@ -215,7 +216,7 @@ class RecentAdvertisement(BaseModel):
|
||||
|
||||
public_key: str = Field(..., description="Node public key")
|
||||
name: Optional[str] = Field(default=None, description="Node name")
|
||||
friendly_name: Optional[str] = Field(default=None, description="Friendly name tag")
|
||||
tag_name: Optional[str] = Field(default=None, description="Name tag")
|
||||
adv_type: Optional[str] = Field(default=None, description="Node type")
|
||||
received_at: datetime = Field(..., description="When received")
|
||||
|
||||
@@ -225,8 +226,8 @@ class ChannelMessage(BaseModel):
|
||||
|
||||
text: str = Field(..., description="Message text")
|
||||
sender_name: Optional[str] = Field(default=None, description="Sender name")
|
||||
sender_friendly_name: Optional[str] = Field(
|
||||
default=None, description="Sender friendly name"
|
||||
sender_tag_name: Optional[str] = Field(
|
||||
default=None, description="Sender name from tags"
|
||||
)
|
||||
pubkey_prefix: Optional[str] = Field(
|
||||
default=None, description="Sender public key prefix"
|
||||
@@ -241,10 +242,14 @@ class DashboardStats(BaseModel):
|
||||
active_nodes: int = Field(..., description="Nodes active in last 24h")
|
||||
total_messages: int = Field(..., description="Total number of messages")
|
||||
messages_today: int = Field(..., description="Messages received today")
|
||||
messages_7d: int = Field(default=0, description="Messages received in last 7 days")
|
||||
total_advertisements: int = Field(..., description="Total advertisements")
|
||||
advertisements_24h: int = Field(
|
||||
default=0, description="Advertisements received in last 24h"
|
||||
)
|
||||
advertisements_7d: int = Field(
|
||||
default=0, description="Advertisements received in last 7 days"
|
||||
)
|
||||
recent_advertisements: list[RecentAdvertisement] = Field(
|
||||
default_factory=list, description="Last 10 advertisements"
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ class NodeTagCreate(BaseModel):
|
||||
default=None,
|
||||
description="Tag value",
|
||||
)
|
||||
value_type: Literal["string", "number", "boolean", "coordinate"] = Field(
|
||||
value_type: Literal["string", "number", "boolean"] = Field(
|
||||
default="string",
|
||||
description="Value type hint",
|
||||
)
|
||||
@@ -32,12 +32,33 @@ class NodeTagUpdate(BaseModel):
|
||||
default=None,
|
||||
description="Tag value",
|
||||
)
|
||||
value_type: Optional[Literal["string", "number", "boolean", "coordinate"]] = Field(
|
||||
value_type: Optional[Literal["string", "number", "boolean"]] = Field(
|
||||
default=None,
|
||||
description="Value type hint",
|
||||
)
|
||||
|
||||
|
||||
class NodeTagMove(BaseModel):
|
||||
"""Schema for moving a node tag to a different node."""
|
||||
|
||||
new_public_key: str = Field(
|
||||
...,
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
description="Public key of the destination node",
|
||||
)
|
||||
|
||||
|
||||
class NodeTagsCopyResult(BaseModel):
|
||||
"""Schema for bulk copy tags result."""
|
||||
|
||||
copied: int = Field(..., description="Number of tags copied")
|
||||
skipped: int = Field(..., description="Number of tags skipped (already exist)")
|
||||
skipped_keys: list[str] = Field(
|
||||
default_factory=list, description="Keys of skipped tags"
|
||||
)
|
||||
|
||||
|
||||
class NodeTagRead(BaseModel):
|
||||
"""Schema for reading a node tag."""
|
||||
|
||||
@@ -59,7 +80,11 @@ class NodeRead(BaseModel):
|
||||
adv_type: Optional[str] = Field(default=None, description="Advertisement type")
|
||||
flags: Optional[int] = Field(default=None, description="Capability flags")
|
||||
first_seen: datetime = Field(..., description="First advertisement timestamp")
|
||||
last_seen: datetime = Field(..., description="Last activity timestamp")
|
||||
last_seen: Optional[datetime] = Field(
|
||||
default=None, description="Last activity timestamp"
|
||||
)
|
||||
lat: Optional[float] = Field(default=None, description="GPS latitude coordinate")
|
||||
lon: Optional[float] = Field(default=None, description="GPS longitude coordinate")
|
||||
created_at: datetime = Field(..., description="Record creation timestamp")
|
||||
updated_at: datetime = Field(..., description="Record update timestamp")
|
||||
tags: list[NodeTagRead] = Field(default_factory=list, description="Node tags")
|
||||
@@ -82,7 +107,7 @@ class NodeFilters(BaseModel):
|
||||
|
||||
search: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Search in name or public key",
|
||||
description="Search in name tag, node name, or public key",
|
||||
)
|
||||
adv_type: Optional[str] = Field(
|
||||
default=None,
|
||||
|
||||
@@ -100,6 +100,19 @@ def interface() -> None:
|
||||
envvar="MQTT_TLS",
|
||||
help="Enable TLS/SSL for MQTT connection",
|
||||
)
|
||||
@click.option(
|
||||
"--contact-cleanup/--no-contact-cleanup",
|
||||
default=True,
|
||||
envvar="CONTACT_CLEANUP_ENABLED",
|
||||
help="Enable/disable automatic removal of stale contacts (RECEIVER mode only)",
|
||||
)
|
||||
@click.option(
|
||||
"--contact-cleanup-days",
|
||||
type=int,
|
||||
default=7,
|
||||
envvar="CONTACT_CLEANUP_DAYS",
|
||||
help="Remove contacts not advertised for this many days (RECEIVER mode only)",
|
||||
)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
|
||||
@@ -120,6 +133,8 @@ def run(
|
||||
mqtt_password: str | None,
|
||||
prefix: str,
|
||||
mqtt_tls: bool,
|
||||
contact_cleanup: bool,
|
||||
contact_cleanup_days: int,
|
||||
log_level: str,
|
||||
) -> None:
|
||||
"""Run the interface component.
|
||||
@@ -162,6 +177,8 @@ def run(
|
||||
mqtt_password=mqtt_password,
|
||||
mqtt_prefix=prefix,
|
||||
mqtt_tls=mqtt_tls,
|
||||
contact_cleanup_enabled=contact_cleanup,
|
||||
contact_cleanup_days=contact_cleanup_days,
|
||||
)
|
||||
elif mode_upper == "SENDER":
|
||||
from meshcore_hub.interface.sender import run_sender
|
||||
@@ -262,6 +279,19 @@ def run(
|
||||
envvar="MQTT_TLS",
|
||||
help="Enable TLS/SSL for MQTT connection",
|
||||
)
|
||||
@click.option(
|
||||
"--contact-cleanup/--no-contact-cleanup",
|
||||
default=True,
|
||||
envvar="CONTACT_CLEANUP_ENABLED",
|
||||
help="Enable/disable automatic removal of stale contacts",
|
||||
)
|
||||
@click.option(
|
||||
"--contact-cleanup-days",
|
||||
type=int,
|
||||
default=7,
|
||||
envvar="CONTACT_CLEANUP_DAYS",
|
||||
help="Remove contacts not advertised for this many days",
|
||||
)
|
||||
def receiver(
|
||||
port: str,
|
||||
baud: int,
|
||||
@@ -274,6 +304,8 @@ def receiver(
|
||||
mqtt_password: str | None,
|
||||
prefix: str,
|
||||
mqtt_tls: bool,
|
||||
contact_cleanup: bool,
|
||||
contact_cleanup_days: int,
|
||||
) -> None:
|
||||
"""Run interface in RECEIVER mode.
|
||||
|
||||
@@ -293,12 +325,15 @@ def receiver(
|
||||
baud=baud,
|
||||
mock=mock,
|
||||
node_address=node_address,
|
||||
device_name=device_name,
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
mqtt_username=mqtt_username,
|
||||
mqtt_password=mqtt_password,
|
||||
mqtt_prefix=prefix,
|
||||
mqtt_tls=mqtt_tls,
|
||||
contact_cleanup_enabled=contact_cleanup,
|
||||
contact_cleanup_days=contact_cleanup_days,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -193,11 +193,50 @@ class BaseMeshCoreDevice(ABC):
|
||||
|
||||
Triggers a CONTACTS event with all stored contacts from the device.
|
||||
|
||||
Note: This should only be called before the event loop is running.
|
||||
|
||||
Returns:
|
||||
True if request was sent successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def schedule_get_contacts(self) -> bool:
|
||||
"""Schedule a get_contacts request on the event loop.
|
||||
|
||||
This is safe to call from event handlers while the event loop is running.
|
||||
|
||||
Returns:
|
||||
True if request was scheduled successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def remove_contact(self, public_key: str) -> bool:
|
||||
"""Remove a contact from the device's contact database.
|
||||
|
||||
Args:
|
||||
public_key: The 64-character hex public key of the contact to remove
|
||||
|
||||
Returns:
|
||||
True if contact was removed successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def schedule_remove_contact(self, public_key: str) -> bool:
|
||||
"""Schedule a remove_contact request on the event loop.
|
||||
|
||||
This is safe to call from event handlers while the event loop is running.
|
||||
|
||||
Args:
|
||||
public_key: The 64-character hex public key of the contact to remove
|
||||
|
||||
Returns:
|
||||
True if request was scheduled successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def run(self) -> None:
|
||||
"""Run the device event loop (blocking)."""
|
||||
@@ -567,7 +606,12 @@ class MeshCoreDevice(BaseMeshCoreDevice):
|
||||
return False
|
||||
|
||||
def get_contacts(self) -> bool:
|
||||
"""Fetch contacts from device contact database."""
|
||||
"""Fetch contacts from device contact database.
|
||||
|
||||
Note: This method should only be called before the event loop is running
|
||||
(e.g., during initialization). For calling during event processing,
|
||||
use schedule_get_contacts() instead.
|
||||
"""
|
||||
if not self._connected or not self._mc:
|
||||
logger.error("Cannot get contacts: not connected")
|
||||
return False
|
||||
@@ -584,6 +628,79 @@ class MeshCoreDevice(BaseMeshCoreDevice):
|
||||
logger.error(f"Failed to get contacts: {e}")
|
||||
return False
|
||||
|
||||
def schedule_get_contacts(self) -> bool:
|
||||
"""Schedule a get_contacts request on the event loop.
|
||||
|
||||
This is safe to call from event handlers while the event loop is running.
|
||||
The request is scheduled as a task on the event loop.
|
||||
|
||||
Returns:
|
||||
True if request was scheduled, False if device not connected
|
||||
"""
|
||||
if not self._connected or not self._mc:
|
||||
logger.error("Cannot get contacts: not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
|
||||
async def _get_contacts() -> None:
|
||||
await self._mc.commands.get_contacts()
|
||||
|
||||
asyncio.run_coroutine_threadsafe(_get_contacts(), self._loop)
|
||||
logger.info("Scheduled contact sync request")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to schedule get contacts: {e}")
|
||||
return False
|
||||
|
||||
def remove_contact(self, public_key: str) -> bool:
|
||||
"""Remove a contact from the device's contact database.
|
||||
|
||||
Note: This method should only be called before the event loop is running
|
||||
(e.g., during initialization). For calling during event processing,
|
||||
use schedule_remove_contact() instead.
|
||||
"""
|
||||
if not self._connected or not self._mc:
|
||||
logger.error("Cannot remove contact: not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
|
||||
async def _remove_contact() -> None:
|
||||
await self._mc.commands.remove_contact(public_key)
|
||||
|
||||
self._loop.run_until_complete(_remove_contact())
|
||||
logger.info(f"Removed contact {public_key[:12]}...")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove contact: {e}")
|
||||
return False
|
||||
|
||||
def schedule_remove_contact(self, public_key: str) -> bool:
|
||||
"""Schedule a remove_contact request on the event loop.
|
||||
|
||||
This is safe to call from event handlers while the event loop is running.
|
||||
The request is scheduled as a task on the event loop.
|
||||
|
||||
Returns:
|
||||
True if request was scheduled, False if device not connected
|
||||
"""
|
||||
if not self._connected or not self._mc:
|
||||
logger.error("Cannot remove contact: not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
|
||||
async def _remove_contact() -> None:
|
||||
await self._mc.commands.remove_contact(public_key)
|
||||
|
||||
asyncio.run_coroutine_threadsafe(_remove_contact(), self._loop)
|
||||
logger.debug(f"Scheduled removal of contact {public_key[:12]}...")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to schedule remove contact: {e}")
|
||||
return False
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the device event loop."""
|
||||
self._running = True
|
||||
|
||||
@@ -292,7 +292,10 @@ class MockMeshCoreDevice(BaseMeshCoreDevice):
|
||||
return True
|
||||
|
||||
def get_contacts(self) -> bool:
|
||||
"""Fetch contacts from mock device contact database."""
|
||||
"""Fetch contacts from mock device contact database.
|
||||
|
||||
Note: This should only be called before the event loop is running.
|
||||
"""
|
||||
if not self._connected:
|
||||
logger.error("Cannot get contacts: not connected")
|
||||
return False
|
||||
@@ -318,6 +321,38 @@ class MockMeshCoreDevice(BaseMeshCoreDevice):
|
||||
threading.Thread(target=send_contacts, daemon=True).start()
|
||||
return True
|
||||
|
||||
def schedule_get_contacts(self) -> bool:
|
||||
"""Schedule a get_contacts request.
|
||||
|
||||
For the mock device, this is the same as get_contacts() since we
|
||||
don't have a real async event loop. The contacts are sent via a thread.
|
||||
"""
|
||||
return self.get_contacts()
|
||||
|
||||
def remove_contact(self, public_key: str) -> bool:
|
||||
"""Remove a contact from the mock device's contact database."""
|
||||
if not self._connected:
|
||||
logger.error("Cannot remove contact: not connected")
|
||||
return False
|
||||
|
||||
# Find and remove the contact from mock_config.nodes
|
||||
for i, node in enumerate(self.mock_config.nodes):
|
||||
if node.public_key == public_key:
|
||||
del self.mock_config.nodes[i]
|
||||
logger.info(f"Mock: Removed contact {public_key[:12]}...")
|
||||
return True
|
||||
|
||||
logger.warning(f"Mock: Contact {public_key[:12]}... not found")
|
||||
return True # Return True even if not found (idempotent)
|
||||
|
||||
def schedule_remove_contact(self, public_key: str) -> bool:
|
||||
"""Schedule a remove_contact request.
|
||||
|
||||
For the mock device, this is the same as remove_contact() since we
|
||||
don't have a real async event loop.
|
||||
"""
|
||||
return self.remove_contact(public_key)
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the mock device event loop."""
|
||||
self._running = True
|
||||
|
||||
@@ -20,6 +20,9 @@ from meshcore_hub.interface.device import (
|
||||
create_device,
|
||||
)
|
||||
|
||||
# Default contact cleanup settings
|
||||
DEFAULT_CONTACT_CLEANUP_DAYS = 7
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -34,6 +37,8 @@ class Receiver:
|
||||
device: BaseMeshCoreDevice,
|
||||
mqtt_client: MQTTClient,
|
||||
device_name: Optional[str] = None,
|
||||
contact_cleanup_enabled: bool = True,
|
||||
contact_cleanup_days: int = DEFAULT_CONTACT_CLEANUP_DAYS,
|
||||
):
|
||||
"""Initialize receiver.
|
||||
|
||||
@@ -41,10 +46,14 @@ class Receiver:
|
||||
device: MeshCore device instance
|
||||
mqtt_client: MQTT client instance
|
||||
device_name: Optional device/node name to set on startup
|
||||
contact_cleanup_enabled: Whether to remove stale contacts from device
|
||||
contact_cleanup_days: Remove contacts not advertised for this many days
|
||||
"""
|
||||
self.device = device
|
||||
self.mqtt = mqtt_client
|
||||
self.device_name = device_name
|
||||
self.contact_cleanup_enabled = contact_cleanup_enabled
|
||||
self.contact_cleanup_days = contact_cleanup_days
|
||||
self._running = False
|
||||
self._shutdown_event = threading.Event()
|
||||
self._device_connected = False
|
||||
@@ -144,14 +153,31 @@ class Receiver:
|
||||
|
||||
logger.debug(f"Published {event_name} event to MQTT")
|
||||
|
||||
# Trigger contact sync on advertisements
|
||||
if event_type == EventType.ADVERTISEMENT:
|
||||
self._sync_contacts()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to publish event to MQTT: {e}")
|
||||
|
||||
def _sync_contacts(self) -> None:
|
||||
"""Request contact sync from device.
|
||||
|
||||
Called when advertisements are received to ensure contact database
|
||||
stays current with all nodes on the mesh.
|
||||
"""
|
||||
logger.info("Advertisement received, triggering contact sync")
|
||||
success = self.device.schedule_get_contacts()
|
||||
if not success:
|
||||
logger.warning("Contact sync request failed")
|
||||
|
||||
def _publish_contacts(self, payload: dict[str, Any]) -> None:
|
||||
"""Publish each contact as a separate MQTT message.
|
||||
|
||||
The device returns contacts as a dict keyed by public_key.
|
||||
We split this into individual 'contact' events for cleaner processing.
|
||||
Stale contacts (not advertised for > contact_cleanup_days) are removed
|
||||
from the device and not published.
|
||||
|
||||
Args:
|
||||
payload: Dict of contacts keyed by public_key
|
||||
@@ -173,22 +199,54 @@ class Receiver:
|
||||
return
|
||||
|
||||
device_key = self.device.public_key # Capture for type narrowing
|
||||
count = 0
|
||||
current_time = int(time.time())
|
||||
stale_threshold = current_time - (self.contact_cleanup_days * 24 * 60 * 60)
|
||||
|
||||
published_count = 0
|
||||
removed_count = 0
|
||||
|
||||
for contact in contacts:
|
||||
if not isinstance(contact, dict):
|
||||
continue
|
||||
|
||||
public_key = contact.get("public_key")
|
||||
if not public_key:
|
||||
continue
|
||||
|
||||
# Check if contact is stale based on last_advert timestamp
|
||||
# Only check if cleanup is enabled and last_advert exists
|
||||
if self.contact_cleanup_enabled:
|
||||
last_advert = contact.get("last_advert")
|
||||
if last_advert is not None and last_advert > 0:
|
||||
if last_advert < stale_threshold:
|
||||
# Contact is stale - remove from device
|
||||
adv_name = contact.get("adv_name", contact.get("name", ""))
|
||||
logger.info(
|
||||
f"Removing stale contact {public_key[:12]}... "
|
||||
f"({adv_name}) - last advertised "
|
||||
f"{(current_time - last_advert) // 86400} days ago"
|
||||
)
|
||||
self.device.schedule_remove_contact(public_key)
|
||||
removed_count += 1
|
||||
continue # Don't publish stale contacts
|
||||
|
||||
try:
|
||||
self.mqtt.publish_event(
|
||||
device_key,
|
||||
"contact", # Use singular 'contact' for individual events
|
||||
contact,
|
||||
)
|
||||
count += 1
|
||||
published_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to publish contact event: {e}")
|
||||
|
||||
logger.info(f"Published {count} contact events to MQTT")
|
||||
if removed_count > 0:
|
||||
logger.info(
|
||||
f"Contact sync: published {published_count}, "
|
||||
f"removed {removed_count} stale contacts"
|
||||
)
|
||||
else:
|
||||
logger.info(f"Published {published_count} contact events to MQTT")
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the receiver."""
|
||||
@@ -291,6 +349,8 @@ def create_receiver(
|
||||
mqtt_password: Optional[str] = None,
|
||||
mqtt_prefix: str = "meshcore",
|
||||
mqtt_tls: bool = False,
|
||||
contact_cleanup_enabled: bool = True,
|
||||
contact_cleanup_days: int = DEFAULT_CONTACT_CLEANUP_DAYS,
|
||||
) -> Receiver:
|
||||
"""Create a configured receiver instance.
|
||||
|
||||
@@ -306,6 +366,8 @@ def create_receiver(
|
||||
mqtt_password: MQTT password
|
||||
mqtt_prefix: MQTT topic prefix
|
||||
mqtt_tls: Enable TLS/SSL for MQTT connection
|
||||
contact_cleanup_enabled: Whether to remove stale contacts from device
|
||||
contact_cleanup_days: Remove contacts not advertised for this many days
|
||||
|
||||
Returns:
|
||||
Configured Receiver instance
|
||||
@@ -330,7 +392,13 @@ def create_receiver(
|
||||
)
|
||||
mqtt_client = MQTTClient(mqtt_config)
|
||||
|
||||
return Receiver(device, mqtt_client, device_name=device_name)
|
||||
return Receiver(
|
||||
device,
|
||||
mqtt_client,
|
||||
device_name=device_name,
|
||||
contact_cleanup_enabled=contact_cleanup_enabled,
|
||||
contact_cleanup_days=contact_cleanup_days,
|
||||
)
|
||||
|
||||
|
||||
def run_receiver(
|
||||
@@ -345,6 +413,8 @@ def run_receiver(
|
||||
mqtt_password: Optional[str] = None,
|
||||
mqtt_prefix: str = "meshcore",
|
||||
mqtt_tls: bool = False,
|
||||
contact_cleanup_enabled: bool = True,
|
||||
contact_cleanup_days: int = DEFAULT_CONTACT_CLEANUP_DAYS,
|
||||
) -> None:
|
||||
"""Run the receiver (blocking).
|
||||
|
||||
@@ -362,6 +432,8 @@ def run_receiver(
|
||||
mqtt_password: MQTT password
|
||||
mqtt_prefix: MQTT topic prefix
|
||||
mqtt_tls: Enable TLS/SSL for MQTT connection
|
||||
contact_cleanup_enabled: Whether to remove stale contacts from device
|
||||
contact_cleanup_days: Remove contacts not advertised for this many days
|
||||
"""
|
||||
receiver = create_receiver(
|
||||
port=port,
|
||||
@@ -375,6 +447,8 @@ def run_receiver(
|
||||
mqtt_password=mqtt_password,
|
||||
mqtt_prefix=mqtt_prefix,
|
||||
mqtt_tls=mqtt_tls,
|
||||
contact_cleanup_enabled=contact_cleanup_enabled,
|
||||
contact_cleanup_days=contact_cleanup_days,
|
||||
)
|
||||
|
||||
# Set up signal handlers
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
"""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 import FastAPI, Request, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||
|
||||
from meshcore_hub import __version__
|
||||
from meshcore_hub.common.i18n import load_locale, t
|
||||
from meshcore_hub.common.schemas import RadioConfig
|
||||
from meshcore_hub.web.middleware import CacheControlMiddleware
|
||||
from meshcore_hub.web.pages import PageLoader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -47,9 +55,78 @@ 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,
|
||||
"locale": app.state.web_locale,
|
||||
"auto_refresh_seconds": app.state.auto_refresh_seconds,
|
||||
}
|
||||
|
||||
return json.dumps(config)
|
||||
|
||||
|
||||
def create_app(
|
||||
api_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
admin_enabled: bool | None = None,
|
||||
network_name: str | None = None,
|
||||
network_city: str | None = None,
|
||||
network_country: str | None = None,
|
||||
@@ -57,7 +134,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.
|
||||
|
||||
@@ -67,6 +146,7 @@ def create_app(
|
||||
Args:
|
||||
api_url: Base URL of the MeshCore Hub API
|
||||
api_key: API key for authentication
|
||||
admin_enabled: Enable admin interface at /a/
|
||||
network_name: Display name for the network
|
||||
network_city: City where the network is located
|
||||
network_country: Country where the network is located
|
||||
@@ -74,7 +154,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
|
||||
@@ -93,9 +175,28 @@ def create_app(
|
||||
redoc_url=None,
|
||||
)
|
||||
|
||||
# Trust proxy headers (X-Forwarded-Proto, X-Forwarded-For) for HTTPS detection
|
||||
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
|
||||
|
||||
# Add cache control headers based on resource type
|
||||
app.add_middleware(CacheControlMiddleware)
|
||||
|
||||
# Load i18n translations
|
||||
app.state.web_locale = settings.web_locale or "en"
|
||||
load_locale(app.state.web_locale)
|
||||
|
||||
# Auto-refresh interval
|
||||
app.state.auto_refresh_seconds = settings.web_auto_refresh_seconds
|
||||
|
||||
# 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 = (
|
||||
admin_enabled if admin_enabled is not None else settings.web_admin_enabled
|
||||
)
|
||||
app.state.network_name = network_name or settings.network_name
|
||||
app.state.network_city = network_city or settings.network_city
|
||||
app.state.network_country = network_country or settings.network_country
|
||||
@@ -111,24 +212,310 @@ 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
|
||||
templates.env.globals["t"] = t
|
||||
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."""
|
||||
@@ -145,30 +532,134 @@ 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("/")
|
||||
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."""
|
||||
base_url = _get_https_base_url(request)
|
||||
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."""
|
||||
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 = [
|
||||
(path, freq, prio)
|
||||
for path, freq, prio, feature in all_static_pages
|
||||
if feature is None or features.get(feature, True)
|
||||
]
|
||||
|
||||
urls = []
|
||||
for path, changefreq, priority in static_pages:
|
||||
urls.append(
|
||||
f" <url>\n"
|
||||
f" <loc>{base_url}{path}</loc>\n"
|
||||
f" <changefreq>{changefreq}</changefreq>\n"
|
||||
f" <priority>{priority}</priority>\n"
|
||||
f" </url>"
|
||||
)
|
||||
|
||||
if features.get("pages", True):
|
||||
page_loader = request.app.state.page_loader
|
||||
for page in page_loader.get_menu_pages():
|
||||
urls.append(
|
||||
f" <url>\n"
|
||||
f" <loc>{base_url}{page.url}</loc>\n"
|
||||
f" <changefreq>weekly</changefreq>\n"
|
||||
f" <priority>0.6</priority>\n"
|
||||
f" </url>"
|
||||
)
|
||||
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||
+ "\n".join(urls)
|
||||
+ "\n</urlset>"
|
||||
)
|
||||
|
||||
return Response(content=xml, media_type="application/xml")
|
||||
|
||||
# --- 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
|
||||
)
|
||||
|
||||
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,
|
||||
"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,
|
||||
)
|
||||
|
||||
|
||||
85
src/meshcore_hub/web/middleware.py
Normal file
85
src/meshcore_hub/web/middleware.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""HTTP caching middleware for the web component."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
|
||||
class CacheControlMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware to set appropriate Cache-Control headers based on resource type."""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
"""Initialize the middleware.
|
||||
|
||||
Args:
|
||||
app: The ASGI application to wrap.
|
||||
"""
|
||||
super().__init__(app)
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
) -> Response:
|
||||
"""Process the request and add appropriate caching headers.
|
||||
|
||||
Args:
|
||||
request: The incoming HTTP request.
|
||||
call_next: The next middleware or route handler.
|
||||
|
||||
Returns:
|
||||
The response with cache headers added.
|
||||
"""
|
||||
response: Response = await call_next(request)
|
||||
|
||||
# Skip if Cache-Control already set (explicit override)
|
||||
if "cache-control" in response.headers:
|
||||
return response
|
||||
|
||||
path = request.url.path
|
||||
query_params = request.url.query
|
||||
|
||||
# Health endpoints - never cache
|
||||
if path.startswith("/health"):
|
||||
response.headers["cache-control"] = "no-cache, no-store, must-revalidate"
|
||||
|
||||
# Static files with version parameter - long-term cache
|
||||
elif path.startswith("/static/") and "v=" in query_params:
|
||||
response.headers["cache-control"] = "public, max-age=31536000, immutable"
|
||||
|
||||
# Static files without version - short cache as fallback
|
||||
elif path.startswith("/static/"):
|
||||
response.headers["cache-control"] = "public, max-age=3600"
|
||||
|
||||
# Media files with version parameter - long-term cache
|
||||
elif path.startswith("/media/") and "v=" in query_params:
|
||||
response.headers["cache-control"] = "public, max-age=31536000, immutable"
|
||||
|
||||
# Media files without version - short cache (user may update)
|
||||
elif path.startswith("/media/"):
|
||||
response.headers["cache-control"] = "public, max-age=3600"
|
||||
|
||||
# Map data - short cache (5 minutes)
|
||||
elif path == "/map/data":
|
||||
response.headers["cache-control"] = "public, max-age=300"
|
||||
|
||||
# Custom pages - moderate cache (1 hour)
|
||||
elif path.startswith("/spa/pages/"):
|
||||
response.headers["cache-control"] = "public, max-age=3600"
|
||||
|
||||
# SEO files - moderate cache (1 hour)
|
||||
elif path in ("/robots.txt", "/sitemap.xml"):
|
||||
response.headers["cache-control"] = "public, max-age=3600"
|
||||
|
||||
# API proxy - don't add headers (pass through backend)
|
||||
elif path.startswith("/api/"):
|
||||
pass
|
||||
|
||||
# SPA shell HTML (catch-all for client-side routes) - no cache
|
||||
elif response.headers.get("content-type", "").startswith("text/html"):
|
||||
response.headers["cache-control"] = "no-cache, public"
|
||||
|
||||
return response
|
||||
119
src/meshcore_hub/web/pages.py
Normal file
119
src/meshcore_hub/web/pages.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Custom markdown pages loader for MeshCore Hub Web Dashboard."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import frontmatter
|
||||
import markdown
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomPage:
|
||||
"""Represents a custom markdown page."""
|
||||
|
||||
slug: str
|
||||
title: str
|
||||
menu_order: int
|
||||
content_html: str
|
||||
file_path: str
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""Get the URL path for this page."""
|
||||
return f"/pages/{self.slug}"
|
||||
|
||||
|
||||
class PageLoader:
|
||||
"""Loads and manages custom markdown pages from a directory."""
|
||||
|
||||
def __init__(self, pages_dir: str) -> None:
|
||||
"""Initialize the page loader.
|
||||
|
||||
Args:
|
||||
pages_dir: Path to the directory containing markdown pages.
|
||||
"""
|
||||
self.pages_dir = Path(pages_dir)
|
||||
self._pages: dict[str, CustomPage] = {}
|
||||
self._md = markdown.Markdown(
|
||||
extensions=["tables", "fenced_code", "toc"],
|
||||
output_format="html",
|
||||
)
|
||||
|
||||
def load_pages(self) -> None:
|
||||
"""Load all markdown pages from the pages directory."""
|
||||
self._pages.clear()
|
||||
|
||||
if not self.pages_dir.exists():
|
||||
logger.debug(f"Pages directory does not exist: {self.pages_dir}")
|
||||
return
|
||||
|
||||
if not self.pages_dir.is_dir():
|
||||
logger.warning(f"Pages path is not a directory: {self.pages_dir}")
|
||||
return
|
||||
|
||||
for md_file in self.pages_dir.glob("*.md"):
|
||||
try:
|
||||
page = self._load_page(md_file)
|
||||
if page:
|
||||
self._pages[page.slug] = page
|
||||
logger.info(f"Loaded custom page: {page.slug} ({md_file.name})")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load page {md_file}: {e}")
|
||||
|
||||
logger.info(f"Loaded {len(self._pages)} custom page(s)")
|
||||
|
||||
def _load_page(self, file_path: Path) -> Optional[CustomPage]:
|
||||
"""Load a single markdown page.
|
||||
|
||||
Args:
|
||||
file_path: Path to the markdown file.
|
||||
|
||||
Returns:
|
||||
CustomPage instance or None if loading failed.
|
||||
"""
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
post = frontmatter.loads(content)
|
||||
|
||||
# Extract frontmatter fields
|
||||
slug = post.get("slug", file_path.stem)
|
||||
title = post.get("title", slug.replace("-", " ").replace("_", " ").title())
|
||||
menu_order = post.get("menu_order", 100)
|
||||
|
||||
# Convert markdown to HTML
|
||||
self._md.reset()
|
||||
content_html = self._md.convert(post.content)
|
||||
|
||||
return CustomPage(
|
||||
slug=slug,
|
||||
title=title,
|
||||
menu_order=menu_order,
|
||||
content_html=content_html,
|
||||
file_path=str(file_path),
|
||||
)
|
||||
|
||||
def get_page(self, slug: str) -> Optional[CustomPage]:
|
||||
"""Get a page by its slug.
|
||||
|
||||
Args:
|
||||
slug: The page slug.
|
||||
|
||||
Returns:
|
||||
CustomPage instance or None if not found.
|
||||
"""
|
||||
return self._pages.get(slug)
|
||||
|
||||
def get_menu_pages(self) -> list[CustomPage]:
|
||||
"""Get all pages sorted by menu_order for navigation.
|
||||
|
||||
Returns:
|
||||
List of CustomPage instances sorted by menu_order.
|
||||
"""
|
||||
return sorted(self._pages.values(), key=lambda p: (p.menu_order, p.title))
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Reload all pages from disk."""
|
||||
self.load_pages()
|
||||
@@ -1,25 +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.network import router as network_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
|
||||
|
||||
# Create main web router
|
||||
web_router = APIRouter()
|
||||
|
||||
# Include all sub-routers
|
||||
web_router.include_router(home_router)
|
||||
web_router.include_router(network_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)
|
||||
|
||||
__all__ = ["web_router"]
|
||||
@@ -1,64 +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,
|
||||
public_key: str | None = Query(None, description="Filter by 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 public_key:
|
||||
params["public_key"] = public_key
|
||||
|
||||
# Fetch advertisements from API
|
||||
advertisements = []
|
||||
total = 0
|
||||
|
||||
try:
|
||||
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,
|
||||
"public_key": public_key or "",
|
||||
}
|
||||
)
|
||||
|
||||
return templates.TemplateResponse("advertisements.html", context)
|
||||
@@ -1,56 +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 chart
|
||||
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:
|
||||
activity = response.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch activity from API: {e}")
|
||||
|
||||
context["stats"] = stats
|
||||
# Pass activity data as JSON string for the chart
|
||||
context["activity_json"] = json.dumps(activity)
|
||||
|
||||
return templates.TemplateResponse("home.html", context)
|
||||
@@ -1,157 +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_key: dict[str, dict[str, Any]] = {}
|
||||
error: str | None = None
|
||||
total_nodes = 0
|
||||
nodes_with_coords = 0
|
||||
|
||||
try:
|
||||
# Fetch all members to build lookup by public_key
|
||||
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", []):
|
||||
# Only include members with public_key (required for node ownership)
|
||||
if member.get("public_key"):
|
||||
member_info = {
|
||||
"public_key": member.get("public_key"),
|
||||
"name": member.get("name"),
|
||||
"callsign": member.get("callsign"),
|
||||
}
|
||||
members_list.append(member_info)
|
||||
members_by_key[member["public_key"]] = 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
|
||||
|
||||
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")
|
||||
|
||||
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 if exists
|
||||
owner = members_by_key.get(public_key)
|
||||
|
||||
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",
|
||||
"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,50 +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."""
|
||||
role = (node.get("node_role") or "").lower()
|
||||
if role == "repeater":
|
||||
return 0
|
||||
if role == "chat":
|
||||
return 1
|
||||
return 2
|
||||
|
||||
try:
|
||||
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", [])
|
||||
# Sort nodes within each member (repeater first, then chat)
|
||||
for member in members:
|
||||
if member.get("nodes"):
|
||||
member["nodes"] = sorted(member["nodes"], key=node_sort_key)
|
||||
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,78 +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"),
|
||||
channel_idx: str | None = Query(None, description="Filter by channel"),
|
||||
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
|
||||
|
||||
# Parse channel_idx, treating empty string as None
|
||||
channel_idx_int: int | None = None
|
||||
if channel_idx and channel_idx.strip():
|
||||
try:
|
||||
channel_idx_int = int(channel_idx)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid channel_idx value: {channel_idx}")
|
||||
|
||||
# Build query params
|
||||
params: dict[str, int | str] = {"limit": limit, "offset": offset}
|
||||
if message_type:
|
||||
params["message_type"] = message_type
|
||||
if channel_idx_int is not None:
|
||||
params["channel_idx"] = channel_idx_int
|
||||
|
||||
# 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 "",
|
||||
"channel_idx": channel_idx_int,
|
||||
"search": search or "",
|
||||
}
|
||||
)
|
||||
|
||||
return templates.TemplateResponse("messages.html", context)
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Network overview 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("/network", response_class=HTMLResponse)
|
||||
async def network_overview(request: Request) -> HTMLResponse:
|
||||
"""Render the network overview 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("network.html", context)
|
||||
@@ -1,117 +0,0 @@
|
||||
"""Nodes page routes."""
|
||||
|
||||
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("/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"),
|
||||
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
|
||||
|
||||
# Fetch nodes from API
|
||||
nodes = []
|
||||
total = 0
|
||||
|
||||
try:
|
||||
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 "",
|
||||
}
|
||||
)
|
||||
|
||||
return templates.TemplateResponse("nodes.html", context)
|
||||
|
||||
|
||||
@router.get("/nodes/{public_key}", response_class=HTMLResponse)
|
||||
async def node_detail(request: Request, public_key: str) -> HTMLResponse:
|
||||
"""Render the node detail page."""
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
node = None
|
||||
advertisements = []
|
||||
telemetry = []
|
||||
|
||||
try:
|
||||
# Fetch node details
|
||||
response = await request.app.state.http_client.get(
|
||||
f"/api/v1/nodes/{public_key}"
|
||||
)
|
||||
if response.status_code == 200:
|
||||
node = response.json()
|
||||
|
||||
# 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)
|
||||
|
||||
context.update(
|
||||
{
|
||||
"node": node,
|
||||
"advertisements": advertisements,
|
||||
"telemetry": telemetry,
|
||||
"public_key": public_key,
|
||||
}
|
||||
)
|
||||
|
||||
return templates.TemplateResponse("node_detail.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;
|
||||
}
|
||||
45
src/meshcore_hub/web/static/img/logo.svg
Normal file
45
src/meshcore_hub/web/static/img/logo.svg
Normal file
@@ -0,0 +1,45 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
12
src/meshcore_hub/web/static/img/meshcore.svg
Normal file
12
src/meshcore_hub/web/static/img/meshcore.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 134 15" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<path d="M3.277,0.053C2.829,0.053 2.401,0.41 2.321,0.851L0.013,13.623C-0.067,14.064 0.232,14.421 0.681,14.421L3.13,14.421C3.578,14.421 4.006,14.064 4.086,13.623L5.004,8.54L6.684,13.957C6.766,14.239 7.02,14.421 7.337,14.421L10.58,14.421C10.897,14.421 11.217,14.239 11.401,13.957L15.043,8.513L14.119,13.623C14.038,14.064 14.338,14.421 14.787,14.421L17.236,14.421C17.684,14.421 18.112,14.064 18.192,13.623L20.5,0.851C20.582,0.41 20.283,0.053 19.834,0.053L16.69,0.053C16.373,0.053 16.053,0.235 15.87,0.517L9.897,9.473C9.803,9.616 9.578,9.578 9.528,9.41L7.074,0.517C6.992,0.235 6.738,0.053 6.421,0.053L3.277,0.053Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M21.146,14.421C21.146,14.421 33.257,14.421 33.257,14.421C33.526,14.421 33.784,14.205 33.831,13.942L34.337,11.128C34.385,10.863 34.206,10.649 33.936,10.649L25.519,10.649C25.429,10.649 25.37,10.576 25.385,10.488L25.635,9.105C25.65,9.017 25.736,8.944 25.826,8.944L32.596,8.944C32.865,8.944 33.123,8.728 33.171,8.465L33.621,5.974C33.669,5.709 33.49,5.495 33.221,5.495L26.45,5.495C26.361,5.495 26.301,5.423 26.317,5.335L26.584,3.852C26.599,3.764 26.685,3.691 26.775,3.691L35.192,3.691C35.462,3.691 35.719,3.476 35.767,3.21L36.258,0.498C36.306,0.235 36.126,0.019 35.857,0.019L23.746,0.019C23.297,0.019 22.867,0.378 22.788,0.819L20.474,13.621C20.396,14.062 20.695,14.421 21.146,14.421Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M45.926,14.419L45.926,14.421L46.346,14.421C48.453,14.421 50.465,12.742 50.839,10.67L51.081,9.327C51.456,7.256 50.05,5.576 47.943,5.576L41.455,5.576C41.186,5.576 41.007,5.363 41.054,5.097L41.218,4.192C41.266,3.927 41.524,3.713 41.793,3.713L50.569,3.713C51.018,3.713 51.446,3.356 51.526,2.915L51.9,0.85C51.98,0.407 51.68,0.05 51.232,0.05L41.638,0.05C39.531,0.05 37.519,1.73 37.145,3.801L36.88,5.267C36.505,7.339 37.91,9.018 40.018,9.018L46.506,9.018C46.775,9.018 46.954,9.231 46.907,9.497L46.785,10.176C46.737,10.441 46.479,10.655 46.21,10.655L37.189,10.655C36.741,10.655 36.313,11.012 36.233,11.453L35.841,13.621C35.761,14.062 36.061,14.419 36.51,14.419L45.926,14.419Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M68.008,0.046C68.008,0.046 65.296,0.046 65.296,0.046C64.847,0.046 64.42,0.403 64.34,0.844L63.532,5.31C63.517,5.398 63.431,5.469 63.341,5.469L58.085,5.469C57.995,5.469 57.936,5.398 57.951,5.31L58.758,0.844C58.837,0.403 58.539,0.046 58.09,0.046L55.378,0.046C54.93,0.046 54.502,0.403 54.422,0.844L52.112,13.623C52.032,14.064 52.331,14.421 52.78,14.421L55.492,14.421C55.941,14.421 56.369,14.064 56.449,13.623L57.272,9.074C57.287,8.986 57.373,8.914 57.462,8.914L62.719,8.914C62.809,8.914 62.868,8.985 62.853,9.074L62.032,13.623C61.952,14.064 62.252,14.421 62.7,14.421L65.413,14.421C65.861,14.421 66.289,14.064 66.369,13.623L68.678,0.844C68.755,0.403 68.457,0.046 68.008,0.046Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M72.099,14.421C72.099,14.421 80.066,14.421 80.066,14.421C80.515,14.421 80.943,14.064 81.022,13.623L81.414,11.453C81.494,11.012 81.194,10.655 80.746,10.655L73.828,10.655C73.559,10.655 73.38,10.441 73.427,10.176L74.51,4.215C74.558,3.951 74.815,3.736 75.082,3.736L82,3.736C82.448,3.736 82.876,3.379 82.956,2.938L83.34,0.817C83.42,0.376 83.12,0.019 82.672,0.019L74.724,0.019C72.622,0.019 70.614,1.691 70.236,3.757L68.965,10.665C68.587,12.738 69.99,14.421 72.099,14.421Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M97.176,-0C97.176,0 88.882,0 88.882,0C86.775,0 84.763,1.68 84.389,3.751L83.139,10.67C82.765,12.741 84.169,14.421 86.277,14.421L94.571,14.421C96.678,14.421 98.69,12.741 99.064,10.67L100.314,3.751C100.689,1.68 99.284,-0 97.176,-0ZM94.798,10.178C94.75,10.443 94.492,10.657 94.223,10.657L87.978,10.657C87.709,10.657 87.529,10.443 87.577,10.178L88.659,4.192C88.707,3.927 88.964,3.713 89.234,3.713L95.477,3.713C95.747,3.713 95.926,3.927 95.878,4.192L94.798,10.178Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M101.284,14.421L103.995,14.421C104.443,14.421 104.871,14.065 104.951,13.624L105.43,10.97C105.446,10.882 105.531,10.81 105.621,10.81L108.902,10.806C109.064,10.806 109.2,10.886 109.267,11.018L110.813,14.035C110.992,14.392 111.319,14.434 112.303,14.419C112.88,14.426 113.756,14.382 115.169,14.382C115.623,14.382 115.902,13.907 115.678,13.51L113.989,10.569C113.945,10.491 113.993,10.386 114.086,10.34C115.39,9.707 116.423,8.477 116.681,7.055L117.27,3.785C117.646,1.713 116.242,0.033 114.134,0.033L103.884,0.033C103.436,0.033 103.008,0.39 102.928,0.831L100.616,13.623C100.536,14.064 100.836,14.421 101.284,14.421L101.284,14.421ZM106.73,3.791C106.745,3.703 106.831,3.631 106.921,3.631L112.225,3.631C112.626,3.631 112.891,3.949 112.821,4.343L112.431,6.494C112.359,6.885 111.979,7.204 111.58,7.204L106.276,7.204C106.186,7.204 106.127,7.133 106.142,7.043L106.73,3.791Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M118.277,14.421C118.277,14.421 130.388,14.421 130.388,14.421C130.657,14.421 130.915,14.205 130.963,13.942L131.468,11.128C131.516,10.863 131.337,10.649 131.068,10.649L122.65,10.649C122.56,10.649 122.501,10.576 122.516,10.488L122.766,9.105C122.781,9.017 122.867,8.944 122.957,8.944L129.728,8.944C129.997,8.944 130.254,8.728 130.302,8.465L130.753,5.974C130.801,5.709 130.621,5.495 130.352,5.495L123.581,5.495C123.492,5.495 123.432,5.423 123.448,5.335L123.715,3.852C123.73,3.764 123.816,3.691 123.906,3.691L132.324,3.691C132.593,3.691 132.851,3.476 132.898,3.21L133.389,0.498C133.437,0.235 133.257,0.019 132.988,0.019L120.877,0.019C120.428,0.019 119.999,0.378 119.919,0.819L117.605,13.621C117.527,14.062 117.827,14.421 118.277,14.421Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.9 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: (window.t && window.t('entities.advertisements')) || '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: (window.t && window.t('entities.messages')) || '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,
|
||||
(window.t && window.t('common.total_entity', { entity: t('entities.nodes') })) || 'Total Nodes',
|
||||
ChartColors.nodes,
|
||||
ChartColors.nodesFill,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (advertData) {
|
||||
createLineChart(
|
||||
'advertChart',
|
||||
advertData,
|
||||
(window.t && window.t('entities.advertisements')) || 'Advertisements',
|
||||
ChartColors.adverts,
|
||||
ChartColors.advertsFill,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (messageData) {
|
||||
createLineChart(
|
||||
'messageChart',
|
||||
messageData,
|
||||
(window.t && window.t('entities.messages')) || 'Messages',
|
||||
ChartColors.messages,
|
||||
ChartColors.messagesFill,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
183
src/meshcore_hub/web/static/js/spa/app.js
Normal file
183
src/meshcore_hub/web/static/js/spa/app.js
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* MeshCore Hub SPA - Main Application Entry Point
|
||||
*
|
||||
* Initializes i18n, the router, registers all page routes,
|
||||
* and handles navigation.
|
||||
*/
|
||||
|
||||
import { Router } from './router.js';
|
||||
import { getConfig } from './components.js';
|
||||
import { loadLocale, t } from './i18n.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">${t('common.error')}</h1>
|
||||
<p class="text-lg opacity-70 mb-6">${t('common.failed_to_load_page')}</p>
|
||||
<p class="text-sm opacity-50 mb-6">${e.message || 'Unknown error'}</p>
|
||||
<a href="/" class="btn btn-primary">${t('common.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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose a page title from entity name and network name.
|
||||
* @param {string} entityKey - Translation key for entity (e.g., 'entities.dashboard')
|
||||
* @returns {string}
|
||||
*/
|
||||
function composePageTitle(entityKey) {
|
||||
const networkName = config.network_name || 'MeshCore Network';
|
||||
const entity = t(entityKey);
|
||||
return `${entity} - ${networkName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the page title based on the current route.
|
||||
* @param {string} pathname
|
||||
*/
|
||||
function updatePageTitle(pathname) {
|
||||
const networkName = config.network_name || 'MeshCore Network';
|
||||
const titles = {
|
||||
'/': networkName,
|
||||
'/a': composePageTitle('entities.admin'),
|
||||
'/a/': composePageTitle('entities.admin'),
|
||||
'/a/node-tags': `${t('entities.tags')} - ${t('entities.admin')} - ${networkName}`,
|
||||
'/a/members': `${t('entities.members')} - ${t('entities.admin')} - ${networkName}`,
|
||||
};
|
||||
|
||||
// Add feature-dependent titles
|
||||
if (features.dashboard !== false) titles['/dashboard'] = composePageTitle('entities.dashboard');
|
||||
if (features.nodes !== false) titles['/nodes'] = composePageTitle('entities.nodes');
|
||||
if (features.messages !== false) titles['/messages'] = composePageTitle('entities.messages');
|
||||
if (features.advertisements !== false) titles['/advertisements'] = composePageTitle('entities.advertisements');
|
||||
if (features.map !== false) titles['/map'] = composePageTitle('entities.map');
|
||||
if (features.members !== false) titles['/members'] = composePageTitle('entities.members');
|
||||
|
||||
if (titles[pathname]) {
|
||||
document.title = titles[pathname];
|
||||
} else if (pathname.startsWith('/nodes/')) {
|
||||
document.title = composePageTitle('entities.node_detail');
|
||||
} 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);
|
||||
});
|
||||
|
||||
// Load locale then start the router
|
||||
const locale = localStorage.getItem('meshcore-locale') || config.locale || 'en';
|
||||
await loadLocale(locale);
|
||||
router.start();
|
||||
87
src/meshcore_hub/web/static/js/spa/auto-refresh.js
Normal file
87
src/meshcore_hub/web/static/js/spa/auto-refresh.js
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Auto-refresh utility for list pages.
|
||||
*
|
||||
* Reads `auto_refresh_seconds` from the app config. When the interval is > 0
|
||||
* it sets up a periodic timer that calls the provided `fetchAndRender` callback
|
||||
* and renders a pause/play toggle button into the given container element.
|
||||
*/
|
||||
|
||||
import { html, litRender, getConfig, t } from './components.js';
|
||||
|
||||
/**
|
||||
* Create an auto-refresh controller.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {Function} options.fetchAndRender - Async function that fetches data and re-renders the page.
|
||||
* @param {HTMLElement} options.toggleContainer - Element to render the pause/play toggle into.
|
||||
* @returns {{ cleanup: Function }} cleanup function to stop the timer.
|
||||
*/
|
||||
export function createAutoRefresh({ fetchAndRender, toggleContainer }) {
|
||||
const config = getConfig();
|
||||
const intervalSeconds = config.auto_refresh_seconds || 0;
|
||||
|
||||
if (!intervalSeconds || !toggleContainer) {
|
||||
return { cleanup() {} };
|
||||
}
|
||||
|
||||
let paused = false;
|
||||
let isPending = false;
|
||||
let timerId = null;
|
||||
|
||||
function renderToggle() {
|
||||
const pauseIcon = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4"><path d="M5.75 3a.75.75 0 0 0-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 0 0 .75-.75V3.75A.75.75 0 0 0 7.25 3h-1.5ZM12.75 3a.75.75 0 0 0-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 0 0 .75-.75V3.75a.75.75 0 0 0-.75-.75h-1.5Z"/></svg>`;
|
||||
const playIcon = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4"><path d="M6.3 2.84A1.5 1.5 0 0 0 4 4.11v11.78a1.5 1.5 0 0 0 2.3 1.27l9.344-5.891a1.5 1.5 0 0 0 0-2.538L6.3 2.84Z"/></svg>`;
|
||||
|
||||
const tooltip = paused ? t('auto_refresh.resume') : t('auto_refresh.pause');
|
||||
const icon = paused ? playIcon : pauseIcon;
|
||||
|
||||
litRender(html`
|
||||
<button class="btn btn-ghost btn-xs gap-1 opacity-60 hover:opacity-100"
|
||||
title=${tooltip}
|
||||
@click=${onToggle}>
|
||||
${icon}
|
||||
<span class="text-xs">${intervalSeconds}s</span>
|
||||
</button>
|
||||
`, toggleContainer);
|
||||
}
|
||||
|
||||
function onToggle() {
|
||||
paused = !paused;
|
||||
if (paused) {
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
} else {
|
||||
startTimer();
|
||||
}
|
||||
renderToggle();
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
if (isPending || paused) return;
|
||||
isPending = true;
|
||||
try {
|
||||
await fetchAndRender();
|
||||
} catch (_e) {
|
||||
// Errors are handled inside fetchAndRender; don't stop the timer.
|
||||
} finally {
|
||||
isPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
timerId = setInterval(tick, intervalSeconds * 1000);
|
||||
}
|
||||
|
||||
// Initial render and start
|
||||
renderToggle();
|
||||
startTimer();
|
||||
|
||||
return {
|
||||
cleanup() {
|
||||
if (timerId) {
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
421
src/meshcore_hub/web/static/js/spa/components.js
Normal file
421
src/meshcore_hub/web/static/js/spa/components.js
Normal file
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* 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';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
// Re-export lit-html utilities for page modules
|
||||
export { html, nothing, unsafeHTML };
|
||||
export { render as litRender } from 'lit-html';
|
||||
export { t } from './i18n.js';
|
||||
|
||||
/**
|
||||
* 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}'; // 📍
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the first emoji from a string.
|
||||
* Uses a regex pattern that matches emoji characters including compound emojis.
|
||||
* @param {string|null} str
|
||||
* @returns {string|null} First emoji found, or null if none
|
||||
*/
|
||||
export function extractFirstEmoji(str) {
|
||||
if (!str) return null;
|
||||
// Match emoji using Unicode ranges and zero-width joiners
|
||||
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{231A}-\u{231B}\u{23E9}-\u{23FA}\u{25AA}-\u{25AB}\u{25B6}\u{25C0}\u{25FB}-\u{25FE}\u{2B50}\u{2B55}\u{3030}\u{303D}\u{3297}\u{3299}](?:\u{FE0F})?(?:\u{200D}[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}](?:\u{FE0F})?)*|\u{00A9}|\u{00AE}|\u{203C}|\u{2049}|\u{2122}|\u{2139}|\u{2194}-\u{2199}|\u{21A9}-\u{21AA}|\u{24C2}|\u{2934}-\u{2935}|\u{2B05}-\u{2B07}|\u{2B1B}-\u{2B1C}/u;
|
||||
const match = str.match(emojiRegex);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display emoji for a node.
|
||||
* Prefers the first emoji from the node name, falls back to type emoji.
|
||||
* @param {string|null} nodeName - Node's display name
|
||||
* @param {string|null} advType - Advertisement type
|
||||
* @returns {string} Emoji character to display
|
||||
*/
|
||||
export function getNodeEmoji(nodeName, advType) {
|
||||
const nameEmoji = extractFirstEmoji(nodeName);
|
||||
return nameEmoji || typeEmoji(advType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 t('time.days_ago', { count: diffDay });
|
||||
if (diffHour > 0) return t('time.hours_ago', { count: diffHour });
|
||||
if (diffMin > 0) return t('time.minutes_ago', { count: diffMin });
|
||||
return t('time.less_than_minute');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard with visual feedback.
|
||||
* Updates the target element to show "Copied!" temporarily.
|
||||
* Falls back to execCommand for browsers without Clipboard API.
|
||||
* @param {Event} e - Click event
|
||||
* @param {string} text - Text to copy to clipboard
|
||||
*/
|
||||
export function copyToClipboard(e, text) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Capture target element synchronously before async operations
|
||||
const targetElement = e.currentTarget;
|
||||
|
||||
const showSuccess = (target) => {
|
||||
const originalText = target.textContent;
|
||||
target.textContent = 'Copied!';
|
||||
target.classList.add('text-success');
|
||||
setTimeout(() => {
|
||||
target.textContent = originalText;
|
||||
target.classList.remove('text-success');
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
// Try modern Clipboard API first
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showSuccess(targetElement);
|
||||
}).catch(err => {
|
||||
console.error('Clipboard API failed:', err);
|
||||
fallbackCopy(text, targetElement);
|
||||
});
|
||||
} else {
|
||||
// Fallback for older browsers or non-secure contexts
|
||||
fallbackCopy(text, targetElement);
|
||||
}
|
||||
|
||||
function fallbackCopy(text, target) {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showSuccess(target);
|
||||
} catch (err) {
|
||||
console.error('Fallback copy failed:', err);
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
}
|
||||
|
||||
// --- UI Components (return lit-html TemplateResult) ---
|
||||
|
||||
/**
|
||||
* Render a node display with emoji, name, and optional description.
|
||||
* Used for consistent node representation across lists (nodes, advertisements, messages, etc.).
|
||||
*
|
||||
* @param {Object} options - Node display options
|
||||
* @param {string|null} options.name - Node display name (from tag or advertised name)
|
||||
* @param {string|null} options.description - Node description from tags
|
||||
* @param {string} options.publicKey - Node public key (for fallback display)
|
||||
* @param {string|null} options.advType - Advertisement type (chat, repeater, room)
|
||||
* @param {string} [options.size='base'] - Size variant: 'sm' (small lists) or 'base' (normal)
|
||||
* @returns {TemplateResult} lit-html template
|
||||
*/
|
||||
export function renderNodeDisplay({ name, description, publicKey, advType, size = 'base' }) {
|
||||
const displayName = name || null;
|
||||
const emoji = getNodeEmoji(name, advType);
|
||||
const emojiSize = size === 'sm' ? 'text-lg' : 'text-lg';
|
||||
const nameSize = size === 'sm' ? 'text-sm' : 'text-base';
|
||||
const descSize = size === 'sm' ? 'text-xs' : 'text-xs';
|
||||
|
||||
const nameBlock = displayName
|
||||
? html`<div class="font-medium ${nameSize} truncate">${displayName}</div>
|
||||
${description ? html`<div class="${descSize} opacity-70 truncate">${description}</div>` : nothing}`
|
||||
: html`<div class="font-mono ${nameSize} truncate">${publicKey.slice(0, 16)}...</div>`;
|
||||
|
||||
return html`
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="${emojiSize} flex-shrink-0" title=${advType || t('node_types.unknown')}>${emoji}</span>
|
||||
<div class="min-w-0">
|
||||
${nameBlock}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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">${t('common.previous')}</a>`
|
||||
: html`<button class="join-item btn btn-sm btn-disabled" disabled>${t('common.previous')}</button>`}
|
||||
${pageNumbers}
|
||||
${page < totalPages
|
||||
? html`<a href=${pageUrl(page + 1)} class="join-item btn btn-sm">${t('common.next')}</a>`
|
||||
: html`<button class="join-item btn btn-sm btn-disabled" disabled>${t('common.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();
|
||||
}
|
||||
}
|
||||
76
src/meshcore_hub/web/static/js/spa/i18n.js
Normal file
76
src/meshcore_hub/web/static/js/spa/i18n.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* MeshCore Hub SPA - Lightweight i18n Module
|
||||
*
|
||||
* Loads a JSON translation file and provides a t() lookup function.
|
||||
* Shares the same locale JSON files with the Python/Jinja2 server side.
|
||||
*
|
||||
* Usage:
|
||||
* import { t, loadLocale } from './i18n.js';
|
||||
* await loadLocale('en');
|
||||
* t('entities.home'); // "Home"
|
||||
* t('common.total', { count: 42 }); // "42 total"
|
||||
*/
|
||||
|
||||
let _translations = {};
|
||||
let _locale = 'en';
|
||||
|
||||
/**
|
||||
* Load a locale JSON file from the server.
|
||||
* @param {string} locale - Language code (e.g. 'en')
|
||||
*/
|
||||
export async function loadLocale(locale) {
|
||||
try {
|
||||
const res = await fetch(`/static/locales/${locale}.json`);
|
||||
if (res.ok) {
|
||||
_translations = await res.json();
|
||||
_locale = locale;
|
||||
} else {
|
||||
console.warn(`Failed to load locale '${locale}', status ${res.status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to load locale '${locale}':`, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a dot-separated key in the translations object.
|
||||
* @param {string} key
|
||||
* @returns {*}
|
||||
*/
|
||||
function resolve(key) {
|
||||
return key.split('.').reduce(
|
||||
(obj, k) => (obj && typeof obj === 'object' ? obj[k] : undefined),
|
||||
_translations,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a key with optional {{var}} interpolation.
|
||||
* Falls back to the key itself if not found.
|
||||
* @param {string} key - Dot-separated translation key
|
||||
* @param {Object} [params={}] - Interpolation values
|
||||
* @returns {string}
|
||||
*/
|
||||
export function t(key, params = {}) {
|
||||
let val = resolve(key);
|
||||
|
||||
if (typeof val !== 'string') return key;
|
||||
|
||||
// Replace {{var}} placeholders
|
||||
if (Object.keys(params).length > 0) {
|
||||
val = val.replace(/\{\{(\w+)\}\}/g, (_, k) => (k in params ? String(params[k]) : ''));
|
||||
}
|
||||
|
||||
return val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently loaded locale code.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getLocale() {
|
||||
return _locale;
|
||||
}
|
||||
|
||||
// Also expose t() globally for non-module scripts (e.g. charts.js)
|
||||
window.t = t;
|
||||
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, unsafeHTML, getConfig, errorAlert, t } 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">${t('admin.access_denied')}</h1>
|
||||
<p class="opacity-70">${t('admin.admin_not_enabled')}</p>
|
||||
<p class="text-sm opacity-50 mt-2">${unsafeHTML(t('admin.admin_enable_hint'))}</p>
|
||||
<a href="/" class="btn btn-primary mt-6">${t('common.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">${t('admin.auth_required')}</h1>
|
||||
<p class="opacity-70">${t('admin.auth_required_description')}</p>
|
||||
<a href="/oauth2/start?rd=${encodeURIComponent(window.location.pathname)}" class="btn btn-primary mt-6">${t('common.sign_in')}</a>
|
||||
</div>`, container);
|
||||
return;
|
||||
}
|
||||
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">${t('entities.admin')}</h1>
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">${t('entities.home')}</a></li>
|
||||
<li>${t('entities.admin')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/oauth2/sign_out" target="_blank" class="btn btn-outline btn-sm">${t('common.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">
|
||||
${t('admin.welcome')}
|
||||
</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')}
|
||||
${t('entities.members')}
|
||||
</h2>
|
||||
<p>${t('admin.members_description')}</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')}
|
||||
${t('entities.tags')}
|
||||
</h2>
|
||||
<p>${t('admin.tags_description')}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>`, container);
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
}
|
||||
332
src/meshcore_hub/web/static/js/spa/pages/admin/members.js
Normal file
332
src/meshcore_hub/web/static/js/spa/pages/admin/members.js
Normal file
@@ -0,0 +1,332 @@
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, errorAlert, successAlert, t,
|
||||
} 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">${t('admin.access_denied')}</h1>
|
||||
<p class="opacity-70">${t('admin.admin_not_enabled')}</p>
|
||||
<a href="/" class="btn btn-primary mt-6">${t('common.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">${t('admin.auth_required')}</h1>
|
||||
<p class="opacity-70">${t('admin.auth_required_description')}</p>
|
||||
<a href="/oauth2/start?rd=${encodeURIComponent(window.location.pathname)}" class="btn btn-primary mt-6">${t('common.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>${t('admin_members.member_id')}</th>
|
||||
<th>${t('common.name')}</th>
|
||||
<th>${t('common.callsign')}</th>
|
||||
<th>${t('common.contact')}</th>
|
||||
<th class="w-32">${t('common.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">${t('common.edit')}</button>
|
||||
<button class="btn btn-ghost btn-xs text-error btn-delete">${t('common.delete')}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`)}</tbody>
|
||||
</table>
|
||||
</div>`
|
||||
: html`
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<p>${t('common.no_entity_yet', { entity: t('entities.members').toLowerCase() })}</p>
|
||||
<p class="text-sm mt-2">${t('admin_members.empty_state_hint')}</p>
|
||||
</div>`;
|
||||
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">${t('entities.members')}</h1>
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">${t('entities.home')}</a></li>
|
||||
<li><a href="/a/">${t('entities.admin')}</a></li>
|
||||
<li>${t('entities.members')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/oauth2/sign_out" target="_blank" class="btn btn-outline btn-sm">${t('common.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">${t('admin_members.network_members', { count: members.length })}</h2>
|
||||
<button id="btn-add-member" class="btn btn-primary btn-sm">${t('common.add_entity', { entity: t('entities.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">${t('common.add_new_entity', { entity: t('entities.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">${t('admin_members.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">${t('admin_members.member_id_hint')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">${t('common.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">${t('common.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">${t('common.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">${t('common.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">${t('common.cancel')}</button>
|
||||
<button type="submit" class="btn btn-primary">${t('common.add_entity', { entity: t('entities.member') })}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>${t('common.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">${t('common.edit_entity', { entity: t('entities.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">${t('admin_members.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">${t('common.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">${t('common.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">${t('common.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">${t('common.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">${t('common.cancel')}</button>
|
||||
<button type="submit" class="btn btn-primary">${t('common.save_changes')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="deleteModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">${t('common.delete_entity', { entity: t('entities.member') })}</h3>
|
||||
<div class="py-4">
|
||||
<p class="py-4" id="delete_confirm_message"></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>${t('common.cannot_be_undone')}</span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="deleteCancel">${t('common.cancel')}</button>
|
||||
<button type="button" class="btn btn-error" id="deleteConfirm">${t('common.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>${t('common.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(t('common.entity_added_success', { entity: t('entities.member') })));
|
||||
} 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(t('common.entity_updated_success', { entity: t('entities.member') })));
|
||||
} 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;
|
||||
const memberName = row.dataset.memberName;
|
||||
const confirmMsg = t('common.delete_entity_confirm', {
|
||||
entity: t('entities.member').toLowerCase(),
|
||||
name: memberName
|
||||
});
|
||||
container.querySelector('#delete_confirm_message').innerHTML = confirmMsg;
|
||||
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(t('common.entity_deleted_success', { entity: t('entities.member') })));
|
||||
} catch (err) {
|
||||
container.querySelector('#deleteModal').close();
|
||||
router.navigate('/a/members?error=' + encodeURIComponent(err.message));
|
||||
}
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
}
|
||||
530
src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js
Normal file
530
src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js
Normal file
@@ -0,0 +1,530 @@
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../api.js';
|
||||
import {
|
||||
html, litRender, nothing, unsafeHTML,
|
||||
getConfig, typeEmoji, formatDateTimeShort, errorAlert,
|
||||
successAlert, truncateKey, t,
|
||||
} 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">${t('admin.access_denied')}</h1>
|
||||
<p class="opacity-70">${t('admin.admin_not_enabled')}</p>
|
||||
<a href="/" class="btn btn-primary mt-6">${t('common.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">${t('admin.auth_required')}</h1>
|
||||
<p class="opacity-70">${t('admin.auth_required_description')}</p>
|
||||
<a href="/oauth2/start?rd=${encodeURIComponent(window.location.pathname)}" class="btn btn-primary mt-6">${t('common.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 || t('common.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>${t('common.key')}</th>
|
||||
<th>${t('common.value')}</th>
|
||||
<th>${t('common.type')}</th>
|
||||
<th>${t('common.updated')}</th>
|
||||
<th class="w-48">${t('common.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">${t('common.edit')}</button>
|
||||
<button class="btn btn-ghost btn-xs btn-move">${t('common.move')}</button>
|
||||
<button class="btn btn-ghost btn-xs text-error btn-delete">${t('common.delete')}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`)}</tbody>
|
||||
</table>
|
||||
</div>`
|
||||
: html`
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<p>${t('common.no_entity_found', { entity: t('entities.tags').toLowerCase() }) + ' ' + t('admin_node_tags.for_this_node')}</p>
|
||||
<p class="text-sm mt-2">${t('admin_node_tags.empty_state_hint')}</p>
|
||||
</div>`;
|
||||
|
||||
const bulkButtons = tags.length > 0
|
||||
? html`
|
||||
<button id="btn-copy-all" class="btn btn-outline btn-sm">${t('admin_node_tags.copy_all')}</button>
|
||||
<button id="btn-delete-all" class="btn btn-outline btn-error btn-sm">${t('admin_node_tags.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">${t('common.view_entity', { entity: t('entities.node') })}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${t('entities.tags')} (${tags.length})</h2>
|
||||
${tagsTableHtml}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${t('common.add_new_entity', { entity: t('entities.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">${t('common.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">${t('common.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">${t('common.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">${t('common.add_entity', { entity: t('entities.tag') })}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="editModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">${t('common.edit_entity', { entity: t('entities.tag') })}</h3>
|
||||
<form id="edit-tag-form" class="py-4">
|
||||
<div class="form-control mb-4">
|
||||
<label class="label"><span class="label-text">${t('common.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">${t('common.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">${t('common.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">${t('common.cancel')}</button>
|
||||
<button type="submit" class="btn btn-primary">${t('common.save_changes')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="moveModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">${t('common.move_entity_to_another_node', { entity: t('entities.tag') })}</h3>
|
||||
<form id="move-tag-form" class="py-4">
|
||||
<div class="form-control mb-4">
|
||||
<label class="label"><span class="label-text">${t('admin_node_tags.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">${t('admin_node_tags.destination_node')}</span></label>
|
||||
<select id="moveDestination" class="select select-bordered w-full" required>
|
||||
<option value="">${t('map.select_destination_node')}</option>
|
||||
${otherNodes.map(n => {
|
||||
const name = n.name || t('common.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>${t('admin_node_tags.move_warning')}</span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="moveCancel">${t('common.cancel')}</button>
|
||||
<button type="submit" class="btn btn-warning">${t('common.move_entity', { entity: t('entities.tag') })}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="deleteModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">${t('common.delete_entity', { entity: t('entities.tag') })}</h3>
|
||||
<div class="py-4">
|
||||
<p class="py-4" id="delete_tag_confirm_message"></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>${t('common.cannot_be_undone')}</span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="deleteCancel">${t('common.cancel')}</button>
|
||||
<button type="button" class="btn btn-error" id="deleteConfirm">${t('common.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="copyAllModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">${t('common.copy_all_entity_to_another_node', { entity: t('entities.tags') })}</h3>
|
||||
<form id="copy-all-form" class="py-4">
|
||||
<p class="mb-4">${unsafeHTML(t('common.copy_all_entity_description', { count: tags.length, entity: t('entities.tags').toLowerCase(), name: nodeName }))}</p>
|
||||
<div class="form-control mb-4">
|
||||
<label class="label"><span class="label-text">${t('admin_node_tags.destination_node')}</span></label>
|
||||
<select id="copyAllDestination" class="select select-bordered w-full" required>
|
||||
<option value="">${t('map.select_destination_node')}</option>
|
||||
${otherNodes.map(n => {
|
||||
const name = n.name || t('common.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>${t('admin_node_tags.copy_all_info')}</span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="copyAllCancel">${t('common.cancel')}</button>
|
||||
<button type="submit" class="btn btn-primary">${t('common.copy_entity', { entity: t('entities.tags') })}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="deleteAllModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">${t('common.delete_all_entity', { entity: t('entities.tags') })}</h3>
|
||||
<div class="py-4">
|
||||
<p class="mb-4">${unsafeHTML(t('common.delete_all_entity_confirm', { count: tags.length, entity: t('entities.tags').toLowerCase(), name: nodeName }))}</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>${t('admin_node_tags.delete_all_warning')}</span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="deleteAllCancel">${t('common.cancel')}</button>
|
||||
<button type="button" class="btn btn-error" id="deleteAllConfirm">${t('common.delete_all_entity', { entity: t('entities.tags') })}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>${t('common.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">${t('admin_node_tags.select_a_node')}</h2>
|
||||
<p class="opacity-70">${t('admin_node_tags.select_a_node_description')}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">${t('entities.tags')}</h1>
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">${t('entities.home')}</a></li>
|
||||
<li><a href="/a/">${t('entities.admin')}</a></li>
|
||||
<li>${t('entities.tags')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/oauth2/sign_out" target="_blank" class="btn btn-outline btn-sm">${t('common.sign_out')}</a>
|
||||
</div>
|
||||
|
||||
${flashHtml}
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${t('admin_node_tags.select_node')}</h2>
|
||||
<div class="flex gap-4 items-end">
|
||||
<div class="form-control flex-1">
|
||||
<label class="label"><span class="label-text">${t('entities.node')}</span></label>
|
||||
<select id="node-selector" class="select select-bordered w-full">
|
||||
<option value="">${t('admin_node_tags.select_node_placeholder')}</option>
|
||||
${allNodes.map(n => {
|
||||
const name = n.name || t('common.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">${t('admin_node_tags.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(t('common.entity_added_success', { entity: t('entities.tag') })));
|
||||
} 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(t('common.entity_updated_success', { entity: t('entities.tag') })));
|
||||
} 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(t('common.entity_moved_success', { entity: t('entities.tag') })));
|
||||
} 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;
|
||||
const confirmMsg = t('common.delete_entity_confirm', {
|
||||
entity: t('entities.tag').toLowerCase(),
|
||||
name: `"<span class="font-mono font-semibold">${activeTagKey}</span>"`
|
||||
});
|
||||
container.querySelector('#delete_tag_confirm_message').innerHTML = confirmMsg;
|
||||
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(t('common.entity_deleted_success', { entity: t('entities.tag') })));
|
||||
} 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 = t('admin_node_tags.copied_entities', { copied: result.copied, 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(t('common.all_entity_deleted_success', { entity: t('entities.tags').toLowerCase() })));
|
||||
} 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 || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
}
|
||||
230
src/meshcore_hub/web/static/js/spa/pages/advertisements.js
Normal file
230
src/meshcore_hub/web/static/js/spa/pages/advertisements.js
Normal file
@@ -0,0 +1,230 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing, t,
|
||||
getConfig, formatDateTime, formatDateTimeShort,
|
||||
truncateKey, errorAlert,
|
||||
pagination, createFilterHandler, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay
|
||||
} from '../components.js';
|
||||
import { createAutoRefresh } from '../auto-refresh.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 features = config.features || {};
|
||||
const showMembers = features.members !== false;
|
||||
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">${t('entities.advertisements')}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="auto-refresh-toggle"></span>
|
||||
${tzBadge}
|
||||
${total !== null ? html`<span class="badge badge-lg">${t('common.total', { count: total })}</span>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
${content}`, container);
|
||||
}
|
||||
|
||||
// Render page header immediately (old content stays visible until data loads)
|
||||
renderPage(nothing);
|
||||
|
||||
async function fetchAndRenderData() {
|
||||
try {
|
||||
const requests = [
|
||||
apiGet('/api/v1/advertisements', { limit, offset, search, public_key, member_id }),
|
||||
apiGet('/api/v1/nodes', { limit: 500 }),
|
||||
];
|
||||
if (showMembers) {
|
||||
requests.push(apiGet('/api/v1/members', { limit: 100 }));
|
||||
}
|
||||
|
||||
const results = await Promise.all(requests);
|
||||
const data = results[0];
|
||||
const nodesData = results[1];
|
||||
const membersData = showMembers ? results[2] : null;
|
||||
|
||||
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">${t('entities.node')}</span>
|
||||
</label>
|
||||
<select name="public_key" class="select select-bordered select-sm" @change=${autoSubmit}>
|
||||
<option value="">${t('common.all_entity', { entity: t('entities.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 = (showMembers && members.length > 0)
|
||||
? html`
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">${t('entities.member')}</span>
|
||||
</label>
|
||||
<select name="member_id" class="select select-bordered select-sm" @change=${autoSubmit}>
|
||||
<option value="">${t('common.all_entity', { entity: t('entities.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">${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}</div>`
|
||||
: advertisements.map(ad => {
|
||||
const adName = ad.node_tag_name || ad.node_name || ad.name;
|
||||
const adDescription = ad.node_tag_description;
|
||||
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">
|
||||
${renderNodeDisplay({
|
||||
name: adName,
|
||||
description: adDescription,
|
||||
publicKey: ad.public_key,
|
||||
advType: ad.adv_type,
|
||||
size: 'sm'
|
||||
})}
|
||||
<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="4" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}</td></tr>`
|
||||
: advertisements.map(ad => {
|
||||
const adName = ad.node_tag_name || ad.node_name || ad.name;
|
||||
const adDescription = ad.node_tag_description;
|
||||
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">
|
||||
${renderNodeDisplay({
|
||||
name: adName,
|
||||
description: adDescription,
|
||||
publicKey: ad.public_key,
|
||||
advType: ad.adv_type,
|
||||
size: 'base'
|
||||
})}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<code class="font-mono text-xs cursor-pointer hover:bg-base-200 px-1 py-0.5 rounded select-all"
|
||||
@click=${(e) => copyToClipboard(e, ad.public_key)}
|
||||
title="Click to copy">${ad.public_key}</code>
|
||||
</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">${t('common.search')}</span>
|
||||
</label>
|
||||
<input type="text" name="search" .value=${search} placeholder="${t('common.search_placeholder')}" 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">${t('common.filter')}</button>
|
||||
<a href="/advertisements" class="btn btn-ghost btn-sm">${t('common.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>${t('entities.node')}</th>
|
||||
<th>${t('common.public_key')}</th>
|
||||
<th>${t('common.time')}</th>
|
||||
<th>${t('common.receivers')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tableRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
${paginationBlock}`, { total });
|
||||
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
await fetchAndRenderData();
|
||||
|
||||
const toggleEl = container.querySelector('#auto-refresh-toggle');
|
||||
const { cleanup } = createAutoRefresh({
|
||||
fetchAndRender: fetchAndRenderData,
|
||||
toggleContainer: toggleEl,
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
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, t } 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(t('common.page_not_found')), container);
|
||||
} else {
|
||||
litRender(errorAlert(e.message || t('custom_page.failed_to_load')), 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, t,
|
||||
} 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">${t('common.no_entity_yet', { entity: t('entities.advertisements').toLowerCase() })}</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>${t('entities.node')}</th>
|
||||
<th>${t('common.type')}</th>
|
||||
<th class="text-right">${t('common.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>
|
||||
${t('dashboard.channel', { number: 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')}
|
||||
${t('dashboard.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">${t('entities.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">${t('common.total_entity', { entity: t('entities.nodes') })}</div>
|
||||
<div class="stat-value" style="color: ${pageColors.nodes}">${stats.total_nodes}</div>
|
||||
<div class="stat-desc">${t('dashboard.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">${t('entities.advertisements')}</div>
|
||||
<div class="stat-value" style="color: ${pageColors.adverts}">${stats.advertisements_7d}</div>
|
||||
<div class="stat-desc">${t('time.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">${t('entities.messages')}</div>
|
||||
<div class="stat-value" style="color: ${pageColors.messages}">${stats.messages_7d}</div>
|
||||
<div class="stat-desc">${t('time.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')}
|
||||
${t('common.total_entity', { entity: t('entities.nodes') })}
|
||||
</h2>
|
||||
<p class="text-xs opacity-70">${t('time.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')}
|
||||
${t('entities.advertisements')}
|
||||
</h2>
|
||||
<p class="text-xs opacity-70">${t('time.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')}
|
||||
${t('entities.messages')}
|
||||
</h2>
|
||||
<p class="text-xs opacity-70">${t('time.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')}
|
||||
${t('common.recent_entity', { entity: t('entities.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 || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
}
|
||||
208
src/meshcore_hub/web/static/js/spa/pages/home.js
Normal file
208
src/meshcore_hub/web/static/js/spa/pages/home.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, errorAlert, pageColors, t,
|
||||
} 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 = [
|
||||
[t('links.profile'), rc.profile],
|
||||
[t('home.frequency'), rc.frequency],
|
||||
[t('home.bandwidth'), rc.bandwidth],
|
||||
[t('home.spreading_factor'), rc.spreading_factor],
|
||||
[t('home.coding_rate'), rc.coding_rate],
|
||||
[t('home.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%]">
|
||||
${t('home.welcome_default', { network_name: networkName })}
|
||||
</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')}
|
||||
${t('entities.dashboard')}
|
||||
</a>` : nothing}
|
||||
${features.nodes !== false ? html`
|
||||
<a href="/nodes" class="btn btn-outline btn-primary">
|
||||
${iconNodes('h-5 w-5 mr-2')}
|
||||
${t('entities.nodes')}
|
||||
</a>` : nothing}
|
||||
${features.advertisements !== false ? html`
|
||||
<a href="/advertisements" class="btn btn-outline btn-secondary">
|
||||
${iconAdvertisements('h-5 w-5 mr-2')}
|
||||
${t('entities.advertisements')}
|
||||
</a>` : nothing}
|
||||
${features.messages !== false ? html`
|
||||
<a href="/messages" class="btn btn-outline btn-accent">
|
||||
${iconMessages('h-5 w-5 mr-2')}
|
||||
${t('entities.messages')}
|
||||
</a>` : nothing}
|
||||
${features.map !== false ? html`
|
||||
<a href="/map" class="btn btn-outline btn-warning">
|
||||
${iconMap('h-5 w-5 mr-2')}
|
||||
${t('entities.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">${t('common.total_entity', { entity: t('entities.nodes') })}</div>
|
||||
<div class="stat-value" style="color: ${pageColors.nodes}">${stats.total_nodes}</div>
|
||||
<div class="stat-desc">${t('home.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">${t('entities.advertisements')}</div>
|
||||
<div class="stat-value" style="color: ${pageColors.adverts}">${stats.advertisements_7d}</div>
|
||||
<div class="stat-desc">${t('time.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">${t('entities.messages')}</div>
|
||||
<div class="stat-value" style="color: ${pageColors.messages}">${stats.messages_7d}</div>
|
||||
<div class="stat-desc">${t('time.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')}
|
||||
${t('home.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">${t('home.meshcore_attribution')}</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')}
|
||||
${t('links.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')}
|
||||
${t('links.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')}
|
||||
${t('home.network_activity')}
|
||||
</h2>
|
||||
<p class="text-sm opacity-70 mb-2">${t('time.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 || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
}
|
||||
340
src/meshcore_hub/web/static/js/spa/pages/map.js
Normal file
340
src/meshcore_hub/web/static/js/spa/pages/map.js
Normal file
@@ -0,0 +1,340 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing, t,
|
||||
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 (window.t && window.t('node_types.chat')) || 'Chat';
|
||||
if (type === 'repeater') return (window.t && window.t('node_types.repeater')) || 'Repeater';
|
||||
if (type === 'room') return (window.t && window.t('node_types.room')) || 'Room';
|
||||
return type ? type.charAt(0).toUpperCase() + type.slice(1) : (window.t && window.t('node_types.unknown')) || '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">' + ((window.t && window.t('map.owner')) || 'Owner:') + '</span> ' + ownerDisplay + '</p>';
|
||||
}
|
||||
|
||||
let roleHtml = '';
|
||||
if (node.role) {
|
||||
roleHtml = '<p><span class="opacity-70">' + ((window.t && window.t('map.role')) || '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 ? ((window.t && window.t('map.infrastructure')) || 'Infrastructure') : ((window.t && window.t('map.public')) || '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 lastSeenLabel = (window.t && window.t('common.last_seen_label')) || 'Last seen:';
|
||||
const lastSeenHtml = node.last_seen
|
||||
? '<p><span class="opacity-70">' + lastSeenLabel + '</span> ' + node.last_seen.substring(0, 19).replace('T', ' ') + '</p>'
|
||||
: '';
|
||||
|
||||
const typeLabel = (window.t && window.t('common.type')) || 'Type:';
|
||||
const keyLabel = (window.t && window.t('common.key')) || 'Key:';
|
||||
const locationLabel = (window.t && window.t('common.location')) || 'Location:';
|
||||
const unknownLabel = (window.t && window.t('node_types.unknown')) || 'Unknown';
|
||||
const viewDetailsLabel = (window.t && window.t('common.view_details')) || 'View Details';
|
||||
|
||||
return '<div class="p-2">' +
|
||||
'<h3 class="font-bold text-lg mb-2">' + nodeTypeEmoji + ' ' + escapeHtml(node.name || unknownLabel) + infraIndicatorHtml + '</h3>' +
|
||||
'<div class="space-y-1 text-sm">' +
|
||||
'<p><span class="opacity-70">' + typeLabel + '</span> ' + escapeHtml(typeDisplay) + '</p>' +
|
||||
roleHtml +
|
||||
ownerHtml +
|
||||
'<p><span class="opacity-70">' + keyLabel + '</span> <code class="text-xs">' + escapeHtml(node.public_key.substring(0, 16)) + '...</code></p>' +
|
||||
'<p><span class="opacity-70">' + locationLabel + '</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">' + viewDetailsLabel + '</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">${t('entities.map')}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
${timezoneIndicator()}
|
||||
<span id="node-count" class="badge badge-lg">${t('common.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">${t('common.show')}</span>
|
||||
</label>
|
||||
<select id="filter-category" class="select select-bordered select-sm" @change=${applyFilters}>
|
||||
<option value="">${t('common.all_entity', { entity: t('entities.nodes') })}</option>
|
||||
<option value="infra">${t('map.infrastructure_only')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">${t('common.node_type')}</span>
|
||||
</label>
|
||||
<select id="filter-type" class="select select-bordered select-sm" @change=${applyFilters}>
|
||||
<option value="">${t('common.all_types')}</option>
|
||||
<option value="chat">${t('node_types.chat')}</option>
|
||||
<option value="repeater">${t('node_types.repeater')}</option>
|
||||
<option value="room">${t('node_types.room')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">${t('entities.member')}</span>
|
||||
</label>
|
||||
<select id="filter-member" class="select select-bordered select-sm" @change=${applyFilters}>
|
||||
<option value="">${t('common.all_entity', { entity: t('entities.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">${t('map.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}>${t('common.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">${t('map.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>${t('map.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>${t('map.public')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm opacity-70">
|
||||
<p>${t('map.gps_description')}</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 = t('map.nodes_on_map', { count: allNodes.length });
|
||||
filteredEl.classList.add('hidden');
|
||||
} else {
|
||||
countEl.textContent = t('common.total', { count: allNodes.length });
|
||||
filteredEl.textContent = t('common.shown', { count: filteredNodes.length });
|
||||
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 = t('common.no_entity_in_database', { entity: t('entities.nodes').toLowerCase() });
|
||||
return () => map.remove();
|
||||
}
|
||||
|
||||
if (debug.nodes_with_coords === 0) {
|
||||
container.querySelector('#node-count').textContent = t('map.nodes_none_have_coordinates', { count: debug.total_nodes });
|
||||
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 || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
}
|
||||
152
src/meshcore_hub/web/static/js/spa/pages/members.js
Normal file
152
src/meshcore_hub/web/static/js/spa/pages/members.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing, t, unsafeHTML,
|
||||
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">${t('common.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">${t('entities.members')}</h1>
|
||||
<span class="badge badge-lg">${t('common.count_entity', { count: 0, entity: t('entities.members').toLowerCase() })}</span>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
${iconInfo('stroke-current shrink-0 h-6 w-6')}
|
||||
<div>
|
||||
<h3 class="font-bold">${t('common.no_entity_configured', { entity: t('entities.members').toLowerCase() })}</h3>
|
||||
<p class="text-sm">${t('members.empty_state_description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${t('members.members_file_format')}</h2>
|
||||
<p class="mb-4">${unsafeHTML(t('members.members_file_description'))}</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">
|
||||
${unsafeHTML(t('members.members_import_instructions'))}
|
||||
</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">${t('entities.members')}</h1>
|
||||
<span class="badge badge-lg">${t('common.count_entity', { count: members.length, entity: t('entities.members').toLowerCase() })}</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 || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
}
|
||||
202
src/meshcore_hub/web/static/js/spa/pages/messages.js
Normal file
202
src/meshcore_hub/web/static/js/spa/pages/messages.js
Normal file
@@ -0,0 +1,202 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing, t,
|
||||
getConfig, formatDateTime, formatDateTimeShort,
|
||||
truncateKey, errorAlert,
|
||||
pagination, timezoneIndicator,
|
||||
createFilterHandler, autoSubmit, submitOnEnter
|
||||
} from '../components.js';
|
||||
import { createAutoRefresh } from '../auto-refresh.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">${t('entities.messages')}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="auto-refresh-toggle"></span>
|
||||
${tzBadge}
|
||||
${total !== null ? html`<span class="badge badge-lg">${t('common.total', { count: total })}</span>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
${content}`, container);
|
||||
}
|
||||
|
||||
// Render page header immediately (old content stays visible until data loads)
|
||||
renderPage(nothing);
|
||||
|
||||
async function fetchAndRenderData() {
|
||||
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">${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}</div>`
|
||||
: messages.map(msg => {
|
||||
const isChannel = msg.message_type === 'channel';
|
||||
const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}';
|
||||
const typeTitle = isChannel ? t('messages.type_channel') : t('messages.type_contact');
|
||||
let senderBlock;
|
||||
if (isChannel) {
|
||||
senderBlock = html`<span class="opacity-60">${t('messages.type_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">${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}</td></tr>`
|
||||
: messages.map(msg => {
|
||||
const isChannel = msg.message_type === 'channel';
|
||||
const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}';
|
||||
const typeTitle = isChannel ? t('messages.type_channel') : t('messages.type_contact');
|
||||
let senderBlock;
|
||||
if (isChannel) {
|
||||
senderBlock = html`<span class="opacity-60">${t('messages.type_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">${t('common.type')}</span>
|
||||
</label>
|
||||
<select name="message_type" class="select select-bordered select-sm" @change=${autoSubmit}>
|
||||
<option value="">${t('common.all_types')}</option>
|
||||
<option value="contact" ?selected=${message_type === 'contact'}>${t('messages.type_direct')}</option>
|
||||
<option value="channel" ?selected=${message_type === 'channel'}>${t('messages.type_channel')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">${t('common.filter')}</button>
|
||||
<a href="/messages" class="btn btn-ghost btn-sm">${t('common.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>${t('common.type')}</th>
|
||||
<th>${t('common.time')}</th>
|
||||
<th>${t('common.from')}</th>
|
||||
<th>${t('entities.message')}</th>
|
||||
<th>${t('common.receivers')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tableRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
${paginationBlock}`, { total });
|
||||
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
await fetchAndRenderData();
|
||||
|
||||
const toggleEl = container.querySelector('#auto-refresh-toggle');
|
||||
const { cleanup } = createAutoRefresh({
|
||||
fetchAndRender: fetchAndRenderData,
|
||||
toggleContainer: toggleEl,
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
256
src/meshcore_hub/web/static/js/spa/pages/node-detail.js
Normal file
256
src/meshcore_hub/web/static/js/spa/pages/node-detail.js
Normal file
@@ -0,0 +1,256 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, typeEmoji, formatDateTime,
|
||||
truncateKey, errorAlert, copyToClipboard, t,
|
||||
} 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 tagDescription = node.tags?.find(t => t.key === 'description')?.value;
|
||||
const displayName = tagName || node.name || t('common.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">${t('nodes.scan_to_add')}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const coordsHtml = hasCoords
|
||||
? html`<div><span class="opacity-70">${t('common.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>${t('common.time')}</th>
|
||||
<th>${t('common.type')}</th>
|
||||
<th>${t('common.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">${t('common.no_entity_recorded', { entity: t('entities.advertisements').toLowerCase() })}</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>${t('common.key')}</th>
|
||||
<th>${t('common.value')}</th>
|
||||
<th>${t('common.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">${t('common.no_entity_defined', { entity: t('entities.tags').toLowerCase() })}</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 ? t('common.edit_entity', { entity: t('entities.tags') }) : t('common.add_entity', { entity: t('entities.tags') })}</a>
|
||||
</div>`
|
||||
: nothing;
|
||||
|
||||
litRender(html`
|
||||
<div class="breadcrumbs text-sm mb-4">
|
||||
<ul>
|
||||
<li><a href="/">${t('entities.home')}</a></li>
|
||||
<li><a href="/nodes">${t('entities.nodes')}</a></li>
|
||||
<li>${tagName || node.name || node.public_key.slice(0, 12) + '...'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4 mb-6">
|
||||
<span class="text-6xl flex-shrink-0" title=${node.adv_type || t('node_types.unknown')}>${emoji}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-3xl font-bold">${displayName}</h1>
|
||||
${tagDescription ? html`<p class="text-base-content/70 mt-2">${tagDescription}</p>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${heroHtml}
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">${t('common.public_key')}</h3>
|
||||
<code class="text-sm bg-base-200 p-2 rounded block break-all cursor-pointer hover:bg-base-300 select-all"
|
||||
@click=${(e) => copyToClipboard(e, node.public_key)}
|
||||
title="Click to copy">${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">${t('common.first_seen_label')}</span> ${formatDateTime(node.first_seen)}</div>
|
||||
<div><span class="opacity-70">${t('common.last_seen_label')}</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">${t('common.recent_entity', { entity: t('entities.advertisements') })}</h2>
|
||||
${adsTableHtml}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${t('entities.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="/">${t('entities.home')}</a></li>
|
||||
<li><a href="/nodes">${t('entities.nodes')}</a></li>
|
||||
<li>${t('common.page_not_found')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="alert alert-error">
|
||||
${iconError('stroke-current shrink-0 h-6 w-6')}
|
||||
<span>${t('common.entity_not_found_details', { entity: t('entities.node'), details: publicKey })}</span>
|
||||
</div>
|
||||
<a href="/nodes" class="btn btn-primary mt-4">${t('common.view_entity', { entity: t('entities.nodes') })}</a>`;
|
||||
}
|
||||
209
src/meshcore_hub/web/static/js/spa/pages/nodes.js
Normal file
209
src/meshcore_hub/web/static/js/spa/pages/nodes.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, formatDateTime, formatDateTimeShort,
|
||||
truncateKey, errorAlert,
|
||||
pagination, timezoneIndicator,
|
||||
createFilterHandler, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, t
|
||||
} from '../components.js';
|
||||
import { createAutoRefresh } from '../auto-refresh.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 features = config.features || {};
|
||||
const showMembers = features.members !== false;
|
||||
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">${t('entities.nodes')}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="auto-refresh-toggle"></span>
|
||||
${tzBadge}
|
||||
${total !== null ? html`<span class="badge badge-lg">${t('common.total', { count: total })}</span>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
${content}`, container);
|
||||
}
|
||||
|
||||
// Render page header immediately (old content stays visible until data loads)
|
||||
renderPage(nothing);
|
||||
|
||||
async function fetchAndRenderData() {
|
||||
try {
|
||||
const requests = [
|
||||
apiGet('/api/v1/nodes', { limit, offset, search, adv_type, member_id }),
|
||||
];
|
||||
if (showMembers) {
|
||||
requests.push(apiGet('/api/v1/members', { limit: 100 }));
|
||||
}
|
||||
|
||||
const results = await Promise.all(requests);
|
||||
const data = results[0];
|
||||
const membersData = showMembers ? results[1] : null;
|
||||
|
||||
const nodes = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const members = membersData?.items || [];
|
||||
|
||||
const membersFilter = (showMembers && members.length > 0)
|
||||
? html`
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">${t('entities.member')}</span>
|
||||
</label>
|
||||
<select name="member_id" class="select select-bordered select-sm" @change=${autoSubmit}>
|
||||
<option value="">${t('common.all_entity', { entity: t('entities.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">${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}</div>`
|
||||
: nodes.map(node => {
|
||||
const tagName = node.tags?.find(tag => tag.key === 'name')?.value;
|
||||
const tagDescription = node.tags?.find(tag => tag.key === 'description')?.value;
|
||||
const displayName = tagName || node.name;
|
||||
const lastSeen = node.last_seen ? formatDateTimeShort(node.last_seen) : '-';
|
||||
const memberIdTag = showMembers ? node.tags?.find(tag => tag.key === 'member_id')?.value : null;
|
||||
const member = memberIdTag ? members.find(m => m.member_id === memberIdTag) : null;
|
||||
const memberBlock = (showMembers && member)
|
||||
? html`<div class="text-xs opacity-60">${member.name}</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">
|
||||
${renderNodeDisplay({
|
||||
name: displayName,
|
||||
description: tagDescription,
|
||||
publicKey: node.public_key,
|
||||
advType: node.adv_type,
|
||||
size: 'sm'
|
||||
})}
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-xs opacity-60">${lastSeen}</div>
|
||||
${memberBlock}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>`;
|
||||
});
|
||||
|
||||
const tableColspan = showMembers ? 4 : 3;
|
||||
const tableRows = nodes.length === 0
|
||||
? html`<tr><td colspan="${tableColspan}" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}</td></tr>`
|
||||
: nodes.map(node => {
|
||||
const tagName = node.tags?.find(tag => tag.key === 'name')?.value;
|
||||
const tagDescription = node.tags?.find(tag => tag.key === 'description')?.value;
|
||||
const displayName = tagName || node.name;
|
||||
const lastSeen = node.last_seen ? formatDateTime(node.last_seen) : '-';
|
||||
const memberIdTag = showMembers ? node.tags?.find(tag => tag.key === 'member_id')?.value : null;
|
||||
const member = memberIdTag ? members.find(m => m.member_id === memberIdTag) : null;
|
||||
const memberBlock = member
|
||||
? html`${member.name}${member.callsign ? html` <span class="opacity-60">(${member.callsign})</span>` : nothing}`
|
||||
: html`<span class="opacity-50">-</span>`;
|
||||
return html`<tr class="hover">
|
||||
<td>
|
||||
<a href="/nodes/${node.public_key}" class="link link-hover">
|
||||
${renderNodeDisplay({
|
||||
name: displayName,
|
||||
description: tagDescription,
|
||||
publicKey: node.public_key,
|
||||
advType: node.adv_type,
|
||||
size: 'base'
|
||||
})}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<code class="font-mono text-xs cursor-pointer hover:bg-base-200 px-1 py-0.5 rounded select-all"
|
||||
@click=${(e) => copyToClipboard(e, node.public_key)}
|
||||
title="Click to copy">${node.public_key}</code>
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">${lastSeen}</td>
|
||||
${showMembers ? html`<td class="text-sm">${memberBlock}</td>` : nothing}
|
||||
</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">${t('common.search')}</span>
|
||||
</label>
|
||||
<input type="text" name="search" .value=${search} placeholder="${t('common.search_placeholder')}" class="input input-bordered input-sm w-80" @keydown=${submitOnEnter} />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">${t('common.type')}</span>
|
||||
</label>
|
||||
<select name="adv_type" class="select select-bordered select-sm" @change=${autoSubmit}>
|
||||
<option value="">${t('common.all_types')}</option>
|
||||
<option value="chat" ?selected=${adv_type === 'chat'}>${t('node_types.chat')}</option>
|
||||
<option value="repeater" ?selected=${adv_type === 'repeater'}>${t('node_types.repeater')}</option>
|
||||
<option value="room" ?selected=${adv_type === 'room'}>${t('node_types.room')}</option>
|
||||
</select>
|
||||
</div>
|
||||
${membersFilter}
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">${t('common.filter')}</button>
|
||||
<a href="/nodes" class="btn btn-ghost btn-sm">${t('common.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>${t('entities.node')}</th>
|
||||
<th>${t('common.public_key')}</th>
|
||||
<th>${t('common.last_seen')}</th>
|
||||
${showMembers ? html`<th>${t('entities.member')}</th>` : nothing}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tableRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
${paginationBlock}`, { total });
|
||||
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
await fetchAndRenderData();
|
||||
|
||||
const toggleEl = container.querySelector('#auto-refresh-toggle');
|
||||
const { cleanup } = createAutoRefresh({
|
||||
fetchAndRender: fetchAndRenderData,
|
||||
toggleContainer: toggleEl,
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user