mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-03-28 17:42:56 +01:00
Compare commits
12 Commits
v0.7.1
...
patch/test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31d591723d | ||
|
|
3eff7f03db | ||
|
|
905ea0190b | ||
|
|
86cc7edca3 | ||
|
|
eb3f8508b7 | ||
|
|
74a34fdcba | ||
|
|
175fc8c524 | ||
|
|
2a153a5239 | ||
|
|
de85e0cd7a | ||
|
|
5a20da3afa | ||
|
|
dcd33711db | ||
|
|
a8cb20fea5 |
24
.env.example
24
.env.example
@@ -190,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
|
||||
# =============================================================================
|
||||
@@ -216,6 +235,11 @@ WEB_PORT=8080
|
||||
# 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
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -76,7 +76,7 @@ jobs:
|
||||
run: python -m build
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
11
AGENTS.md
11
AGENTS.md
@@ -281,6 +281,7 @@ meshcore-hub/
|
||||
│ │ ├── app.py # FastAPI app
|
||||
│ │ ├── auth.py # Authentication
|
||||
│ │ ├── dependencies.py
|
||||
│ │ ├── metrics.py # Prometheus metrics endpoint
|
||||
│ │ └── routes/ # API routes
|
||||
│ │ ├── members.py # Member CRUD endpoints
|
||||
│ │ └── ...
|
||||
@@ -311,7 +312,12 @@ 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
|
||||
@@ -606,8 +612,11 @@ Key variables:
|
||||
- `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys
|
||||
- `WEB_ADMIN_ENABLED` - Enable admin interface at /a/ (default: `false`, requires auth proxy)
|
||||
- `WEB_THEME` - Default theme for the web dashboard (default: `dark`, options: `dark`, `light`). Users can override via the theme toggle in the navbar, which persists their preference in browser localStorage.
|
||||
- `WEB_AUTO_REFRESH_SECONDS` - Auto-refresh interval in seconds for list pages (default: `30`, `0` to disable)
|
||||
- `TZ` - Timezone for web dashboard date/time display (default: `UTC`, e.g., `America/New_York`, `Europe/London`)
|
||||
- `FEATURE_DASHBOARD`, `FEATURE_NODES`, `FEATURE_ADVERTISEMENTS`, `FEATURE_MESSAGES`, `FEATURE_MAP`, `FEATURE_MEMBERS`, `FEATURE_PAGES` - Feature flags to enable/disable specific web dashboard pages (default: all `true`). Dependencies: Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled.
|
||||
- `METRICS_ENABLED` - Enable Prometheus metrics endpoint at /metrics (default: `true`)
|
||||
- `METRICS_CACHE_TTL` - Seconds to cache metrics output (default: `60`)
|
||||
- `LOG_LEVEL` - Logging verbosity
|
||||
|
||||
The database defaults to `sqlite:///{DATA_HOME}/collector/meshcore.db` and does not typically need to be configured.
|
||||
|
||||
@@ -185,6 +185,7 @@ Docker Compose uses **profiles** to select which services to run:
|
||||
| `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.
|
||||
|
||||
@@ -337,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
|
||||
|
||||
@@ -348,6 +351,7 @@ The collector automatically cleans up old event data and inactive nodes:
|
||||
| `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) |
|
||||
@@ -540,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
|
||||
|
||||
@@ -647,7 +652,7 @@ meshcore-hub/
|
||||
│ └── locales/ # Translation files (en.json, languages.md)
|
||||
├── tests/ # Test suite
|
||||
├── alembic/ # Database migrations
|
||||
├── etc/ # Configuration files (mosquitto.conf)
|
||||
├── etc/ # Configuration files (MQTT, Prometheus, Alertmanager)
|
||||
├── example/ # Example files for reference
|
||||
│ ├── seed/ # Example seed data files
|
||||
│ │ ├── node_tags.yaml # Example node tags
|
||||
@@ -694,6 +699,8 @@ meshcore-hub/
|
||||
|
||||
This project is licensed under the GNU General Public License v3.0 or later (GPL-3.0-or-later). See [LICENSE](LICENSE) for details.
|
||||
|
||||
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [MeshCore](https://meshcore.dev/) - The mesh networking protocol
|
||||
|
||||
@@ -215,6 +215,8 @@ services:
|
||||
- API_PORT=8000
|
||||
- API_READ_KEY=${API_READ_KEY:-}
|
||||
- API_ADMIN_KEY=${API_ADMIN_KEY:-}
|
||||
- METRICS_ENABLED=${METRICS_ENABLED:-true}
|
||||
- METRICS_CACHE_TTL=${METRICS_CACHE_TTL:-60}
|
||||
command: ["api"]
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
@@ -326,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
|
||||
# ==========================================================================
|
||||
@@ -336,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
|
||||
|
||||
35
etc/alertmanager/alertmanager.yml
Normal file
35
etc/alertmanager/alertmanager.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
# Alertmanager configuration for MeshCore Hub
|
||||
#
|
||||
# Default configuration routes all alerts to a "blackhole" receiver
|
||||
# (logs only, no external notifications).
|
||||
#
|
||||
# To receive notifications, configure a receiver below.
|
||||
# See: https://prometheus.io/docs/alerting/latest/configuration/
|
||||
#
|
||||
# Examples:
|
||||
#
|
||||
# Email:
|
||||
# receivers:
|
||||
# - name: 'email'
|
||||
# email_configs:
|
||||
# - to: 'admin@example.com'
|
||||
# from: 'alertmanager@example.com'
|
||||
# smarthost: 'smtp.example.com:587'
|
||||
# auth_username: 'alertmanager@example.com'
|
||||
# auth_password: 'password'
|
||||
#
|
||||
# Webhook (e.g. Slack incoming webhook, ntfy, Gotify):
|
||||
# receivers:
|
||||
# - name: 'webhook'
|
||||
# webhook_configs:
|
||||
# - url: 'https://example.com/webhook'
|
||||
|
||||
route:
|
||||
receiver: 'default'
|
||||
group_by: ['alertname']
|
||||
group_wait: 30s
|
||||
group_interval: 5m
|
||||
repeat_interval: 4h
|
||||
|
||||
receivers:
|
||||
- name: 'default'
|
||||
16
etc/prometheus/alerts.yml
Normal file
16
etc/prometheus/alerts.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Prometheus alert rules for MeshCore Hub
|
||||
#
|
||||
# These rules are evaluated by Prometheus and fired alerts are sent
|
||||
# to Alertmanager for routing and notification.
|
||||
|
||||
groups:
|
||||
- name: meshcore
|
||||
rules:
|
||||
- alert: NodeNotSeen
|
||||
expr: time() - meshcore_node_last_seen_timestamp_seconds{role="infra"} > 48 * 3600
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Node {{ $labels.node_name }} ({{ $labels.role }}) not seen for 48+ hours"
|
||||
description: "Node {{ $labels.public_key }} ({{ $labels.adv_type }}, role={{ $labels.role }}) last seen {{ $value | humanizeDuration }} ago."
|
||||
29
etc/prometheus/prometheus.yml
Normal file
29
etc/prometheus/prometheus.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
# Prometheus scrape configuration for MeshCore Hub
|
||||
#
|
||||
# This file is used when running Prometheus via Docker Compose:
|
||||
# docker compose --profile core --profile metrics up -d
|
||||
#
|
||||
# The scrape interval matches the default metrics cache TTL (60s)
|
||||
# to avoid unnecessary database queries.
|
||||
|
||||
global:
|
||||
scrape_interval: 60s
|
||||
evaluation_interval: 60s
|
||||
|
||||
alerting:
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets: ['alertmanager:9093']
|
||||
|
||||
rule_files:
|
||||
- 'alerts.yml'
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'meshcore-hub'
|
||||
metrics_path: '/metrics'
|
||||
# Uncomment basic_auth if API_READ_KEY is configured
|
||||
# basic_auth:
|
||||
# username: 'metrics'
|
||||
# password: '<API_READ_KEY>'
|
||||
static_configs:
|
||||
- targets: ['api:8000']
|
||||
@@ -41,6 +41,7 @@ dependencies = [
|
||||
"pyyaml>=6.0.0",
|
||||
"python-frontmatter>=1.0.0",
|
||||
"markdown>=3.5.0",
|
||||
"prometheus-client>=0.20.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -116,6 +117,7 @@ module = [
|
||||
"meshcore.*",
|
||||
"frontmatter.*",
|
||||
"markdown.*",
|
||||
"prometheus_client.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ def create_app(
|
||||
mqtt_prefix: str = "meshcore",
|
||||
mqtt_tls: bool = False,
|
||||
cors_origins: list[str] | None = None,
|
||||
metrics_enabled: bool = True,
|
||||
metrics_cache_ttl: int = 60,
|
||||
) -> FastAPI:
|
||||
"""Create and configure the FastAPI application.
|
||||
|
||||
@@ -66,6 +68,8 @@ def create_app(
|
||||
mqtt_prefix: MQTT topic prefix
|
||||
mqtt_tls: Enable TLS/SSL for MQTT connection
|
||||
cors_origins: Allowed CORS origins
|
||||
metrics_enabled: Enable Prometheus metrics endpoint at /metrics
|
||||
metrics_cache_ttl: Seconds to cache metrics output
|
||||
|
||||
Returns:
|
||||
Configured FastAPI application
|
||||
@@ -88,6 +92,7 @@ def create_app(
|
||||
app.state.mqtt_port = mqtt_port
|
||||
app.state.mqtt_prefix = mqtt_prefix
|
||||
app.state.mqtt_tls = mqtt_tls
|
||||
app.state.metrics_cache_ttl = metrics_cache_ttl
|
||||
|
||||
# Configure CORS
|
||||
if cors_origins is None:
|
||||
@@ -106,6 +111,12 @@ def create_app(
|
||||
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
# Include Prometheus metrics endpoint
|
||||
if metrics_enabled:
|
||||
from meshcore_hub.api.metrics import router as metrics_router
|
||||
|
||||
app.include_router(metrics_router)
|
||||
|
||||
# Health check endpoints
|
||||
@app.get("/health", tags=["Health"])
|
||||
async def health() -> dict:
|
||||
|
||||
@@ -81,6 +81,19 @@ import click
|
||||
envvar="CORS_ORIGINS",
|
||||
help="Comma-separated list of allowed CORS origins",
|
||||
)
|
||||
@click.option(
|
||||
"--metrics-enabled/--no-metrics",
|
||||
default=True,
|
||||
envvar="METRICS_ENABLED",
|
||||
help="Enable Prometheus metrics endpoint at /metrics",
|
||||
)
|
||||
@click.option(
|
||||
"--metrics-cache-ttl",
|
||||
type=int,
|
||||
default=60,
|
||||
envvar="METRICS_CACHE_TTL",
|
||||
help="Seconds to cache metrics output (reduces database load)",
|
||||
)
|
||||
@click.option(
|
||||
"--reload",
|
||||
is_flag=True,
|
||||
@@ -101,6 +114,8 @@ def api(
|
||||
mqtt_prefix: str,
|
||||
mqtt_tls: bool,
|
||||
cors_origins: str | None,
|
||||
metrics_enabled: bool,
|
||||
metrics_cache_ttl: int,
|
||||
reload: bool,
|
||||
) -> None:
|
||||
"""Run the REST API server.
|
||||
@@ -149,6 +164,8 @@ def api(
|
||||
click.echo(f"Read key configured: {read_key is not None}")
|
||||
click.echo(f"Admin key configured: {admin_key is not None}")
|
||||
click.echo(f"CORS origins: {cors_origins or 'none'}")
|
||||
click.echo(f"Metrics enabled: {metrics_enabled}")
|
||||
click.echo(f"Metrics cache TTL: {metrics_cache_ttl}s")
|
||||
click.echo(f"Reload mode: {reload}")
|
||||
click.echo("=" * 50)
|
||||
|
||||
@@ -181,6 +198,8 @@ def api(
|
||||
mqtt_prefix=mqtt_prefix,
|
||||
mqtt_tls=mqtt_tls,
|
||||
cors_origins=origins_list,
|
||||
metrics_enabled=metrics_enabled,
|
||||
metrics_cache_ttl=metrics_cache_ttl,
|
||||
)
|
||||
|
||||
click.echo("\nStarting API server...")
|
||||
|
||||
331
src/meshcore_hub/api/metrics.py
Normal file
331
src/meshcore_hub/api/metrics.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""Prometheus metrics endpoint for MeshCore Hub API."""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from prometheus_client import CollectorRegistry, Gauge, generate_latest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from meshcore_hub.common.models import (
|
||||
Advertisement,
|
||||
EventLog,
|
||||
Member,
|
||||
Message,
|
||||
Node,
|
||||
NodeTag,
|
||||
Telemetry,
|
||||
TracePath,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Module-level cache
|
||||
_cache: dict[str, Any] = {"output": b"", "expires_at": 0.0}
|
||||
|
||||
|
||||
def verify_basic_auth(request: Request) -> bool:
|
||||
"""Verify HTTP Basic Auth credentials for metrics endpoint.
|
||||
|
||||
Uses username 'metrics' and the API read key as password.
|
||||
Returns True if no read key is configured (public access).
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
True if authentication passes
|
||||
"""
|
||||
read_key = getattr(request.app.state, "read_key", None)
|
||||
|
||||
# No read key configured = public access
|
||||
if not read_key:
|
||||
return True
|
||||
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Basic "):
|
||||
return False
|
||||
|
||||
try:
|
||||
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
|
||||
username, password = decoded.split(":", 1)
|
||||
return username == "metrics" and password == read_key
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def collect_metrics(session: Any) -> bytes:
|
||||
"""Collect all metrics from the database and generate Prometheus output.
|
||||
|
||||
Creates a fresh CollectorRegistry per call to avoid global state issues.
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy database session
|
||||
|
||||
Returns:
|
||||
Prometheus text exposition format as bytes
|
||||
"""
|
||||
from meshcore_hub import __version__
|
||||
|
||||
registry = CollectorRegistry()
|
||||
|
||||
# -- Info gauge --
|
||||
info_gauge = Gauge(
|
||||
"meshcore_info",
|
||||
"MeshCore Hub application info",
|
||||
["version"],
|
||||
registry=registry,
|
||||
)
|
||||
info_gauge.labels(version=__version__).set(1)
|
||||
|
||||
# -- Nodes total --
|
||||
nodes_total = Gauge(
|
||||
"meshcore_nodes_total",
|
||||
"Total number of nodes",
|
||||
registry=registry,
|
||||
)
|
||||
count = session.execute(select(func.count(Node.id))).scalar() or 0
|
||||
nodes_total.set(count)
|
||||
|
||||
# -- Nodes active by time window --
|
||||
nodes_active = Gauge(
|
||||
"meshcore_nodes_active",
|
||||
"Number of active nodes in time window",
|
||||
["window"],
|
||||
registry=registry,
|
||||
)
|
||||
for window, hours in [("1h", 1), ("24h", 24), ("7d", 168), ("30d", 720)]:
|
||||
cutoff = time.time() - (hours * 3600)
|
||||
from datetime import datetime, timezone
|
||||
|
||||
cutoff_dt = datetime.fromtimestamp(cutoff, tz=timezone.utc)
|
||||
count = (
|
||||
session.execute(
|
||||
select(func.count(Node.id)).where(Node.last_seen >= cutoff_dt)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
nodes_active.labels(window=window).set(count)
|
||||
|
||||
# -- Nodes by type --
|
||||
nodes_by_type = Gauge(
|
||||
"meshcore_nodes_by_type",
|
||||
"Number of nodes by advertisement type",
|
||||
["adv_type"],
|
||||
registry=registry,
|
||||
)
|
||||
type_counts = session.execute(
|
||||
select(Node.adv_type, func.count(Node.id)).group_by(Node.adv_type)
|
||||
).all()
|
||||
for adv_type, count in type_counts:
|
||||
nodes_by_type.labels(adv_type=adv_type or "unknown").set(count)
|
||||
|
||||
# -- Nodes with location --
|
||||
nodes_with_location = Gauge(
|
||||
"meshcore_nodes_with_location",
|
||||
"Number of nodes with GPS coordinates",
|
||||
registry=registry,
|
||||
)
|
||||
count = (
|
||||
session.execute(
|
||||
select(func.count(Node.id)).where(
|
||||
Node.lat.isnot(None), Node.lon.isnot(None)
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
nodes_with_location.set(count)
|
||||
|
||||
# -- Node last seen timestamp --
|
||||
node_last_seen = Gauge(
|
||||
"meshcore_node_last_seen_timestamp_seconds",
|
||||
"Unix timestamp of when the node was last seen",
|
||||
["public_key", "node_name", "adv_type", "role"],
|
||||
registry=registry,
|
||||
)
|
||||
role_subq = (
|
||||
select(NodeTag.node_id, NodeTag.value.label("role"))
|
||||
.where(NodeTag.key == "role")
|
||||
.subquery()
|
||||
)
|
||||
nodes_with_last_seen = session.execute(
|
||||
select(
|
||||
Node.public_key,
|
||||
Node.name,
|
||||
Node.adv_type,
|
||||
Node.last_seen,
|
||||
role_subq.c.role,
|
||||
)
|
||||
.outerjoin(role_subq, Node.id == role_subq.c.node_id)
|
||||
.where(Node.last_seen.isnot(None))
|
||||
).all()
|
||||
for public_key, name, adv_type, last_seen, role in nodes_with_last_seen:
|
||||
node_last_seen.labels(
|
||||
public_key=public_key,
|
||||
node_name=name or "",
|
||||
adv_type=adv_type or "unknown",
|
||||
role=role or "",
|
||||
).set(last_seen.timestamp())
|
||||
|
||||
# -- Messages total by type --
|
||||
messages_total = Gauge(
|
||||
"meshcore_messages_total",
|
||||
"Total number of messages by type",
|
||||
["type"],
|
||||
registry=registry,
|
||||
)
|
||||
msg_type_counts = session.execute(
|
||||
select(Message.message_type, func.count(Message.id)).group_by(
|
||||
Message.message_type
|
||||
)
|
||||
).all()
|
||||
for msg_type, count in msg_type_counts:
|
||||
messages_total.labels(type=msg_type).set(count)
|
||||
|
||||
# -- Messages received by type and window --
|
||||
messages_received = Gauge(
|
||||
"meshcore_messages_received",
|
||||
"Messages received in time window by type",
|
||||
["type", "window"],
|
||||
registry=registry,
|
||||
)
|
||||
for window, hours in [("1h", 1), ("24h", 24), ("7d", 168), ("30d", 720)]:
|
||||
cutoff = time.time() - (hours * 3600)
|
||||
cutoff_dt = datetime.fromtimestamp(cutoff, tz=timezone.utc)
|
||||
window_counts = session.execute(
|
||||
select(Message.message_type, func.count(Message.id))
|
||||
.where(Message.received_at >= cutoff_dt)
|
||||
.group_by(Message.message_type)
|
||||
).all()
|
||||
for msg_type, count in window_counts:
|
||||
messages_received.labels(type=msg_type, window=window).set(count)
|
||||
|
||||
# -- Advertisements total --
|
||||
advertisements_total = Gauge(
|
||||
"meshcore_advertisements_total",
|
||||
"Total number of advertisements",
|
||||
registry=registry,
|
||||
)
|
||||
count = session.execute(select(func.count(Advertisement.id))).scalar() or 0
|
||||
advertisements_total.set(count)
|
||||
|
||||
# -- Advertisements received by window --
|
||||
advertisements_received = Gauge(
|
||||
"meshcore_advertisements_received",
|
||||
"Advertisements received in time window",
|
||||
["window"],
|
||||
registry=registry,
|
||||
)
|
||||
for window, hours in [("1h", 1), ("24h", 24), ("7d", 168), ("30d", 720)]:
|
||||
cutoff = time.time() - (hours * 3600)
|
||||
cutoff_dt = datetime.fromtimestamp(cutoff, tz=timezone.utc)
|
||||
count = (
|
||||
session.execute(
|
||||
select(func.count(Advertisement.id)).where(
|
||||
Advertisement.received_at >= cutoff_dt
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
advertisements_received.labels(window=window).set(count)
|
||||
|
||||
# -- Telemetry total --
|
||||
telemetry_total = Gauge(
|
||||
"meshcore_telemetry_total",
|
||||
"Total number of telemetry records",
|
||||
registry=registry,
|
||||
)
|
||||
count = session.execute(select(func.count(Telemetry.id))).scalar() or 0
|
||||
telemetry_total.set(count)
|
||||
|
||||
# -- Trace paths total --
|
||||
trace_paths_total = Gauge(
|
||||
"meshcore_trace_paths_total",
|
||||
"Total number of trace path records",
|
||||
registry=registry,
|
||||
)
|
||||
count = session.execute(select(func.count(TracePath.id))).scalar() or 0
|
||||
trace_paths_total.set(count)
|
||||
|
||||
# -- Events by type --
|
||||
events_total = Gauge(
|
||||
"meshcore_events_total",
|
||||
"Total events by type from event log",
|
||||
["event_type"],
|
||||
registry=registry,
|
||||
)
|
||||
event_counts = session.execute(
|
||||
select(EventLog.event_type, func.count(EventLog.id)).group_by(
|
||||
EventLog.event_type
|
||||
)
|
||||
).all()
|
||||
for event_type, count in event_counts:
|
||||
events_total.labels(event_type=event_type).set(count)
|
||||
|
||||
# -- Members total --
|
||||
members_total = Gauge(
|
||||
"meshcore_members_total",
|
||||
"Total number of network members",
|
||||
registry=registry,
|
||||
)
|
||||
count = session.execute(select(func.count(Member.id))).scalar() or 0
|
||||
members_total.set(count)
|
||||
|
||||
output: bytes = generate_latest(registry)
|
||||
return output
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
async def metrics(request: Request) -> Response:
|
||||
"""Prometheus metrics endpoint.
|
||||
|
||||
Returns metrics in Prometheus text exposition format.
|
||||
Supports HTTP Basic Auth with username 'metrics' and API read key as password.
|
||||
Results are cached with a configurable TTL to reduce database load.
|
||||
"""
|
||||
# Check authentication
|
||||
if not verify_basic_auth(request):
|
||||
return PlainTextResponse(
|
||||
"Unauthorized",
|
||||
status_code=401,
|
||||
headers={"WWW-Authenticate": 'Basic realm="metrics"'},
|
||||
)
|
||||
|
||||
# Check cache
|
||||
cache_ttl = getattr(request.app.state, "metrics_cache_ttl", 60)
|
||||
now = time.time()
|
||||
|
||||
if _cache["output"] and now < _cache["expires_at"]:
|
||||
return Response(
|
||||
content=_cache["output"],
|
||||
media_type="text/plain; version=0.0.4; charset=utf-8",
|
||||
)
|
||||
|
||||
# Collect fresh metrics
|
||||
try:
|
||||
from meshcore_hub.api.app import get_db_manager
|
||||
|
||||
db_manager = get_db_manager()
|
||||
with db_manager.session_scope() as session:
|
||||
output = collect_metrics(session)
|
||||
|
||||
# Update cache
|
||||
_cache["output"] = output
|
||||
_cache["expires_at"] = now + cache_ttl
|
||||
|
||||
return Response(
|
||||
content=output,
|
||||
media_type="text/plain; version=0.0.4; charset=utf-8",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to collect metrics: %s", e)
|
||||
return PlainTextResponse(
|
||||
f"# Error collecting metrics: {e}\n",
|
||||
status_code=500,
|
||||
media_type="text/plain; version=0.0.4; charset=utf-8",
|
||||
)
|
||||
@@ -268,6 +268,13 @@ class WebSettings(CommonSettings):
|
||||
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,
|
||||
|
||||
@@ -117,6 +117,7 @@ def _build_config_json(app: FastAPI, request: Request) -> str:
|
||||
"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)
|
||||
@@ -184,6 +185,9 @@ def create_app(
|
||||
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"
|
||||
|
||||
87
src/meshcore_hub/web/static/js/spa/auto-refresh.js
Normal file
87
src/meshcore_hub/web/static/js/spa/auto-refresh.js
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Auto-refresh utility for list pages.
|
||||
*
|
||||
* Reads `auto_refresh_seconds` from the app config. When the interval is > 0
|
||||
* it sets up a periodic timer that calls the provided `fetchAndRender` callback
|
||||
* and renders a pause/play toggle button into the given container element.
|
||||
*/
|
||||
|
||||
import { html, litRender, getConfig, t } from './components.js';
|
||||
|
||||
/**
|
||||
* Create an auto-refresh controller.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {Function} options.fetchAndRender - Async function that fetches data and re-renders the page.
|
||||
* @param {HTMLElement} options.toggleContainer - Element to render the pause/play toggle into.
|
||||
* @returns {{ cleanup: Function }} cleanup function to stop the timer.
|
||||
*/
|
||||
export function createAutoRefresh({ fetchAndRender, toggleContainer }) {
|
||||
const config = getConfig();
|
||||
const intervalSeconds = config.auto_refresh_seconds || 0;
|
||||
|
||||
if (!intervalSeconds || !toggleContainer) {
|
||||
return { cleanup() {} };
|
||||
}
|
||||
|
||||
let paused = false;
|
||||
let isPending = false;
|
||||
let timerId = null;
|
||||
|
||||
function renderToggle() {
|
||||
const pauseIcon = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4"><path d="M5.75 3a.75.75 0 0 0-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 0 0 .75-.75V3.75A.75.75 0 0 0 7.25 3h-1.5ZM12.75 3a.75.75 0 0 0-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 0 0 .75-.75V3.75a.75.75 0 0 0-.75-.75h-1.5Z"/></svg>`;
|
||||
const playIcon = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4"><path d="M6.3 2.84A1.5 1.5 0 0 0 4 4.11v11.78a1.5 1.5 0 0 0 2.3 1.27l9.344-5.891a1.5 1.5 0 0 0 0-2.538L6.3 2.84Z"/></svg>`;
|
||||
|
||||
const tooltip = paused ? t('auto_refresh.resume') : t('auto_refresh.pause');
|
||||
const icon = paused ? playIcon : pauseIcon;
|
||||
|
||||
litRender(html`
|
||||
<button class="btn btn-ghost btn-xs gap-1 opacity-60 hover:opacity-100"
|
||||
title=${tooltip}
|
||||
@click=${onToggle}>
|
||||
${icon}
|
||||
<span class="text-xs">${intervalSeconds}s</span>
|
||||
</button>
|
||||
`, toggleContainer);
|
||||
}
|
||||
|
||||
function onToggle() {
|
||||
paused = !paused;
|
||||
if (paused) {
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
} else {
|
||||
startTimer();
|
||||
}
|
||||
renderToggle();
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
if (isPending || paused) return;
|
||||
isPending = true;
|
||||
try {
|
||||
await fetchAndRender();
|
||||
} catch (_e) {
|
||||
// Errors are handled inside fetchAndRender; don't stop the timer.
|
||||
} finally {
|
||||
isPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
timerId = setInterval(tick, intervalSeconds * 1000);
|
||||
}
|
||||
|
||||
// Initial render and start
|
||||
renderToggle();
|
||||
startTimer();
|
||||
|
||||
return {
|
||||
cleanup() {
|
||||
if (timerId) {
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
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 || {};
|
||||
@@ -27,6 +28,7 @@ export async function render(container, params, router) {
|
||||
<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>
|
||||
@@ -37,139 +39,140 @@ ${content}`, container);
|
||||
// Render page header immediately (old content stays visible until data loads)
|
||||
renderPage(nothing);
|
||||
|
||||
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 }));
|
||||
}
|
||||
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 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 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 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 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 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}
|
||||
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>
|
||||
</div>
|
||||
</a>`;
|
||||
</a>`;
|
||||
});
|
||||
|
||||
const tableRows = advertisements.length === 0
|
||||
? html`<tr><td colspan="4" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}</td></tr>`
|
||||
: advertisements.map(ad => {
|
||||
const adName = ad.node_tag_name || ad.node_name || ad.name;
|
||||
const adDescription = ad.node_tag_description;
|
||||
let receiversBlock;
|
||||
if (ad.receivers && ad.receivers.length >= 1) {
|
||||
receiversBlock = html`<div class="flex gap-1">
|
||||
${ad.receivers.map(recv => {
|
||||
const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12);
|
||||
return html`<a href="/nodes/${recv.public_key}" class="text-lg hover:opacity-70" title=${recvName}>\u{1F4E1}</a>`;
|
||||
})}
|
||||
</div>`;
|
||||
} else if (ad.received_by) {
|
||||
const recvTitle = ad.receiver_tag_name || ad.receiver_name || truncateKey(ad.received_by, 12);
|
||||
receiversBlock = html`<a href="/nodes/${ad.received_by}" class="text-lg hover:opacity-70" title=${recvTitle}>\u{1F4E1}</a>`;
|
||||
} else {
|
||||
receiversBlock = html`<span class="opacity-50">-</span>`;
|
||||
}
|
||||
return html`<tr class="hover">
|
||||
<td>
|
||||
<a href="/nodes/${ad.public_key}" class="link link-hover">
|
||||
${renderNodeDisplay({
|
||||
name: adName,
|
||||
description: adDescription,
|
||||
publicKey: ad.public_key,
|
||||
advType: ad.adv_type,
|
||||
size: 'base'
|
||||
})}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<code class="font-mono text-xs cursor-pointer hover:bg-base-200 px-1 py-0.5 rounded select-all"
|
||||
@click=${(e) => copyToClipboard(e, ad.public_key)}
|
||||
title="Click to copy">${ad.public_key}</code>
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">${formatDateTime(ad.received_at)}</td>
|
||||
<td>${receiversBlock}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
const paginationBlock = pagination(page, totalPages, '/advertisements', {
|
||||
search, public_key, member_id, limit,
|
||||
});
|
||||
|
||||
const tableRows = advertisements.length === 0
|
||||
? html`<tr><td colspan="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`
|
||||
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)}>
|
||||
@@ -211,7 +214,17 @@ ${content}`, container);
|
||||
|
||||
${paginationBlock}`, { total });
|
||||
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
await fetchAndRenderData();
|
||||
|
||||
const toggleEl = container.querySelector('#auto-refresh-toggle');
|
||||
const { cleanup } = createAutoRefresh({
|
||||
fetchAndRender: fetchAndRenderData,
|
||||
toggleContainer: toggleEl,
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
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 || {};
|
||||
@@ -24,6 +25,7 @@ export async function render(container, params, router) {
|
||||
<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>
|
||||
@@ -34,111 +36,112 @@ ${content}`, container);
|
||||
// Render page header immediately (old content stays visible until data loads)
|
||||
renderPage(nothing);
|
||||
|
||||
try {
|
||||
const data = await apiGet('/api/v1/messages', { limit, offset, message_type });
|
||||
const messages = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
async function fetchAndRenderData() {
|
||||
try {
|
||||
const data = await apiGet('/api/v1/messages', { limit, offset, message_type });
|
||||
const messages = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const mobileCards = messages.length === 0
|
||||
? html`<div class="text-center py-8 opacity-70">${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;
|
||||
const mobileCards = messages.length === 0
|
||||
? html`<div class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}</div>`
|
||||
: messages.map(msg => {
|
||||
const isChannel = msg.message_type === 'channel';
|
||||
const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}';
|
||||
const typeTitle = isChannel ? t('messages.type_channel') : t('messages.type_contact');
|
||||
let senderBlock;
|
||||
if (isChannel) {
|
||||
senderBlock = html`<span class="opacity-60">${t('messages.type_public')}</span>`;
|
||||
} else {
|
||||
senderBlock = html`<span class="font-mono text-xs">${(msg.pubkey_prefix || '-').slice(0, 12)}</span>`;
|
||||
const senderName = msg.sender_tag_name || msg.sender_name;
|
||||
if (senderName) {
|
||||
senderBlock = senderName;
|
||||
} else {
|
||||
senderBlock = html`<span class="font-mono text-xs">${(msg.pubkey_prefix || '-').slice(0, 12)}</span>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
let receiversBlock = nothing;
|
||||
if (msg.receivers && msg.receivers.length >= 1) {
|
||||
receiversBlock = html`<div class="flex gap-0.5">
|
||||
${msg.receivers.map(recv => {
|
||||
const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12);
|
||||
return html`<a href="/nodes/${recv.public_key}" class="text-sm hover:opacity-70" title=${recvName}>\u{1F4E1}</a>`;
|
||||
})}
|
||||
</div>`;
|
||||
} else if (msg.received_by) {
|
||||
const recvTitle = msg.receiver_tag_name || msg.receiver_name || truncateKey(msg.received_by, 12);
|
||||
receiversBlock = html`<a href="/nodes/${msg.received_by}" class="text-sm hover:opacity-70" title=${recvTitle}>\u{1F4E1}</a>`;
|
||||
}
|
||||
return html`<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-lg flex-shrink-0" title=${typeTitle}>
|
||||
${typeIcon}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-sm truncate">
|
||||
${senderBlock}
|
||||
</div>
|
||||
<div class="text-xs opacity-60">
|
||||
${formatDateTimeShort(msg.received_at)}
|
||||
let receiversBlock = nothing;
|
||||
if (msg.receivers && msg.receivers.length >= 1) {
|
||||
receiversBlock = html`<div class="flex gap-0.5">
|
||||
${msg.receivers.map(recv => {
|
||||
const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12);
|
||||
return html`<a href="/nodes/${recv.public_key}" class="text-sm hover:opacity-70" title=${recvName}>\u{1F4E1}</a>`;
|
||||
})}
|
||||
</div>`;
|
||||
} else if (msg.received_by) {
|
||||
const recvTitle = msg.receiver_tag_name || msg.receiver_name || truncateKey(msg.received_by, 12);
|
||||
receiversBlock = html`<a href="/nodes/${msg.received_by}" class="text-sm hover:opacity-70" title=${recvTitle}>\u{1F4E1}</a>`;
|
||||
}
|
||||
return html`<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-lg flex-shrink-0" title=${typeTitle}>
|
||||
${typeIcon}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-sm truncate">
|
||||
${senderBlock}
|
||||
</div>
|
||||
<div class="text-xs opacity-60">
|
||||
${formatDateTimeShort(msg.received_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
${receiversBlock}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
${receiversBlock}
|
||||
</div>
|
||||
<p class="text-sm mt-2 break-words whitespace-pre-wrap">${msg.text || '-'}</p>
|
||||
</div>
|
||||
<p class="text-sm mt-2 break-words whitespace-pre-wrap">${msg.text || '-'}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
</div>`;
|
||||
});
|
||||
|
||||
const tableRows = messages.length === 0
|
||||
? html`<tr><td colspan="5" class="text-center py-8 opacity-70">${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>`;
|
||||
const tableRows = messages.length === 0
|
||||
? html`<tr><td colspan="5" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}</td></tr>`
|
||||
: messages.map(msg => {
|
||||
const isChannel = msg.message_type === 'channel';
|
||||
const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}';
|
||||
const typeTitle = isChannel ? t('messages.type_channel') : t('messages.type_contact');
|
||||
let senderBlock;
|
||||
if (isChannel) {
|
||||
senderBlock = html`<span class="opacity-60">${t('messages.type_public')}</span>`;
|
||||
} else {
|
||||
senderBlock = html`<span class="font-mono text-xs">${(msg.pubkey_prefix || '-').slice(0, 12)}</span>`;
|
||||
const senderName = msg.sender_tag_name || msg.sender_name;
|
||||
if (senderName) {
|
||||
senderBlock = html`<span class="font-medium">${senderName}</span>`;
|
||||
} else {
|
||||
senderBlock = html`<span class="font-mono text-xs">${(msg.pubkey_prefix || '-').slice(0, 12)}</span>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
let receiversBlock;
|
||||
if (msg.receivers && msg.receivers.length >= 1) {
|
||||
receiversBlock = html`<div class="flex gap-1">
|
||||
${msg.receivers.map(recv => {
|
||||
const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12);
|
||||
return html`<a href="/nodes/${recv.public_key}" class="text-lg hover:opacity-70" title=${recvName}>\u{1F4E1}</a>`;
|
||||
})}
|
||||
</div>`;
|
||||
} else if (msg.received_by) {
|
||||
const recvTitle = msg.receiver_tag_name || msg.receiver_name || truncateKey(msg.received_by, 12);
|
||||
receiversBlock = html`<a href="/nodes/${msg.received_by}" class="text-lg hover:opacity-70" title=${recvTitle}>\u{1F4E1}</a>`;
|
||||
} else {
|
||||
receiversBlock = html`<span class="opacity-50">-</span>`;
|
||||
}
|
||||
return html`<tr class="hover align-top">
|
||||
<td class="text-lg" title=${typeTitle}>${typeIcon}</td>
|
||||
<td class="text-sm whitespace-nowrap">${formatDateTime(msg.received_at)}</td>
|
||||
<td class="text-sm whitespace-nowrap">${senderBlock}</td>
|
||||
<td class="break-words max-w-md" style="white-space: pre-wrap;">${msg.text || '-'}</td>
|
||||
<td>${receiversBlock}</td>
|
||||
</tr>`;
|
||||
let receiversBlock;
|
||||
if (msg.receivers && msg.receivers.length >= 1) {
|
||||
receiversBlock = html`<div class="flex gap-1">
|
||||
${msg.receivers.map(recv => {
|
||||
const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12);
|
||||
return html`<a href="/nodes/${recv.public_key}" class="text-lg hover:opacity-70" title=${recvName}>\u{1F4E1}</a>`;
|
||||
})}
|
||||
</div>`;
|
||||
} else if (msg.received_by) {
|
||||
const recvTitle = msg.receiver_tag_name || msg.receiver_name || truncateKey(msg.received_by, 12);
|
||||
receiversBlock = html`<a href="/nodes/${msg.received_by}" class="text-lg hover:opacity-70" title=${recvTitle}>\u{1F4E1}</a>`;
|
||||
} else {
|
||||
receiversBlock = html`<span class="opacity-50">-</span>`;
|
||||
}
|
||||
return html`<tr class="hover align-top">
|
||||
<td class="text-lg" title=${typeTitle}>${typeIcon}</td>
|
||||
<td class="text-sm whitespace-nowrap">${formatDateTime(msg.received_at)}</td>
|
||||
<td class="text-sm whitespace-nowrap">${senderBlock}</td>
|
||||
<td class="break-words max-w-md" style="white-space: pre-wrap;">${msg.text || '-'}</td>
|
||||
<td>${receiversBlock}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
const paginationBlock = pagination(page, totalPages, '/messages', {
|
||||
message_type, limit,
|
||||
});
|
||||
|
||||
const paginationBlock = pagination(page, totalPages, '/messages', {
|
||||
message_type, limit,
|
||||
});
|
||||
|
||||
renderPage(html`
|
||||
renderPage(html`
|
||||
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
|
||||
<div class="card-body py-4">
|
||||
<form method="GET" action="/messages" class="flex gap-4 flex-wrap items-end" @submit=${createFilterHandler('/messages', navigate)}>
|
||||
@@ -183,7 +186,17 @@ ${content}`, container);
|
||||
|
||||
${paginationBlock}`, { total });
|
||||
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
await fetchAndRenderData();
|
||||
|
||||
const toggleEl = container.querySelector('#auto-refresh-toggle');
|
||||
const { cleanup } = createAutoRefresh({
|
||||
fetchAndRender: fetchAndRenderData,
|
||||
toggleContainer: toggleEl,
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
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 || {};
|
||||
@@ -28,6 +29,7 @@ export async function render(container, params, router) {
|
||||
<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>
|
||||
@@ -38,107 +40,108 @@ ${content}`, container);
|
||||
// Render page header immediately (old content stays visible until data loads)
|
||||
renderPage(nothing);
|
||||
|
||||
try {
|
||||
const requests = [
|
||||
apiGet('/api/v1/nodes', { limit, offset, search, adv_type, member_id }),
|
||||
];
|
||||
if (showMembers) {
|
||||
requests.push(apiGet('/api/v1/members', { limit: 100 }));
|
||||
}
|
||||
async function fetchAndRenderData() {
|
||||
try {
|
||||
const requests = [
|
||||
apiGet('/api/v1/nodes', { limit, offset, search, adv_type, member_id }),
|
||||
];
|
||||
if (showMembers) {
|
||||
requests.push(apiGet('/api/v1/members', { limit: 100 }));
|
||||
}
|
||||
|
||||
const results = await Promise.all(requests);
|
||||
const data = results[0];
|
||||
const membersData = showMembers ? results[1] : null;
|
||||
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 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 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}
|
||||
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>
|
||||
</div>
|
||||
</a>`;
|
||||
</a>`;
|
||||
});
|
||||
|
||||
const tableColspan = showMembers ? 4 : 3;
|
||||
const tableRows = nodes.length === 0
|
||||
? html`<tr><td colspan="${tableColspan}" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}</td></tr>`
|
||||
: nodes.map(node => {
|
||||
const tagName = node.tags?.find(tag => tag.key === 'name')?.value;
|
||||
const tagDescription = node.tags?.find(tag => tag.key === 'description')?.value;
|
||||
const displayName = tagName || node.name;
|
||||
const lastSeen = node.last_seen ? formatDateTime(node.last_seen) : '-';
|
||||
const memberIdTag = showMembers ? node.tags?.find(tag => tag.key === 'member_id')?.value : null;
|
||||
const member = memberIdTag ? members.find(m => m.member_id === memberIdTag) : null;
|
||||
const memberBlock = member
|
||||
? html`${member.name}${member.callsign ? html` <span class="opacity-60">(${member.callsign})</span>` : nothing}`
|
||||
: html`<span class="opacity-50">-</span>`;
|
||||
return html`<tr class="hover">
|
||||
<td>
|
||||
<a href="/nodes/${node.public_key}" class="link link-hover">
|
||||
${renderNodeDisplay({
|
||||
name: displayName,
|
||||
description: tagDescription,
|
||||
publicKey: node.public_key,
|
||||
advType: node.adv_type,
|
||||
size: 'base'
|
||||
})}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<code class="font-mono text-xs cursor-pointer hover:bg-base-200 px-1 py-0.5 rounded select-all"
|
||||
@click=${(e) => copyToClipboard(e, node.public_key)}
|
||||
title="Click to copy">${node.public_key}</code>
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">${lastSeen}</td>
|
||||
${showMembers ? html`<td class="text-sm">${memberBlock}</td>` : nothing}
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
const paginationBlock = pagination(page, totalPages, '/nodes', {
|
||||
search, adv_type, member_id, limit,
|
||||
});
|
||||
|
||||
const 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`
|
||||
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)}>
|
||||
@@ -190,7 +193,17 @@ ${content}`, container);
|
||||
|
||||
${paginationBlock}`, { total });
|
||||
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
await fetchAndRenderData();
|
||||
|
||||
const toggleEl = container.querySelector('#auto-refresh-toggle');
|
||||
const { cleanup } = createAutoRefresh({
|
||||
fetchAndRender: fetchAndRenderData,
|
||||
toggleContainer: toggleEl,
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@
|
||||
"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",
|
||||
|
||||
@@ -191,7 +191,16 @@ Platform and external link labels:
|
||||
| `youtube` | YouTube | YouTube link label (preserve capitalization) |
|
||||
| `profile` | Profile | Radio profile label |
|
||||
|
||||
### 4. `time`
|
||||
### 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:
|
||||
|
||||
@@ -206,7 +215,7 @@ Time-related labels and formats:
|
||||
| `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 |
|
||||
|
||||
### 5. `node_types`
|
||||
### 6. `node_types`
|
||||
|
||||
Mesh network node type labels:
|
||||
|
||||
@@ -217,7 +226,7 @@ Mesh network node type labels:
|
||||
| `room` | Room | Room/group node type |
|
||||
| `unknown` | Unknown | Unknown node type fallback |
|
||||
|
||||
### 6. `home`
|
||||
### 7. `home`
|
||||
|
||||
Homepage-specific content:
|
||||
|
||||
@@ -238,7 +247,7 @@ Homepage-specific content:
|
||||
|
||||
**Note:** MeshCore tagline "Connecting people and things, without using the internet" is hardcoded in English and should not be translated (trademark).
|
||||
|
||||
### 7. `dashboard`
|
||||
### 8. `dashboard`
|
||||
|
||||
Dashboard page content:
|
||||
|
||||
@@ -248,7 +257,7 @@ Dashboard page content:
|
||||
| `recent_channel_messages` | Recent Channel Messages | Recent messages card title |
|
||||
| `channel` | Channel {{number}} | Channel label with number |
|
||||
|
||||
### 8. `nodes`
|
||||
### 9. `nodes`
|
||||
|
||||
Node-specific labels:
|
||||
|
||||
@@ -256,11 +265,11 @@ Node-specific labels:
|
||||
|-----|---------|---------|
|
||||
| `scan_to_add` | Scan to add as contact | QR code instruction |
|
||||
|
||||
### 9. `advertisements`
|
||||
### 10. `advertisements`
|
||||
|
||||
Currently empty - advertisements page uses common patterns.
|
||||
|
||||
### 10. `messages`
|
||||
### 11. `messages`
|
||||
|
||||
Message type labels:
|
||||
|
||||
@@ -271,7 +280,7 @@ Message type labels:
|
||||
| `type_contact` | Contact | Contact message type |
|
||||
| `type_public` | Public | Public message type |
|
||||
|
||||
### 11. `map`
|
||||
### 12. `map`
|
||||
|
||||
Map page content:
|
||||
|
||||
@@ -289,7 +298,7 @@ Map page content:
|
||||
| `role` | Role: | Member role label |
|
||||
| `select_destination_node` | -- Select destination node -- | Dropdown placeholder |
|
||||
|
||||
### 12. `members`
|
||||
### 13. `members`
|
||||
|
||||
Members page content:
|
||||
|
||||
@@ -300,7 +309,7 @@ Members page content:
|
||||
| `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) |
|
||||
|
||||
### 13. `not_found`
|
||||
### 14. `not_found`
|
||||
|
||||
404 page content:
|
||||
|
||||
@@ -308,7 +317,7 @@ Members page content:
|
||||
|-----|---------|---------|
|
||||
| `description` | The page you're looking for doesn't exist or has been moved. | 404 description |
|
||||
|
||||
### 14. `custom_page`
|
||||
### 15. `custom_page`
|
||||
|
||||
Custom markdown page errors:
|
||||
|
||||
@@ -316,7 +325,7 @@ Custom markdown page errors:
|
||||
|-----|---------|---------|
|
||||
| `failed_to_load` | Failed to load page | Page load error |
|
||||
|
||||
### 15. `admin`
|
||||
### 16. `admin`
|
||||
|
||||
Admin panel content:
|
||||
|
||||
@@ -331,7 +340,7 @@ Admin panel content:
|
||||
| `members_description` | Manage network members and operators. | Members card description |
|
||||
| `tags_description` | Manage custom tags and metadata for network nodes. | Tags card description |
|
||||
|
||||
### 16. `admin_members`
|
||||
### 17. `admin_members`
|
||||
|
||||
Admin members page:
|
||||
|
||||
@@ -344,7 +353,7 @@ Admin members page:
|
||||
|
||||
**Note:** Confirmation and success messages use `common.*` patterns.
|
||||
|
||||
### 17. `admin_node_tags`
|
||||
### 18. `admin_node_tags`
|
||||
|
||||
Admin node tags page:
|
||||
|
||||
@@ -368,7 +377,7 @@ Admin node tags page:
|
||||
|
||||
**Note:** Titles, confirmations, and success messages use `common.*` patterns.
|
||||
|
||||
### 18. `footer`
|
||||
### 19. `footer`
|
||||
|
||||
Footer content:
|
||||
|
||||
|
||||
216
src/meshcore_hub/web/static/locales/nl.json
Normal file
216
src/meshcore_hub/web/static/locales/nl.json
Normal file
@@ -0,0 +1,216 @@
|
||||
{
|
||||
"entities": {
|
||||
"home": "Startpagina",
|
||||
"dashboard": "Dashboard",
|
||||
"nodes": "Knooppunten",
|
||||
"node": "Knooppunt",
|
||||
"node_detail": "Knooppuntdetails",
|
||||
"advertisements": "Advertenties",
|
||||
"advertisement": "Advertentie",
|
||||
"messages": "Berichten",
|
||||
"message": "Bericht",
|
||||
"map": "Kaart",
|
||||
"members": "Leden",
|
||||
"member": "Lid",
|
||||
"admin": "Beheer",
|
||||
"tags": "Labels",
|
||||
"tag": "Label"
|
||||
},
|
||||
"common": {
|
||||
"filter": "Filter",
|
||||
"clear": "Wissen",
|
||||
"clear_filters": "Filters wissen",
|
||||
"search": "Zoeken",
|
||||
"cancel": "Annuleren",
|
||||
"delete": "Verwijderen",
|
||||
"edit": "Bewerken",
|
||||
"move": "Verplaatsen",
|
||||
"save": "Opslaan",
|
||||
"save_changes": "Wijzigingen opslaan",
|
||||
"add": "Toevoegen",
|
||||
"add_entity": "{{entity}} toevoegen",
|
||||
"add_new_entity": "Nieuwe {{entity}} toevoegen",
|
||||
"edit_entity": "{{entity}} bewerken",
|
||||
"delete_entity": "{{entity}} verwijderen",
|
||||
"delete_all_entity": "Alle {{entity}} verwijderen",
|
||||
"move_entity": "{{entity}} verplaatsen",
|
||||
"move_entity_to_another_node": "{{entity}} naar ander knooppunt verplaatsen",
|
||||
"copy_entity": "{{entity}} kopiëren",
|
||||
"copy_all_entity_to_another_node": "Alle {{entity}} naar ander knooppunt kopiëren",
|
||||
"view_entity": "{{entity}} bekijken",
|
||||
"recent_entity": "Recente {{entity}}",
|
||||
"total_entity": "Totaal {{entity}}",
|
||||
"all_entity": "Alle {{entity}}",
|
||||
"no_entity_found": "Geen {{entity}} gevonden",
|
||||
"no_entity_recorded": "Geen {{entity}} geregistreerd",
|
||||
"no_entity_defined": "Geen {{entity}} gedefinieerd",
|
||||
"no_entity_in_database": "Geen {{entity}} in database",
|
||||
"no_entity_configured": "Geen {{entity}} geconfigureerd",
|
||||
"no_entity_yet": "Nog geen {{entity}}",
|
||||
"entity_not_found_details": "{{entity}} niet gevonden: {{details}}",
|
||||
"page_not_found": "Pagina niet gevonden",
|
||||
"delete_entity_confirm": "Weet u zeker dat u {{entity}} <strong>{{name}}</strong> wilt verwijderen?",
|
||||
"delete_all_entity_confirm": "Weet u zeker dat u alle {{count}} {{entity}} van <strong>{{name}}</strong> wilt verwijderen?",
|
||||
"cannot_be_undone": "Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"entity_added_success": "{{entity}} succesvol toegevoegd",
|
||||
"entity_updated_success": "{{entity}} succesvol bijgewerkt",
|
||||
"entity_deleted_success": "{{entity}} succesvol verwijderd",
|
||||
"entity_moved_success": "{{entity}} succesvol verplaatst",
|
||||
"all_entity_deleted_success": "Alle {{entity}} succesvol verwijderd",
|
||||
"copy_all_entity_description": "Kopieer alle {{count}} {{entity}} van <strong>{{name}}</strong> naar een ander knooppunt.",
|
||||
"previous": "Vorige",
|
||||
"next": "Volgende",
|
||||
"go_home": "Naar startpagina",
|
||||
"loading": "Laden...",
|
||||
"error": "Fout",
|
||||
"failed_to_load_page": "Pagina laden mislukt",
|
||||
"total": "{{count}} totaal",
|
||||
"shown": "{{count}} weergegeven",
|
||||
"count_entity": "{{count}} {{entity}}",
|
||||
"type": "Type",
|
||||
"name": "Naam",
|
||||
"key": "Sleutel",
|
||||
"value": "Waarde",
|
||||
"time": "Tijd",
|
||||
"actions": "Acties",
|
||||
"updated": "Bijgewerkt",
|
||||
"sign_in": "Inloggen",
|
||||
"sign_out": "Uitloggen",
|
||||
"view_details": "Details bekijken",
|
||||
"all_types": "Alle types",
|
||||
"node_type": "Knooppunttype",
|
||||
"show": "Toon",
|
||||
"search_placeholder": "Zoek op naam, ID of publieke sleutel...",
|
||||
"contact": "Contact",
|
||||
"description": "Beschrijving",
|
||||
"callsign": "Roepnaam",
|
||||
"tags": "Labels",
|
||||
"last_seen": "Laatst gezien",
|
||||
"first_seen_label": "Eerst gezien:",
|
||||
"last_seen_label": "Laatst gezien:",
|
||||
"location": "Locatie",
|
||||
"public_key": "Publieke sleutel",
|
||||
"received": "Ontvangen",
|
||||
"received_by": "Ontvangen door",
|
||||
"receivers": "Ontvangers",
|
||||
"from": "Van",
|
||||
"close": "sluiten",
|
||||
"unnamed": "Naamloos",
|
||||
"unnamed_node": "Naamloos knooppunt"
|
||||
},
|
||||
"links": {
|
||||
"website": "Website",
|
||||
"github": "GitHub",
|
||||
"discord": "Discord",
|
||||
"youtube": "YouTube",
|
||||
"profile": "Profiel"
|
||||
},
|
||||
"auto_refresh": {
|
||||
"pause": "Pauzeer verversen",
|
||||
"resume": "Hervat verversen"
|
||||
},
|
||||
"time": {
|
||||
"days_ago": "{{count}}d geleden",
|
||||
"hours_ago": "{{count}}u geleden",
|
||||
"minutes_ago": "{{count}}m geleden",
|
||||
"less_than_minute": "<1m geleden",
|
||||
"last_7_days": "Laatste 7 dagen",
|
||||
"per_day_last_7_days": "Per dag (laatste 7 dagen)",
|
||||
"over_time_last_7_days": "In de tijd (laatste 7 dagen)",
|
||||
"activity_per_day_last_7_days": "Activiteit per dag (laatste 7 dagen)"
|
||||
},
|
||||
"node_types": {
|
||||
"chat": "Chat",
|
||||
"repeater": "Repeater",
|
||||
"room": "Ruimte",
|
||||
"unknown": "Onbekend"
|
||||
},
|
||||
"home": {
|
||||
"welcome_default": "Welkom bij het {{network_name}} mesh-netwerk dashboard. Monitor netwerkactiviteit, bekijk verbonden knooppunten en verken berichtgeschiedenis.",
|
||||
"all_discovered_nodes": "Alle ontdekte knooppunten",
|
||||
"network_info": "Netwerkinfo",
|
||||
"network_activity": "Netwerkactiviteit",
|
||||
"meshcore_attribution": "Ons lokale off-grid mesh-netwerk is mogelijk gemaakt door",
|
||||
"frequency": "Frequentie",
|
||||
"bandwidth": "Bandbreedte",
|
||||
"spreading_factor": "Spreading Factor",
|
||||
"coding_rate": "Coderingssnelheid",
|
||||
"tx_power": "TX Vermogen"
|
||||
},
|
||||
"dashboard": {
|
||||
"all_discovered_nodes": "Alle ontdekte knooppunten",
|
||||
"recent_channel_messages": "Recente kanaalberichten",
|
||||
"channel": "Kanaal {{number}}"
|
||||
},
|
||||
"nodes": {
|
||||
"scan_to_add": "Scan om als contact toe te voegen"
|
||||
},
|
||||
"advertisements": {},
|
||||
"messages": {
|
||||
"type_direct": "Direct",
|
||||
"type_channel": "Kanaal",
|
||||
"type_contact": "Contact",
|
||||
"type_public": "Publiek"
|
||||
},
|
||||
"map": {
|
||||
"show_labels": "Toon labels",
|
||||
"infrastructure_only": "Alleen infrastructuur",
|
||||
"legend": "Legenda:",
|
||||
"infrastructure": "Infrastructuur",
|
||||
"public": "Publiek",
|
||||
"nodes_on_map": "{{count}} knooppunten op kaart",
|
||||
"nodes_none_have_coordinates": "{{count}} knooppunten (geen met coördinaten)",
|
||||
"gps_description": "Knooppunten worden op de kaart geplaatst op basis van GPS-coördinaten uit knooppuntrapporten of handmatige labels.",
|
||||
"owner": "Eigenaar:",
|
||||
"role": "Rol:",
|
||||
"select_destination_node": "-- Selecteer bestemmingsknooppunt --"
|
||||
},
|
||||
"members": {
|
||||
"empty_state_description": "Om netwerkleden weer te geven, maak een members.yaml bestand aan in je seed-directory.",
|
||||
"members_file_format": "Members bestandsformaat",
|
||||
"members_file_description": "Maak een YAML-bestand aan op <code>$SEED_HOME/members.yaml</code> met de volgende structuur:",
|
||||
"members_import_instructions": "Voer <code>meshcore-hub collector seed</code> uit om leden te importeren.<br/>Om knooppunten aan leden te koppelen, voeg een <code>member_id</code> label toe aan knooppunten in <code>node_tags.yaml</code>."
|
||||
},
|
||||
"not_found": {
|
||||
"description": "De pagina die u zoekt bestaat niet of is verplaatst."
|
||||
},
|
||||
"custom_page": {
|
||||
"failed_to_load": "Pagina laden mislukt"
|
||||
},
|
||||
"admin": {
|
||||
"access_denied": "Toegang geweigerd",
|
||||
"admin_not_enabled": "De beheerinterface is niet ingeschakeld.",
|
||||
"admin_enable_hint": "Stel <code>WEB_ADMIN_ENABLED=true</code> in om beheerfuncties in te schakelen.",
|
||||
"auth_required": "Authenticatie vereist",
|
||||
"auth_required_description": "U moet inloggen om toegang te krijgen tot de beheerinterface.",
|
||||
"welcome": "Welkom bij het beheerpaneel.",
|
||||
"members_description": "Beheer netwerkleden en operators.",
|
||||
"tags_description": "Beheer aangepaste labels en metadata voor netwerkknooppunten."
|
||||
},
|
||||
"admin_members": {
|
||||
"network_members": "Netwerkleden ({{count}})",
|
||||
"member_id": "Lid-ID",
|
||||
"member_id_hint": "Unieke identificatie (letters, cijfers, underscore)",
|
||||
"empty_state_hint": "Klik op \"Lid toevoegen\" om het eerste lid aan te maken."
|
||||
},
|
||||
"admin_node_tags": {
|
||||
"select_node": "Selecteer knooppunt",
|
||||
"select_node_placeholder": "-- Selecteer een knooppunt --",
|
||||
"load_tags": "Labels laden",
|
||||
"move_warning": "Dit verplaatst het label van het huidige knooppunt naar het bestemmingsknooppunt.",
|
||||
"copy_all": "Alles kopiëren",
|
||||
"copy_all_info": "Labels die al bestaan op het bestemmingsknooppunt worden overgeslagen. Originele labels blijven op dit knooppunt.",
|
||||
"delete_all": "Alles verwijderen",
|
||||
"delete_all_warning": "Alle labels worden permanent verwijderd.",
|
||||
"destination_node": "Bestemmingsknooppunt",
|
||||
"tag_key": "Label sleutel",
|
||||
"for_this_node": "voor dit knooppunt",
|
||||
"empty_state_hint": "Voeg hieronder een nieuw label toe.",
|
||||
"select_a_node": "Selecteer een knooppunt",
|
||||
"select_a_node_description": "Kies een knooppunt uit de vervolgkeuzelijst hierboven om de labels te bekijken en beheren.",
|
||||
"copied_entities": "{{copied}} label(s) gekopieerd, {{skipped}} overgeslagen"
|
||||
},
|
||||
"footer": {
|
||||
"powered_by": "Mogelijk gemaakt door"
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -81,6 +82,20 @@ def mock_db_manager(api_db_engine):
|
||||
manager = MagicMock(spec=DatabaseManager)
|
||||
Session = sessionmaker(bind=api_db_engine)
|
||||
manager.get_session = lambda: Session()
|
||||
|
||||
@contextmanager
|
||||
def _session_scope():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
manager.session_scope = _session_scope
|
||||
return manager
|
||||
|
||||
|
||||
|
||||
346
tests/test_api/test_metrics.py
Normal file
346
tests/test_api/test_metrics.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""Tests for Prometheus metrics endpoint."""
|
||||
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from meshcore_hub.api.app import create_app
|
||||
from meshcore_hub.api.dependencies import (
|
||||
get_db_manager,
|
||||
get_db_session,
|
||||
get_mqtt_client,
|
||||
)
|
||||
from meshcore_hub.common.models import Node, NodeTag
|
||||
|
||||
|
||||
def _make_basic_auth(username: str, password: str) -> str:
|
||||
"""Create a Basic auth header value."""
|
||||
credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||
return f"Basic {credentials}"
|
||||
|
||||
|
||||
def _clear_metrics_cache() -> None:
|
||||
"""Clear the metrics module cache."""
|
||||
from meshcore_hub.api.metrics import _cache
|
||||
|
||||
_cache["output"] = b""
|
||||
_cache["expires_at"] = 0.0
|
||||
|
||||
|
||||
class TestMetricsEndpoint:
|
||||
"""Tests for basic metrics endpoint availability."""
|
||||
|
||||
def test_metrics_endpoint_available(self, client_no_auth):
|
||||
"""Test that /metrics endpoint returns 200."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_metrics_content_type(self, client_no_auth):
|
||||
"""Test that metrics returns correct content type."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert "text/plain" in response.headers["content-type"]
|
||||
|
||||
def test_metrics_contains_expected_names(self, client_no_auth):
|
||||
"""Test that metrics output contains expected metric names."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
content = response.text
|
||||
assert "meshcore_info" in content
|
||||
assert "meshcore_nodes_total" in content
|
||||
assert "meshcore_nodes_active" in content
|
||||
assert "meshcore_advertisements_total" in content
|
||||
assert "meshcore_telemetry_total" in content
|
||||
assert "meshcore_trace_paths_total" in content
|
||||
assert "meshcore_members_total" in content
|
||||
|
||||
def test_metrics_info_has_version(self, client_no_auth):
|
||||
"""Test that meshcore_info includes version label."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert 'meshcore_info{version="' in response.text
|
||||
|
||||
|
||||
class TestMetricsAuth:
|
||||
"""Tests for metrics endpoint authentication."""
|
||||
|
||||
def test_no_auth_when_no_read_key(self, client_no_auth):
|
||||
"""Test that no auth is required when no read key is configured."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_401_when_read_key_set_no_auth(self, client_with_auth):
|
||||
"""Test 401 when read key is set but no auth provided."""
|
||||
_clear_metrics_cache()
|
||||
response = client_with_auth.get("/metrics")
|
||||
assert response.status_code == 401
|
||||
assert "WWW-Authenticate" in response.headers
|
||||
|
||||
def test_success_with_correct_basic_auth(self, client_with_auth):
|
||||
"""Test successful auth with correct Basic credentials."""
|
||||
_clear_metrics_cache()
|
||||
response = client_with_auth.get(
|
||||
"/metrics",
|
||||
headers={"Authorization": _make_basic_auth("metrics", "test-read-key")},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_fail_with_wrong_password(self, client_with_auth):
|
||||
"""Test 401 with incorrect password."""
|
||||
_clear_metrics_cache()
|
||||
response = client_with_auth.get(
|
||||
"/metrics",
|
||||
headers={"Authorization": _make_basic_auth("metrics", "wrong-key")},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_fail_with_wrong_username(self, client_with_auth):
|
||||
"""Test 401 with incorrect username."""
|
||||
_clear_metrics_cache()
|
||||
response = client_with_auth.get(
|
||||
"/metrics",
|
||||
headers={
|
||||
"Authorization": _make_basic_auth("admin", "test-read-key"),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_fail_with_bearer_auth(self, client_with_auth):
|
||||
"""Test that Bearer auth does not work for metrics."""
|
||||
_clear_metrics_cache()
|
||||
response = client_with_auth.get(
|
||||
"/metrics",
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestMetricsData:
|
||||
"""Tests for metrics data accuracy."""
|
||||
|
||||
def test_nodes_total_reflects_database(self, client_no_auth, sample_node):
|
||||
"""Test that nodes_total matches actual node count."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
# Should have at least 1 node
|
||||
assert "meshcore_nodes_total 1.0" in response.text
|
||||
|
||||
def test_messages_total_reflects_database(self, client_no_auth, sample_message):
|
||||
"""Test that messages_total reflects database state."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "meshcore_messages_total" in response.text
|
||||
|
||||
def test_advertisements_total_reflects_database(
|
||||
self, client_no_auth, sample_advertisement
|
||||
):
|
||||
"""Test that advertisements_total reflects database state."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "meshcore_advertisements_total 1.0" in response.text
|
||||
|
||||
def test_members_total_reflects_database(self, client_no_auth, sample_member):
|
||||
"""Test that members_total reflects database state."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "meshcore_members_total 1.0" in response.text
|
||||
|
||||
def test_nodes_by_type_has_labels(self, client_no_auth, sample_node):
|
||||
"""Test that nodes_by_type includes adv_type labels."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert 'meshcore_nodes_by_type{adv_type="REPEATER"}' in response.text
|
||||
|
||||
def test_telemetry_total_reflects_database(self, client_no_auth, sample_telemetry):
|
||||
"""Test that telemetry_total reflects database state."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "meshcore_telemetry_total 1.0" in response.text
|
||||
|
||||
def test_trace_paths_total_reflects_database(
|
||||
self, client_no_auth, sample_trace_path
|
||||
):
|
||||
"""Test that trace_paths_total reflects database state."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "meshcore_trace_paths_total 1.0" in response.text
|
||||
|
||||
def test_node_last_seen_timestamp_present(self, api_db_session, client_no_auth):
|
||||
"""Test that node_last_seen_timestamp is present for nodes with last_seen."""
|
||||
seen_at = datetime(2025, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
node = Node(
|
||||
public_key="lastseen1234lastseen1234lastseen",
|
||||
name="Seen Node",
|
||||
adv_type="REPEATER",
|
||||
first_seen=seen_at,
|
||||
last_seen=seen_at,
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.commit()
|
||||
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
# Labels are sorted alphabetically by prometheus_client
|
||||
assert (
|
||||
"meshcore_node_last_seen_timestamp_seconds"
|
||||
'{adv_type="REPEATER",'
|
||||
'node_name="Seen Node",'
|
||||
'public_key="lastseen1234lastseen1234lastseen",'
|
||||
'role=""}'
|
||||
) in response.text
|
||||
|
||||
def test_node_last_seen_timestamp_with_role(self, api_db_session, client_no_auth):
|
||||
"""Test that node_last_seen_timestamp includes role label from node tags."""
|
||||
seen_at = datetime(2025, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
node = Node(
|
||||
public_key="rolenode1234rolenode1234rolenode",
|
||||
name="Infra Node",
|
||||
adv_type="REPEATER",
|
||||
first_seen=seen_at,
|
||||
last_seen=seen_at,
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.flush()
|
||||
|
||||
tag = NodeTag(node_id=node.id, key="role", value="infra")
|
||||
api_db_session.add(tag)
|
||||
api_db_session.commit()
|
||||
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert (
|
||||
"meshcore_node_last_seen_timestamp_seconds"
|
||||
'{adv_type="REPEATER",'
|
||||
'node_name="Infra Node",'
|
||||
'public_key="rolenode1234rolenode1234rolenode",'
|
||||
'role="infra"}'
|
||||
) in response.text
|
||||
|
||||
def test_node_last_seen_timestamp_skips_null(self, api_db_session, client_no_auth):
|
||||
"""Test that nodes with last_seen=None are excluded from the metric."""
|
||||
node = Node(
|
||||
public_key="neverseen1234neverseen1234neversx",
|
||||
name="Never Seen",
|
||||
adv_type="CLIENT",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
last_seen=None,
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.commit()
|
||||
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "neverseen1234neverseen1234neversx" not in response.text
|
||||
|
||||
def test_node_last_seen_timestamp_multiple_nodes(
|
||||
self, api_db_session, client_no_auth
|
||||
):
|
||||
"""Test that multiple nodes each get their own labeled time series."""
|
||||
seen1 = datetime(2025, 6, 15, 10, 0, 0, tzinfo=timezone.utc)
|
||||
seen2 = datetime(2025, 6, 15, 11, 0, 0, tzinfo=timezone.utc)
|
||||
node1 = Node(
|
||||
public_key="multinode1multinode1multinode1mu",
|
||||
name="Node One",
|
||||
adv_type="REPEATER",
|
||||
first_seen=seen1,
|
||||
last_seen=seen1,
|
||||
)
|
||||
node2 = Node(
|
||||
public_key="multinode2multinode2multinode2mu",
|
||||
name="Node Two",
|
||||
adv_type="CHAT",
|
||||
first_seen=seen2,
|
||||
last_seen=seen2,
|
||||
)
|
||||
api_db_session.add_all([node1, node2])
|
||||
api_db_session.commit()
|
||||
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert ('public_key="multinode1multinode1multinode1mu"') in response.text
|
||||
assert ('public_key="multinode2multinode2multinode2mu"') in response.text
|
||||
|
||||
def test_nodes_with_location(self, api_db_session, client_no_auth):
|
||||
"""Test that nodes_with_location counts correctly."""
|
||||
node = Node(
|
||||
public_key="locationtest1234locationtest1234",
|
||||
name="GPS Node",
|
||||
adv_type="CHAT",
|
||||
lat=37.7749,
|
||||
lon=-122.4194,
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
last_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.commit()
|
||||
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "meshcore_nodes_with_location 1.0" in response.text
|
||||
|
||||
|
||||
class TestMetricsDisabled:
|
||||
"""Tests for when metrics are disabled."""
|
||||
|
||||
def test_metrics_404_when_disabled(
|
||||
self, test_db_path, api_db_engine, mock_mqtt, mock_db_manager
|
||||
):
|
||||
"""Test that /metrics returns 404 when disabled."""
|
||||
db_url = f"sqlite:///{test_db_path}"
|
||||
|
||||
with patch("meshcore_hub.api.app._db_manager", mock_db_manager):
|
||||
app = create_app(
|
||||
database_url=db_url,
|
||||
metrics_enabled=False,
|
||||
)
|
||||
|
||||
Session = sessionmaker(bind=api_db_engine)
|
||||
|
||||
def override_get_db_manager(request=None):
|
||||
return mock_db_manager
|
||||
|
||||
def override_get_db_session():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def override_get_mqtt_client(request=None):
|
||||
return mock_mqtt
|
||||
|
||||
app.dependency_overrides[get_db_manager] = override_get_db_manager
|
||||
app.dependency_overrides[get_db_session] = override_get_db_session
|
||||
app.dependency_overrides[get_mqtt_client] = override_get_mqtt_client
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
response = client.get("/metrics")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestMetricsCache:
|
||||
"""Tests for metrics caching behavior."""
|
||||
|
||||
def test_cache_returns_same_output(self, client_no_auth):
|
||||
"""Test that cached responses return the same content."""
|
||||
_clear_metrics_cache()
|
||||
response1 = client_no_auth.get("/metrics")
|
||||
response2 = client_no_auth.get("/metrics")
|
||||
assert response1.text == response2.text
|
||||
Reference in New Issue
Block a user