mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-03-28 17:42:56 +01:00
Compare commits
74 Commits
v0.6.13
...
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 |
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.
|
||||
59
.env.example
59
.env.example
@@ -107,6 +107,17 @@ MESHCORE_DEVICE_NAME=
|
||||
NODE_ADDRESS=
|
||||
NODE_ADDRESS_SENDER=
|
||||
|
||||
# -------------------
|
||||
# Contact Cleanup Settings (RECEIVER mode only)
|
||||
# -------------------
|
||||
# Automatic removal of stale contacts from the MeshCore companion node
|
||||
|
||||
# Enable automatic removal of stale contacts from companion node
|
||||
CONTACT_CLEANUP_ENABLED=true
|
||||
|
||||
# Remove contacts not advertised for this many days
|
||||
CONTACT_CLEANUP_DAYS=7
|
||||
|
||||
# =============================================================================
|
||||
# COLLECTOR SETTINGS
|
||||
# =============================================================================
|
||||
@@ -179,6 +190,25 @@ API_PORT=8000
|
||||
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
|
||||
# =============================================================================
|
||||
@@ -187,21 +217,50 @@ API_ADMIN_KEY=
|
||||
# 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
|
||||
|
||||
|
||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
buy_me_a_coffee: jinglemansweep
|
||||
41
.github/workflows/ci.yml
vendored
41
.github/workflows/ci.yml
vendored
@@ -3,37 +3,40 @@ name: CI
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "tests/**"
|
||||
- "alembic/**"
|
||||
- ".python-version"
|
||||
- "pyproject.toml"
|
||||
- ".pre-commit-config.yaml"
|
||||
- ".github/workflows/ci.yml"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Run pre-commit
|
||||
uses: pre-commit/action@v3.0.1
|
||||
|
||||
test:
|
||||
name: Test (Python ${{ matrix.python-version }})
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.13"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
@@ -45,8 +48,8 @@ jobs:
|
||||
pytest --cov=meshcore_hub --cov-report=xml --cov-report=term-missing
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
if: matrix.python-version == '3.13'
|
||||
uses: codecov/codecov-action@v5
|
||||
if: always()
|
||||
with:
|
||||
files: ./coverage.xml
|
||||
fail_ci_if_error: false
|
||||
@@ -57,12 +60,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
@@ -73,7 +76,7 @@ jobs:
|
||||
run: python -m build
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
43
.github/workflows/claude.yml
vendored
Normal file
43
.github/workflows/claude.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
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.'
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
13
.github/workflows/docker.yml
vendored
13
.github/workflows/docker.yml
vendored
@@ -3,6 +3,15 @@ name: Docker
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "alembic/**"
|
||||
- "alembic.ini"
|
||||
- ".python-version"
|
||||
- "pyproject.toml"
|
||||
- "Dockerfile"
|
||||
- "docker-compose.yml"
|
||||
- ".github/workflows/docker.yml"
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
@@ -19,7 +28,7 @@ jobs:
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -48,7 +57,7 @@ jobs:
|
||||
type=sha
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
|
||||
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,3 +1,6 @@
|
||||
default_language_version:
|
||||
python: python3
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
@@ -14,7 +17,6 @@ repos:
|
||||
rev: 24.3.0
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3.13
|
||||
args: ["--line-length=88"]
|
||||
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.13
|
||||
3.14
|
||||
|
||||
145
AGENTS.md
145
AGENTS.md
@@ -281,13 +281,15 @@ meshcore-hub/
|
||||
│ │ ├── app.py # FastAPI app
|
||||
│ │ ├── auth.py # Authentication
|
||||
│ │ ├── dependencies.py
|
||||
│ │ ├── metrics.py # Prometheus metrics endpoint
|
||||
│ │ └── routes/ # API routes
|
||||
│ │ ├── members.py # Member CRUD endpoints
|
||||
│ │ └── ...
|
||||
│ └── web/
|
||||
│ ├── cli.py
|
||||
│ ├── app.py # FastAPI app
|
||||
│ ├── templates/ # Jinja2 templates (spa.html shell, base.html)
|
||||
│ ├── pages.py # Custom markdown page loader
|
||||
│ ├── templates/ # Jinja2 templates (spa.html shell)
|
||||
│ └── static/
|
||||
│ ├── css/app.css # Custom styles
|
||||
│ └── js/spa/ # SPA frontend (ES modules)
|
||||
@@ -310,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
|
||||
@@ -355,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
|
||||
@@ -449,13 +478,103 @@ The web dashboard is a Single Page Application. Pages are ES modules loaded by t
|
||||
- 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
|
||||
|
||||
@@ -477,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
|
||||
@@ -493,8 +612,11 @@ Key variables:
|
||||
- `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.
|
||||
@@ -715,9 +837,10 @@ await mc.start_auto_message_fetching()
|
||||
|
||||
On startup, the receiver performs these initialization steps:
|
||||
1. Set device clock to current Unix timestamp
|
||||
2. Send a local (non-flood) advertisement
|
||||
3. Start automatic message fetching
|
||||
4. Sync the device's contact database
|
||||
2. Optionally set the device name (if `MESHCORE_DEVICE_NAME` is configured)
|
||||
3. Send a flood advertisement (broadcasts device name to the mesh)
|
||||
4. Start automatic message fetching
|
||||
5. Sync the device's contact database
|
||||
|
||||
### Contact Sync Behavior
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# =============================================================================
|
||||
# Stage 1: Builder - Install dependencies and build package
|
||||
# =============================================================================
|
||||
FROM python:3.13-slim AS builder
|
||||
FROM python:3.14-slim AS builder
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
@@ -39,7 +39,7 @@ RUN sed -i "s|__version__ = \"dev\"|__version__ = \"${BUILD_VERSION}\"|" src/mes
|
||||
# =============================================================================
|
||||
# Stage 2: Runtime - Final production image
|
||||
# =============================================================================
|
||||
FROM python:3.13-slim AS runtime
|
||||
FROM python:3.14-slim AS runtime
|
||||
|
||||
# Labels
|
||||
LABEL org.opencontainers.image.title="MeshCore Hub" \
|
||||
|
||||
57
README.md
57
README.md
@@ -1,9 +1,18 @@
|
||||
# MeshCore Hub
|
||||
|
||||
[](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
|
||||
|
||||
MeshCore Hub provides a complete solution for monitoring, collecting, and interacting with MeshCore mesh networks. It consists of multiple components that work together:
|
||||
@@ -66,6 +75,7 @@ flowchart LR
|
||||
- **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
|
||||
@@ -168,13 +178,14 @@ 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.
|
||||
|
||||
@@ -244,7 +255,7 @@ 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
|
||||
@@ -275,6 +286,10 @@ All components are configured via environment variables. Create a `.env` file or
|
||||
| `SERIAL_PORT` | `/dev/ttyUSB0` | Serial port for MeshCore device |
|
||||
| `SERIAL_BAUD` | `115200` | Serial baud rate |
|
||||
| `MESHCORE_DEVICE_NAME` | *(none)* | Device/node name set on startup (broadcast in advertisements) |
|
||||
| `NODE_ADDRESS` | *(none)* | Override for device public key (64-char hex string) |
|
||||
| `NODE_ADDRESS_SENDER` | *(none)* | Override for sender device public key |
|
||||
| `CONTACT_CLEANUP_ENABLED` | `true` | Enable automatic removal of stale contacts from companion node |
|
||||
| `CONTACT_CLEANUP_DAYS` | `7` | Remove contacts not advertised for this many days |
|
||||
|
||||
### Webhooks
|
||||
|
||||
@@ -287,7 +302,9 @@ The collector can forward certain events to external HTTP endpoints:
|
||||
| `WEBHOOK_MESSAGE_URL` | *(none)* | Webhook URL for all message events |
|
||||
| `WEBHOOK_MESSAGE_SECRET` | *(none)* | Secret for message webhook |
|
||||
| `WEBHOOK_CHANNEL_MESSAGE_URL` | *(none)* | Override URL for channel messages only |
|
||||
| `WEBHOOK_CHANNEL_MESSAGE_SECRET` | *(none)* | Secret for channel message webhook |
|
||||
| `WEBHOOK_DIRECT_MESSAGE_URL` | *(none)* | Override URL for direct messages only |
|
||||
| `WEBHOOK_DIRECT_MESSAGE_SECRET` | *(none)* | Secret for direct message webhook |
|
||||
| `WEBHOOK_TIMEOUT` | `10.0` | Request timeout in seconds |
|
||||
| `WEBHOOK_MAX_RETRIES` | `3` | Max retry attempts on failure |
|
||||
| `WEBHOOK_RETRY_BACKOFF` | `2.0` | Exponential backoff multiplier |
|
||||
@@ -321,6 +338,8 @@ The collector automatically cleans up old event data and inactive nodes:
|
||||
| `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
|
||||
|
||||
@@ -329,8 +348,13 @@ The collector automatically cleans up old event data and inactive nodes:
|
||||
| `WEB_HOST` | `0.0.0.0` | Web server bind address |
|
||||
| `WEB_PORT` | `8080` | Web server port |
|
||||
| `API_BASE_URL` | `http://localhost:8000` | API endpoint URL |
|
||||
| `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) |
|
||||
@@ -339,6 +363,7 @@ The collector automatically cleans up old event data and inactive nodes:
|
||||
| `NETWORK_CONTACT_EMAIL` | *(none)* | Contact email address |
|
||||
| `NETWORK_CONTACT_DISCORD` | *(none)* | Discord server link |
|
||||
| `NETWORK_CONTACT_GITHUB` | *(none)* | GitHub repository URL |
|
||||
| `NETWORK_CONTACT_YOUTUBE` | *(none)* | YouTube channel URL |
|
||||
| `CONTENT_HOME` | `./content` | Directory containing custom content (pages/, media/) |
|
||||
|
||||
#### Feature Flags
|
||||
@@ -455,15 +480,16 @@ 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
|
||||
name: Oakland Repeater
|
||||
elevation: 150
|
||||
```
|
||||
|
||||
Tag values can be:
|
||||
@@ -518,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
|
||||
|
||||
@@ -541,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
|
||||
|
||||
@@ -614,17 +647,19 @@ meshcore-hub/
|
||||
│ ├── api/ # REST API
|
||||
│ └── web/ # Web dashboard
|
||||
│ ├── templates/ # Jinja2 templates (SPA shell)
|
||||
│ └── static/js/spa/ # SPA frontend (ES modules, lit-html)
|
||||
│ └── 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
|
||||
├── 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
|
||||
│ │ └── about.md # Example about page
|
||||
│ │ └── join.md # Example join page
|
||||
│ └── media/ # Example media files
|
||||
│ └── images/ # Custom images
|
||||
├── seed/ # Seed data directory (SEED_HOME, copy from example/seed/)
|
||||
@@ -664,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
|
||||
|
||||
@@ -215,6 +215,8 @@ services:
|
||||
- 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')"]
|
||||
@@ -251,6 +253,8 @@ services:
|
||||
- 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:-}
|
||||
@@ -324,6 +328,48 @@ services:
|
||||
# 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
|
||||
# ==========================================================================
|
||||
@@ -334,3 +380,7 @@ volumes:
|
||||
name: meshcore_mosquitto_data
|
||||
mosquitto_log:
|
||||
name: meshcore_mosquitto_log
|
||||
prometheus_data:
|
||||
name: meshcore_prometheus_data
|
||||
alertmanager_data:
|
||||
name: meshcore_alertmanager_data
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 124 KiB 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']
|
||||
@@ -41,6 +41,7 @@ dependencies = [
|
||||
"pyyaml>=6.0.0",
|
||||
"python-frontmatter>=1.0.0",
|
||||
"markdown>=3.5.0",
|
||||
"prometheus-client>=0.20.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -52,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",
|
||||
]
|
||||
@@ -115,6 +117,7 @@ module = [
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
@@ -29,6 +29,16 @@ def _get_tag_name(node: Optional[Node]) -> Optional[str]:
|
||||
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
|
||||
|
||||
|
||||
def _fetch_receivers_for_events(
|
||||
session: DbSession,
|
||||
event_type: str,
|
||||
@@ -210,6 +220,7 @@ async def list_advertisements(
|
||||
"name": adv.name,
|
||||
"node_name": row.source_name,
|
||||
"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,
|
||||
@@ -292,6 +303,7 @@ async def get_advertisement(
|
||||
"name": adv.name,
|
||||
"node_name": result.source_name,
|
||||
"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,
|
||||
|
||||
@@ -262,6 +262,19 @@ class WebSettings(CommonSettings):
|
||||
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,
|
||||
|
||||
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
|
||||
@@ -119,6 +119,9 @@ class AdvertisementRead(BaseModel):
|
||||
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")
|
||||
received_at: datetime = Field(..., description="When received")
|
||||
|
||||
@@ -16,7 +16,9 @@ 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__)
|
||||
@@ -114,6 +116,8 @@ def _build_config_json(app: FastAPI, request: Request) -> str:
|
||||
"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)
|
||||
@@ -174,6 +178,16 @@ def create_app(
|
||||
# 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"
|
||||
@@ -227,6 +241,7 @@ def create_app(
|
||||
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
|
||||
|
||||
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
|
||||
@@ -154,7 +154,7 @@ function createActivityChart(canvasId, advertData, messageData) {
|
||||
if (advertData && advertData.data && advertData.data.length > 0) {
|
||||
if (!labels) labels = formatDateLabels(advertData.data);
|
||||
datasets.push({
|
||||
label: 'Advertisements',
|
||||
label: (window.t && window.t('entities.advertisements')) || 'Advertisements',
|
||||
data: advertData.data.map(function(d) { return d.count; }),
|
||||
borderColor: ChartColors.adverts,
|
||||
backgroundColor: ChartColors.advertsFill,
|
||||
@@ -168,7 +168,7 @@ function createActivityChart(canvasId, advertData, messageData) {
|
||||
if (messageData && messageData.data && messageData.data.length > 0) {
|
||||
if (!labels) labels = formatDateLabels(messageData.data);
|
||||
datasets.push({
|
||||
label: 'Messages',
|
||||
label: (window.t && window.t('entities.messages')) || 'Messages',
|
||||
data: messageData.data.map(function(d) { return d.count; }),
|
||||
borderColor: ChartColors.messages,
|
||||
backgroundColor: ChartColors.messagesFill,
|
||||
@@ -200,7 +200,7 @@ function initDashboardCharts(nodeData, advertData, messageData) {
|
||||
createLineChart(
|
||||
'nodeChart',
|
||||
nodeData,
|
||||
'Total Nodes',
|
||||
(window.t && window.t('common.total_entity', { entity: t('entities.nodes') })) || 'Total Nodes',
|
||||
ChartColors.nodes,
|
||||
ChartColors.nodesFill,
|
||||
true
|
||||
@@ -211,7 +211,7 @@ function initDashboardCharts(nodeData, advertData, messageData) {
|
||||
createLineChart(
|
||||
'advertChart',
|
||||
advertData,
|
||||
'Advertisements',
|
||||
(window.t && window.t('entities.advertisements')) || 'Advertisements',
|
||||
ChartColors.adverts,
|
||||
ChartColors.advertsFill,
|
||||
true
|
||||
@@ -222,7 +222,7 @@ function initDashboardCharts(nodeData, advertData, messageData) {
|
||||
createLineChart(
|
||||
'messageChart',
|
||||
messageData,
|
||||
'Messages',
|
||||
(window.t && window.t('entities.messages')) || 'Messages',
|
||||
ChartColors.messages,
|
||||
ChartColors.messagesFill,
|
||||
true
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* MeshCore Hub SPA - Main Application Entry Point
|
||||
*
|
||||
* Initializes the router, registers all page routes, and handles navigation.
|
||||
* 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 = {
|
||||
@@ -46,10 +48,10 @@ function pageHandler(loader) {
|
||||
console.error('Page load error:', e);
|
||||
appContainer.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
<h1 class="text-4xl font-bold mb-4">Error</h1>
|
||||
<p class="text-lg opacity-70 mb-6">Failed to load page</p>
|
||||
<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">Go Home</a>
|
||||
<a href="/" class="btn btn-primary">${t('common.go_home')}</a>
|
||||
</div>`;
|
||||
}
|
||||
};
|
||||
@@ -124,33 +126,43 @@ function updateNavActiveState(pathname) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 config = getConfig();
|
||||
const networkName = config.network_name || 'MeshCore Network';
|
||||
const titles = {
|
||||
'/': networkName,
|
||||
'/a': `Admin - ${networkName}`,
|
||||
'/a/': `Admin - ${networkName}`,
|
||||
'/a/node-tags': `Node Tags - Admin - ${networkName}`,
|
||||
'/a/members': `Members - Admin - ${networkName}`,
|
||||
'/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'] = `Dashboard - ${networkName}`;
|
||||
if (features.nodes !== false) titles['/nodes'] = `Nodes - ${networkName}`;
|
||||
if (features.messages !== false) titles['/messages'] = `Messages - ${networkName}`;
|
||||
if (features.advertisements !== false) titles['/advertisements'] = `Advertisements - ${networkName}`;
|
||||
if (features.map !== false) titles['/map'] = `Map - ${networkName}`;
|
||||
if (features.members !== false) titles['/members'] = `Members - ${networkName}`;
|
||||
if (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 = `Node Detail - ${networkName}`;
|
||||
document.title = composePageTitle('entities.node_detail');
|
||||
} else if (pathname.startsWith('/pages/')) {
|
||||
// Custom pages set their own title in the page module
|
||||
document.title = networkName;
|
||||
@@ -165,5 +177,7 @@ router.onNavigate((pathname) => {
|
||||
updatePageTitle(pathname);
|
||||
});
|
||||
|
||||
// Start the router when DOM is ready
|
||||
// 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,10 +7,12 @@
|
||||
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.
|
||||
@@ -49,6 +51,32 @@ export function typeEmoji(advType) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -113,10 +141,10 @@ export function formatRelativeTime(isoString) {
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
if (diffDay > 0) return `${diffDay}d ago`;
|
||||
if (diffHour > 0) return `${diffHour}h ago`;
|
||||
if (diffMin > 0) return `${diffMin}m ago`;
|
||||
return '<1m ago';
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,8 +172,97 @@ export function escapeHtml(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}
|
||||
@@ -226,12 +343,12 @@ export function pagination(page, totalPages, basePath, params = {}) {
|
||||
|
||||
return html`<div class="flex justify-center mt-6"><div class="join">
|
||||
${page > 1
|
||||
? html`<a href=${pageUrl(page - 1)} class="join-item btn btn-sm">Previous</a>`
|
||||
: html`<button class="join-item btn btn-sm btn-disabled" disabled>Previous</button>`}
|
||||
? 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">Next</a>`
|
||||
: html`<button class="join-item btn btn-sm btn-disabled" disabled>Next</button>`}
|
||||
? 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>`;
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, litRender, getConfig, errorAlert } from '../../components.js';
|
||||
import { html, litRender, unsafeHTML, getConfig, errorAlert, t } from '../../components.js';
|
||||
import { iconLock, iconUsers, iconTag } from '../../icons.js';
|
||||
|
||||
export async function render(container, params, router) {
|
||||
@@ -9,10 +9,10 @@ export async function render(container, params, router) {
|
||||
litRender(html`
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
${iconLock('h-16 w-16 opacity-30 mb-4')}
|
||||
<h1 class="text-3xl font-bold mb-2">Access Denied</h1>
|
||||
<p class="opacity-70">The admin interface is not enabled.</p>
|
||||
<p class="text-sm opacity-50 mt-2">Set <code>WEB_ADMIN_ENABLED=true</code> to enable admin features.</p>
|
||||
<a href="/" class="btn btn-primary mt-6">Go Home</a>
|
||||
<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;
|
||||
}
|
||||
@@ -21,9 +21,9 @@ export async function render(container, params, router) {
|
||||
litRender(html`
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
${iconLock('h-16 w-16 opacity-30 mb-4')}
|
||||
<h1 class="text-3xl font-bold mb-2">Authentication Required</h1>
|
||||
<p class="opacity-70">You must sign in to access the admin interface.</p>
|
||||
<a href="/oauth2/start?rd=${encodeURIComponent(window.location.pathname)}" class="btn btn-primary mt-6">Sign In</a>
|
||||
<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;
|
||||
}
|
||||
@@ -31,20 +31,20 @@ export async function render(container, params, router) {
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Admin</h1>
|
||||
<h1 class="text-3xl font-bold">${t('entities.admin')}</h1>
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li>Admin</li>
|
||||
<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">Sign Out</a>
|
||||
<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">
|
||||
Welcome to the admin panel.
|
||||
${t('admin.welcome')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -53,23 +53,23 @@ export async function render(container, params, router) {
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
${iconUsers('h-6 w-6')}
|
||||
Members
|
||||
${t('entities.members')}
|
||||
</h2>
|
||||
<p>Manage network members and operators.</p>
|
||||
<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')}
|
||||
Node Tags
|
||||
${t('entities.tags')}
|
||||
</h2>
|
||||
<p>Manage custom tags and metadata for network nodes.</p>
|
||||
<p>${t('admin.tags_description')}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>`, container);
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || 'Failed to load admin page'), container);
|
||||
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, errorAlert, successAlert,
|
||||
getConfig, errorAlert, successAlert, t,
|
||||
} from '../../components.js';
|
||||
import { iconLock } from '../../icons.js';
|
||||
|
||||
@@ -13,9 +13,9 @@ export async function render(container, params, router) {
|
||||
litRender(html`
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
${iconLock('h-16 w-16 opacity-30 mb-4')}
|
||||
<h1 class="text-3xl font-bold mb-2">Access Denied</h1>
|
||||
<p class="opacity-70">The admin interface is not enabled.</p>
|
||||
<a href="/" class="btn btn-primary mt-6">Go Home</a>
|
||||
<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;
|
||||
}
|
||||
@@ -24,9 +24,9 @@ export async function render(container, params, router) {
|
||||
litRender(html`
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
${iconLock('h-16 w-16 opacity-30 mb-4')}
|
||||
<h1 class="text-3xl font-bold mb-2">Authentication Required</h1>
|
||||
<p class="opacity-70">You must sign in to access the admin interface.</p>
|
||||
<a href="/oauth2/start?rd=${encodeURIComponent(window.location.pathname)}" class="btn btn-primary mt-6">Sign In</a>
|
||||
<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;
|
||||
}
|
||||
@@ -45,11 +45,11 @@ export async function render(container, params, router) {
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member ID</th>
|
||||
<th>Name</th>
|
||||
<th>Callsign</th>
|
||||
<th>Contact</th>
|
||||
<th class="w-32">Actions</th>
|
||||
<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`
|
||||
@@ -69,8 +69,8 @@ export async function render(container, params, router) {
|
||||
<td class="max-w-xs truncate" title=${m.contact || ''}>${m.contact || '-'}</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-ghost btn-xs btn-edit">Edit</button>
|
||||
<button class="btn btn-ghost btn-xs text-error btn-delete">Delete</button>
|
||||
<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>
|
||||
@@ -78,23 +78,23 @@ export async function render(container, params, router) {
|
||||
</div>`
|
||||
: html`
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<p>No members configured yet.</p>
|
||||
<p class="text-sm mt-2">Click "Add Member" to create the first member.</p>
|
||||
<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">Members</h1>
|
||||
<h1 class="text-3xl font-bold">${t('entities.members')}</h1>
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/a/">Admin</a></li>
|
||||
<li>Members</li>
|
||||
<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">Sign Out</a>
|
||||
<a href="/oauth2/sign_out" target="_blank" class="btn btn-outline btn-sm">${t('common.sign_out')}</a>
|
||||
</div>
|
||||
|
||||
${flashHtml}
|
||||
@@ -102,8 +102,8 @@ ${flashHtml}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="card-title">Network Members (${members.length})</h2>
|
||||
<button id="btn-add-member" class="btn btn-primary btn-sm">Add Member</button>
|
||||
<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>
|
||||
@@ -111,62 +111,62 @@ ${flashHtml}
|
||||
|
||||
<dialog id="addModal" class="modal">
|
||||
<div class="modal-box w-11/12 max-w-2xl">
|
||||
<h3 class="font-bold text-lg">Add New Member</h3>
|
||||
<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">Member ID <span class="text-error">*</span></span>
|
||||
<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">Unique identifier (letters, numbers, underscore)</span>
|
||||
<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">Name <span class="text-error">*</span></span>
|
||||
<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">Callsign</span></label>
|
||||
<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">Contact</span></label>
|
||||
<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">Description</span></label>
|
||||
<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">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Add Member</button>
|
||||
<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>close</button></form>
|
||||
<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">Edit Member</h3>
|
||||
<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">Member ID <span class="text-error">*</span></span>
|
||||
<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_]+"
|
||||
@@ -174,52 +174,52 @@ ${flashHtml}
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Name <span class="text-error">*</span></span>
|
||||
<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">Callsign</span></label>
|
||||
<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">Contact</span></label>
|
||||
<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">Description</span></label>
|
||||
<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">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
<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>close</button></form>
|
||||
<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">Delete Member</h3>
|
||||
<h3 class="font-bold text-lg">${t('common.delete_entity', { entity: t('entities.member') })}</h3>
|
||||
<div class="py-4">
|
||||
<p class="py-4">Are you sure you want to delete member <strong id="delete_member_name"></strong>?</p>
|
||||
<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>This action cannot be undone.</span>
|
||||
<span>${t('common.cannot_be_undone')}</span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="deleteCancel">Cancel</button>
|
||||
<button type="button" class="btn btn-error" id="deleteConfirm">Delete</button>
|
||||
<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>close</button></form>
|
||||
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
|
||||
</dialog>`, container);
|
||||
|
||||
let activeDeleteId = '';
|
||||
@@ -249,7 +249,7 @@ ${flashHtml}
|
||||
try {
|
||||
await apiPost('/api/v1/members', body);
|
||||
container.querySelector('#addModal').close();
|
||||
router.navigate('/a/members?message=' + encodeURIComponent('Member added successfully'));
|
||||
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));
|
||||
@@ -289,7 +289,7 @@ ${flashHtml}
|
||||
try {
|
||||
await apiPut('/api/v1/members/' + encodeURIComponent(id), body);
|
||||
container.querySelector('#editModal').close();
|
||||
router.navigate('/a/members?message=' + encodeURIComponent('Member updated successfully'));
|
||||
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));
|
||||
@@ -301,7 +301,12 @@ ${flashHtml}
|
||||
btn.addEventListener('click', () => {
|
||||
const row = btn.closest('tr');
|
||||
activeDeleteId = row.dataset.memberId;
|
||||
container.querySelector('#delete_member_name').textContent = row.dataset.memberName;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -314,7 +319,7 @@ ${flashHtml}
|
||||
try {
|
||||
await apiDelete('/api/v1/members/' + encodeURIComponent(activeDeleteId));
|
||||
container.querySelector('#deleteModal').close();
|
||||
router.navigate('/a/members?message=' + encodeURIComponent('Member deleted successfully'));
|
||||
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));
|
||||
@@ -322,6 +327,6 @@ ${flashHtml}
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || 'Failed to load members'), container);
|
||||
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
html, litRender, nothing, unsafeHTML,
|
||||
getConfig, typeEmoji, formatDateTimeShort, errorAlert,
|
||||
successAlert, truncateKey,
|
||||
successAlert, truncateKey, t,
|
||||
} from '../../components.js';
|
||||
import { iconTag, iconLock } from '../../icons.js';
|
||||
|
||||
@@ -14,9 +14,9 @@ export async function render(container, params, router) {
|
||||
litRender(html`
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
${iconLock('h-16 w-16 opacity-30 mb-4')}
|
||||
<h1 class="text-3xl font-bold mb-2">Access Denied</h1>
|
||||
<p class="opacity-70">The admin interface is not enabled.</p>
|
||||
<a href="/" class="btn btn-primary mt-6">Go Home</a>
|
||||
<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;
|
||||
}
|
||||
@@ -25,9 +25,9 @@ export async function render(container, params, router) {
|
||||
litRender(html`
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
${iconLock('h-16 w-16 opacity-30 mb-4')}
|
||||
<h1 class="text-3xl font-bold mb-2">Authentication Required</h1>
|
||||
<p class="opacity-70">You must sign in to access the admin interface.</p>
|
||||
<a href="/oauth2/start?rd=${encodeURIComponent(window.location.pathname)}" class="btn btn-primary mt-6">Sign In</a>
|
||||
<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;
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export async function render(container, params, router) {
|
||||
|
||||
if (selectedPublicKey && selectedNode) {
|
||||
const nodeEmoji = typeEmoji(selectedNode.adv_type);
|
||||
const nodeName = selectedNode.name || 'Unnamed Node';
|
||||
const nodeName = selectedNode.name || t('common.unnamed_node');
|
||||
const otherNodes = allNodes.filter(n => n.public_key !== selectedPublicKey);
|
||||
|
||||
const tagsTableHtml = tags.length > 0
|
||||
@@ -66,11 +66,11 @@ export async function render(container, params, router) {
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
<th>Updated</th>
|
||||
<th class="w-48">Actions</th>
|
||||
<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`
|
||||
@@ -83,9 +83,9 @@ export async function render(container, params, router) {
|
||||
<td class="text-sm opacity-70">${formatDateTimeShort(tag.updated_at)}</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-ghost btn-xs btn-edit">Edit</button>
|
||||
<button class="btn btn-ghost btn-xs btn-move">Move</button>
|
||||
<button class="btn btn-ghost btn-xs text-error btn-delete">Delete</button>
|
||||
<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>
|
||||
@@ -93,14 +93,14 @@ export async function render(container, params, router) {
|
||||
</div>`
|
||||
: html`
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<p>No tags found for this node.</p>
|
||||
<p class="text-sm mt-2">Add a new tag below.</p>
|
||||
<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">Copy All</button>
|
||||
<button id="btn-delete-all" class="btn btn-outline btn-error btn-sm">Delete All</button>`
|
||||
<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`
|
||||
@@ -116,7 +116,7 @@ export async function render(container, params, router) {
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
${bulkButtons}
|
||||
<a href="/nodes/${encodeURIComponent(selectedPublicKey)}" class="btn btn-ghost btn-sm">View Node</a>
|
||||
<a href="/nodes/${encodeURIComponent(selectedPublicKey)}" class="btn btn-ghost btn-sm">${t('common.view_entity', { entity: t('entities.node') })}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,25 +124,25 @@ export async function render(container, params, router) {
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Tags (${tags.length})</h2>
|
||||
<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">Add New Tag</h2>
|
||||
<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">Key</span></label>
|
||||
<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">Value</span></label>
|
||||
<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">Type</span></label>
|
||||
<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>
|
||||
@@ -151,7 +151,7 @@ export async function render(container, params, router) {
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text"> </span></label>
|
||||
<button type="submit" class="btn btn-primary">Add Tag</button>
|
||||
<button type="submit" class="btn btn-primary">${t('common.add_entity', { entity: t('entities.tag') })}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -159,18 +159,18 @@ export async function render(container, params, router) {
|
||||
|
||||
<dialog id="editModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Edit Tag</h3>
|
||||
<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">Key</span></label>
|
||||
<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">Value</span></label>
|
||||
<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">Type</span></label>
|
||||
<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>
|
||||
@@ -178,28 +178,28 @@ export async function render(container, params, router) {
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="editCancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
<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>close</button></form>
|
||||
<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">Move Tag to Another Node</h3>
|
||||
<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">Tag Key</span></label>
|
||||
<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">Destination Node</span></label>
|
||||
<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="">-- Select destination node --</option>
|
||||
<option value="">${t('map.select_destination_node')}</option>
|
||||
${otherNodes.map(n => {
|
||||
const name = n.name || 'Unnamed';
|
||||
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>`;
|
||||
})}
|
||||
@@ -207,46 +207,46 @@ export async function render(container, params, router) {
|
||||
</div>
|
||||
<div class="alert alert-warning mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span>This will move the tag from the current node to the destination node.</span>
|
||||
<span>${t('admin_node_tags.move_warning')}</span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="moveCancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-warning">Move Tag</button>
|
||||
<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>close</button></form>
|
||||
<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">Delete Tag</h3>
|
||||
<h3 class="font-bold text-lg">${t('common.delete_entity', { entity: t('entities.tag') })}</h3>
|
||||
<div class="py-4">
|
||||
<p class="py-4">Are you sure you want to delete the tag "<span id="deleteKeyDisplay" class="font-mono font-semibold"></span>"?</p>
|
||||
<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>This action cannot be undone.</span>
|
||||
<span>${t('common.cannot_be_undone')}</span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="deleteCancel">Cancel</button>
|
||||
<button type="button" class="btn btn-error" id="deleteConfirm">Delete</button>
|
||||
<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>close</button></form>
|
||||
<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">Copy All Tags to Another Node</h3>
|
||||
<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">Copy all ${tags.length} tag(s) from <strong>${nodeName}</strong> to another node.</p>
|
||||
<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">Destination Node</span></label>
|
||||
<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="">-- Select destination node --</option>
|
||||
<option value="">${t('map.select_destination_node')}</option>
|
||||
${otherNodes.map(n => {
|
||||
const name = n.name || 'Unnamed';
|
||||
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>`;
|
||||
})}
|
||||
@@ -254,33 +254,33 @@ export async function render(container, params, router) {
|
||||
</div>
|
||||
<div class="alert alert-info mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span>Tags that already exist on the destination node will be skipped. Original tags remain on this node.</span>
|
||||
<span>${t('admin_node_tags.copy_all_info')}</span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="copyAllCancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Copy Tags</button>
|
||||
<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>close</button></form>
|
||||
<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">Delete All Tags</h3>
|
||||
<h3 class="font-bold text-lg">${t('common.delete_all_entity', { entity: t('entities.tags') })}</h3>
|
||||
<div class="py-4">
|
||||
<p class="mb-4">Are you sure you want to delete all ${tags.length} tag(s) from <strong>${nodeName}</strong>?</p>
|
||||
<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>This action cannot be undone. All tags will be permanently deleted.</span>
|
||||
<span>${t('admin_node_tags.delete_all_warning')}</span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="deleteAllCancel">Cancel</button>
|
||||
<button type="button" class="btn btn-error" id="deleteAllConfirm">Delete All Tags</button>
|
||||
<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>close</button></form>
|
||||
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
|
||||
</dialog>`;
|
||||
} else if (selectedPublicKey && !selectedNode) {
|
||||
contentHtml = html`
|
||||
@@ -293,8 +293,8 @@ export async function render(container, params, router) {
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body text-center py-12">
|
||||
${iconTag('h-16 w-16 mx-auto mb-4 opacity-30')}
|
||||
<h2 class="text-xl font-semibold mb-2">Select a Node</h2>
|
||||
<p class="opacity-70">Choose a node from the dropdown above to view and manage its tags.</p>
|
||||
<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>`;
|
||||
}
|
||||
@@ -302,36 +302,36 @@ export async function render(container, params, router) {
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Node Tags</h1>
|
||||
<h1 class="text-3xl font-bold">${t('entities.tags')}</h1>
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/a/">Admin</a></li>
|
||||
<li>Node Tags</li>
|
||||
<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">Sign Out</a>
|
||||
<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">Select Node</h2>
|
||||
<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">Node</span></label>
|
||||
<label class="label"><span class="label-text">${t('entities.node')}</span></label>
|
||||
<select id="node-selector" class="select select-bordered w-full">
|
||||
<option value="">-- Select a node --</option>
|
||||
<option value="">${t('admin_node_tags.select_node_placeholder')}</option>
|
||||
${allNodes.map(n => {
|
||||
const name = n.name || 'Unnamed';
|
||||
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">Load Tags</button>
|
||||
<button id="load-tags-btn" class="btn btn-primary">${t('admin_node_tags.load_tags')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -371,7 +371,7 @@ ${contentHtml}`, container);
|
||||
await apiPost('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags', {
|
||||
key, value, value_type,
|
||||
});
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent('Tag added successfully'));
|
||||
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));
|
||||
}
|
||||
@@ -403,7 +403,7 @@ ${contentHtml}`, container);
|
||||
value, value_type,
|
||||
});
|
||||
container.querySelector('#editModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent('Tag updated successfully'));
|
||||
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));
|
||||
@@ -435,7 +435,7 @@ ${contentHtml}`, container);
|
||||
new_public_key: newPublicKey,
|
||||
});
|
||||
container.querySelector('#moveModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent('Tag moved successfully'));
|
||||
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));
|
||||
@@ -447,7 +447,11 @@ ${contentHtml}`, container);
|
||||
btn.addEventListener('click', () => {
|
||||
const row = btn.closest('tr');
|
||||
activeTagKey = row.dataset.tagKey;
|
||||
container.querySelector('#deleteKeyDisplay').textContent = activeTagKey;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -460,7 +464,7 @@ ${contentHtml}`, container);
|
||||
try {
|
||||
await apiDelete('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags/' + encodeURIComponent(activeTagKey));
|
||||
container.querySelector('#deleteModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent('Tag deleted successfully'));
|
||||
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));
|
||||
@@ -487,7 +491,7 @@ ${contentHtml}`, container);
|
||||
try {
|
||||
const result = await apiPost('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags/copy-to/' + encodeURIComponent(destKey));
|
||||
container.querySelector('#copyAllModal').close();
|
||||
const msg = `Copied ${result.copied} tag(s), skipped ${result.skipped}`;
|
||||
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();
|
||||
@@ -511,7 +515,7 @@ ${contentHtml}`, container);
|
||||
try {
|
||||
await apiDelete('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags');
|
||||
container.querySelector('#deleteAllModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent('All tags deleted successfully'));
|
||||
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));
|
||||
@@ -521,6 +525,6 @@ ${contentHtml}`, container);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || 'Failed to load node tags'), container);
|
||||
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, typeEmoji, formatDateTime, formatDateTimeShort,
|
||||
html, litRender, nothing, t,
|
||||
getConfig, formatDateTime, formatDateTimeShort,
|
||||
truncateKey, errorAlert,
|
||||
pagination, createFilterHandler, autoSubmit, submitOnEnter
|
||||
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 || {};
|
||||
@@ -16,6 +17,8 @@ export async function render(container, params, router) {
|
||||
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);
|
||||
@@ -23,10 +26,11 @@ export async function render(container, params, router) {
|
||||
function renderPage(content, { total = null } = {}) {
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Advertisements</h1>
|
||||
<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">${total} total</span>` : nothing}
|
||||
${total !== null ? html`<span class="badge badge-lg">${t('common.total', { count: total })}</span>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
${content}`, container);
|
||||
@@ -35,145 +39,154 @@ ${content}`, container);
|
||||
// Render page header immediately (old content stays visible until data loads)
|
||||
renderPage(nothing);
|
||||
|
||||
try {
|
||||
const [data, nodesData, membersData] = await Promise.all([
|
||||
apiGet('/api/v1/advertisements', { limit, offset, search, public_key, member_id }),
|
||||
apiGet('/api/v1/nodes', { limit: 500 }),
|
||||
apiGet('/api/v1/members', { limit: 100 }),
|
||||
]);
|
||||
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 advertisements = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const allNodes = nodesData.items || [];
|
||||
const members = membersData.items || [];
|
||||
const results = await Promise.all(requests);
|
||||
const data = results[0];
|
||||
const nodesData = results[1];
|
||||
const membersData = showMembers ? results[2] : null;
|
||||
|
||||
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 advertisements = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const allNodes = nodesData.items || [];
|
||||
const members = membersData?.items || [];
|
||||
|
||||
const nodesFilter = sortedNodes.length > 0
|
||||
? html`
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Node</span>
|
||||
</label>
|
||||
<select name="public_key" class="select select-bordered select-sm" @change=${autoSubmit}>
|
||||
<option value="">All Nodes</option>
|
||||
${sortedNodes.map(n => html`<option value=${n.public_key} ?selected=${public_key === n.public_key}>${n._displayName}</option>`)}
|
||||
</select>
|
||||
</div>`
|
||||
: nothing;
|
||||
const 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 membersFilter = members.length > 0
|
||||
? html`
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Member</span>
|
||||
</label>
|
||||
<select name="member_id" class="select select-bordered select-sm" @change=${autoSubmit}>
|
||||
<option value="">All Members</option>
|
||||
${members.map(m => html`<option value=${m.member_id} ?selected=${member_id === m.member_id}>${m.name}${m.callsign ? ` (${m.callsign})` : ''}</option>`)}
|
||||
</select>
|
||||
</div>`
|
||||
: nothing;
|
||||
const 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 mobileCards = advertisements.length === 0
|
||||
? html`<div class="text-center py-8 opacity-70">No advertisements found.</div>`
|
||||
: advertisements.map(ad => {
|
||||
const emoji = typeEmoji(ad.adv_type);
|
||||
const adName = ad.node_tag_name || ad.node_name || ad.name;
|
||||
const nameBlock = adName
|
||||
? html`<div class="font-medium text-sm truncate">${adName}</div>
|
||||
<div class="text-xs font-mono opacity-60 truncate">${ad.public_key.slice(0, 16)}...</div>`
|
||||
: html`<div class="font-mono text-sm truncate">${ad.public_key.slice(0, 16)}...</div>`;
|
||||
let receiversBlock = nothing;
|
||||
if (ad.receivers && ad.receivers.length >= 1) {
|
||||
receiversBlock = html`<div class="flex gap-0.5 justify-end mt-1">
|
||||
${ad.receivers.map(recv => {
|
||||
const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12);
|
||||
return html`<span class="text-sm" title=${recvName}>\u{1F4E1}</span>`;
|
||||
})}
|
||||
</div>`;
|
||||
} else if (ad.received_by) {
|
||||
const recvTitle = ad.receiver_tag_name || ad.receiver_name || truncateKey(ad.received_by, 12);
|
||||
receiversBlock = html`<span class="text-sm" title=${recvTitle}>\u{1F4E1}</span>`;
|
||||
}
|
||||
return html`<a href="/nodes/${ad.public_key}" class="card bg-base-100 shadow-sm block">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-lg flex-shrink-0" title=${ad.adv_type || 'Unknown'}>${emoji}</span>
|
||||
<div class="min-w-0">
|
||||
${nameBlock}
|
||||
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 class="text-right flex-shrink-0">
|
||||
<div class="text-xs opacity-60">${formatDateTimeShort(ad.received_at)}</div>
|
||||
${receiversBlock}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>`;
|
||||
</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,
|
||||
});
|
||||
|
||||
const tableRows = advertisements.length === 0
|
||||
? html`<tr><td colspan="3" class="text-center py-8 opacity-70">No advertisements found.</td></tr>`
|
||||
: advertisements.map(ad => {
|
||||
const emoji = typeEmoji(ad.adv_type);
|
||||
const adName = ad.node_tag_name || ad.node_name || ad.name;
|
||||
const nameBlock = adName
|
||||
? html`<div class="font-medium">${adName}</div>
|
||||
<div class="text-xs font-mono opacity-70">${ad.public_key.slice(0, 16)}...</div>`
|
||||
: html`<span class="font-mono text-sm">${ad.public_key.slice(0, 16)}...</span>`;
|
||||
let receiversBlock;
|
||||
if (ad.receivers && ad.receivers.length >= 1) {
|
||||
receiversBlock = html`<div class="flex gap-1">
|
||||
${ad.receivers.map(recv => {
|
||||
const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12);
|
||||
return html`<a href="/nodes/${recv.public_key}" class="text-lg hover:opacity-70" title=${recvName}>\u{1F4E1}</a>`;
|
||||
})}
|
||||
</div>`;
|
||||
} else if (ad.received_by) {
|
||||
const recvTitle = ad.receiver_tag_name || ad.receiver_name || truncateKey(ad.received_by, 12);
|
||||
receiversBlock = html`<a href="/nodes/${ad.received_by}" class="text-lg hover:opacity-70" title=${recvTitle}>\u{1F4E1}</a>`;
|
||||
} else {
|
||||
receiversBlock = html`<span class="opacity-50">-</span>`;
|
||||
}
|
||||
return html`<tr class="hover">
|
||||
<td>
|
||||
<a href="/nodes/${ad.public_key}" class="link link-hover flex items-center gap-2">
|
||||
<span class="text-lg" title=${ad.adv_type || 'Unknown'}>${emoji}</span>
|
||||
<div>
|
||||
${nameBlock}
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">${formatDateTime(ad.received_at)}</td>
|
||||
<td>${receiversBlock}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
const paginationBlock = pagination(page, totalPages, '/advertisements', {
|
||||
search, public_key, member_id, limit,
|
||||
});
|
||||
|
||||
renderPage(html`
|
||||
renderPage(html`
|
||||
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
|
||||
<div class="card-body py-4">
|
||||
<form method="GET" action="/advertisements" class="flex gap-4 flex-wrap items-end" @submit=${createFilterHandler('/advertisements', navigate)}>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Search</span>
|
||||
<span class="label-text">${t('common.search')}</span>
|
||||
</label>
|
||||
<input type="text" name="search" .value=${search} placeholder="Search by name, ID, or public key..." class="input input-bordered input-sm w-80" @keydown=${submitOnEnter} />
|
||||
<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">Filter</button>
|
||||
<a href="/advertisements" class="btn btn-ghost btn-sm">Clear</a>
|
||||
<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>
|
||||
@@ -187,9 +200,10 @@ ${content}`, container);
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Time</th>
|
||||
<th>Receivers</th>
|
||||
<th>${t('entities.node')}</th>
|
||||
<th>${t('common.public_key')}</th>
|
||||
<th>${t('common.time')}</th>
|
||||
<th>${t('common.receivers')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -200,7 +214,17 @@ ${content}`, container);
|
||||
|
||||
${paginationBlock}`, { total });
|
||||
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
await fetchAndRenderData();
|
||||
|
||||
const toggleEl = container.querySelector('#auto-refresh-toggle');
|
||||
const { cleanup } = createAutoRefresh({
|
||||
fetchAndRender: fetchAndRenderData,
|
||||
toggleContainer: toggleEl,
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import { html, litRender, unsafeHTML, getConfig, errorAlert } from '../components.js';
|
||||
import { html, litRender, unsafeHTML, getConfig, errorAlert, t } from '../components.js';
|
||||
|
||||
export async function render(container, params, router) {
|
||||
try {
|
||||
@@ -20,9 +20,9 @@ export async function render(container, params, router) {
|
||||
|
||||
} catch (e) {
|
||||
if (e.message && e.message.includes('404')) {
|
||||
litRender(errorAlert('Page not found'), container);
|
||||
litRender(errorAlert(t('common.page_not_found')), container);
|
||||
} else {
|
||||
litRender(errorAlert(e.message || 'Failed to load page'), container);
|
||||
litRender(errorAlert(e.message || t('custom_page.failed_to_load')), container);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, typeEmoji, errorAlert, pageColors,
|
||||
getConfig, typeEmoji, errorAlert, pageColors, t,
|
||||
} from '../components.js';
|
||||
import {
|
||||
iconNodes, iconAdvertisements, iconMessages, iconChannel,
|
||||
@@ -43,7 +43,7 @@ function formatTimeShort(isoString) {
|
||||
|
||||
function renderRecentAds(ads) {
|
||||
if (!ads || ads.length === 0) {
|
||||
return html`<p class="text-sm opacity-70">No advertisements recorded yet.</p>`;
|
||||
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;
|
||||
@@ -67,9 +67,9 @@ function renderRecentAds(ads) {
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Type</th>
|
||||
<th class="text-right">Received</th>
|
||||
<th>${t('entities.node')}</th>
|
||||
<th>${t('common.type')}</th>
|
||||
<th class="text-right">${t('common.received')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
@@ -90,7 +90,7 @@ function renderChannelMessages(channelMessages) {
|
||||
return html`<div>
|
||||
<h3 class="font-semibold text-sm mb-2 flex items-center gap-2">
|
||||
<span class="badge badge-info badge-sm">CH${String(channel)}</span>
|
||||
Channel ${String(channel)}
|
||||
${t('dashboard.channel', { number: String(channel) })}
|
||||
</h3>
|
||||
<div class="space-y-1 pl-2 border-l-2 border-base-300">
|
||||
${msgLines}
|
||||
@@ -102,7 +102,7 @@ function renderChannelMessages(channelMessages) {
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
${iconChannel('h-6 w-6')}
|
||||
Recent Channel Messages
|
||||
${t('dashboard.recent_channel_messages')}
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
${channels}
|
||||
@@ -142,7 +142,7 @@ export async function render(container, params, router) {
|
||||
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Dashboard</h1>
|
||||
<h1 class="text-3xl font-bold">${t('entities.dashboard')}</h1>
|
||||
</div>
|
||||
|
||||
${topCount > 0 ? html`
|
||||
@@ -152,9 +152,9 @@ ${topCount > 0 ? html`
|
||||
<div class="stat-figure" style="color: ${pageColors.nodes}">
|
||||
${iconNodes('h-8 w-8')}
|
||||
</div>
|
||||
<div class="stat-title">Total Nodes</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">All discovered nodes</div>
|
||||
<div class="stat-desc">${t('dashboard.all_discovered_nodes')}</div>
|
||||
</div>` : nothing}
|
||||
|
||||
${showAdverts ? html`
|
||||
@@ -162,9 +162,9 @@ ${topCount > 0 ? html`
|
||||
<div class="stat-figure" style="color: ${pageColors.adverts}">
|
||||
${iconAdvertisements('h-8 w-8')}
|
||||
</div>
|
||||
<div class="stat-title">Advertisements</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">Last 7 days</div>
|
||||
<div class="stat-desc">${t('time.last_7_days')}</div>
|
||||
</div>` : nothing}
|
||||
|
||||
${showMessages ? html`
|
||||
@@ -172,9 +172,9 @@ ${topCount > 0 ? html`
|
||||
<div class="stat-figure" style="color: ${pageColors.messages}">
|
||||
${iconMessages('h-8 w-8')}
|
||||
</div>
|
||||
<div class="stat-title">Messages</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">Last 7 days</div>
|
||||
<div class="stat-desc">${t('time.last_7_days')}</div>
|
||||
</div>` : nothing}
|
||||
</div>
|
||||
|
||||
@@ -184,9 +184,9 @@ ${topCount > 0 ? html`
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">
|
||||
${iconNodes('h-5 w-5')}
|
||||
Total Nodes
|
||||
${t('common.total_entity', { entity: t('entities.nodes') })}
|
||||
</h2>
|
||||
<p class="text-xs opacity-70">Over time (last 7 days)</p>
|
||||
<p class="text-xs opacity-70">${t('time.over_time_last_7_days')}</p>
|
||||
<div class="h-32">
|
||||
<canvas id="nodeChart"></canvas>
|
||||
</div>
|
||||
@@ -198,9 +198,9 @@ ${topCount > 0 ? html`
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">
|
||||
${iconAdvertisements('h-5 w-5')}
|
||||
Advertisements
|
||||
${t('entities.advertisements')}
|
||||
</h2>
|
||||
<p class="text-xs opacity-70">Per day (last 7 days)</p>
|
||||
<p class="text-xs opacity-70">${t('time.per_day_last_7_days')}</p>
|
||||
<div class="h-32">
|
||||
<canvas id="advertChart"></canvas>
|
||||
</div>
|
||||
@@ -212,9 +212,9 @@ ${topCount > 0 ? html`
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">
|
||||
${iconMessages('h-5 w-5')}
|
||||
Messages
|
||||
${t('entities.messages')}
|
||||
</h2>
|
||||
<p class="text-xs opacity-70">Per day (last 7 days)</p>
|
||||
<p class="text-xs opacity-70">${t('time.per_day_last_7_days')}</p>
|
||||
<div class="h-32">
|
||||
<canvas id="messageChart"></canvas>
|
||||
</div>
|
||||
@@ -229,7 +229,7 @@ ${bottomCount > 0 ? html`
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
${iconAdvertisements('h-6 w-6')}
|
||||
Recent Advertisements
|
||||
${t('common.recent_entity', { entity: t('entities.advertisements') })}
|
||||
</h2>
|
||||
${renderRecentAds(stats.recent_advertisements)}
|
||||
</div>
|
||||
@@ -256,6 +256,6 @@ ${bottomCount > 0 ? html`
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || 'Failed to load dashboard'), container);
|
||||
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, errorAlert, pageColors,
|
||||
getConfig, errorAlert, pageColors, t,
|
||||
} from '../components.js';
|
||||
import {
|
||||
iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconMap,
|
||||
@@ -11,12 +11,12 @@ import {
|
||||
function renderRadioConfig(rc) {
|
||||
if (!rc) return nothing;
|
||||
const fields = [
|
||||
['Profile', rc.profile],
|
||||
['Frequency', rc.frequency],
|
||||
['Bandwidth', rc.bandwidth],
|
||||
['Spreading Factor', rc.spreading_factor],
|
||||
['Coding Rate', rc.coding_rate],
|
||||
['TX Power', rc.tx_power],
|
||||
[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)
|
||||
@@ -49,8 +49,7 @@ export async function render(container, params, router) {
|
||||
const welcomeText = config.network_welcome_text
|
||||
? html`<p class="py-4 max-w-[70%]">${config.network_welcome_text}</p>`
|
||||
: html`<p class="py-4 max-w-[70%]">
|
||||
Welcome to the ${networkName} mesh network dashboard.
|
||||
Monitor network activity, view connected nodes, and explore message history.
|
||||
${t('home.welcome_default', { network_name: networkName })}
|
||||
</p>`;
|
||||
|
||||
const customPageButtons = features.pages !== false
|
||||
@@ -82,27 +81,27 @@ export async function render(container, params, router) {
|
||||
${features.dashboard !== false ? html`
|
||||
<a href="/dashboard" class="btn btn-outline btn-info">
|
||||
${iconDashboard('h-5 w-5 mr-2')}
|
||||
Dashboard
|
||||
${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')}
|
||||
Nodes
|
||||
${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')}
|
||||
Adverts
|
||||
${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')}
|
||||
Messages
|
||||
${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')}
|
||||
Map
|
||||
${t('entities.map')}
|
||||
</a>` : nothing}
|
||||
${customPageButtons}
|
||||
</div>
|
||||
@@ -115,9 +114,9 @@ export async function render(container, params, router) {
|
||||
<div class="stat-figure" style="color: ${pageColors.nodes}">
|
||||
${iconNodes('h-8 w-8')}
|
||||
</div>
|
||||
<div class="stat-title">Total Nodes</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">All discovered nodes</div>
|
||||
<div class="stat-desc">${t('home.all_discovered_nodes')}</div>
|
||||
</div>` : nothing}
|
||||
|
||||
${features.advertisements !== false ? html`
|
||||
@@ -125,9 +124,9 @@ export async function render(container, params, router) {
|
||||
<div class="stat-figure" style="color: ${pageColors.adverts}">
|
||||
${iconAdvertisements('h-8 w-8')}
|
||||
</div>
|
||||
<div class="stat-title">Advertisements</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">Last 7 days</div>
|
||||
<div class="stat-desc">${t('time.last_7_days')}</div>
|
||||
</div>` : nothing}
|
||||
|
||||
${features.messages !== false ? html`
|
||||
@@ -135,9 +134,9 @@ export async function render(container, params, router) {
|
||||
<div class="stat-figure" style="color: ${pageColors.messages}">
|
||||
${iconMessages('h-8 w-8')}
|
||||
</div>
|
||||
<div class="stat-title">Messages</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">Last 7 days</div>
|
||||
<div class="stat-desc">${t('time.last_7_days')}</div>
|
||||
</div>` : nothing}
|
||||
</div>` : nothing}
|
||||
</div>
|
||||
@@ -147,7 +146,7 @@ export async function render(container, params, router) {
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
${iconInfo('h-6 w-6')}
|
||||
Network Info
|
||||
${t('home.network_info')}
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
${renderRadioConfig(rc)}
|
||||
@@ -157,7 +156,7 @@ export async function render(container, params, router) {
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body flex flex-col items-center justify-center">
|
||||
<p class="text-sm opacity-70 mb-4 text-center">Our local off-grid mesh network is made possible by</p>
|
||||
<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>
|
||||
@@ -165,11 +164,11 @@ export async function render(container, params, router) {
|
||||
<div class="flex gap-2 mt-4">
|
||||
<a href="https://meshcore.co.uk/" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
|
||||
${iconGlobe('h-4 w-4 mr-1')}
|
||||
Website
|
||||
${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')}
|
||||
GitHub
|
||||
${t('links.github')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,9 +179,9 @@ export async function render(container, params, router) {
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
${iconChart('h-6 w-6')}
|
||||
Network Activity
|
||||
${t('home.network_activity')}
|
||||
</h2>
|
||||
<p class="text-sm opacity-70 mb-2">Activity per day (last 7 days)</p>
|
||||
<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>
|
||||
@@ -204,6 +203,6 @@ export async function render(container, params, router) {
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || 'Failed to load home page'), container);
|
||||
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
html, litRender, nothing, t,
|
||||
typeEmoji, formatRelativeTime, escapeHtml, errorAlert,
|
||||
timezoneIndicator,
|
||||
} from '../components.js';
|
||||
@@ -37,10 +37,10 @@ function normalizeType(type) {
|
||||
|
||||
function getTypeDisplay(node) {
|
||||
const type = normalizeType(node.adv_type);
|
||||
if (type === 'chat') return 'Chat';
|
||||
if (type === 'repeater') return 'Repeater';
|
||||
if (type === 'room') return 'Room';
|
||||
return type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Unknown';
|
||||
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
|
||||
@@ -72,12 +72,12 @@ function createPopupContent(node) {
|
||||
const ownerDisplay = node.owner.callsign
|
||||
? escapeHtml(node.owner.name) + ' (' + escapeHtml(node.owner.callsign) + ')'
|
||||
: escapeHtml(node.owner.name);
|
||||
ownerHtml = '<p><span class="opacity-70">Owner:</span> ' + ownerDisplay + '</p>';
|
||||
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">Role:</span> <span class="badge badge-xs badge-ghost">' + escapeHtml(node.role) + '</span></p>';
|
||||
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);
|
||||
@@ -87,25 +87,32 @@ function createPopupContent(node) {
|
||||
if (typeof node.is_infra !== 'undefined') {
|
||||
const dotColor = node.is_infra ? '#ef4444' : '#3b82f6';
|
||||
const borderColor = node.is_infra ? '#b91c1c' : '#1e40af';
|
||||
const title = node.is_infra ? 'Infrastructure' : 'Public';
|
||||
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">Last seen:</span> ' + node.last_seen.substring(0, 19).replace('T', ' ') + '</p>'
|
||||
? '<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 || 'Unknown') + infraIndicatorHtml + '</h3>' +
|
||||
'<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">Type:</span> ' + escapeHtml(typeDisplay) + '</p>' +
|
||||
'<p><span class="opacity-70">' + typeLabel + '</span> ' + escapeHtml(typeDisplay) + '</p>' +
|
||||
roleHtml +
|
||||
ownerHtml +
|
||||
'<p><span class="opacity-70">Key:</span> <code class="text-xs">' + escapeHtml(node.public_key.substring(0, 16)) + '...</code></p>' +
|
||||
'<p><span class="opacity-70">Location:</span> ' + node.lat.toFixed(4) + ', ' + node.lon.toFixed(4) + '</p>' +
|
||||
'<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">View Details</a>' +
|
||||
'<a href="/nodes/' + encodeURIComponent(node.public_key) + '" class="btn btn-outline btn-xs mt-3">' + viewDetailsLabel + '</a>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
@@ -166,10 +173,10 @@ export async function render(container, params, router) {
|
||||
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Map</h1>
|
||||
<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">Loading...</span>
|
||||
<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>
|
||||
@@ -179,30 +186,30 @@ export async function render(container, params, router) {
|
||||
<div class="flex gap-4 flex-wrap items-end">
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Show</span>
|
||||
<span class="label-text">${t('common.show')}</span>
|
||||
</label>
|
||||
<select id="filter-category" class="select select-bordered select-sm" @change=${applyFilters}>
|
||||
<option value="">All Nodes</option>
|
||||
<option value="infra">Infrastructure Only</option>
|
||||
<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">Node Type</span>
|
||||
<span class="label-text">${t('common.node_type')}</span>
|
||||
</label>
|
||||
<select id="filter-type" class="select select-bordered select-sm" @change=${applyFilters}>
|
||||
<option value="">All Types</option>
|
||||
<option value="chat">Chat</option>
|
||||
<option value="repeater">Repeater</option>
|
||||
<option value="room">Room</option>
|
||||
<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">Member</span>
|
||||
<span class="label-text">${t('entities.member')}</span>
|
||||
</label>
|
||||
<select id="filter-member" class="select select-bordered select-sm" @change=${applyFilters}>
|
||||
<option value="">All Members</option>
|
||||
<option value="">${t('common.all_entity', { entity: t('entities.members') })}</option>
|
||||
${sortedMembers
|
||||
.filter(m => m.member_id)
|
||||
.map(m => {
|
||||
@@ -215,11 +222,11 @@ export async function render(container, params, router) {
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer gap-2 py-1">
|
||||
<span class="label-text">Show Labels</span>
|
||||
<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}>Clear Filters</button>
|
||||
<button id="clear-filters" class="btn btn-ghost btn-sm" @click=${clearFiltersHandler}>${t('common.clear_filters')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,19 +238,19 @@ export async function render(container, params, router) {
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-4 items-center text-sm">
|
||||
<span class="opacity-70">Legend:</span>
|
||||
<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>Infrastructure</span>
|
||||
<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>Public</span>
|
||||
<span>${t('map.public')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm opacity-70">
|
||||
<p>Nodes are placed on the map based on GPS coordinates from node reports or manual tags.</p>
|
||||
<p>${t('map.gps_description')}</p>
|
||||
</div>`, container);
|
||||
|
||||
const mapEl = container.querySelector('#spa-map');
|
||||
@@ -285,11 +292,11 @@ export async function render(container, params, router) {
|
||||
const filteredEl = container.querySelector('#filtered-count');
|
||||
|
||||
if (filteredNodes.length === allNodes.length) {
|
||||
countEl.textContent = allNodes.length + ' nodes on map';
|
||||
countEl.textContent = t('map.nodes_on_map', { count: allNodes.length });
|
||||
filteredEl.classList.add('hidden');
|
||||
} else {
|
||||
countEl.textContent = allNodes.length + ' total';
|
||||
filteredEl.textContent = filteredNodes.length + ' shown';
|
||||
countEl.textContent = t('common.total', { count: allNodes.length });
|
||||
filteredEl.textContent = t('common.shown', { count: filteredNodes.length });
|
||||
filteredEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
@@ -302,12 +309,12 @@ export async function render(container, params, router) {
|
||||
}
|
||||
|
||||
if (debug.total_nodes === 0) {
|
||||
container.querySelector('#node-count').textContent = 'No nodes in database';
|
||||
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 = debug.total_nodes + ' nodes (none have coordinates)';
|
||||
container.querySelector('#node-count').textContent = t('map.nodes_none_have_coordinates', { count: debug.total_nodes });
|
||||
return () => map.remove();
|
||||
}
|
||||
|
||||
@@ -328,6 +335,6 @@ export async function render(container, params, router) {
|
||||
return () => map.remove();
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || 'Failed to load map'), container);
|
||||
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
html, litRender, nothing, t, unsafeHTML,
|
||||
formatRelativeTime, formatDateTime, errorAlert,
|
||||
} from '../components.js';
|
||||
import { iconInfo } from '../icons.js';
|
||||
@@ -61,7 +61,7 @@ function renderMemberCard(member, nodes) {
|
||||
: nothing;
|
||||
|
||||
const contactBlock = member.contact
|
||||
? html`<p class="text-sm mt-2"><span class="opacity-70">Contact:</span> ${member.contact}</p>`
|
||||
? 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">
|
||||
@@ -85,22 +85,22 @@ export async function render(container, params, router) {
|
||||
if (members.length === 0) {
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Members</h1>
|
||||
<span class="badge badge-lg">0 members</span>
|
||||
<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">No members configured</h3>
|
||||
<p class="text-sm">To display network members, create a members.yaml file in your seed directory.</p>
|
||||
<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">Members File Format</h2>
|
||||
<p class="mb-4">Create a YAML file at <code>$SEED_HOME/members.yaml</code> with the following structure:</p>
|
||||
<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
|
||||
@@ -113,8 +113,7 @@ export async function render(container, params, router) {
|
||||
role: Member
|
||||
description: Regular user in the downtown area.</code></pre>
|
||||
<p class="mt-4 text-sm opacity-70">
|
||||
Run <code>meshcore-hub collector seed</code> to import members.<br/>
|
||||
To associate nodes with members, add a <code>member_id</code> tag to nodes in <code>node_tags.yaml</code>.
|
||||
${unsafeHTML(t('members.members_import_instructions'))}
|
||||
</p>
|
||||
</div>
|
||||
</div>`, container);
|
||||
@@ -139,8 +138,8 @@ export async function render(container, params, router) {
|
||||
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Members</h1>
|
||||
<span class="badge badge-lg">${members.length} members</span>
|
||||
<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">
|
||||
@@ -148,6 +147,6 @@ export async function render(container, params, router) {
|
||||
</div>`, container);
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || 'Failed to load members'), container);
|
||||
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
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 || {};
|
||||
@@ -22,10 +23,11 @@ export async function render(container, params, router) {
|
||||
function renderPage(content, { total = null } = {}) {
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Messages</h1>
|
||||
<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">${total} total</span>` : nothing}
|
||||
${total !== null ? html`<span class="badge badge-lg">${t('common.total', { count: total })}</span>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
${content}`, container);
|
||||
@@ -34,127 +36,128 @@ ${content}`, container);
|
||||
// Render page header immediately (old content stays visible until data loads)
|
||||
renderPage(nothing);
|
||||
|
||||
try {
|
||||
const data = await apiGet('/api/v1/messages', { limit, offset, message_type });
|
||||
const messages = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
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">No messages found.</div>`
|
||||
: messages.map(msg => {
|
||||
const isChannel = msg.message_type === 'channel';
|
||||
const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}';
|
||||
const typeTitle = isChannel ? 'Channel' : 'Contact';
|
||||
let senderBlock;
|
||||
if (isChannel) {
|
||||
senderBlock = html`<span class="opacity-60">Public</span>`;
|
||||
} else {
|
||||
const senderName = msg.sender_tag_name || msg.sender_name;
|
||||
if (senderName) {
|
||||
senderBlock = senderName;
|
||||
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 {
|
||||
senderBlock = html`<span class="font-mono text-xs">${(msg.pubkey_prefix || '-').slice(0, 12)}</span>`;
|
||||
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)}
|
||||
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>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
${receiversBlock}
|
||||
</div>
|
||||
<p class="text-sm mt-2 break-words whitespace-pre-wrap">${msg.text || '-'}</p>
|
||||
</div>
|
||||
<p class="text-sm mt-2 break-words whitespace-pre-wrap">${msg.text || '-'}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
</div>`;
|
||||
});
|
||||
|
||||
const tableRows = messages.length === 0
|
||||
? html`<tr><td colspan="5" class="text-center py-8 opacity-70">No messages found.</td></tr>`
|
||||
: messages.map(msg => {
|
||||
const isChannel = msg.message_type === 'channel';
|
||||
const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}';
|
||||
const typeTitle = isChannel ? 'Channel' : 'Contact';
|
||||
let senderBlock;
|
||||
if (isChannel) {
|
||||
senderBlock = html`<span class="opacity-60">Public</span>`;
|
||||
} else {
|
||||
const senderName = msg.sender_tag_name || msg.sender_name;
|
||||
if (senderName) {
|
||||
senderBlock = html`<span class="font-medium">${senderName}</span>`;
|
||||
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 {
|
||||
senderBlock = html`<span class="font-mono text-xs">${(msg.pubkey_prefix || '-').slice(0, 12)}</span>`;
|
||||
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>`;
|
||||
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,
|
||||
});
|
||||
|
||||
const paginationBlock = pagination(page, totalPages, '/messages', {
|
||||
message_type, limit,
|
||||
});
|
||||
|
||||
renderPage(html`
|
||||
renderPage(html`
|
||||
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
|
||||
<div class="card-body py-4">
|
||||
<form method="GET" action="/messages" class="flex gap-4 flex-wrap items-end" @submit=${createFilterHandler('/messages', navigate)}>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Type</span>
|
||||
<span class="label-text">${t('common.type')}</span>
|
||||
</label>
|
||||
<select name="message_type" class="select select-bordered select-sm" @change=${autoSubmit}>
|
||||
<option value="">All Types</option>
|
||||
<option value="contact" ?selected=${message_type === 'contact'}>Direct</option>
|
||||
<option value="channel" ?selected=${message_type === 'channel'}>Channel</option>
|
||||
<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">Filter</button>
|
||||
<a href="/messages" class="btn btn-ghost btn-sm">Clear</a>
|
||||
<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>
|
||||
@@ -168,11 +171,11 @@ ${content}`, container);
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Time</th>
|
||||
<th>From</th>
|
||||
<th>Message</th>
|
||||
<th>Receivers</th>
|
||||
<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>
|
||||
@@ -183,7 +186,17 @@ ${content}`, container);
|
||||
|
||||
${paginationBlock}`, { total });
|
||||
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
await fetchAndRenderData();
|
||||
|
||||
const toggleEl = container.querySelector('#auto-refresh-toggle');
|
||||
const { cleanup } = createAutoRefresh({
|
||||
fetchAndRender: fetchAndRenderData,
|
||||
toggleContainer: toggleEl,
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, typeEmoji, formatDateTime,
|
||||
truncateKey, errorAlert,
|
||||
truncateKey, errorAlert, copyToClipboard, t,
|
||||
} from '../components.js';
|
||||
import { iconError } from '../icons.js';
|
||||
|
||||
@@ -30,7 +30,8 @@ export async function render(container, params, router) {
|
||||
|
||||
const config = getConfig();
|
||||
const tagName = node.tags?.find(t => t.key === 'name')?.value;
|
||||
const displayName = tagName || node.name || 'Unnamed Node';
|
||||
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;
|
||||
@@ -57,12 +58,12 @@ export async function render(container, params, router) {
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body flex-row items-center gap-4">
|
||||
<div id="qr-code" class="bg-white p-1 rounded"></div>
|
||||
<p class="text-sm opacity-70">Scan to add as contact</p>
|
||||
<p class="text-sm opacity-70">${t('nodes.scan_to_add')}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const coordsHtml = hasCoords
|
||||
? html`<div><span class="opacity-70">Location:</span> ${lat}, ${lon}</div>`
|
||||
? html`<div><span class="opacity-70">${t('common.location')}:</span> ${lat}, ${lon}</div>`
|
||||
: nothing;
|
||||
|
||||
const adsTableHtml = advertisements.length > 0
|
||||
@@ -70,9 +71,9 @@ export async function render(container, params, router) {
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Type</th>
|
||||
<th>Received By</th>
|
||||
<th>${t('common.time')}</th>
|
||||
<th>${t('common.type')}</th>
|
||||
<th>${t('common.received_by')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -101,7 +102,7 @@ export async function render(container, params, router) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`
|
||||
: html`<p class="opacity-70">No advertisements recorded.</p>`;
|
||||
: 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
|
||||
@@ -109,9 +110,9 @@ export async function render(container, params, router) {
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
<th>${t('common.key')}</th>
|
||||
<th>${t('common.value')}</th>
|
||||
<th>${t('common.type')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -123,39 +124,44 @@ export async function render(container, params, router) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`
|
||||
: html`<p class="opacity-70">No tags defined.</p>`;
|
||||
: 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 ? 'Edit Tags' : 'Add Tags'}</a>
|
||||
<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="/">Home</a></li>
|
||||
<li><a href="/nodes">Nodes</a></li>
|
||||
<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>
|
||||
|
||||
<h1 class="text-3xl font-bold mb-6">
|
||||
<span title=${node.adv_type || 'Unknown'}>${emoji}</span>
|
||||
${displayName}
|
||||
</h1>
|
||||
<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">Public Key</h3>
|
||||
<code class="text-sm bg-base-200 p-2 rounded block break-all">${node.public_key}</code>
|
||||
<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">First seen:</span> ${formatDateTime(node.first_seen)}</div>
|
||||
<div><span class="opacity-70">Last seen:</span> ${formatDateTime(node.last_seen)}</div>
|
||||
<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>
|
||||
@@ -164,14 +170,14 @@ ${heroHtml}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Recent Advertisements</h2>
|
||||
<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">Tags</h2>
|
||||
<h2 class="card-title">${t('entities.tags')}</h2>
|
||||
${tagsTableHtml}
|
||||
${adminTagsHtml}
|
||||
</div>
|
||||
@@ -237,14 +243,14 @@ function renderNotFound(publicKey) {
|
||||
return html`
|
||||
<div class="breadcrumbs text-sm mb-4">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/nodes">Nodes</a></li>
|
||||
<li>Not Found</li>
|
||||
<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>Node not found: ${publicKey}</span>
|
||||
<span>${t('common.entity_not_found_details', { entity: t('entities.node'), details: publicKey })}</span>
|
||||
</div>
|
||||
<a href="/nodes" class="btn btn-primary mt-4">Back to Nodes</a>`;
|
||||
<a href="/nodes" class="btn btn-primary mt-4">${t('common.view_entity', { entity: t('entities.nodes') })}</a>`;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, typeEmoji, formatDateTime, formatDateTimeShort,
|
||||
getConfig, formatDateTime, formatDateTimeShort,
|
||||
truncateKey, errorAlert,
|
||||
pagination, timezoneIndicator,
|
||||
createFilterHandler, autoSubmit, submitOnEnter
|
||||
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 || {};
|
||||
@@ -17,6 +18,8 @@ export async function render(container, params, router) {
|
||||
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);
|
||||
@@ -24,10 +27,11 @@ export async function render(container, params, router) {
|
||||
function renderPage(content, { total = null } = {}) {
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Nodes</h1>
|
||||
<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">${total} total</span>` : nothing}
|
||||
${total !== null ? html`<span class="badge badge-lg">${t('common.total', { count: total })}</span>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
${content}`, container);
|
||||
@@ -36,127 +40,132 @@ ${content}`, container);
|
||||
// Render page header immediately (old content stays visible until data loads)
|
||||
renderPage(nothing);
|
||||
|
||||
try {
|
||||
const [data, membersData] = await Promise.all([
|
||||
apiGet('/api/v1/nodes', { limit, offset, search, adv_type, member_id }),
|
||||
apiGet('/api/v1/members', { limit: 100 }),
|
||||
]);
|
||||
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 nodes = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const members = membersData.items || [];
|
||||
const results = await Promise.all(requests);
|
||||
const data = results[0];
|
||||
const membersData = showMembers ? results[1] : null;
|
||||
|
||||
const membersFilter = members.length > 0
|
||||
? html`
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Member</span>
|
||||
</label>
|
||||
<select name="member_id" class="select select-bordered select-sm" @change=${autoSubmit}>
|
||||
<option value="">All Members</option>
|
||||
${members.map(m => html`<option value=${m.member_id} ?selected=${member_id === m.member_id}>${m.name}${m.callsign ? ` (${m.callsign})` : ''}</option>`)}
|
||||
</select>
|
||||
</div>`
|
||||
: nothing;
|
||||
const nodes = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const members = membersData?.items || [];
|
||||
|
||||
const mobileCards = nodes.length === 0
|
||||
? html`<div class="text-center py-8 opacity-70">No nodes found.</div>`
|
||||
: nodes.map(node => {
|
||||
const tagName = node.tags?.find(t => t.key === 'name')?.value;
|
||||
const displayName = tagName || node.name;
|
||||
const emoji = typeEmoji(node.adv_type);
|
||||
const nameBlock = displayName
|
||||
? html`<div class="font-medium text-sm truncate">${displayName}</div>
|
||||
<div class="text-xs font-mono opacity-60 truncate">${node.public_key.slice(0, 16)}...</div>`
|
||||
: html`<div class="font-mono text-sm truncate">${node.public_key.slice(0, 16)}...</div>`;
|
||||
const lastSeen = node.last_seen ? formatDateTimeShort(node.last_seen) : '-';
|
||||
const tags = node.tags || [];
|
||||
const tagsBlock = tags.length > 0
|
||||
? html`<div class="flex gap-1 justify-end mt-1">
|
||||
${tags.slice(0, 2).map(t => html`<span class="badge badge-ghost badge-xs">${t.key}</span>`)}
|
||||
${tags.length > 2 ? html`<span class="badge badge-ghost badge-xs">+${tags.length - 2}</span>` : nothing}
|
||||
</div>`
|
||||
: nothing;
|
||||
return html`<a href="/nodes/${node.public_key}" class="card bg-base-100 shadow-sm block">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-lg flex-shrink-0" title=${node.adv_type || 'Unknown'}>${emoji}</span>
|
||||
<div class="min-w-0">
|
||||
${nameBlock}
|
||||
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 class="text-right flex-shrink-0">
|
||||
<div class="text-xs opacity-60">${lastSeen}</div>
|
||||
${tagsBlock}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>`;
|
||||
</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,
|
||||
});
|
||||
|
||||
const tableRows = nodes.length === 0
|
||||
? html`<tr><td colspan="3" class="text-center py-8 opacity-70">No nodes found.</td></tr>`
|
||||
: nodes.map(node => {
|
||||
const tagName = node.tags?.find(t => t.key === 'name')?.value;
|
||||
const displayName = tagName || node.name;
|
||||
const emoji = typeEmoji(node.adv_type);
|
||||
const nameBlock = displayName
|
||||
? html`<div class="font-medium">${displayName}</div>
|
||||
<div class="text-xs font-mono opacity-70">${node.public_key.slice(0, 16)}...</div>`
|
||||
: html`<span class="font-mono text-sm">${node.public_key.slice(0, 16)}...</span>`;
|
||||
const lastSeen = node.last_seen ? formatDateTime(node.last_seen) : '-';
|
||||
const tags = node.tags || [];
|
||||
const tagsBlock = tags.length > 0
|
||||
? html`<div class="flex gap-1 flex-wrap">
|
||||
${tags.slice(0, 3).map(t => html`<span class="badge badge-ghost badge-xs">${t.key}</span>`)}
|
||||
${tags.length > 3 ? html`<span class="badge badge-ghost badge-xs">+${tags.length - 3}</span>` : nothing}
|
||||
</div>`
|
||||
: html`<span class="opacity-50">-</span>`;
|
||||
return html`<tr class="hover">
|
||||
<td>
|
||||
<a href="/nodes/${node.public_key}" class="link link-hover flex items-center gap-2">
|
||||
<span class="text-lg" title=${node.adv_type || 'Unknown'}>${emoji}</span>
|
||||
<div>
|
||||
${nameBlock}
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">${lastSeen}</td>
|
||||
<td>${tagsBlock}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
const paginationBlock = pagination(page, totalPages, '/nodes', {
|
||||
search, adv_type, member_id, limit,
|
||||
});
|
||||
|
||||
renderPage(html`
|
||||
renderPage(html`
|
||||
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
|
||||
<div class="card-body py-4">
|
||||
<form method="GET" action="/nodes" class="flex gap-4 flex-wrap items-end" @submit=${createFilterHandler('/nodes', navigate)}>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Search</span>
|
||||
<span class="label-text">${t('common.search')}</span>
|
||||
</label>
|
||||
<input type="text" name="search" .value=${search} placeholder="Search by name, ID, or public key..." class="input input-bordered input-sm w-80" @keydown=${submitOnEnter} />
|
||||
<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">Type</span>
|
||||
<span class="label-text">${t('common.type')}</span>
|
||||
</label>
|
||||
<select name="adv_type" class="select select-bordered select-sm" @change=${autoSubmit}>
|
||||
<option value="">All Types</option>
|
||||
<option value="chat" ?selected=${adv_type === 'chat'}>Chat</option>
|
||||
<option value="repeater" ?selected=${adv_type === 'repeater'}>Repeater</option>
|
||||
<option value="room" ?selected=${adv_type === 'room'}>Room</option>
|
||||
<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">Filter</button>
|
||||
<a href="/nodes" class="btn btn-ghost btn-sm">Clear</a>
|
||||
<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>
|
||||
@@ -170,9 +179,10 @@ ${content}`, container);
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Tags</th>
|
||||
<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>
|
||||
@@ -183,7 +193,17 @@ ${content}`, container);
|
||||
|
||||
${paginationBlock}`, { total });
|
||||
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
await fetchAndRenderData();
|
||||
|
||||
const toggleEl = container.querySelector('#auto-refresh-toggle');
|
||||
const { cleanup } = createAutoRefresh({
|
||||
fetchAndRender: fetchAndRenderData,
|
||||
toggleContainer: toggleEl,
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, litRender } from '../components.js';
|
||||
import { html, litRender, t } from '../components.js';
|
||||
import { iconHome, iconNodes } from '../icons.js';
|
||||
|
||||
export async function render(container, params, router) {
|
||||
@@ -7,18 +7,18 @@ export async function render(container, params, router) {
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<div class="text-9xl font-bold text-primary opacity-20">404</div>
|
||||
<h1 class="text-4xl font-bold -mt-8">Page Not Found</h1>
|
||||
<h1 class="text-4xl font-bold -mt-8">${t('common.page_not_found')}</h1>
|
||||
<p class="py-6 text-base-content/70">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
${t('not_found.description')}
|
||||
</p>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<a href="/" class="btn btn-primary">
|
||||
${iconHome('h-5 w-5 mr-2')}
|
||||
Go Home
|
||||
${t('common.go_home')}
|
||||
</a>
|
||||
<a href="/nodes" class="btn btn-outline">
|
||||
${iconNodes('h-5 w-5 mr-2')}
|
||||
Browse Nodes
|
||||
${t('common.view_entity', { entity: t('entities.nodes') })}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
216
src/meshcore_hub/web/static/locales/en.json
Normal file
216
src/meshcore_hub/web/static/locales/en.json
Normal file
@@ -0,0 +1,216 @@
|
||||
{
|
||||
"entities": {
|
||||
"home": "Home",
|
||||
"dashboard": "Dashboard",
|
||||
"nodes": "Nodes",
|
||||
"node": "Node",
|
||||
"node_detail": "Node Detail",
|
||||
"advertisements": "Advertisements",
|
||||
"advertisement": "Advertisement",
|
||||
"messages": "Messages",
|
||||
"message": "Message",
|
||||
"map": "Map",
|
||||
"members": "Members",
|
||||
"member": "Member",
|
||||
"admin": "Admin",
|
||||
"tags": "Tags",
|
||||
"tag": "Tag"
|
||||
},
|
||||
"common": {
|
||||
"filter": "Filter",
|
||||
"clear": "Clear",
|
||||
"clear_filters": "Clear Filters",
|
||||
"search": "Search",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"move": "Move",
|
||||
"save": "Save",
|
||||
"save_changes": "Save Changes",
|
||||
"add": "Add",
|
||||
"add_entity": "Add {{entity}}",
|
||||
"add_new_entity": "Add New {{entity}}",
|
||||
"edit_entity": "Edit {{entity}}",
|
||||
"delete_entity": "Delete {{entity}}",
|
||||
"delete_all_entity": "Delete All {{entity}}",
|
||||
"move_entity": "Move {{entity}}",
|
||||
"move_entity_to_another_node": "Move {{entity}} to Another Node",
|
||||
"copy_entity": "Copy {{entity}}",
|
||||
"copy_all_entity_to_another_node": "Copy All {{entity}} to Another Node",
|
||||
"view_entity": "View {{entity}}",
|
||||
"recent_entity": "Recent {{entity}}",
|
||||
"total_entity": "Total {{entity}}",
|
||||
"all_entity": "All {{entity}}",
|
||||
"no_entity_found": "No {{entity}} found",
|
||||
"no_entity_recorded": "No {{entity}} recorded",
|
||||
"no_entity_defined": "No {{entity}} defined",
|
||||
"no_entity_in_database": "No {{entity}} in database",
|
||||
"no_entity_configured": "No {{entity}} configured",
|
||||
"no_entity_yet": "No {{entity}} yet",
|
||||
"entity_not_found_details": "{{entity}} not found: {{details}}",
|
||||
"page_not_found": "Page not found",
|
||||
"delete_entity_confirm": "Are you sure you want to delete {{entity}} <strong>{{name}}</strong>?",
|
||||
"delete_all_entity_confirm": "Are you sure you want to delete all {{count}} {{entity}} from <strong>{{name}}</strong>?",
|
||||
"cannot_be_undone": "This action cannot be undone.",
|
||||
"entity_added_success": "{{entity}} added successfully",
|
||||
"entity_updated_success": "{{entity}} updated successfully",
|
||||
"entity_deleted_success": "{{entity}} deleted successfully",
|
||||
"entity_moved_success": "{{entity}} moved successfully",
|
||||
"all_entity_deleted_success": "All {{entity}} deleted successfully",
|
||||
"copy_all_entity_description": "Copy all {{count}} {{entity}} from <strong>{{name}}</strong> to another node.",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"go_home": "Go Home",
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"failed_to_load_page": "Failed to load page",
|
||||
"total": "{{count}} total",
|
||||
"shown": "{{count}} shown",
|
||||
"count_entity": "{{count}} {{entity}}",
|
||||
"type": "Type",
|
||||
"name": "Name",
|
||||
"key": "Key",
|
||||
"value": "Value",
|
||||
"time": "Time",
|
||||
"actions": "Actions",
|
||||
"updated": "Updated",
|
||||
"sign_in": "Sign In",
|
||||
"sign_out": "Sign Out",
|
||||
"view_details": "View Details",
|
||||
"all_types": "All Types",
|
||||
"node_type": "Node Type",
|
||||
"show": "Show",
|
||||
"search_placeholder": "Search by name, ID, or public key...",
|
||||
"contact": "Contact",
|
||||
"description": "Description",
|
||||
"callsign": "Callsign",
|
||||
"tags": "Tags",
|
||||
"last_seen": "Last Seen",
|
||||
"first_seen_label": "First seen:",
|
||||
"last_seen_label": "Last seen:",
|
||||
"location": "Location",
|
||||
"public_key": "Public Key",
|
||||
"received": "Received",
|
||||
"received_by": "Received By",
|
||||
"receivers": "Receivers",
|
||||
"from": "From",
|
||||
"close": "close",
|
||||
"unnamed": "Unnamed",
|
||||
"unnamed_node": "Unnamed Node"
|
||||
},
|
||||
"links": {
|
||||
"website": "Website",
|
||||
"github": "GitHub",
|
||||
"discord": "Discord",
|
||||
"youtube": "YouTube",
|
||||
"profile": "Profile"
|
||||
},
|
||||
"auto_refresh": {
|
||||
"pause": "Pause auto-refresh",
|
||||
"resume": "Resume auto-refresh"
|
||||
},
|
||||
"time": {
|
||||
"days_ago": "{{count}}d ago",
|
||||
"hours_ago": "{{count}}h ago",
|
||||
"minutes_ago": "{{count}}m ago",
|
||||
"less_than_minute": "<1m ago",
|
||||
"last_7_days": "Last 7 days",
|
||||
"per_day_last_7_days": "Per day (last 7 days)",
|
||||
"over_time_last_7_days": "Over time (last 7 days)",
|
||||
"activity_per_day_last_7_days": "Activity per day (last 7 days)"
|
||||
},
|
||||
"node_types": {
|
||||
"chat": "Chat",
|
||||
"repeater": "Repeater",
|
||||
"room": "Room",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"home": {
|
||||
"welcome_default": "Welcome to the {{network_name}} mesh network dashboard. Monitor network activity, view connected nodes, and explore message history.",
|
||||
"all_discovered_nodes": "All discovered nodes",
|
||||
"network_info": "Network Info",
|
||||
"network_activity": "Network Activity",
|
||||
"meshcore_attribution": "Our local off-grid mesh network is made possible by",
|
||||
"frequency": "Frequency",
|
||||
"bandwidth": "Bandwidth",
|
||||
"spreading_factor": "Spreading Factor",
|
||||
"coding_rate": "Coding Rate",
|
||||
"tx_power": "TX Power"
|
||||
},
|
||||
"dashboard": {
|
||||
"all_discovered_nodes": "All discovered nodes",
|
||||
"recent_channel_messages": "Recent Channel Messages",
|
||||
"channel": "Channel {{number}}"
|
||||
},
|
||||
"nodes": {
|
||||
"scan_to_add": "Scan to add as contact"
|
||||
},
|
||||
"advertisements": {},
|
||||
"messages": {
|
||||
"type_direct": "Direct",
|
||||
"type_channel": "Channel",
|
||||
"type_contact": "Contact",
|
||||
"type_public": "Public"
|
||||
},
|
||||
"map": {
|
||||
"show_labels": "Show Labels",
|
||||
"infrastructure_only": "Infrastructure Only",
|
||||
"legend": "Legend:",
|
||||
"infrastructure": "Infrastructure",
|
||||
"public": "Public",
|
||||
"nodes_on_map": "{{count}} nodes on map",
|
||||
"nodes_none_have_coordinates": "{{count}} nodes (none have coordinates)",
|
||||
"gps_description": "Nodes are placed on the map based on GPS coordinates from node reports or manual tags.",
|
||||
"owner": "Owner:",
|
||||
"role": "Role:",
|
||||
"select_destination_node": "-- Select destination node --"
|
||||
},
|
||||
"members": {
|
||||
"empty_state_description": "To display network members, create a members.yaml file in your seed directory.",
|
||||
"members_file_format": "Members File Format",
|
||||
"members_file_description": "Create a YAML file at <code>$SEED_HOME/members.yaml</code> with the following structure:",
|
||||
"members_import_instructions": "Run <code>meshcore-hub collector seed</code> to import members.<br/>To associate nodes with members, add a <code>member_id</code> tag to nodes in <code>node_tags.yaml</code>."
|
||||
},
|
||||
"not_found": {
|
||||
"description": "The page you're looking for doesn't exist or has been moved."
|
||||
},
|
||||
"custom_page": {
|
||||
"failed_to_load": "Failed to load page"
|
||||
},
|
||||
"admin": {
|
||||
"access_denied": "Access Denied",
|
||||
"admin_not_enabled": "The admin interface is not enabled.",
|
||||
"admin_enable_hint": "Set <code>WEB_ADMIN_ENABLED=true</code> to enable admin features.",
|
||||
"auth_required": "Authentication Required",
|
||||
"auth_required_description": "You must sign in to access the admin interface.",
|
||||
"welcome": "Welcome to the admin panel.",
|
||||
"members_description": "Manage network members and operators.",
|
||||
"tags_description": "Manage custom tags and metadata for network nodes."
|
||||
},
|
||||
"admin_members": {
|
||||
"network_members": "Network Members ({{count}})",
|
||||
"member_id": "Member ID",
|
||||
"member_id_hint": "Unique identifier (letters, numbers, underscore)",
|
||||
"empty_state_hint": "Click \"Add Member\" to create the first member."
|
||||
},
|
||||
"admin_node_tags": {
|
||||
"select_node": "Select Node",
|
||||
"select_node_placeholder": "-- Select a node --",
|
||||
"load_tags": "Load Tags",
|
||||
"move_warning": "This will move the tag from the current node to the destination node.",
|
||||
"copy_all": "Copy All",
|
||||
"copy_all_info": "Tags that already exist on the destination node will be skipped. Original tags remain on this node.",
|
||||
"delete_all": "Delete All",
|
||||
"delete_all_warning": "All tags will be permanently deleted.",
|
||||
"destination_node": "Destination Node",
|
||||
"tag_key": "Tag Key",
|
||||
"for_this_node": "for this node",
|
||||
"empty_state_hint": "Add a new tag below.",
|
||||
"select_a_node": "Select a Node",
|
||||
"select_a_node_description": "Choose a node from the dropdown above to view and manage its tags.",
|
||||
"copied_entities": "Copied {{copied}} tag(s), skipped {{skipped}}"
|
||||
},
|
||||
"footer": {
|
||||
"powered_by": "Powered by"
|
||||
}
|
||||
}
|
||||
426
src/meshcore_hub/web/static/locales/languages.md
Normal file
426
src/meshcore_hub/web/static/locales/languages.md
Normal file
@@ -0,0 +1,426 @@
|
||||
# Translation Reference Guide
|
||||
|
||||
This document provides a comprehensive reference for translating the MeshCore Hub web dashboard.
|
||||
|
||||
## File Structure
|
||||
|
||||
Translation files are JSON files named by language code (e.g., `en.json`, `es.json`, `fr.json`) and located in `/src/meshcore_hub/web/static/locales/`.
|
||||
|
||||
## Variable Interpolation
|
||||
|
||||
Many translations use `{{variable}}` syntax for dynamic content. These must be preserved exactly:
|
||||
|
||||
```json
|
||||
"total": "{{count}} total"
|
||||
```
|
||||
|
||||
When translating, keep the variable names unchanged:
|
||||
```json
|
||||
"total": "{{count}} au total" // French example
|
||||
```
|
||||
|
||||
## Translation Sections
|
||||
|
||||
### 1. `entities`
|
||||
|
||||
Core entity names used throughout the application. These are referenced by other translations for composition.
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `home` | Home | Homepage/breadcrumb navigation |
|
||||
| `dashboard` | Dashboard | Main dashboard page |
|
||||
| `nodes` | Nodes | Mesh network nodes (plural) |
|
||||
| `node` | Node | Single mesh network node |
|
||||
| `node_detail` | Node Detail | Node details page |
|
||||
| `advertisements` | Advertisements | Network advertisements (plural) |
|
||||
| `advertisement` | Advertisement | Single advertisement |
|
||||
| `messages` | Messages | Network messages (plural) |
|
||||
| `message` | Message | Single message |
|
||||
| `map` | Map | Network map page |
|
||||
| `members` | Members | Network members (plural) |
|
||||
| `member` | Member | Single network member |
|
||||
| `admin` | Admin | Admin panel |
|
||||
| `tags` | Tags | Node metadata tags (plural) |
|
||||
| `tag` | Tag | Single tag |
|
||||
|
||||
**Usage:** These are used with composite patterns. For example, `t('common.add_entity', { entity: t('entities.node') })` produces "Add Node".
|
||||
|
||||
### 2. `common`
|
||||
|
||||
Reusable patterns and UI elements used across multiple pages.
|
||||
|
||||
#### Actions
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `filter` | Filter | Filter button/action |
|
||||
| `clear` | Clear | Clear action |
|
||||
| `clear_filters` | Clear Filters | Reset all filters |
|
||||
| `search` | Search | Search button/action |
|
||||
| `cancel` | Cancel | Cancel button in dialogs |
|
||||
| `delete` | Delete | Delete button |
|
||||
| `edit` | Edit | Edit button |
|
||||
| `move` | Move | Move button |
|
||||
| `save` | Save | Save button |
|
||||
| `save_changes` | Save Changes | Save changes button |
|
||||
| `add` | Add | Add button |
|
||||
| `close` | close | Close button (lowercase for accessibility) |
|
||||
| `sign_in` | Sign In | Authentication sign in |
|
||||
| `sign_out` | Sign Out | Authentication sign out |
|
||||
| `go_home` | Go Home | Return to homepage button |
|
||||
|
||||
#### Composite Patterns with Entity
|
||||
|
||||
These patterns use `{{entity}}` variable - the entity name is provided dynamically:
|
||||
|
||||
| Key | English | Example Output |
|
||||
|-----|---------|----------------|
|
||||
| `add_entity` | Add {{entity}} | "Add Node", "Add Tag" |
|
||||
| `add_new_entity` | Add New {{entity}} | "Add New Member" |
|
||||
| `edit_entity` | Edit {{entity}} | "Edit Tag" |
|
||||
| `delete_entity` | Delete {{entity}} | "Delete Member" |
|
||||
| `delete_all_entity` | Delete All {{entity}} | "Delete All Tags" |
|
||||
| `move_entity` | Move {{entity}} | "Move Tag" |
|
||||
| `move_entity_to_another_node` | Move {{entity}} to Another Node | "Move Tag to Another Node" |
|
||||
| `copy_entity` | Copy {{entity}} | "Copy Tags" |
|
||||
| `copy_all_entity_to_another_node` | Copy All {{entity}} to Another Node | "Copy All Tags to Another Node" |
|
||||
| `view_entity` | View {{entity}} | "View Node" |
|
||||
| `recent_entity` | Recent {{entity}} | "Recent Advertisements" |
|
||||
| `total_entity` | Total {{entity}} | "Total Nodes" |
|
||||
| `all_entity` | All {{entity}} | "All Messages" |
|
||||
|
||||
#### Empty State Patterns
|
||||
|
||||
These patterns indicate when data is absent. Use `{{entity}}` in lowercase (e.g., "nodes", not "Nodes"):
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `no_entity_found` | No {{entity}} found | Search/filter returned no results |
|
||||
| `no_entity_recorded` | No {{entity}} recorded | No historical records exist |
|
||||
| `no_entity_defined` | No {{entity}} defined | No configuration/definitions exist |
|
||||
| `no_entity_in_database` | No {{entity}} in database | Database is empty |
|
||||
| `no_entity_configured` | No {{entity}} configured | System not configured |
|
||||
| `no_entity_yet` | No {{entity}} yet | Empty state, expecting data later |
|
||||
| `entity_not_found_details` | {{entity}} not found: {{details}} | Specific item not found with details |
|
||||
| `page_not_found` | Page not found | 404 error message |
|
||||
|
||||
#### Confirmation Patterns
|
||||
|
||||
Used in delete/move dialogs. Variables: `{{entity}}`, `{{name}}`, `{{count}}`:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `delete_entity_confirm` | Are you sure you want to delete {{entity}} <strong>{{name}}</strong>? | Single item delete confirmation |
|
||||
| `delete_all_entity_confirm` | Are you sure you want to delete all {{count}} {{entity}} from <strong>{{name}}</strong>? | Bulk delete confirmation |
|
||||
| `cannot_be_undone` | This action cannot be undone. | Warning in delete dialogs |
|
||||
|
||||
#### Success Messages
|
||||
|
||||
Toast/flash messages after successful operations:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `entity_added_success` | {{entity}} added successfully | After creating new item |
|
||||
| `entity_updated_success` | {{entity}} updated successfully | After updating item |
|
||||
| `entity_deleted_success` | {{entity}} deleted successfully | After deleting item |
|
||||
| `entity_moved_success` | {{entity}} moved successfully | After moving tag to another node |
|
||||
| `all_entity_deleted_success` | All {{entity}} deleted successfully | After bulk delete |
|
||||
| `copy_all_entity_description` | Copy all {{count}} {{entity}} from <strong>{{name}}</strong> to another node. | Copy operation description |
|
||||
|
||||
#### Navigation & Status
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `previous` | Previous | Pagination previous |
|
||||
| `next` | Next | Pagination next |
|
||||
| `loading` | Loading... | Loading indicator |
|
||||
| `error` | Error | Error state |
|
||||
| `failed_to_load_page` | Failed to load page | Page load error |
|
||||
|
||||
#### Counts & Metrics
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `total` | {{count}} total | Total count display |
|
||||
| `shown` | {{count}} shown | Filtered count display |
|
||||
| `count_entity` | {{count}} {{entity}} | Generic count with entity |
|
||||
|
||||
#### Form Fields & Labels
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `type` | Type | Type field/column header |
|
||||
| `name` | Name | Name field/column header |
|
||||
| `key` | Key | Key field (for tags) |
|
||||
| `value` | Value | Value field (for tags) |
|
||||
| `time` | Time | Time column header |
|
||||
| `actions` | Actions | Actions column header |
|
||||
| `updated` | Updated | Last updated timestamp |
|
||||
| `view_details` | View Details | View details link |
|
||||
| `all_types` | All Types | "All types" filter option |
|
||||
| `node_type` | Node Type | Node type field |
|
||||
| `show` | Show | Show/display action |
|
||||
| `search_placeholder` | Search by name, ID, or public key... | Search input placeholder |
|
||||
| `contact` | Contact | Contact information field |
|
||||
| `description` | Description | Description field |
|
||||
| `callsign` | Callsign | Amateur radio callsign field |
|
||||
| `tags` | Tags | Tags label/header |
|
||||
| `last_seen` | Last Seen | Last seen timestamp (table header) |
|
||||
| `first_seen_label` | First seen: | First seen label (inline with colon) |
|
||||
| `last_seen_label` | Last seen: | Last seen label (inline with colon) |
|
||||
| `location` | Location | Geographic location |
|
||||
| `public_key` | Public Key | Node public key |
|
||||
| `received` | Received | Received timestamp |
|
||||
| `received_by` | Received By | Received by field |
|
||||
| `receivers` | Receivers | Multiple receivers |
|
||||
| `from` | From | Message sender |
|
||||
| `unnamed` | Unnamed | Fallback for unnamed items |
|
||||
| `unnamed_node` | Unnamed Node | Fallback for unnamed nodes |
|
||||
|
||||
**Note:** Keys ending in `_label` have colons and are used inline. Keys without `_label` are for table headers.
|
||||
|
||||
### 3. `links`
|
||||
|
||||
Platform and external link labels:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `website` | Website | Website link label |
|
||||
| `github` | GitHub | GitHub link label (preserve capitalization) |
|
||||
| `discord` | Discord | Discord link label |
|
||||
| `youtube` | YouTube | YouTube link label (preserve capitalization) |
|
||||
| `profile` | Profile | Radio profile label |
|
||||
|
||||
### 4. `auto_refresh`
|
||||
|
||||
Auto-refresh controls for list pages (nodes, advertisements, messages):
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `pause` | Pause auto-refresh | Tooltip on pause button when auto-refresh is active |
|
||||
| `resume` | Resume auto-refresh | Tooltip on play button when auto-refresh is paused |
|
||||
|
||||
### 5. `time`
|
||||
|
||||
Time-related labels and formats:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `days_ago` | {{count}}d ago | Days ago (abbreviated) |
|
||||
| `hours_ago` | {{count}}h ago | Hours ago (abbreviated) |
|
||||
| `minutes_ago` | {{count}}m ago | Minutes ago (abbreviated) |
|
||||
| `less_than_minute` | <1m ago | Less than one minute ago |
|
||||
| `last_7_days` | Last 7 days | Last 7 days label |
|
||||
| `per_day_last_7_days` | Per day (last 7 days) | Per day over last 7 days |
|
||||
| `over_time_last_7_days` | Over time (last 7 days) | Over time last 7 days |
|
||||
| `activity_per_day_last_7_days` | Activity per day (last 7 days) | Activity chart label |
|
||||
|
||||
### 6. `node_types`
|
||||
|
||||
Mesh network node type labels:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `chat` | Chat | Chat node type |
|
||||
| `repeater` | Repeater | Repeater/relay node type |
|
||||
| `room` | Room | Room/group node type |
|
||||
| `unknown` | Unknown | Unknown node type fallback |
|
||||
|
||||
### 7. `home`
|
||||
|
||||
Homepage-specific content:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `welcome_default` | Welcome to the {{network_name}} mesh network dashboard. Monitor network activity, view connected nodes, and explore message history. | Default welcome message |
|
||||
| `all_discovered_nodes` | All discovered nodes | Stat description |
|
||||
| `network_info` | Network Info | Network info card title |
|
||||
| `network_activity` | Network Activity | Activity chart title |
|
||||
| `meshcore_attribution` | Our local off-grid mesh network is made possible by | Attribution text before MeshCore logo |
|
||||
| `frequency` | Frequency | Radio frequency label |
|
||||
| `bandwidth` | Bandwidth | Radio bandwidth label |
|
||||
| `spreading_factor` | Spreading Factor | LoRa spreading factor label |
|
||||
| `coding_rate` | Coding Rate | LoRa coding rate label |
|
||||
| `tx_power` | TX Power | Transmit power label |
|
||||
| `advertisements` | Advertisements | Homepage stat label |
|
||||
| `messages` | Messages | Homepage stat label |
|
||||
|
||||
**Note:** MeshCore tagline "Connecting people and things, without using the internet" is hardcoded in English and should not be translated (trademark).
|
||||
|
||||
### 8. `dashboard`
|
||||
|
||||
Dashboard page content:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `all_discovered_nodes` | All discovered nodes | Stat label |
|
||||
| `recent_channel_messages` | Recent Channel Messages | Recent messages card title |
|
||||
| `channel` | Channel {{number}} | Channel label with number |
|
||||
|
||||
### 9. `nodes`
|
||||
|
||||
Node-specific labels:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `scan_to_add` | Scan to add as contact | QR code instruction |
|
||||
|
||||
### 10. `advertisements`
|
||||
|
||||
Currently empty - advertisements page uses common patterns.
|
||||
|
||||
### 11. `messages`
|
||||
|
||||
Message type labels:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `type_direct` | Direct | Direct message type |
|
||||
| `type_channel` | Channel | Channel message type |
|
||||
| `type_contact` | Contact | Contact message type |
|
||||
| `type_public` | Public | Public message type |
|
||||
|
||||
### 12. `map`
|
||||
|
||||
Map page content:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `show_labels` | Show Labels | Toggle to show node labels |
|
||||
| `infrastructure_only` | Infrastructure Only | Toggle to show only infrastructure nodes |
|
||||
| `legend` | Legend: | Map legend header |
|
||||
| `infrastructure` | Infrastructure | Infrastructure node category |
|
||||
| `public` | Public | Public node category |
|
||||
| `nodes_on_map` | {{count}} nodes on map | Status text with coordinates |
|
||||
| `nodes_none_have_coordinates` | {{count}} nodes (none have coordinates) | Status text without coordinates |
|
||||
| `gps_description` | Nodes are placed on the map based on GPS coordinates from node reports or manual tags. | Map data source explanation |
|
||||
| `owner` | Owner: | Node owner label |
|
||||
| `role` | Role: | Member role label |
|
||||
| `select_destination_node` | -- Select destination node -- | Dropdown placeholder |
|
||||
|
||||
### 13. `members`
|
||||
|
||||
Members page content:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `empty_state_description` | To display network members, create a members.yaml file in your seed directory. | Empty state instructions |
|
||||
| `members_file_format` | Members File Format | Documentation section title |
|
||||
| `members_file_description` | Create a YAML file at <code>$SEED_HOME/members.yaml</code> with the following structure: | File creation instructions |
|
||||
| `members_import_instructions` | Run <code>meshcore-hub collector seed</code> to import members.<br/>To associate nodes with members, add a <code>member_id</code> tag to nodes in <code>node_tags.yaml</code>. | Import instructions (HTML allowed) |
|
||||
|
||||
### 14. `not_found`
|
||||
|
||||
404 page content:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `description` | The page you're looking for doesn't exist or has been moved. | 404 description |
|
||||
|
||||
### 15. `custom_page`
|
||||
|
||||
Custom markdown page errors:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `failed_to_load` | Failed to load page | Page load error |
|
||||
|
||||
### 16. `admin`
|
||||
|
||||
Admin panel content:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `access_denied` | Access Denied | Access denied heading |
|
||||
| `admin_not_enabled` | The admin interface is not enabled. | Admin disabled message |
|
||||
| `admin_enable_hint` | Set <code>WEB_ADMIN_ENABLED=true</code> to enable admin features. | Configuration hint (HTML allowed) |
|
||||
| `auth_required` | Authentication Required | Auth required heading |
|
||||
| `auth_required_description` | You must sign in to access the admin interface. | Auth required description |
|
||||
| `welcome` | Welcome to the admin panel. | Admin welcome message |
|
||||
| `members_description` | Manage network members and operators. | Members card description |
|
||||
| `tags_description` | Manage custom tags and metadata for network nodes. | Tags card description |
|
||||
|
||||
### 17. `admin_members`
|
||||
|
||||
Admin members page:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `network_members` | Network Members ({{count}}) | Page heading with count |
|
||||
| `member_id` | Member ID | Member ID field label |
|
||||
| `member_id_hint` | Unique identifier (letters, numbers, underscore) | Member ID input hint |
|
||||
| `empty_state_hint` | Click "Add Member" to create the first member. | Empty state hint |
|
||||
|
||||
**Note:** Confirmation and success messages use `common.*` patterns.
|
||||
|
||||
### 18. `admin_node_tags`
|
||||
|
||||
Admin node tags page:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `select_node` | Select Node | Section heading |
|
||||
| `select_node_placeholder` | -- Select a node -- | Dropdown placeholder |
|
||||
| `load_tags` | Load Tags | Load button |
|
||||
| `move_warning` | This will move the tag from the current node to the destination node. | Move operation warning |
|
||||
| `copy_all` | Copy All | Copy all button |
|
||||
| `copy_all_info` | Tags that already exist on the destination node will be skipped. Original tags remain on this node. | Copy operation info |
|
||||
| `delete_all` | Delete All | Delete all button |
|
||||
| `delete_all_warning` | All tags will be permanently deleted. | Delete all warning |
|
||||
| `destination_node` | Destination Node | Destination node field |
|
||||
| `tag_key` | Tag Key | Tag key field |
|
||||
| `for_this_node` | for this node | Suffix for "No tags found for this node" |
|
||||
| `empty_state_hint` | Add a new tag below. | Empty state hint |
|
||||
| `select_a_node` | Select a Node | Empty state heading |
|
||||
| `select_a_node_description` | Choose a node from the dropdown above to view and manage its tags. | Empty state description |
|
||||
| `copied_entities` | Copied {{copied}} tag(s), skipped {{skipped}} | Copy operation result message |
|
||||
|
||||
**Note:** Titles, confirmations, and success messages use `common.*` patterns.
|
||||
|
||||
### 19. `footer`
|
||||
|
||||
Footer content:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `powered_by` | Powered by | "Powered by" attribution |
|
||||
|
||||
## Translation Tips
|
||||
|
||||
1. **Preserve HTML tags:** Some strings contain `<code>`, `<strong>`, or `<br/>` tags - keep these intact.
|
||||
|
||||
2. **Preserve variables:** Keep `{{variable}}` placeholders exactly as-is, only translate surrounding text.
|
||||
|
||||
3. **Entity composition:** Many translations reference `entities.*` keys. When translating entities, consider how they'll work in composite patterns (e.g., "Add {{entity}}" should make sense with "Node", "Tag", etc.).
|
||||
|
||||
4. **Capitalization:**
|
||||
- Entity names should follow your language's capitalization rules for UI elements
|
||||
- Inline labels (with colons) typically use sentence case
|
||||
- Table headers typically use title case
|
||||
- Action buttons can vary by language convention
|
||||
|
||||
5. **Colons:** Keys ending in `_label` include colons in English. Adjust punctuation to match your language's conventions for inline labels.
|
||||
|
||||
6. **Plurals:** Some languages have complex plural rules. You may need to add plural variants for `{{count}}` patterns. Consult the i18n library documentation for plural support.
|
||||
|
||||
7. **Length:** UI space is limited. Try to keep translations concise, especially for button labels and table headers.
|
||||
|
||||
8. **Brand names:** Preserve "MeshCore", "GitHub", "YouTube" capitalization.
|
||||
|
||||
## Testing Your Translation
|
||||
|
||||
1. Create your translation file: `locales/xx.json` (where `xx` is your language code)
|
||||
2. Copy the structure from `en.json`
|
||||
3. Translate all values, preserving all variables and HTML
|
||||
4. Test in the application by setting the language
|
||||
5. Check all pages for:
|
||||
- Text overflow/truncation
|
||||
- Proper variable interpolation
|
||||
- Natural phrasing in context
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you're unsure about the context of a translation key, check:
|
||||
1. The "Context" column in this reference
|
||||
2. The JavaScript files in `/src/meshcore_hub/web/static/js/spa/pages/`
|
||||
3. Grep for the key: `grep -r "t('section.key')" src/`
|
||||
216
src/meshcore_hub/web/static/locales/nl.json
Normal file
216
src/meshcore_hub/web/static/locales/nl.json
Normal file
@@ -0,0 +1,216 @@
|
||||
{
|
||||
"entities": {
|
||||
"home": "Startpagina",
|
||||
"dashboard": "Dashboard",
|
||||
"nodes": "Knooppunten",
|
||||
"node": "Knooppunt",
|
||||
"node_detail": "Knooppuntdetails",
|
||||
"advertisements": "Advertenties",
|
||||
"advertisement": "Advertentie",
|
||||
"messages": "Berichten",
|
||||
"message": "Bericht",
|
||||
"map": "Kaart",
|
||||
"members": "Leden",
|
||||
"member": "Lid",
|
||||
"admin": "Beheer",
|
||||
"tags": "Labels",
|
||||
"tag": "Label"
|
||||
},
|
||||
"common": {
|
||||
"filter": "Filter",
|
||||
"clear": "Wissen",
|
||||
"clear_filters": "Filters wissen",
|
||||
"search": "Zoeken",
|
||||
"cancel": "Annuleren",
|
||||
"delete": "Verwijderen",
|
||||
"edit": "Bewerken",
|
||||
"move": "Verplaatsen",
|
||||
"save": "Opslaan",
|
||||
"save_changes": "Wijzigingen opslaan",
|
||||
"add": "Toevoegen",
|
||||
"add_entity": "{{entity}} toevoegen",
|
||||
"add_new_entity": "Nieuwe {{entity}} toevoegen",
|
||||
"edit_entity": "{{entity}} bewerken",
|
||||
"delete_entity": "{{entity}} verwijderen",
|
||||
"delete_all_entity": "Alle {{entity}} verwijderen",
|
||||
"move_entity": "{{entity}} verplaatsen",
|
||||
"move_entity_to_another_node": "{{entity}} naar ander knooppunt verplaatsen",
|
||||
"copy_entity": "{{entity}} kopiëren",
|
||||
"copy_all_entity_to_another_node": "Alle {{entity}} naar ander knooppunt kopiëren",
|
||||
"view_entity": "{{entity}} bekijken",
|
||||
"recent_entity": "Recente {{entity}}",
|
||||
"total_entity": "Totaal {{entity}}",
|
||||
"all_entity": "Alle {{entity}}",
|
||||
"no_entity_found": "Geen {{entity}} gevonden",
|
||||
"no_entity_recorded": "Geen {{entity}} geregistreerd",
|
||||
"no_entity_defined": "Geen {{entity}} gedefinieerd",
|
||||
"no_entity_in_database": "Geen {{entity}} in database",
|
||||
"no_entity_configured": "Geen {{entity}} geconfigureerd",
|
||||
"no_entity_yet": "Nog geen {{entity}}",
|
||||
"entity_not_found_details": "{{entity}} niet gevonden: {{details}}",
|
||||
"page_not_found": "Pagina niet gevonden",
|
||||
"delete_entity_confirm": "Weet u zeker dat u {{entity}} <strong>{{name}}</strong> wilt verwijderen?",
|
||||
"delete_all_entity_confirm": "Weet u zeker dat u alle {{count}} {{entity}} van <strong>{{name}}</strong> wilt verwijderen?",
|
||||
"cannot_be_undone": "Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"entity_added_success": "{{entity}} succesvol toegevoegd",
|
||||
"entity_updated_success": "{{entity}} succesvol bijgewerkt",
|
||||
"entity_deleted_success": "{{entity}} succesvol verwijderd",
|
||||
"entity_moved_success": "{{entity}} succesvol verplaatst",
|
||||
"all_entity_deleted_success": "Alle {{entity}} succesvol verwijderd",
|
||||
"copy_all_entity_description": "Kopieer alle {{count}} {{entity}} van <strong>{{name}}</strong> naar een ander knooppunt.",
|
||||
"previous": "Vorige",
|
||||
"next": "Volgende",
|
||||
"go_home": "Naar startpagina",
|
||||
"loading": "Laden...",
|
||||
"error": "Fout",
|
||||
"failed_to_load_page": "Pagina laden mislukt",
|
||||
"total": "{{count}} totaal",
|
||||
"shown": "{{count}} weergegeven",
|
||||
"count_entity": "{{count}} {{entity}}",
|
||||
"type": "Type",
|
||||
"name": "Naam",
|
||||
"key": "Sleutel",
|
||||
"value": "Waarde",
|
||||
"time": "Tijd",
|
||||
"actions": "Acties",
|
||||
"updated": "Bijgewerkt",
|
||||
"sign_in": "Inloggen",
|
||||
"sign_out": "Uitloggen",
|
||||
"view_details": "Details bekijken",
|
||||
"all_types": "Alle types",
|
||||
"node_type": "Knooppunttype",
|
||||
"show": "Toon",
|
||||
"search_placeholder": "Zoek op naam, ID of publieke sleutel...",
|
||||
"contact": "Contact",
|
||||
"description": "Beschrijving",
|
||||
"callsign": "Roepnaam",
|
||||
"tags": "Labels",
|
||||
"last_seen": "Laatst gezien",
|
||||
"first_seen_label": "Eerst gezien:",
|
||||
"last_seen_label": "Laatst gezien:",
|
||||
"location": "Locatie",
|
||||
"public_key": "Publieke sleutel",
|
||||
"received": "Ontvangen",
|
||||
"received_by": "Ontvangen door",
|
||||
"receivers": "Ontvangers",
|
||||
"from": "Van",
|
||||
"close": "sluiten",
|
||||
"unnamed": "Naamloos",
|
||||
"unnamed_node": "Naamloos knooppunt"
|
||||
},
|
||||
"links": {
|
||||
"website": "Website",
|
||||
"github": "GitHub",
|
||||
"discord": "Discord",
|
||||
"youtube": "YouTube",
|
||||
"profile": "Profiel"
|
||||
},
|
||||
"auto_refresh": {
|
||||
"pause": "Pauzeer verversen",
|
||||
"resume": "Hervat verversen"
|
||||
},
|
||||
"time": {
|
||||
"days_ago": "{{count}}d geleden",
|
||||
"hours_ago": "{{count}}u geleden",
|
||||
"minutes_ago": "{{count}}m geleden",
|
||||
"less_than_minute": "<1m geleden",
|
||||
"last_7_days": "Laatste 7 dagen",
|
||||
"per_day_last_7_days": "Per dag (laatste 7 dagen)",
|
||||
"over_time_last_7_days": "In de tijd (laatste 7 dagen)",
|
||||
"activity_per_day_last_7_days": "Activiteit per dag (laatste 7 dagen)"
|
||||
},
|
||||
"node_types": {
|
||||
"chat": "Chat",
|
||||
"repeater": "Repeater",
|
||||
"room": "Ruimte",
|
||||
"unknown": "Onbekend"
|
||||
},
|
||||
"home": {
|
||||
"welcome_default": "Welkom bij het {{network_name}} mesh-netwerk dashboard. Monitor netwerkactiviteit, bekijk verbonden knooppunten en verken berichtgeschiedenis.",
|
||||
"all_discovered_nodes": "Alle ontdekte knooppunten",
|
||||
"network_info": "Netwerkinfo",
|
||||
"network_activity": "Netwerkactiviteit",
|
||||
"meshcore_attribution": "Ons lokale off-grid mesh-netwerk is mogelijk gemaakt door",
|
||||
"frequency": "Frequentie",
|
||||
"bandwidth": "Bandbreedte",
|
||||
"spreading_factor": "Spreading Factor",
|
||||
"coding_rate": "Coderingssnelheid",
|
||||
"tx_power": "TX Vermogen"
|
||||
},
|
||||
"dashboard": {
|
||||
"all_discovered_nodes": "Alle ontdekte knooppunten",
|
||||
"recent_channel_messages": "Recente kanaalberichten",
|
||||
"channel": "Kanaal {{number}}"
|
||||
},
|
||||
"nodes": {
|
||||
"scan_to_add": "Scan om als contact toe te voegen"
|
||||
},
|
||||
"advertisements": {},
|
||||
"messages": {
|
||||
"type_direct": "Direct",
|
||||
"type_channel": "Kanaal",
|
||||
"type_contact": "Contact",
|
||||
"type_public": "Publiek"
|
||||
},
|
||||
"map": {
|
||||
"show_labels": "Toon labels",
|
||||
"infrastructure_only": "Alleen infrastructuur",
|
||||
"legend": "Legenda:",
|
||||
"infrastructure": "Infrastructuur",
|
||||
"public": "Publiek",
|
||||
"nodes_on_map": "{{count}} knooppunten op kaart",
|
||||
"nodes_none_have_coordinates": "{{count}} knooppunten (geen met coördinaten)",
|
||||
"gps_description": "Knooppunten worden op de kaart geplaatst op basis van GPS-coördinaten uit knooppuntrapporten of handmatige labels.",
|
||||
"owner": "Eigenaar:",
|
||||
"role": "Rol:",
|
||||
"select_destination_node": "-- Selecteer bestemmingsknooppunt --"
|
||||
},
|
||||
"members": {
|
||||
"empty_state_description": "Om netwerkleden weer te geven, maak een members.yaml bestand aan in je seed-directory.",
|
||||
"members_file_format": "Members bestandsformaat",
|
||||
"members_file_description": "Maak een YAML-bestand aan op <code>$SEED_HOME/members.yaml</code> met de volgende structuur:",
|
||||
"members_import_instructions": "Voer <code>meshcore-hub collector seed</code> uit om leden te importeren.<br/>Om knooppunten aan leden te koppelen, voeg een <code>member_id</code> label toe aan knooppunten in <code>node_tags.yaml</code>."
|
||||
},
|
||||
"not_found": {
|
||||
"description": "De pagina die u zoekt bestaat niet of is verplaatst."
|
||||
},
|
||||
"custom_page": {
|
||||
"failed_to_load": "Pagina laden mislukt"
|
||||
},
|
||||
"admin": {
|
||||
"access_denied": "Toegang geweigerd",
|
||||
"admin_not_enabled": "De beheerinterface is niet ingeschakeld.",
|
||||
"admin_enable_hint": "Stel <code>WEB_ADMIN_ENABLED=true</code> in om beheerfuncties in te schakelen.",
|
||||
"auth_required": "Authenticatie vereist",
|
||||
"auth_required_description": "U moet inloggen om toegang te krijgen tot de beheerinterface.",
|
||||
"welcome": "Welkom bij het beheerpaneel.",
|
||||
"members_description": "Beheer netwerkleden en operators.",
|
||||
"tags_description": "Beheer aangepaste labels en metadata voor netwerkknooppunten."
|
||||
},
|
||||
"admin_members": {
|
||||
"network_members": "Netwerkleden ({{count}})",
|
||||
"member_id": "Lid-ID",
|
||||
"member_id_hint": "Unieke identificatie (letters, cijfers, underscore)",
|
||||
"empty_state_hint": "Klik op \"Lid toevoegen\" om het eerste lid aan te maken."
|
||||
},
|
||||
"admin_node_tags": {
|
||||
"select_node": "Selecteer knooppunt",
|
||||
"select_node_placeholder": "-- Selecteer een knooppunt --",
|
||||
"load_tags": "Labels laden",
|
||||
"move_warning": "Dit verplaatst het label van het huidige knooppunt naar het bestemmingsknooppunt.",
|
||||
"copy_all": "Alles kopiëren",
|
||||
"copy_all_info": "Labels die al bestaan op het bestemmingsknooppunt worden overgeslagen. Originele labels blijven op dit knooppunt.",
|
||||
"delete_all": "Alles verwijderen",
|
||||
"delete_all_warning": "Alle labels worden permanent verwijderd.",
|
||||
"destination_node": "Bestemmingsknooppunt",
|
||||
"tag_key": "Label sleutel",
|
||||
"for_this_node": "voor dit knooppunt",
|
||||
"empty_state_hint": "Voeg hieronder een nieuw label toe.",
|
||||
"select_a_node": "Selecteer een knooppunt",
|
||||
"select_a_node_description": "Kies een knooppunt uit de vervolgkeuzelijst hierboven om de labels te bekijken en beheren.",
|
||||
"copied_entities": "{{copied}} label(s) gekopieerd, {{skipped}} overgeslagen"
|
||||
},
|
||||
"footer": {
|
||||
"powered_by": "Mogelijk gemaakt door"
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
|
||||
<!-- Custom application styles -->
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
<link rel="stylesheet" href="/static/css/app.css?v={{ version }}">
|
||||
|
||||
<!-- Import map for ES module dependencies -->
|
||||
<script type="importmap">
|
||||
@@ -60,24 +60,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" /></svg>
|
||||
</div>
|
||||
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
|
||||
<li><a href="/" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg> Home</a></li>
|
||||
<li><a href="/" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg> {{ t('entities.home') }}</a></li>
|
||||
{% if features.dashboard %}
|
||||
<li><a href="/dashboard" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-dashboard" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> Dashboard</a></li>
|
||||
<li><a href="/dashboard" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-dashboard" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> {{ t('entities.dashboard') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.nodes %}
|
||||
<li><a href="/nodes" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-nodes" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> Nodes</a></li>
|
||||
<li><a href="/nodes" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-nodes" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> {{ t('entities.nodes') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.advertisements %}
|
||||
<li><a href="/advertisements" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-adverts" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg> Advertisements</a></li>
|
||||
<li><a href="/advertisements" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-adverts" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg> {{ t('entities.advertisements') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.messages %}
|
||||
<li><a href="/messages" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-messages" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> Messages</a></li>
|
||||
<li><a href="/messages" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-messages" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> {{ t('entities.messages') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.map %}
|
||||
<li><a href="/map" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-map" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" /></svg> Map</a></li>
|
||||
<li><a href="/map" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-map" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" /></svg> {{ t('entities.map') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.members %}
|
||||
<li><a href="/members" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-members" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg> Members</a></li>
|
||||
<li><a href="/members" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-members" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg> {{ t('entities.members') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.pages %}
|
||||
{% for page in custom_pages %}
|
||||
@@ -93,24 +93,24 @@
|
||||
</div>
|
||||
<div class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li><a href="/" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg> Home</a></li>
|
||||
<li><a href="/" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg> {{ t('entities.home') }}</a></li>
|
||||
{% if features.dashboard %}
|
||||
<li><a href="/dashboard" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-dashboard" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> Dashboard</a></li>
|
||||
<li><a href="/dashboard" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-dashboard" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> {{ t('entities.dashboard') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.nodes %}
|
||||
<li><a href="/nodes" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-nodes" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> Nodes</a></li>
|
||||
<li><a href="/nodes" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-nodes" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> {{ t('entities.nodes') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.advertisements %}
|
||||
<li><a href="/advertisements" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-adverts" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg> Advertisements</a></li>
|
||||
<li><a href="/advertisements" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-adverts" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg> {{ t('entities.advertisements') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.messages %}
|
||||
<li><a href="/messages" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-messages" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> Messages</a></li>
|
||||
<li><a href="/messages" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-messages" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> {{ t('entities.messages') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.map %}
|
||||
<li><a href="/map" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-map" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" /></svg> Map</a></li>
|
||||
<li><a href="/map" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-map" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" /></svg> {{ t('entities.map') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.members %}
|
||||
<li><a href="/members" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-members" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg> Members</a></li>
|
||||
<li><a href="/members" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-members" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg> {{ t('entities.members') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.pages %}
|
||||
{% for page in custom_pages %}
|
||||
@@ -150,18 +150,18 @@
|
||||
{% endif %}
|
||||
{% if network_contact_email and network_contact_discord %} | {% endif %}
|
||||
{% if network_contact_discord %}
|
||||
<a href="{{ network_contact_discord }}" target="_blank" rel="noopener noreferrer" class="link link-hover">Discord</a>
|
||||
<a href="{{ network_contact_discord }}" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.discord') }}</a>
|
||||
{% endif %}
|
||||
{% if (network_contact_email or network_contact_discord) and network_contact_github %} | {% endif %}
|
||||
{% if network_contact_github %}
|
||||
<a href="{{ network_contact_github }}" target="_blank" rel="noopener noreferrer" class="link link-hover">GitHub</a>
|
||||
<a href="{{ network_contact_github }}" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.github') }}</a>
|
||||
{% endif %}
|
||||
{% if (network_contact_email or network_contact_discord or network_contact_github) and network_contact_youtube %} | {% endif %}
|
||||
{% if network_contact_youtube %}
|
||||
<a href="{{ network_contact_youtube }}" target="_blank" rel="noopener noreferrer" class="link link-hover">YouTube</a>
|
||||
<a href="{{ network_contact_youtube }}" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.youtube') }}</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-xs opacity-50 mt-2">{% if admin_enabled %}<a href="/a/" class="link link-hover">Admin</a> | {% endif %}Powered by <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
|
||||
<p class="text-xs opacity-50 mt-2">{% if admin_enabled %}<a href="/a/" class="link link-hover">{{ t('entities.admin') }}</a> | {% endif %}{{ t('footer.powered_by') }} <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
|
||||
</aside>
|
||||
</footer>
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
||||
|
||||
<!-- Chart helper functions -->
|
||||
<script src="/static/js/charts.js"></script>
|
||||
<script src="/static/js/charts.js?v={{ version }}"></script>
|
||||
|
||||
<!-- Embedded app configuration -->
|
||||
<script>
|
||||
@@ -199,6 +199,6 @@
|
||||
</script>
|
||||
|
||||
<!-- SPA Application (ES Module) -->
|
||||
<script type="module" src="/static/js/spa/app.js"></script>
|
||||
<script type="module" src="/static/js/spa/app.js?v={{ version }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -81,6 +82,20 @@ def mock_db_manager(api_db_engine):
|
||||
manager = MagicMock(spec=DatabaseManager)
|
||||
Session = sessionmaker(bind=api_db_engine)
|
||||
manager.get_session = lambda: Session()
|
||||
|
||||
@contextmanager
|
||||
def _session_scope():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
manager.session_scope = _session_scope
|
||||
return manager
|
||||
|
||||
|
||||
|
||||
346
tests/test_api/test_metrics.py
Normal file
346
tests/test_api/test_metrics.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""Tests for Prometheus metrics endpoint."""
|
||||
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from meshcore_hub.api.app import create_app
|
||||
from meshcore_hub.api.dependencies import (
|
||||
get_db_manager,
|
||||
get_db_session,
|
||||
get_mqtt_client,
|
||||
)
|
||||
from meshcore_hub.common.models import Node, NodeTag
|
||||
|
||||
|
||||
def _make_basic_auth(username: str, password: str) -> str:
|
||||
"""Create a Basic auth header value."""
|
||||
credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||
return f"Basic {credentials}"
|
||||
|
||||
|
||||
def _clear_metrics_cache() -> None:
|
||||
"""Clear the metrics module cache."""
|
||||
from meshcore_hub.api.metrics import _cache
|
||||
|
||||
_cache["output"] = b""
|
||||
_cache["expires_at"] = 0.0
|
||||
|
||||
|
||||
class TestMetricsEndpoint:
|
||||
"""Tests for basic metrics endpoint availability."""
|
||||
|
||||
def test_metrics_endpoint_available(self, client_no_auth):
|
||||
"""Test that /metrics endpoint returns 200."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_metrics_content_type(self, client_no_auth):
|
||||
"""Test that metrics returns correct content type."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert "text/plain" in response.headers["content-type"]
|
||||
|
||||
def test_metrics_contains_expected_names(self, client_no_auth):
|
||||
"""Test that metrics output contains expected metric names."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
content = response.text
|
||||
assert "meshcore_info" in content
|
||||
assert "meshcore_nodes_total" in content
|
||||
assert "meshcore_nodes_active" in content
|
||||
assert "meshcore_advertisements_total" in content
|
||||
assert "meshcore_telemetry_total" in content
|
||||
assert "meshcore_trace_paths_total" in content
|
||||
assert "meshcore_members_total" in content
|
||||
|
||||
def test_metrics_info_has_version(self, client_no_auth):
|
||||
"""Test that meshcore_info includes version label."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert 'meshcore_info{version="' in response.text
|
||||
|
||||
|
||||
class TestMetricsAuth:
|
||||
"""Tests for metrics endpoint authentication."""
|
||||
|
||||
def test_no_auth_when_no_read_key(self, client_no_auth):
|
||||
"""Test that no auth is required when no read key is configured."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_401_when_read_key_set_no_auth(self, client_with_auth):
|
||||
"""Test 401 when read key is set but no auth provided."""
|
||||
_clear_metrics_cache()
|
||||
response = client_with_auth.get("/metrics")
|
||||
assert response.status_code == 401
|
||||
assert "WWW-Authenticate" in response.headers
|
||||
|
||||
def test_success_with_correct_basic_auth(self, client_with_auth):
|
||||
"""Test successful auth with correct Basic credentials."""
|
||||
_clear_metrics_cache()
|
||||
response = client_with_auth.get(
|
||||
"/metrics",
|
||||
headers={"Authorization": _make_basic_auth("metrics", "test-read-key")},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_fail_with_wrong_password(self, client_with_auth):
|
||||
"""Test 401 with incorrect password."""
|
||||
_clear_metrics_cache()
|
||||
response = client_with_auth.get(
|
||||
"/metrics",
|
||||
headers={"Authorization": _make_basic_auth("metrics", "wrong-key")},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_fail_with_wrong_username(self, client_with_auth):
|
||||
"""Test 401 with incorrect username."""
|
||||
_clear_metrics_cache()
|
||||
response = client_with_auth.get(
|
||||
"/metrics",
|
||||
headers={
|
||||
"Authorization": _make_basic_auth("admin", "test-read-key"),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_fail_with_bearer_auth(self, client_with_auth):
|
||||
"""Test that Bearer auth does not work for metrics."""
|
||||
_clear_metrics_cache()
|
||||
response = client_with_auth.get(
|
||||
"/metrics",
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestMetricsData:
|
||||
"""Tests for metrics data accuracy."""
|
||||
|
||||
def test_nodes_total_reflects_database(self, client_no_auth, sample_node):
|
||||
"""Test that nodes_total matches actual node count."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
# Should have at least 1 node
|
||||
assert "meshcore_nodes_total 1.0" in response.text
|
||||
|
||||
def test_messages_total_reflects_database(self, client_no_auth, sample_message):
|
||||
"""Test that messages_total reflects database state."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "meshcore_messages_total" in response.text
|
||||
|
||||
def test_advertisements_total_reflects_database(
|
||||
self, client_no_auth, sample_advertisement
|
||||
):
|
||||
"""Test that advertisements_total reflects database state."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "meshcore_advertisements_total 1.0" in response.text
|
||||
|
||||
def test_members_total_reflects_database(self, client_no_auth, sample_member):
|
||||
"""Test that members_total reflects database state."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "meshcore_members_total 1.0" in response.text
|
||||
|
||||
def test_nodes_by_type_has_labels(self, client_no_auth, sample_node):
|
||||
"""Test that nodes_by_type includes adv_type labels."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert 'meshcore_nodes_by_type{adv_type="REPEATER"}' in response.text
|
||||
|
||||
def test_telemetry_total_reflects_database(self, client_no_auth, sample_telemetry):
|
||||
"""Test that telemetry_total reflects database state."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "meshcore_telemetry_total 1.0" in response.text
|
||||
|
||||
def test_trace_paths_total_reflects_database(
|
||||
self, client_no_auth, sample_trace_path
|
||||
):
|
||||
"""Test that trace_paths_total reflects database state."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "meshcore_trace_paths_total 1.0" in response.text
|
||||
|
||||
def test_node_last_seen_timestamp_present(self, api_db_session, client_no_auth):
|
||||
"""Test that node_last_seen_timestamp is present for nodes with last_seen."""
|
||||
seen_at = datetime(2025, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
node = Node(
|
||||
public_key="lastseen1234lastseen1234lastseen",
|
||||
name="Seen Node",
|
||||
adv_type="REPEATER",
|
||||
first_seen=seen_at,
|
||||
last_seen=seen_at,
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.commit()
|
||||
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
# Labels are sorted alphabetically by prometheus_client
|
||||
assert (
|
||||
"meshcore_node_last_seen_timestamp_seconds"
|
||||
'{adv_type="REPEATER",'
|
||||
'node_name="Seen Node",'
|
||||
'public_key="lastseen1234lastseen1234lastseen",'
|
||||
'role=""}'
|
||||
) in response.text
|
||||
|
||||
def test_node_last_seen_timestamp_with_role(self, api_db_session, client_no_auth):
|
||||
"""Test that node_last_seen_timestamp includes role label from node tags."""
|
||||
seen_at = datetime(2025, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
node = Node(
|
||||
public_key="rolenode1234rolenode1234rolenode",
|
||||
name="Infra Node",
|
||||
adv_type="REPEATER",
|
||||
first_seen=seen_at,
|
||||
last_seen=seen_at,
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.flush()
|
||||
|
||||
tag = NodeTag(node_id=node.id, key="role", value="infra")
|
||||
api_db_session.add(tag)
|
||||
api_db_session.commit()
|
||||
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert (
|
||||
"meshcore_node_last_seen_timestamp_seconds"
|
||||
'{adv_type="REPEATER",'
|
||||
'node_name="Infra Node",'
|
||||
'public_key="rolenode1234rolenode1234rolenode",'
|
||||
'role="infra"}'
|
||||
) in response.text
|
||||
|
||||
def test_node_last_seen_timestamp_skips_null(self, api_db_session, client_no_auth):
|
||||
"""Test that nodes with last_seen=None are excluded from the metric."""
|
||||
node = Node(
|
||||
public_key="neverseen1234neverseen1234neversx",
|
||||
name="Never Seen",
|
||||
adv_type="CLIENT",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
last_seen=None,
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.commit()
|
||||
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "neverseen1234neverseen1234neversx" not in response.text
|
||||
|
||||
def test_node_last_seen_timestamp_multiple_nodes(
|
||||
self, api_db_session, client_no_auth
|
||||
):
|
||||
"""Test that multiple nodes each get their own labeled time series."""
|
||||
seen1 = datetime(2025, 6, 15, 10, 0, 0, tzinfo=timezone.utc)
|
||||
seen2 = datetime(2025, 6, 15, 11, 0, 0, tzinfo=timezone.utc)
|
||||
node1 = Node(
|
||||
public_key="multinode1multinode1multinode1mu",
|
||||
name="Node One",
|
||||
adv_type="REPEATER",
|
||||
first_seen=seen1,
|
||||
last_seen=seen1,
|
||||
)
|
||||
node2 = Node(
|
||||
public_key="multinode2multinode2multinode2mu",
|
||||
name="Node Two",
|
||||
adv_type="CHAT",
|
||||
first_seen=seen2,
|
||||
last_seen=seen2,
|
||||
)
|
||||
api_db_session.add_all([node1, node2])
|
||||
api_db_session.commit()
|
||||
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert ('public_key="multinode1multinode1multinode1mu"') in response.text
|
||||
assert ('public_key="multinode2multinode2multinode2mu"') in response.text
|
||||
|
||||
def test_nodes_with_location(self, api_db_session, client_no_auth):
|
||||
"""Test that nodes_with_location counts correctly."""
|
||||
node = Node(
|
||||
public_key="locationtest1234locationtest1234",
|
||||
name="GPS Node",
|
||||
adv_type="CHAT",
|
||||
lat=37.7749,
|
||||
lon=-122.4194,
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
last_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.commit()
|
||||
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "meshcore_nodes_with_location 1.0" in response.text
|
||||
|
||||
|
||||
class TestMetricsDisabled:
|
||||
"""Tests for when metrics are disabled."""
|
||||
|
||||
def test_metrics_404_when_disabled(
|
||||
self, test_db_path, api_db_engine, mock_mqtt, mock_db_manager
|
||||
):
|
||||
"""Test that /metrics returns 404 when disabled."""
|
||||
db_url = f"sqlite:///{test_db_path}"
|
||||
|
||||
with patch("meshcore_hub.api.app._db_manager", mock_db_manager):
|
||||
app = create_app(
|
||||
database_url=db_url,
|
||||
metrics_enabled=False,
|
||||
)
|
||||
|
||||
Session = sessionmaker(bind=api_db_engine)
|
||||
|
||||
def override_get_db_manager(request=None):
|
||||
return mock_db_manager
|
||||
|
||||
def override_get_db_session():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def override_get_mqtt_client(request=None):
|
||||
return mock_mqtt
|
||||
|
||||
app.dependency_overrides[get_db_manager] = override_get_db_manager
|
||||
app.dependency_overrides[get_db_session] = override_get_db_session
|
||||
app.dependency_overrides[get_mqtt_client] = override_get_mqtt_client
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
response = client.get("/metrics")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestMetricsCache:
|
||||
"""Tests for metrics caching behavior."""
|
||||
|
||||
def test_cache_returns_same_output(self, client_no_auth):
|
||||
"""Test that cached responses return the same content."""
|
||||
_clear_metrics_cache()
|
||||
response1 = client_no_auth.get("/metrics")
|
||||
response2 = client_no_auth.get("/metrics")
|
||||
assert response1.text == response2.text
|
||||
139
tests/test_common/test_i18n.py
Normal file
139
tests/test_common/test_i18n.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Tests for the i18n translation module."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from meshcore_hub.common.i18n import LOCALES_DIR, load_locale, t, get_locale
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_locale():
|
||||
"""Reset locale to English before each test."""
|
||||
load_locale("en")
|
||||
yield
|
||||
|
||||
|
||||
class TestLoadLocale:
|
||||
"""Tests for load_locale()."""
|
||||
|
||||
def test_load_english(self):
|
||||
"""Loading 'en' should succeed and set locale."""
|
||||
load_locale("en")
|
||||
assert get_locale() == "en"
|
||||
|
||||
def test_fallback_to_english(self, tmp_path: Path):
|
||||
"""Unknown locale falls back to 'en' if the directory has en.json."""
|
||||
# Copy en.json into a temp directory
|
||||
en_data = {"entities": {"home": "Home"}}
|
||||
(tmp_path / "en.json").write_text(json.dumps(en_data))
|
||||
load_locale("xx", locales_dir=tmp_path)
|
||||
assert t("entities.home") == "Home"
|
||||
|
||||
def test_missing_locale_dir(self, tmp_path: Path):
|
||||
"""Missing locale file doesn't crash."""
|
||||
load_locale("zz", locales_dir=tmp_path / "nonexistent")
|
||||
# Should still work, just returns keys
|
||||
assert t("anything") == "anything"
|
||||
|
||||
|
||||
class TestTranslation:
|
||||
"""Tests for the t() translation function."""
|
||||
|
||||
def test_simple_key(self):
|
||||
"""Simple dot-separated key resolves correctly."""
|
||||
assert t("entities.home") == "Home"
|
||||
assert t("entities.nodes") == "Nodes"
|
||||
|
||||
def test_nested_key(self):
|
||||
"""Deeply nested keys resolve correctly."""
|
||||
assert t("entities.advertisements") == "Advertisements"
|
||||
|
||||
def test_missing_key_returns_key(self):
|
||||
"""Missing key returns the key itself as fallback."""
|
||||
assert t("nonexistent.key") == "nonexistent.key"
|
||||
|
||||
def test_interpolation(self):
|
||||
"""{{var}} placeholders are replaced."""
|
||||
assert t("common.total", count=42) == "42 total"
|
||||
|
||||
def test_interpolation_multiple(self):
|
||||
"""Multiple placeholders are all replaced."""
|
||||
result = t(
|
||||
"admin_node_tags.copied_entities",
|
||||
copied=5,
|
||||
skipped=2,
|
||||
)
|
||||
assert "5" in result
|
||||
assert "2" in result
|
||||
|
||||
def test_missing_interpolation_var(self):
|
||||
"""Missing interpolation variable leaves empty string."""
|
||||
# total has {{count}} placeholder
|
||||
result = t("common.total")
|
||||
# The {{count}} should remain as-is since no var was passed
|
||||
# Actually our implementation doesn't replace if key not in kwargs
|
||||
assert "total" in result
|
||||
|
||||
|
||||
class TestEnJsonCompleteness:
|
||||
"""Tests to verify the en.json file is well-formed."""
|
||||
|
||||
def test_en_json_exists(self):
|
||||
"""The en.json file exists in the expected location."""
|
||||
en_path = LOCALES_DIR / "en.json"
|
||||
assert en_path.exists(), f"en.json not found at {en_path}"
|
||||
|
||||
def test_en_json_valid(self):
|
||||
"""The en.json file is valid JSON."""
|
||||
en_path = LOCALES_DIR / "en.json"
|
||||
data = json.loads(en_path.read_text(encoding="utf-8"))
|
||||
assert isinstance(data, dict)
|
||||
|
||||
def test_required_sections_exist(self):
|
||||
"""All required top-level sections exist."""
|
||||
en_path = LOCALES_DIR / "en.json"
|
||||
data = json.loads(en_path.read_text(encoding="utf-8"))
|
||||
required = [
|
||||
"entities",
|
||||
"common",
|
||||
"links",
|
||||
"time",
|
||||
"node_types",
|
||||
"home",
|
||||
"dashboard",
|
||||
"nodes",
|
||||
"advertisements",
|
||||
"messages",
|
||||
"map",
|
||||
"members",
|
||||
"not_found",
|
||||
"custom_page",
|
||||
"admin",
|
||||
"admin_members",
|
||||
"admin_node_tags",
|
||||
"footer",
|
||||
]
|
||||
for section in required:
|
||||
assert section in data, f"Missing section: {section}"
|
||||
|
||||
def test_common_no_entity_patterns(self):
|
||||
"""Test that common 'no entity' patterns exist."""
|
||||
assert t("common.no_entity_found", entity="test") == "No test found"
|
||||
assert t("common.no_entity_recorded", entity="test") == "No test recorded"
|
||||
assert t("common.no_entity_defined", entity="test") == "No test defined"
|
||||
assert t("common.no_entity_configured", entity="test") == "No test configured"
|
||||
assert t("common.no_entity_yet", entity="test") == "No test yet"
|
||||
assert t("common.page_not_found") == "Page not found"
|
||||
|
||||
def test_entity_keys(self):
|
||||
"""Entity keys are all present."""
|
||||
assert t("entities.home") != "entities.home"
|
||||
assert t("entities.dashboard") != "entities.dashboard"
|
||||
assert t("entities.nodes") != "entities.nodes"
|
||||
assert t("entities.advertisements") != "entities.advertisements"
|
||||
assert t("entities.messages") != "entities.messages"
|
||||
assert t("entities.map") != "entities.map"
|
||||
assert t("entities.members") != "entities.members"
|
||||
assert t("entities.admin") != "entities.admin"
|
||||
220
tests/test_web/test_caching.py
Normal file
220
tests/test_web/test_caching.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""Tests for HTTP caching middleware and version parameters."""
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from meshcore_hub import __version__
|
||||
|
||||
|
||||
class TestCacheControlHeaders:
|
||||
"""Test Cache-Control headers are correctly set for different resource types."""
|
||||
|
||||
def test_static_css_with_version(self, client):
|
||||
"""Static CSS with version parameter should have long-term cache."""
|
||||
response = client.get(f"/static/css/app.css?v={__version__}")
|
||||
assert response.status_code == 200
|
||||
assert "cache-control" in response.headers
|
||||
assert (
|
||||
response.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
)
|
||||
|
||||
def test_static_js_with_version(self, client):
|
||||
"""Static JS with version parameter should have long-term cache."""
|
||||
response = client.get(f"/static/js/charts.js?v={__version__}")
|
||||
assert response.status_code == 200
|
||||
assert "cache-control" in response.headers
|
||||
assert (
|
||||
response.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
)
|
||||
|
||||
def test_static_module_with_version(self, client):
|
||||
"""Static ES module with version parameter should have long-term cache."""
|
||||
response = client.get(f"/static/js/spa/app.js?v={__version__}")
|
||||
assert response.status_code == 200
|
||||
assert "cache-control" in response.headers
|
||||
assert (
|
||||
response.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
)
|
||||
|
||||
def test_static_css_without_version(self, client):
|
||||
"""Static CSS without version should have short fallback cache."""
|
||||
response = client.get("/static/css/app.css")
|
||||
assert response.status_code == 200
|
||||
assert "cache-control" in response.headers
|
||||
assert response.headers["cache-control"] == "public, max-age=3600"
|
||||
|
||||
def test_static_js_without_version(self, client):
|
||||
"""Static JS without version should have short fallback cache."""
|
||||
response = client.get("/static/js/charts.js")
|
||||
assert response.status_code == 200
|
||||
assert "cache-control" in response.headers
|
||||
assert response.headers["cache-control"] == "public, max-age=3600"
|
||||
|
||||
def test_spa_shell_html(self, client):
|
||||
"""SPA shell HTML should not be cached."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "cache-control" in response.headers
|
||||
assert response.headers["cache-control"] == "no-cache, public"
|
||||
|
||||
def test_spa_route_html(self, client):
|
||||
"""Client-side route should not be cached."""
|
||||
response = client.get("/dashboard")
|
||||
assert response.status_code == 200
|
||||
assert "cache-control" in response.headers
|
||||
assert response.headers["cache-control"] == "no-cache, public"
|
||||
|
||||
def test_map_data_endpoint(self, client, mock_http_client):
|
||||
"""Map data endpoint should have short cache (5 minutes)."""
|
||||
# Mock the API response for map data
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes/map",
|
||||
200,
|
||||
{"nodes": []},
|
||||
)
|
||||
|
||||
response = client.get("/map/data")
|
||||
assert response.status_code == 200
|
||||
assert "cache-control" in response.headers
|
||||
assert response.headers["cache-control"] == "public, max-age=300"
|
||||
|
||||
def test_health_endpoint(self, client):
|
||||
"""Health endpoint should never be cached."""
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert "cache-control" in response.headers
|
||||
assert (
|
||||
response.headers["cache-control"] == "no-cache, no-store, must-revalidate"
|
||||
)
|
||||
|
||||
def test_healthz_endpoint(self, client):
|
||||
"""Healthz endpoint should never be cached."""
|
||||
response = client.get("/healthz")
|
||||
assert response.status_code == 200
|
||||
assert "cache-control" in response.headers
|
||||
assert (
|
||||
response.headers["cache-control"] == "no-cache, no-store, must-revalidate"
|
||||
)
|
||||
|
||||
def test_robots_txt(self, client):
|
||||
"""Robots.txt should have moderate cache (1 hour)."""
|
||||
response = client.get("/robots.txt")
|
||||
assert response.status_code == 200
|
||||
assert "cache-control" in response.headers
|
||||
assert response.headers["cache-control"] == "public, max-age=3600"
|
||||
|
||||
def test_sitemap_xml(self, client):
|
||||
"""Sitemap.xml should have moderate cache (1 hour)."""
|
||||
response = client.get("/sitemap.xml")
|
||||
assert response.status_code == 200
|
||||
assert "cache-control" in response.headers
|
||||
assert response.headers["cache-control"] == "public, max-age=3600"
|
||||
|
||||
def test_api_proxy_no_cache_header_added(self, client, mock_http_client):
|
||||
"""API proxy should not add cache headers (lets backend control caching)."""
|
||||
# The mock client doesn't add cache-control headers by default
|
||||
# Middleware should not add any either for /api/* paths
|
||||
response = client.get("/api/v1/nodes")
|
||||
assert response.status_code == 200
|
||||
# Cache-control should either not be present, or be from the backend
|
||||
# Since our mock doesn't add it, middleware shouldn't add it either
|
||||
# (In production, backend would set its own cache-control)
|
||||
|
||||
|
||||
class TestVersionParameterInHTML:
|
||||
"""Test that version parameters are correctly added to static file references."""
|
||||
|
||||
def test_css_link_has_version(self, client):
|
||||
"""CSS link should include version parameter."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
css_link = soup.find(
|
||||
"link", {"href": lambda x: x and "/static/css/app.css" in x}
|
||||
)
|
||||
|
||||
assert css_link is not None
|
||||
assert f"?v={__version__}" in css_link["href"]
|
||||
|
||||
def test_charts_js_has_version(self, client):
|
||||
"""Charts.js script should include version parameter."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
charts_script = soup.find(
|
||||
"script", {"src": lambda x: x and "/static/js/charts.js" in x}
|
||||
)
|
||||
|
||||
assert charts_script is not None
|
||||
assert f"?v={__version__}" in charts_script["src"]
|
||||
|
||||
def test_app_js_has_version(self, client):
|
||||
"""SPA app.js script should include version parameter."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
app_script = soup.find(
|
||||
"script", {"src": lambda x: x and "/static/js/spa/app.js" in x}
|
||||
)
|
||||
|
||||
assert app_script is not None
|
||||
assert f"?v={__version__}" in app_script["src"]
|
||||
|
||||
def test_cdn_resources_unchanged(self, client):
|
||||
"""CDN resources should not have version parameters."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
# Check external CDN resources don't have our version param
|
||||
cdn_scripts = soup.find_all("script", {"src": lambda x: x and "cdn" in x})
|
||||
for script in cdn_scripts:
|
||||
assert f"?v={__version__}" not in script["src"]
|
||||
|
||||
cdn_links = soup.find_all("link", {"href": lambda x: x and "cdn" in x})
|
||||
for link in cdn_links:
|
||||
assert f"?v={__version__}" not in link["href"]
|
||||
|
||||
|
||||
class TestMediaFileCaching:
|
||||
"""Test caching behavior for custom media files."""
|
||||
|
||||
def test_media_file_with_version(self, client, tmp_path):
|
||||
"""Media files with version parameter should have long-term cache."""
|
||||
# Note: This test assumes media files are served via StaticFiles
|
||||
# In practice, you may need to create a test media file
|
||||
response = client.get(f"/media/test.png?v={__version__}")
|
||||
# May be 404 if no test media exists, but header should still be set
|
||||
if response.status_code == 200:
|
||||
assert "cache-control" in response.headers
|
||||
assert (
|
||||
response.headers["cache-control"]
|
||||
== "public, max-age=31536000, immutable"
|
||||
)
|
||||
|
||||
def test_media_file_without_version(self, client):
|
||||
"""Media files without version should have short cache."""
|
||||
response = client.get("/media/test.png")
|
||||
# May be 404 if no test media exists, but header should still be set
|
||||
if response.status_code == 200:
|
||||
assert "cache-control" in response.headers
|
||||
assert response.headers["cache-control"] == "public, max-age=3600"
|
||||
|
||||
|
||||
class TestCustomPageCaching:
|
||||
"""Test caching behavior for custom markdown pages."""
|
||||
|
||||
def test_custom_page_cache(self, client):
|
||||
"""Custom pages should have moderate cache (1 hour)."""
|
||||
# Custom pages are served by the web app (not API proxy)
|
||||
# They use the PageLoader which reads from CONTENT_HOME
|
||||
# For this test, we'll check that a 404 still gets cache headers
|
||||
# (In a real deployment with content files, this would return 200)
|
||||
response = client.get("/spa/pages/test")
|
||||
# May be 404 if no test page exists, but cache header should still be set
|
||||
assert "cache-control" in response.headers
|
||||
assert response.headers["cache-control"] == "public, max-age=3600"
|
||||
Reference in New Issue
Block a user