Compare commits

115 Commits

Author SHA1 Message Date
JingleManSweep
31d591723d Add acknowledgments section to README 2026-03-05 11:32:09 +00:00
JingleManSweep
3eff7f03db Merge pull request #130 from shiqual/main
Add Dutch localization file nl.json
2026-03-02 23:38:37 +00:00
JingleManSweep
905ea0190b Merge branch 'main' into main 2026-03-02 23:35:45 +00:00
JingleManSweep
86cc7edca3 Merge pull request #129 from ipnet-mesh/renovate/major-github-artifact-actions
Update actions/upload-artifact action to v7
2026-03-02 23:30:39 +00:00
shiqual
eb3f8508b7 Add Dutch localization file nl.json
Dutch translation
2026-03-02 00:13:46 +01:00
renovate[bot]
74a34fdcba Update actions/upload-artifact action to v7 2026-02-26 20:53:07 +00:00
JingleManSweep
175fc8c524 Merge pull request #127 from ipnet-mesh/chore/fix-metrics-labels
Add role label to node last seen metric and filter alerts by role
2026-02-19 00:05:08 +00:00
Louis King
2a153a5239 Add role label to node last seen metric and filter alerts by role
Joins NodeTag (key='role') to the node last seen Prometheus metric so
alert rules can target infrastructure nodes only (role="infra").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 00:01:20 +00:00
JingleManSweep
de85e0cd7a Merge pull request #126 from ipnet-mesh/feat/prometheus
Add Prometheus metrics endpoint, Alertmanager, and 1h stats window
2026-02-18 23:09:22 +00:00
Louis King
5a20da3afa Add Prometheus metrics endpoint, Alertmanager, and 1h stats window
Add /metrics endpoint with Prometheus gauges for nodes, messages,
advertisements, telemetry, trace paths, events, and members. Include
per-node last_seen timestamps for alerting. Add Alertmanager service
to Docker Compose metrics profile with default blackhole receiver.
Add NodeNotSeen alert rule (48h threshold). Add 1h time window to
all windowed metrics alongside existing 24h/7d/30d windows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 23:06:07 +00:00
JingleManSweep
dcd33711db Merge pull request #125 from ipnet-mesh/feat/auto-update-lists
Add configurable auto-refresh for list pages
2026-02-18 16:07:25 +00:00
Louis King
a8cb20fea5 Add configurable auto-refresh for list pages
Nodes, advertisements, and messages pages now auto-refresh on a
configurable interval (WEB_AUTO_REFRESH_SECONDS, default 30s). A
pause/play toggle in the page header lets users control it. Setting
the interval to 0 disables auto-refresh entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 14:37:33 +00:00
JingleManSweep
3ac5667d7a Merge pull request #118 from ipnet-mesh/feat/node-list-tag-improvements
Fix clipboard copy error with null target
2026-02-14 01:49:09 +00:00
JingleManSweep
c8c53b25bd Merge branch 'main' into feat/node-list-tag-improvements 2026-02-14 01:46:45 +00:00
Louis King
e4a1b005dc Fix clipboard copy error with null target
Capture e.currentTarget synchronously before async operations
to prevent it from becoming null in async promise handlers.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 01:44:28 +00:00
JingleManSweep
27adc6e2de Merge pull request #117 from ipnet-mesh/feat/node-list-tag-improvements
Improve node list tag display with name, description, members, and emoji extraction
2026-02-14 01:37:11 +00:00
Louis King
835fb1c094 Respect FEATURE_MEMBERS flag in advertisements page
- Only fetch members data when feature is enabled
- Hide member filter when feature is disabled

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 01:32:18 +00:00
Louis King
d7a351a803 Respect FEATURE_MEMBERS flag in nodes list
- Only fetch members data when feature is enabled
- Hide member filter when feature is disabled
- Hide member column when feature is disabled
- Adjust table colspan dynamically based on feature

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 01:30:38 +00:00
JingleManSweep
317627833c Merge pull request #116 from ipnet-mesh/feat/node-list-tag-improvements
Improve node display with descriptions, members, and emoji extraction
2026-02-14 01:24:05 +00:00
Louis King
f4514d1150 Improve node display with descriptions, members, and emoji extraction
Enhances the web dashboard's node presentation to match official MeshCore
app behavior and provide better user experience:

- Extract emoji from node names (e.g., "🏠 Home Gateway" uses 🏠 icon)
- Display description tags under node names across all list pages
- Add Member column to show network member associations
- Add copyable public key columns on Nodes and Advertisements pages
- Create reusable renderNodeDisplay() component for consistency
- Improve node detail page layout with larger emoji and inline description
- Document standard node tags (name, description, member_id, etc.)
- Fix documentation: correct Python version requirement and tag examples

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 01:20:52 +00:00
JingleManSweep
7be5f6afdf Merge pull request #115 from ipnet-mesh/chore/http-caching
Add HTTP caching for web dashboard resources
2026-02-14 00:05:44 +00:00
Louis King
54695ab9e2 Add beautifulsoup4 to dev dependencies
Required for HTML parsing in web caching tests.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 00:03:28 +00:00
Louis King
189eb3a139 Add HTTP caching for web dashboard resources
Implement cache-control middleware to optimize browser caching and reduce
bandwidth usage. Static files are cached for 1 year when accessed with
version parameters, while dynamic content is never cached.

Changes:
- Add CacheControlMiddleware with path-based caching logic
- Register middleware in web app after ProxyHeadersMiddleware
- Add version query parameters to CSS, JS, and app.js references
- Create comprehensive test suite (20 tests) for all cache behaviors

Cache strategy:
- Static files with ?v=X.Y.Z: 1 year (immutable)
- Static files without version: 1 hour (fallback)
- SPA shell HTML: no-cache (dynamic config)
- Health endpoints: no-cache, no-store (always fresh)
- Map data: 5 minutes (location updates)
- Custom pages: 1 hour (stable markdown)
- API proxy: pass-through (backend controls)

All 458 tests passing, 95% middleware coverage.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 00:01:08 +00:00
JingleManSweep
96ca6190db Merge pull request #113 from ipnet-mesh/claude/add-i18n-support-1duUx
Fix translation key in node detail page: nodes.tags → entities.tags
2026-02-13 23:10:35 +00:00
Louis King
baf08a9545 Shorten translation call-to-action with GitHub alert
Replaced verbose translation section with concise GitHub alert notification.

- Uses [!IMPORTANT] alert style for better visibility
- Reduced from 16 lines to 4 lines
- Keeps essential information and link to Translation Guide
- More scannable and professional appearance

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 23:07:53 +00:00
JingleManSweep
1d3e649ce0 Merge branch 'main' into claude/add-i18n-support-1duUx 2026-02-13 23:03:43 +00:00
Louis King
45abc66816 Remove Claude Code review GitHub action
Removed the code-review.yml workflow that automatically runs Claude Code review on pull requests.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 23:03:22 +00:00
Louis King
9c8eb27455 Fix translation key in node detail page: nodes.tags → entities.tags
The Tags panel title was showing 'nodes.tags' as literal text instead of the translation.

Fixed: node-detail.js line 174 now uses entities.tags

Comprehensive review completed:
- Verified all 115 unique translation keys across all pages
- All keys properly resolve to valid translations in en.json
- All i18n tests passing

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 23:01:49 +00:00
JingleManSweep
e6c6d4aecc Merge pull request #112 from ipnet-mesh/claude/add-i18n-support-1duUx
Add i18n support for web dashboard
2026-02-13 22:38:49 +00:00
Louis King
19bb06953e Fix remaining translation key: common.all_nodes
Replaced non-existent common.all_nodes key with common.all_entity pattern.

- advertisements.js: Use common.all_entity with entities.nodes
- map.js: Use common.all_entity with entities.nodes

All translation keys now properly resolve across the entire dashboard.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 22:36:17 +00:00
Louis King
1f55d912ea Fix translation key references across all pages
Fixes critical issue where translation keys were displaying as literal text instead of translations.

Changes:
- home.js: Fix stat headers (home.* → entities.*)
- dashboard.js: Fix stat headers, chart labels, table columns
- nodes.js: Fix table columns and filter labels (common.* → entities.*)
- advertisements.js: Fix filter widgets and table headers
- messages.js: Fix table column header
- map.js: Fix filter label and dropdown
- admin/node-tags.js: Fix node label reference

All translation keys now correctly reference entities.* section.
Used common.all_entity pattern instead of non-existent common.all_members.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 22:31:59 +00:00
Louis King
5272a72647 Refactor i18n, add translation guide, and audit documentation
## i18n Refactoring

- Refactor admin translations to use common composable patterns
- Add common patterns: delete_entity_confirm, entity_added_success, move_entity_to_another_node, etc.
- Remove 18 duplicate keys from admin_members and admin_node_tags sections
- Update all admin JavaScript files to use new common patterns with dynamic entity composition
- Fix label consistency: rename first_seen to first_seen_label to match naming convention

## Translation Documentation

- Create comprehensive translation reference guide (languages.md) with 200+ documented keys
- Add translation architecture documentation to AGENTS.md with examples and best practices
- Add "Help Translate" call-to-action section in README with link to translation guide
- Add i18n feature to README features list

## Documentation Audit

- Add undocumented config options: API_KEY, WEB_LOCALE, WEB_DOMAIN to README and .env.example
- Fix outdated CLI syntax: interface --mode receiver → interface receiver
- Update database migration commands to use CLI wrapper (meshcore-hub db) instead of direct alembic
- Add static/locales/ directory to project structure section
- Add i18n configuration (WEB_LOCALE, WEB_THEME) to docker-compose.yml

## Testing

- All 438 tests passing
- All pre-commit checks passing (black, flake8, mypy)
- Added tests for new common translation patterns

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 22:19:37 +00:00
Louis King
b2f8e18f13 Fix admin translations to use entity references
- Update admin index page to use entities.members and entities.tags
- Rename admin.node_tags_description to admin.tags_description
- Remove redundant admin.*_title keys in favor of entities

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 21:33:35 +00:00
Louis King
a15e91c754 Further refine i18n structure
- Remove "nav" section, use "entities" references instead
- Remove composite strings like "Total Nodes", "Recent Advertisements"
  - Use composed patterns: t('common.total_entity', { entity: t('entities.nodes') })
  - Use common.recent_entity, common.edit_entity, common.add_entity patterns
- Hardcode MeshCore tagline (official trademark, not configurable)
- Update all page components and templates to use entity-based translations
- Update tests to reflect new structure
- Remove redundant page-specific composite keys

This maximizes reusability and reduces duplication across translations.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 21:32:36 +00:00
Louis King
85129e528e Refactor i18n translations for better maintainability
- Remove page_title section, compose titles dynamically as "{{entity}} - {{network_name}}"
- Add entities section for centralized entity names (nodes, members, tags, etc.)
- Replace specific action translations with composed patterns (add_entity, edit_entity, etc.)
- Create links section for common platform names (github, discord, youtube)
- Remove redundant page-specific title fields, use entity names instead
- Update all page components to use new translation structure
- Keep user-defined strings (network_name) separate from translatable content

This follows i18n best practices by using composition over duplication,
centralizing reusable terms, and making it easier to add new languages.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 21:19:02 +00:00
Claude
127cd7adf6 Add i18n support for web dashboard
Implement lightweight i18n infrastructure with shared JSON translation
files used by both server-side Jinja2 templates and client-side SPA.

- Add custom i18n module (Python + JS, ~80 lines total, zero deps)
- Create en.json with ~200 translation keys covering all web strings
- Add WEB_LOCALE config setting (default: 'en', with localStorage override)
- Translate all navigation labels, page titles, and footer in spa.html
- Translate all 13 SPA page modules (home, dashboard, nodes, etc.)
- Translate shared components (pagination, relative time, charts)
- Translate all 3 admin pages (index, members, node-tags)
- Fix Adverts/Advertisements inconsistency (standardize to Advertisements)
- Add i18n unit tests with 100% coverage

https://claude.ai/code/session_01FbnUnwYAwPrsQmAh5EuSkF
2026-02-13 18:49:06 +00:00
JingleManSweep
91b3f1926f Merge pull request #110 from ipnet-mesh/chore/testing-claude-github-actions
Testing Claude GitHub Actions integrations
2026-02-11 12:53:17 +00:00
Louis King
3ef94a21df Testing Claude GitHub Actions integrations 2026-02-11 12:49:17 +00:00
JingleManSweep
19e724fcc8 Merge pull request #109 from ipnet-mesh/chore/test-claude
Updates
2026-02-11 12:40:56 +00:00
Louis King
7b7910b42e Updates 2026-02-11 12:35:45 +00:00
JingleManSweep
c711a0eb9b Merge pull request #108 from ipnet-mesh/renovate/actions-checkout-6.x
Update actions/checkout action to v6
2026-02-11 12:25:52 +00:00
renovate[bot]
dcd7ed248d Update actions/checkout action to v6 2026-02-11 12:24:09 +00:00
JingleManSweep
b0ea6bcc0e Merge pull request #107 from ipnet-mesh/add-claude-github-actions-1770812503821
Add Claude Code GitHub Workflow
2026-02-11 12:23:36 +00:00
JingleManSweep
7ef41a3671 "Claude Code Review workflow" 2026-02-11 12:21:46 +00:00
JingleManSweep
a7611dd8d4 "Claude PR Assistant workflow" 2026-02-11 12:21:44 +00:00
JingleManSweep
8f907edce6 Merge pull request #106 from ipnet-mesh/chore/screenshot
Updated Screenshot
2026-02-11 12:09:05 +00:00
JingleManSweep
95d1b260ab Merge pull request #105 from ipnet-mesh/renovate/docker-build-push-action-6.x
Update docker/build-push-action action to v6
2026-02-11 12:08:35 +00:00
renovate[bot]
fba2656268 Update docker/build-push-action action to v6 2026-02-11 12:08:24 +00:00
JingleManSweep
69adca09e3 Merge pull request #102 from ipnet-mesh/renovate/major-github-artifact-actions
Update actions/upload-artifact action to v6
2026-02-11 12:06:28 +00:00
JingleManSweep
9c2a0527ff Merge pull request #101 from ipnet-mesh/renovate/actions-setup-python-6.x
Update actions/setup-python action to v6
2026-02-11 12:04:56 +00:00
JingleManSweep
c0db5b1da5 Merge pull request #103 from ipnet-mesh/renovate/codecov-codecov-action-5.x
Update codecov/codecov-action action to v5
2026-02-11 12:04:31 +00:00
Louis King
77dcbb77ba Push 2026-02-11 12:02:40 +00:00
renovate[bot]
5bf0265fd9 Update codecov/codecov-action action to v5 2026-02-11 12:01:49 +00:00
renovate[bot]
1adef40fdc Update actions/upload-artifact action to v6 2026-02-11 12:01:21 +00:00
renovate[bot]
c9beb7e801 Update actions/setup-python action to v6 2026-02-11 12:01:18 +00:00
JingleManSweep
cd14c23cf2 Merge pull request #104 from ipnet-mesh/chore/ci-fixes
CI Fixes
2026-02-11 11:54:10 +00:00
Louis King
708bfd1811 CI Fixes 2026-02-11 11:53:21 +00:00
JingleManSweep
afdc76e546 Merge pull request #97 from ipnet-mesh/renovate/python-3.x
Update python Docker tag to v3.14
2026-02-11 11:34:18 +00:00
renovate[bot]
e07b9ee2ab Update python Docker tag to v3.14 2026-02-11 11:33:31 +00:00
JingleManSweep
00851bfcaa Merge pull request #100 from ipnet-mesh/chore/fix-ci
Push
2026-02-11 11:30:44 +00:00
Louis King
6a035e41c0 Push 2026-02-11 11:30:25 +00:00
JingleManSweep
2ffc78fda2 Merge pull request #98 from ipnet-mesh/renovate/actions-checkout-6.x
Update actions/checkout action to v6
2026-02-11 11:26:25 +00:00
renovate[bot]
3f341a4031 Update actions/checkout action to v6 2026-02-11 11:24:17 +00:00
JingleManSweep
1ea729bd51 Merge pull request #96 from ipnet-mesh/renovate/configure
Configure Renovate
2026-02-11 11:23:03 +00:00
renovate[bot]
d329f67ba8 Add renovate.json 2026-02-11 11:22:03 +00:00
JingleManSweep
c42a2deffb Merge pull request #95 from ipnet-mesh/chore/add-sponsorship-badge
Add README badges and workflow path filters
2026-02-11 00:40:57 +00:00
Louis King
dfa4157c9c Fixed funding 2026-02-11 00:36:13 +00:00
Louis King
b52fd32106 Add path filters to CI and Docker workflows
Skip unnecessary workflow runs when only non-code files change (README,
docs, etc). Docker workflow always runs on version tags.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 15:43:23 +00:00
JingleManSweep
bafc16d746 Merge pull request #89 from ipnet-mesh/claude/fix-admin-auth-bypass-atTWJ
Enforce authentication for admin API proxy mutations
2026-02-10 08:51:40 +00:00
Claude
9b09e32d41 Fix admin authentication bypass in web dashboard
The admin pages only checked config.admin_enabled but not
config.is_authenticated, allowing unauthenticated users to access
admin functionality when WEB_ADMIN_ENABLED=true. Additionally, the
API proxy forwarded the service-level Bearer token on all requests
regardless of user authentication, granting full admin API access
to unauthenticated browsers.

Server-side: block POST/PUT/DELETE/PATCH through the API proxy when
admin is enabled and no X-Forwarded-User header is present.

Client-side: add is_authenticated check to all three admin pages,
showing a sign-in prompt instead of admin content.

https://claude.ai/code/session_01HYuz5XLjYZ6JaowWqz643A
2026-02-10 01:20:04 +00:00
JingleManSweep
2b9f83e55e Merge pull request #88 from ipnet-mesh/feat/spa
Initial SPA (Single Page App) Conversion
2026-02-10 00:43:53 +00:00
Louis King
75c1966385 Fix Map nav icon color to exact DaisyUI warning yellow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 00:39:06 +00:00
Louis King
3089ff46a8 Clean up legacy templates, fix nav colors and QR code timing
Remove all old Jinja2 templates (only spa.html is used now). Fix Map
nav icon color to yellow (matching btn-warning) and Members to orange.
Fix QR code intermittently not rendering on node detail pages with GPS
coords by deferring init to requestAnimationFrame.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 00:36:24 +00:00
Louis King
f1bceb5780 Rewrite web dashboard as Single Page Application
Replace server-side rendered Jinja2 page routes with a client-side SPA
using ES modules, lit-html templating, and a custom History API router.
All page rendering now happens in the browser with efficient DOM diffing.

Key changes:
- Add SPA router, API client, shared components, and 14 page modules
- Serve single spa.html shell template with catch-all route
- Remove server-side page routes (web/routes/) and legacy JS files
- Add centralized OKLCH color palette in CSS custom properties
- Add colored nav icons, navbar spacing, and loading spinner
- Add canonical URL and SEO path exclusions to SPA router
- Update charts.js to read from shared color palette
- Update tests for SPA architecture (template-agnostic assertions)
- Update AGENTS.md and README.md with SPA documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 00:23:45 +00:00
JingleManSweep
caf88bdba1 Merge pull request #87 from ipnet-mesh/feat/timezones
Move timezone display to page headers instead of each timestamp
2026-02-09 00:52:28 +00:00
Louis King
9eb1acfc02 Add TZ variable to .env.example
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 00:47:58 +00:00
Louis King
62e0568646 Use timezone abbreviation (GMT, EST) instead of full name in headers
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 00:46:39 +00:00
Louis King
b4da93e4f0 Move timezone display to page headers instead of each timestamp
- Remove timezone abbreviation from datetime format strings
- Add timezone label to page headers (Nodes, Messages, Advertisements, Map)
- Only show timezone when not UTC to reduce clutter

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 00:43:51 +00:00
JingleManSweep
981402f7aa Merge pull request #86 from ipnet-mesh/feat/timezones
Add timezone support for web dashboard date/time display
2026-02-09 00:38:43 +00:00
Louis King
76717179c2 Add timezone support for web dashboard date/time display
- Add TZ environment variable support (standard Linux timezone)
- Create Jinja2 filters for timezone-aware formatting (localtime, localdate, etc.)
- Update all templates to use timezone filters with abbreviation suffix
- Pass TZ through docker-compose for web service
- Document TZ setting in README and AGENTS.md

Timestamps remain stored as UTC; only display is converted.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 00:34:57 +00:00
JingleManSweep
f42987347e Merge pull request #85 from ipnet-mesh/chore/tidy-map
Use colored dots for map markers instead of logo
2026-02-08 23:53:48 +00:00
Louis King
25831f14e6 Use colored dots for map markers instead of logo
Replace logo icons with colored circle markers:
- Red dots for infrastructure nodes
- Blue dots for public nodes

Update popup overlay to show type emoji (📡, 💬, etc.) on the left
and infra/public indicator dot on the right of the node name.
Update legend to match new marker style.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 23:50:44 +00:00
Louis King
0e6cbc8094 Optimised GitHub CI workflow triggers 2026-02-08 23:40:35 +00:00
JingleManSweep
76630f0bb0 Merge pull request #84 from ipnet-mesh/chore/youtube-link
Add NETWORK_CONTACT_YOUTUBE config for footer link
2026-02-08 23:39:35 +00:00
Louis King
8fbac2cbd6 Add NETWORK_CONTACT_YOUTUBE config for footer link
Add YouTube channel URL configuration option alongside existing
GitHub/Discord/Email contact links. Also crop logo SVG to content
bounds and pass YouTube env var through docker-compose.
2026-02-08 23:36:40 +00:00
Louis King
fcac5e01dc Use network welcome text for SEO meta description
Meta description now uses NETWORK_WELCOME_TEXT prefixed with network
name for better SEO, falling back to generic message if not set.
2026-02-08 23:21:17 +00:00
Louis King
b6f3b2d864 Redesign node detail page with hero map header
- Add hero panel with non-interactive map background when GPS coords exist
- Fix coordinate detection: check node model fields before falling back to tags
- Move node name to standard page header above hero panel
- QR code displayed in hero panel (right side, 140px)
- Map pans to show node at 1/3 horizontal position (avoiding QR overlap)
- Replace Telemetry section with Tags card in grid layout
- Consolidate First Seen, Last Seen, Location into single row
- Add configurable offset support to map-node.js (offsetX, offsetY)
- Add configurable size support to qrcode-init.js
2026-02-08 23:16:13 +00:00
JingleManSweep
7de6520ae7 Merge pull request #83 from ipnet-mesh/feat/js-filter-submit
Add auto-submit for filter controls on list pages
2026-02-08 22:11:10 +00:00
Louis King
5b8b2eda10 Fix mixed content blocking for static assets behind reverse proxy
Add ProxyHeadersMiddleware to trust X-Forwarded-Proto headers from
reverse proxies. This ensures url_for() generates HTTPS URLs when
the app is accessed via HTTPS through nginx or similar proxies.

Without this, static assets (CSS, JS) were blocked by browsers as
mixed content when the site was served over HTTPS.
2026-02-08 22:08:04 +00:00
Louis King
042a1b04fa Add auto-submit for filter controls on list pages
Filter forms now auto-submit when select dropdowns change or when
Enter is pressed in text inputs. Uses a data-auto-submit attribute
pattern for consistency with existing data attribute conventions.
2026-02-08 21:53:35 +00:00
JingleManSweep
5832cbf53a Merge pull request #82 from ipnet-mesh/chore/tidy-html-output
Refactored inline styles/SVG/scripts, improved SEO
2026-02-08 21:45:30 +00:00
Louis King
c540e15432 Improve HTML output and SEO title tags
- Add Jinja2 whitespace control (trim_blocks, lstrip_blocks) to
  eliminate excessive newlines in rendered HTML output
- Reverse title tag order to "Page - Brand" for better SEO (specific
  content first, brand name second to avoid truncation)
- Add dynamic titles for node detail pages using node name
- Standardize UI text: Dashboard, Advertisements, Map, Members
- Remove refresh button from dashboard page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 21:40:19 +00:00
Louis King
6b1b277c6c Refactor HTML output: extract inline CSS, JS, and SVGs
Extract inline styles, JavaScript, and SVG icons from templates into
reusable external resources for improved maintainability and caching.

New static files:
- static/css/app.css: Custom CSS (scrollbar, prose, animations, Leaflet)
- static/js/charts.js: Chart.js helpers with shared colors/options
- static/js/map-main.js: Full map page functionality
- static/js/map-node.js: Node detail page map
- static/js/qrcode-init.js: QR code generation

New icon macros in macros/icons.html:
- icon_info, icon_alert, icon_chart, icon_refresh, icon_menu
- icon_github, icon_globe, icon_error, icon_channel
- icon_success, icon_lock, icon_user, icon_email, icon_tag, icon_users

Updated templates to use external resources and icon macros:
- base.html, home.html, dashboard.html, map.html, node_detail.html
- nodes.html, messages.html, advertisements.html, members.html
- errors/404.html, admin/*.html

Net reduction: ~700 lines of inline code removed from templates.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 21:23:06 +00:00
Louis King
470c374f11 Remove redundant Show Chat Nodes checkbox from map
The Node Type dropdown already provides chat node filtering,
making the separate checkbox unnecessary.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 21:06:25 +00:00
Louis King
71859b2168 Adjust map zoom levels for mobile devices
- Mobile portrait (< 480px): padding [50, 50] for wider view
- Mobile landscape (< 768px): padding [75, 75]
- Desktop: padding [100, 100]

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 21:01:29 +00:00
Louis King
3d7ed53df3 Improve map UI and add QR code to node detail page
Map improvements:
- Change non-infra nodes from emojis to subtle blue circles
- Add "Show Chat Nodes" checkbox (hidden by default)
- Fix z-index for hovered marker labels
- Increase zoom on mobile devices
- Simplify legend to show Infrastructure and Node icons

Node detail page:
- Add QR code for meshcore:// contact protocol
- Move activity (first/last seen) to title row
- QR code positioned under public key with white background
- Protocol: meshcore://contact/add?name=<name>&public_key=<key>&type=<n>
- Type mapping: chat=1, repeater=2, room=3, sensor=4

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 20:51:25 +00:00
Louis King
ceaef9178a Fixed map z-order 2026-02-07 20:13:24 +00:00
JingleManSweep
5ccb077188 Merge pull request #81 from ipnet-mesh/feat/public-node-map
Enhance map page with GPS fallback, infrastructure filter, and UI improvements
2026-02-07 20:09:13 +00:00
Louis King
8f660d6b94 Enhance map page with GPS fallback, infrastructure filter, and UI improvements
- Add GPS coordinate fallback: use tag coords, fall back to model coords
- Filter out nodes at (0, 0) coordinates (likely unset defaults)
- Add "Show" filter to toggle between All Nodes and Infrastructure Only
- Add "Show Labels" checkbox (labels hidden by default, appear on hover)
- Infrastructure nodes display network logo instead of emoji
- Add radius-based bounds filtering (20km) to prevent outlier zoom issues
- Position labels underneath pins, centered with transparent background
- Calculate and return infra_center for infrastructure node focus
- Initial map view focuses on infrastructure nodes when available
- Update popup button to outline style
- Add comprehensive tests for new functionality

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 20:05:56 +00:00
103 changed files with 9571 additions and 5488 deletions

View 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
---

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -107,6 +107,17 @@ MESHCORE_DEVICE_NAME=
NODE_ADDRESS=
NODE_ADDRESS_SENDER=
# -------------------
# Contact Cleanup Settings (RECEIVER mode only)
# -------------------
# Automatic removal of stale contacts from the MeshCore companion node
# Enable automatic removal of stale contacts from companion node
CONTACT_CLEANUP_ENABLED=true
# Remove contacts not advertised for this many days
CONTACT_CLEANUP_DAYS=7
# =============================================================================
# COLLECTOR SETTINGS
# =============================================================================
@@ -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,11 +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
@@ -208,6 +277,20 @@ NETWORK_RADIO_CONFIG=
# If not set, a default welcome message is shown
NETWORK_WELCOME_TEXT=
# -------------------
# Feature Flags
# -------------------
# Control which pages are visible in the web dashboard
# Set to false to completely hide a page (nav, routes, sitemap, robots.txt)
# FEATURE_DASHBOARD=true
# FEATURE_NODES=true
# FEATURE_ADVERTISEMENTS=true
# FEATURE_MESSAGES=true
# FEATURE_MAP=true
# FEATURE_MEMBERS=true
# FEATURE_PAGES=true
# -------------------
# Contact Information
# -------------------
@@ -216,3 +299,4 @@ NETWORK_WELCOME_TEXT=
NETWORK_CONTACT_EMAIL=
NETWORK_CONTACT_DISCORD=
NETWORK_CONTACT_GITHUB=
NETWORK_CONTACT_YOUTUBE=

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

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

View File

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

43
.github/workflows/claude.yml vendored Normal file
View 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:*)'

View File

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

27
.github/workflows/issue-triage.yml vendored Normal file
View 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 }}

View File

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

View File

@@ -1 +1 @@
3.13
3.14

192
AGENTS.md
View File

@@ -13,7 +13,13 @@ This document provides context and guidelines for AI coding assistants working o
* You MUST install all project dependencies using `pip install -e ".[dev]"` command`
* You MUST install `pre-commit` for quality checks
* Before commiting:
- Run tests with `pytest` to ensure recent changes haven't broken anything
- Run **targeted tests** for the components you changed, not the full suite:
- `pytest tests/test_web/` for web-only changes (templates, static JS, web routes)
- `pytest tests/test_api/` for API changes
- `pytest tests/test_collector/` for collector changes
- `pytest tests/test_interface/` for interface/sender/receiver changes
- `pytest tests/test_common/` for common models/schemas/config changes
- Only run the full `pytest` if changes span multiple components
- Run `pre-commit run --all-files` to perform all quality checks
## Project Overview
@@ -46,7 +52,8 @@ MeshCore Hub is a Python 3.13+ monorepo for managing and orchestrating MeshCore
| REST API | FastAPI |
| MQTT Client | paho-mqtt |
| MeshCore Interface | meshcore |
| Templates | Jinja2 |
| Templates | Jinja2 (server), lit-html (SPA) |
| Frontend | ES Modules SPA with client-side routing |
| CSS Framework | Tailwind CSS + DaisyUI |
| Testing | pytest, pytest-asyncio |
| Formatting | black |
@@ -274,17 +281,26 @@ 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
│ ├── routes/ # Page routes
├── members.py # Members page
│ └── ...
├── templates/ # Jinja2 templates
└── static/ # CSS, JS
│ ├── pages.py # Custom markdown page loader
│ ├── templates/ # Jinja2 templates (spa.html shell)
└── static/
├── css/app.css # Custom styles
└── js/spa/ # SPA frontend (ES modules)
│ ├── app.js # Entry point, route registration
│ ├── router.js # Client-side History API router
│ ├── api.js # API fetch helper
│ ├── components.js # Shared UI components (lit-html)
│ ├── icons.js # SVG icon functions (lit-html)
│ └── pages/ # Page modules (lazy-loaded)
│ ├── home.js, dashboard.js, nodes.js, ...
│ └── admin/ # Admin page modules
├── tests/
│ ├── conftest.py
│ ├── test_common/
@@ -296,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
@@ -341,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
@@ -417,13 +460,121 @@ async def client(db_session):
5. Add Alembic migration if schema changed
6. Add tests in `tests/test_collector/`
### Adding a New SPA Page
The web dashboard is a Single Page Application. Pages are ES modules loaded by the client-side router.
1. Create a page module in `web/static/js/spa/pages/` (e.g., `my-page.js`)
2. Export an `async function render(container, params, router)` that renders into `container` using `litRender(html\`...\`, container)`
3. Register the route in `web/static/js/spa/app.js` with `router.addRoute('/my-page', pageHandler(pages.myPage))`
4. Add the page title to `updatePageTitle()` in `app.js`
5. Add a nav link in `web/templates/spa.html` (both mobile and desktop menus)
**Key patterns:**
- Import `html`, `litRender`, `nothing` from `../components.js` (re-exports lit-html)
- Use `apiGet()` from `../api.js` for API calls
- For list pages with filters, use the `renderPage()` pattern: render the page header immediately, then re-render with the filter form + results after fetch (keeps the form out of the shell to avoid layout shift from data-dependent filter selects)
- Old page content stays visible until data is ready (navbar spinner indicates loading)
- Use `pageColors` from `components.js` for section-specific colors (reads CSS custom properties from `app.css`)
- Return a cleanup function if the page creates resources (e.g., Leaflet maps, Chart.js instances)
### Internationalization (i18n)
The web dashboard supports internationalization via JSON translation files. The default language is English.
**Translation files location:** `src/meshcore_hub/web/static/locales/`
**Key files:**
- `en.json` - English translations (reference implementation)
- `languages.md` - Comprehensive translation reference guide for translators
**Using translations in JavaScript:**
Import the `t()` function from `components.js`:
```javascript
import { t } from '../components.js';
// Simple translation
const label = t('common.save'); // "Save"
// Translation with variable interpolation
const title = t('common.add_entity', { entity: t('entities.node') }); // "Add Node"
// Composed patterns for consistency
const emptyMsg = t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() }); // "No nodes found"
```
**Translation architecture:**
1. **Entity-based composition:** Core entity names (`entities.*`) are referenced by composite patterns for consistency
2. **Reusable patterns:** Common UI patterns (`common.*`) use `{{variable}}` interpolation for dynamic content
3. **Separation of concerns:**
- Keys without `_label` suffix = table headers (title case, no colon)
- Keys with `_label` suffix = inline labels (sentence case, with colon)
**When adding/modifying translations:**
1. **Add new keys** to `en.json` following existing patterns:
- Use composition when possible (reference `entities.*` in `common.*` patterns)
- Group related keys by section (e.g., `admin_members.*`, `admin_node_tags.*`)
- Use `{{variable}}` syntax for dynamic content
2. **Update `languages.md`** with:
- Key name, English value, and usage context
- Variable descriptions if using interpolation
- Notes about HTML content or special formatting
3. **Add tests** in `tests/test_common/test_i18n.py`:
- Test new interpolation patterns
- Test required sections if adding new top-level sections
- Test composed patterns with entity references
4. **Run i18n tests:**
```bash
pytest tests/test_common/test_i18n.py -v
```
**Best practices:**
- **Avoid duplication:** Use `common.*` patterns instead of duplicating similar strings
- **Compose with entities:** Reference `entities.*` keys in patterns rather than hardcoding entity names
- **Preserve variables:** Keep `{{variable}}` placeholders unchanged when translating
- **Test composition:** Verify patterns work with all entity types (singular/plural, lowercase/uppercase)
- **Document context:** Always update `languages.md` so translators understand usage
**Example - adding a new entity and patterns:**
```javascript
// 1. Add entity to en.json
"entities": {
"sensor": "Sensor"
}
// 2. Use with existing common patterns
t('common.add_entity', { entity: t('entities.sensor') }) // "Add Sensor"
t('common.no_entity_found', { entity: t('entities.sensors').toLowerCase() }) // "No sensors found"
// 3. Update languages.md with context
// 4. Add test to test_i18n.py
```
**Translation loading:**
The i18n system (`src/meshcore_hub/common/i18n.py`) loads translations on startup:
- Defaults to English (`en`)
- Falls back to English for missing keys
- Returns the key itself if translation not found
For full translation guidelines, see `src/meshcore_hub/web/static/locales/languages.md`.
### Adding a New Database Model
1. Create model in `common/models/`
2. Export in `common/models/__init__.py`
3. Create Alembic migration: `alembic revision --autogenerate -m "description"`
3. Create Alembic migration: `meshcore-hub db revision --autogenerate -m "description"`
4. Review and adjust migration file
5. Test migration: `alembic upgrade head`
5. Test migration: `meshcore-hub db upgrade`
### Running the Development Environment
@@ -445,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
@@ -460,6 +611,12 @@ Key variables:
- `MQTT_TLS` - Enable TLS/SSL for MQTT (default: `false`)
- `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys
- `WEB_ADMIN_ENABLED` - Enable admin interface at /a/ (default: `false`, requires auth proxy)
- `WEB_THEME` - Default theme for the web dashboard (default: `dark`, options: `dark`, `light`). Users can override via the theme toggle in the navbar, which persists their preference in browser localStorage.
- `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.
@@ -680,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

View File

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

View File

@@ -1,9 +1,18 @@
# MeshCore Hub
[![CI](https://github.com/ipnet-mesh/meshcore-hub/actions/workflows/ci.yml/badge.svg)](https://github.com/ipnet-mesh/meshcore-hub/actions/workflows/ci.yml)
[![Docker](https://github.com/ipnet-mesh/meshcore-hub/actions/workflows/docker.yml/badge.svg)](https://github.com/ipnet-mesh/meshcore-hub/actions/workflows/docker.yml)
[![BuyMeACoffee](https://raw.githubusercontent.com/pachadotdev/buymeacoffee-badges/main/bmc-donate-yellow.svg)](https://www.buymeacoffee.com/jinglemansweep)
Python 3.13+ platform for managing and orchestrating MeshCore mesh networks.
![MeshCore Hub Web Dashboard](docs/images/web.png)
> [!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:
@@ -13,7 +22,7 @@ MeshCore Hub provides a complete solution for monitoring, collecting, and intera
| **Interface** | Connects to MeshCore companion nodes via Serial/USB, bridges events to/from MQTT |
| **Collector** | Subscribes to MQTT events and persists them to a database |
| **API** | REST API for querying data and sending commands to the network |
| **Web Dashboard** | User-friendly web interface for visualizing network status |
| **Web Dashboard** | Single Page Application (SPA) for visualizing network status |
## Architecture
@@ -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,7 +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) |
@@ -338,8 +363,25 @@ The collector automatically cleans up old event data and inactive nodes:
| `NETWORK_CONTACT_EMAIL` | *(none)* | Contact email address |
| `NETWORK_CONTACT_DISCORD` | *(none)* | Discord server link |
| `NETWORK_CONTACT_GITHUB` | *(none)* | GitHub repository URL |
| `NETWORK_CONTACT_YOUTUBE` | *(none)* | YouTube channel URL |
| `CONTENT_HOME` | `./content` | Directory containing custom content (pages/, media/) |
#### Feature Flags
Control which pages are visible in the web dashboard. Disabled features are fully hidden: removed from navigation, return 404 on their routes, and excluded from sitemap/robots.txt.
| Variable | Default | Description |
|----------|---------|-------------|
| `FEATURE_DASHBOARD` | `true` | Enable the `/dashboard` page |
| `FEATURE_NODES` | `true` | Enable the `/nodes` pages (list, detail, short links) |
| `FEATURE_ADVERTISEMENTS` | `true` | Enable the `/advertisements` page |
| `FEATURE_MESSAGES` | `true` | Enable the `/messages` page |
| `FEATURE_MAP` | `true` | Enable the `/map` page and `/map/data` endpoint |
| `FEATURE_MEMBERS` | `true` | Enable the `/members` page |
| `FEATURE_PAGES` | `true` | Enable custom markdown pages |
**Dependencies:** Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled.
### Custom Content
The web dashboard supports custom content including markdown pages and media files. Content is organized in subdirectories:
@@ -438,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:
@@ -501,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
@@ -524,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
@@ -596,16 +646,20 @@ meshcore-hub/
│ ├── collector/ # MQTT event collector
│ ├── api/ # REST API
│ └── web/ # Web dashboard
│ ├── templates/ # Jinja2 templates (SPA shell)
│ └── static/
│ ├── js/spa/ # SPA frontend (ES modules, lit-html)
│ └── locales/ # Translation files (en.json, languages.md)
├── tests/ # Test suite
├── alembic/ # Database migrations
├── etc/ # Configuration files (mosquitto.conf)
├── example/ # Example files for testing
├── 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/)
@@ -645,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

View File

@@ -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:-}
@@ -259,8 +263,18 @@ services:
- NETWORK_CONTACT_EMAIL=${NETWORK_CONTACT_EMAIL:-}
- NETWORK_CONTACT_DISCORD=${NETWORK_CONTACT_DISCORD:-}
- NETWORK_CONTACT_GITHUB=${NETWORK_CONTACT_GITHUB:-}
- NETWORK_CONTACT_YOUTUBE=${NETWORK_CONTACT_YOUTUBE:-}
- NETWORK_WELCOME_TEXT=${NETWORK_WELCOME_TEXT:-}
- CONTENT_HOME=/content
- TZ=${TZ:-UTC}
# Feature flags (set to false to disable specific pages)
- FEATURE_DASHBOARD=${FEATURE_DASHBOARD:-true}
- FEATURE_NODES=${FEATURE_NODES:-true}
- FEATURE_ADVERTISEMENTS=${FEATURE_ADVERTISEMENTS:-true}
- FEATURE_MESSAGES=${FEATURE_MESSAGES:-true}
- FEATURE_MAP=${FEATURE_MAP:-true}
- FEATURE_MEMBERS=${FEATURE_MEMBERS:-true}
- FEATURE_PAGES=${FEATURE_PAGES:-true}
command: ["web"]
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
@@ -314,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
# ==========================================================================
@@ -324,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

View 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
View 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."

View 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']

View File

@@ -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
View File

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

View File

@@ -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:

View File

@@ -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...")

View 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",
)

View File

@@ -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,

View File

@@ -253,6 +253,28 @@ class WebSettings(CommonSettings):
web_host: str = Field(default="0.0.0.0", description="Web server host")
web_port: int = Field(default=8080, description="Web server port")
# Timezone for date/time display (uses standard TZ environment variable)
tz: str = Field(default="UTC", description="Timezone for displaying dates/times")
# Theme (dark or light, default dark)
web_theme: str = Field(
default="dark",
description="Default theme for the web dashboard (dark or light)",
)
# Locale / language (default: English)
web_locale: str = Field(
default="en",
description="Locale/language for the web dashboard (e.g. 'en')",
)
# Auto-refresh interval for list pages
web_auto_refresh_seconds: int = Field(
default=30,
description="Auto-refresh interval in seconds for list pages (0 to disable)",
ge=0,
)
# Admin interface (disabled by default for security)
web_admin_enabled: bool = Field(
default=False,
@@ -291,16 +313,59 @@ class WebSettings(CommonSettings):
network_contact_github: Optional[str] = Field(
default=None, description="GitHub repository URL"
)
network_contact_youtube: Optional[str] = Field(
default=None, description="YouTube channel URL"
)
network_welcome_text: Optional[str] = Field(
default=None, description="Welcome text for homepage"
)
# Feature flags (control which pages are visible in the web dashboard)
feature_dashboard: bool = Field(
default=True, description="Enable the /dashboard page"
)
feature_nodes: bool = Field(default=True, description="Enable the /nodes pages")
feature_advertisements: bool = Field(
default=True, description="Enable the /advertisements page"
)
feature_messages: bool = Field(
default=True, description="Enable the /messages page"
)
feature_map: bool = Field(
default=True, description="Enable the /map page and /map/data endpoint"
)
feature_members: bool = Field(default=True, description="Enable the /members page")
feature_pages: bool = Field(
default=True, description="Enable custom markdown pages"
)
# Content directory (contains pages/ and media/ subdirectories)
content_home: Optional[str] = Field(
default=None,
description="Directory containing custom content (pages/, media/) (default: ./content)",
)
@property
def features(self) -> dict[str, bool]:
"""Get feature flags as a dictionary.
Automatic dependencies:
- Dashboard requires at least one of nodes/advertisements/messages.
- Map requires nodes (map displays node locations).
"""
has_dashboard_content = (
self.feature_nodes or self.feature_advertisements or self.feature_messages
)
return {
"dashboard": self.feature_dashboard and has_dashboard_content,
"nodes": self.feature_nodes,
"advertisements": self.feature_advertisements,
"messages": self.feature_messages,
"map": self.feature_map and self.feature_nodes,
"members": self.feature_members,
"pages": self.feature_pages,
}
@property
def effective_content_home(self) -> str:
"""Get the effective content home directory."""

View 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

View File

@@ -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")

View File

@@ -1,19 +1,24 @@
"""FastAPI application for MeshCore Hub Web Dashboard."""
"""FastAPI application for MeshCore Hub Web Dashboard (SPA)."""
import json
import logging
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import AsyncGenerator
from typing import Any, AsyncGenerator
from zoneinfo import ZoneInfo
import httpx
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, Response
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException as StarletteHTTPException
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
from meshcore_hub import __version__
from meshcore_hub.common.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__)
@@ -50,6 +55,74 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
logger.info("Web dashboard stopped")
def _build_config_json(app: FastAPI, request: Request) -> str:
"""Build the JSON config object to embed in the SPA shell.
Args:
app: The FastAPI application instance.
request: The current HTTP request.
Returns:
JSON string with app configuration.
"""
# Parse radio config
radio_config = RadioConfig.from_config_string(app.state.network_radio_config)
radio_config_dict = None
if radio_config:
radio_config_dict = {
"profile": radio_config.profile,
"frequency": radio_config.frequency,
"bandwidth": radio_config.bandwidth,
"spreading_factor": radio_config.spreading_factor,
"coding_rate": radio_config.coding_rate,
"tx_power": radio_config.tx_power,
}
# Get feature flags
features = app.state.features
# Get custom pages for navigation (empty when pages feature is disabled)
page_loader = app.state.page_loader
custom_pages = (
[
{
"slug": p.slug,
"title": p.title,
"url": p.url,
"menu_order": p.menu_order,
}
for p in page_loader.get_menu_pages()
]
if features.get("pages", True)
else []
)
config = {
"network_name": app.state.network_name,
"network_city": app.state.network_city,
"network_country": app.state.network_country,
"network_radio_config": radio_config_dict,
"network_contact_email": app.state.network_contact_email,
"network_contact_discord": app.state.network_contact_discord,
"network_contact_github": app.state.network_contact_github,
"network_contact_youtube": app.state.network_contact_youtube,
"network_welcome_text": app.state.network_welcome_text,
"admin_enabled": app.state.admin_enabled,
"features": features,
"custom_pages": custom_pages,
"logo_url": app.state.logo_url,
"version": __version__,
"timezone": app.state.timezone_abbr,
"timezone_iana": app.state.timezone,
"is_authenticated": bool(request.headers.get("X-Forwarded-User")),
"default_theme": app.state.web_theme,
"locale": app.state.web_locale,
"auto_refresh_seconds": app.state.auto_refresh_seconds,
}
return json.dumps(config)
def create_app(
api_url: str | None = None,
api_key: str | None = None,
@@ -61,7 +134,9 @@ def create_app(
network_contact_email: str | None = None,
network_contact_discord: str | None = None,
network_contact_github: str | None = None,
network_contact_youtube: str | None = None,
network_welcome_text: str | None = None,
features: dict[str, bool] | None = None,
) -> FastAPI:
"""Create and configure the web dashboard application.
@@ -79,7 +154,9 @@ def create_app(
network_contact_email: Contact email address
network_contact_discord: Discord invite/server info
network_contact_github: GitHub repository URL
network_contact_youtube: YouTube channel URL
network_welcome_text: Welcome text for homepage
features: Feature flags dict (default: all enabled from settings)
Returns:
Configured FastAPI application
@@ -98,7 +175,23 @@ def create_app(
redoc_url=None,
)
# Trust proxy headers (X-Forwarded-Proto, X-Forwarded-For) for HTTPS detection
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
# Add cache control headers based on resource type
app.add_middleware(CacheControlMiddleware)
# Load i18n translations
app.state.web_locale = settings.web_locale or "en"
load_locale(app.state.web_locale)
# Auto-refresh interval
app.state.auto_refresh_seconds = settings.web_auto_refresh_seconds
# Store configuration in app state (use args if provided, else settings)
app.state.web_theme = (
settings.web_theme if settings.web_theme in ("dark", "light") else "dark"
)
app.state.api_url = api_url or settings.api_base_url
app.state.api_key = api_key or settings.api_key
app.state.admin_enabled = (
@@ -119,14 +212,46 @@ def create_app(
app.state.network_contact_github = (
network_contact_github or settings.network_contact_github
)
app.state.network_contact_youtube = (
network_contact_youtube or settings.network_contact_youtube
)
app.state.network_welcome_text = (
network_welcome_text or settings.network_welcome_text
)
# Set up templates
# Store feature flags with automatic dependencies:
# - Dashboard requires at least one of nodes/advertisements/messages
# - Map requires nodes (map displays node locations)
effective_features = features if features is not None else settings.features
overrides: dict[str, bool] = {}
has_dashboard_content = (
effective_features.get("nodes", True)
or effective_features.get("advertisements", True)
or effective_features.get("messages", True)
)
if not has_dashboard_content:
overrides["dashboard"] = False
if not effective_features.get("nodes", True):
overrides["map"] = False
if overrides:
effective_features = {**effective_features, **overrides}
app.state.features = effective_features
# Set up templates (for SPA shell only)
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
templates.env.trim_blocks = True
templates.env.lstrip_blocks = True
templates.env.globals["t"] = t
app.state.templates = templates
# Compute timezone
app.state.timezone = settings.tz
try:
tz = ZoneInfo(settings.tz)
app.state.timezone_abbr = datetime.now(tz).strftime("%Z")
except Exception:
app.state.timezone_abbr = "UTC"
# Initialize page loader for custom markdown pages
page_loader = PageLoader(settings.effective_pages_home)
page_loader.load_pages()
@@ -149,12 +274,248 @@ def create_app(
if media_home.exists() and media_home.is_dir():
app.mount("/media", StaticFiles(directory=str(media_home)), name="media")
# Include routers
from meshcore_hub.web.routes import web_router
# --- API Proxy ---
@app.api_route(
"/api/{path:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
tags=["API Proxy"],
)
async def api_proxy(request: Request, path: str) -> Response:
"""Proxy API requests to the backend API server."""
client: httpx.AsyncClient = request.app.state.http_client
url = f"/api/{path}"
app.include_router(web_router)
# Forward query parameters
params = dict(request.query_params)
# Health check endpoint
# Forward body for write methods
body = None
if request.method in ("POST", "PUT", "PATCH"):
body = await request.body()
# Forward content-type header
headers: dict[str, str] = {}
if "content-type" in request.headers:
headers["content-type"] = request.headers["content-type"]
# Forward auth proxy headers for admin operations
for h in ("x-forwarded-user", "x-forwarded-email", "x-forwarded-groups"):
if h in request.headers:
headers[h] = request.headers[h]
# Block mutating requests from unauthenticated users when admin is
# enabled. OAuth2Proxy is expected to set X-Forwarded-User for
# authenticated sessions; without it, write operations must be
# rejected server-side to prevent auth bypass.
if (
request.method in ("POST", "PUT", "DELETE", "PATCH")
and request.app.state.admin_enabled
and not request.headers.get("x-forwarded-user")
):
return JSONResponse(
{"detail": "Authentication required"},
status_code=401,
)
try:
response = await client.request(
method=request.method,
url=url,
params=params,
content=body,
headers=headers,
)
# Filter response headers (remove hop-by-hop headers)
resp_headers: dict[str, str] = {}
for k, v in response.headers.items():
if k.lower() not in (
"transfer-encoding",
"connection",
"keep-alive",
"content-encoding",
):
resp_headers[k] = v
return Response(
content=response.content,
status_code=response.status_code,
headers=resp_headers,
)
except httpx.ConnectError:
return JSONResponse(
{"detail": "API server unavailable"},
status_code=502,
)
except Exception as e:
logger.error(f"API proxy error: {e}")
return JSONResponse(
{"detail": "API proxy error"},
status_code=502,
)
# --- Map Data Endpoint (server-side aggregation) ---
@app.get("/map/data", tags=["Map"])
async def map_data(request: Request) -> JSONResponse:
"""Return node location data as JSON for the map."""
if not request.app.state.features.get("map", True):
return JSONResponse({"detail": "Map feature is disabled"}, status_code=404)
nodes_with_location: list[dict[str, Any]] = []
members_list: list[dict[str, Any]] = []
members_by_id: dict[str, dict[str, Any]] = {}
error: str | None = None
total_nodes = 0
nodes_with_coords = 0
try:
# Fetch all members to build lookup by member_id
members_response = await request.app.state.http_client.get(
"/api/v1/members", params={"limit": 500}
)
if members_response.status_code == 200:
members_data = members_response.json()
for member in members_data.get("items", []):
member_info = {
"member_id": member.get("member_id"),
"name": member.get("name"),
"callsign": member.get("callsign"),
}
members_list.append(member_info)
if member.get("member_id"):
members_by_id[member["member_id"]] = member_info
# Fetch all nodes from API
response = await request.app.state.http_client.get(
"/api/v1/nodes", params={"limit": 500}
)
if response.status_code == 200:
data = response.json()
nodes = data.get("items", [])
total_nodes = len(nodes)
for node in nodes:
tags = node.get("tags", [])
tag_lat = None
tag_lon = None
friendly_name = None
role = None
node_member_id = None
for tag in tags:
key = tag.get("key")
if key == "lat":
try:
tag_lat = float(tag.get("value"))
except (ValueError, TypeError):
pass
elif key == "lon":
try:
tag_lon = float(tag.get("value"))
except (ValueError, TypeError):
pass
elif key == "friendly_name":
friendly_name = tag.get("value")
elif key == "role":
role = tag.get("value")
elif key == "member_id":
node_member_id = tag.get("value")
lat = tag_lat if tag_lat is not None else node.get("lat")
lon = tag_lon if tag_lon is not None else node.get("lon")
if lat is None or lon is None:
continue
if lat == 0.0 and lon == 0.0:
continue
nodes_with_coords += 1
display_name = (
friendly_name
or node.get("name")
or node.get("public_key", "")[:12]
)
public_key = node.get("public_key")
owner = (
members_by_id.get(node_member_id) if node_member_id else None
)
nodes_with_location.append(
{
"public_key": public_key,
"name": display_name,
"adv_type": node.get("adv_type"),
"lat": lat,
"lon": lon,
"last_seen": node.get("last_seen"),
"role": role,
"is_infra": role == "infra",
"member_id": node_member_id,
"owner": owner,
}
)
else:
error = f"API returned status {response.status_code}"
except Exception as e:
error = str(e)
logger.warning(f"Failed to fetch nodes for map: {e}")
infra_nodes = [n for n in nodes_with_location if n.get("is_infra")]
infra_count = len(infra_nodes)
center_lat = 0.0
center_lon = 0.0
if nodes_with_location:
center_lat = sum(n["lat"] for n in nodes_with_location) / len(
nodes_with_location
)
center_lon = sum(n["lon"] for n in nodes_with_location) / len(
nodes_with_location
)
infra_center: dict[str, float] | None = None
if infra_nodes:
infra_center = {
"lat": sum(n["lat"] for n in infra_nodes) / len(infra_nodes),
"lon": sum(n["lon"] for n in infra_nodes) / len(infra_nodes),
}
return JSONResponse(
{
"nodes": nodes_with_location,
"members": members_list,
"center": {"lat": center_lat, "lon": center_lon},
"infra_center": infra_center,
"debug": {
"total_nodes": total_nodes,
"nodes_with_coords": nodes_with_coords,
"infra_nodes": infra_count,
"error": error,
},
}
)
# --- Custom Pages API ---
@app.get("/spa/pages/{slug}", tags=["SPA"])
async def get_custom_page(request: Request, slug: str) -> JSONResponse:
"""Get a custom page by slug."""
if not request.app.state.features.get("pages", True):
return JSONResponse(
{"detail": "Pages feature is disabled"}, status_code=404
)
page_loader = request.app.state.page_loader
page = page_loader.get_page(slug)
if not page:
return JSONResponse({"detail": "Page not found"}, status_code=404)
return JSONResponse(
{
"slug": page.slug,
"title": page.title,
"content_html": page.content_html,
}
)
# --- Health Endpoints ---
@app.get("/health", tags=["Health"])
async def health() -> dict:
"""Basic health check."""
@@ -171,34 +532,69 @@ def create_app(
except Exception as e:
return {"status": "not_ready", "api": str(e)}
# --- SEO Endpoints ---
def _get_https_base_url(request: Request) -> str:
"""Get base URL, ensuring HTTPS is used for public-facing URLs."""
base_url = str(request.base_url).rstrip("/")
# Ensure HTTPS for sitemaps and robots.txt (SEO requires canonical URLs)
if base_url.startswith("http://"):
base_url = "https://" + base_url[7:]
return base_url
@app.get("/robots.txt", response_class=PlainTextResponse)
async def robots_txt(request: Request) -> str:
"""Serve robots.txt to control search engine crawling."""
"""Serve robots.txt."""
base_url = _get_https_base_url(request)
return f"User-agent: *\nDisallow:\n\nSitemap: {base_url}/sitemap.xml\n"
features = request.app.state.features
# Always disallow message and node detail pages
disallow_lines = [
"Disallow: /messages",
"Disallow: /nodes/",
]
# Add disallow for disabled features
feature_paths = {
"dashboard": "/dashboard",
"nodes": "/nodes",
"advertisements": "/advertisements",
"map": "/map",
"members": "/members",
"pages": "/pages",
}
for feature, path in feature_paths.items():
if not features.get(feature, True):
line = f"Disallow: {path}"
if line not in disallow_lines:
disallow_lines.append(line)
disallow_block = "\n".join(disallow_lines)
return (
f"User-agent: *\n"
f"{disallow_block}\n"
f"\n"
f"Sitemap: {base_url}/sitemap.xml\n"
)
@app.get("/sitemap.xml")
async def sitemap_xml(request: Request) -> Response:
"""Generate dynamic sitemap including all node pages."""
"""Generate dynamic sitemap."""
base_url = _get_https_base_url(request)
features = request.app.state.features
# Home is always included; other pages depend on feature flags
all_static_pages = [
("", "daily", "1.0", None),
("/dashboard", "hourly", "0.9", "dashboard"),
("/nodes", "hourly", "0.9", "nodes"),
("/advertisements", "hourly", "0.8", "advertisements"),
("/map", "daily", "0.7", "map"),
("/members", "weekly", "0.6", "members"),
]
# Static pages
static_pages = [
("", "daily", "1.0"),
("/dashboard", "hourly", "0.9"),
("/nodes", "hourly", "0.9"),
("/advertisements", "hourly", "0.8"),
("/messages", "hourly", "0.8"),
("/map", "daily", "0.7"),
("/members", "weekly", "0.6"),
(path, freq, prio)
for path, freq, prio, feature in all_static_pages
if feature is None or features.get(feature, True)
]
urls = []
@@ -211,41 +607,16 @@ def create_app(
f" </url>"
)
# Fetch infrastructure nodes for dynamic pages
try:
response = await request.app.state.http_client.get(
"/api/v1/nodes", params={"limit": 500, "role": "infra"}
)
if response.status_code == 200:
nodes = response.json().get("items", [])
for node in nodes:
public_key = node.get("public_key")
if public_key:
# Use 8-char prefix (route handles redirect to full key)
urls.append(
f" <url>\n"
f" <loc>{base_url}/nodes/{public_key[:8]}</loc>\n"
f" <changefreq>daily</changefreq>\n"
f" <priority>0.5</priority>\n"
f" </url>"
)
else:
logger.warning(
f"Failed to fetch nodes for sitemap: {response.status_code}"
if features.get("pages", True):
page_loader = request.app.state.page_loader
for page in page_loader.get_menu_pages():
urls.append(
f" <url>\n"
f" <loc>{base_url}{page.url}</loc>\n"
f" <changefreq>weekly</changefreq>\n"
f" <priority>0.6</priority>\n"
f" </url>"
)
except Exception as e:
logger.warning(f"Failed to fetch nodes for sitemap: {e}")
# Add custom pages to sitemap
page_loader = request.app.state.page_loader
for page in page_loader.get_menu_pages():
urls.append(
f" <url>\n"
f" <loc>{base_url}{page.url}</loc>\n"
f" <changefreq>weekly</changefreq>\n"
f" <priority>0.6</priority>\n"
f" </url>"
)
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
@@ -256,55 +627,39 @@ def create_app(
return Response(content=xml, media_type="application/xml")
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(
request: Request, exc: StarletteHTTPException
) -> HTMLResponse:
"""Handle HTTP exceptions with custom error pages."""
if exc.status_code == 404:
context = get_network_context(request)
context["request"] = request
context["detail"] = exc.detail if exc.detail != "Not Found" else None
return templates.TemplateResponse(
"errors/404.html", context, status_code=404
)
# For other errors, return a simple response
return HTMLResponse(
content=f"<h1>{exc.status_code}</h1><p>{exc.detail}</p>",
status_code=exc.status_code,
# --- SPA Catch-All (MUST be last) ---
@app.api_route("/{path:path}", methods=["GET"], tags=["SPA"])
async def spa_catchall(request: Request, path: str = "") -> HTMLResponse:
"""Serve the SPA shell for all non-API routes."""
templates_inst: Jinja2Templates = request.app.state.templates
features = request.app.state.features
page_loader = request.app.state.page_loader
custom_pages = (
page_loader.get_menu_pages() if features.get("pages", True) else []
)
config_json = _build_config_json(request.app, request)
return templates_inst.TemplateResponse(
"spa.html",
{
"request": request,
"network_name": request.app.state.network_name,
"network_city": request.app.state.network_city,
"network_country": request.app.state.network_country,
"network_contact_email": request.app.state.network_contact_email,
"network_contact_discord": request.app.state.network_contact_discord,
"network_contact_github": request.app.state.network_contact_github,
"network_contact_youtube": request.app.state.network_contact_youtube,
"network_welcome_text": request.app.state.network_welcome_text,
"admin_enabled": request.app.state.admin_enabled,
"features": features,
"custom_pages": custom_pages,
"logo_url": request.app.state.logo_url,
"version": __version__,
"default_theme": request.app.state.web_theme,
"config_json": config_json,
},
)
return app
def get_templates(request: Request) -> Jinja2Templates:
"""Get templates from app state."""
templates: Jinja2Templates = request.app.state.templates
return templates
def get_network_context(request: Request) -> dict:
"""Get network configuration context for templates."""
# Parse radio config from comma-delimited string
radio_config = RadioConfig.from_config_string(
request.app.state.network_radio_config
)
# Get custom pages for navigation
page_loader = request.app.state.page_loader
custom_pages = page_loader.get_menu_pages()
return {
"network_name": request.app.state.network_name,
"network_city": request.app.state.network_city,
"network_country": request.app.state.network_country,
"network_radio_config": radio_config,
"network_contact_email": request.app.state.network_contact_email,
"network_contact_discord": request.app.state.network_contact_discord,
"network_contact_github": request.app.state.network_contact_github,
"network_welcome_text": request.app.state.network_welcome_text,
"admin_enabled": request.app.state.admin_enabled,
"custom_pages": custom_pages,
"logo_url": request.app.state.logo_url,
"version": __version__,
}

View File

@@ -88,6 +88,13 @@ import click
envvar="NETWORK_CONTACT_GITHUB",
help="GitHub repository URL",
)
@click.option(
"--network-contact-youtube",
type=str,
default=None,
envvar="NETWORK_CONTACT_YOUTUBE",
help="YouTube channel URL",
)
@click.option(
"--network-welcome-text",
type=str,
@@ -116,6 +123,7 @@ def web(
network_contact_email: str | None,
network_contact_discord: str | None,
network_contact_github: str | None,
network_contact_youtube: str | None,
network_welcome_text: str | None,
reload: bool,
) -> None:
@@ -175,6 +183,11 @@ def web(
if effective_city and effective_country:
click.echo(f"Location: {effective_city}, {effective_country}")
click.echo(f"Reload mode: {reload}")
disabled_features = [
name for name, enabled in settings.features.items() if not enabled
]
if disabled_features:
click.echo(f"Disabled features: {', '.join(disabled_features)}")
click.echo("=" * 50)
if reload:
@@ -201,6 +214,7 @@ def web(
network_contact_email=network_contact_email,
network_contact_discord=network_contact_discord,
network_contact_github=network_contact_github,
network_contact_youtube=network_contact_youtube,
network_welcome_text=network_welcome_text,
)

View 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

View File

@@ -1,29 +0,0 @@
"""Web routes for MeshCore Hub Dashboard."""
from fastapi import APIRouter
from meshcore_hub.web.routes.home import router as home_router
from meshcore_hub.web.routes.dashboard import router as dashboard_router
from meshcore_hub.web.routes.nodes import router as nodes_router
from meshcore_hub.web.routes.messages import router as messages_router
from meshcore_hub.web.routes.advertisements import router as advertisements_router
from meshcore_hub.web.routes.map import router as map_router
from meshcore_hub.web.routes.members import router as members_router
from meshcore_hub.web.routes.admin import router as admin_router
from meshcore_hub.web.routes.pages import router as pages_router
# Create main web router
web_router = APIRouter()
# Include all sub-routers
web_router.include_router(home_router)
web_router.include_router(dashboard_router)
web_router.include_router(nodes_router)
web_router.include_router(messages_router)
web_router.include_router(advertisements_router)
web_router.include_router(map_router)
web_router.include_router(members_router)
web_router.include_router(admin_router)
web_router.include_router(pages_router)
__all__ = ["web_router"]

View File

@@ -1,591 +0,0 @@
"""Admin page routes."""
import logging
from typing import Any, Optional
from urllib.parse import urlencode
from fastapi import APIRouter, Form, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from httpx import Response
from meshcore_hub.web.app import get_network_context, get_templates
def _build_redirect_url(
public_key: str,
message: Optional[str] = None,
error: Optional[str] = None,
) -> str:
"""Build a properly encoded redirect URL with optional message/error."""
params: dict[str, str] = {"public_key": public_key}
if message:
params["message"] = message
if error:
params["error"] = error
return f"/a/node-tags?{urlencode(params)}"
def _get_error_detail(response: Response) -> str:
"""Safely extract error detail from response JSON."""
try:
data: Any = response.json()
detail: str = data.get("detail", "Unknown error")
return detail
except Exception:
return "Unknown error"
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/a", tags=["admin"])
def _check_admin_enabled(request: Request) -> None:
"""Check if admin interface is enabled, raise 404 if not."""
if not getattr(request.app.state, "admin_enabled", False):
raise HTTPException(status_code=404, detail="Not Found")
def _get_auth_context(request: Request) -> dict:
"""Extract OAuth2Proxy authentication headers."""
return {
"auth_user": request.headers.get("X-Forwarded-User"),
"auth_groups": request.headers.get("X-Forwarded-Groups"),
"auth_email": request.headers.get("X-Forwarded-Email"),
"auth_username": request.headers.get("X-Forwarded-Preferred-Username"),
}
def _is_authenticated(request: Request) -> bool:
"""Check if user is authenticated via OAuth2Proxy headers."""
return bool(
request.headers.get("X-Forwarded-User")
or request.headers.get("X-Forwarded-Email")
)
def _require_auth(request: Request) -> None:
"""Require authentication, raise 403 if not authenticated."""
if not _is_authenticated(request):
raise HTTPException(status_code=403, detail="Access denied")
@router.get("/", response_class=HTMLResponse)
async def admin_home(request: Request) -> HTMLResponse:
"""Render the admin page with OAuth2Proxy user info."""
_check_admin_enabled(request)
templates = get_templates(request)
context = get_network_context(request)
context["request"] = request
context.update(_get_auth_context(request))
# Check if user is authenticated
if not _is_authenticated(request):
return templates.TemplateResponse(
"admin/access_denied.html", context, status_code=403
)
return templates.TemplateResponse("admin/index.html", context)
@router.get("/node-tags", response_class=HTMLResponse)
async def admin_node_tags(
request: Request,
public_key: Optional[str] = Query(None),
message: Optional[str] = Query(None),
error: Optional[str] = Query(None),
) -> HTMLResponse:
"""Admin page for managing node tags."""
_check_admin_enabled(request)
templates = get_templates(request)
context = get_network_context(request)
context["request"] = request
context.update(_get_auth_context(request))
# Check if user is authenticated
if not _is_authenticated(request):
return templates.TemplateResponse(
"admin/access_denied.html", context, status_code=403
)
# Flash messages from redirects
context["message"] = message
context["error"] = error
# Fetch all nodes for dropdown
nodes = []
try:
response = await request.app.state.http_client.get(
"/api/v1/nodes",
params={"limit": 100},
)
if response.status_code == 200:
data = response.json()
nodes = data.get("items", [])
# Sort nodes alphabetically by name (unnamed nodes at the end)
nodes.sort(
key=lambda n: (n.get("name") is None, (n.get("name") or "").lower())
)
except Exception as e:
logger.exception("Failed to fetch nodes: %s", e)
context["error"] = "Failed to fetch nodes"
context["nodes"] = nodes
context["selected_public_key"] = public_key
# Fetch tags for selected node
tags = []
selected_node = None
if public_key:
# Find the selected node in the list
for node in nodes:
if node.get("public_key") == public_key:
selected_node = node
break
try:
response = await request.app.state.http_client.get(
f"/api/v1/nodes/{public_key}/tags",
)
if response.status_code == 200:
tags = response.json()
elif response.status_code == 404:
context["error"] = "Node not found"
except Exception as e:
logger.exception("Failed to fetch tags: %s", e)
context["error"] = "Failed to fetch tags"
context["tags"] = tags
context["selected_node"] = selected_node
return templates.TemplateResponse("admin/node_tags.html", context)
@router.post("/node-tags", response_class=RedirectResponse)
async def admin_create_node_tag(
request: Request,
public_key: str = Form(...),
key: str = Form(...),
value: str = Form(""),
value_type: str = Form("string"),
) -> RedirectResponse:
"""Create a new node tag."""
_check_admin_enabled(request)
_require_auth(request)
try:
response = await request.app.state.http_client.post(
f"/api/v1/nodes/{public_key}/tags",
json={
"key": key,
"value": value or None,
"value_type": value_type,
},
)
if response.status_code == 201:
redirect_url = _build_redirect_url(
public_key, message=f"Tag '{key}' created successfully"
)
elif response.status_code == 409:
redirect_url = _build_redirect_url(
public_key, error=f"Tag '{key}' already exists"
)
elif response.status_code == 404:
redirect_url = _build_redirect_url(public_key, error="Node not found")
else:
redirect_url = _build_redirect_url(
public_key, error=_get_error_detail(response)
)
except Exception as e:
logger.exception("Failed to create tag: %s", e)
redirect_url = _build_redirect_url(public_key, error="Failed to create tag")
return RedirectResponse(url=redirect_url, status_code=303)
@router.post("/node-tags/update", response_class=RedirectResponse)
async def admin_update_node_tag(
request: Request,
public_key: str = Form(...),
key: str = Form(...),
value: str = Form(""),
value_type: str = Form("string"),
) -> RedirectResponse:
"""Update an existing node tag."""
_check_admin_enabled(request)
_require_auth(request)
try:
response = await request.app.state.http_client.put(
f"/api/v1/nodes/{public_key}/tags/{key}",
json={
"value": value or None,
"value_type": value_type,
},
)
if response.status_code == 200:
redirect_url = _build_redirect_url(
public_key, message=f"Tag '{key}' updated successfully"
)
elif response.status_code == 404:
redirect_url = _build_redirect_url(
public_key, error=f"Tag '{key}' not found"
)
else:
redirect_url = _build_redirect_url(
public_key, error=_get_error_detail(response)
)
except Exception as e:
logger.exception("Failed to update tag: %s", e)
redirect_url = _build_redirect_url(public_key, error="Failed to update tag")
return RedirectResponse(url=redirect_url, status_code=303)
@router.post("/node-tags/move", response_class=RedirectResponse)
async def admin_move_node_tag(
request: Request,
public_key: str = Form(...),
key: str = Form(...),
new_public_key: str = Form(...),
) -> RedirectResponse:
"""Move a node tag to a different node."""
_check_admin_enabled(request)
_require_auth(request)
try:
response = await request.app.state.http_client.put(
f"/api/v1/nodes/{public_key}/tags/{key}/move",
json={"new_public_key": new_public_key},
)
if response.status_code == 200:
# Redirect to the destination node after successful move
redirect_url = _build_redirect_url(
new_public_key, message=f"Tag '{key}' moved successfully"
)
elif response.status_code == 404:
# Stay on source node if not found
redirect_url = _build_redirect_url(
public_key, error=_get_error_detail(response)
)
elif response.status_code == 409:
redirect_url = _build_redirect_url(
public_key, error=f"Tag '{key}' already exists on destination node"
)
else:
redirect_url = _build_redirect_url(
public_key, error=_get_error_detail(response)
)
except Exception as e:
logger.exception("Failed to move tag: %s", e)
redirect_url = _build_redirect_url(public_key, error="Failed to move tag")
return RedirectResponse(url=redirect_url, status_code=303)
@router.post("/node-tags/delete", response_class=RedirectResponse)
async def admin_delete_node_tag(
request: Request,
public_key: str = Form(...),
key: str = Form(...),
) -> RedirectResponse:
"""Delete a node tag."""
_check_admin_enabled(request)
_require_auth(request)
try:
response = await request.app.state.http_client.delete(
f"/api/v1/nodes/{public_key}/tags/{key}",
)
if response.status_code == 204:
redirect_url = _build_redirect_url(
public_key, message=f"Tag '{key}' deleted successfully"
)
elif response.status_code == 404:
redirect_url = _build_redirect_url(
public_key, error=f"Tag '{key}' not found"
)
else:
redirect_url = _build_redirect_url(
public_key, error=_get_error_detail(response)
)
except Exception as e:
logger.exception("Failed to delete tag: %s", e)
redirect_url = _build_redirect_url(public_key, error="Failed to delete tag")
return RedirectResponse(url=redirect_url, status_code=303)
@router.post("/node-tags/copy-all", response_class=RedirectResponse)
async def admin_copy_all_tags(
request: Request,
public_key: str = Form(...),
dest_public_key: str = Form(...),
) -> RedirectResponse:
"""Copy all tags from one node to another."""
_check_admin_enabled(request)
_require_auth(request)
try:
response = await request.app.state.http_client.post(
f"/api/v1/nodes/{public_key}/tags/copy-to/{dest_public_key}",
)
if response.status_code == 200:
data = response.json()
copied = data.get("copied", 0)
skipped = data.get("skipped", 0)
if skipped > 0:
message = f"Copied {copied} tag(s), skipped {skipped} existing"
else:
message = f"Copied {copied} tag(s) successfully"
# Redirect to destination node to show copied tags
redirect_url = _build_redirect_url(dest_public_key, message=message)
elif response.status_code == 400:
redirect_url = _build_redirect_url(
public_key, error=_get_error_detail(response)
)
elif response.status_code == 404:
redirect_url = _build_redirect_url(
public_key, error=_get_error_detail(response)
)
else:
redirect_url = _build_redirect_url(
public_key, error=_get_error_detail(response)
)
except Exception as e:
logger.exception("Failed to copy tags: %s", e)
redirect_url = _build_redirect_url(public_key, error="Failed to copy tags")
return RedirectResponse(url=redirect_url, status_code=303)
@router.post("/node-tags/delete-all", response_class=RedirectResponse)
async def admin_delete_all_tags(
request: Request,
public_key: str = Form(...),
) -> RedirectResponse:
"""Delete all tags from a node."""
_check_admin_enabled(request)
_require_auth(request)
try:
response = await request.app.state.http_client.delete(
f"/api/v1/nodes/{public_key}/tags",
)
if response.status_code == 200:
data = response.json()
deleted = data.get("deleted", 0)
message = f"Deleted {deleted} tag(s) successfully"
redirect_url = _build_redirect_url(public_key, message=message)
elif response.status_code == 404:
redirect_url = _build_redirect_url(
public_key, error=_get_error_detail(response)
)
else:
redirect_url = _build_redirect_url(
public_key, error=_get_error_detail(response)
)
except Exception as e:
logger.exception("Failed to delete tags: %s", e)
redirect_url = _build_redirect_url(public_key, error="Failed to delete tags")
return RedirectResponse(url=redirect_url, status_code=303)
def _build_members_redirect_url(
message: Optional[str] = None,
error: Optional[str] = None,
) -> str:
"""Build a properly encoded redirect URL for members page with optional message/error."""
params: dict[str, str] = {}
if message:
params["message"] = message
if error:
params["error"] = error
if params:
return f"/a/members?{urlencode(params)}"
return "/a/members"
@router.get("/members", response_class=HTMLResponse)
async def admin_members(
request: Request,
message: Optional[str] = Query(None),
error: Optional[str] = Query(None),
) -> HTMLResponse:
"""Admin page for managing members."""
_check_admin_enabled(request)
templates = get_templates(request)
context = get_network_context(request)
context["request"] = request
context.update(_get_auth_context(request))
# Check if user is authenticated
if not _is_authenticated(request):
return templates.TemplateResponse(
"admin/access_denied.html", context, status_code=403
)
# Flash messages from redirects
context["message"] = message
context["error"] = error
# Fetch all members
members = []
try:
response = await request.app.state.http_client.get(
"/api/v1/members",
params={"limit": 500},
)
if response.status_code == 200:
data = response.json()
members = data.get("items", [])
# Sort members alphabetically by name
members.sort(key=lambda m: m.get("name", "").lower())
except Exception as e:
logger.exception("Failed to fetch members: %s", e)
context["error"] = "Failed to fetch members"
context["members"] = members
return templates.TemplateResponse("admin/members.html", context)
@router.post("/members", response_class=RedirectResponse)
async def admin_create_member(
request: Request,
name: str = Form(...),
member_id: str = Form(...),
callsign: Optional[str] = Form(None),
role: Optional[str] = Form(None),
description: Optional[str] = Form(None),
contact: Optional[str] = Form(None),
) -> RedirectResponse:
"""Create a new member."""
_check_admin_enabled(request)
_require_auth(request)
try:
# Build request payload
payload = {
"name": name,
"member_id": member_id,
}
if callsign:
payload["callsign"] = callsign
if role:
payload["role"] = role
if description:
payload["description"] = description
if contact:
payload["contact"] = contact
response = await request.app.state.http_client.post(
"/api/v1/members",
json=payload,
)
if response.status_code == 201:
redirect_url = _build_members_redirect_url(
message=f"Member '{name}' created successfully"
)
elif response.status_code == 409:
redirect_url = _build_members_redirect_url(
error=f"Member ID '{member_id}' already exists"
)
else:
redirect_url = _build_members_redirect_url(
error=_get_error_detail(response)
)
except Exception as e:
logger.exception("Failed to create member: %s", e)
redirect_url = _build_members_redirect_url(error="Failed to create member")
return RedirectResponse(url=redirect_url, status_code=303)
@router.post("/members/update", response_class=RedirectResponse)
async def admin_update_member(
request: Request,
id: str = Form(...),
name: Optional[str] = Form(None),
member_id: Optional[str] = Form(None),
callsign: Optional[str] = Form(None),
role: Optional[str] = Form(None),
description: Optional[str] = Form(None),
contact: Optional[str] = Form(None),
) -> RedirectResponse:
"""Update an existing member."""
_check_admin_enabled(request)
_require_auth(request)
try:
# Build update payload (only include non-None fields)
payload: dict[str, str | None] = {}
if name is not None:
payload["name"] = name
if member_id is not None:
payload["member_id"] = member_id
if callsign is not None:
payload["callsign"] = callsign if callsign else None
if role is not None:
payload["role"] = role if role else None
if description is not None:
payload["description"] = description if description else None
if contact is not None:
payload["contact"] = contact if contact else None
response = await request.app.state.http_client.put(
f"/api/v1/members/{id}",
json=payload,
)
if response.status_code == 200:
redirect_url = _build_members_redirect_url(
message="Member updated successfully"
)
elif response.status_code == 404:
redirect_url = _build_members_redirect_url(error="Member not found")
elif response.status_code == 409:
redirect_url = _build_members_redirect_url(
error=f"Member ID '{member_id}' already exists"
)
else:
redirect_url = _build_members_redirect_url(
error=_get_error_detail(response)
)
except Exception as e:
logger.exception("Failed to update member: %s", e)
redirect_url = _build_members_redirect_url(error="Failed to update member")
return RedirectResponse(url=redirect_url, status_code=303)
@router.post("/members/delete", response_class=RedirectResponse)
async def admin_delete_member(
request: Request,
id: str = Form(...),
) -> RedirectResponse:
"""Delete a member."""
_check_admin_enabled(request)
_require_auth(request)
try:
response = await request.app.state.http_client.delete(
f"/api/v1/members/{id}",
)
if response.status_code == 204:
redirect_url = _build_members_redirect_url(
message="Member deleted successfully"
)
elif response.status_code == 404:
redirect_url = _build_members_redirect_url(error="Member not found")
else:
redirect_url = _build_members_redirect_url(
error=_get_error_detail(response)
)
except Exception as e:
logger.exception("Failed to delete member: %s", e)
redirect_url = _build_members_redirect_url(error="Failed to delete member")
return RedirectResponse(url=redirect_url, status_code=303)

View File

@@ -1,99 +0,0 @@
"""Advertisements page route."""
import logging
from fastapi import APIRouter, Query, Request
from fastapi.responses import HTMLResponse
from meshcore_hub.web.app import get_network_context, get_templates
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/advertisements", response_class=HTMLResponse)
async def advertisements_list(
request: Request,
search: str | None = Query(None, description="Search term"),
member_id: str | None = Query(None, description="Filter by member"),
public_key: str | None = Query(None, description="Filter by node public key"),
page: int = Query(1, ge=1, description="Page number"),
limit: int = Query(50, ge=1, le=100, description="Items per page"),
) -> HTMLResponse:
"""Render the advertisements list page."""
templates = get_templates(request)
context = get_network_context(request)
context["request"] = request
# Calculate offset
offset = (page - 1) * limit
# Build query params
params: dict[str, int | str] = {"limit": limit, "offset": offset}
if search:
params["search"] = search
if member_id:
params["member_id"] = member_id
if public_key:
params["public_key"] = public_key
# Fetch advertisements from API
advertisements = []
total = 0
members = []
nodes = []
try:
# Fetch members for dropdown
members_response = await request.app.state.http_client.get(
"/api/v1/members", params={"limit": 100}
)
if members_response.status_code == 200:
members = members_response.json().get("items", [])
# Fetch nodes for dropdown
nodes_response = await request.app.state.http_client.get(
"/api/v1/nodes", params={"limit": 500}
)
if nodes_response.status_code == 200:
nodes = nodes_response.json().get("items", [])
# Sort nodes alphabetically by display name
def get_node_display_name(node: dict) -> str:
for tag in node.get("tags") or []:
if tag.get("key") == "name":
return str(tag.get("value", "")).lower()
return str(node.get("name") or node.get("public_key", "")).lower()
nodes.sort(key=get_node_display_name)
response = await request.app.state.http_client.get(
"/api/v1/advertisements", params=params
)
if response.status_code == 200:
data = response.json()
advertisements = data.get("items", [])
total = data.get("total", 0)
except Exception as e:
logger.warning(f"Failed to fetch advertisements from API: {e}")
context["api_error"] = str(e)
# Calculate pagination
total_pages = (total + limit - 1) // limit if total > 0 else 1
context.update(
{
"advertisements": advertisements,
"total": total,
"page": page,
"limit": limit,
"total_pages": total_pages,
"search": search or "",
"member_id": member_id or "",
"public_key": public_key or "",
"members": members,
"nodes": nodes,
}
)
return templates.TemplateResponse("advertisements.html", context)

View File

@@ -1,79 +0,0 @@
"""Dashboard page route."""
import json
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from meshcore_hub.web.app import get_network_context, get_templates
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request) -> HTMLResponse:
"""Render the dashboard page."""
templates = get_templates(request)
context = get_network_context(request)
context["request"] = request
# Fetch stats from API
stats = {
"total_nodes": 0,
"active_nodes": 0,
"total_messages": 0,
"messages_today": 0,
"total_advertisements": 0,
"advertisements_24h": 0,
"recent_advertisements": [],
"channel_message_counts": {},
}
# Fetch activity data for charts (7 days)
advert_activity = {"days": 7, "data": []}
message_activity = {"days": 7, "data": []}
node_count = {"days": 7, "data": []}
try:
response = await request.app.state.http_client.get("/api/v1/dashboard/stats")
if response.status_code == 200:
stats = response.json()
except Exception as e:
logger.warning(f"Failed to fetch stats from API: {e}")
context["api_error"] = str(e)
try:
response = await request.app.state.http_client.get(
"/api/v1/dashboard/activity", params={"days": 7}
)
if response.status_code == 200:
advert_activity = response.json()
except Exception as e:
logger.warning(f"Failed to fetch advertisement activity from API: {e}")
try:
response = await request.app.state.http_client.get(
"/api/v1/dashboard/message-activity", params={"days": 7}
)
if response.status_code == 200:
message_activity = response.json()
except Exception as e:
logger.warning(f"Failed to fetch message activity from API: {e}")
try:
response = await request.app.state.http_client.get(
"/api/v1/dashboard/node-count", params={"days": 7}
)
if response.status_code == 200:
node_count = response.json()
except Exception as e:
logger.warning(f"Failed to fetch node count from API: {e}")
context["stats"] = stats
context["advert_activity_json"] = json.dumps(advert_activity)
context["message_activity_json"] = json.dumps(message_activity)
context["node_count_json"] = json.dumps(node_count)
return templates.TemplateResponse("dashboard.html", context)

View File

@@ -1,67 +0,0 @@
"""Home page route."""
import json
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from meshcore_hub.web.app import get_network_context, get_templates
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/", response_class=HTMLResponse)
async def home(request: Request) -> HTMLResponse:
"""Render the home page."""
templates = get_templates(request)
context = get_network_context(request)
context["request"] = request
# Fetch stats from API
stats = {
"total_nodes": 0,
"active_nodes": 0,
"total_messages": 0,
"messages_today": 0,
"total_advertisements": 0,
"advertisements_24h": 0,
}
# Fetch activity data for charts
advert_activity = {"days": 7, "data": []}
message_activity = {"days": 7, "data": []}
try:
response = await request.app.state.http_client.get("/api/v1/dashboard/stats")
if response.status_code == 200:
stats = response.json()
except Exception as e:
logger.warning(f"Failed to fetch stats from API: {e}")
context["api_error"] = str(e)
try:
response = await request.app.state.http_client.get(
"/api/v1/dashboard/activity", params={"days": 7}
)
if response.status_code == 200:
advert_activity = response.json()
except Exception as e:
logger.warning(f"Failed to fetch activity from API: {e}")
try:
response = await request.app.state.http_client.get(
"/api/v1/dashboard/message-activity", params={"days": 7}
)
if response.status_code == 200:
message_activity = response.json()
except Exception as e:
logger.warning(f"Failed to fetch message activity from API: {e}")
context["stats"] = stats
# Pass activity data as JSON strings for the chart
context["advert_activity_json"] = json.dumps(advert_activity)
context["message_activity_json"] = json.dumps(message_activity)
return templates.TemplateResponse("home.html", context)

View File

@@ -1,162 +0,0 @@
"""Map page route."""
import logging
from typing import Any
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from meshcore_hub.web.app import get_network_context, get_templates
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/map", response_class=HTMLResponse)
async def map_page(request: Request) -> HTMLResponse:
"""Render the map page."""
templates = get_templates(request)
context = get_network_context(request)
context["request"] = request
return templates.TemplateResponse("map.html", context)
@router.get("/map/data")
async def map_data(request: Request) -> JSONResponse:
"""Return node location data as JSON for the map.
Includes role tag, member ownership info, and all data needed for filtering.
"""
nodes_with_location: list[dict[str, Any]] = []
members_list: list[dict[str, Any]] = []
members_by_id: dict[str, dict[str, Any]] = {}
error: str | None = None
total_nodes = 0
nodes_with_coords = 0
try:
# Fetch all members to build lookup by member_id
members_response = await request.app.state.http_client.get(
"/api/v1/members", params={"limit": 500}
)
if members_response.status_code == 200:
members_data = members_response.json()
for member in members_data.get("items", []):
member_info = {
"member_id": member.get("member_id"),
"name": member.get("name"),
"callsign": member.get("callsign"),
}
members_list.append(member_info)
if member.get("member_id"):
members_by_id[member["member_id"]] = member_info
else:
logger.warning(
f"Failed to fetch members: status {members_response.status_code}"
)
# Fetch all nodes from API
response = await request.app.state.http_client.get(
"/api/v1/nodes", params={"limit": 500}
)
if response.status_code == 200:
data = response.json()
nodes = data.get("items", [])
total_nodes = len(nodes)
# Filter nodes with location tags
for node in nodes:
tags = node.get("tags", [])
lat = None
lon = None
friendly_name = None
role = None
node_member_id = None
for tag in tags:
key = tag.get("key")
if key == "lat":
try:
lat = float(tag.get("value"))
except (ValueError, TypeError):
pass
elif key == "lon":
try:
lon = float(tag.get("value"))
except (ValueError, TypeError):
pass
elif key == "friendly_name":
friendly_name = tag.get("value")
elif key == "role":
role = tag.get("value")
elif key == "member_id":
node_member_id = tag.get("value")
if lat is not None and lon is not None:
nodes_with_coords += 1
# Use friendly_name, then node name, then public key prefix
display_name = (
friendly_name
or node.get("name")
or node.get("public_key", "")[:12]
)
public_key = node.get("public_key")
# Find owner member by member_id tag
owner = (
members_by_id.get(node_member_id) if node_member_id else None
)
nodes_with_location.append(
{
"public_key": public_key,
"name": display_name,
"adv_type": node.get("adv_type"),
"lat": lat,
"lon": lon,
"last_seen": node.get("last_seen"),
"role": role,
"is_infra": role == "infra",
"member_id": node_member_id,
"owner": owner,
}
)
else:
error = f"API returned status {response.status_code}"
logger.warning(f"Failed to fetch nodes: {error}")
except Exception as e:
error = str(e)
logger.warning(f"Failed to fetch nodes for map: {e}")
logger.info(
f"Map data: {total_nodes} total nodes, " f"{nodes_with_coords} with coordinates"
)
# Calculate center from nodes, or use default (0, 0)
center_lat = 0.0
center_lon = 0.0
if nodes_with_location:
center_lat = sum(n["lat"] for n in nodes_with_location) / len(
nodes_with_location
)
center_lon = sum(n["lon"] for n in nodes_with_location) / len(
nodes_with_location
)
return JSONResponse(
{
"nodes": nodes_with_location,
"members": members_list,
"center": {
"lat": center_lat,
"lon": center_lon,
},
"debug": {
"total_nodes": total_nodes,
"nodes_with_coords": nodes_with_coords,
"error": error,
},
}
)

View File

@@ -1,98 +0,0 @@
"""Members page route."""
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from meshcore_hub.web.app import get_network_context, get_templates
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/members", response_class=HTMLResponse)
async def members_page(request: Request) -> HTMLResponse:
"""Render the members page."""
templates = get_templates(request)
context = get_network_context(request)
context["request"] = request
# Fetch members from API
members = []
def node_sort_key(node: dict) -> int:
"""Sort nodes: repeater first, then chat, then others."""
adv_type = (node.get("adv_type") or "").lower()
if adv_type == "repeater":
return 0
if adv_type == "chat":
return 1
return 2
try:
# Fetch all members
response = await request.app.state.http_client.get(
"/api/v1/members", params={"limit": 100}
)
if response.status_code == 200:
data = response.json()
members = data.get("items", [])
# Fetch all nodes with member_id tags in one query
nodes_response = await request.app.state.http_client.get(
"/api/v1/nodes", params={"has_tag": "member_id", "limit": 500}
)
# Build a map of member_id -> nodes
member_nodes_map: dict[str, list] = {}
if nodes_response.status_code == 200:
nodes_data = nodes_response.json()
all_nodes = nodes_data.get("items", [])
for node in all_nodes:
# Find member_id tag
for tag in node.get("tags", []):
if tag.get("key") == "member_id":
member_id_value = tag.get("value")
if member_id_value:
if member_id_value not in member_nodes_map:
member_nodes_map[member_id_value] = []
member_nodes_map[member_id_value].append(node)
break
# Assign nodes to members and sort
for member in members:
member_id = member.get("member_id")
if member_id and member_id in member_nodes_map:
# Sort nodes (repeater first, then chat, then by name tag)
nodes = member_nodes_map[member_id]
# Sort by advertisement type first, then by name
def full_sort_key(node: dict) -> tuple:
adv_type = (node.get("adv_type") or "").lower()
type_priority = (
0
if adv_type == "repeater"
else (1 if adv_type == "chat" else 2)
)
# Get name from tags
node_name = node.get("name") or ""
for tag in node.get("tags", []):
if tag.get("key") == "name":
node_name = tag.get("value") or node_name
break
return (type_priority, node_name.lower())
member["nodes"] = sorted(nodes, key=full_sort_key)
else:
member["nodes"] = []
except Exception as e:
logger.warning(f"Failed to fetch members from API: {e}")
context["api_error"] = str(e)
context["members"] = members
return templates.TemplateResponse("members.html", context)

View File

@@ -1,66 +0,0 @@
"""Messages page route."""
import logging
from fastapi import APIRouter, Query, Request
from fastapi.responses import HTMLResponse
from meshcore_hub.web.app import get_network_context, get_templates
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/messages", response_class=HTMLResponse)
async def messages_list(
request: Request,
message_type: str | None = Query(None, description="Filter by message type"),
search: str | None = Query(None, description="Search in message text"),
page: int = Query(1, ge=1, description="Page number"),
limit: int = Query(50, ge=1, le=100, description="Items per page"),
) -> HTMLResponse:
"""Render the messages list page."""
templates = get_templates(request)
context = get_network_context(request)
context["request"] = request
# Calculate offset
offset = (page - 1) * limit
# Build query params
params: dict[str, int | str] = {"limit": limit, "offset": offset}
if message_type:
params["message_type"] = message_type
# Fetch messages from API
messages = []
total = 0
try:
response = await request.app.state.http_client.get(
"/api/v1/messages", params=params
)
if response.status_code == 200:
data = response.json()
messages = data.get("items", [])
total = data.get("total", 0)
except Exception as e:
logger.warning(f"Failed to fetch messages from API: {e}")
context["api_error"] = str(e)
# Calculate pagination
total_pages = (total + limit - 1) // limit if total > 0 else 1
context.update(
{
"messages": messages,
"total": total,
"page": page,
"limit": limit,
"total_pages": total_pages,
"message_type": message_type or "",
"search": search or "",
}
)
return templates.TemplateResponse("messages.html", context)

View File

@@ -1,156 +0,0 @@
"""Nodes page routes."""
import logging
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from meshcore_hub.web.app import get_network_context, get_templates
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/nodes", response_class=HTMLResponse)
async def nodes_list(
request: Request,
search: str | None = Query(None, description="Search term"),
adv_type: str | None = Query(None, description="Filter by node type"),
member_id: str | None = Query(None, description="Filter by member"),
page: int = Query(1, ge=1, description="Page number"),
limit: int = Query(20, ge=1, le=100, description="Items per page"),
) -> HTMLResponse:
"""Render the nodes list page."""
templates = get_templates(request)
context = get_network_context(request)
context["request"] = request
# Calculate offset
offset = (page - 1) * limit
# Build query params
params: dict[str, int | str] = {"limit": limit, "offset": offset}
if search:
params["search"] = search
if adv_type:
params["adv_type"] = adv_type
if member_id:
params["member_id"] = member_id
# Fetch nodes from API
nodes = []
total = 0
members = []
try:
# Fetch members for dropdown
members_response = await request.app.state.http_client.get(
"/api/v1/members", params={"limit": 100}
)
if members_response.status_code == 200:
members = members_response.json().get("items", [])
response = await request.app.state.http_client.get(
"/api/v1/nodes", params=params
)
if response.status_code == 200:
data = response.json()
nodes = data.get("items", [])
total = data.get("total", 0)
except Exception as e:
logger.warning(f"Failed to fetch nodes from API: {e}")
context["api_error"] = str(e)
# Calculate pagination
total_pages = (total + limit - 1) // limit if total > 0 else 1
context.update(
{
"nodes": nodes,
"total": total,
"page": page,
"limit": limit,
"total_pages": total_pages,
"search": search or "",
"adv_type": adv_type or "",
"member_id": member_id or "",
"members": members,
}
)
return templates.TemplateResponse("nodes.html", context)
@router.get("/n/{prefix}")
async def node_short_link(prefix: str) -> RedirectResponse:
"""Redirect short link to nodes page."""
return RedirectResponse(url=f"/nodes/{prefix}", status_code=302)
@router.get("/nodes/{public_key}", response_model=None)
async def node_detail(
request: Request, public_key: str
) -> HTMLResponse | RedirectResponse:
"""Render the node detail page.
If the key is not a full 64-character public key, uses the prefix API
to resolve it and redirects to the canonical URL.
"""
# If not a full public key, resolve via prefix API and redirect
if len(public_key) != 64:
response = await request.app.state.http_client.get(
f"/api/v1/nodes/prefix/{public_key}"
)
if response.status_code == 200:
node = response.json()
return RedirectResponse(url=f"/nodes/{node['public_key']}", status_code=302)
raise HTTPException(status_code=404, detail="Node not found")
templates = get_templates(request)
context = get_network_context(request)
context["request"] = request
advertisements = []
telemetry = []
# Fetch node details (exact match)
response = await request.app.state.http_client.get(f"/api/v1/nodes/{public_key}")
if response.status_code != 200:
raise HTTPException(status_code=404, detail="Node not found")
node = response.json()
try:
# Fetch recent advertisements for this node
response = await request.app.state.http_client.get(
"/api/v1/advertisements", params={"public_key": public_key, "limit": 10}
)
if response.status_code == 200:
advertisements = response.json().get("items", [])
# Fetch recent telemetry for this node
response = await request.app.state.http_client.get(
"/api/v1/telemetry", params={"node_public_key": public_key, "limit": 10}
)
if response.status_code == 200:
telemetry = response.json().get("items", [])
except Exception as e:
logger.warning(f"Failed to fetch node details from API: {e}")
context["api_error"] = str(e)
# Check if admin editing is available
admin_enabled = getattr(request.app.state, "admin_enabled", False)
auth_user = request.headers.get("X-Forwarded-User")
context.update(
{
"node": node,
"advertisements": advertisements,
"telemetry": telemetry,
"public_key": public_key,
"admin_enabled": admin_enabled,
"is_authenticated": bool(auth_user),
}
)
return templates.TemplateResponse("node_detail.html", context)

View File

@@ -1,36 +0,0 @@
"""Custom pages route for MeshCore Hub Web Dashboard."""
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from meshcore_hub.web.app import get_network_context, get_templates
router = APIRouter(tags=["Pages"])
@router.get("/pages/{slug}", response_class=HTMLResponse)
async def custom_page(request: Request, slug: str) -> HTMLResponse:
"""Render a custom markdown page.
Args:
request: FastAPI request object.
slug: The page slug from the URL.
Returns:
Rendered HTML page.
Raises:
HTTPException: 404 if page not found.
"""
page_loader = request.app.state.page_loader
page = page_loader.get_page(slug)
if not page:
raise HTTPException(status_code=404, detail=f"Page '{slug}' not found")
templates = get_templates(request)
context = get_network_context(request)
context["request"] = request
context["page"] = page
return templates.TemplateResponse("page.html", context)

View File

@@ -0,0 +1,349 @@
/**
* MeshCore Hub - Custom Application Styles
*
* This file contains all custom CSS that extends the Tailwind/DaisyUI framework.
* Organized in sections:
* - Color palette
* - Navbar styling
* - Scrollbar styling
* - Table styling
* - Text utilities
* - Prose (markdown content) styling
* - Leaflet map theming
*/
/* ==========================================================================
Color Palette
Single source of truth for page/section colors used across nav, charts,
and page content. All values are OKLCH.
========================================================================== */
:root {
--color-dashboard: oklch(0.75 0.15 210); /* cyan */
--color-nodes: oklch(0.65 0.24 265); /* violet */
--color-adverts: oklch(0.7 0.17 330); /* magenta */
--color-messages: oklch(0.75 0.18 180); /* teal */
--color-map: oklch(0.8471 0.199 83.87); /* yellow (matches btn-warning) */
--color-members: oklch(0.72 0.17 50); /* orange */
--color-neutral: oklch(0.3 0.01 250); /* subtle dark grey */
}
/* Light mode: darker section colors for contrast on light backgrounds */
[data-theme="light"] {
--color-dashboard: oklch(0.55 0.15 210);
--color-nodes: oklch(0.50 0.24 265);
--color-adverts: oklch(0.55 0.17 330);
--color-messages: oklch(0.55 0.18 180);
--color-map: oklch(0.58 0.16 45);
--color-members: oklch(0.55 0.18 25);
--color-neutral: oklch(0.85 0.01 250);
}
/* ==========================================================================
Navbar Styling
========================================================================== */
/* Spacing between horizontal nav items */
.menu-horizontal { gap: 0.125rem; }
/* Invert white logos/images to dark for light mode */
[data-theme="light"] .theme-logo {
filter: brightness(0.15);
}
/* Ensure hero heading is pure black/white per theme */
.hero-title {
color: #fff;
}
[data-theme="light"] .hero-title {
color: #000;
}
/* Nav icon colors */
.nav-icon-dashboard { color: var(--color-dashboard); }
.nav-icon-nodes { color: var(--color-nodes); }
.nav-icon-adverts { color: var(--color-adverts); }
.nav-icon-messages { color: var(--color-messages); }
.nav-icon-map { color: var(--color-map); }
.nav-icon-members { color: var(--color-members); }
/* Propagate section color to parent li for hover/active backgrounds */
.navbar .menu li:has(.nav-icon-dashboard) { --nav-color: var(--color-dashboard); }
.navbar .menu li:has(.nav-icon-nodes) { --nav-color: var(--color-nodes); }
.navbar .menu li:has(.nav-icon-adverts) { --nav-color: var(--color-adverts); }
.navbar .menu li:has(.nav-icon-messages) { --nav-color: var(--color-messages); }
.navbar .menu li:has(.nav-icon-map) { --nav-color: var(--color-map); }
.navbar .menu li:has(.nav-icon-members) { --nav-color: var(--color-members); }
/* Section-tinted hover and active backgrounds (!important to override DaisyUI CDN) */
.navbar .menu li > a:hover {
background-color: color-mix(in oklch, var(--nav-color, oklch(var(--bc))) 12%, transparent) !important;
}
.navbar .menu li > a.active {
background-color: color-mix(in oklch, var(--nav-color, oklch(var(--bc))) 20%, transparent) !important;
color: inherit !important;
}
/* Homepage hero buttons: slightly thicker outline, white text on hover */
#app .btn-outline {
border-width: 2px;
}
#app .btn-outline:hover {
color: #fff !important;
}
/* ==========================================================================
Panel Glow
Radial color glow from bottom-right corner.
Set --panel-color on the element for a section-tinted glow.
========================================================================== */
.panel-glow {
background-image:
radial-gradient(
ellipse at 80% 80%,
color-mix(in oklch, var(--panel-color, transparent) 15%, transparent),
transparent 70%
);
}
.panel-glow.panel-glow-tl {
background-image:
radial-gradient(
ellipse at 20% 20%,
color-mix(in oklch, var(--panel-color, transparent) 15%, transparent),
transparent 70%
);
}
.panel-solid {
background-color: color-mix(in oklch, var(--panel-color, transparent) 10%, oklch(var(--b1)));
}
/* ==========================================================================
Scrollbar Styling
========================================================================== */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: oklch(var(--b2));
}
::-webkit-scrollbar-thumb {
background: oklch(var(--bc) / 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(var(--bc) / 0.5);
}
/* ==========================================================================
Table Styling
========================================================================== */
.table-compact td,
.table-compact th {
padding: 0.5rem 0.75rem;
}
/* ==========================================================================
Text Utilities
========================================================================== */
.truncate-cell {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ==========================================================================
Prose Styling (Custom Markdown Pages)
========================================================================== */
.prose h1 {
font-size: 2.25rem;
font-weight: 700;
margin-top: 1.5rem;
margin-bottom: 1rem;
}
.prose h2 {
font-size: 1.875rem;
font-weight: 600;
margin-top: 1.25rem;
margin-bottom: 0.75rem;
}
.prose h3 {
font-size: 1.5rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.prose h4 {
font-size: 1.25rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.prose p {
margin-bottom: 1rem;
line-height: 1.75;
}
.prose ul,
.prose ol {
margin-bottom: 1rem;
padding-left: 1.5rem;
}
.prose ul {
list-style-type: disc;
}
.prose ol {
list-style-type: decimal;
}
.prose li {
margin-bottom: 0.25rem;
}
.prose a {
color: oklch(var(--p));
text-decoration: underline;
}
.prose a:hover {
color: oklch(var(--pf));
}
.prose code {
background: oklch(var(--b2));
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875em;
}
.prose pre {
background: oklch(var(--b2));
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin-bottom: 1rem;
}
.prose pre code {
background: none;
padding: 0;
}
.prose blockquote {
border-left: 4px solid oklch(var(--bc) / 0.3);
padding-left: 1rem;
margin: 1rem 0;
font-style: italic;
}
.prose table {
width: 100%;
margin-bottom: 1rem;
border-collapse: collapse;
}
.prose th,
.prose td {
border: 1px solid oklch(var(--bc) / 0.2);
padding: 0.5rem;
text-align: left;
}
.prose th {
background: oklch(var(--b2));
font-weight: 600;
}
.prose hr {
border: none;
border-top: 1px solid oklch(var(--bc) / 0.2);
margin: 2rem 0;
}
.prose img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
margin: 1rem 0;
}
/* ==========================================================================
Leaflet Map Theming (Dark Mode)
========================================================================== */
/* Popup styling */
.leaflet-popup-content-wrapper {
background: oklch(var(--b1));
color: oklch(var(--bc));
}
.leaflet-popup-tip {
background: oklch(var(--b1));
}
/* Map container defaults */
#map,
#node-map {
border-radius: var(--rounded-box);
}
#map {
height: calc(100vh - 350px);
min-height: 400px;
}
#node-map {
height: 300px;
}
/* Map label visibility */
.map-label {
opacity: 0;
transition: opacity 0.15s ease-in-out;
}
.map-marker:hover .map-label {
opacity: 1;
}
.show-labels .map-label {
opacity: 1;
}
/* Bring hovered marker to front */
.leaflet-marker-icon:hover {
z-index: 10000 !important;
}
/* ==========================================================================
Node Header Hero Map Background
========================================================================== */
#header-map {
height: 100%;
width: 100%;
z-index: 0;
}
/* Ensure Leaflet elements stay within the map layer */
#header-map .leaflet-pane,
#header-map .leaflet-control {
z-index: auto !important;
}

View File

@@ -1,21 +1,45 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 70 70"
width="70"
height="70"
viewBox="0 0 53 53"
width="53"
height="53"
version="1.1"
xmlns="http://www.w3.org/2000/svg">
id="svg3"
sodipodi:docname="logo_bak.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs3" />
<sodipodi:namedview
id="namedview3"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1" />
<!-- WiFi arcs radiating from bottom-left corner -->
<g
fill="none"
stroke="#ffffff"
stroke-width="8"
stroke-linecap="round">
stroke-linecap="round"
id="g3"
transform="translate(-1,-16)">
<!-- Inner arc: from right to top -->
<path d="M 20,65 A 15,15 0 0 0 5,50" />
<path
d="M 20,65 A 15,15 0 0 0 5,50"
id="path1" />
<!-- Middle arc -->
<path d="M 35,65 A 30,30 0 0 0 5,35" />
<path
d="M 35,65 A 30,30 0 0 0 5,35"
id="path2" />
<!-- Outer arc -->
<path d="M 50,65 A 45,45 0 0 0 5,20" />
<path
d="M 50,65 A 45,45 0 0 0 5,20"
id="path3" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 553 B

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,231 @@
/**
* MeshCore Hub - Chart.js Helpers
*
* Provides common chart configuration and initialization helpers
* for activity charts used on home and dashboard pages.
*/
/**
* Read page colors from CSS custom properties (defined in app.css :root).
* Falls back to hardcoded values if CSS vars are unavailable.
*/
function getCSSColor(varName, fallback) {
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || fallback;
}
function withAlpha(color, alpha) {
// oklch(0.65 0.24 265) -> oklch(0.65 0.24 265 / 0.1)
return color.replace(')', ' / ' + alpha + ')');
}
const ChartColors = {
get nodes() { return getCSSColor('--color-nodes', 'oklch(0.65 0.24 265)'); },
get nodesFill() { return withAlpha(this.nodes, 0.1); },
get adverts() { return getCSSColor('--color-adverts', 'oklch(0.7 0.17 330)'); },
get advertsFill() { return withAlpha(this.adverts, 0.1); },
get messages() { return getCSSColor('--color-messages', 'oklch(0.75 0.18 180)'); },
get messagesFill() { return withAlpha(this.messages, 0.1); },
// Neutral grays (not page-specific)
grid: 'oklch(0.4 0 0 / 0.2)',
text: 'oklch(0.7 0 0)',
tooltipBg: 'oklch(0.25 0 0)',
tooltipText: 'oklch(0.9 0 0)',
tooltipBorder: 'oklch(0.4 0 0)'
};
/**
* Create common chart options with optional legend
* @param {boolean} showLegend - Whether to show the legend
* @returns {Object} Chart.js options object
*/
function createChartOptions(showLegend) {
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: showLegend,
position: 'bottom',
labels: {
color: ChartColors.text,
boxWidth: 12,
padding: 8
}
},
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: ChartColors.tooltipBg,
titleColor: ChartColors.tooltipText,
bodyColor: ChartColors.tooltipText,
borderColor: ChartColors.tooltipBorder,
borderWidth: 1
}
},
scales: {
x: {
grid: { color: ChartColors.grid },
ticks: {
color: ChartColors.text,
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 10
}
},
y: {
beginAtZero: true,
grid: { color: ChartColors.grid },
ticks: {
color: ChartColors.text,
precision: 0
}
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
}
};
}
/**
* Format date labels for chart display (e.g., "8 Feb")
* @param {Array} data - Array of objects with 'date' property
* @returns {Array} Formatted date strings
*/
function formatDateLabels(data) {
return data.map(function(d) {
var date = new Date(d.date);
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
});
}
/**
* Create a single-dataset line chart
* @param {string} canvasId - ID of the canvas element
* @param {Object} data - Data object with 'data' array containing {date, count} objects
* @param {string} label - Dataset label
* @param {string} borderColor - Line color
* @param {string} backgroundColor - Fill color
* @param {boolean} fill - Whether to fill under the line
*/
function createLineChart(canvasId, data, label, borderColor, backgroundColor, fill) {
var ctx = document.getElementById(canvasId);
if (!ctx || !data || !data.data || data.data.length === 0) {
return null;
}
return new Chart(ctx, {
type: 'line',
data: {
labels: formatDateLabels(data.data),
datasets: [{
label: label,
data: data.data.map(function(d) { return d.count; }),
borderColor: borderColor,
backgroundColor: backgroundColor,
fill: fill,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}]
},
options: createChartOptions(false)
});
}
/**
* Create a multi-dataset activity chart (for home page).
* Pass null for advertData or messageData to omit that series.
* @param {string} canvasId - ID of the canvas element
* @param {Object|null} advertData - Advertisement data with 'data' array, or null to omit
* @param {Object|null} messageData - Message data with 'data' array, or null to omit
*/
function createActivityChart(canvasId, advertData, messageData) {
var ctx = document.getElementById(canvasId);
if (!ctx) return null;
// Build datasets from whichever series are provided
var datasets = [];
var labels = null;
if (advertData && advertData.data && advertData.data.length > 0) {
if (!labels) labels = formatDateLabels(advertData.data);
datasets.push({
label: (window.t && window.t('entities.advertisements')) || 'Advertisements',
data: advertData.data.map(function(d) { return d.count; }),
borderColor: ChartColors.adverts,
backgroundColor: ChartColors.advertsFill,
fill: false,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
});
}
if (messageData && messageData.data && messageData.data.length > 0) {
if (!labels) labels = formatDateLabels(messageData.data);
datasets.push({
label: (window.t && window.t('entities.messages')) || 'Messages',
data: messageData.data.map(function(d) { return d.count; }),
borderColor: ChartColors.messages,
backgroundColor: ChartColors.messagesFill,
fill: false,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
});
}
if (datasets.length === 0 || !labels) return null;
return new Chart(ctx, {
type: 'line',
data: { labels: labels, datasets: datasets },
options: createChartOptions(true)
});
}
/**
* Initialize dashboard charts (nodes, advertisements, messages).
* Pass null for any data parameter to skip that chart.
* @param {Object|null} nodeData - Node count data, or null to skip
* @param {Object|null} advertData - Advertisement data, or null to skip
* @param {Object|null} messageData - Message data, or null to skip
*/
function initDashboardCharts(nodeData, advertData, messageData) {
if (nodeData) {
createLineChart(
'nodeChart',
nodeData,
(window.t && window.t('common.total_entity', { entity: t('entities.nodes') })) || 'Total Nodes',
ChartColors.nodes,
ChartColors.nodesFill,
true
);
}
if (advertData) {
createLineChart(
'advertChart',
advertData,
(window.t && window.t('entities.advertisements')) || 'Advertisements',
ChartColors.adverts,
ChartColors.advertsFill,
true
);
}
if (messageData) {
createLineChart(
'messageChart',
messageData,
(window.t && window.t('entities.messages')) || 'Messages',
ChartColors.messages,
ChartColors.messagesFill,
true
);
}
}

View File

@@ -0,0 +1,99 @@
/**
* MeshCore Hub SPA - API Client
*
* Wrapper around fetch() for making API calls to the proxied backend.
*/
/**
* Make a GET request and return parsed JSON.
* @param {string} path - URL path (e.g., '/api/v1/nodes')
* @param {Object} [params] - Query parameters
* @returns {Promise<any>} Parsed JSON response
*/
export async function apiGet(path, params = {}) {
const url = new URL(path, window.location.origin);
for (const [k, v] of Object.entries(params)) {
if (v !== null && v !== undefined && v !== '') {
url.searchParams.set(k, String(v));
}
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
/**
* Make a POST request with JSON body.
* @param {string} path - URL path
* @param {Object} body - Request body
* @returns {Promise<any>} Parsed JSON response
*/
export async function apiPost(path, body) {
const response = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`API error: ${response.status} - ${text}`);
}
if (response.status === 204) return null;
return response.json();
}
/**
* Make a PUT request with JSON body.
* @param {string} path - URL path
* @param {Object} body - Request body
* @returns {Promise<any>} Parsed JSON response
*/
export async function apiPut(path, body) {
const response = await fetch(path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`API error: ${response.status} - ${text}`);
}
if (response.status === 204) return null;
return response.json();
}
/**
* Make a DELETE request.
* @param {string} path - URL path
* @returns {Promise<void>}
*/
export async function apiDelete(path) {
const response = await fetch(path, { method: 'DELETE' });
if (!response.ok) {
const text = await response.text();
throw new Error(`API error: ${response.status} - ${text}`);
}
}
/**
* Make a POST request with form-encoded body.
* @param {string} path - URL path
* @param {Object} data - Form data as key-value pairs
* @returns {Promise<any>} Parsed JSON response
*/
export async function apiPostForm(path, data) {
const body = new URLSearchParams(data);
const response = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`API error: ${response.status} - ${text}`);
}
if (response.status === 204) return null;
return response.json();
}

View File

@@ -0,0 +1,183 @@
/**
* MeshCore Hub SPA - Main Application Entry Point
*
* Initializes i18n, the router, registers all page routes,
* and handles navigation.
*/
import { Router } from './router.js';
import { getConfig } from './components.js';
import { loadLocale, t } from './i18n.js';
// Page modules (lazy-loaded)
const pages = {
home: () => import('./pages/home.js'),
dashboard: () => import('./pages/dashboard.js'),
nodes: () => import('./pages/nodes.js'),
nodeDetail: () => import('./pages/node-detail.js'),
messages: () => import('./pages/messages.js'),
advertisements: () => import('./pages/advertisements.js'),
map: () => import('./pages/map.js'),
members: () => import('./pages/members.js'),
customPage: () => import('./pages/custom-page.js'),
notFound: () => import('./pages/not-found.js'),
adminIndex: () => import('./pages/admin/index.js'),
adminNodeTags: () => import('./pages/admin/node-tags.js'),
adminMembers: () => import('./pages/admin/members.js'),
};
// Main app container
const appContainer = document.getElementById('app');
const router = new Router();
// Read feature flags from config
const config = getConfig();
const features = config.features || {};
/**
* Create a route handler that lazy-loads a page module and calls its render function.
* @param {Function} loader - Module loader function
* @returns {Function} Route handler
*/
function pageHandler(loader) {
return async (params) => {
try {
const module = await loader();
return await module.render(appContainer, params, router);
} catch (e) {
console.error('Page load error:', e);
appContainer.innerHTML = `
<div class="flex flex-col items-center justify-center py-20">
<h1 class="text-4xl font-bold mb-4">${t('common.error')}</h1>
<p class="text-lg opacity-70 mb-6">${t('common.failed_to_load_page')}</p>
<p class="text-sm opacity-50 mb-6">${e.message || 'Unknown error'}</p>
<a href="/" class="btn btn-primary">${t('common.go_home')}</a>
</div>`;
}
};
}
// Register routes (conditionally based on feature flags)
router.addRoute('/', pageHandler(pages.home));
if (features.dashboard !== false) {
router.addRoute('/dashboard', pageHandler(pages.dashboard));
}
if (features.nodes !== false) {
router.addRoute('/nodes', pageHandler(pages.nodes));
router.addRoute('/nodes/:publicKey', pageHandler(pages.nodeDetail));
router.addRoute('/n/:prefix', async (params) => {
// Short link redirect
router.navigate(`/nodes/${params.prefix}`, true);
});
}
if (features.messages !== false) {
router.addRoute('/messages', pageHandler(pages.messages));
}
if (features.advertisements !== false) {
router.addRoute('/advertisements', pageHandler(pages.advertisements));
}
if (features.map !== false) {
router.addRoute('/map', pageHandler(pages.map));
}
if (features.members !== false) {
router.addRoute('/members', pageHandler(pages.members));
}
if (features.pages !== false) {
router.addRoute('/pages/:slug', pageHandler(pages.customPage));
}
// Admin routes
router.addRoute('/a', pageHandler(pages.adminIndex));
router.addRoute('/a/', pageHandler(pages.adminIndex));
router.addRoute('/a/node-tags', pageHandler(pages.adminNodeTags));
router.addRoute('/a/members', pageHandler(pages.adminMembers));
// 404 handler
router.setNotFound(pageHandler(pages.notFound));
/**
* Update the active state of navigation links.
* @param {string} pathname - Current URL path
*/
function updateNavActiveState(pathname) {
document.querySelectorAll('[data-nav-link]').forEach(link => {
const href = link.getAttribute('href');
let isActive = false;
if (href === '/') {
isActive = pathname === '/';
} else if (href === '/nodes') {
isActive = pathname.startsWith('/nodes');
} else {
isActive = pathname === href || pathname.startsWith(href + '/');
}
if (isActive) {
link.classList.add('active');
} else {
link.classList.remove('active');
}
});
// Close mobile dropdown if open (DaisyUI dropdowns stay open while focused)
if (document.activeElement?.closest('.dropdown')) {
document.activeElement.blur();
}
}
/**
* Compose a page title from entity name and network name.
* @param {string} entityKey - Translation key for entity (e.g., 'entities.dashboard')
* @returns {string}
*/
function composePageTitle(entityKey) {
const networkName = config.network_name || 'MeshCore Network';
const entity = t(entityKey);
return `${entity} - ${networkName}`;
}
/**
* Update the page title based on the current route.
* @param {string} pathname
*/
function updatePageTitle(pathname) {
const networkName = config.network_name || 'MeshCore Network';
const titles = {
'/': networkName,
'/a': composePageTitle('entities.admin'),
'/a/': composePageTitle('entities.admin'),
'/a/node-tags': `${t('entities.tags')} - ${t('entities.admin')} - ${networkName}`,
'/a/members': `${t('entities.members')} - ${t('entities.admin')} - ${networkName}`,
};
// Add feature-dependent titles
if (features.dashboard !== false) titles['/dashboard'] = composePageTitle('entities.dashboard');
if (features.nodes !== false) titles['/nodes'] = composePageTitle('entities.nodes');
if (features.messages !== false) titles['/messages'] = composePageTitle('entities.messages');
if (features.advertisements !== false) titles['/advertisements'] = composePageTitle('entities.advertisements');
if (features.map !== false) titles['/map'] = composePageTitle('entities.map');
if (features.members !== false) titles['/members'] = composePageTitle('entities.members');
if (titles[pathname]) {
document.title = titles[pathname];
} else if (pathname.startsWith('/nodes/')) {
document.title = composePageTitle('entities.node_detail');
} else if (pathname.startsWith('/pages/')) {
// Custom pages set their own title in the page module
document.title = networkName;
} else {
document.title = networkName;
}
}
// Set up navigation callback
router.onNavigate((pathname) => {
updateNavActiveState(pathname);
updatePageTitle(pathname);
});
// Load locale then start the router
const locale = localStorage.getItem('meshcore-locale') || config.locale || 'en';
await loadLocale(locale);
router.start();

View 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;
}
},
};
}

View File

@@ -0,0 +1,421 @@
/**
* MeshCore Hub SPA - Shared UI Components
*
* Reusable rendering functions using lit-html.
*/
import { html, nothing } from 'lit-html';
import { render } from 'lit-html';
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
import { t } from './i18n.js';
// Re-export lit-html utilities for page modules
export { html, nothing, unsafeHTML };
export { render as litRender } from 'lit-html';
export { t } from './i18n.js';
/**
* Get app config from the embedded window object.
* @returns {Object} App configuration
*/
export function getConfig() {
return window.__APP_CONFIG__ || {};
}
/**
* Page color palette - reads from CSS custom properties (defined in app.css :root).
* Use for inline styles or dynamic coloring in page modules.
*/
export const pageColors = {
get dashboard() { return getComputedStyle(document.documentElement).getPropertyValue('--color-dashboard').trim(); },
get nodes() { return getComputedStyle(document.documentElement).getPropertyValue('--color-nodes').trim(); },
get adverts() { return getComputedStyle(document.documentElement).getPropertyValue('--color-adverts').trim(); },
get messages() { return getComputedStyle(document.documentElement).getPropertyValue('--color-messages').trim(); },
get map() { return getComputedStyle(document.documentElement).getPropertyValue('--color-map').trim(); },
get members() { return getComputedStyle(document.documentElement).getPropertyValue('--color-members').trim(); },
};
// --- Formatting Helpers (return strings) ---
/**
* Get the type emoji for a node advertisement type.
* @param {string|null} advType
* @returns {string} Emoji character
*/
export function typeEmoji(advType) {
switch ((advType || '').toLowerCase()) {
case 'chat': return '\u{1F4AC}'; // 💬
case 'repeater': return '\u{1F4E1}'; // 📡
case 'room': return '\u{1FAA7}'; // 🪧
default: return '\u{1F4CD}'; // 📍
}
}
/**
* Extract the first emoji from a string.
* Uses a regex pattern that matches emoji characters including compound emojis.
* @param {string|null} str
* @returns {string|null} First emoji found, or null if none
*/
export function extractFirstEmoji(str) {
if (!str) return null;
// Match emoji using Unicode ranges and zero-width joiners
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{231A}-\u{231B}\u{23E9}-\u{23FA}\u{25AA}-\u{25AB}\u{25B6}\u{25C0}\u{25FB}-\u{25FE}\u{2B50}\u{2B55}\u{3030}\u{303D}\u{3297}\u{3299}](?:\u{FE0F})?(?:\u{200D}[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}](?:\u{FE0F})?)*|\u{00A9}|\u{00AE}|\u{203C}|\u{2049}|\u{2122}|\u{2139}|\u{2194}-\u{2199}|\u{21A9}-\u{21AA}|\u{24C2}|\u{2934}-\u{2935}|\u{2B05}-\u{2B07}|\u{2B1B}-\u{2B1C}/u;
const match = str.match(emojiRegex);
return match ? match[0] : null;
}
/**
* Get the display emoji for a node.
* Prefers the first emoji from the node name, falls back to type emoji.
* @param {string|null} nodeName - Node's display name
* @param {string|null} advType - Advertisement type
* @returns {string} Emoji character to display
*/
export function getNodeEmoji(nodeName, advType) {
const nameEmoji = extractFirstEmoji(nodeName);
return nameEmoji || typeEmoji(advType);
}
/**
* Format an ISO datetime string to the configured timezone.
* @param {string|null} isoString
* @param {Object} [options] - Intl.DateTimeFormat options override
* @returns {string} Formatted datetime string
*/
export function formatDateTime(isoString, options) {
if (!isoString) return '-';
try {
const config = getConfig();
const tz = config.timezone_iana || 'UTC';
const date = new Date(isoString);
if (isNaN(date.getTime())) return '-';
const opts = options || {
timeZone: tz,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
};
if (!opts.timeZone) opts.timeZone = tz;
return date.toLocaleString('en-GB', opts);
} catch {
return isoString ? isoString.slice(0, 19).replace('T', ' ') : '-';
}
}
/**
* Format an ISO datetime string to short format (date + HH:MM).
* @param {string|null} isoString
* @returns {string}
*/
export function formatDateTimeShort(isoString) {
if (!isoString) return '-';
try {
const config = getConfig();
const tz = config.timezone_iana || 'UTC';
const date = new Date(isoString);
if (isNaN(date.getTime())) return '-';
return date.toLocaleString('en-GB', {
timeZone: tz,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
hour12: false,
});
} catch {
return isoString ? isoString.slice(0, 16).replace('T', ' ') : '-';
}
}
/**
* Format an ISO datetime as relative time (e.g., "2m ago", "1h ago").
* @param {string|null} isoString
* @returns {string}
*/
export function formatRelativeTime(isoString) {
if (!isoString) return '';
const date = new Date(isoString);
if (isNaN(date.getTime())) return '';
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffDay > 0) return t('time.days_ago', { count: diffDay });
if (diffHour > 0) return t('time.hours_ago', { count: diffHour });
if (diffMin > 0) return t('time.minutes_ago', { count: diffMin });
return t('time.less_than_minute');
}
/**
* Truncate a public key for display.
* @param {string} key - Full public key
* @param {number} [length=12] - Characters to show
* @returns {string} Truncated key with ellipsis
*/
export function truncateKey(key, length = 12) {
if (!key) return '-';
if (key.length <= length) return key;
return key.slice(0, length) + '...';
}
/**
* Escape HTML special characters. Rarely needed with lit-html
* since template interpolation auto-escapes, but kept for edge cases.
* @param {string} str
* @returns {string}
*/
export function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
/**
* Copy text to clipboard with visual feedback.
* Updates the target element to show "Copied!" temporarily.
* Falls back to execCommand for browsers without Clipboard API.
* @param {Event} e - Click event
* @param {string} text - Text to copy to clipboard
*/
export function copyToClipboard(e, text) {
e.preventDefault();
e.stopPropagation();
// Capture target element synchronously before async operations
const targetElement = e.currentTarget;
const showSuccess = (target) => {
const originalText = target.textContent;
target.textContent = 'Copied!';
target.classList.add('text-success');
setTimeout(() => {
target.textContent = originalText;
target.classList.remove('text-success');
}, 1500);
};
// Try modern Clipboard API first
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
showSuccess(targetElement);
}).catch(err => {
console.error('Clipboard API failed:', err);
fallbackCopy(text, targetElement);
});
} else {
// Fallback for older browsers or non-secure contexts
fallbackCopy(text, targetElement);
}
function fallbackCopy(text, target) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
showSuccess(target);
} catch (err) {
console.error('Fallback copy failed:', err);
}
document.body.removeChild(textArea);
}
}
// --- UI Components (return lit-html TemplateResult) ---
/**
* Render a node display with emoji, name, and optional description.
* Used for consistent node representation across lists (nodes, advertisements, messages, etc.).
*
* @param {Object} options - Node display options
* @param {string|null} options.name - Node display name (from tag or advertised name)
* @param {string|null} options.description - Node description from tags
* @param {string} options.publicKey - Node public key (for fallback display)
* @param {string|null} options.advType - Advertisement type (chat, repeater, room)
* @param {string} [options.size='base'] - Size variant: 'sm' (small lists) or 'base' (normal)
* @returns {TemplateResult} lit-html template
*/
export function renderNodeDisplay({ name, description, publicKey, advType, size = 'base' }) {
const displayName = name || null;
const emoji = getNodeEmoji(name, advType);
const emojiSize = size === 'sm' ? 'text-lg' : 'text-lg';
const nameSize = size === 'sm' ? 'text-sm' : 'text-base';
const descSize = size === 'sm' ? 'text-xs' : 'text-xs';
const nameBlock = displayName
? html`<div class="font-medium ${nameSize} truncate">${displayName}</div>
${description ? html`<div class="${descSize} opacity-70 truncate">${description}</div>` : nothing}`
: html`<div class="font-mono ${nameSize} truncate">${publicKey.slice(0, 16)}...</div>`;
return html`
<div class="flex items-center gap-2 min-w-0">
<span class="${emojiSize} flex-shrink-0" title=${advType || t('node_types.unknown')}>${emoji}</span>
<div class="min-w-0">
${nameBlock}
</div>
</div>`;
}
/**
* Render a loading spinner.
* @returns {TemplateResult}
*/
export function loading() {
return html`<div class="flex justify-center py-12"><span class="loading loading-spinner loading-lg"></span></div>`;
}
/**
* Render an error alert.
* @param {string} message
* @returns {TemplateResult}
*/
export function errorAlert(message) {
return html`<div role="alert" class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>${message}</span>
</div>`;
}
/**
* Render an info alert. Use unsafeHTML for HTML content.
* @param {string} message - Plain text message
* @returns {TemplateResult}
*/
export function infoAlert(message) {
return html`<div role="alert" class="alert alert-info mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>${message}</span>
</div>`;
}
/**
* Render a success alert.
* @param {string} message
* @returns {TemplateResult}
*/
export function successAlert(message) {
return html`<div role="alert" class="alert alert-success mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>${message}</span>
</div>`;
}
/**
* Render pagination controls.
* @param {number} page - Current page (1-based)
* @param {number} totalPages - Total number of pages
* @param {string} basePath - Base URL path (e.g., '/nodes')
* @param {Object} [params={}] - Extra query parameters to preserve
* @returns {TemplateResult|nothing}
*/
export function pagination(page, totalPages, basePath, params = {}) {
if (totalPages <= 1) return nothing;
const queryParts = [];
for (const [k, v] of Object.entries(params)) {
if (k !== 'page' && v !== null && v !== undefined && v !== '') {
queryParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
}
}
const extraQuery = queryParts.length > 0 ? '&' + queryParts.join('&') : '';
function pageUrl(p) {
return `${basePath}?page=${p}${extraQuery}`;
}
const pageNumbers = [];
for (let p = 1; p <= totalPages; p++) {
if (p === page) {
pageNumbers.push(html`<button class="join-item btn btn-sm btn-active">${p}</button>`);
} else if (p === 1 || p === totalPages || (p >= page - 2 && p <= page + 2)) {
pageNumbers.push(html`<a href=${pageUrl(p)} class="join-item btn btn-sm">${p}</a>`);
} else if (p === 2 || p === totalPages - 1) {
pageNumbers.push(html`<button class="join-item btn btn-sm btn-disabled" disabled>...</button>`);
}
}
return html`<div class="flex justify-center mt-6"><div class="join">
${page > 1
? html`<a href=${pageUrl(page - 1)} class="join-item btn btn-sm">${t('common.previous')}</a>`
: html`<button class="join-item btn btn-sm btn-disabled" disabled>${t('common.previous')}</button>`}
${pageNumbers}
${page < totalPages
? html`<a href=${pageUrl(page + 1)} class="join-item btn btn-sm">${t('common.next')}</a>`
: html`<button class="join-item btn btn-sm btn-disabled" disabled>${t('common.next')}</button>`}
</div></div>`;
}
/**
* Render a timezone indicator for page headers.
* @returns {TemplateResult|nothing}
*/
export function timezoneIndicator() {
const config = getConfig();
const tz = config.timezone || 'UTC';
return html`<span class="text-xs opacity-50 ml-2">(${tz})</span>`;
}
/**
* Render receiver node icons with tooltips.
* @param {Array} receivers
* @returns {TemplateResult|nothing}
*/
export function receiverIcons(receivers) {
if (!receivers || receivers.length === 0) return nothing;
return html`${receivers.map(r => {
const name = r.receiver_node_name || truncateKey(r.receiver_node_public_key || '', 8);
const time = formatRelativeTime(r.received_at);
const tooltip = time ? `${name} (${time})` : name;
return html`<span class="cursor-help" title=${tooltip}>\u{1F4E1}</span>`;
})}`;
}
// --- Form Helpers ---
/**
* Create a submit handler for filter forms that uses SPA navigation.
* Use as: @submit=${createFilterHandler('/nodes', navigate)}
* @param {string} basePath - Base URL path for the page
* @param {Function} navigate - Router navigate function
* @returns {Function} Event handler
*/
export function createFilterHandler(basePath, navigate) {
return (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const params = new URLSearchParams();
for (const [k, v] of formData.entries()) {
if (v) params.set(k, v);
}
const queryStr = params.toString();
navigate(queryStr ? `${basePath}?${queryStr}` : basePath);
};
}
/**
* Auto-submit handler for select/checkbox elements.
* Use as: @change=${autoSubmit}
* @param {Event} e
*/
export function autoSubmit(e) {
e.target.closest('form').requestSubmit();
}
/**
* Submit form on Enter key in text inputs.
* Use as: @keydown=${submitOnEnter}
* @param {KeyboardEvent} e
*/
export function submitOnEnter(e) {
if (e.key === 'Enter') {
e.preventDefault();
e.target.closest('form').requestSubmit();
}
}

View 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;

View File

@@ -0,0 +1,103 @@
/**
* MeshCore Hub SPA - SVG Icon Functions
*
* Each function returns a lit-html TemplateResult. Pass a CSS class string to customize size.
*/
import { html } from 'lit-html';
export function iconDashboard(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>`;
}
export function iconMap(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" /></svg>`;
}
export function iconNodes(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>`;
}
export function iconAdvertisements(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg>`;
}
export function iconMessages(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg>`;
}
export function iconHome(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg>`;
}
export function iconMembers(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>`;
}
export function iconPage(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>`;
}
export function iconInfo(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>`;
}
export function iconAlert(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>`;
}
export function iconChart(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" /></svg>`;
}
export function iconRefresh(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>`;
}
export function iconMenu(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" /></svg>`;
}
export function iconGithub(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>`;
}
export function iconExternalLink(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>`;
}
export function iconGlobe(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>`;
}
export function iconError(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>`;
}
export function iconChannel(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg>`;
}
export function iconSuccess(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>`;
}
export function iconLock(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>`;
}
export function iconUser(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>`;
}
export function iconEmail(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>`;
}
export function iconTag(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" /></svg>`;
}
export function iconUsers(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg>`;
}

View File

@@ -0,0 +1,75 @@
import { html, litRender, unsafeHTML, getConfig, errorAlert, t } from '../../components.js';
import { iconLock, iconUsers, iconTag } from '../../icons.js';
export async function render(container, params, router) {
try {
const config = getConfig();
if (!config.admin_enabled) {
litRender(html`
<div class="flex flex-col items-center justify-center py-20">
${iconLock('h-16 w-16 opacity-30 mb-4')}
<h1 class="text-3xl font-bold mb-2">${t('admin.access_denied')}</h1>
<p class="opacity-70">${t('admin.admin_not_enabled')}</p>
<p class="text-sm opacity-50 mt-2">${unsafeHTML(t('admin.admin_enable_hint'))}</p>
<a href="/" class="btn btn-primary mt-6">${t('common.go_home')}</a>
</div>`, container);
return;
}
if (!config.is_authenticated) {
litRender(html`
<div class="flex flex-col items-center justify-center py-20">
${iconLock('h-16 w-16 opacity-30 mb-4')}
<h1 class="text-3xl font-bold mb-2">${t('admin.auth_required')}</h1>
<p class="opacity-70">${t('admin.auth_required_description')}</p>
<a href="/oauth2/start?rd=${encodeURIComponent(window.location.pathname)}" class="btn btn-primary mt-6">${t('common.sign_in')}</a>
</div>`, container);
return;
}
litRender(html`
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-3xl font-bold">${t('entities.admin')}</h1>
<div class="text-sm breadcrumbs">
<ul>
<li><a href="/">${t('entities.home')}</a></li>
<li>${t('entities.admin')}</li>
</ul>
</div>
</div>
<a href="/oauth2/sign_out" target="_blank" class="btn btn-outline btn-sm">${t('common.sign_out')}</a>
</div>
<div class="flex flex-wrap items-center gap-4 text-sm opacity-70 mb-6">
<span class="flex items-center gap-1.5">
${t('admin.welcome')}
</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<a href="/a/members" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body">
<h2 class="card-title">
${iconUsers('h-6 w-6')}
${t('entities.members')}
</h2>
<p>${t('admin.members_description')}</p>
</div>
</a>
<a href="/a/node-tags" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body">
<h2 class="card-title">
${iconTag('h-6 w-6')}
${t('entities.tags')}
</h2>
<p>${t('admin.tags_description')}</p>
</div>
</a>
</div>`, container);
} catch (e) {
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
}
}

View File

@@ -0,0 +1,332 @@
import { apiGet, apiPost, apiPut, apiDelete } from '../../api.js';
import {
html, litRender, nothing,
getConfig, errorAlert, successAlert, t,
} from '../../components.js';
import { iconLock } from '../../icons.js';
export async function render(container, params, router) {
try {
const config = getConfig();
if (!config.admin_enabled) {
litRender(html`
<div class="flex flex-col items-center justify-center py-20">
${iconLock('h-16 w-16 opacity-30 mb-4')}
<h1 class="text-3xl font-bold mb-2">${t('admin.access_denied')}</h1>
<p class="opacity-70">${t('admin.admin_not_enabled')}</p>
<a href="/" class="btn btn-primary mt-6">${t('common.go_home')}</a>
</div>`, container);
return;
}
if (!config.is_authenticated) {
litRender(html`
<div class="flex flex-col items-center justify-center py-20">
${iconLock('h-16 w-16 opacity-30 mb-4')}
<h1 class="text-3xl font-bold mb-2">${t('admin.auth_required')}</h1>
<p class="opacity-70">${t('admin.auth_required_description')}</p>
<a href="/oauth2/start?rd=${encodeURIComponent(window.location.pathname)}" class="btn btn-primary mt-6">${t('common.sign_in')}</a>
</div>`, container);
return;
}
const flashMessage = (params.query && params.query.message) || '';
const flashError = (params.query && params.query.error) || '';
const data = await apiGet('/api/v1/members', { limit: 100 });
const members = data.items || [];
const flashHtml = html`${flashMessage ? successAlert(flashMessage) : nothing}${flashError ? errorAlert(flashError) : nothing}`;
const tableHtml = members.length > 0
? html`
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>${t('admin_members.member_id')}</th>
<th>${t('common.name')}</th>
<th>${t('common.callsign')}</th>
<th>${t('common.contact')}</th>
<th class="w-32">${t('common.actions')}</th>
</tr>
</thead>
<tbody>${members.map(m => html`
<tr data-member-id=${m.id}
data-member-name=${m.name}
data-member-member-id=${m.member_id}
data-member-callsign=${m.callsign || ''}
data-member-description=${m.description || ''}
data-member-contact=${m.contact || ''}>
<td class="font-mono font-semibold">${m.member_id}</td>
<td>${m.name}</td>
<td>
${m.callsign
? html`<span class="badge badge-primary">${m.callsign}</span>`
: html`<span class="text-base-content/40">-</span>`}
</td>
<td class="max-w-xs truncate" title=${m.contact || ''}>${m.contact || '-'}</td>
<td>
<div class="flex gap-1">
<button class="btn btn-ghost btn-xs btn-edit">${t('common.edit')}</button>
<button class="btn btn-ghost btn-xs text-error btn-delete">${t('common.delete')}</button>
</div>
</td>
</tr>`)}</tbody>
</table>
</div>`
: html`
<div class="text-center py-8 text-base-content/60">
<p>${t('common.no_entity_yet', { entity: t('entities.members').toLowerCase() })}</p>
<p class="text-sm mt-2">${t('admin_members.empty_state_hint')}</p>
</div>`;
litRender(html`
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold">${t('entities.members')}</h1>
<div class="text-sm breadcrumbs">
<ul>
<li><a href="/">${t('entities.home')}</a></li>
<li><a href="/a/">${t('entities.admin')}</a></li>
<li>${t('entities.members')}</li>
</ul>
</div>
</div>
<a href="/oauth2/sign_out" target="_blank" class="btn btn-outline btn-sm">${t('common.sign_out')}</a>
</div>
${flashHtml}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex justify-between items-center">
<h2 class="card-title">${t('admin_members.network_members', { count: members.length })}</h2>
<button id="btn-add-member" class="btn btn-primary btn-sm">${t('common.add_entity', { entity: t('entities.member') })}</button>
</div>
${tableHtml}
</div>
</div>
<dialog id="addModal" class="modal">
<div class="modal-box w-11/12 max-w-2xl">
<h3 class="font-bold text-lg">${t('common.add_new_entity', { entity: t('entities.member') })}</h3>
<form id="add-member-form" class="py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">${t('admin_members.member_id')} <span class="text-error">*</span></span>
</label>
<input type="text" name="member_id" class="input input-bordered"
placeholder="walshie86" required maxlength="50"
pattern="[a-zA-Z0-9_]+"
title="Letters, numbers, and underscores only">
<label class="label">
<span class="label-text-alt">${t('admin_members.member_id_hint')}</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">${t('common.name')} <span class="text-error">*</span></span>
</label>
<input type="text" name="name" class="input input-bordered"
placeholder="John Smith" required maxlength="255">
</div>
<div class="form-control">
<label class="label"><span class="label-text">${t('common.callsign')}</span></label>
<input type="text" name="callsign" class="input input-bordered"
placeholder="VK4ABC" maxlength="20">
</div>
<div class="form-control">
<label class="label"><span class="label-text">${t('common.contact')}</span></label>
<input type="text" name="contact" class="input input-bordered"
placeholder="john@example.com or phone number" maxlength="255">
</div>
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text">${t('common.description')}</span></label>
<textarea name="description" rows="3" class="textarea textarea-bordered"
placeholder="Brief description of member's role and responsibilities..."></textarea>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn" id="addCancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn-primary">${t('common.add_entity', { entity: t('entities.member') })}</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>
<dialog id="editModal" class="modal">
<div class="modal-box w-11/12 max-w-2xl">
<h3 class="font-bold text-lg">${t('common.edit_entity', { entity: t('entities.member') })}</h3>
<form id="edit-member-form" class="py-4">
<input type="hidden" name="id" id="edit_id">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">${t('admin_members.member_id')} <span class="text-error">*</span></span>
</label>
<input type="text" name="member_id" id="edit_member_id" class="input input-bordered"
required maxlength="50" pattern="[a-zA-Z0-9_]+"
title="Letters, numbers, and underscores only">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">${t('common.name')} <span class="text-error">*</span></span>
</label>
<input type="text" name="name" id="edit_name" class="input input-bordered"
required maxlength="255">
</div>
<div class="form-control">
<label class="label"><span class="label-text">${t('common.callsign')}</span></label>
<input type="text" name="callsign" id="edit_callsign" class="input input-bordered"
maxlength="20">
</div>
<div class="form-control">
<label class="label"><span class="label-text">${t('common.contact')}</span></label>
<input type="text" name="contact" id="edit_contact" class="input input-bordered"
maxlength="255">
</div>
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text">${t('common.description')}</span></label>
<textarea name="description" id="edit_description" rows="3"
class="textarea textarea-bordered"></textarea>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn" id="editCancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn-primary">${t('common.save_changes')}</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>
<dialog id="deleteModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">${t('common.delete_entity', { entity: t('entities.member') })}</h3>
<div class="py-4">
<p class="py-4" id="delete_confirm_message"></p>
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>${t('common.cannot_be_undone')}</span>
</div>
<div class="modal-action">
<button type="button" class="btn" id="deleteCancel">${t('common.cancel')}</button>
<button type="button" class="btn btn-error" id="deleteConfirm">${t('common.delete')}</button>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>`, container);
let activeDeleteId = '';
// Add Member
container.querySelector('#btn-add-member').addEventListener('click', () => {
const form = container.querySelector('#add-member-form');
form.reset();
container.querySelector('#addModal').showModal();
});
container.querySelector('#addCancel').addEventListener('click', () => {
container.querySelector('#addModal').close();
});
container.querySelector('#add-member-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const body = {
member_id: form.member_id.value.trim(),
name: form.name.value.trim(),
callsign: form.callsign.value.trim() || null,
description: form.description.value.trim() || null,
contact: form.contact.value.trim() || null,
};
try {
await apiPost('/api/v1/members', body);
container.querySelector('#addModal').close();
router.navigate('/a/members?message=' + encodeURIComponent(t('common.entity_added_success', { entity: t('entities.member') })));
} catch (err) {
container.querySelector('#addModal').close();
router.navigate('/a/members?error=' + encodeURIComponent(err.message));
}
});
// Edit Member
container.querySelectorAll('.btn-edit').forEach(btn => {
btn.addEventListener('click', () => {
const row = btn.closest('tr');
container.querySelector('#edit_id').value = row.dataset.memberId;
container.querySelector('#edit_member_id').value = row.dataset.memberMemberId;
container.querySelector('#edit_name').value = row.dataset.memberName;
container.querySelector('#edit_callsign').value = row.dataset.memberCallsign;
container.querySelector('#edit_description').value = row.dataset.memberDescription;
container.querySelector('#edit_contact').value = row.dataset.memberContact;
container.querySelector('#editModal').showModal();
});
});
container.querySelector('#editCancel').addEventListener('click', () => {
container.querySelector('#editModal').close();
});
container.querySelector('#edit-member-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const id = form.id.value;
const body = {
member_id: form.member_id.value.trim(),
name: form.name.value.trim(),
callsign: form.callsign.value.trim() || null,
description: form.description.value.trim() || null,
contact: form.contact.value.trim() || null,
};
try {
await apiPut('/api/v1/members/' + encodeURIComponent(id), body);
container.querySelector('#editModal').close();
router.navigate('/a/members?message=' + encodeURIComponent(t('common.entity_updated_success', { entity: t('entities.member') })));
} catch (err) {
container.querySelector('#editModal').close();
router.navigate('/a/members?error=' + encodeURIComponent(err.message));
}
});
// Delete Member
container.querySelectorAll('.btn-delete').forEach(btn => {
btn.addEventListener('click', () => {
const row = btn.closest('tr');
activeDeleteId = row.dataset.memberId;
const memberName = row.dataset.memberName;
const confirmMsg = t('common.delete_entity_confirm', {
entity: t('entities.member').toLowerCase(),
name: memberName
});
container.querySelector('#delete_confirm_message').innerHTML = confirmMsg;
container.querySelector('#deleteModal').showModal();
});
});
container.querySelector('#deleteCancel').addEventListener('click', () => {
container.querySelector('#deleteModal').close();
});
container.querySelector('#deleteConfirm').addEventListener('click', async () => {
try {
await apiDelete('/api/v1/members/' + encodeURIComponent(activeDeleteId));
container.querySelector('#deleteModal').close();
router.navigate('/a/members?message=' + encodeURIComponent(t('common.entity_deleted_success', { entity: t('entities.member') })));
} catch (err) {
container.querySelector('#deleteModal').close();
router.navigate('/a/members?error=' + encodeURIComponent(err.message));
}
});
} catch (e) {
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
}
}

View File

@@ -0,0 +1,530 @@
import { apiGet, apiPost, apiPut, apiDelete } from '../../api.js';
import {
html, litRender, nothing, unsafeHTML,
getConfig, typeEmoji, formatDateTimeShort, errorAlert,
successAlert, truncateKey, t,
} from '../../components.js';
import { iconTag, iconLock } from '../../icons.js';
export async function render(container, params, router) {
try {
const config = getConfig();
if (!config.admin_enabled) {
litRender(html`
<div class="flex flex-col items-center justify-center py-20">
${iconLock('h-16 w-16 opacity-30 mb-4')}
<h1 class="text-3xl font-bold mb-2">${t('admin.access_denied')}</h1>
<p class="opacity-70">${t('admin.admin_not_enabled')}</p>
<a href="/" class="btn btn-primary mt-6">${t('common.go_home')}</a>
</div>`, container);
return;
}
if (!config.is_authenticated) {
litRender(html`
<div class="flex flex-col items-center justify-center py-20">
${iconLock('h-16 w-16 opacity-30 mb-4')}
<h1 class="text-3xl font-bold mb-2">${t('admin.auth_required')}</h1>
<p class="opacity-70">${t('admin.auth_required_description')}</p>
<a href="/oauth2/start?rd=${encodeURIComponent(window.location.pathname)}" class="btn btn-primary mt-6">${t('common.sign_in')}</a>
</div>`, container);
return;
}
const selectedPublicKey = (params.query && params.query.public_key) || '';
const flashMessage = (params.query && params.query.message) || '';
const flashError = (params.query && params.query.error) || '';
const nodesData = await apiGet('/api/v1/nodes', { limit: 500 });
const allNodes = nodesData.items || [];
let selectedNode = null;
let tags = [];
if (selectedPublicKey) {
try {
selectedNode = await apiGet('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey));
tags = selectedNode.tags || [];
} catch {
selectedNode = null;
}
}
const flashHtml = html`${flashMessage ? successAlert(flashMessage) : nothing}${flashError ? errorAlert(flashError) : nothing}`;
let contentHtml = nothing;
if (selectedPublicKey && selectedNode) {
const nodeEmoji = typeEmoji(selectedNode.adv_type);
const nodeName = selectedNode.name || t('common.unnamed_node');
const otherNodes = allNodes.filter(n => n.public_key !== selectedPublicKey);
const tagsTableHtml = tags.length > 0
? html`
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>${t('common.key')}</th>
<th>${t('common.value')}</th>
<th>${t('common.type')}</th>
<th>${t('common.updated')}</th>
<th class="w-48">${t('common.actions')}</th>
</tr>
</thead>
<tbody>${tags.map(tag => html`
<tr data-tag-key=${tag.key} data-tag-value=${tag.value || ''} data-tag-type=${tag.value_type}>
<td class="font-mono font-semibold">${tag.key}</td>
<td class="max-w-xs truncate" title=${tag.value || ''}>${tag.value || '-'}</td>
<td>
<span class="badge badge-ghost badge-sm">${tag.value_type}</span>
</td>
<td class="text-sm opacity-70">${formatDateTimeShort(tag.updated_at)}</td>
<td>
<div class="flex gap-1">
<button class="btn btn-ghost btn-xs btn-edit">${t('common.edit')}</button>
<button class="btn btn-ghost btn-xs btn-move">${t('common.move')}</button>
<button class="btn btn-ghost btn-xs text-error btn-delete">${t('common.delete')}</button>
</div>
</td>
</tr>`)}</tbody>
</table>
</div>`
: html`
<div class="text-center py-8 text-base-content/60">
<p>${t('common.no_entity_found', { entity: t('entities.tags').toLowerCase() }) + ' ' + t('admin_node_tags.for_this_node')}</p>
<p class="text-sm mt-2">${t('admin_node_tags.empty_state_hint')}</p>
</div>`;
const bulkButtons = tags.length > 0
? html`
<button id="btn-copy-all" class="btn btn-outline btn-sm">${t('admin_node_tags.copy_all')}</button>
<button id="btn-delete-all" class="btn btn-outline btn-error btn-sm">${t('admin_node_tags.delete_all')}</button>`
: nothing;
contentHtml = html`
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<div class="flex justify-between items-start">
<div class="flex items-start gap-3">
<span class="text-2xl" title=${selectedNode.adv_type || 'Unknown'}>${nodeEmoji}</span>
<div>
<h2 class="card-title">${nodeName}</h2>
<p class="text-sm opacity-70 font-mono">${selectedPublicKey}</p>
</div>
</div>
<div class="flex gap-2">
${bulkButtons}
<a href="/nodes/${encodeURIComponent(selectedPublicKey)}" class="btn btn-ghost btn-sm">${t('common.view_entity', { entity: t('entities.node') })}</a>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title">${t('entities.tags')} (${tags.length})</h2>
${tagsTableHtml}
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">${t('common.add_new_entity', { entity: t('entities.tag') })}</h2>
<form id="add-tag-form" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="form-control">
<label class="label"><span class="label-text">${t('common.key')}</span></label>
<input type="text" name="key" class="input input-bordered" placeholder="tag_name" required maxlength="100">
</div>
<div class="form-control">
<label class="label"><span class="label-text">${t('common.value')}</span></label>
<input type="text" name="value" class="input input-bordered" placeholder="tag value">
</div>
<div class="form-control">
<label class="label"><span class="label-text">${t('common.type')}</span></label>
<select name="value_type" class="select select-bordered">
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
</select>
</div>
<div class="form-control">
<label class="label"><span class="label-text">&nbsp;</span></label>
<button type="submit" class="btn btn-primary">${t('common.add_entity', { entity: t('entities.tag') })}</button>
</div>
</form>
</div>
</div>
<dialog id="editModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">${t('common.edit_entity', { entity: t('entities.tag') })}</h3>
<form id="edit-tag-form" class="py-4">
<div class="form-control mb-4">
<label class="label"><span class="label-text">${t('common.key')}</span></label>
<input type="text" id="editKeyDisplay" class="input input-bordered" disabled>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">${t('common.value')}</span></label>
<input type="text" id="editValue" class="input input-bordered">
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">${t('common.type')}</span></label>
<select id="editValueType" class="select select-bordered w-full">
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
</select>
</div>
<div class="modal-action">
<button type="button" class="btn" id="editCancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn-primary">${t('common.save_changes')}</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>
<dialog id="moveModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">${t('common.move_entity_to_another_node', { entity: t('entities.tag') })}</h3>
<form id="move-tag-form" class="py-4">
<div class="form-control mb-4">
<label class="label"><span class="label-text">${t('admin_node_tags.tag_key')}</span></label>
<input type="text" id="moveKeyDisplay" class="input input-bordered" disabled>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">${t('admin_node_tags.destination_node')}</span></label>
<select id="moveDestination" class="select select-bordered w-full" required>
<option value="">${t('map.select_destination_node')}</option>
${otherNodes.map(n => {
const name = n.name || t('common.unnamed');
const keyPreview = n.public_key.slice(0, 8) + '...' + n.public_key.slice(-4);
return html`<option value=${n.public_key}>${name} (${keyPreview})</option>`;
})}
</select>
</div>
<div class="alert alert-warning mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>${t('admin_node_tags.move_warning')}</span>
</div>
<div class="modal-action">
<button type="button" class="btn" id="moveCancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn-warning">${t('common.move_entity', { entity: t('entities.tag') })}</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>
<dialog id="deleteModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">${t('common.delete_entity', { entity: t('entities.tag') })}</h3>
<div class="py-4">
<p class="py-4" id="delete_tag_confirm_message"></p>
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>${t('common.cannot_be_undone')}</span>
</div>
<div class="modal-action">
<button type="button" class="btn" id="deleteCancel">${t('common.cancel')}</button>
<button type="button" class="btn btn-error" id="deleteConfirm">${t('common.delete')}</button>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>
<dialog id="copyAllModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">${t('common.copy_all_entity_to_another_node', { entity: t('entities.tags') })}</h3>
<form id="copy-all-form" class="py-4">
<p class="mb-4">${unsafeHTML(t('common.copy_all_entity_description', { count: tags.length, entity: t('entities.tags').toLowerCase(), name: nodeName }))}</p>
<div class="form-control mb-4">
<label class="label"><span class="label-text">${t('admin_node_tags.destination_node')}</span></label>
<select id="copyAllDestination" class="select select-bordered w-full" required>
<option value="">${t('map.select_destination_node')}</option>
${otherNodes.map(n => {
const name = n.name || t('common.unnamed');
const keyPreview = n.public_key.slice(0, 8) + '...' + n.public_key.slice(-4);
return html`<option value=${n.public_key}>${name} (${keyPreview})</option>`;
})}
</select>
</div>
<div class="alert alert-info mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>${t('admin_node_tags.copy_all_info')}</span>
</div>
<div class="modal-action">
<button type="button" class="btn" id="copyAllCancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn-primary">${t('common.copy_entity', { entity: t('entities.tags') })}</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>
<dialog id="deleteAllModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">${t('common.delete_all_entity', { entity: t('entities.tags') })}</h3>
<div class="py-4">
<p class="mb-4">${unsafeHTML(t('common.delete_all_entity_confirm', { count: tags.length, entity: t('entities.tags').toLowerCase(), name: nodeName }))}</p>
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>${t('admin_node_tags.delete_all_warning')}</span>
</div>
<div class="modal-action">
<button type="button" class="btn" id="deleteAllCancel">${t('common.cancel')}</button>
<button type="button" class="btn btn-error" id="deleteAllConfirm">${t('common.delete_all_entity', { entity: t('entities.tags') })}</button>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>`;
} else if (selectedPublicKey && !selectedNode) {
contentHtml = html`
<div class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>Node not found: ${selectedPublicKey}</span>
</div>`;
} else {
contentHtml = html`
<div class="card bg-base-100 shadow-xl">
<div class="card-body text-center py-12">
${iconTag('h-16 w-16 mx-auto mb-4 opacity-30')}
<h2 class="text-xl font-semibold mb-2">${t('admin_node_tags.select_a_node')}</h2>
<p class="opacity-70">${t('admin_node_tags.select_a_node_description')}</p>
</div>
</div>`;
}
litRender(html`
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold">${t('entities.tags')}</h1>
<div class="text-sm breadcrumbs">
<ul>
<li><a href="/">${t('entities.home')}</a></li>
<li><a href="/a/">${t('entities.admin')}</a></li>
<li>${t('entities.tags')}</li>
</ul>
</div>
</div>
<a href="/oauth2/sign_out" target="_blank" class="btn btn-outline btn-sm">${t('common.sign_out')}</a>
</div>
${flashHtml}
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title">${t('admin_node_tags.select_node')}</h2>
<div class="flex gap-4 items-end">
<div class="form-control flex-1">
<label class="label"><span class="label-text">${t('entities.node')}</span></label>
<select id="node-selector" class="select select-bordered w-full">
<option value="">${t('admin_node_tags.select_node_placeholder')}</option>
${allNodes.map(n => {
const name = n.name || t('common.unnamed');
const keyPreview = n.public_key.slice(0, 8) + '...' + n.public_key.slice(-4);
return html`<option value=${n.public_key} ?selected=${n.public_key === selectedPublicKey}>${name} (${keyPreview})</option>`;
})}
</select>
</div>
<button id="load-tags-btn" class="btn btn-primary">${t('admin_node_tags.load_tags')}</button>
</div>
</div>
</div>
${contentHtml}`, container);
// Event: node selector change
const nodeSelector = container.querySelector('#node-selector');
nodeSelector.addEventListener('change', () => {
const pk = nodeSelector.value;
if (pk) {
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(pk));
} else {
router.navigate('/a/node-tags');
}
});
container.querySelector('#load-tags-btn').addEventListener('click', () => {
const pk = nodeSelector.value;
if (pk) {
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(pk));
}
});
if (selectedPublicKey && selectedNode) {
let activeTagKey = '';
// Add tag form
container.querySelector('#add-tag-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const key = form.key.value.trim();
const value = form.value.value;
const value_type = form.value_type.value;
try {
await apiPost('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags', {
key, value, value_type,
});
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_added_success', { entity: t('entities.tag') })));
} catch (err) {
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
}
});
// Edit button handlers
container.querySelectorAll('.btn-edit').forEach(btn => {
btn.addEventListener('click', () => {
const row = btn.closest('tr');
activeTagKey = row.dataset.tagKey;
container.querySelector('#editKeyDisplay').value = activeTagKey;
container.querySelector('#editValue').value = row.dataset.tagValue;
container.querySelector('#editValueType').value = row.dataset.tagType;
container.querySelector('#editModal').showModal();
});
});
container.querySelector('#editCancel').addEventListener('click', () => {
container.querySelector('#editModal').close();
});
container.querySelector('#edit-tag-form').addEventListener('submit', async (e) => {
e.preventDefault();
const value = container.querySelector('#editValue').value;
const value_type = container.querySelector('#editValueType').value;
try {
await apiPut('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags/' + encodeURIComponent(activeTagKey), {
value, value_type,
});
container.querySelector('#editModal').close();
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_updated_success', { entity: t('entities.tag') })));
} catch (err) {
container.querySelector('#editModal').close();
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
}
});
// Move button handlers
container.querySelectorAll('.btn-move').forEach(btn => {
btn.addEventListener('click', () => {
const row = btn.closest('tr');
activeTagKey = row.dataset.tagKey;
container.querySelector('#moveKeyDisplay').value = activeTagKey;
container.querySelector('#moveDestination').selectedIndex = 0;
container.querySelector('#moveModal').showModal();
});
});
container.querySelector('#moveCancel').addEventListener('click', () => {
container.querySelector('#moveModal').close();
});
container.querySelector('#move-tag-form').addEventListener('submit', async (e) => {
e.preventDefault();
const newPublicKey = container.querySelector('#moveDestination').value;
if (!newPublicKey) return;
try {
await apiPut('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags/' + encodeURIComponent(activeTagKey) + '/move', {
new_public_key: newPublicKey,
});
container.querySelector('#moveModal').close();
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_moved_success', { entity: t('entities.tag') })));
} catch (err) {
container.querySelector('#moveModal').close();
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
}
});
// Delete button handlers
container.querySelectorAll('.btn-delete').forEach(btn => {
btn.addEventListener('click', () => {
const row = btn.closest('tr');
activeTagKey = row.dataset.tagKey;
const confirmMsg = t('common.delete_entity_confirm', {
entity: t('entities.tag').toLowerCase(),
name: `"<span class="font-mono font-semibold">${activeTagKey}</span>"`
});
container.querySelector('#delete_tag_confirm_message').innerHTML = confirmMsg;
container.querySelector('#deleteModal').showModal();
});
});
container.querySelector('#deleteCancel').addEventListener('click', () => {
container.querySelector('#deleteModal').close();
});
container.querySelector('#deleteConfirm').addEventListener('click', async () => {
try {
await apiDelete('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags/' + encodeURIComponent(activeTagKey));
container.querySelector('#deleteModal').close();
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_deleted_success', { entity: t('entities.tag') })));
} catch (err) {
container.querySelector('#deleteModal').close();
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
}
});
// Copy All button
const copyAllBtn = container.querySelector('#btn-copy-all');
if (copyAllBtn) {
copyAllBtn.addEventListener('click', () => {
container.querySelector('#copyAllDestination').selectedIndex = 0;
container.querySelector('#copyAllModal').showModal();
});
container.querySelector('#copyAllCancel').addEventListener('click', () => {
container.querySelector('#copyAllModal').close();
});
container.querySelector('#copy-all-form').addEventListener('submit', async (e) => {
e.preventDefault();
const destKey = container.querySelector('#copyAllDestination').value;
if (!destKey) return;
try {
const result = await apiPost('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags/copy-to/' + encodeURIComponent(destKey));
container.querySelector('#copyAllModal').close();
const msg = t('admin_node_tags.copied_entities', { copied: result.copied, skipped: result.skipped });
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(msg));
} catch (err) {
container.querySelector('#copyAllModal').close();
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
}
});
}
// Delete All button
const deleteAllBtn = container.querySelector('#btn-delete-all');
if (deleteAllBtn) {
deleteAllBtn.addEventListener('click', () => {
container.querySelector('#deleteAllModal').showModal();
});
container.querySelector('#deleteAllCancel').addEventListener('click', () => {
container.querySelector('#deleteAllModal').close();
});
container.querySelector('#deleteAllConfirm').addEventListener('click', async () => {
try {
await apiDelete('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags');
container.querySelector('#deleteAllModal').close();
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.all_entity_deleted_success', { entity: t('entities.tags').toLowerCase() })));
} catch (err) {
container.querySelector('#deleteAllModal').close();
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
}
});
}
}
} catch (e) {
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
}
}

View File

@@ -0,0 +1,230 @@
import { apiGet } from '../api.js';
import {
html, litRender, nothing, t,
getConfig, formatDateTime, formatDateTimeShort,
truncateKey, errorAlert,
pagination, createFilterHandler, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay
} from '../components.js';
import { createAutoRefresh } from '../auto-refresh.js';
export async function render(container, params, router) {
const query = params.query || {};
const search = query.search || '';
const public_key = query.public_key || '';
const member_id = query.member_id || '';
const page = parseInt(query.page, 10) || 1;
const limit = parseInt(query.limit, 10) || 20;
const offset = (page - 1) * limit;
const config = getConfig();
const features = config.features || {};
const showMembers = features.members !== false;
const tz = config.timezone || '';
const tzBadge = tz && tz !== 'UTC' ? html`<span class="text-sm opacity-60">${tz}</span>` : nothing;
const navigate = (url) => router.navigate(url);
function renderPage(content, { total = null } = {}) {
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">${t('entities.advertisements')}</h1>
<div class="flex items-center gap-2">
<span id="auto-refresh-toggle"></span>
${tzBadge}
${total !== null ? html`<span class="badge badge-lg">${t('common.total', { count: total })}</span>` : nothing}
</div>
</div>
${content}`, container);
}
// Render page header immediately (old content stays visible until data loads)
renderPage(nothing);
async function fetchAndRenderData() {
try {
const requests = [
apiGet('/api/v1/advertisements', { limit, offset, search, public_key, member_id }),
apiGet('/api/v1/nodes', { limit: 500 }),
];
if (showMembers) {
requests.push(apiGet('/api/v1/members', { limit: 100 }));
}
const results = await Promise.all(requests);
const data = results[0];
const nodesData = results[1];
const membersData = showMembers ? results[2] : null;
const advertisements = data.items || [];
const total = data.total || 0;
const totalPages = Math.ceil(total / limit);
const allNodes = nodesData.items || [];
const members = membersData?.items || [];
const sortedNodes = allNodes.map(n => {
const tagName = n.tags?.find(t => t.key === 'name')?.value;
return { ...n, _sortName: (tagName || n.name || '').toLowerCase(), _displayName: tagName || n.name || n.public_key.slice(0, 12) + '...' };
}).sort((a, b) => a._sortName.localeCompare(b._sortName));
const nodesFilter = sortedNodes.length > 0
? html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('entities.node')}</span>
</label>
<select name="public_key" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="">${t('common.all_entity', { entity: t('entities.nodes') })}</option>
${sortedNodes.map(n => html`<option value=${n.public_key} ?selected=${public_key === n.public_key}>${n._displayName}</option>`)}
</select>
</div>`
: nothing;
const membersFilter = (showMembers && members.length > 0)
? html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('entities.member')}</span>
</label>
<select name="member_id" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="">${t('common.all_entity', { entity: t('entities.members') })}</option>
${members.map(m => html`<option value=${m.member_id} ?selected=${member_id === m.member_id}>${m.name}${m.callsign ? ` (${m.callsign})` : ''}</option>`)}
</select>
</div>`
: nothing;
const mobileCards = advertisements.length === 0
? html`<div class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}</div>`
: advertisements.map(ad => {
const adName = ad.node_tag_name || ad.node_name || ad.name;
const adDescription = ad.node_tag_description;
let receiversBlock = nothing;
if (ad.receivers && ad.receivers.length >= 1) {
receiversBlock = html`<div class="flex gap-0.5 justify-end mt-1">
${ad.receivers.map(recv => {
const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12);
return html`<span class="text-sm" title=${recvName}>\u{1F4E1}</span>`;
})}
</div>`;
} else if (ad.received_by) {
const recvTitle = ad.receiver_tag_name || ad.receiver_name || truncateKey(ad.received_by, 12);
receiversBlock = html`<span class="text-sm" title=${recvTitle}>\u{1F4E1}</span>`;
}
return html`<a href="/nodes/${ad.public_key}" class="card bg-base-100 shadow-sm block">
<div class="card-body p-3">
<div class="flex items-center justify-between gap-2">
${renderNodeDisplay({
name: adName,
description: adDescription,
publicKey: ad.public_key,
advType: ad.adv_type,
size: 'sm'
})}
<div class="text-right flex-shrink-0">
<div class="text-xs opacity-60">${formatDateTimeShort(ad.received_at)}</div>
${receiversBlock}
</div>
</div>
</div>
</a>`;
});
const tableRows = advertisements.length === 0
? html`<tr><td colspan="4" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}</td></tr>`
: advertisements.map(ad => {
const adName = ad.node_tag_name || ad.node_name || ad.name;
const adDescription = ad.node_tag_description;
let receiversBlock;
if (ad.receivers && ad.receivers.length >= 1) {
receiversBlock = html`<div class="flex gap-1">
${ad.receivers.map(recv => {
const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12);
return html`<a href="/nodes/${recv.public_key}" class="text-lg hover:opacity-70" title=${recvName}>\u{1F4E1}</a>`;
})}
</div>`;
} else if (ad.received_by) {
const recvTitle = ad.receiver_tag_name || ad.receiver_name || truncateKey(ad.received_by, 12);
receiversBlock = html`<a href="/nodes/${ad.received_by}" class="text-lg hover:opacity-70" title=${recvTitle}>\u{1F4E1}</a>`;
} else {
receiversBlock = html`<span class="opacity-50">-</span>`;
}
return html`<tr class="hover">
<td>
<a href="/nodes/${ad.public_key}" class="link link-hover">
${renderNodeDisplay({
name: adName,
description: adDescription,
publicKey: ad.public_key,
advType: ad.adv_type,
size: 'base'
})}
</a>
</td>
<td>
<code class="font-mono text-xs cursor-pointer hover:bg-base-200 px-1 py-0.5 rounded select-all"
@click=${(e) => copyToClipboard(e, ad.public_key)}
title="Click to copy">${ad.public_key}</code>
</td>
<td class="text-sm whitespace-nowrap">${formatDateTime(ad.received_at)}</td>
<td>${receiversBlock}</td>
</tr>`;
});
const paginationBlock = pagination(page, totalPages, '/advertisements', {
search, public_key, member_id, limit,
});
renderPage(html`
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
<div class="card-body py-4">
<form method="GET" action="/advertisements" class="flex gap-4 flex-wrap items-end" @submit=${createFilterHandler('/advertisements', navigate)}>
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('common.search')}</span>
</label>
<input type="text" name="search" .value=${search} placeholder="${t('common.search_placeholder')}" class="input input-bordered input-sm w-80" @keydown=${submitOnEnter} />
</div>
${nodesFilter}
${membersFilter}
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">${t('common.filter')}</button>
<a href="/advertisements" class="btn btn-ghost btn-sm">${t('common.clear')}</a>
</div>
</form>
</div>
</div>
<div class="lg:hidden space-y-3">
${mobileCards}
</div>
<div class="hidden lg:block overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow">
<table class="table table-zebra">
<thead>
<tr>
<th>${t('entities.node')}</th>
<th>${t('common.public_key')}</th>
<th>${t('common.time')}</th>
<th>${t('common.receivers')}</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
</div>
${paginationBlock}`, { total });
} catch (e) {
renderPage(errorAlert(e.message));
}
}
await fetchAndRenderData();
const toggleEl = container.querySelector('#auto-refresh-toggle');
const { cleanup } = createAutoRefresh({
fetchAndRender: fetchAndRenderData,
toggleContainer: toggleEl,
});
return cleanup;
}

View File

@@ -0,0 +1,28 @@
import { apiGet } from '../api.js';
import { html, litRender, unsafeHTML, getConfig, errorAlert, t } from '../components.js';
export async function render(container, params, router) {
try {
const page = await apiGet('/spa/pages/' + encodeURIComponent(params.slug));
const config = getConfig();
const networkName = config.network_name || 'MeshCore Network';
document.title = `${page.title} - ${networkName}`;
litRender(html`
<div class="max-w-4xl mx-auto">
<div class="card bg-base-100 shadow-xl">
<div class="card-body prose prose-lg max-w-none">
${unsafeHTML(page.content_html)}
</div>
</div>
</div>`, container);
} catch (e) {
if (e.message && e.message.includes('404')) {
litRender(errorAlert(t('common.page_not_found')), container);
} else {
litRender(errorAlert(e.message || t('custom_page.failed_to_load')), container);
}
}
}

View File

@@ -0,0 +1,261 @@
import { apiGet } from '../api.js';
import {
html, litRender, nothing,
getConfig, typeEmoji, errorAlert, pageColors, t,
} from '../components.js';
import {
iconNodes, iconAdvertisements, iconMessages, iconChannel,
} from '../icons.js';
function formatTimeOnly(isoString) {
if (!isoString) return '-';
try {
const config = getConfig();
const tz = config.timezone_iana || 'UTC';
const date = new Date(isoString);
if (isNaN(date.getTime())) return '-';
return date.toLocaleString('en-GB', {
timeZone: tz,
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
});
} catch {
return '-';
}
}
function formatTimeShort(isoString) {
if (!isoString) return '-';
try {
const config = getConfig();
const tz = config.timezone_iana || 'UTC';
const date = new Date(isoString);
if (isNaN(date.getTime())) return '-';
return date.toLocaleString('en-GB', {
timeZone: tz,
hour: '2-digit', minute: '2-digit',
hour12: false,
});
} catch {
return '-';
}
}
function renderRecentAds(ads) {
if (!ads || ads.length === 0) {
return html`<p class="text-sm opacity-70">${t('common.no_entity_yet', { entity: t('entities.advertisements').toLowerCase() })}</p>`;
}
const rows = ads.slice(0, 5).map(ad => {
const friendlyName = ad.tag_name || ad.name;
const displayName = friendlyName || (ad.public_key.slice(0, 12) + '...');
const keyLine = friendlyName
? html`<div class="text-xs opacity-50 font-mono">${ad.public_key.slice(0, 12)}...</div>`
: nothing;
return html`<tr>
<td>
<a href="/nodes/${ad.public_key}" class="link link-hover">
<div class="font-medium">${displayName}</div>
</a>
${keyLine}
</td>
<td>${ad.adv_type ? typeEmoji(ad.adv_type) : html`<span class="opacity-50">-</span>`}</td>
<td class="text-right text-sm opacity-70">${formatTimeOnly(ad.received_at)}</td>
</tr>`;
});
return html`<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
<tr>
<th>${t('entities.node')}</th>
<th>${t('common.type')}</th>
<th class="text-right">${t('common.received')}</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
function renderChannelMessages(channelMessages) {
if (!channelMessages || Object.keys(channelMessages).length === 0) return nothing;
const channels = Object.entries(channelMessages).map(([channel, messages]) => {
const msgLines = messages.map(msg => html`
<div class="text-sm">
<span class="text-xs opacity-50">${formatTimeShort(msg.received_at)}</span>
<span class="break-words" style="white-space: pre-wrap;">${msg.text || ''}</span>
</div>`);
return html`<div>
<h3 class="font-semibold text-sm mb-2 flex items-center gap-2">
<span class="badge badge-info badge-sm">CH${String(channel)}</span>
${t('dashboard.channel', { number: String(channel) })}
</h3>
<div class="space-y-1 pl-2 border-l-2 border-base-300">
${msgLines}
</div>
</div>`;
});
return html`<div class="card bg-base-100 shadow-xl panel-glow" style="--panel-color: var(--color-neutral)">
<div class="card-body">
<h2 class="card-title">
${iconChannel('h-6 w-6')}
${t('dashboard.recent_channel_messages')}
</h2>
<div class="space-y-4">
${channels}
</div>
</div>
</div>`;
}
/** Return a Tailwind grid-cols class for the given visible column count. */
function gridCols(count) {
if (count <= 1) return '';
return `md:grid-cols-${count}`;
}
export async function render(container, params, router) {
try {
const config = getConfig();
const features = config.features || {};
const showNodes = features.nodes !== false;
const showAdverts = features.advertisements !== false;
const showMessages = features.messages !== false;
const [stats, advertActivity, messageActivity, nodeCount] = await Promise.all([
apiGet('/api/v1/dashboard/stats'),
apiGet('/api/v1/dashboard/activity', { days: 7 }),
apiGet('/api/v1/dashboard/message-activity', { days: 7 }),
apiGet('/api/v1/dashboard/node-count', { days: 7 }),
]);
// Top section: stats + charts
const topCount = (showNodes ? 1 : 0) + (showAdverts ? 1 : 0) + (showMessages ? 1 : 0);
const topGrid = gridCols(topCount);
// Bottom section: recent adverts + recent channel messages
const bottomCount = (showAdverts ? 1 : 0) + (showMessages ? 1 : 0);
const bottomGrid = gridCols(bottomCount);
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">${t('entities.dashboard')}</h1>
</div>
${topCount > 0 ? html`
<div class="grid grid-cols-1 ${topGrid} gap-6 mb-6">
${showNodes ? html`
<div class="stat bg-base-100 rounded-box shadow-xl panel-glow" style="--panel-color: ${pageColors.nodes}">
<div class="stat-figure" style="color: ${pageColors.nodes}">
${iconNodes('h-8 w-8')}
</div>
<div class="stat-title">${t('common.total_entity', { entity: t('entities.nodes') })}</div>
<div class="stat-value" style="color: ${pageColors.nodes}">${stats.total_nodes}</div>
<div class="stat-desc">${t('dashboard.all_discovered_nodes')}</div>
</div>` : nothing}
${showAdverts ? html`
<div class="stat bg-base-100 rounded-box shadow-xl panel-glow" style="--panel-color: ${pageColors.adverts}">
<div class="stat-figure" style="color: ${pageColors.adverts}">
${iconAdvertisements('h-8 w-8')}
</div>
<div class="stat-title">${t('entities.advertisements')}</div>
<div class="stat-value" style="color: ${pageColors.adverts}">${stats.advertisements_7d}</div>
<div class="stat-desc">${t('time.last_7_days')}</div>
</div>` : nothing}
${showMessages ? html`
<div class="stat bg-base-100 rounded-box shadow-xl panel-glow" style="--panel-color: ${pageColors.messages}">
<div class="stat-figure" style="color: ${pageColors.messages}">
${iconMessages('h-8 w-8')}
</div>
<div class="stat-title">${t('entities.messages')}</div>
<div class="stat-value" style="color: ${pageColors.messages}">${stats.messages_7d}</div>
<div class="stat-desc">${t('time.last_7_days')}</div>
</div>` : nothing}
</div>
<div class="grid grid-cols-1 ${topGrid} gap-6 mb-8">
${showNodes ? html`
<div class="card bg-base-100 shadow-xl panel-glow" style="--panel-color: var(--color-neutral)">
<div class="card-body">
<h2 class="card-title text-base">
${iconNodes('h-5 w-5')}
${t('common.total_entity', { entity: t('entities.nodes') })}
</h2>
<p class="text-xs opacity-70">${t('time.over_time_last_7_days')}</p>
<div class="h-32">
<canvas id="nodeChart"></canvas>
</div>
</div>
</div>` : nothing}
${showAdverts ? html`
<div class="card bg-base-100 shadow-xl panel-glow" style="--panel-color: var(--color-neutral)">
<div class="card-body">
<h2 class="card-title text-base">
${iconAdvertisements('h-5 w-5')}
${t('entities.advertisements')}
</h2>
<p class="text-xs opacity-70">${t('time.per_day_last_7_days')}</p>
<div class="h-32">
<canvas id="advertChart"></canvas>
</div>
</div>
</div>` : nothing}
${showMessages ? html`
<div class="card bg-base-100 shadow-xl panel-glow" style="--panel-color: var(--color-neutral)">
<div class="card-body">
<h2 class="card-title text-base">
${iconMessages('h-5 w-5')}
${t('entities.messages')}
</h2>
<p class="text-xs opacity-70">${t('time.per_day_last_7_days')}</p>
<div class="h-32">
<canvas id="messageChart"></canvas>
</div>
</div>
</div>` : nothing}
</div>` : nothing}
${bottomCount > 0 ? html`
<div class="grid grid-cols-1 ${bottomGrid} gap-6">
${showAdverts ? html`
<div class="card bg-base-100 shadow-xl panel-glow" style="--panel-color: var(--color-neutral)">
<div class="card-body">
<h2 class="card-title">
${iconAdvertisements('h-6 w-6')}
${t('common.recent_entity', { entity: t('entities.advertisements') })}
</h2>
${renderRecentAds(stats.recent_advertisements)}
</div>
</div>` : nothing}
${showMessages ? renderChannelMessages(stats.channel_messages) : nothing}
</div>` : nothing}`, container);
window.initDashboardCharts(
showNodes ? nodeCount : null,
showAdverts ? advertActivity : null,
showMessages ? messageActivity : null,
);
const chartIds = ['nodeChart', 'advertChart', 'messageChart'];
return () => {
chartIds.forEach(id => {
const canvas = document.getElementById(id);
if (canvas) {
const instance = window.Chart.getChart(canvas);
if (instance) instance.destroy();
}
});
};
} catch (e) {
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
}
}

View File

@@ -0,0 +1,208 @@
import { apiGet } from '../api.js';
import {
html, litRender, nothing,
getConfig, errorAlert, pageColors, t,
} from '../components.js';
import {
iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconMap,
iconPage, iconInfo, iconChart, iconGlobe, iconGithub,
} from '../icons.js';
function renderRadioConfig(rc) {
if (!rc) return nothing;
const fields = [
[t('links.profile'), rc.profile],
[t('home.frequency'), rc.frequency],
[t('home.bandwidth'), rc.bandwidth],
[t('home.spreading_factor'), rc.spreading_factor],
[t('home.coding_rate'), rc.coding_rate],
[t('home.tx_power'), rc.tx_power],
];
return fields
.filter(([, v]) => v)
.map(([label, value]) => html`
<div class="flex justify-between">
<span class="opacity-70">${label}:</span>
<span class="font-mono">${String(value)}</span>
</div>`);
}
export async function render(container, params, router) {
try {
const config = getConfig();
const features = config.features || {};
const networkName = config.network_name || 'MeshCore Network';
const logoUrl = config.logo_url || '/static/img/logo.svg';
const customPages = config.custom_pages || [];
const rc = config.network_radio_config;
const [stats, advertActivity, messageActivity] = await Promise.all([
apiGet('/api/v1/dashboard/stats'),
apiGet('/api/v1/dashboard/activity', { days: 7 }),
apiGet('/api/v1/dashboard/message-activity', { days: 7 }),
]);
const cityCountry = (config.network_city && config.network_country)
? html`<p class="text-lg sm:text-2xl opacity-70 mt-2">${config.network_city}, ${config.network_country}</p>`
: nothing;
const welcomeText = config.network_welcome_text
? html`<p class="py-4 max-w-[70%]">${config.network_welcome_text}</p>`
: html`<p class="py-4 max-w-[70%]">
${t('home.welcome_default', { network_name: networkName })}
</p>`;
const customPageButtons = features.pages !== false
? customPages.slice(0, 3).map(page => html`
<a href="${page.url}" class="btn btn-outline btn-neutral">
${iconPage('h-5 w-5 mr-2')}
${page.title}
</a>`)
: [];
const showStats = features.nodes !== false || features.advertisements !== false || features.messages !== false;
const showAdvertSeries = features.advertisements !== false;
const showMessageSeries = features.messages !== false;
const showActivityChart = showAdvertSeries || showMessageSeries;
litRender(html`
<div class="${showStats ? 'grid grid-cols-1 lg:grid-cols-3 gap-6' : ''} bg-base-100 rounded-box shadow-xl p-6">
<div class="${showStats ? 'lg:col-span-2' : ''} flex flex-col items-center text-center">
<div class="flex flex-col sm:flex-row items-center gap-4 sm:gap-8 mb-4">
<img src="${logoUrl}" alt="${networkName}" class="theme-logo h-24 w-24 sm:h-36 sm:w-36" />
<div class="flex flex-col justify-center">
<h1 class="hero-title text-3xl sm:text-5xl lg:text-6xl font-black tracking-tight">${networkName}</h1>
${cityCountry}
</div>
</div>
${welcomeText}
<div class="flex-1"></div>
<div class="flex flex-wrap justify-center gap-3 mt-auto">
${features.dashboard !== false ? html`
<a href="/dashboard" class="btn btn-outline btn-info">
${iconDashboard('h-5 w-5 mr-2')}
${t('entities.dashboard')}
</a>` : nothing}
${features.nodes !== false ? html`
<a href="/nodes" class="btn btn-outline btn-primary">
${iconNodes('h-5 w-5 mr-2')}
${t('entities.nodes')}
</a>` : nothing}
${features.advertisements !== false ? html`
<a href="/advertisements" class="btn btn-outline btn-secondary">
${iconAdvertisements('h-5 w-5 mr-2')}
${t('entities.advertisements')}
</a>` : nothing}
${features.messages !== false ? html`
<a href="/messages" class="btn btn-outline btn-accent">
${iconMessages('h-5 w-5 mr-2')}
${t('entities.messages')}
</a>` : nothing}
${features.map !== false ? html`
<a href="/map" class="btn btn-outline btn-warning">
${iconMap('h-5 w-5 mr-2')}
${t('entities.map')}
</a>` : nothing}
${customPageButtons}
</div>
</div>
${showStats ? html`
<div class="flex flex-col gap-4">
${features.nodes !== false ? html`
<div class="stat bg-base-200 rounded-box shadow panel-glow" style="--panel-color: ${pageColors.nodes}">
<div class="stat-figure" style="color: ${pageColors.nodes}">
${iconNodes('h-8 w-8')}
</div>
<div class="stat-title">${t('common.total_entity', { entity: t('entities.nodes') })}</div>
<div class="stat-value" style="color: ${pageColors.nodes}">${stats.total_nodes}</div>
<div class="stat-desc">${t('home.all_discovered_nodes')}</div>
</div>` : nothing}
${features.advertisements !== false ? html`
<div class="stat bg-base-200 rounded-box shadow panel-glow" style="--panel-color: ${pageColors.adverts}">
<div class="stat-figure" style="color: ${pageColors.adverts}">
${iconAdvertisements('h-8 w-8')}
</div>
<div class="stat-title">${t('entities.advertisements')}</div>
<div class="stat-value" style="color: ${pageColors.adverts}">${stats.advertisements_7d}</div>
<div class="stat-desc">${t('time.last_7_days')}</div>
</div>` : nothing}
${features.messages !== false ? html`
<div class="stat bg-base-200 rounded-box shadow panel-glow" style="--panel-color: ${pageColors.messages}">
<div class="stat-figure" style="color: ${pageColors.messages}">
${iconMessages('h-8 w-8')}
</div>
<div class="stat-title">${t('entities.messages')}</div>
<div class="stat-value" style="color: ${pageColors.messages}">${stats.messages_7d}</div>
<div class="stat-desc">${t('time.last_7_days')}</div>
</div>` : nothing}
</div>` : nothing}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 ${showActivityChart ? 'lg:grid-cols-3' : ''} gap-6 mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
${iconInfo('h-6 w-6')}
${t('home.network_info')}
</h2>
<div class="space-y-2">
${renderRadioConfig(rc)}
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body flex flex-col items-center justify-center">
<p class="text-sm opacity-70 mb-4 text-center">${t('home.meshcore_attribution')}</p>
<a href="https://meshcore.co.uk/" target="_blank" rel="noopener noreferrer" class="hover:opacity-80 transition-opacity">
<img src="/static/img/meshcore.svg" alt="MeshCore" class="theme-logo h-8" />
</a>
<p class="text-xs opacity-50 mt-4 text-center">Connecting people and things, without using the internet</p>
<div class="flex gap-2 mt-4">
<a href="https://meshcore.co.uk/" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
${iconGlobe('h-4 w-4 mr-1')}
${t('links.website')}
</a>
<a href="https://github.com/meshcore-dev/MeshCore" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
${iconGithub('h-4 w-4 mr-1')}
${t('links.github')}
</a>
</div>
</div>
</div>
${showActivityChart ? html`
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
${iconChart('h-6 w-6')}
${t('home.network_activity')}
</h2>
<p class="text-sm opacity-70 mb-2">${t('time.activity_per_day_last_7_days')}</p>
<div class="h-48">
<canvas id="activityChart"></canvas>
</div>
</div>
</div>` : nothing}
</div>`, container);
let chart = null;
if (showActivityChart) {
chart = window.createActivityChart(
'activityChart',
showAdvertSeries ? advertActivity : null,
showMessageSeries ? messageActivity : null,
);
}
return () => {
if (chart) chart.destroy();
};
} catch (e) {
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
}
}

View File

@@ -0,0 +1,340 @@
import { apiGet } from '../api.js';
import {
html, litRender, nothing, t,
typeEmoji, formatRelativeTime, escapeHtml, errorAlert,
timezoneIndicator,
} from '../components.js';
const MAX_BOUNDS_RADIUS_KM = 20;
function getDistanceKm(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function getNodesWithinRadius(nodes, anchorLat, anchorLon, radiusKm) {
return nodes.filter(n => getDistanceKm(anchorLat, anchorLon, n.lat, n.lon) <= radiusKm);
}
function getAnchorPoint(nodes, infraCenter) {
if (infraCenter) return infraCenter;
if (nodes.length === 0) return { lat: 0, lon: 0 };
return {
lat: nodes.reduce((sum, n) => sum + n.lat, 0) / nodes.length,
lon: nodes.reduce((sum, n) => sum + n.lon, 0) / nodes.length,
};
}
function normalizeType(type) {
return type ? type.toLowerCase() : null;
}
function getTypeDisplay(node) {
const type = normalizeType(node.adv_type);
if (type === 'chat') return (window.t && window.t('node_types.chat')) || 'Chat';
if (type === 'repeater') return (window.t && window.t('node_types.repeater')) || 'Repeater';
if (type === 'room') return (window.t && window.t('node_types.room')) || 'Room';
return type ? type.charAt(0).toUpperCase() + type.slice(1) : (window.t && window.t('node_types.unknown')) || 'Unknown';
}
// Leaflet DivIcon requires plain HTML strings, so keep escapeHtml here
function createNodeIcon(node) {
const displayName = node.name || '';
const relativeTime = formatRelativeTime(node.last_seen);
const timeDisplay = relativeTime ? ' (' + relativeTime + ')' : '';
const iconHtml = node.is_infra
? '<div style="width: 12px; height: 12px; background: #ef4444; border: 2px solid #b91c1c; border-radius: 50%; box-shadow: 0 0 4px rgba(239,68,68,0.6), 0 1px 2px rgba(0,0,0,0.5);"></div>'
: '<div style="width: 12px; height: 12px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%; box-shadow: 0 0 4px rgba(59,130,246,0.6), 0 1px 2px rgba(0,0,0,0.5);"></div>';
return L.divIcon({
className: 'custom-div-icon',
html: '<div class="map-marker" style="display: flex; flex-direction: column; align-items: center; gap: 2px;">' +
iconHtml +
'<span class="map-label" style="font-size: 10px; font-weight: bold; color: #fff; background: rgba(0,0,0,0.5); padding: 1px 4px; border-radius: 3px; white-space: nowrap; text-align: center;">' +
escapeHtml(displayName) + timeDisplay + '</span>' +
'</div>',
iconSize: [120, 50],
iconAnchor: [60, 12],
});
}
// Leaflet popup requires plain HTML strings, so keep escapeHtml here
function createPopupContent(node) {
let ownerHtml = '';
if (node.owner) {
const ownerDisplay = node.owner.callsign
? escapeHtml(node.owner.name) + ' (' + escapeHtml(node.owner.callsign) + ')'
: escapeHtml(node.owner.name);
ownerHtml = '<p><span class="opacity-70">' + ((window.t && window.t('map.owner')) || 'Owner:') + '</span> ' + ownerDisplay + '</p>';
}
let roleHtml = '';
if (node.role) {
roleHtml = '<p><span class="opacity-70">' + ((window.t && window.t('map.role')) || 'Role:') + '</span> <span class="badge badge-xs badge-ghost">' + escapeHtml(node.role) + '</span></p>';
}
const typeDisplay = getTypeDisplay(node);
const nodeTypeEmoji = typeEmoji(node.adv_type);
let infraIndicatorHtml = '';
if (typeof node.is_infra !== 'undefined') {
const dotColor = node.is_infra ? '#ef4444' : '#3b82f6';
const borderColor = node.is_infra ? '#b91c1c' : '#1e40af';
const title = node.is_infra ? ((window.t && window.t('map.infrastructure')) || 'Infrastructure') : ((window.t && window.t('map.public')) || 'Public');
infraIndicatorHtml = ' <span style="display: inline-block; width: 10px; height: 10px; background: ' + dotColor + '; border: 2px solid ' + borderColor + '; border-radius: 50%; vertical-align: middle;" title="' + title + '"></span>';
}
const lastSeenLabel = (window.t && window.t('common.last_seen_label')) || 'Last seen:';
const lastSeenHtml = node.last_seen
? '<p><span class="opacity-70">' + lastSeenLabel + '</span> ' + node.last_seen.substring(0, 19).replace('T', ' ') + '</p>'
: '';
const typeLabel = (window.t && window.t('common.type')) || 'Type:';
const keyLabel = (window.t && window.t('common.key')) || 'Key:';
const locationLabel = (window.t && window.t('common.location')) || 'Location:';
const unknownLabel = (window.t && window.t('node_types.unknown')) || 'Unknown';
const viewDetailsLabel = (window.t && window.t('common.view_details')) || 'View Details';
return '<div class="p-2">' +
'<h3 class="font-bold text-lg mb-2">' + nodeTypeEmoji + ' ' + escapeHtml(node.name || unknownLabel) + infraIndicatorHtml + '</h3>' +
'<div class="space-y-1 text-sm">' +
'<p><span class="opacity-70">' + typeLabel + '</span> ' + escapeHtml(typeDisplay) + '</p>' +
roleHtml +
ownerHtml +
'<p><span class="opacity-70">' + keyLabel + '</span> <code class="text-xs">' + escapeHtml(node.public_key.substring(0, 16)) + '...</code></p>' +
'<p><span class="opacity-70">' + locationLabel + '</span> ' + node.lat.toFixed(4) + ', ' + node.lon.toFixed(4) + '</p>' +
lastSeenHtml +
'</div>' +
'<a href="/nodes/' + encodeURIComponent(node.public_key) + '" class="btn btn-outline btn-xs mt-3">' + viewDetailsLabel + '</a>' +
'</div>';
}
export async function render(container, params, router) {
try {
const data = await apiGet('/map/data');
const allNodes = data.nodes || [];
const allMembers = data.members || [];
const mapCenter = data.center || { lat: 0, lon: 0 };
const infraCenter = data.infra_center || null;
const debug = data.debug || {};
const isMobilePortrait = window.innerWidth < 480;
const isMobile = window.innerWidth < 768;
const BOUNDS_PADDING = isMobilePortrait ? [50, 50] : (isMobile ? [75, 75] : [100, 100]);
const sortedMembers = allMembers.slice().sort((a, b) => a.name.localeCompare(b.name));
function applyFilters() {
const filteredNodes = applyFiltersCore();
const categoryFilter = container.querySelector('#filter-category').value;
if (filteredNodes.length > 0) {
let nodesToFit = filteredNodes;
if (categoryFilter !== 'infra') {
const anchor = getAnchorPoint(filteredNodes, infraCenter);
const nearbyNodes = getNodesWithinRadius(filteredNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM);
if (nearbyNodes.length > 0) {
nodesToFit = nearbyNodes;
}
}
const bounds = L.latLngBounds(nodesToFit.map(n => [n.lat, n.lon]));
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
} else if (mapCenter.lat !== 0 || mapCenter.lon !== 0) {
map.setView([mapCenter.lat, mapCenter.lon], 10);
}
}
function updateLabelVisibility() {
const showLabels = container.querySelector('#show-labels').checked;
if (showLabels) {
mapEl.classList.add('show-labels');
} else {
mapEl.classList.remove('show-labels');
}
}
function clearFiltersHandler() {
container.querySelector('#filter-category').value = '';
container.querySelector('#filter-type').value = '';
container.querySelector('#filter-member').value = '';
container.querySelector('#show-labels').checked = false;
updateLabelVisibility();
applyFilters();
}
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">${t('entities.map')}</h1>
<div class="flex items-center gap-2">
${timezoneIndicator()}
<span id="node-count" class="badge badge-lg">${t('common.loading')}</span>
<span id="filtered-count" class="badge badge-lg badge-ghost hidden"></span>
</div>
</div>
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
<div class="card-body py-4">
<div class="flex gap-4 flex-wrap items-end">
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('common.show')}</span>
</label>
<select id="filter-category" class="select select-bordered select-sm" @change=${applyFilters}>
<option value="">${t('common.all_entity', { entity: t('entities.nodes') })}</option>
<option value="infra">${t('map.infrastructure_only')}</option>
</select>
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('common.node_type')}</span>
</label>
<select id="filter-type" class="select select-bordered select-sm" @change=${applyFilters}>
<option value="">${t('common.all_types')}</option>
<option value="chat">${t('node_types.chat')}</option>
<option value="repeater">${t('node_types.repeater')}</option>
<option value="room">${t('node_types.room')}</option>
</select>
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('entities.member')}</span>
</label>
<select id="filter-member" class="select select-bordered select-sm" @change=${applyFilters}>
<option value="">${t('common.all_entity', { entity: t('entities.members') })}</option>
${sortedMembers
.filter(m => m.member_id)
.map(m => {
const label = m.callsign
? m.name + ' (' + m.callsign + ')'
: m.name;
return html`<option value=${m.member_id}>${label}</option>`;
})}
</select>
</div>
<div class="form-control">
<label class="label cursor-pointer gap-2 py-1">
<span class="label-text">${t('map.show_labels')}</span>
<input type="checkbox" id="show-labels" class="checkbox checkbox-sm" @change=${updateLabelVisibility}>
</label>
</div>
<button id="clear-filters" class="btn btn-ghost btn-sm" @click=${clearFiltersHandler}>${t('common.clear_filters')}</button>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body p-2">
<div id="spa-map" style="height: calc(100vh - 300px); min-height: 400px;"></div>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-4 items-center text-sm">
<span class="opacity-70">${t('map.legend')}</span>
<div class="flex items-center gap-1">
<div style="width: 10px; height: 10px; background: #ef4444; border: 2px solid #b91c1c; border-radius: 50%;"></div>
<span>${t('map.infrastructure')}</span>
</div>
<div class="flex items-center gap-1">
<div style="width: 10px; height: 10px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%;"></div>
<span>${t('map.public')}</span>
</div>
</div>
<div class="mt-2 text-sm opacity-70">
<p>${t('map.gps_description')}</p>
</div>`, container);
const mapEl = container.querySelector('#spa-map');
const map = L.map(mapEl).setView([0, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
let markers = [];
function clearMarkers() {
markers.forEach(m => map.removeLayer(m));
markers = [];
}
function applyFiltersCore() {
const categoryFilter = container.querySelector('#filter-category').value;
const typeFilter = container.querySelector('#filter-type').value;
const memberFilter = container.querySelector('#filter-member').value;
const filteredNodes = allNodes.filter(node => {
if (categoryFilter === 'infra' && !node.is_infra) return false;
const nodeType = normalizeType(node.adv_type);
if (typeFilter && nodeType !== typeFilter) return false;
if (memberFilter && node.member_id !== memberFilter) return false;
return true;
});
clearMarkers();
filteredNodes.forEach(node => {
const marker = L.marker([node.lat, node.lon], { icon: createNodeIcon(node) }).addTo(map);
marker.bindPopup(createPopupContent(node));
markers.push(marker);
});
const countEl = container.querySelector('#node-count');
const filteredEl = container.querySelector('#filtered-count');
if (filteredNodes.length === allNodes.length) {
countEl.textContent = t('map.nodes_on_map', { count: allNodes.length });
filteredEl.classList.add('hidden');
} else {
countEl.textContent = t('common.total', { count: allNodes.length });
filteredEl.textContent = t('common.shown', { count: filteredNodes.length });
filteredEl.classList.remove('hidden');
}
return filteredNodes;
}
if (debug.error) {
container.querySelector('#node-count').textContent = 'Error: ' + debug.error;
return () => map.remove();
}
if (debug.total_nodes === 0) {
container.querySelector('#node-count').textContent = t('common.no_entity_in_database', { entity: t('entities.nodes').toLowerCase() });
return () => map.remove();
}
if (debug.nodes_with_coords === 0) {
container.querySelector('#node-count').textContent = t('map.nodes_none_have_coordinates', { count: debug.total_nodes });
return () => map.remove();
}
const infraNodes = allNodes.filter(n => n.is_infra);
if (infraNodes.length > 0) {
const bounds = L.latLngBounds(infraNodes.map(n => [n.lat, n.lon]));
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
} else if (allNodes.length > 0) {
const anchor = getAnchorPoint(allNodes, infraCenter);
const nearbyNodes = getNodesWithinRadius(allNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM);
const nodesToFit = nearbyNodes.length > 0 ? nearbyNodes : allNodes;
const bounds = L.latLngBounds(nodesToFit.map(n => [n.lat, n.lon]));
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
}
applyFiltersCore();
return () => map.remove();
} catch (e) {
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
}
}

View File

@@ -0,0 +1,152 @@
import { apiGet } from '../api.js';
import {
html, litRender, nothing, t, unsafeHTML,
formatRelativeTime, formatDateTime, errorAlert,
} from '../components.js';
import { iconInfo } from '../icons.js';
function nodeTypeEmoji(advType) {
switch ((advType || '').toLowerCase()) {
case 'chat': return '\u{1F4AC}';
case 'repeater': return '\u{1F4E1}';
case 'room': return '\u{1FAA7}';
default: return advType ? '\u{1F4CD}' : '\u{1F4E6}';
}
}
function nodeSortKey(node) {
const t = (node.adv_type || '').toLowerCase();
if (t === 'repeater') return 0;
if (t === 'chat') return 1;
return 2;
}
function renderNodeCard(node) {
const tagName = node.tags ? (node.tags.find(t => t.key === 'name') || {}).value : null;
const displayName = tagName || node.name;
const emoji = nodeTypeEmoji(node.adv_type);
const relTime = formatRelativeTime(node.last_seen);
const fullTime = formatDateTime(node.last_seen);
const nameBlock = displayName
? html`<div class="font-medium text-sm">${displayName}</div>
<div class="font-mono text-xs opacity-60">${node.public_key.slice(0, 12)}...</div>`
: html`<div class="font-mono text-sm">${node.public_key.slice(0, 12)}...</div>`;
const timeBlock = node.last_seen
? html`<time class="text-xs opacity-60 whitespace-nowrap" datetime=${node.last_seen} title=${fullTime} data-relative-time>${relTime}</time>`
: nothing;
return html`<a href="/nodes/${node.public_key}" class="flex items-center gap-3 p-2 bg-base-200 rounded-lg hover:bg-base-300 transition-colors">
<span class="text-lg" title=${node.adv_type || 'Unknown'}>${emoji}</span>
<div class="flex-1 min-w-0">
${nameBlock}
</div>
${timeBlock}
</a>`;
}
function renderMemberCard(member, nodes) {
const sorted = [...nodes].sort((a, b) => nodeSortKey(a) - nodeSortKey(b));
const nodesBlock = sorted.length > 0
? html`<div class="mt-4 space-y-2">${sorted.map(renderNodeCard)}</div>`
: nothing;
const callsignBadge = member.callsign
? html`<span class="badge badge-neutral">${member.callsign}</span>`
: nothing;
const descBlock = member.description
? html`<p class="mt-2">${member.description}</p>`
: nothing;
const contactBlock = member.contact
? html`<p class="text-sm mt-2"><span class="opacity-70">${t('common.contact')}:</span> ${member.contact}</p>`
: nothing;
return html`<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
${member.name}
${callsignBadge}
</h2>
${descBlock}
${contactBlock}
${nodesBlock}
</div>
</div>`;
}
export async function render(container, params, router) {
try {
const membersResp = await apiGet('/api/v1/members', { limit: 100 });
const members = membersResp.items || [];
if (members.length === 0) {
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">${t('entities.members')}</h1>
<span class="badge badge-lg">${t('common.count_entity', { count: 0, entity: t('entities.members').toLowerCase() })}</span>
</div>
<div class="alert alert-info">
${iconInfo('stroke-current shrink-0 h-6 w-6')}
<div>
<h3 class="font-bold">${t('common.no_entity_configured', { entity: t('entities.members').toLowerCase() })}</h3>
<p class="text-sm">${t('members.empty_state_description')}</p>
</div>
</div>
<div class="mt-6 card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">${t('members.members_file_format')}</h2>
<p class="mb-4">${unsafeHTML(t('members.members_file_description'))}</p>
<pre class="bg-base-200 p-4 rounded-box text-sm overflow-x-auto"><code>members:
- member_id: johndoe
name: John Doe
callsign: AB1CD
role: Network Admin
description: Manages the main repeater node.
contact: john@example.com
- member_id: janesmith
name: Jane Smith
role: Member
description: Regular user in the downtown area.</code></pre>
<p class="mt-4 text-sm opacity-70">
${unsafeHTML(t('members.members_import_instructions'))}
</p>
</div>
</div>`, container);
return;
}
const nodePromises = members.map(m =>
apiGet('/api/v1/nodes', { member_id: m.member_id, limit: 50 })
.then(resp => ({ memberId: m.member_id, nodes: resp.items || [] }))
.catch(() => ({ memberId: m.member_id, nodes: [] }))
);
const nodeResults = await Promise.all(nodePromises);
const nodesByMember = {};
for (const r of nodeResults) {
nodesByMember[r.memberId] = r.nodes;
}
const cards = members.map(m =>
renderMemberCard(m, nodesByMember[m.member_id] || [])
);
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">${t('entities.members')}</h1>
<span class="badge badge-lg">${t('common.count_entity', { count: members.length, entity: t('entities.members').toLowerCase() })}</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 items-start">
${cards}
</div>`, container);
} catch (e) {
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
}
}

View File

@@ -0,0 +1,202 @@
import { apiGet } from '../api.js';
import {
html, litRender, nothing, t,
getConfig, formatDateTime, formatDateTimeShort,
truncateKey, errorAlert,
pagination, timezoneIndicator,
createFilterHandler, autoSubmit, submitOnEnter
} from '../components.js';
import { createAutoRefresh } from '../auto-refresh.js';
export async function render(container, params, router) {
const query = params.query || {};
const message_type = query.message_type || '';
const page = parseInt(query.page, 10) || 1;
const limit = parseInt(query.limit, 10) || 50;
const offset = (page - 1) * limit;
const config = getConfig();
const tz = config.timezone || '';
const tzBadge = tz && tz !== 'UTC' ? html`<span class="text-sm opacity-60">${tz}</span>` : nothing;
const navigate = (url) => router.navigate(url);
function renderPage(content, { total = null } = {}) {
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">${t('entities.messages')}</h1>
<div class="flex items-center gap-2">
<span id="auto-refresh-toggle"></span>
${tzBadge}
${total !== null ? html`<span class="badge badge-lg">${t('common.total', { count: total })}</span>` : nothing}
</div>
</div>
${content}`, container);
}
// Render page header immediately (old content stays visible until data loads)
renderPage(nothing);
async function fetchAndRenderData() {
try {
const data = await apiGet('/api/v1/messages', { limit, offset, message_type });
const messages = data.items || [];
const total = data.total || 0;
const totalPages = Math.ceil(total / limit);
const mobileCards = messages.length === 0
? html`<div class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}</div>`
: messages.map(msg => {
const isChannel = msg.message_type === 'channel';
const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}';
const typeTitle = isChannel ? t('messages.type_channel') : t('messages.type_contact');
let senderBlock;
if (isChannel) {
senderBlock = html`<span class="opacity-60">${t('messages.type_public')}</span>`;
} else {
const senderName = msg.sender_tag_name || msg.sender_name;
if (senderName) {
senderBlock = senderName;
} else {
senderBlock = html`<span class="font-mono text-xs">${(msg.pubkey_prefix || '-').slice(0, 12)}</span>`;
}
}
let receiversBlock = nothing;
if (msg.receivers && msg.receivers.length >= 1) {
receiversBlock = html`<div class="flex gap-0.5">
${msg.receivers.map(recv => {
const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12);
return html`<a href="/nodes/${recv.public_key}" class="text-sm hover:opacity-70" title=${recvName}>\u{1F4E1}</a>`;
})}
</div>`;
} else if (msg.received_by) {
const recvTitle = msg.receiver_tag_name || msg.receiver_name || truncateKey(msg.received_by, 12);
receiversBlock = html`<a href="/nodes/${msg.received_by}" class="text-sm hover:opacity-70" title=${recvTitle}>\u{1F4E1}</a>`;
}
return html`<div class="card bg-base-100 shadow-sm">
<div class="card-body p-3">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<span class="text-lg flex-shrink-0" title=${typeTitle}>
${typeIcon}
</span>
<div class="min-w-0">
<div class="font-medium text-sm truncate">
${senderBlock}
</div>
<div class="text-xs opacity-60">
${formatDateTimeShort(msg.received_at)}
</div>
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
${receiversBlock}
</div>
</div>
<p class="text-sm mt-2 break-words whitespace-pre-wrap">${msg.text || '-'}</p>
</div>
</div>`;
});
const tableRows = messages.length === 0
? html`<tr><td colspan="5" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}</td></tr>`
: messages.map(msg => {
const isChannel = msg.message_type === 'channel';
const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}';
const typeTitle = isChannel ? t('messages.type_channel') : t('messages.type_contact');
let senderBlock;
if (isChannel) {
senderBlock = html`<span class="opacity-60">${t('messages.type_public')}</span>`;
} else {
const senderName = msg.sender_tag_name || msg.sender_name;
if (senderName) {
senderBlock = html`<span class="font-medium">${senderName}</span>`;
} else {
senderBlock = html`<span class="font-mono text-xs">${(msg.pubkey_prefix || '-').slice(0, 12)}</span>`;
}
}
let receiversBlock;
if (msg.receivers && msg.receivers.length >= 1) {
receiversBlock = html`<div class="flex gap-1">
${msg.receivers.map(recv => {
const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12);
return html`<a href="/nodes/${recv.public_key}" class="text-lg hover:opacity-70" title=${recvName}>\u{1F4E1}</a>`;
})}
</div>`;
} else if (msg.received_by) {
const recvTitle = msg.receiver_tag_name || msg.receiver_name || truncateKey(msg.received_by, 12);
receiversBlock = html`<a href="/nodes/${msg.received_by}" class="text-lg hover:opacity-70" title=${recvTitle}>\u{1F4E1}</a>`;
} else {
receiversBlock = html`<span class="opacity-50">-</span>`;
}
return html`<tr class="hover align-top">
<td class="text-lg" title=${typeTitle}>${typeIcon}</td>
<td class="text-sm whitespace-nowrap">${formatDateTime(msg.received_at)}</td>
<td class="text-sm whitespace-nowrap">${senderBlock}</td>
<td class="break-words max-w-md" style="white-space: pre-wrap;">${msg.text || '-'}</td>
<td>${receiversBlock}</td>
</tr>`;
});
const paginationBlock = pagination(page, totalPages, '/messages', {
message_type, limit,
});
renderPage(html`
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
<div class="card-body py-4">
<form method="GET" action="/messages" class="flex gap-4 flex-wrap items-end" @submit=${createFilterHandler('/messages', navigate)}>
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('common.type')}</span>
</label>
<select name="message_type" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="">${t('common.all_types')}</option>
<option value="contact" ?selected=${message_type === 'contact'}>${t('messages.type_direct')}</option>
<option value="channel" ?selected=${message_type === 'channel'}>${t('messages.type_channel')}</option>
</select>
</div>
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">${t('common.filter')}</button>
<a href="/messages" class="btn btn-ghost btn-sm">${t('common.clear')}</a>
</div>
</form>
</div>
</div>
<div class="lg:hidden space-y-3">
${mobileCards}
</div>
<div class="hidden lg:block overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow">
<table class="table table-zebra">
<thead>
<tr>
<th>${t('common.type')}</th>
<th>${t('common.time')}</th>
<th>${t('common.from')}</th>
<th>${t('entities.message')}</th>
<th>${t('common.receivers')}</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
</div>
${paginationBlock}`, { total });
} catch (e) {
renderPage(errorAlert(e.message));
}
}
await fetchAndRenderData();
const toggleEl = container.querySelector('#auto-refresh-toggle');
const { cleanup } = createAutoRefresh({
fetchAndRender: fetchAndRenderData,
toggleContainer: toggleEl,
});
return cleanup;
}

View File

@@ -0,0 +1,256 @@
import { apiGet } from '../api.js';
import {
html, litRender, nothing,
getConfig, typeEmoji, formatDateTime,
truncateKey, errorAlert, copyToClipboard, t,
} from '../components.js';
import { iconError } from '../icons.js';
export async function render(container, params, router) {
const cleanupFns = [];
let publicKey = params.publicKey;
try {
if (publicKey.length !== 64) {
const resolved = await apiGet('/api/v1/nodes/prefix/' + encodeURIComponent(publicKey));
router.navigate('/nodes/' + resolved.public_key, true);
return;
}
const [node, adsData, telemetryData] = await Promise.all([
apiGet('/api/v1/nodes/' + publicKey),
apiGet('/api/v1/advertisements', { public_key: publicKey, limit: 10 }),
apiGet('/api/v1/telemetry', { node_public_key: publicKey, limit: 10 }),
]);
if (!node) {
litRender(renderNotFound(publicKey), container);
return;
}
const config = getConfig();
const tagName = node.tags?.find(t => t.key === 'name')?.value;
const tagDescription = node.tags?.find(t => t.key === 'description')?.value;
const displayName = tagName || node.name || t('common.unnamed_node');
const emoji = typeEmoji(node.adv_type);
let lat = node.lat;
let lon = node.lon;
if (!lat || !lon) {
for (const tag of node.tags || []) {
if (tag.key === 'lat' && !lat) lat = parseFloat(tag.value);
if (tag.key === 'lon' && !lon) lon = parseFloat(tag.value);
}
}
const hasCoords = lat != null && lon != null && !(lat === 0 && lon === 0);
const advertisements = adsData.items || [];
const heroHtml = hasCoords
? html`
<div class="relative rounded-box overflow-hidden mb-6 shadow-xl" style="height: 180px;">
<div id="header-map" class="absolute inset-0 z-0"></div>
<div class="relative z-20 h-full p-3 flex items-center justify-end">
<div id="qr-code" class="bg-white p-2 rounded shadow-lg"></div>
</div>
</div>`
: html`
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body flex-row items-center gap-4">
<div id="qr-code" class="bg-white p-1 rounded"></div>
<p class="text-sm opacity-70">${t('nodes.scan_to_add')}</p>
</div>
</div>`;
const coordsHtml = hasCoords
? html`<div><span class="opacity-70">${t('common.location')}:</span> ${lat}, ${lon}</div>`
: nothing;
const adsTableHtml = advertisements.length > 0
? html`<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
<tr>
<th>${t('common.time')}</th>
<th>${t('common.type')}</th>
<th>${t('common.received_by')}</th>
</tr>
</thead>
<tbody>
${advertisements.map(adv => {
const advEmoji = adv.adv_type ? typeEmoji(adv.adv_type) : '';
const advTypeHtml = adv.adv_type
? html`<span title=${adv.adv_type.charAt(0).toUpperCase() + adv.adv_type.slice(1)}>${advEmoji}</span>`
: html`<span class="opacity-50">-</span>`;
const recvName = adv.received_by ? (adv.receiver_tag_name || adv.receiver_name) : null;
const receiverHtml = !adv.received_by
? html`<span class="opacity-50">-</span>`
: recvName
? html`<a href="/nodes/${adv.received_by}" class="link link-hover">
<div class="font-medium text-sm">${recvName}</div>
<div class="text-xs font-mono opacity-70">${adv.received_by.slice(0, 16)}...</div>
</a>`
: html`<a href="/nodes/${adv.received_by}" class="link link-hover">
<span class="font-mono text-xs">${adv.received_by.slice(0, 16)}...</span>
</a>`;
return html`<tr>
<td class="text-xs whitespace-nowrap">${formatDateTime(adv.received_at)}</td>
<td>${advTypeHtml}</td>
<td>${receiverHtml}</td>
</tr>`;
})}
</tbody>
</table>
</div>`
: html`<p class="opacity-70">${t('common.no_entity_recorded', { entity: t('entities.advertisements').toLowerCase() })}</p>`;
const tags = node.tags || [];
const tagsTableHtml = tags.length > 0
? html`<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
<tr>
<th>${t('common.key')}</th>
<th>${t('common.value')}</th>
<th>${t('common.type')}</th>
</tr>
</thead>
<tbody>
${tags.map(tag => html`<tr>
<td class="font-mono">${tag.key}</td>
<td>${tag.value || ''}</td>
<td class="opacity-70">${tag.value_type || 'string'}</td>
</tr>`)}
</tbody>
</table>
</div>`
: html`<p class="opacity-70">${t('common.no_entity_defined', { entity: t('entities.tags').toLowerCase() })}</p>`;
const adminTagsHtml = (config.admin_enabled && config.is_authenticated)
? html`<div class="mt-3">
<a href="/a/node-tags?public_key=${node.public_key}" class="btn btn-sm btn-outline">${tags.length > 0 ? t('common.edit_entity', { entity: t('entities.tags') }) : t('common.add_entity', { entity: t('entities.tags') })}</a>
</div>`
: nothing;
litRender(html`
<div class="breadcrumbs text-sm mb-4">
<ul>
<li><a href="/">${t('entities.home')}</a></li>
<li><a href="/nodes">${t('entities.nodes')}</a></li>
<li>${tagName || node.name || node.public_key.slice(0, 12) + '...'}</li>
</ul>
</div>
<div class="flex items-start gap-4 mb-6">
<span class="text-6xl flex-shrink-0" title=${node.adv_type || t('node_types.unknown')}>${emoji}</span>
<div class="flex-1 min-w-0">
<h1 class="text-3xl font-bold">${displayName}</h1>
${tagDescription ? html`<p class="text-base-content/70 mt-2">${tagDescription}</p>` : nothing}
</div>
</div>
${heroHtml}
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<div>
<h3 class="font-semibold opacity-70 mb-2">${t('common.public_key')}</h3>
<code class="text-sm bg-base-200 p-2 rounded block break-all cursor-pointer hover:bg-base-300 select-all"
@click=${(e) => copyToClipboard(e, node.public_key)}
title="Click to copy">${node.public_key}</code>
</div>
<div class="flex flex-wrap gap-x-8 gap-y-2 mt-4 text-sm">
<div><span class="opacity-70">${t('common.first_seen_label')}</span> ${formatDateTime(node.first_seen)}</div>
<div><span class="opacity-70">${t('common.last_seen_label')}</span> ${formatDateTime(node.last_seen)}</div>
${coordsHtml}
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">${t('common.recent_entity', { entity: t('entities.advertisements') })}</h2>
${adsTableHtml}
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">${t('entities.tags')}</h2>
${tagsTableHtml}
${adminTagsHtml}
</div>
</div>
</div>`, container);
// Initialize map if coordinates exist
if (hasCoords && typeof L !== 'undefined') {
const map = L.map('header-map', {
zoomControl: false, dragging: false, scrollWheelZoom: false,
doubleClickZoom: false, boxZoom: false, keyboard: false,
attributionControl: false,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
map.setView([lat, lon], 14);
const point = map.latLngToContainerPoint([lat, lon]);
const newPoint = L.point(point.x + map.getSize().x * 0.17, point.y);
const newLatLng = map.containerPointToLatLng(newPoint);
map.setView(newLatLng, 14, { animate: false });
const icon = L.divIcon({
html: '<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">' + emoji + '</span>',
className: '', iconSize: [32, 32], iconAnchor: [16, 16],
});
L.marker([lat, lon], { icon }).addTo(map);
cleanupFns.push(() => map.remove());
}
// Initialize QR code - wait for both DOM element and QRCode library
const initQr = () => {
const qrEl = document.getElementById('qr-code');
if (!qrEl || typeof QRCode === 'undefined') return false;
const typeMap = { chat: 1, repeater: 2, room: 3, sensor: 4 };
const typeNum = typeMap[(node.adv_type || '').toLowerCase()] || 1;
const url = 'meshcore://contact/add?name=' + encodeURIComponent(displayName) + '&public_key=' + node.public_key + '&type=' + typeNum;
new QRCode(qrEl, {
text: url, width: 140, height: 140,
colorDark: '#000000', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.L,
});
return true;
};
if (!initQr()) {
let attempts = 0;
const qrInterval = setInterval(() => {
if (initQr() || ++attempts >= 20) clearInterval(qrInterval);
}, 100);
cleanupFns.push(() => clearInterval(qrInterval));
}
return () => {
cleanupFns.forEach(fn => fn());
};
} catch (e) {
if (e.message && e.message.includes('404')) {
litRender(renderNotFound(publicKey), container);
} else {
litRender(errorAlert(e.message), container);
}
}
}
function renderNotFound(publicKey) {
return html`
<div class="breadcrumbs text-sm mb-4">
<ul>
<li><a href="/">${t('entities.home')}</a></li>
<li><a href="/nodes">${t('entities.nodes')}</a></li>
<li>${t('common.page_not_found')}</li>
</ul>
</div>
<div class="alert alert-error">
${iconError('stroke-current shrink-0 h-6 w-6')}
<span>${t('common.entity_not_found_details', { entity: t('entities.node'), details: publicKey })}</span>
</div>
<a href="/nodes" class="btn btn-primary mt-4">${t('common.view_entity', { entity: t('entities.nodes') })}</a>`;
}

View File

@@ -0,0 +1,209 @@
import { apiGet } from '../api.js';
import {
html, litRender, nothing,
getConfig, formatDateTime, formatDateTimeShort,
truncateKey, errorAlert,
pagination, timezoneIndicator,
createFilterHandler, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, t
} from '../components.js';
import { createAutoRefresh } from '../auto-refresh.js';
export async function render(container, params, router) {
const query = params.query || {};
const search = query.search || '';
const adv_type = query.adv_type || '';
const member_id = query.member_id || '';
const page = parseInt(query.page, 10) || 1;
const limit = parseInt(query.limit, 10) || 20;
const offset = (page - 1) * limit;
const config = getConfig();
const features = config.features || {};
const showMembers = features.members !== false;
const tz = config.timezone || '';
const tzBadge = tz && tz !== 'UTC' ? html`<span class="text-sm opacity-60">${tz}</span>` : nothing;
const navigate = (url) => router.navigate(url);
function renderPage(content, { total = null } = {}) {
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">${t('entities.nodes')}</h1>
<div class="flex items-center gap-2">
<span id="auto-refresh-toggle"></span>
${tzBadge}
${total !== null ? html`<span class="badge badge-lg">${t('common.total', { count: total })}</span>` : nothing}
</div>
</div>
${content}`, container);
}
// Render page header immediately (old content stays visible until data loads)
renderPage(nothing);
async function fetchAndRenderData() {
try {
const requests = [
apiGet('/api/v1/nodes', { limit, offset, search, adv_type, member_id }),
];
if (showMembers) {
requests.push(apiGet('/api/v1/members', { limit: 100 }));
}
const results = await Promise.all(requests);
const data = results[0];
const membersData = showMembers ? results[1] : null;
const nodes = data.items || [];
const total = data.total || 0;
const totalPages = Math.ceil(total / limit);
const members = membersData?.items || [];
const membersFilter = (showMembers && members.length > 0)
? html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('entities.member')}</span>
</label>
<select name="member_id" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="">${t('common.all_entity', { entity: t('entities.members') })}</option>
${members.map(m => html`<option value=${m.member_id} ?selected=${member_id === m.member_id}>${m.name}${m.callsign ? ` (${m.callsign})` : ''}</option>`)}
</select>
</div>`
: nothing;
const mobileCards = nodes.length === 0
? html`<div class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}</div>`
: nodes.map(node => {
const tagName = node.tags?.find(tag => tag.key === 'name')?.value;
const tagDescription = node.tags?.find(tag => tag.key === 'description')?.value;
const displayName = tagName || node.name;
const lastSeen = node.last_seen ? formatDateTimeShort(node.last_seen) : '-';
const memberIdTag = showMembers ? node.tags?.find(tag => tag.key === 'member_id')?.value : null;
const member = memberIdTag ? members.find(m => m.member_id === memberIdTag) : null;
const memberBlock = (showMembers && member)
? html`<div class="text-xs opacity-60">${member.name}</div>`
: nothing;
return html`<a href="/nodes/${node.public_key}" class="card bg-base-100 shadow-sm block">
<div class="card-body p-3">
<div class="flex items-center justify-between gap-2">
${renderNodeDisplay({
name: displayName,
description: tagDescription,
publicKey: node.public_key,
advType: node.adv_type,
size: 'sm'
})}
<div class="text-right flex-shrink-0">
<div class="text-xs opacity-60">${lastSeen}</div>
${memberBlock}
</div>
</div>
</div>
</a>`;
});
const tableColspan = showMembers ? 4 : 3;
const tableRows = nodes.length === 0
? html`<tr><td colspan="${tableColspan}" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}</td></tr>`
: nodes.map(node => {
const tagName = node.tags?.find(tag => tag.key === 'name')?.value;
const tagDescription = node.tags?.find(tag => tag.key === 'description')?.value;
const displayName = tagName || node.name;
const lastSeen = node.last_seen ? formatDateTime(node.last_seen) : '-';
const memberIdTag = showMembers ? node.tags?.find(tag => tag.key === 'member_id')?.value : null;
const member = memberIdTag ? members.find(m => m.member_id === memberIdTag) : null;
const memberBlock = member
? html`${member.name}${member.callsign ? html` <span class="opacity-60">(${member.callsign})</span>` : nothing}`
: html`<span class="opacity-50">-</span>`;
return html`<tr class="hover">
<td>
<a href="/nodes/${node.public_key}" class="link link-hover">
${renderNodeDisplay({
name: displayName,
description: tagDescription,
publicKey: node.public_key,
advType: node.adv_type,
size: 'base'
})}
</a>
</td>
<td>
<code class="font-mono text-xs cursor-pointer hover:bg-base-200 px-1 py-0.5 rounded select-all"
@click=${(e) => copyToClipboard(e, node.public_key)}
title="Click to copy">${node.public_key}</code>
</td>
<td class="text-sm whitespace-nowrap">${lastSeen}</td>
${showMembers ? html`<td class="text-sm">${memberBlock}</td>` : nothing}
</tr>`;
});
const paginationBlock = pagination(page, totalPages, '/nodes', {
search, adv_type, member_id, limit,
});
renderPage(html`
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
<div class="card-body py-4">
<form method="GET" action="/nodes" class="flex gap-4 flex-wrap items-end" @submit=${createFilterHandler('/nodes', navigate)}>
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('common.search')}</span>
</label>
<input type="text" name="search" .value=${search} placeholder="${t('common.search_placeholder')}" class="input input-bordered input-sm w-80" @keydown=${submitOnEnter} />
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('common.type')}</span>
</label>
<select name="adv_type" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="">${t('common.all_types')}</option>
<option value="chat" ?selected=${adv_type === 'chat'}>${t('node_types.chat')}</option>
<option value="repeater" ?selected=${adv_type === 'repeater'}>${t('node_types.repeater')}</option>
<option value="room" ?selected=${adv_type === 'room'}>${t('node_types.room')}</option>
</select>
</div>
${membersFilter}
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">${t('common.filter')}</button>
<a href="/nodes" class="btn btn-ghost btn-sm">${t('common.clear')}</a>
</div>
</form>
</div>
</div>
<div class="lg:hidden space-y-3">
${mobileCards}
</div>
<div class="hidden lg:block overflow-x-auto bg-base-100 rounded-box shadow">
<table class="table table-zebra">
<thead>
<tr>
<th>${t('entities.node')}</th>
<th>${t('common.public_key')}</th>
<th>${t('common.last_seen')}</th>
${showMembers ? html`<th>${t('entities.member')}</th>` : nothing}
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
</div>
${paginationBlock}`, { total });
} catch (e) {
renderPage(errorAlert(e.message));
}
}
await fetchAndRenderData();
const toggleEl = container.querySelector('#auto-refresh-toggle');
const { cleanup } = createAutoRefresh({
fetchAndRender: fetchAndRenderData,
toggleContainer: toggleEl,
});
return cleanup;
}

View File

@@ -0,0 +1,27 @@
import { html, litRender, t } from '../components.js';
import { iconHome, iconNodes } from '../icons.js';
export async function render(container, params, router) {
litRender(html`
<div class="hero min-h-[60vh]">
<div class="hero-content text-center">
<div class="max-w-md">
<div class="text-9xl font-bold text-primary opacity-20">404</div>
<h1 class="text-4xl font-bold -mt-8">${t('common.page_not_found')}</h1>
<p class="py-6 text-base-content/70">
${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')}
${t('common.go_home')}
</a>
<a href="/nodes" class="btn btn-outline">
${iconNodes('h-5 w-5 mr-2')}
${t('common.view_entity', { entity: t('entities.nodes') })}
</a>
</div>
</div>
</div>
</div>`, container);
}

View File

@@ -0,0 +1,163 @@
/**
* MeshCore Hub SPA - Client-Side Router
*
* Simple History API based router with parameterized routes.
*/
export class Router {
constructor() {
this._routes = [];
this._notFoundHandler = null;
this._currentCleanup = null;
this._onNavigate = null;
}
/**
* Register a route.
* @param {string} path - URL pattern (e.g., '/nodes/:publicKey')
* @param {Function} handler - async function(params) where params includes route params and query
*/
addRoute(path, handler) {
const paramNames = [];
const regexStr = path
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex chars
.replace(/:([a-zA-Z_]+)/g, (_, name) => {
paramNames.push(name);
return '([^/]+)';
});
this._routes.push({
pattern: new RegExp('^' + regexStr + '$'),
paramNames,
handler,
path,
});
}
/**
* Set the 404 handler.
* @param {Function} handler - async function(params)
*/
setNotFound(handler) {
this._notFoundHandler = handler;
}
/**
* Set a callback to run on every navigation (for updating navbar, etc.)
* @param {Function} fn - function(pathname)
*/
onNavigate(fn) {
this._onNavigate = fn;
}
/**
* Navigate to a URL.
* @param {string} url - URL path with optional query string
* @param {boolean} [replace=false] - Use replaceState instead of pushState
*/
navigate(url, replace = false) {
// Skip if already on this exact URL
const current = window.location.pathname + window.location.search;
if (url === current && !replace) return;
if (replace) {
history.replaceState(null, '', url);
} else {
history.pushState(null, '', url);
}
this._handleRoute();
}
/**
* Match a pathname against registered routes.
* @param {string} pathname
* @returns {{ handler: Function, params: Object } | null}
*/
_match(pathname) {
for (const route of this._routes) {
const match = pathname.match(route.pattern);
if (match) {
const params = {};
route.paramNames.forEach((name, i) => {
params[name] = decodeURIComponent(match[i + 1]);
});
return { handler: route.handler, params };
}
}
return null;
}
/**
* Handle the current URL.
*/
async _handleRoute() {
// Clean up previous page
if (this._currentCleanup) {
try { this._currentCleanup(); } catch (e) { /* ignore */ }
this._currentCleanup = null;
}
const pathname = window.location.pathname;
const query = Object.fromEntries(new URLSearchParams(window.location.search));
// Notify navigation listener
if (this._onNavigate) {
this._onNavigate(pathname);
}
// Show navbar loading indicator
const loader = document.getElementById('nav-loading');
if (loader) loader.classList.remove('hidden');
try {
const result = this._match(pathname);
if (result) {
const cleanup = await result.handler({ ...result.params, query });
if (typeof cleanup === 'function') {
this._currentCleanup = cleanup;
}
} else if (this._notFoundHandler) {
await this._notFoundHandler({ query });
}
} finally {
if (loader) loader.classList.add('hidden');
}
// Scroll to top on navigation
window.scrollTo(0, 0);
}
/**
* Start the router - listen for events and handle initial route.
*/
start() {
// Handle browser back/forward
window.addEventListener('popstate', () => this._handleRoute());
// Intercept link clicks for SPA navigation
document.addEventListener('click', (e) => {
const link = e.target.closest('a[href]');
if (!link) return;
const href = link.getAttribute('href');
// Skip external links, anchors, downloads, new tabs
if (!href || !href.startsWith('/') || href.startsWith('//')) return;
if (link.hasAttribute('download') || link.target === '_blank') return;
// Skip non-SPA paths (static files, API, media, OAuth, SEO)
if (href.startsWith('/static/') || href.startsWith('/media/') ||
href.startsWith('/api/') || href.startsWith('/oauth2/') ||
href.startsWith('/health') || href === '/robots.txt' ||
href === '/sitemap.xml') return;
// Skip mailto and tel links
if (href.startsWith('mailto:') || href.startsWith('tel:')) return;
e.preventDefault();
this.navigate(href);
});
// Handle initial route
this._handleRoute();
}
}

View File

@@ -1,78 +0,0 @@
/**
* MeshCore Hub - Common JavaScript Utilities
*/
/**
* Format a timestamp as relative time (e.g., "2m", "1h", "2d")
* @param {string|Date} timestamp - ISO timestamp string or Date object
* @returns {string} Relative time string, or empty string if invalid
*/
function formatRelativeTime(timestamp) {
if (!timestamp) return '';
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
if (isNaN(date.getTime())) return '';
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffDay > 0) return `${diffDay}d`;
if (diffHour > 0) return `${diffHour}h`;
if (diffMin > 0) return `${diffMin}m`;
return '<1m';
}
/**
* Populate all elements with data-timestamp attribute with relative time
*/
function populateRelativeTimestamps() {
document.querySelectorAll('[data-timestamp]:not([data-receiver-tooltip])').forEach(el => {
const timestamp = el.dataset.timestamp;
if (timestamp) {
el.textContent = formatRelativeTime(timestamp);
}
});
}
/**
* Populate receiver tooltip elements with name and relative time
*/
function populateReceiverTooltips() {
document.querySelectorAll('[data-receiver-tooltip]').forEach(el => {
const name = el.dataset.name || '';
const timestamp = el.dataset.timestamp;
const relTime = timestamp ? formatRelativeTime(timestamp) : '';
// Build tooltip: "NodeName (2m ago)" or just "NodeName" or just "2m ago"
let tooltip = name;
if (relTime) {
tooltip = name ? `${name} (${relTime} ago)` : `${relTime} ago`;
}
el.title = tooltip;
});
}
/**
* Populate <time> elements with data-relative-time attribute
* Uses the datetime attribute as the timestamp source
*/
function populateRelativeTimeElements() {
document.querySelectorAll('time[data-relative-time]').forEach(el => {
const timestamp = el.getAttribute('datetime');
if (timestamp) {
const relTime = formatRelativeTime(timestamp);
el.textContent = relTime ? `${relTime} ago` : '';
}
});
}
// Auto-populate when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
populateRelativeTimestamps();
populateReceiverTooltips();
populateRelativeTimeElements();
});

View 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"
}
}

View 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/`

View 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"
}
}

View File

@@ -1,47 +0,0 @@
{# Reusable macros for templates #}
{#
Pagination macro
Parameters:
- page: Current page number
- total_pages: Total number of pages
- params: Dict of query parameters to preserve (e.g., {"search": "foo", "limit": 50})
#}
{% macro pagination(page, total_pages, params={}) %}
{% if total_pages > 1 %}
{% set query_parts = [] %}
{% for key, value in params.items() %}
{% if value is not none and value != '' %}
{% set _ = query_parts.append(key ~ '=' ~ value) %}
{% endif %}
{% endfor %}
{% set base_query = query_parts|join('&') %}
{% set query_prefix = '&' if base_query else '' %}
<div class="flex justify-center mt-6">
<div class="join">
{% if page > 1 %}
<a href="?page={{ page - 1 }}{{ query_prefix }}{{ base_query }}" class="join-item btn btn-sm">Previous</a>
{% else %}
<button class="join-item btn btn-sm btn-disabled">Previous</button>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
<a href="?page={{ p }}{{ query_prefix }}{{ base_query }}" class="join-item btn btn-sm">{{ p }}</a>
{% elif p == 2 or p == total_pages - 1 %}
<button class="join-item btn btn-sm btn-disabled">...</button>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="?page={{ page + 1 }}{{ query_prefix }}{{ base_query }}" class="join-item btn btn-sm">Next</a>
{% else %}
<button class="join-item btn btn-sm btn-disabled">Next</button>
{% endif %}
</div>
</div>
{% endif %}
{% endmacro %}

View File

@@ -1,20 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ network_name }} - Access Denied{% endblock %}
{% block content %}
<div class="flex flex-col items-center justify-center min-h-[50vh]">
<div class="text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 mx-auto text-error opacity-50 mb-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<h1 class="text-3xl font-bold mb-2">Access Denied</h1>
<p class="text-lg opacity-70 mb-6">You don't have permission to access the admin area.</p>
<p class="text-sm opacity-50 mb-8">Please contact the network administrator if you believe this is an error.</p>
<div class="flex gap-4 justify-center">
<a href="/" class="btn btn-primary">Return Home</a>
<a href="/oauth2/sign_out" class="btn btn-outline">Sign Out</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,70 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ network_name }} - Admin{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-3xl font-bold">Admin</h1>
<div class="text-sm breadcrumbs">
<ul>
<li><a href="/">Home</a></li>
<li>Admin</li>
</ul>
</div>
</div>
<a href="/oauth2/sign_out" class="btn btn-outline btn-sm">Sign Out</a>
</div>
<!-- Authenticated User Info -->
<div class="flex flex-wrap items-center gap-4 text-sm opacity-70 mb-6">
{% if auth_username or auth_user %}
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
{{ auth_username or auth_user }}
</span>
{% endif %}
{% if auth_email %}
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{{ auth_email }}
</span>
{% endif %}
</div>
<!-- Navigation Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<a href="/a/members" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
Members
</h2>
<p>Manage network members and operators.</p>
</div>
</a>
<a href="/a/node-tags" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
</svg>
Node Tags
</h2>
<p>Manage custom tags and metadata for network nodes.</p>
</div>
</a>
</div>
{% endblock %}

View File

@@ -1,282 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ network_name }} - Members Admin{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold">Members</h1>
<div class="text-sm breadcrumbs">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/a/">Admin</a></li>
<li>Members</li>
</ul>
</div>
</div>
<a href="/oauth2/sign_out" class="btn btn-outline btn-sm">Sign Out</a>
</div>
<!-- Flash Messages -->
{% if message %}
<div class="alert alert-success mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ message }}</span>
</div>
{% endif %}
{% if error %}
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ error }}</span>
</div>
{% endif %}
<!-- Members Table -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex justify-between items-center">
<h2 class="card-title">Network Members ({{ members|length }})</h2>
<button class="btn btn-primary btn-sm" onclick="addModal.showModal()">Add Member</button>
</div>
{% if members %}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Member ID</th>
<th>Name</th>
<th>Callsign</th>
<th>Contact</th>
<th class="w-32">Actions</th>
</tr>
</thead>
<tbody>
{% for member in members %}
<tr data-member-id="{{ member.id }}"
data-member-name="{{ member.name }}"
data-member-member-id="{{ member.member_id }}"
data-member-callsign="{{ member.callsign or '' }}"
data-member-description="{{ member.description or '' }}"
data-member-contact="{{ member.contact or '' }}">
<td class="font-mono font-semibold">{{ member.member_id }}</td>
<td>{{ member.name }}</td>
<td>
{% if member.callsign %}
<span class="badge badge-primary">{{ member.callsign }}</span>
{% else %}
<span class="text-base-content/40">-</span>
{% endif %}
</td>
<td class="max-w-xs truncate" title="{{ member.contact or '' }}">{{ member.contact or '-' }}</td>
<td>
<div class="flex gap-1">
<button class="btn btn-ghost btn-xs btn-edit">
Edit
</button>
<button class="btn btn-ghost btn-xs text-error btn-delete">
Delete
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-8 text-base-content/60">
<p>No members configured yet.</p>
<p class="text-sm mt-2">Click "Add Member" to create the first member.</p>
</div>
{% endif %}
</div>
</div>
<!-- Add Modal -->
<dialog id="addModal" class="modal">
<div class="modal-box w-11/12 max-w-2xl">
<h3 class="font-bold text-lg">Add New Member</h3>
<form method="post" action="/a/members" class="py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Member ID <span class="text-error">*</span></span>
</label>
<input type="text" name="member_id" id="add_member_id" class="input input-bordered"
placeholder="walshie86" required maxlength="50"
pattern="[a-zA-Z0-9_]+"
title="Letters, numbers, and underscores only">
<label class="label">
<span class="label-text-alt">Unique identifier (letters, numbers, underscore)</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Name <span class="text-error">*</span></span>
</label>
<input type="text" name="name" id="add_name" class="input input-bordered"
placeholder="John Smith" required maxlength="255">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Callsign</span>
</label>
<input type="text" name="callsign" id="add_callsign" class="input input-bordered"
placeholder="VK4ABC" maxlength="20">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Contact</span>
</label>
<input type="text" name="contact" id="add_contact" class="input input-bordered"
placeholder="john@example.com or phone number" maxlength="255">
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea name="description" id="add_description" rows="3" class="textarea textarea-bordered"
placeholder="Brief description of member's role and responsibilities..."></textarea>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="addModal.close()">Cancel</button>
<button type="submit" class="btn btn-primary">Add Member</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- Edit Modal -->
<dialog id="editModal" class="modal">
<div class="modal-box w-11/12 max-w-2xl">
<h3 class="font-bold text-lg">Edit Member</h3>
<form method="post" action="/a/members/update" class="py-4">
<input type="hidden" name="id" id="edit_id">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Member ID <span class="text-error">*</span></span>
</label>
<input type="text" name="member_id" id="edit_member_id" class="input input-bordered"
required maxlength="50" pattern="[a-zA-Z0-9_]+"
title="Letters, numbers, and underscores only">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Name <span class="text-error">*</span></span>
</label>
<input type="text" name="name" id="edit_name" class="input input-bordered"
required maxlength="255">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Callsign</span>
</label>
<input type="text" name="callsign" id="edit_callsign" class="input input-bordered"
maxlength="20">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Contact</span>
</label>
<input type="text" name="contact" id="edit_contact" class="input input-bordered"
maxlength="255">
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea name="description" id="edit_description" rows="3"
class="textarea textarea-bordered"></textarea>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="editModal.close()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- Delete Modal -->
<dialog id="deleteModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Delete Member</h3>
<form method="post" action="/a/members/delete" class="py-4">
<input type="hidden" name="id" id="delete_id">
<p class="py-4">Are you sure you want to delete member <strong id="delete_member_name"></strong>?</p>
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>This action cannot be undone.</span>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="deleteModal.close()">Cancel</button>
<button type="submit" class="btn btn-error">Delete</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
{% endblock %}
{% block extra_scripts %}
<script>
// Use event delegation to handle button clicks safely
document.addEventListener('DOMContentLoaded', function() {
// Edit button handler
document.querySelectorAll('.btn-edit').forEach(function(btn) {
btn.addEventListener('click', function() {
var row = this.closest('tr');
document.getElementById('edit_id').value = row.dataset.memberId;
document.getElementById('edit_member_id').value = row.dataset.memberMemberId;
document.getElementById('edit_name').value = row.dataset.memberName;
document.getElementById('edit_callsign').value = row.dataset.memberCallsign;
document.getElementById('edit_description').value = row.dataset.memberDescription;
document.getElementById('edit_contact').value = row.dataset.memberContact;
editModal.showModal();
});
});
// Delete button handler
document.querySelectorAll('.btn-delete').forEach(function(btn) {
btn.addEventListener('click', function() {
var row = this.closest('tr');
document.getElementById('delete_id').value = row.dataset.memberId;
document.getElementById('delete_member_name').textContent = row.dataset.memberName;
deleteModal.showModal();
});
});
});
</script>
{% endblock %}

View File

@@ -1,434 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ network_name }} - Node Tags Admin{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold">Node Tags</h1>
<div class="text-sm breadcrumbs">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/a/">Admin</a></li>
<li>Node Tags</li>
</ul>
</div>
</div>
<a href="/oauth2/sign_out" class="btn btn-outline btn-sm">Sign Out</a>
</div>
<!-- Flash Messages -->
{% if message %}
<div class="alert alert-success mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ message }}</span>
</div>
{% endif %}
{% if error %}
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ error }}</span>
</div>
{% endif %}
<!-- Node Selector -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title">Select Node</h2>
<form method="get" action="/a/node-tags" class="flex gap-4 items-end">
<div class="form-control flex-1">
<label class="label">
<span class="label-text">Node</span>
</label>
<select name="public_key" class="select select-bordered w-full" onchange="this.form.submit()">
<option value="">-- Select a node --</option>
{% for node in nodes %}
<option value="{{ node.public_key }}" {% if node.public_key == selected_public_key %}selected{% endif %}>
{{ node.name or 'Unnamed' }} ({{ node.public_key[:8] }}...{{ node.public_key[-4:] }})
</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary">Load Tags</button>
</form>
</div>
</div>
{% if selected_public_key and selected_node %}
<!-- Selected Node Info -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<div class="flex justify-between items-start">
<div class="flex items-start gap-3">
<span class="text-2xl" title="{{ selected_node.adv_type or 'Unknown' }}">{% if selected_node.adv_type and selected_node.adv_type|lower == 'chat' %}💬{% elif selected_node.adv_type and selected_node.adv_type|lower == 'repeater' %}📡{% elif selected_node.adv_type and selected_node.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
<div>
<h2 class="card-title">{{ selected_node.name or 'Unnamed Node' }}</h2>
<p class="text-sm opacity-70 font-mono">{{ selected_public_key }}</p>
</div>
</div>
<div class="flex gap-2">
{% if tags %}
<button class="btn btn-outline btn-sm" onclick="copyAllModal.showModal()">Copy All</button>
<button class="btn btn-outline btn-error btn-sm" onclick="deleteAllModal.showModal()">Delete All</button>
{% endif %}
<a href="/nodes/{{ selected_public_key }}" class="btn btn-ghost btn-sm">View Node</a>
</div>
</div>
</div>
</div>
<!-- Tags Table -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title">Tags ({{ tags|length }})</h2>
{% if tags %}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Type</th>
<th>Updated</th>
<th class="w-48">Actions</th>
</tr>
</thead>
<tbody>
{% for tag in tags %}
<tr data-tag-key="{{ tag.key }}" data-tag-value="{{ tag.value or '' }}" data-tag-type="{{ tag.value_type }}">
<td class="font-mono font-semibold">{{ tag.key }}</td>
<td class="max-w-xs truncate" title="{{ tag.value or '' }}">{{ tag.value or '-' }}</td>
<td>
<span class="badge badge-ghost badge-sm">{{ tag.value_type }}</span>
</td>
<td class="text-sm opacity-70">{{ tag.updated_at[:10] if tag.updated_at else '-' }}</td>
<td>
<div class="flex gap-1">
<button class="btn btn-ghost btn-xs btn-edit">
Edit
</button>
<button class="btn btn-ghost btn-xs btn-move">
Move
</button>
<button class="btn btn-ghost btn-xs text-error btn-delete">
Delete
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-8 text-base-content/60">
<p>No tags found for this node.</p>
<p class="text-sm mt-2">Add a new tag below.</p>
</div>
{% endif %}
</div>
</div>
<!-- Add New Tag Form -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Add New Tag</h2>
<form method="post" action="/a/node-tags" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
<div class="form-control">
<label class="label">
<span class="label-text">Key</span>
</label>
<input type="text" name="key" class="input input-bordered" placeholder="tag_name" required maxlength="100">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Value</span>
</label>
<input type="text" name="value" class="input input-bordered" placeholder="tag value">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Type</span>
</label>
<select name="value_type" class="select select-bordered">
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">&nbsp;</span>
</label>
<button type="submit" class="btn btn-primary">Add Tag</button>
</div>
</form>
</div>
</div>
<!-- Edit Modal -->
<dialog id="editModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Edit Tag</h3>
<form method="post" action="/a/node-tags/update" class="py-4">
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
<input type="hidden" name="key" id="editKey">
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Key</span>
</label>
<input type="text" id="editKeyDisplay" class="input input-bordered" disabled>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Value</span>
</label>
<input type="text" name="value" id="editValue" class="input input-bordered">
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Type</span>
</label>
<select name="value_type" id="editValueType" class="select select-bordered w-full">
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
</select>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="editModal.close()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- Move Modal -->
<dialog id="moveModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Move Tag to Another Node</h3>
<form method="post" action="/a/node-tags/move" class="py-4">
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
<input type="hidden" name="key" id="moveKey">
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Tag Key</span>
</label>
<input type="text" id="moveKeyDisplay" class="input input-bordered" disabled>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Destination Node</span>
</label>
<select name="new_public_key" id="moveDestination" class="select select-bordered w-full" required>
<option value="">-- Select destination node --</option>
{% for node in nodes %}
{% if node.public_key != selected_public_key %}
<option value="{{ node.public_key }}">
{{ node.name or 'Unnamed' }} ({{ node.public_key[:8] }}...{{ node.public_key[-4:] }})
</option>
{% endif %}
{% endfor %}
</select>
</div>
<div class="alert alert-warning mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>This will move the tag from the current node to the destination node.</span>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="moveModal.close()">Cancel</button>
<button type="submit" class="btn btn-warning">Move Tag</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- Delete Modal -->
<dialog id="deleteModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Delete Tag</h3>
<form method="post" action="/a/node-tags/delete" class="py-4">
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
<input type="hidden" name="key" id="deleteKey">
<p class="py-4">Are you sure you want to delete the tag "<span id="deleteKeyDisplay" class="font-mono font-semibold"></span>"?</p>
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>This action cannot be undone.</span>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="deleteModal.close()">Cancel</button>
<button type="submit" class="btn btn-error">Delete</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- Copy All Tags Modal -->
<dialog id="copyAllModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Copy All Tags to Another Node</h3>
<form method="post" action="/a/node-tags/copy-all" class="py-4">
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
<p class="mb-4">Copy all {{ tags|length }} tag(s) from <strong>{{ selected_node.name or 'Unnamed' }}</strong> to another node.</p>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Destination Node</span>
</label>
<select name="dest_public_key" class="select select-bordered w-full" required>
<option value="">-- Select destination node --</option>
{% for node in nodes %}
{% if node.public_key != selected_public_key %}
<option value="{{ node.public_key }}">
{{ node.name or 'Unnamed' }} ({{ node.public_key[:8] }}...{{ node.public_key[-4:] }})
</option>
{% endif %}
{% endfor %}
</select>
</div>
<div class="alert alert-info mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Tags that already exist on the destination node will be skipped. Original tags remain on this node.</span>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="copyAllModal.close()">Cancel</button>
<button type="submit" class="btn btn-primary">Copy Tags</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- Delete All Tags Modal -->
<dialog id="deleteAllModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Delete All Tags</h3>
<form method="post" action="/a/node-tags/delete-all" class="py-4">
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
<p class="mb-4">Are you sure you want to delete all {{ tags|length }} tag(s) from <strong>{{ selected_node.name or 'Unnamed' }}</strong>?</p>
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>This action cannot be undone. All tags will be permanently deleted.</span>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="deleteAllModal.close()">Cancel</button>
<button type="submit" class="btn btn-error">Delete All Tags</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
{% elif selected_public_key and not selected_node %}
<div class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>Node not found: {{ selected_public_key }}</span>
</div>
{% else %}
<div class="card bg-base-100 shadow-xl">
<div class="card-body text-center py-12">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto mb-4 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
</svg>
<h2 class="text-xl font-semibold mb-2">Select a Node</h2>
<p class="opacity-70">Choose a node from the dropdown above to view and manage its tags.</p>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_scripts %}
<script>
// Use event delegation to handle button clicks safely
document.addEventListener('DOMContentLoaded', function() {
// Edit button handler
document.querySelectorAll('.btn-edit').forEach(function(btn) {
btn.addEventListener('click', function() {
var row = this.closest('tr');
var key = row.dataset.tagKey;
var value = row.dataset.tagValue;
var valueType = row.dataset.tagType;
document.getElementById('editKey').value = key;
document.getElementById('editKeyDisplay').value = key;
document.getElementById('editValue').value = value;
document.getElementById('editValueType').value = valueType;
editModal.showModal();
});
});
// Move button handler
document.querySelectorAll('.btn-move').forEach(function(btn) {
btn.addEventListener('click', function() {
var row = this.closest('tr');
var key = row.dataset.tagKey;
document.getElementById('moveKey').value = key;
document.getElementById('moveKeyDisplay').value = key;
document.getElementById('moveDestination').selectedIndex = 0;
moveModal.showModal();
});
});
// Delete button handler
document.querySelectorAll('.btn-delete').forEach(function(btn) {
btn.addEventListener('click', function() {
var row = this.closest('tr');
var key = row.dataset.tagKey;
document.getElementById('deleteKey').value = key;
document.getElementById('deleteKeyDisplay').textContent = key;
deleteModal.showModal();
});
});
});
</script>
{% endblock %}

View File

@@ -1,163 +0,0 @@
{% extends "base.html" %}
{% from "_macros.html" import pagination %}
{% block title %}{{ network_name }} - Advertisements{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Advertisements</h1>
<span class="badge badge-lg">{{ total }} total</span>
</div>
{% if api_error %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>Could not fetch data from API: {{ api_error }}</span>
</div>
{% endif %}
<!-- Filters -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body py-4">
<form method="GET" action="/advertisements" class="flex gap-4 flex-wrap items-end">
<div class="form-control">
<label class="label py-1">
<span class="label-text">Search</span>
</label>
<input type="text" name="search" value="{{ search }}" placeholder="Search by name, ID, or public key..." class="input input-bordered input-sm w-80" />
</div>
{% if nodes %}
<div class="form-control">
<label class="label py-1">
<span class="label-text">Node</span>
</label>
<select name="public_key" class="select select-bordered select-sm">
<option value="">All Nodes</option>
{% for node in nodes %}
{% set ns = namespace(tag_name=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'name' %}
{% set ns.tag_name = tag.value %}
{% endif %}
{% endfor %}
<option value="{{ node.public_key }}" {% if public_key == node.public_key %}selected{% endif %}>{{ ns.tag_name or node.name or node.public_key[:12] + '...' }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{% if members %}
<div class="form-control">
<label class="label py-1">
<span class="label-text">Member</span>
</label>
<select name="member_id" class="select select-bordered select-sm">
<option value="">All Members</option>
{% for member in members %}
<option value="{{ member.member_id }}" {% if member_id == member.member_id %}selected{% endif %}>{{ member.name }}{% if member.callsign %} ({{ member.callsign }}){% endif %}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
<a href="/advertisements" class="btn btn-ghost btn-sm">Clear</a>
</div>
</form>
</div>
</div>
<!-- Advertisements List - Mobile Card View -->
<div class="lg:hidden space-y-3">
{% for ad in advertisements %}
<a href="/nodes/{{ ad.public_key }}" class="card bg-base-100 shadow-sm block">
<div class="card-body p-3">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<span class="text-lg flex-shrink-0" title="{{ ad.adv_type or 'Unknown' }}">{% if ad.adv_type and ad.adv_type|lower == 'chat' %}💬{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}📡{% elif ad.adv_type and ad.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
<div class="min-w-0">
{% if ad.node_tag_name or ad.node_name or ad.name %}
<div class="font-medium text-sm truncate">{{ ad.node_tag_name or ad.node_name or ad.name }}</div>
<div class="text-xs font-mono opacity-60 truncate">{{ ad.public_key[:16] }}...</div>
{% else %}
<div class="font-mono text-sm truncate">{{ ad.public_key[:16] }}...</div>
{% endif %}
</div>
</div>
<div class="text-right flex-shrink-0">
<div class="text-xs opacity-60">
{{ ad.received_at[:16].replace('T', ' ') if ad.received_at else '-' }}
</div>
{% if ad.receivers and ad.receivers|length >= 1 %}
<div class="flex gap-0.5 justify-end mt-1">
{% for recv in ad.receivers %}
<span class="text-sm" title="{{ recv.tag_name or recv.name or recv.public_key[:12] }}">📡</span>
{% endfor %}
</div>
{% elif ad.received_by %}
<span class="text-sm" title="{{ ad.receiver_tag_name or ad.receiver_name or ad.received_by[:12] }}">📡</span>
{% endif %}
</div>
</div>
</div>
</a>
{% else %}
<div class="text-center py-8 opacity-70">No advertisements found.</div>
{% endfor %}
</div>
<!-- Advertisements Table - Desktop View -->
<div class="hidden lg:block overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow">
<table class="table table-zebra">
<thead>
<tr>
<th>Node</th>
<th>Time</th>
<th>Receivers</th>
</tr>
</thead>
<tbody>
{% for ad in advertisements %}
<tr class="hover">
<td>
<a href="/nodes/{{ ad.public_key }}" class="link link-hover flex items-center gap-2">
<span class="text-lg" title="{{ ad.adv_type or 'Unknown' }}">{% if ad.adv_type and ad.adv_type|lower == 'chat' %}💬{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}📡{% elif ad.adv_type and ad.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
<div>
{% if ad.node_tag_name or ad.node_name or ad.name %}
<div class="font-medium">{{ ad.node_tag_name or ad.node_name or ad.name }}</div>
<div class="text-xs font-mono opacity-70">{{ ad.public_key[:16] }}...</div>
{% else %}
<span class="font-mono text-sm">{{ ad.public_key[:16] }}...</span>
{% endif %}
</div>
</a>
</td>
<td class="text-sm whitespace-nowrap">
{{ ad.received_at[:19].replace('T', ' ') if ad.received_at else '-' }}
</td>
<td>
{% if ad.receivers and ad.receivers|length >= 1 %}
<div class="flex gap-1">
{% for recv in ad.receivers %}
<a href="/nodes/{{ recv.public_key }}" class="text-lg hover:opacity-70" data-receiver-tooltip data-name="{{ recv.tag_name or recv.name or recv.public_key[:12] }}" data-timestamp="{{ recv.received_at }}">📡</a>
{% endfor %}
</div>
{% elif ad.received_by %}
<a href="/nodes/{{ ad.received_by }}" class="text-lg hover:opacity-70" title="{{ ad.receiver_tag_name or ad.receiver_name or ad.received_by[:12] }}">📡</a>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="text-center py-8 opacity-70">No advertisements found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{{ pagination(page, total_pages, {"search": search, "public_key": public_key, "member_id": member_id, "limit": limit}) }}
{% endblock %}

View File

@@ -1,244 +0,0 @@
{% from "macros/icons.html" import icon_home, icon_dashboard, icon_nodes, icon_advertisements, icon_messages, icon_map, icon_members, icon_page %}
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ network_name }}{% endblock %}</title>
<!-- SEO Meta Tags -->
{% set default_description = network_name ~ " - MeshCore off-grid LoRa mesh network dashboard. Monitor nodes, messages, and network activity." %}
<meta name="description" content="{% block meta_description %}{{ default_description }}{% endblock %}">
<meta name="generator" content="MeshCore Hub {{ version }}">
<link rel="canonical" href="{{ request.url }}">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="{{ request.url }}">
<meta property="og:title" content="{% block og_title %}{{ self.title() }}{% endblock %}">
<meta property="og:description" content="{% block og_description %}{{ self.meta_description() }}{% endblock %}">
<meta property="og:site_name" content="{{ network_name }}">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{% block twitter_title %}{{ self.title() }}{% endblock %}">
<meta name="twitter:description" content="{% block twitter_description %}{{ self.meta_description() }}{% endblock %}">
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="{{ logo_url }}">
<!-- Enable View Transitions API for smooth page navigation -->
<meta name="view-transition" content="same-origin">
<!-- Tailwind CSS with DaisyUI -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.19/dist/full.min.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.tailwindcss.com"></script>
<!-- Leaflet CSS for maps -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: oklch(var(--b2));
}
::-webkit-scrollbar-thumb {
background: oklch(var(--bc) / 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(var(--bc) / 0.5);
}
/* Table styling */
.table-compact td, .table-compact th {
padding: 0.5rem 0.75rem;
}
/* Truncate text in table cells */
.truncate-cell {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Prose styling for custom markdown pages */
.prose h1 { font-size: 2.25rem; font-weight: 700; margin-top: 1.5rem; margin-bottom: 1rem; }
.prose h2 { font-size: 1.875rem; font-weight: 600; margin-top: 1.25rem; margin-bottom: 0.75rem; }
.prose h3 { font-size: 1.5rem; font-weight: 600; margin-top: 1rem; margin-bottom: 0.5rem; }
.prose h4 { font-size: 1.25rem; font-weight: 600; margin-top: 1rem; margin-bottom: 0.5rem; }
.prose p { margin-bottom: 1rem; line-height: 1.75; }
.prose ul, .prose ol { margin-bottom: 1rem; padding-left: 1.5rem; }
.prose ul { list-style-type: disc; }
.prose ol { list-style-type: decimal; }
.prose li { margin-bottom: 0.25rem; }
.prose a { color: oklch(var(--p)); text-decoration: underline; }
.prose a:hover { color: oklch(var(--pf)); }
.prose code { background: oklch(var(--b2)); padding: 0.125rem 0.25rem; border-radius: 0.25rem; font-size: 0.875em; }
.prose pre { background: oklch(var(--b2)); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-bottom: 1rem; }
.prose pre code { background: none; padding: 0; }
.prose blockquote { border-left: 4px solid oklch(var(--bc) / 0.3); padding-left: 1rem; margin: 1rem 0; font-style: italic; }
.prose table { width: 100%; margin-bottom: 1rem; border-collapse: collapse; }
.prose th, .prose td { border: 1px solid oklch(var(--bc) / 0.2); padding: 0.5rem; text-align: left; }
.prose th { background: oklch(var(--b2)); font-weight: 600; }
.prose hr { border: none; border-top: 1px solid oklch(var(--bc) / 0.2); margin: 2rem 0; }
.prose img { max-width: 100%; height: auto; border-radius: 0.5rem; margin: 1rem 0; }
/* View Transitions API - Cross-document page transitions */
.navbar { view-transition-name: navbar; position: relative; z-index: 50; }
main { view-transition-name: main-content; position: relative; z-index: 10; }
footer { view-transition-name: footer; position: relative; z-index: 10; }
/* Subtle slide + fade for main content */
::view-transition-old(main-content) {
animation: vt-fade-out 200ms ease-out forwards;
}
::view-transition-new(main-content) {
animation: vt-slide-up 250ms ease-out forwards;
}
/* Keep navbar and footer stable */
::view-transition-old(navbar),
::view-transition-new(navbar),
::view-transition-old(footer),
::view-transition-new(footer) {
animation: none;
}
/* Subtle crossfade for background */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 150ms;
}
@keyframes vt-fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes vt-slide-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Card entrance animations - only for stat cards with .animate-stagger class */
.animate-stagger > .card,
.animate-stagger > .stat {
animation: card-fade-in 300ms ease-out backwards;
}
.animate-stagger > :nth-child(1) { animation-delay: 0ms; }
.animate-stagger > :nth-child(2) { animation-delay: 50ms; }
.animate-stagger > :nth-child(3) { animation-delay: 100ms; }
.animate-stagger > :nth-child(4) { animation-delay: 150ms; }
.animate-stagger > :nth-child(5) { animation-delay: 200ms; }
.animate-stagger > :nth-child(6) { animation-delay: 250ms; }
@keyframes card-fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
.card,
::view-transition-old(main-content),
::view-transition-new(main-content),
::view-transition-old(root),
::view-transition-new(root) {
animation: none !important;
}
}
</style>
{% block extra_head %}{% endblock %}
</head>
<body class="min-h-screen bg-base-200">
<!-- Navbar -->
<div class="navbar bg-base-100 shadow-lg">
<div class="navbar-start">
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" />
</svg>
</div>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
<li><a href="/" class="{% if request.url.path == '/' %}active{% endif %}">{{ icon_home("h-4 w-4") }} Home</a></li>
<li><a href="/dashboard" class="{% if request.url.path == '/dashboard' %}active{% endif %}">{{ icon_dashboard("h-4 w-4") }} Dashboard</a></li>
<li><a href="/nodes" class="{% if '/nodes' in request.url.path %}active{% endif %}">{{ icon_nodes("h-4 w-4") }} Nodes</a></li>
<li><a href="/advertisements" class="{% if request.url.path == '/advertisements' %}active{% endif %}">{{ icon_advertisements("h-4 w-4") }} Adverts</a></li>
<li><a href="/messages" class="{% if request.url.path == '/messages' %}active{% endif %}">{{ icon_messages("h-4 w-4") }} Messages</a></li>
<li><a href="/map" class="{% if request.url.path == '/map' %}active{% endif %}">{{ icon_map("h-4 w-4") }} Map</a></li>
<li><a href="/members" class="{% if request.url.path == '/members' %}active{% endif %}">{{ icon_members("h-4 w-4") }} Members</a></li>
{% for page in custom_pages %}
<li><a href="{{ page.url }}" class="{% if request.url.path == page.url %}active{% endif %}">{{ icon_page("h-4 w-4") }} {{ page.title }}</a></li>
{% endfor %}
</ul>
</div>
<a href="/" class="btn btn-ghost text-xl">
<img src="{{ logo_url }}" alt="{{ network_name }}" class="h-6 w-6 mr-2" />
{{ network_name }}
</a>
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<li><a href="/" class="{% if request.url.path == '/' %}active{% endif %}">{{ icon_home("h-4 w-4") }} Home</a></li>
<li><a href="/dashboard" class="{% if request.url.path == '/dashboard' %}active{% endif %}">{{ icon_dashboard("h-4 w-4") }} Dashboard</a></li>
<li><a href="/nodes" class="{% if '/nodes' in request.url.path %}active{% endif %}">{{ icon_nodes("h-4 w-4") }} Nodes</a></li>
<li><a href="/advertisements" class="{% if request.url.path == '/advertisements' %}active{% endif %}">{{ icon_advertisements("h-4 w-4") }} Adverts</a></li>
<li><a href="/messages" class="{% if request.url.path == '/messages' %}active{% endif %}">{{ icon_messages("h-4 w-4") }} Messages</a></li>
<li><a href="/map" class="{% if request.url.path == '/map' %}active{% endif %}">{{ icon_map("h-4 w-4") }} Map</a></li>
<li><a href="/members" class="{% if request.url.path == '/members' %}active{% endif %}">{{ icon_members("h-4 w-4") }} Members</a></li>
{% for page in custom_pages %}
<li><a href="{{ page.url }}" class="{% if request.url.path == page.url %}active{% endif %}">{{ icon_page("h-4 w-4") }} {{ page.title }}</a></li>
{% endfor %}
</ul>
</div>
<div class="navbar-end">
<div class="badge badge-outline badge-sm">{{ version }}</div>
</div>
</div>
<!-- Main Content -->
<main class="container mx-auto px-4 py-6">
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer class="footer footer-center p-4 bg-base-100 text-base-content mt-auto">
<aside>
<p>
{{ network_name }}
{% if network_city and network_country %}
- {{ network_city }}, {{ network_country }}
{% endif %}
</p>
<p class="text-sm opacity-70">
{% if network_contact_email %}
<a href="mailto:{{ network_contact_email }}" class="link link-hover">{{ network_contact_email }}</a>
{% endif %}
{% if network_contact_email and network_contact_discord %} | {% endif %}
{% if network_contact_discord %}
<a href="{{ network_contact_discord }}" target="_blank" rel="noopener noreferrer" class="link link-hover">Discord</a>
{% endif %}
{% if (network_contact_email or network_contact_discord) and network_contact_github %} | {% endif %}
{% if network_contact_github %}
<a href="{{ network_contact_github }}" target="_blank" rel="noopener noreferrer" class="link link-hover">GitHub</a>
{% endif %}
</p>
<p class="text-xs opacity-50 mt-2">{% if admin_enabled %}<a href="/a/" class="link link-hover">Admin</a> | {% endif %}Powered by <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
</aside>
</footer>
<!-- Leaflet JS for maps -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Common utilities -->
<script src="/static/js/utils.js"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

View File

@@ -1,320 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ network_name }} - Network Overview{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Network Overview</h1>
<button onclick="location.reload()" class="btn btn-ghost btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
</div>
{% if api_error %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>Could not fetch data from API: {{ api_error }}</span>
</div>
{% endif %}
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6 animate-stagger">
<!-- Total Nodes -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div class="stat-title">Total Nodes</div>
<div class="stat-value text-primary">{{ stats.total_nodes }}</div>
<div class="stat-desc">All discovered nodes</div>
</div>
<!-- Advertisements (7 days) -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
</div>
<div class="stat-title">Advertisements</div>
<div class="stat-value text-secondary">{{ stats.advertisements_7d }}</div>
<div class="stat-desc">Last 7 days</div>
</div>
<!-- Messages (7 days) -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-accent">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
</div>
<div class="stat-title">Messages</div>
<div class="stat-value text-accent">{{ stats.messages_7d }}</div>
<div class="stat-desc">Last 7 days</div>
</div>
</div>
<!-- Activity Charts -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8 animate-stagger">
<!-- Node Count Chart -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-base">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
Total Nodes
</h2>
<p class="text-xs opacity-70">Over time (last 7 days)</p>
<div class="h-32">
<canvas id="nodeChart"></canvas>
</div>
</div>
</div>
<!-- Advertisements Chart -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-base">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
Advertisements
</h2>
<p class="text-xs opacity-70">Per day (last 7 days)</p>
<div class="h-32">
<canvas id="advertChart"></canvas>
</div>
</div>
</div>
<!-- Messages Chart -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-base">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
Messages
</h2>
<p class="text-xs opacity-70">Per day (last 7 days)</p>
<div class="h-32">
<canvas id="messageChart"></canvas>
</div>
</div>
</div>
</div>
<!-- Additional Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 animate-stagger">
<!-- Recent Advertisements -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
Recent Advertisements
</h2>
{% if stats.recent_advertisements %}
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
<tr>
<th>Node</th>
<th>Type</th>
<th class="text-right">Received</th>
</tr>
</thead>
<tbody>
{% for ad in stats.recent_advertisements %}
<tr>
<td>
<a href="/nodes/{{ ad.public_key }}" class="link link-hover">
<div class="font-medium">{{ ad.friendly_name or ad.name or ad.public_key[:12] + '...' }}</div>
</a>
{% if ad.friendly_name or ad.name %}
<div class="text-xs opacity-50 font-mono">{{ ad.public_key[:12] }}...</div>
{% endif %}
</td>
<td>
{% if ad.adv_type and ad.adv_type|lower == 'chat' %}
<span title="Chat">💬</span>
{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}
<span title="Repeater">📡</span>
{% elif ad.adv_type and ad.adv_type|lower == 'room' %}
<span title="Room">🪧</span>
{% elif ad.adv_type %}
<span title="{{ ad.adv_type }}">📍</span>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
<td class="text-right text-sm opacity-70">{{ ad.received_at.split('T')[1][:8] if ad.received_at else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm opacity-70">No advertisements recorded yet.</p>
{% endif %}
</div>
</div>
<!-- Channel Messages -->
{% if stats.channel_messages %}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
</svg>
Recent Channel Messages
</h2>
<div class="space-y-4">
{% for channel, messages in stats.channel_messages.items() %}
<div>
<h3 class="font-semibold text-sm mb-2 flex items-center gap-2">
<span class="badge badge-info badge-sm">CH{{ channel }}</span>
Channel {{ channel }}
</h3>
<div class="space-y-1 pl-2 border-l-2 border-base-300">
{% for msg in messages %}
<div class="text-sm">
<span class="text-xs opacity-50">{{ msg.received_at.split('T')[1][:5] if msg.received_at else '' }}</span>
<span class="break-words" style="white-space: pre-wrap;">{{ msg.text }}</span>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
(function() {
const advertData = {{ advert_activity_json | safe }};
const messageData = {{ message_activity_json | safe }};
const nodeData = {{ node_count_json | safe }};
// Common chart options
const commonOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: 'oklch(0.25 0 0)',
titleColor: 'oklch(0.9 0 0)',
bodyColor: 'oklch(0.9 0 0)',
borderColor: 'oklch(0.4 0 0)',
borderWidth: 1
}
},
scales: {
x: {
grid: { color: 'oklch(0.4 0 0 / 0.2)' },
ticks: { color: 'oklch(0.7 0 0)', maxRotation: 45, minRotation: 45 }
},
y: {
beginAtZero: true,
grid: { color: 'oklch(0.4 0 0 / 0.2)' },
ticks: { color: 'oklch(0.7 0 0)', precision: 0 }
}
},
interaction: { mode: 'nearest', axis: 'x', intersect: false }
};
// Helper to format dates
function formatLabels(data) {
return data.map(d => {
const date = new Date(d.date);
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
});
}
// Advertisements chart (secondary color - pink/magenta)
const advertCtx = document.getElementById('advertChart');
if (advertCtx && advertData.data && advertData.data.length > 0) {
new Chart(advertCtx, {
type: 'line',
data: {
labels: formatLabels(advertData.data),
datasets: [{
label: 'Advertisements',
data: advertData.data.map(d => d.count),
borderColor: 'oklch(0.7 0.17 330)',
backgroundColor: 'oklch(0.7 0.17 330 / 0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}]
},
options: commonOptions
});
}
// Messages chart (accent color - teal/cyan)
const messageCtx = document.getElementById('messageChart');
if (messageCtx && messageData.data && messageData.data.length > 0) {
new Chart(messageCtx, {
type: 'line',
data: {
labels: formatLabels(messageData.data),
datasets: [{
label: 'Messages',
data: messageData.data.map(d => d.count),
borderColor: 'oklch(0.75 0.18 180)',
backgroundColor: 'oklch(0.75 0.18 180 / 0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}]
},
options: commonOptions
});
}
// Node count chart (primary color - purple/blue)
const nodeCtx = document.getElementById('nodeChart');
if (nodeCtx && nodeData.data && nodeData.data.length > 0) {
new Chart(nodeCtx, {
type: 'line',
data: {
labels: formatLabels(nodeData.data),
datasets: [{
label: 'Total Nodes',
data: nodeData.data.map(d => d.count),
borderColor: 'oklch(0.65 0.24 265)',
backgroundColor: 'oklch(0.65 0.24 265 / 0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}]
},
options: commonOptions
});
}
})();
</script>
{% endblock %}

View File

@@ -1,35 +0,0 @@
{% extends "base.html" %}
{% block title %}Page Not Found - {{ network_name }}{% endblock %}
{% block content %}
<div class="hero min-h-[60vh]">
<div class="hero-content text-center">
<div class="max-w-md">
<div class="text-9xl font-bold text-primary opacity-20">404</div>
<h1 class="text-4xl font-bold -mt-8">Page Not Found</h1>
<p class="py-6 text-base-content/70">
{% if detail %}
{{ detail }}
{% else %}
The page you're looking for doesn't exist or has been moved.
{% endif %}
</p>
<div class="flex gap-4 justify-center">
<a href="/" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Go Home
</a>
<a href="/nodes" class="btn btn-outline">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
Browse Nodes
</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,293 +0,0 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon_dashboard, icon_map, icon_nodes, icon_advertisements, icon_messages, icon_page %}
{% block title %}{{ network_name }} - Home{% endblock %}
{% block content %}
<!-- Hero Section with Stats -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 bg-base-100 rounded-box p-6">
<!-- Hero Content (2 columns) -->
<div class="lg:col-span-2 flex flex-col items-center text-center">
<!-- Header: Logo and Title side by side -->
<div class="flex items-center gap-8 mb-4">
<img src="{{ logo_url }}" alt="{{ network_name }}" class="h-36 w-36" />
<div class="flex flex-col justify-center">
<h1 class="text-6xl font-black tracking-tight">{{ network_name }}</h1>
{% if network_city and network_country %}
<p class="text-2xl opacity-70 mt-2">{{ network_city }}, {{ network_country }}</p>
{% endif %}
</div>
</div>
{% if network_welcome_text %}
<p class="py-4 max-w-[70%]">{{ network_welcome_text }}</p>
{% else %}
<p class="py-4 max-w-[70%]">
Welcome to the {{ network_name }} mesh network dashboard.
Monitor network activity, view connected nodes, and explore message history.
</p>
{% endif %}
<div class="flex-1"></div>
<div class="flex flex-wrap justify-center gap-3 mt-auto">
<a href="/dashboard" class="btn btn-outline btn-info">
{{ icon_dashboard("h-5 w-5 mr-2") }}
Dashboard
</a>
<a href="/nodes" class="btn btn-outline btn-primary">
{{ icon_nodes("h-5 w-5 mr-2") }}
Nodes
</a>
<a href="/advertisements" class="btn btn-outline btn-secondary">
{{ icon_advertisements("h-5 w-5 mr-2") }}
Adverts
</a>
<a href="/messages" class="btn btn-outline btn-accent">
{{ icon_messages("h-5 w-5 mr-2") }}
Messages
</a>
<a href="/map" class="btn btn-outline btn-warning">
{{ icon_map("h-5 w-5 mr-2") }}
Map
</a>
{% for page in custom_pages[:3] %}
<a href="{{ page.url }}" class="btn btn-outline btn-neutral">
{{ icon_page("h-5 w-5 mr-2") }}
{{ page.title }}
</a>
{% endfor %}
</div>
</div>
<!-- Stats Column (stacked vertically) -->
<div class="flex flex-col gap-4 animate-stagger">
<!-- Total Nodes -->
<div class="stat bg-base-200 rounded-box">
<div class="stat-figure text-primary">
{{ icon_nodes("h-8 w-8") }}
</div>
<div class="stat-title">Total Nodes</div>
<div class="stat-value text-primary">{{ stats.total_nodes }}</div>
<div class="stat-desc">All discovered nodes</div>
</div>
<!-- Advertisements (7 days) -->
<div class="stat bg-base-200 rounded-box">
<div class="stat-figure text-secondary">
{{ icon_advertisements("h-8 w-8") }}
</div>
<div class="stat-title">Advertisements</div>
<div class="stat-value text-secondary">{{ stats.advertisements_7d }}</div>
<div class="stat-desc">Last 7 days</div>
</div>
<!-- Messages (7 days) -->
<div class="stat bg-base-200 rounded-box">
<div class="stat-figure text-accent">
{{ icon_messages("h-8 w-8") }}
</div>
<div class="stat-title">Messages</div>
<div class="stat-value text-accent">{{ stats.messages_7d }}</div>
<div class="stat-desc">Last 7 days</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-6 animate-stagger">
<!-- Network Info Card -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Network Info
</h2>
<div class="space-y-2">
{% if network_radio_config %}
{% if network_radio_config.profile %}
<div class="flex justify-between">
<span class="opacity-70">Profile:</span>
<span class="font-mono">{{ network_radio_config.profile }}</span>
</div>
{% endif %}
{% if network_radio_config.frequency %}
<div class="flex justify-between">
<span class="opacity-70">Frequency:</span>
<span class="font-mono">{{ network_radio_config.frequency }}</span>
</div>
{% endif %}
{% if network_radio_config.bandwidth %}
<div class="flex justify-between">
<span class="opacity-70">Bandwidth:</span>
<span class="font-mono">{{ network_radio_config.bandwidth }}</span>
</div>
{% endif %}
{% if network_radio_config.spreading_factor %}
<div class="flex justify-between">
<span class="opacity-70">Spreading Factor:</span>
<span class="font-mono">{{ network_radio_config.spreading_factor }}</span>
</div>
{% endif %}
{% if network_radio_config.coding_rate %}
<div class="flex justify-between">
<span class="opacity-70">Coding Rate:</span>
<span class="font-mono">{{ network_radio_config.coding_rate }}</span>
</div>
{% endif %}
{% if network_radio_config.tx_power %}
<div class="flex justify-between">
<span class="opacity-70">TX Power:</span>
<span class="font-mono">{{ network_radio_config.tx_power }}</span>
</div>
{% endif %}
{% endif %}
{% if network_location and network_location != (0.0, 0.0) %}
<div class="flex justify-between">
<span class="opacity-70">Location:</span>
<span class="font-mono">{{ "%.4f"|format(network_location[0]) }}, {{ "%.4f"|format(network_location[1]) }}</span>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Powered by MeshCore -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body flex flex-col items-center justify-center">
<p class="text-sm opacity-70 mb-4 text-center">Our local off-grid mesh network is made possible by</p>
<a href="https://meshcore.co.uk/" target="_blank" rel="noopener noreferrer" class="hover:opacity-80 transition-opacity">
<img src="/static/img/meshcore.svg" alt="MeshCore" class="h-8" />
</a>
<p class="text-xs opacity-50 mt-4 text-center">Connecting people and things, without using the internet</p>
<div class="flex gap-2 mt-4">
<a href="https://meshcore.co.uk/" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
Website
</a>
<a href="https://github.com/meshcore-dev/MeshCore" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
GitHub
</a>
</div>
</div>
</div>
<!-- Network Activity Chart -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
</svg>
Network Activity
</h2>
<p class="text-sm opacity-70 mb-2">Activity per day (last 7 days)</p>
<div class="h-48">
<canvas id="activityChart"></canvas>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
(function() {
const advertData = {{ advert_activity_json | safe }};
const messageData = {{ message_activity_json | safe }};
const ctx = document.getElementById('activityChart');
if (ctx && advertData.data && advertData.data.length > 0) {
// Format dates for display (show only day/month)
const labels = advertData.data.map(d => {
const date = new Date(d.date);
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
});
const advertCounts = advertData.data.map(d => d.count);
const messageCounts = messageData.data ? messageData.data.map(d => d.count) : [];
new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Advertisements',
data: advertCounts,
borderColor: 'oklch(0.7 0.17 330)',
backgroundColor: 'oklch(0.7 0.17 330 / 0.1)',
fill: false,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}, {
label: 'Messages',
data: messageCounts,
borderColor: 'oklch(0.7 0.15 200)',
backgroundColor: 'oklch(0.7 0.15 200 / 0.1)',
fill: false,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
color: 'oklch(0.7 0 0)',
boxWidth: 12,
padding: 8
}
},
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: 'oklch(0.25 0 0)',
titleColor: 'oklch(0.9 0 0)',
bodyColor: 'oklch(0.9 0 0)',
borderColor: 'oklch(0.4 0 0)',
borderWidth: 1
}
},
scales: {
x: {
grid: {
color: 'oklch(0.4 0 0 / 0.2)'
},
ticks: {
color: 'oklch(0.7 0 0)',
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 10
}
},
y: {
beginAtZero: true,
grid: {
color: 'oklch(0.4 0 0 / 0.2)'
},
ticks: {
color: 'oklch(0.7 0 0)',
precision: 0
}
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
}
}
});
}
})();
</script>
{% endblock %}

View File

@@ -1,47 +0,0 @@
{% macro icon_dashboard(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
{% endmacro %}
{% macro icon_map(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
{% endmacro %}
{% macro icon_nodes(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
{% endmacro %}
{% macro icon_advertisements(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
{% endmacro %}
{% macro icon_messages(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
{% endmacro %}
{% macro icon_home(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
{% endmacro %}
{% macro icon_members(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
{% endmacro %}
{% macro icon_page(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{% endmacro %}

View File

@@ -1,329 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ network_name }} - Node Map{% endblock %}
{% block extra_head %}
<style>
#map {
height: calc(100vh - 350px);
min-height: 400px;
border-radius: var(--rounded-box);
}
.leaflet-popup-content-wrapper {
background: oklch(var(--b1));
color: oklch(var(--bc));
}
.leaflet-popup-tip {
background: oklch(var(--b1));
}
</style>
{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Node Map</h1>
<div class="flex items-center gap-2">
<span id="node-count" class="badge badge-lg">Loading...</span>
<span id="filtered-count" class="badge badge-lg badge-ghost hidden"></span>
</div>
</div>
<!-- Filters -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body py-4">
<div class="flex gap-4 flex-wrap items-end">
<div class="form-control">
<label class="label py-1">
<span class="label-text">Node Type</span>
</label>
<select id="filter-type" class="select select-bordered select-sm">
<option value="">All Types</option>
<option value="chat">Chat</option>
<option value="repeater">Repeater</option>
<option value="room">Room</option>
</select>
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text">Member</span>
</label>
<select id="filter-member" class="select select-bordered select-sm">
<option value="">All Members</option>
<!-- Populated dynamically -->
</select>
</div>
<button id="clear-filters" class="btn btn-ghost btn-sm">Clear Filters</button>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body p-2">
<div id="map"></div>
</div>
</div>
<!-- Legend -->
<div class="mt-4 flex flex-wrap gap-4 items-center text-sm">
<span class="opacity-70">Legend:</span>
<div class="flex items-center gap-1">
<span class="text-lg">💬</span>
<span>Chat</span>
</div>
<div class="flex items-center gap-1">
<span class="text-lg">📡</span>
<span>Repeater</span>
</div>
<div class="flex items-center gap-1">
<span class="text-lg">🪧</span>
<span>Room</span>
</div>
<div class="flex items-center gap-1">
<span class="text-lg">📍</span>
<span>Other</span>
</div>
</div>
<div class="mt-2 text-sm opacity-70">
<p>Nodes are placed on the map based on their <code>lat</code> and <code>lon</code> tags.</p>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Initialize map with world view (will be centered on nodes once loaded)
const map = L.map('map').setView([0, 0], 2);
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
// Store all nodes and markers
let allNodes = [];
let allMembers = [];
let markers = [];
let mapCenter = { lat: 0, lon: 0 };
// Normalize adv_type to lowercase for consistent comparison
function normalizeType(type) {
return type ? type.toLowerCase() : null;
}
// formatRelativeTime is provided by /static/js/utils.js
// Get emoji marker based on node type
function getNodeEmoji(node) {
const type = normalizeType(node.adv_type);
if (type === 'chat') return '💬';
if (type === 'repeater') return '📡';
if (type === 'room') return '🪧';
return '📍';
}
// Get display name for node type
function getTypeDisplay(node) {
const type = normalizeType(node.adv_type);
if (type === 'chat') return 'Chat';
if (type === 'repeater') return 'Repeater';
if (type === 'room') return 'Room';
return type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Unknown';
}
// Create marker icon for a node
function createNodeIcon(node) {
const emoji = getNodeEmoji(node);
const displayName = node.name || '';
const relativeTime = formatRelativeTime(node.last_seen);
const timeDisplay = relativeTime ? ` (${relativeTime})` : '';
return L.divIcon({
className: 'custom-div-icon',
html: `<div style="display: flex; align-items: center; gap: 2px;">
<span style="font-size: 24px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>
<span style="font-size: 10px; font-weight: bold; color: #000; background: rgba(255,255,255,0.9); padding: 1px 4px; border-radius: 3px; box-shadow: 0 1px 3px rgba(0,0,0,0.3);">${displayName}${timeDisplay}</span>
</div>`,
iconSize: [82, 28],
iconAnchor: [14, 14]
});
}
// Create popup content for a node
function createPopupContent(node) {
let ownerHtml = '';
if (node.owner) {
const ownerDisplay = node.owner.callsign
? `${node.owner.name} (${node.owner.callsign})`
: node.owner.name;
ownerHtml = `<p><span class="opacity-70">Owner:</span> ${ownerDisplay}</p>`;
}
let roleHtml = '';
if (node.role) {
roleHtml = `<p><span class="opacity-70">Role:</span> <span class="badge badge-xs badge-ghost">${node.role}</span></p>`;
}
const emoji = getNodeEmoji(node);
const typeDisplay = getTypeDisplay(node);
return `
<div class="p-2">
<h3 class="font-bold text-lg mb-2">${emoji} ${node.name}</h3>
<div class="space-y-1 text-sm">
<p><span class="opacity-70">Type:</span> ${typeDisplay}</p>
${roleHtml}
${ownerHtml}
<p><span class="opacity-70">Key:</span> <code class="text-xs">${node.public_key.substring(0, 16)}...</code></p>
<p><span class="opacity-70">Location:</span> ${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}</p>
${node.last_seen ? `<p><span class="opacity-70">Last seen:</span> ${node.last_seen.substring(0, 19).replace('T', ' ')}</p>` : ''}
</div>
<a href="/nodes/${node.public_key}" class="btn btn-primary btn-xs mt-3">View Details</a>
</div>
`;
}
// Clear all markers from map
function clearMarkers() {
markers.forEach(marker => map.removeLayer(marker));
markers = [];
}
// Core filter logic - returns filtered nodes and updates markers
function applyFiltersCore() {
const typeFilter = document.getElementById('filter-type').value;
const memberFilter = document.getElementById('filter-member').value;
// Filter nodes
const filteredNodes = allNodes.filter(node => {
// Type filter (case-insensitive)
if (typeFilter && normalizeType(node.adv_type) !== typeFilter) return false;
// Member filter - match node's member_id tag to selected member_id
if (memberFilter) {
if (node.member_id !== memberFilter) return false;
}
return true;
});
// Clear existing markers
clearMarkers();
// Add filtered markers
filteredNodes.forEach(node => {
const marker = L.marker([node.lat, node.lon], { icon: createNodeIcon(node) }).addTo(map);
marker.bindPopup(createPopupContent(node));
markers.push(marker);
});
// Update counts
const countEl = document.getElementById('node-count');
const filteredEl = document.getElementById('filtered-count');
if (filteredNodes.length === allNodes.length) {
countEl.textContent = `${allNodes.length} nodes on map`;
filteredEl.classList.add('hidden');
} else {
countEl.textContent = `${allNodes.length} total`;
filteredEl.textContent = `${filteredNodes.length} shown`;
filteredEl.classList.remove('hidden');
}
return filteredNodes;
}
// Apply filters and recenter map on filtered nodes
function applyFilters() {
const filteredNodes = applyFiltersCore();
// Fit bounds if we have filtered nodes
if (filteredNodes.length > 0) {
const bounds = L.latLngBounds(filteredNodes.map(n => [n.lat, n.lon]));
map.fitBounds(bounds, { padding: [50, 50] });
} else if (mapCenter.lat !== 0 || mapCenter.lon !== 0) {
map.setView([mapCenter.lat, mapCenter.lon], 10);
}
}
// Apply filters without recentering (for initial load after manual center)
function applyFiltersNoRecenter() {
applyFiltersCore();
}
// Populate member filter dropdown
function populateMemberFilter() {
const select = document.getElementById('filter-member');
// Sort members by name
const sortedMembers = [...allMembers].sort((a, b) => a.name.localeCompare(b.name));
// Add options for all members
sortedMembers.forEach(member => {
if (member.member_id) {
const option = document.createElement('option');
option.value = member.member_id;
option.textContent = member.callsign
? `${member.name} (${member.callsign})`
: member.name;
select.appendChild(option);
}
});
}
// Clear all filters
function clearFilters() {
document.getElementById('filter-type').value = '';
document.getElementById('filter-member').value = '';
applyFilters();
}
// Event listeners for filters
document.getElementById('filter-type').addEventListener('change', applyFilters);
document.getElementById('filter-member').addEventListener('change', applyFilters);
document.getElementById('clear-filters').addEventListener('click', clearFilters);
// Fetch and display nodes
fetch('/map/data')
.then(response => response.json())
.then(data => {
allNodes = data.nodes;
allMembers = data.members || [];
mapCenter = data.center;
// Log debug info
const debug = data.debug || {};
console.log('Map data loaded:', debug);
console.log('Sample node data:', allNodes.length > 0 ? allNodes[0] : 'No nodes');
if (debug.error) {
document.getElementById('node-count').textContent = `Error: ${debug.error}`;
return;
}
if (debug.total_nodes === 0) {
document.getElementById('node-count').textContent = 'No nodes in database';
return;
}
if (debug.nodes_with_coords === 0) {
document.getElementById('node-count').textContent = `${debug.total_nodes} nodes (none have coordinates)`;
return;
}
// Populate member filter
populateMemberFilter();
// Initial display - center map on nodes if available
if (allNodes.length > 0) {
const bounds = L.latLngBounds(allNodes.map(n => [n.lat, n.lon]));
map.fitBounds(bounds, { padding: [50, 50] });
}
// Apply filters (won't re-center since we just did above)
applyFiltersNoRecenter();
})
.catch(error => {
console.error('Error loading map data:', error);
document.getElementById('node-count').textContent = 'Error loading data';
});
</script>
{% endblock %}

View File

@@ -1,105 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ network_name }} - Members{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Network Members</h1>
<span class="badge badge-lg">{{ members|length }} members</span>
</div>
{% if members %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 items-start">
{% for member in members %}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
{{ member.name }}
{% if member.callsign %}
<span class="badge badge-success">{{ member.callsign }}</span>
{% endif %}
</h2>
{% if member.description %}
<p class="mt-2">{{ member.description }}</p>
{% endif %}
{% if member.contact %}
<p class="text-sm mt-2">
<span class="opacity-70">Contact:</span> {{ member.contact }}
</p>
{% endif %}
{% if member.nodes %}
<div class="mt-4 space-y-2">
{% for node in member.nodes %}
{% set adv_type = node.adv_type %}
{% set node_tag_name = node.tags|selectattr('key', 'equalto', 'name')|map(attribute='value')|first %}
{% set display_name = node_tag_name or node.name %}
<a href="/nodes/{{ node.public_key }}" class="flex items-center gap-3 p-2 bg-base-200 rounded-lg hover:bg-base-300 transition-colors">
<span class="text-lg" title="{{ adv_type or 'Unknown' }}">
{% if adv_type and adv_type|lower == 'chat' %}
💬
{% elif adv_type and adv_type|lower == 'repeater' %}
📡
{% elif adv_type and adv_type|lower == 'room' %}
🪧
{% elif adv_type %}
📍
{% else %}
📦
{% endif %}
</span>
<div class="flex-1 min-w-0">
{% if display_name %}
<div class="font-medium text-sm">{{ display_name }}</div>
<div class="font-mono text-xs opacity-60">{{ node.public_key[:12] }}...</div>
{% else %}
<div class="font-mono text-sm">{{ node.public_key[:12] }}...</div>
{% endif %}
</div>
{% if node.last_seen %}
<time class="text-xs opacity-60 whitespace-nowrap" datetime="{{ node.last_seen }}" title="{{ node.last_seen[:19].replace('T', ' ') }}" data-relative-time>-</time>
{% endif %}
</a>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h3 class="font-bold">No members configured</h3>
<p class="text-sm">To display network members, create a members.yaml file in your seed directory.</p>
</div>
</div>
<div class="mt-6 card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Members File Format</h2>
<p class="mb-4">Create a YAML file at <code>$SEED_HOME/members.yaml</code> with the following structure:</p>
<pre class="bg-base-200 p-4 rounded-box text-sm overflow-x-auto"><code>members:
- member_id: johndoe
name: John Doe
callsign: AB1CD
role: Network Admin
description: Manages the main repeater node.
contact: john@example.com
- member_id: janesmith
name: Jane Smith
role: Member
description: Regular user in the downtown area.</code></pre>
<p class="mt-4 text-sm opacity-70">
Run <code>meshcore-hub collector seed</code> to import members.<br/>
To associate nodes with members, add a <code>member_id</code> tag to nodes in <code>node_tags.yaml</code>.
</p>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,147 +0,0 @@
{% extends "base.html" %}
{% from "_macros.html" import pagination %}
{% block title %}{{ network_name }} - Messages{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Messages</h1>
<span class="badge badge-lg">{{ total }} total</span>
</div>
{% if api_error %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>Could not fetch data from API: {{ api_error }}</span>
</div>
{% endif %}
<!-- Filters -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body py-4">
<form method="GET" action="/messages" class="flex gap-4 flex-wrap items-end">
<div class="form-control">
<label class="label py-1">
<span class="label-text">Type</span>
</label>
<select name="message_type" class="select select-bordered select-sm">
<option value="">All Types</option>
<option value="contact" {% if message_type == 'contact' %}selected{% endif %}>Direct</option>
<option value="channel" {% if message_type == 'channel' %}selected{% endif %}>Channel</option>
</select>
</div>
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
<a href="/messages" class="btn btn-ghost btn-sm">Clear</a>
</div>
</form>
</div>
</div>
<!-- Messages List - Mobile Card View -->
<div class="lg:hidden space-y-3">
{% for msg in messages %}
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-3">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<span class="text-lg flex-shrink-0" title="{{ msg.message_type|capitalize }}">
{% if msg.message_type == 'channel' %}📻{% else %}👤{% endif %}
</span>
<div class="min-w-0">
<div class="font-medium text-sm truncate">
{% if msg.message_type == 'channel' %}
<span class="opacity-60">Public</span>
{% else %}
{% if msg.sender_tag_name or msg.sender_name %}
{{ msg.sender_tag_name or msg.sender_name }}
{% else %}
<span class="font-mono text-xs">{{ (msg.pubkey_prefix or '-')[:12] }}</span>
{% endif %}
{% endif %}
</div>
<div class="text-xs opacity-60">
{{ msg.received_at[:16].replace('T', ' ') if msg.received_at else '-' }}
</div>
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
{% if msg.receivers and msg.receivers|length >= 1 %}
<div class="flex gap-0.5">
{% for recv in msg.receivers %}
<a href="/nodes/{{ recv.public_key }}" class="text-sm hover:opacity-70" title="{{ recv.tag_name or recv.name or recv.public_key[:12] }}">📡</a>
{% endfor %}
</div>
{% elif msg.received_by %}
<a href="/nodes/{{ msg.received_by }}" class="text-sm hover:opacity-70" title="{{ msg.receiver_tag_name or msg.receiver_name or msg.received_by[:12] }}">📡</a>
{% endif %}
</div>
</div>
<p class="text-sm mt-2 break-words whitespace-pre-wrap">{{ msg.text or '-' }}</p>
</div>
</div>
{% else %}
<div class="text-center py-8 opacity-70">No messages found.</div>
{% endfor %}
</div>
<!-- Messages Table - Desktop View -->
<div class="hidden lg:block overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow">
<table class="table table-zebra">
<thead>
<tr>
<th>Type</th>
<th>Time</th>
<th>From</th>
<th>Message</th>
<th>Receivers</th>
</tr>
</thead>
<tbody>
{% for msg in messages %}
<tr class="hover align-top">
<td class="text-lg" title="{{ msg.message_type|capitalize }}">
{% if msg.message_type == 'channel' %}📻{% else %}👤{% endif %}
</td>
<td class="text-sm whitespace-nowrap">
{{ msg.received_at[:19].replace('T', ' ') if msg.received_at else '-' }}
</td>
<td class="text-sm whitespace-nowrap">
{% if msg.message_type == 'channel' %}
<span class="opacity-60">Public</span>
{% else %}
{% if msg.sender_tag_name or msg.sender_name %}
<span class="font-medium">{{ msg.sender_tag_name or msg.sender_name }}</span>
{% else %}
<span class="font-mono text-xs">{{ (msg.pubkey_prefix or '-')[:12] }}</span>
{% endif %}
{% endif %}
</td>
<td class="break-words max-w-md" style="white-space: pre-wrap;">{{ msg.text or '-' }}</td>
<td>
{% if msg.receivers and msg.receivers|length >= 1 %}
<div class="flex gap-1">
{% for recv in msg.receivers %}
<a href="/nodes/{{ recv.public_key }}" class="text-lg hover:opacity-70" data-receiver-tooltip data-name="{{ recv.tag_name or recv.name or recv.public_key[:12] }}" data-timestamp="{{ recv.received_at }}">📡</a>
{% endfor %}
</div>
{% elif msg.received_by %}
<a href="/nodes/{{ msg.received_by }}" class="text-lg hover:opacity-70" title="{{ msg.receiver_tag_name or msg.receiver_name or msg.received_by[:12] }}">📡</a>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center py-8 opacity-70">No messages found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{{ pagination(page, total_pages, {"message_type": message_type, "limit": limit}) }}
{% endblock %}

View File

@@ -1,330 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ network_name }} - Node Details{% endblock %}
{% block extra_head %}
<style>
#node-map {
height: 300px;
border-radius: var(--rounded-box);
}
.leaflet-popup-content-wrapper {
background: oklch(var(--b1));
color: oklch(var(--bc));
}
.leaflet-popup-tip {
background: oklch(var(--b1));
}
</style>
{% endblock %}
{% block content %}
<div class="breadcrumbs text-sm mb-4">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/nodes">Nodes</a></li>
{% if node %}
{% set ns = namespace(tag_name=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'name' %}
{% set ns.tag_name = tag.value %}
{% endif %}
{% endfor %}
<li>{{ ns.tag_name or node.name or public_key[:12] + '...' }}</li>
{% else %}
<li>Not Found</li>
{% endif %}
</ul>
</div>
{% if api_error %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>Could not fetch data from API: {{ api_error }}</span>
</div>
{% endif %}
{% if node %}
{% set ns = namespace(tag_name=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'name' %}
{% set ns.tag_name = tag.value %}
{% endif %}
{% endfor %}
<!-- Node Info Card -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h1 class="card-title text-2xl">
{% if node.adv_type %}
{% if node.adv_type|lower == 'chat' %}
<span title="Chat">💬</span>
{% elif node.adv_type|lower == 'repeater' %}
<span title="Repeater">📡</span>
{% elif node.adv_type|lower == 'room' %}
<span title="Room">🪧</span>
{% else %}
<span title="{{ node.adv_type }}">📍</span>
{% endif %}
{% endif %}
{{ ns.tag_name or node.name or 'Unnamed Node' }}
</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<h3 class="font-semibold opacity-70 mb-2">Public Key</h3>
<code class="text-sm bg-base-200 p-2 rounded block break-all">{{ node.public_key }}</code>
</div>
<div>
<h3 class="font-semibold opacity-70 mb-2">Activity</h3>
<div class="space-y-1 text-sm">
<p><span class="opacity-70">First seen:</span> {{ node.first_seen[:19].replace('T', ' ') if node.first_seen else '-' }}</p>
<p><span class="opacity-70">Last seen:</span> {{ node.last_seen[:19].replace('T', ' ') if node.last_seen else '-' }}</p>
</div>
</div>
</div>
<!-- Tags and Map Grid -->
{% set ns_map = namespace(lat=none, lon=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'lat' %}
{% set ns_map.lat = tag.value %}
{% elif tag.key == 'lon' %}
{% set ns_map.lon = tag.value %}
{% endif %}
{% endfor %}
<div class="grid grid-cols-1 {% if ns_map.lat and ns_map.lon %}lg:grid-cols-2{% endif %} gap-6 mt-6">
<!-- Tags -->
{% if node.tags or (admin_enabled and is_authenticated) %}
<div>
<h3 class="font-semibold opacity-70 mb-2">Tags</h3>
{% if node.tags %}
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for tag in node.tags %}
<tr>
<td class="font-mono">{{ tag.key }}</td>
<td>{{ tag.value }}</td>
<td class="opacity-70">{{ tag.value_type or 'string' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm opacity-70 mb-2">No tags defined.</p>
{% endif %}
{% if admin_enabled and is_authenticated %}
<div class="mt-3">
<a href="/a/node-tags?public_key={{ node.public_key }}" class="btn btn-sm btn-outline">{% if node.tags %}Edit Tags{% else %}Add Tags{% endif %}</a>
</div>
{% endif %}
</div>
{% endif %}
<!-- Location Map -->
{% if ns_map.lat and ns_map.lon %}
<div>
<h3 class="font-semibold opacity-70 mb-2">Location</h3>
<div id="node-map" class="mb-2"></div>
<div class="text-sm opacity-70">
<p>Coordinates: {{ ns_map.lat }}, {{ ns_map.lon }}</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Recent Advertisements -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Recent Advertisements</h2>
{% if advertisements %}
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>Received By</th>
</tr>
</thead>
<tbody>
{% for adv in advertisements %}
<tr>
<td class="text-xs whitespace-nowrap">{{ adv.received_at[:19].replace('T', ' ') if adv.received_at else '-' }}</td>
<td>
{% if adv.adv_type and adv.adv_type|lower == 'chat' %}
<span title="Chat">💬</span>
{% elif adv.adv_type and adv.adv_type|lower == 'repeater' %}
<span title="Repeater">📡</span>
{% elif adv.adv_type and adv.adv_type|lower == 'room' %}
<span title="Room">🪧</span>
{% elif adv.adv_type %}
<span title="{{ adv.adv_type }}">📍</span>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
<td>
{% if adv.received_by %}
<a href="/nodes/{{ adv.received_by }}" class="link link-hover">
{% if adv.receiver_tag_name or adv.receiver_name %}
<div class="font-medium text-sm">{{ adv.receiver_tag_name or adv.receiver_name }}</div>
<div class="text-xs font-mono opacity-70">{{ adv.received_by[:16] }}...</div>
{% else %}
<span class="font-mono text-xs">{{ adv.received_by[:16] }}...</span>
{% endif %}
</a>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="opacity-70">No advertisements recorded.</p>
{% endif %}
</div>
</div>
<!-- Recent Telemetry -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Recent Telemetry</h2>
{% if telemetry %}
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
<tr>
<th>Time</th>
<th>Data</th>
<th>Received By</th>
</tr>
</thead>
<tbody>
{% for tel in telemetry %}
<tr>
<td class="text-xs whitespace-nowrap">{{ tel.received_at[:19].replace('T', ' ') if tel.received_at else '-' }}</td>
<td class="text-xs font-mono">
{% if tel.parsed_data %}
{{ tel.parsed_data | tojson }}
{% else %}
-
{% endif %}
</td>
<td>
{% if tel.received_by %}
<a href="/nodes/{{ tel.received_by }}" class="link link-hover">
{% if tel.receiver_tag_name or tel.receiver_name %}
<div class="font-medium text-sm">{{ tel.receiver_tag_name or tel.receiver_name }}</div>
<div class="text-xs font-mono opacity-70">{{ tel.received_by[:16] }}...</div>
{% else %}
<span class="font-mono text-xs">{{ tel.received_by[:16] }}...</span>
{% endif %}
</a>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="opacity-70">No telemetry recorded.</p>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Node not found: {{ public_key }}</span>
</div>
<a href="/nodes" class="btn btn-primary mt-4">Back to Nodes</a>
{% endif %}
{% endblock %}
{% block extra_scripts %}
{% if node %}
{% set ns_map = namespace(lat=none, lon=none, name=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'lat' %}
{% set ns_map.lat = tag.value %}
{% elif tag.key == 'lon' %}
{% set ns_map.lon = tag.value %}
{% elif tag.key == 'name' %}
{% set ns_map.name = tag.value %}
{% endif %}
{% endfor %}
{% if ns_map.lat and ns_map.lon %}
<script>
// Initialize map centered on the node's location
const nodeLat = {{ ns_map.lat }};
const nodeLon = {{ ns_map.lon }};
const nodeName = {{ (ns_map.name or node.name or 'Unnamed Node') | tojson }};
const nodeType = {{ (node.adv_type or '') | tojson }};
const publicKey = {{ node.public_key | tojson }};
const map = L.map('node-map').setView([nodeLat, nodeLon], 15);
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
// Get emoji marker based on node type
function getNodeEmoji(type) {
const normalizedType = type ? type.toLowerCase() : null;
if (normalizedType === 'chat') return '💬';
if (normalizedType === 'repeater') return '📡';
if (normalizedType === 'room') return '🪧';
return '📍';
}
// Create marker icon (just the emoji, no label)
const emoji = getNodeEmoji(nodeType);
const icon = L.divIcon({
className: 'custom-div-icon',
html: `<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>`,
iconSize: [32, 32],
iconAnchor: [16, 16]
});
// Add marker
const marker = L.marker([nodeLat, nodeLon], { icon: icon }).addTo(map);
// Add popup (shown on click, not by default)
marker.bindPopup(`
<div class="p-2">
<h3 class="font-bold text-lg mb-2">${emoji} ${nodeName}</h3>
<div class="space-y-1 text-sm">
${nodeType ? `<p><span class="opacity-70">Type:</span> ${nodeType}</p>` : ''}
<p><span class="opacity-70">Coordinates:</span> ${nodeLat.toFixed(4)}, ${nodeLon.toFixed(4)}</p>
</div>
</div>
`);
</script>
{% endif %}
{% endif %}
{% endblock %}

View File

@@ -1,177 +0,0 @@
{% extends "base.html" %}
{% from "_macros.html" import pagination %}
{% block title %}{{ network_name }} - Nodes{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Nodes</h1>
<span class="badge badge-lg">{{ total }} total</span>
</div>
{% if api_error %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>Could not fetch data from API: {{ api_error }}</span>
</div>
{% endif %}
<!-- Filters -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body py-4">
<form method="GET" action="/nodes" class="flex gap-4 flex-wrap items-end">
<div class="form-control">
<label class="label py-1">
<span class="label-text">Search</span>
</label>
<input type="text" name="search" value="{{ search }}" placeholder="Search by name, ID, or public key..." class="input input-bordered input-sm w-80" />
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text">Type</span>
</label>
<select name="adv_type" class="select select-bordered select-sm">
<option value="">All Types</option>
<option value="chat" {% if adv_type == 'chat' %}selected{% endif %}>Chat</option>
<option value="repeater" {% if adv_type == 'repeater' %}selected{% endif %}>Repeater</option>
<option value="room" {% if adv_type == 'room' %}selected{% endif %}>Room</option>
</select>
</div>
{% if members %}
<div class="form-control">
<label class="label py-1">
<span class="label-text">Member</span>
</label>
<select name="member_id" class="select select-bordered select-sm">
<option value="">All Members</option>
{% for member in members %}
<option value="{{ member.member_id }}" {% if member_id == member.member_id %}selected{% endif %}>{{ member.name }}{% if member.callsign %} ({{ member.callsign }}){% endif %}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
<a href="/nodes" class="btn btn-ghost btn-sm">Clear</a>
</div>
</form>
</div>
</div>
<!-- Nodes List - Mobile Card View -->
<div class="lg:hidden space-y-3">
{% for node in nodes %}
{% set ns = namespace(tag_name=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'name' %}
{% set ns.tag_name = tag.value %}
{% endif %}
{% endfor %}
<a href="/nodes/{{ node.public_key }}" class="card bg-base-100 shadow-sm block">
<div class="card-body p-3">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<span class="text-lg flex-shrink-0" title="{{ node.adv_type or 'Unknown' }}">{% if node.adv_type and node.adv_type|lower == 'chat' %}💬{% elif node.adv_type and node.adv_type|lower == 'repeater' %}📡{% elif node.adv_type and node.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
<div class="min-w-0">
{% if ns.tag_name or node.name %}
<div class="font-medium text-sm truncate">{{ ns.tag_name or node.name }}</div>
<div class="text-xs font-mono opacity-60 truncate">{{ node.public_key[:16] }}...</div>
{% else %}
<div class="font-mono text-sm truncate">{{ node.public_key[:16] }}...</div>
{% endif %}
</div>
</div>
<div class="text-right flex-shrink-0">
<div class="text-xs opacity-60">
{% if node.last_seen %}
{{ node.last_seen[:10] }}
{% else %}
-
{% endif %}
</div>
{% if node.tags %}
<div class="flex gap-1 justify-end mt-1">
{% for tag in node.tags[:2] %}
<span class="badge badge-ghost badge-xs">{{ tag.key }}</span>
{% endfor %}
{% if node.tags|length > 2 %}
<span class="badge badge-ghost badge-xs">+{{ node.tags|length - 2 }}</span>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
</a>
{% else %}
<div class="text-center py-8 opacity-70">No nodes found.</div>
{% endfor %}
</div>
<!-- Nodes Table - Desktop View -->
<div class="hidden lg:block overflow-x-auto bg-base-100 rounded-box shadow">
<table class="table table-zebra">
<thead>
<tr>
<th>Node</th>
<th>Last Seen</th>
<th>Tags</th>
</tr>
</thead>
<tbody>
{% for node in nodes %}
{% set ns = namespace(tag_name=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'name' %}
{% set ns.tag_name = tag.value %}
{% endif %}
{% endfor %}
<tr class="hover">
<td>
<a href="/nodes/{{ node.public_key }}" class="link link-hover flex items-center gap-2">
<span class="text-lg" title="{{ node.adv_type or 'Unknown' }}">{% if node.adv_type and node.adv_type|lower == 'chat' %}💬{% elif node.adv_type and node.adv_type|lower == 'repeater' %}📡{% elif node.adv_type and node.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
<div>
{% if ns.tag_name or node.name %}
<div class="font-medium">{{ ns.tag_name or node.name }}</div>
<div class="text-xs font-mono opacity-70">{{ node.public_key[:16] }}...</div>
{% else %}
<span class="font-mono text-sm">{{ node.public_key[:16] }}...</span>
{% endif %}
</div>
</a>
</td>
<td class="text-sm whitespace-nowrap">
{% if node.last_seen %}
{{ node.last_seen[:19].replace('T', ' ') }}
{% else %}
-
{% endif %}
</td>
<td>
{% if node.tags %}
<div class="flex gap-1 flex-wrap">
{% for tag in node.tags[:3] %}
<span class="badge badge-ghost badge-xs">{{ tag.key }}</span>
{% endfor %}
{% if node.tags|length > 3 %}
<span class="badge badge-ghost badge-xs">+{{ node.tags|length - 3 }}</span>
{% endif %}
</div>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="text-center py-8 opacity-70">No nodes found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{{ pagination(page, total_pages, {"search": search, "adv_type": adv_type, "member_id": member_id, "limit": limit}) }}
{% endblock %}

View File

@@ -1,15 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ page.title }} - {{ network_name }}{% endblock %}
{% block meta_description %}{{ page.title }} - {{ network_name }}{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="card bg-base-100 shadow-xl">
<div class="card-body prose prose-lg max-w-none">
{{ page.content_html | safe }}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,204 @@
<!DOCTYPE html>
<html lang="en" data-theme="{{ default_theme }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ network_name }}</title>
<!-- Theme initialization (before CSS to prevent flash) -->
<script>
(function() {
var theme = localStorage.getItem('meshcore-theme');
if (theme) document.documentElement.setAttribute('data-theme', theme);
})();
</script>
<!-- SEO Meta Tags -->
<meta name="description" content="{{ network_name }}{% if network_welcome_text %} - {{ network_welcome_text }}{% else %} - MeshCore off-grid LoRa mesh network dashboard.{% endif %}">
<meta name="generator" content="MeshCore Hub {{ version }}">
<link rel="canonical" href="{{ request.url }}">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:title" content="{{ network_name }}">
<meta property="og:description" content="{{ network_name }}{% if network_welcome_text %} - {{ network_welcome_text }}{% else %} - MeshCore off-grid LoRa mesh network dashboard.{% endif %}">
<meta property="og:site_name" content="{{ network_name }}">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{ network_name }}">
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="{{ logo_url }}">
<!-- Tailwind CSS with DaisyUI -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.19/dist/full.min.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.tailwindcss.com"></script>
<!-- Leaflet CSS for maps -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<!-- Custom application styles -->
<link rel="stylesheet" href="/static/css/app.css?v={{ version }}">
<!-- Import map for ES module dependencies -->
<script type="importmap">
{
"imports": {
"lit-html": "https://esm.sh/lit-html@3",
"lit-html/": "https://esm.sh/lit-html@3/"
}
}
</script>
</head>
<body class="min-h-screen bg-base-200 flex flex-col">
<!-- Navbar -->
<div class="navbar bg-base-100 shadow-lg">
<div class="navbar-start">
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" /></svg>
</div>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
<li><a href="/" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg> {{ 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> {{ 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> {{ 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> {{ 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> {{ 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> {{ 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> {{ t('entities.members') }}</a></li>
{% endif %}
{% if features.pages %}
{% for page in custom_pages %}
<li><a href="{{ page.url }}" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> {{ page.title }}</a></li>
{% endfor %}
{% endif %}
</ul>
</div>
<a href="/" class="btn btn-ghost text-xl">
<img src="{{ logo_url }}" alt="{{ network_name }}" class="theme-logo h-6 w-6 mr-2" />
{{ network_name }}
</a>
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<li><a href="/" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg> {{ 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> {{ 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> {{ 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> {{ 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> {{ 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> {{ 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> {{ t('entities.members') }}</a></li>
{% endif %}
{% if features.pages %}
{% for page in custom_pages %}
<li><a href="{{ page.url }}" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> {{ page.title }}</a></li>
{% endfor %}
{% endif %}
</ul>
</div>
<div class="navbar-end gap-1 pr-2">
<span id="nav-loading" class="loading loading-spinner loading-sm hidden"></span>
<label class="swap swap-rotate btn btn-ghost btn-circle btn-sm">
<input type="checkbox" id="theme-toggle" />
<!-- sun icon - shown in dark mode (click to switch to light) -->
<svg class="swap-off fill-current w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/></svg>
<!-- moon icon - shown in light mode (click to switch to dark) -->
<svg class="swap-on fill-current w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/></svg>
</label>
</div>
</div>
<!-- Main Content -->
<main class="container mx-auto px-4 py-6 flex-1" id="app">
</main>
<!-- Footer -->
<footer class="footer footer-center p-4 bg-base-100 text-base-content mt-auto">
<aside>
<p>
{{ network_name }}
{% if network_city and network_country %}
- {{ network_city }}, {{ network_country }}
{% endif %}
</p>
<p class="text-sm opacity-70">
{% if network_contact_email %}
<a href="mailto:{{ network_contact_email }}" class="link link-hover">{{ network_contact_email }}</a>
{% endif %}
{% if network_contact_email and network_contact_discord %} | {% endif %}
{% if network_contact_discord %}
<a href="{{ network_contact_discord }}" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ 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">{{ 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">{{ t('links.youtube') }}</a>
{% endif %}
</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>
<!-- Leaflet JS for maps -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Chart.js for charts -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- QR Code library -->
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<!-- Chart helper functions -->
<script src="/static/js/charts.js?v={{ version }}"></script>
<!-- Embedded app configuration -->
<script>
window.__APP_CONFIG__ = {{ config_json|safe }};
</script>
<!-- Theme toggle initialization -->
<script>
(function() {
var toggle = document.getElementById('theme-toggle');
if (toggle) {
var current = document.documentElement.getAttribute('data-theme');
toggle.checked = current === 'light';
toggle.addEventListener('change', function() {
var theme = this.checked ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('meshcore-theme', theme);
});
}
})();
</script>
<!-- SPA Application (ES Module) -->
<script type="module" src="/static/js/spa/app.js?v={{ version }}"></script>
</body>
</html>

View File

@@ -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

View 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

View 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"

View File

@@ -9,6 +9,18 @@ from httpx import Response
from meshcore_hub.web.app import create_app
# Explicit all-enabled features dict so tests are not affected by the user's
# local .env file (pydantic-settings loads .env by default).
ALL_FEATURES_ENABLED = {
"dashboard": True,
"nodes": True,
"advertisements": True,
"messages": True,
"map": True,
"members": True,
"pages": True,
}
class MockHttpClient:
"""Mock HTTP client for testing web routes."""
@@ -225,19 +237,42 @@ class MockHttpClient:
def _create_response(self, key: str) -> Response:
"""Create a mock response for a given key."""
import json as _json
response_data = self._responses.get(key)
if response_data is None:
# Return 404 for unknown endpoints
response = MagicMock(spec=Response)
response.status_code = 404
response.json.return_value = {"detail": "Not found"}
response.content = b'{"detail": "Not found"}'
response.headers = {"content-type": "application/json"}
return response
response = MagicMock(spec=Response)
response.status_code = response_data["status_code"]
response.json.return_value = response_data["json"]
response.content = _json.dumps(response_data["json"]).encode()
response.headers = {"content-type": "application/json"}
return response
async def request(
self,
method: str,
url: str,
params: dict | None = None,
content: bytes | None = None,
headers: dict | None = None,
) -> Response:
"""Mock generic request (used by API proxy)."""
key = f"{method.upper()}:{url}"
if key in self._responses:
return self._create_response(key)
# Try base path without query params
base_path = url.split("?")[0]
key = f"{method.upper()}:{base_path}"
return self._create_response(key)
async def get(self, path: str, params: dict | None = None) -> Response:
"""Mock GET request."""
# Try exact match first
@@ -292,6 +327,7 @@ def web_app(mock_http_client: MockHttpClient) -> Any:
network_radio_config="Test Radio Config",
network_contact_email="test@example.com",
network_contact_discord="https://discord.gg/test",
features=ALL_FEATURES_ENABLED,
)
# Override the lifespan to use our mock client
@@ -312,6 +348,38 @@ def client(web_app: Any, mock_http_client: MockHttpClient) -> TestClient:
return TestClient(web_app, raise_server_exceptions=True)
@pytest.fixture
def web_app_no_features(mock_http_client: MockHttpClient) -> Any:
"""Create a web app with all features disabled."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
network_name="Test Network",
network_city="Test City",
network_country="Test Country",
features={
"dashboard": False,
"nodes": False,
"advertisements": False,
"messages": False,
"map": False,
"members": False,
"pages": False,
},
)
app.state.http_client = mock_http_client
return app
@pytest.fixture
def client_no_features(
web_app_no_features: Any, mock_http_client: MockHttpClient
) -> TestClient:
"""Create a test client with all features disabled."""
web_app_no_features.state.http_client = mock_http_client
return TestClient(web_app_no_features, raise_server_exceptions=True)
@pytest.fixture
def mock_http_client_with_members() -> MockHttpClient:
"""Create a mock HTTP client with members data."""
@@ -406,6 +474,7 @@ def web_app_with_members(mock_http_client_with_members: MockHttpClient) -> Any:
network_radio_config="Test Radio Config",
network_contact_email="test@example.com",
network_contact_discord="https://discord.gg/test",
features=ALL_FEATURES_ENABLED,
)
app.state.http_client = mock_http_client_with_members

View File

@@ -1,5 +1,6 @@
"""Tests for admin web routes."""
"""Tests for admin web routes (SPA)."""
import json
from typing import Any
import pytest
@@ -11,122 +12,7 @@ from .conftest import MockHttpClient
@pytest.fixture
def mock_http_client_admin() -> MockHttpClient:
"""Create a mock HTTP client for admin tests."""
client = MockHttpClient()
# Mock the nodes API response for admin dropdown
client.set_response(
"GET",
"/api/v1/nodes",
200,
{
"items": [
{
"public_key": "abc123def456abc123def456abc123de",
"name": "Node One",
"adv_type": "REPEATER",
"first_seen": "2024-01-01T00:00:00Z",
"last_seen": "2024-01-01T12:00:00Z",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"tags": [],
},
{
"public_key": "xyz789xyz789xyz789xyz789xyz789xy",
"name": "Node Two",
"adv_type": "CHAT",
"first_seen": "2024-01-01T00:00:00Z",
"last_seen": "2024-01-01T11:00:00Z",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"tags": [],
},
],
"total": 2,
"limit": 100,
"offset": 0,
},
)
# Mock node tags response
client.set_response(
"GET",
"/api/v1/nodes/abc123def456abc123def456abc123de/tags",
200,
[
{
"key": "environment",
"value": "production",
"value_type": "string",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
},
{
"key": "location",
"value": "building-a",
"value_type": "string",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
},
],
)
# Mock create tag response
client.set_response(
"POST",
"/api/v1/nodes/abc123def456abc123def456abc123de/tags",
201,
{
"key": "new_tag",
"value": "new_value",
"value_type": "string",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
},
)
# Mock update tag response
client.set_response(
"PUT",
"/api/v1/nodes/abc123def456abc123def456abc123de/tags/environment",
200,
{
"key": "environment",
"value": "staging",
"value_type": "string",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T12:00:00Z",
},
)
# Mock move tag response
client.set_response(
"PUT",
"/api/v1/nodes/abc123def456abc123def456abc123de/tags/environment/move",
200,
{
"key": "environment",
"value": "production",
"value_type": "string",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T12:00:00Z",
},
)
# Mock delete tag response
client.set_response(
"DELETE",
"/api/v1/nodes/abc123def456abc123def456abc123de/tags/environment",
204,
None,
)
return client
@pytest.fixture
def admin_app(mock_http_client_admin: MockHttpClient) -> Any:
def admin_app(mock_http_client: MockHttpClient) -> Any:
"""Create a web app with admin enabled."""
app = create_app(
api_url="http://localhost:8000",
@@ -139,13 +25,13 @@ def admin_app(mock_http_client_admin: MockHttpClient) -> Any:
admin_enabled=True,
)
app.state.http_client = mock_http_client_admin
app.state.http_client = mock_http_client
return app
@pytest.fixture
def admin_app_disabled(mock_http_client_admin: MockHttpClient) -> Any:
def admin_app_disabled(mock_http_client: MockHttpClient) -> Any:
"""Create a web app with admin disabled."""
app = create_app(
api_url="http://localhost:8000",
@@ -158,7 +44,7 @@ def admin_app_disabled(mock_http_client_admin: MockHttpClient) -> Any:
admin_enabled=False,
)
app.state.http_client = mock_http_client_admin
app.state.http_client = mock_http_client
return app
@@ -174,253 +60,249 @@ def auth_headers() -> dict:
@pytest.fixture
def admin_client(admin_app: Any, mock_http_client_admin: MockHttpClient) -> TestClient:
def admin_client(admin_app: Any, mock_http_client: MockHttpClient) -> TestClient:
"""Create a test client with admin enabled."""
admin_app.state.http_client = mock_http_client_admin
admin_app.state.http_client = mock_http_client
return TestClient(admin_app, raise_server_exceptions=True)
@pytest.fixture
def admin_client_disabled(
admin_app_disabled: Any, mock_http_client_admin: MockHttpClient
admin_app_disabled: Any, mock_http_client: MockHttpClient
) -> TestClient:
"""Create a test client with admin disabled."""
admin_app_disabled.state.http_client = mock_http_client_admin
admin_app_disabled.state.http_client = mock_http_client
return TestClient(admin_app_disabled, raise_server_exceptions=True)
class TestAdminHome:
"""Tests for admin home page."""
"""Tests for admin home page (SPA).
def test_admin_home_enabled(self, admin_client, auth_headers):
"""Test admin home page when enabled."""
In the SPA architecture, admin routes serve the same shell HTML.
Admin access control is handled client-side based on
window.__APP_CONFIG__.admin_enabled and is_authenticated.
"""
def test_admin_home_returns_spa_shell(self, admin_client, auth_headers):
"""Test admin home page returns the SPA shell."""
response = admin_client.get("/a/", headers=auth_headers)
assert response.status_code == 200
assert "Admin" in response.text
assert "Node Tags" in response.text
assert "window.__APP_CONFIG__" in response.text
def test_admin_home_disabled(self, admin_client_disabled, auth_headers):
"""Test admin home page when disabled."""
def test_admin_home_config_admin_enabled(self, admin_client, auth_headers):
"""Test admin config shows admin_enabled: true."""
response = admin_client.get("/a/", headers=auth_headers)
text = response.text
config_start = text.find("window.__APP_CONFIG__ = ") + len(
"window.__APP_CONFIG__ = "
)
config_end = text.find(";", config_start)
config = json.loads(text[config_start:config_end])
assert config["admin_enabled"] is True
def test_admin_home_config_authenticated(self, admin_client, auth_headers):
"""Test admin config shows is_authenticated: true with auth headers."""
response = admin_client.get("/a/", headers=auth_headers)
text = response.text
config_start = text.find("window.__APP_CONFIG__ = ") + len(
"window.__APP_CONFIG__ = "
)
config_end = text.find(";", config_start)
config = json.loads(text[config_start:config_end])
assert config["is_authenticated"] is True
def test_admin_home_disabled_returns_spa_shell(
self, admin_client_disabled, auth_headers
):
"""Test admin page returns SPA shell even when disabled.
The SPA catch-all serves the shell for all routes.
Client-side code checks admin_enabled to show/hide admin UI.
"""
response = admin_client_disabled.get("/a/", headers=auth_headers)
assert response.status_code == 404
assert response.status_code == 200
assert "window.__APP_CONFIG__" in response.text
def test_admin_home_unauthenticated(self, admin_client):
"""Test admin home page without authentication."""
def test_admin_home_disabled_config(self, admin_client_disabled, auth_headers):
"""Test admin config shows admin_enabled: false when disabled."""
response = admin_client_disabled.get("/a/", headers=auth_headers)
text = response.text
config_start = text.find("window.__APP_CONFIG__ = ") + len(
"window.__APP_CONFIG__ = "
)
config_end = text.find(";", config_start)
config = json.loads(text[config_start:config_end])
assert config["admin_enabled"] is False
def test_admin_home_unauthenticated_returns_spa_shell(self, admin_client):
"""Test admin page returns SPA shell without authentication.
The SPA catch-all serves the shell for all routes.
Client-side code checks is_authenticated to show access denied.
"""
response = admin_client.get("/a/")
assert response.status_code == 403
assert "Access Denied" in response.text
assert response.status_code == 200
assert "window.__APP_CONFIG__" in response.text
def test_admin_home_unauthenticated_config(self, admin_client):
"""Test admin config shows is_authenticated: false without auth headers."""
response = admin_client.get("/a/")
text = response.text
config_start = text.find("window.__APP_CONFIG__ = ") + len(
"window.__APP_CONFIG__ = "
)
config_end = text.find(";", config_start)
config = json.loads(text[config_start:config_end])
assert config["is_authenticated"] is False
class TestAdminNodeTags:
"""Tests for admin node tags page."""
"""Tests for admin node tags page (SPA)."""
def test_node_tags_page_no_selection(self, admin_client, auth_headers):
"""Test node tags page without selecting a node."""
def test_node_tags_page_returns_spa_shell(self, admin_client, auth_headers):
"""Test node tags page returns the SPA shell."""
response = admin_client.get("/a/node-tags", headers=auth_headers)
assert response.status_code == 200
assert "Node Tags" in response.text
assert "Select a Node" in response.text
# Should show node dropdown
assert "Node One" in response.text
assert "Node Two" in response.text
assert "window.__APP_CONFIG__" in response.text
def test_node_tags_page_with_selection(self, admin_client, auth_headers):
"""Test node tags page with a node selected."""
def test_node_tags_page_with_public_key(self, admin_client, auth_headers):
"""Test node tags page with public_key param returns SPA shell."""
response = admin_client.get(
"/a/node-tags?public_key=abc123def456abc123def456abc123de",
headers=auth_headers,
)
assert response.status_code == 200
assert "Node Tags" in response.text
# Should show the selected node's tags
assert "environment" in response.text
assert "production" in response.text
assert "location" in response.text
assert "building-a" in response.text
assert "window.__APP_CONFIG__" in response.text
def test_node_tags_page_disabled(self, admin_client_disabled, auth_headers):
"""Test node tags page when admin is disabled."""
def test_node_tags_page_disabled_returns_spa_shell(
self, admin_client_disabled, auth_headers
):
"""Test node tags page returns SPA shell even when admin is disabled."""
response = admin_client_disabled.get("/a/node-tags", headers=auth_headers)
assert response.status_code == 404
def test_node_tags_page_with_message(self, admin_client, auth_headers):
"""Test node tags page displays success message."""
response = admin_client.get(
"/a/node-tags?public_key=abc123def456abc123def456abc123de"
"&message=Tag%20created%20successfully",
headers=auth_headers,
)
assert response.status_code == 200
assert "Tag created successfully" in response.text
def test_node_tags_page_with_error(self, admin_client, auth_headers):
"""Test node tags page displays error message."""
response = admin_client.get(
"/a/node-tags?public_key=abc123def456abc123def456abc123de"
"&error=Tag%20already%20exists",
headers=auth_headers,
)
assert response.status_code == 200
assert "Tag already exists" in response.text
assert "window.__APP_CONFIG__" in response.text
def test_node_tags_page_unauthenticated(self, admin_client):
"""Test node tags page without authentication."""
"""Test node tags page returns SPA shell without authentication."""
response = admin_client.get("/a/node-tags")
assert response.status_code == 403
assert "Access Denied" in response.text
assert response.status_code == 200
assert "window.__APP_CONFIG__" in response.text
class TestAdminCreateTag:
"""Tests for creating node tags."""
class TestAdminApiProxyAuth:
"""Tests for admin API proxy authentication enforcement.
def test_create_tag_success(self, admin_client, auth_headers):
"""Test creating a new tag."""
When admin is enabled, mutating requests (POST/PUT/DELETE/PATCH) through
the API proxy must require authentication via X-Forwarded-User header.
This prevents unauthenticated users from performing admin operations
even though the web app's HTTP client has a service-level API key.
"""
def test_proxy_post_blocked_without_auth(self, admin_client, mock_http_client):
"""POST to API proxy returns 401 without auth headers."""
mock_http_client.set_response("POST", "/api/v1/members", 201, {"id": "new"})
response = admin_client.post(
"/a/node-tags",
data={
"public_key": "abc123def456abc123def456abc123de",
"key": "new_tag",
"value": "new_value",
"value_type": "string",
},
headers=auth_headers,
follow_redirects=False,
"/api/v1/members",
json={"name": "Test", "member_id": "test"},
)
assert response.status_code == 303
assert "message=" in response.headers["location"]
assert "created" in response.headers["location"]
assert response.status_code == 401
assert "Authentication required" in response.json()["detail"]
def test_create_tag_disabled(self, admin_client_disabled, auth_headers):
"""Test creating tag when admin is disabled."""
response = admin_client_disabled.post(
"/a/node-tags",
data={
"public_key": "abc123def456abc123def456abc123de",
"key": "new_tag",
"value": "new_value",
"value_type": "string",
},
headers=auth_headers,
follow_redirects=False,
def test_proxy_put_blocked_without_auth(self, admin_client, mock_http_client):
"""PUT to API proxy returns 401 without auth headers."""
mock_http_client.set_response("PUT", "/api/v1/members/1", 200, {"id": "1"})
response = admin_client.put(
"/api/v1/members/1",
json={"name": "Updated"},
)
assert response.status_code == 404
assert response.status_code == 401
def test_create_tag_unauthenticated(self, admin_client):
"""Test creating tag without authentication."""
response = admin_client.post(
"/a/node-tags",
data={
"public_key": "abc123def456abc123def456abc123de",
"key": "new_tag",
"value": "new_value",
"value_type": "string",
},
follow_redirects=False,
def test_proxy_delete_blocked_without_auth(self, admin_client, mock_http_client):
"""DELETE to API proxy returns 401 without auth headers."""
mock_http_client.set_response("DELETE", "/api/v1/members/1", 204, None)
response = admin_client.delete("/api/v1/members/1")
assert response.status_code == 401
def test_proxy_patch_blocked_without_auth(self, admin_client, mock_http_client):
"""PATCH to API proxy returns 401 without auth headers."""
mock_http_client.set_response("PATCH", "/api/v1/members/1", 200, {"id": "1"})
response = admin_client.patch(
"/api/v1/members/1",
json={"name": "Patched"},
)
assert response.status_code == 403
assert response.status_code == 401
class TestAdminUpdateTag:
"""Tests for updating node tags."""
def test_update_tag_success(self, admin_client, auth_headers):
"""Test updating a tag."""
response = admin_client.post(
"/a/node-tags/update",
data={
"public_key": "abc123def456abc123def456abc123de",
"key": "environment",
"value": "staging",
"value_type": "string",
},
headers=auth_headers,
follow_redirects=False,
)
assert response.status_code == 303
assert "message=" in response.headers["location"]
assert "updated" in response.headers["location"]
def test_update_tag_not_found(
self, admin_app, mock_http_client_admin: MockHttpClient, auth_headers
def test_proxy_post_allowed_with_auth(
self, admin_client, auth_headers, mock_http_client
):
"""Test updating a non-existent tag returns error."""
# Set up 404 response for this specific tag
mock_http_client_admin.set_response(
"PUT",
"/api/v1/nodes/abc123def456abc123def456abc123de/tags/nonexistent",
404,
{"detail": "Tag not found"},
)
admin_app.state.http_client = mock_http_client_admin
client = TestClient(admin_app, raise_server_exceptions=True)
response = client.post(
"/a/node-tags/update",
data={
"public_key": "abc123def456abc123def456abc123de",
"key": "nonexistent",
"value": "value",
"value_type": "string",
},
"""POST to API proxy succeeds with auth headers."""
mock_http_client.set_response("POST", "/api/v1/members", 201, {"id": "new"})
response = admin_client.post(
"/api/v1/members",
json={"name": "Test", "member_id": "test"},
headers=auth_headers,
follow_redirects=False,
)
assert response.status_code == 303
assert "error=" in response.headers["location"]
assert "not+found" in response.headers["location"].lower()
assert response.status_code == 201
def test_update_tag_disabled(self, admin_client_disabled, auth_headers):
"""Test updating tag when admin is disabled."""
def test_proxy_put_allowed_with_auth(
self, admin_client, auth_headers, mock_http_client
):
"""PUT to API proxy succeeds with auth headers."""
mock_http_client.set_response("PUT", "/api/v1/members/1", 200, {"id": "1"})
response = admin_client.put(
"/api/v1/members/1",
json={"name": "Updated"},
headers=auth_headers,
)
assert response.status_code == 200
def test_proxy_delete_allowed_with_auth(
self, admin_client, auth_headers, mock_http_client
):
"""DELETE to API proxy succeeds with auth headers."""
mock_http_client.set_response("DELETE", "/api/v1/members/1", 204, None)
response = admin_client.delete(
"/api/v1/members/1",
headers=auth_headers,
)
# 204 from the mock API
assert response.status_code == 204
def test_proxy_get_allowed_without_auth(self, admin_client, mock_http_client):
"""GET to API proxy is allowed without auth (read-only)."""
response = admin_client.get("/api/v1/nodes")
assert response.status_code == 200
def test_proxy_post_allowed_when_admin_disabled(
self, admin_client_disabled, mock_http_client
):
"""POST to API proxy allowed when admin is disabled (no proxy auth)."""
mock_http_client.set_response("POST", "/api/v1/members", 201, {"id": "new"})
response = admin_client_disabled.post(
"/a/node-tags/update",
data={
"public_key": "abc123def456abc123def456abc123de",
"key": "environment",
"value": "staging",
"value_type": "string",
},
headers=auth_headers,
follow_redirects=False,
"/api/v1/members",
json={"name": "Test", "member_id": "test"},
)
assert response.status_code == 404
# Should reach the API (which may return its own auth error, but
# the proxy itself should not block it)
assert response.status_code == 201
class TestAdminMoveTag:
"""Tests for moving node tags."""
class TestAdminFooterLink:
"""Tests for admin link in footer."""
def test_move_tag_success(self, admin_client, auth_headers):
"""Test moving a tag to another node."""
response = admin_client.post(
"/a/node-tags/move",
data={
"public_key": "abc123def456abc123def456abc123de",
"key": "environment",
"new_public_key": "xyz789xyz789xyz789xyz789xyz789xy",
},
headers=auth_headers,
follow_redirects=False,
)
assert response.status_code == 303
# Should redirect to destination node
assert "xyz789xyz789xyz789xyz789xyz789xy" in response.headers["location"]
assert "message=" in response.headers["location"]
assert "moved" in response.headers["location"]
def test_admin_link_visible_when_enabled(self, admin_client):
"""Test that admin link appears in footer when enabled."""
response = admin_client.get("/")
assert response.status_code == 200
assert 'href="/a/"' in response.text
assert "Admin" in response.text
class TestAdminDeleteTag:
"""Tests for deleting node tags."""
def test_delete_tag_success(self, admin_client, auth_headers):
"""Test deleting a tag."""
response = admin_client.post(
"/a/node-tags/delete",
data={
"public_key": "abc123def456abc123def456abc123de",
"key": "environment",
},
headers=auth_headers,
follow_redirects=False,
)
assert response.status_code == 303
assert "message=" in response.headers["location"]
assert "deleted" in response.headers["location"]
def test_admin_link_hidden_when_disabled(self, admin_client_disabled):
"""Test that admin link does not appear in footer when disabled."""
response = admin_client_disabled.get("/")
assert response.status_code == 200
assert 'href="/a/"' not in response.text

View File

@@ -1,11 +1,9 @@
"""Tests for the advertisements page route."""
"""Tests for the advertisements page route (SPA)."""
from typing import Any
import json
from fastapi.testclient import TestClient
from tests.test_web.conftest import MockHttpClient
class TestAdvertisementsPage:
"""Tests for the advertisements page."""
@@ -25,340 +23,82 @@ class TestAdvertisementsPage:
response = client.get("/advertisements")
assert "Test Network" in response.text
def test_advertisements_displays_advertisement_list(
self, client: TestClient, mock_http_client: MockHttpClient
) -> None:
"""Test that advertisements page displays advertisements from API."""
def test_advertisements_contains_app_config(self, client: TestClient) -> None:
"""Test that advertisements page contains SPA config."""
response = client.get("/advertisements")
assert response.status_code == 200
# Check for advertisement data from mock
assert "Node One" in response.text
assert "window.__APP_CONFIG__" in response.text
def test_advertisements_displays_adv_type(
self, client: TestClient, mock_http_client: MockHttpClient
) -> None:
"""Test that advertisements page displays advertisement types."""
def test_advertisements_contains_spa_script(self, client: TestClient) -> None:
"""Test that advertisements page includes SPA application script."""
response = client.get("/advertisements")
# Should show adv type from mock data
assert "REPEATER" in response.text
assert "/static/js/spa/app.js" in response.text
class TestAdvertisementsPageFilters:
"""Tests for advertisements page filtering."""
"""Tests for advertisements page with query parameters.
In the SPA architecture, all routes return the same shell.
Query parameters are handled client-side.
"""
def test_advertisements_with_search(self, client: TestClient) -> None:
"""Test advertisements page with search parameter."""
"""Test advertisements page with search parameter returns SPA shell."""
response = client.get("/advertisements?search=node")
assert response.status_code == 200
def test_advertisements_with_member_filter(self, client: TestClient) -> None:
"""Test advertisements page with member_id filter."""
"""Test advertisements page with member_id filter returns SPA shell."""
response = client.get("/advertisements?member_id=alice")
assert response.status_code == 200
def test_advertisements_with_public_key_filter(self, client: TestClient) -> None:
"""Test advertisements page with public_key filter."""
"""Test advertisements page with public_key filter returns SPA shell."""
response = client.get(
"/advertisements?public_key=abc123def456abc123def456abc123de"
)
assert response.status_code == 200
def test_advertisements_with_pagination(self, client: TestClient) -> None:
"""Test advertisements page with pagination parameters."""
"""Test advertisements page with pagination parameters returns SPA shell."""
response = client.get("/advertisements?page=1&limit=25")
assert response.status_code == 200
def test_advertisements_page_2(self, client: TestClient) -> None:
"""Test advertisements page 2."""
"""Test advertisements page 2 returns SPA shell."""
response = client.get("/advertisements?page=2")
assert response.status_code == 200
def test_advertisements_with_all_filters(self, client: TestClient) -> None:
"""Test advertisements page with multiple filters."""
"""Test advertisements page with multiple filters returns SPA shell."""
response = client.get(
"/advertisements?search=test&member_id=alice&page=1&limit=10"
)
assert response.status_code == 200
class TestAdvertisementsPageDropdowns:
"""Tests for advertisements page dropdown data."""
class TestAdvertisementsConfig:
"""Tests for advertisements page SPA config content."""
def test_advertisements_loads_members_for_dropdown(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that advertisements page loads members for filter dropdown."""
# Set up members response
mock_http_client.set_response(
"GET",
"/api/v1/members",
200,
{
"items": [
{"id": "m1", "member_id": "alice", "name": "Alice"},
{"id": "m2", "member_id": "bob", "name": "Bob"},
],
"total": 2,
},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
def test_advertisements_config_has_network_name(self, client: TestClient) -> None:
"""Test that SPA config includes network name."""
response = client.get("/advertisements")
assert response.status_code == 200
# Members should be available for dropdown
assert "Alice" in response.text or "alice" in response.text
def test_advertisements_loads_nodes_for_dropdown(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that advertisements page loads nodes for filter dropdown."""
# Set up nodes response with tags
mock_http_client.set_response(
"GET",
"/api/v1/nodes",
200,
{
"items": [
{
"id": "n1",
"public_key": "abc123",
"name": "Node Alpha",
"tags": [{"key": "name", "value": "Custom Name"}],
},
{
"id": "n2",
"public_key": "def456",
"name": "Node Beta",
"tags": [],
},
],
"total": 2,
},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/advertisements")
assert response.status_code == 200
class TestAdvertisementsNodeSorting:
"""Tests for node sorting in advertisements dropdown."""
def test_nodes_sorted_by_display_name(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that nodes are sorted alphabetically by display name."""
# Set up nodes with tags - "Zebra" should come after "Alpha"
mock_http_client.set_response(
"GET",
"/api/v1/nodes",
200,
{
"items": [
{
"id": "n1",
"public_key": "abc123",
"name": "Zebra Node",
"tags": [],
},
{
"id": "n2",
"public_key": "def456",
"name": "Alpha Node",
"tags": [],
},
],
"total": 2,
},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/advertisements")
assert response.status_code == 200
# Both nodes should appear
text = response.text
assert "Alpha Node" in text or "alpha" in text.lower()
assert "Zebra Node" in text or "zebra" in text.lower()
def test_nodes_sorted_by_tag_name_when_present(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that nodes use tag name for sorting when available."""
mock_http_client.set_response(
"GET",
"/api/v1/nodes",
200,
{
"items": [
{
"id": "n1",
"public_key": "abc123",
"name": "Zebra",
"tags": [{"key": "name", "value": "Alpha Custom"}],
},
],
"total": 1,
},
config_start = text.find("window.__APP_CONFIG__ = ") + len(
"window.__APP_CONFIG__ = "
)
web_app.state.http_client = mock_http_client
config_end = text.find(";", config_start)
config = json.loads(text[config_start:config_end])
client = TestClient(web_app, raise_server_exceptions=True)
assert config["network_name"] == "Test Network"
def test_advertisements_config_unauthenticated(self, client: TestClient) -> None:
"""Test that SPA config shows unauthenticated without auth header."""
response = client.get("/advertisements")
assert response.status_code == 200
def test_nodes_fallback_to_public_key_when_no_name(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that nodes fall back to public_key when no name."""
mock_http_client.set_response(
"GET",
"/api/v1/nodes",
200,
{
"items": [
{
"id": "n1",
"public_key": "abc123def456",
"name": None,
"tags": [],
},
],
"total": 1,
},
text = response.text
config_start = text.find("window.__APP_CONFIG__ = ") + len(
"window.__APP_CONFIG__ = "
)
web_app.state.http_client = mock_http_client
config_end = text.find(";", config_start)
config = json.loads(text[config_start:config_end])
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/advertisements")
assert response.status_code == 200
class TestAdvertisementsPageAPIErrors:
"""Tests for advertisements page handling API errors."""
def test_advertisements_handles_api_error(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that advertisements page handles API errors gracefully."""
mock_http_client.set_response(
"GET", "/api/v1/advertisements", status_code=500, json_data=None
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/advertisements")
# Should still return 200 (page renders with empty list)
assert response.status_code == 200
def test_advertisements_handles_api_not_found(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that advertisements page handles API 404 gracefully."""
mock_http_client.set_response(
"GET",
"/api/v1/advertisements",
status_code=404,
json_data={"detail": "Not found"},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/advertisements")
# Should still return 200 (page renders with empty list)
assert response.status_code == 200
def test_advertisements_handles_members_api_error(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that page handles members API error gracefully."""
mock_http_client.set_response(
"GET", "/api/v1/members", status_code=500, json_data=None
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/advertisements")
# Should still return 200 (page renders without member dropdown)
assert response.status_code == 200
def test_advertisements_handles_nodes_api_error(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that page handles nodes API error gracefully."""
mock_http_client.set_response(
"GET", "/api/v1/nodes", status_code=500, json_data=None
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/advertisements")
# Should still return 200 (page renders without node dropdown)
assert response.status_code == 200
def test_advertisements_handles_empty_response(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that page handles empty advertisements list."""
mock_http_client.set_response(
"GET",
"/api/v1/advertisements",
200,
{"items": [], "total": 0},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/advertisements")
assert response.status_code == 200
class TestAdvertisementsPagination:
"""Tests for advertisements pagination calculations."""
def test_pagination_calculates_total_pages(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that pagination correctly calculates total pages."""
mock_http_client.set_response(
"GET",
"/api/v1/advertisements",
200,
{"items": [], "total": 150},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
# With limit=50 and total=150, should have 3 pages
response = client.get("/advertisements?limit=50")
assert response.status_code == 200
def test_pagination_with_zero_total(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test pagination with zero results shows at least 1 page."""
mock_http_client.set_response(
"GET",
"/api/v1/advertisements",
200,
{"items": [], "total": 0},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/advertisements")
assert response.status_code == 200
assert config["is_authenticated"] is False

View 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"

View File

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

View File

@@ -1,4 +1,6 @@
"""Tests for the home page route."""
"""Tests for the home page route (SPA)."""
import json
from fastapi.testclient import TestClient
@@ -31,25 +33,81 @@ class TestHomePage:
response = client.get("/")
assert "Test Country" in response.text
def test_home_contains_radio_config(self, client: TestClient) -> None:
"""Test that home page contains the radio configuration."""
def test_home_contains_app_config(self, client: TestClient) -> None:
"""Test that home page contains the SPA config JSON."""
response = client.get("/")
assert "Test Radio Config" in response.text
assert "window.__APP_CONFIG__" in response.text
def test_home_config_contains_network_info(self, client: TestClient) -> None:
"""Test that SPA config contains network information."""
response = client.get("/")
# Extract the config JSON from the HTML
text = response.text
config_start = text.find("window.__APP_CONFIG__ = ") + len(
"window.__APP_CONFIG__ = "
)
config_end = text.find(";", config_start)
config = json.loads(text[config_start:config_end])
assert config["network_name"] == "Test Network"
assert config["network_city"] == "Test City"
assert config["network_country"] == "Test Country"
def test_home_config_contains_contact_info(self, client: TestClient) -> None:
"""Test that SPA config contains contact information."""
response = client.get("/")
text = response.text
config_start = text.find("window.__APP_CONFIG__ = ") + len(
"window.__APP_CONFIG__ = "
)
config_end = text.find(";", config_start)
config = json.loads(text[config_start:config_end])
assert config["network_contact_email"] == "test@example.com"
assert config["network_contact_discord"] == "https://discord.gg/test"
def test_home_contains_contact_email(self, client: TestClient) -> None:
"""Test that home page contains the contact email."""
"""Test that home page contains the contact email in footer."""
response = client.get("/")
assert "test@example.com" in response.text
def test_home_contains_discord_link(self, client: TestClient) -> None:
"""Test that home page contains the Discord link."""
"""Test that home page contains the Discord link in footer."""
response = client.get("/")
assert "discord.gg/test" in response.text
def test_home_contains_navigation(self, client: TestClient) -> None:
"""Test that home page contains navigation links."""
response = client.get("/")
# Check for navigation links to other pages
assert 'href="/"' in response.text or 'href=""' in response.text
assert 'href="/nodes"' in response.text or "/nodes" in response.text
assert 'href="/messages"' in response.text or "/messages" in response.text
assert 'href="/"' in response.text
assert 'href="/nodes"' in response.text
assert 'href="/messages"' in response.text
def test_home_contains_spa_app_script(self, client: TestClient) -> None:
"""Test that home page includes the SPA application script."""
response = client.get("/")
assert "/static/js/spa/app.js" in response.text
def test_home_unauthenticated(self, client: TestClient) -> None:
"""Test that home page config shows unauthenticated by default."""
response = client.get("/")
text = response.text
config_start = text.find("window.__APP_CONFIG__ = ") + len(
"window.__APP_CONFIG__ = "
)
config_end = text.find(";", config_start)
config = json.loads(text[config_start:config_end])
assert config["is_authenticated"] is False
def test_home_authenticated(self, client: TestClient) -> None:
"""Test that home page config shows authenticated with auth header."""
response = client.get("/", headers={"X-Forwarded-User": "test-user"})
text = response.text
config_start = text.find("window.__APP_CONFIG__ = ") + len(
"window.__APP_CONFIG__ = "
)
config_end = text.find(";", config_start)
config = json.loads(text[config_start:config_end])
assert config["is_authenticated"] is True

View File

@@ -173,3 +173,248 @@ class TestMapDataFiltering:
# Node with only lat should be excluded
assert len(data["nodes"]) == 0
def test_map_data_filters_zero_coordinates(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that map data filters nodes with (0, 0) coordinates."""
mock_http_client.set_response(
"GET",
"/api/v1/nodes",
status_code=200,
json_data={
"items": [
{
"id": "node-1",
"public_key": "abc123",
"name": "Zero Coord Node",
"lat": 0.0,
"lon": 0.0,
"tags": [],
},
],
"total": 1,
},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/map/data")
data = response.json()
# Node at (0, 0) should be excluded
assert len(data["nodes"]) == 0
def test_map_data_uses_model_coordinates_as_fallback(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that map data uses model lat/lon when tags are not present."""
mock_http_client.set_response(
"GET",
"/api/v1/nodes",
status_code=200,
json_data={
"items": [
{
"id": "node-1",
"public_key": "abc123",
"name": "Model Coords Node",
"lat": 51.5074,
"lon": -0.1278,
"tags": [],
},
],
"total": 1,
},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/map/data")
data = response.json()
# Node should use model coordinates
assert len(data["nodes"]) == 1
assert data["nodes"][0]["lat"] == 51.5074
assert data["nodes"][0]["lon"] == -0.1278
def test_map_data_prefers_tag_coordinates_over_model(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that tag coordinates take priority over model coordinates."""
mock_http_client.set_response(
"GET",
"/api/v1/nodes",
status_code=200,
json_data={
"items": [
{
"id": "node-1",
"public_key": "abc123",
"name": "Both Coords Node",
"lat": 51.5074,
"lon": -0.1278,
"tags": [
{"key": "lat", "value": "40.7128"},
{"key": "lon", "value": "-74.0060"},
],
},
],
"total": 1,
},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/map/data")
data = response.json()
# Node should use tag coordinates, not model
assert len(data["nodes"]) == 1
assert data["nodes"][0]["lat"] == 40.7128
assert data["nodes"][0]["lon"] == -74.0060
class TestMapDataInfrastructure:
"""Tests for infrastructure node handling in map data."""
def test_map_data_includes_infra_center(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that map data includes infrastructure center when infra nodes exist."""
mock_http_client.set_response(
"GET",
"/api/v1/nodes",
status_code=200,
json_data={
"items": [
{
"id": "node-1",
"public_key": "abc123",
"name": "Infra Node",
"lat": 40.0,
"lon": -74.0,
"tags": [{"key": "role", "value": "infra"}],
},
{
"id": "node-2",
"public_key": "def456",
"name": "Regular Node",
"lat": 41.0,
"lon": -75.0,
"tags": [],
},
],
"total": 2,
},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/map/data")
data = response.json()
# Should have infra_center based on infra node only
assert data["infra_center"] is not None
assert data["infra_center"]["lat"] == 40.0
assert data["infra_center"]["lon"] == -74.0
def test_map_data_infra_center_null_when_no_infra(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that infra_center is null when no infrastructure nodes exist."""
mock_http_client.set_response(
"GET",
"/api/v1/nodes",
status_code=200,
json_data={
"items": [
{
"id": "node-1",
"public_key": "abc123",
"name": "Regular Node",
"lat": 40.0,
"lon": -74.0,
"tags": [],
},
],
"total": 1,
},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/map/data")
data = response.json()
assert data["infra_center"] is None
def test_map_data_sets_is_infra_flag(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that nodes have correct is_infra flag based on role tag."""
mock_http_client.set_response(
"GET",
"/api/v1/nodes",
status_code=200,
json_data={
"items": [
{
"id": "node-1",
"public_key": "abc123",
"name": "Infra Node",
"lat": 40.0,
"lon": -74.0,
"tags": [{"key": "role", "value": "infra"}],
},
{
"id": "node-2",
"public_key": "def456",
"name": "Regular Node",
"lat": 41.0,
"lon": -75.0,
"tags": [{"key": "role", "value": "other"}],
},
],
"total": 2,
},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/map/data")
data = response.json()
nodes_by_name = {n["name"]: n for n in data["nodes"]}
assert nodes_by_name["Infra Node"]["is_infra"] is True
assert nodes_by_name["Regular Node"]["is_infra"] is False
def test_map_data_debug_includes_infra_count(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that debug info includes infrastructure node count."""
mock_http_client.set_response(
"GET",
"/api/v1/nodes",
status_code=200,
json_data={
"items": [
{
"id": "node-1",
"public_key": "abc123",
"name": "Infra Node",
"lat": 40.0,
"lon": -74.0,
"tags": [{"key": "role", "value": "infra"}],
},
],
"total": 1,
},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/map/data")
data = response.json()
assert data["debug"]["infra_nodes"] == 1

View File

@@ -1,4 +1,6 @@
"""Tests for the members page route."""
"""Tests for the members page route (SPA)."""
import json
from fastapi.testclient import TestClient
@@ -21,36 +23,24 @@ class TestMembersPage:
response = client.get("/members")
assert "Test Network" in response.text
def test_members_without_data_shows_empty(self, client: TestClient) -> None:
"""Test that members page with no API data shows no members."""
def test_members_contains_app_config(self, client: TestClient) -> None:
"""Test that members page contains SPA config."""
response = client.get("/members")
# Should still render successfully
assert response.status_code == 200
assert "window.__APP_CONFIG__" in response.text
def test_members_with_api_data_shows_members(
self, client_with_members: TestClient
) -> None:
"""Test that members page with API data shows member data."""
response = client_with_members.get("/members")
assert response.status_code == 200
# Check for member data from mock API response
assert "Alice" in response.text
assert "Bob" in response.text
assert "W1ABC" in response.text
assert "W2XYZ" in response.text
def test_members_contains_spa_script(self, client: TestClient) -> None:
"""Test that members page includes SPA application script."""
response = client.get("/members")
assert "/static/js/spa/app.js" in response.text
def test_members_with_nodes_shows_node_links(
self, client_with_members: TestClient
) -> None:
"""Test that members page shows associated nodes with links."""
response = client_with_members.get("/members")
assert response.status_code == 200
# Alice has a node associated - check for friendly name display
assert "Alice Chat" in response.text
# Check for partial public key underneath
assert "abc123def456" in response.text
# Check for link to node detail page (full public key)
assert (
"/nodes/abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
in response.text
def test_members_config_has_network_name(self, client: TestClient) -> None:
"""Test that SPA config includes network name."""
response = client.get("/members")
text = response.text
config_start = text.find("window.__APP_CONFIG__ = ") + len(
"window.__APP_CONFIG__ = "
)
config_end = text.find(";", config_start)
config = json.loads(text[config_start:config_end])
assert config["network_name"] == "Test Network"

Some files were not shown because too many files have changed in this diff Show More