forked from iarv/meshcore-hub
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caf88bdba1 | ||
|
|
9eb1acfc02 | ||
|
|
62e0568646 | ||
|
|
b4da93e4f0 | ||
|
|
981402f7aa | ||
|
|
76717179c2 | ||
|
|
f42987347e | ||
|
|
25831f14e6 | ||
|
|
0e6cbc8094 | ||
|
|
76630f0bb0 | ||
|
|
8fbac2cbd6 | ||
|
|
fcac5e01dc | ||
|
|
b6f3b2d864 |
@@ -187,6 +187,11 @@ API_ADMIN_KEY=
|
||||
# External web port
|
||||
WEB_PORT=8080
|
||||
|
||||
# Timezone for displaying dates/times on the web dashboard
|
||||
# Uses standard IANA timezone names (e.g., America/New_York, Europe/London)
|
||||
# Default: UTC
|
||||
TZ=UTC
|
||||
|
||||
# -------------------
|
||||
# Network Information
|
||||
# -------------------
|
||||
@@ -216,3 +221,4 @@ NETWORK_WELCOME_TEXT=
|
||||
NETWORK_CONTACT_EMAIL=
|
||||
NETWORK_CONTACT_DISCORD=
|
||||
NETWORK_CONTACT_GITHUB=
|
||||
NETWORK_CONTACT_YOUTUBE=
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -1,8 +1,6 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
|
||||
@@ -460,6 +460,7 @@ Key variables:
|
||||
- `MQTT_TLS` - Enable TLS/SSL for MQTT (default: `false`)
|
||||
- `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys
|
||||
- `WEB_ADMIN_ENABLED` - Enable admin interface at /a/ (default: `false`, requires auth proxy)
|
||||
- `TZ` - Timezone for web dashboard date/time display (default: `UTC`, e.g., `America/New_York`, `Europe/London`)
|
||||
- `LOG_LEVEL` - Logging verbosity
|
||||
|
||||
The database defaults to `sqlite:///{DATA_HOME}/collector/meshcore.db` and does not typically need to be configured.
|
||||
|
||||
@@ -330,6 +330,7 @@ The collector automatically cleans up old event data and inactive nodes:
|
||||
| `WEB_PORT` | `8080` | Web server port |
|
||||
| `API_BASE_URL` | `http://localhost:8000` | API endpoint URL |
|
||||
| `WEB_ADMIN_ENABLED` | `false` | Enable admin interface at /a/ (requires auth proxy) |
|
||||
| `TZ` | `UTC` | Timezone for displaying dates/times (e.g., `America/New_York`, `Europe/London`) |
|
||||
| `NETWORK_NAME` | `MeshCore Network` | Display name for the network |
|
||||
| `NETWORK_CITY` | *(none)* | City where network is located |
|
||||
| `NETWORK_COUNTRY` | *(none)* | Country code (ISO 3166-1 alpha-2) |
|
||||
|
||||
@@ -259,8 +259,10 @@ services:
|
||||
- NETWORK_CONTACT_EMAIL=${NETWORK_CONTACT_EMAIL:-}
|
||||
- NETWORK_CONTACT_DISCORD=${NETWORK_CONTACT_DISCORD:-}
|
||||
- NETWORK_CONTACT_GITHUB=${NETWORK_CONTACT_GITHUB:-}
|
||||
- NETWORK_CONTACT_YOUTUBE=${NETWORK_CONTACT_YOUTUBE:-}
|
||||
- NETWORK_WELCOME_TEXT=${NETWORK_WELCOME_TEXT:-}
|
||||
- CONTENT_HOME=/content
|
||||
- TZ=${TZ:-UTC}
|
||||
command: ["web"]
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
|
||||
|
||||
@@ -253,6 +253,9 @@ class WebSettings(CommonSettings):
|
||||
web_host: str = Field(default="0.0.0.0", description="Web server host")
|
||||
web_port: int = Field(default=8080, description="Web server port")
|
||||
|
||||
# Timezone for date/time display (uses standard TZ environment variable)
|
||||
tz: str = Field(default="UTC", description="Timezone for displaying dates/times")
|
||||
|
||||
# Admin interface (disabled by default for security)
|
||||
web_admin_enabled: bool = Field(
|
||||
default=False,
|
||||
@@ -291,6 +294,9 @@ class WebSettings(CommonSettings):
|
||||
network_contact_github: Optional[str] = Field(
|
||||
default=None, description="GitHub repository URL"
|
||||
)
|
||||
network_contact_youtube: Optional[str] = Field(
|
||||
default=None, description="YouTube channel URL"
|
||||
)
|
||||
network_welcome_text: Optional[str] = Field(
|
||||
default=None, description="Welcome text for homepage"
|
||||
)
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Request
|
||||
@@ -25,6 +27,75 @@ TEMPLATES_DIR = PACKAGE_DIR / "templates"
|
||||
STATIC_DIR = PACKAGE_DIR / "static"
|
||||
|
||||
|
||||
def _create_timezone_filters(tz_name: str) -> dict:
|
||||
"""Create Jinja2 filters for timezone-aware date formatting.
|
||||
|
||||
Args:
|
||||
tz_name: IANA timezone name (e.g., "America/New_York", "Europe/London")
|
||||
|
||||
Returns:
|
||||
Dict of filter name -> filter function
|
||||
"""
|
||||
try:
|
||||
tz = ZoneInfo(tz_name)
|
||||
except Exception:
|
||||
logger.warning(f"Invalid timezone '{tz_name}', falling back to UTC")
|
||||
tz = ZoneInfo("UTC")
|
||||
|
||||
def format_datetime(
|
||||
value: str | datetime | None,
|
||||
fmt: str = "%Y-%m-%d %H:%M:%S",
|
||||
) -> str:
|
||||
"""Format a UTC datetime string or object to the configured timezone.
|
||||
|
||||
Args:
|
||||
value: ISO 8601 UTC datetime string or datetime object
|
||||
fmt: strftime format string
|
||||
|
||||
Returns:
|
||||
Formatted datetime string in configured timezone
|
||||
"""
|
||||
if value is None:
|
||||
return "-"
|
||||
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
# Parse ISO 8601 string (assume UTC if no timezone)
|
||||
value = value.replace("Z", "+00:00")
|
||||
if "+" not in value and "-" not in value[10:]:
|
||||
# No timezone info, assume UTC
|
||||
dt = datetime.fromisoformat(value).replace(tzinfo=ZoneInfo("UTC"))
|
||||
else:
|
||||
dt = datetime.fromisoformat(value)
|
||||
else:
|
||||
dt = value
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
|
||||
|
||||
# Convert to target timezone
|
||||
local_dt = dt.astimezone(tz)
|
||||
return local_dt.strftime(fmt)
|
||||
except Exception:
|
||||
# Fallback to original value if parsing fails
|
||||
return str(value)[:19].replace("T", " ") if value else "-"
|
||||
|
||||
def format_time(value: str | datetime | None, fmt: str = "%H:%M:%S") -> str:
|
||||
"""Format just the time portion in the configured timezone."""
|
||||
return format_datetime(value, fmt)
|
||||
|
||||
def format_date(value: str | datetime | None, fmt: str = "%Y-%m-%d") -> str:
|
||||
"""Format just the date portion in the configured timezone."""
|
||||
return format_datetime(value, fmt)
|
||||
|
||||
return {
|
||||
"localtime": format_datetime,
|
||||
"localtime_short": lambda v: format_datetime(v, "%Y-%m-%d %H:%M"),
|
||||
"localdate": format_date,
|
||||
"localtimeonly": format_time,
|
||||
"localtimeonly_short": lambda v: format_time(v, "%H:%M"),
|
||||
}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan handler."""
|
||||
@@ -62,6 +133,7 @@ def create_app(
|
||||
network_contact_email: str | None = None,
|
||||
network_contact_discord: str | None = None,
|
||||
network_contact_github: str | None = None,
|
||||
network_contact_youtube: str | None = None,
|
||||
network_welcome_text: str | None = None,
|
||||
) -> FastAPI:
|
||||
"""Create and configure the web dashboard application.
|
||||
@@ -80,6 +152,7 @@ def create_app(
|
||||
network_contact_email: Contact email address
|
||||
network_contact_discord: Discord invite/server info
|
||||
network_contact_github: GitHub repository URL
|
||||
network_contact_youtube: YouTube channel URL
|
||||
network_welcome_text: Welcome text for homepage
|
||||
|
||||
Returns:
|
||||
@@ -124,6 +197,9 @@ def create_app(
|
||||
app.state.network_contact_github = (
|
||||
network_contact_github or settings.network_contact_github
|
||||
)
|
||||
app.state.network_contact_youtube = (
|
||||
network_contact_youtube or settings.network_contact_youtube
|
||||
)
|
||||
app.state.network_welcome_text = (
|
||||
network_welcome_text or settings.network_welcome_text
|
||||
)
|
||||
@@ -132,6 +208,20 @@ def create_app(
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
templates.env.trim_blocks = True # Remove first newline after block tags
|
||||
templates.env.lstrip_blocks = True # Remove leading whitespace before block tags
|
||||
|
||||
# Register timezone-aware date formatting filters
|
||||
app.state.timezone = settings.tz
|
||||
tz_filters = _create_timezone_filters(settings.tz)
|
||||
for name, func in tz_filters.items():
|
||||
templates.env.filters[name] = func
|
||||
|
||||
# Compute timezone abbreviation (e.g., "GMT", "EST", "PST")
|
||||
try:
|
||||
tz = ZoneInfo(settings.tz)
|
||||
app.state.timezone_abbr = datetime.now(tz).strftime("%Z")
|
||||
except Exception:
|
||||
app.state.timezone_abbr = "UTC"
|
||||
|
||||
app.state.templates = templates
|
||||
|
||||
# Initialize page loader for custom markdown pages
|
||||
@@ -309,9 +399,11 @@ def get_network_context(request: Request) -> dict:
|
||||
"network_contact_email": request.app.state.network_contact_email,
|
||||
"network_contact_discord": request.app.state.network_contact_discord,
|
||||
"network_contact_github": request.app.state.network_contact_github,
|
||||
"network_contact_youtube": request.app.state.network_contact_youtube,
|
||||
"network_welcome_text": request.app.state.network_welcome_text,
|
||||
"admin_enabled": request.app.state.admin_enabled,
|
||||
"custom_pages": custom_pages,
|
||||
"logo_url": request.app.state.logo_url,
|
||||
"version": __version__,
|
||||
"timezone": request.app.state.timezone_abbr,
|
||||
}
|
||||
|
||||
@@ -88,6 +88,13 @@ import click
|
||||
envvar="NETWORK_CONTACT_GITHUB",
|
||||
help="GitHub repository URL",
|
||||
)
|
||||
@click.option(
|
||||
"--network-contact-youtube",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="NETWORK_CONTACT_YOUTUBE",
|
||||
help="YouTube channel URL",
|
||||
)
|
||||
@click.option(
|
||||
"--network-welcome-text",
|
||||
type=str,
|
||||
@@ -116,6 +123,7 @@ def web(
|
||||
network_contact_email: str | None,
|
||||
network_contact_discord: str | None,
|
||||
network_contact_github: str | None,
|
||||
network_contact_youtube: str | None,
|
||||
network_welcome_text: str | None,
|
||||
reload: bool,
|
||||
) -> None:
|
||||
@@ -201,6 +209,7 @@ def web(
|
||||
network_contact_email=network_contact_email,
|
||||
network_contact_discord=network_contact_discord,
|
||||
network_contact_github=network_contact_github,
|
||||
network_contact_youtube=network_contact_youtube,
|
||||
network_welcome_text=network_welcome_text,
|
||||
)
|
||||
|
||||
|
||||
@@ -347,3 +347,19 @@ footer {
|
||||
.leaflet-marker-icon:hover {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Node Header Hero Map Background
|
||||
========================================================================== */
|
||||
|
||||
#header-map {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Ensure Leaflet elements stay within the map layer */
|
||||
#header-map .leaflet-pane,
|
||||
#header-map .leaflet-control {
|
||||
z-index: auto !important;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 70 70"
|
||||
width="70"
|
||||
height="70"
|
||||
viewBox="0 0 53 53"
|
||||
width="53"
|
||||
height="53"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
id="svg3"
|
||||
sodipodi:docname="logo_bak.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs3" />
|
||||
<sodipodi:namedview
|
||||
id="namedview3"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1" />
|
||||
<!-- WiFi arcs radiating from bottom-left corner -->
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round">
|
||||
stroke-linecap="round"
|
||||
id="g3"
|
||||
transform="translate(-1,-16)">
|
||||
<!-- Inner arc: from right to top -->
|
||||
<path d="M 20,65 A 15,15 0 0 0 5,50" />
|
||||
<path
|
||||
d="M 20,65 A 15,15 0 0 0 5,50"
|
||||
id="path1" />
|
||||
<!-- Middle arc -->
|
||||
<path d="M 35,65 A 30,30 0 0 0 5,35" />
|
||||
<path
|
||||
d="M 35,65 A 30,30 0 0 0 5,35"
|
||||
id="path2" />
|
||||
<!-- Outer arc -->
|
||||
<path d="M 50,65 A 45,45 0 0 0 5,20" />
|
||||
<path
|
||||
d="M 50,65 A 45,45 0 0 0 5,20"
|
||||
id="path3" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 553 B After Width: | Height: | Size: 1.2 KiB |
@@ -104,10 +104,10 @@
|
||||
var relativeTime = typeof formatRelativeTime === 'function' ? formatRelativeTime(node.last_seen) : '';
|
||||
var timeDisplay = relativeTime ? ' (' + relativeTime + ')' : '';
|
||||
|
||||
// Use logo for infrastructure nodes, blue circle for others
|
||||
// Use red circle for infrastructure nodes, blue circle for others
|
||||
var iconHtml;
|
||||
if (node.is_infra) {
|
||||
iconHtml = '<img src="' + logoUrl + '" alt="Infra" style="width: 24px; height: 24px; filter: drop-shadow(0 0 2px #1a237e) drop-shadow(0 0 4px #1a237e) drop-shadow(0 1px 2px rgba(0,0,0,0.7));">';
|
||||
iconHtml = '<div style="width: 12px; height: 12px; background: #ef4444; border: 2px solid #b91c1c; border-radius: 50%; box-shadow: 0 0 4px rgba(239,68,68,0.6), 0 1px 2px rgba(0,0,0,0.5);"></div>';
|
||||
} else {
|
||||
iconHtml = '<div style="width: 12px; height: 12px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%; box-shadow: 0 0 4px rgba(59,130,246,0.6), 0 1px 2px rgba(0,0,0,0.5);"></div>';
|
||||
}
|
||||
@@ -123,6 +123,17 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get type emoji for node
|
||||
*/
|
||||
function getTypeEmoji(node) {
|
||||
var type = normalizeType(node.adv_type);
|
||||
if (type === 'chat') return '💬';
|
||||
if (type === 'repeater') return '📡';
|
||||
if (type === 'room') return '🪧';
|
||||
return '📍';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create popup content for a node
|
||||
*/
|
||||
@@ -141,18 +152,23 @@
|
||||
}
|
||||
|
||||
var typeDisplay = getTypeDisplay(node);
|
||||
var typeEmoji = getTypeEmoji(node);
|
||||
|
||||
// Use logo for infrastructure nodes, blue circle for others
|
||||
var iconHtml = node.is_infra
|
||||
? '<img src="' + logoUrl + '" alt="Infra" style="width: 20px; height: 20px; display: inline-block; vertical-align: middle;">'
|
||||
: '<span style="display: inline-block; width: 12px; height: 12px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%; vertical-align: middle;"></span>';
|
||||
// Infra indicator (red/blue dot) shown to the right of the title if is_infra is defined
|
||||
var infraIndicatorHtml = '';
|
||||
if (typeof node.is_infra !== 'undefined') {
|
||||
var dotColor = node.is_infra ? '#ef4444' : '#3b82f6';
|
||||
var borderColor = node.is_infra ? '#b91c1c' : '#1e40af';
|
||||
var title = node.is_infra ? 'Infrastructure' : 'Public';
|
||||
infraIndicatorHtml = ' <span style="display: inline-block; width: 10px; height: 10px; background: ' + dotColor + '; border: 2px solid ' + borderColor + '; border-radius: 50%; vertical-align: middle;" title="' + title + '"></span>';
|
||||
}
|
||||
|
||||
var lastSeenHtml = node.last_seen
|
||||
? '<p><span class="opacity-70">Last seen:</span> ' + node.last_seen.substring(0, 19).replace('T', ' ') + '</p>'
|
||||
: '';
|
||||
|
||||
return '<div class="p-2">' +
|
||||
'<h3 class="font-bold text-lg mb-2">' + iconHtml + ' ' + node.name + '</h3>' +
|
||||
'<h3 class="font-bold text-lg mb-2">' + typeEmoji + ' ' + node.name + infraIndicatorHtml + '</h3>' +
|
||||
'<div class="space-y-1 text-sm">' +
|
||||
'<p><span class="opacity-70">Type:</span> ' + typeDisplay + '</p>' +
|
||||
roleHtml +
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
* - name: Node display name (required)
|
||||
* - type: Node adv_type (optional)
|
||||
* - publicKey: Node public key (optional, for linking)
|
||||
* - elementId: Map container element ID (default: 'node-map')
|
||||
* - interactive: Enable map interactions (default: true)
|
||||
* - zoom: Initial zoom level (default: 15)
|
||||
* - showMarker: Show node marker (default: true)
|
||||
* - offsetX: Horizontal position of node (0-1, default: 0.5 = center)
|
||||
* - offsetY: Vertical position of node (0-1, default: 0.5 = center)
|
||||
*/
|
||||
|
||||
(function() {
|
||||
@@ -26,54 +32,90 @@
|
||||
var nodeLon = config.lon;
|
||||
var nodeName = config.name || 'Unnamed Node';
|
||||
var nodeType = config.type || '';
|
||||
var elementId = config.elementId || 'node-map';
|
||||
var interactive = config.interactive !== false; // Default true
|
||||
var zoomLevel = config.zoom || 15;
|
||||
var showMarker = config.showMarker !== false; // Default true
|
||||
var offsetX = typeof config.offsetX === 'number' ? config.offsetX : 0.5; // 0-1, default center
|
||||
var offsetY = typeof config.offsetY === 'number' ? config.offsetY : 0.5; // 0-1, default center
|
||||
|
||||
// Check if map container exists
|
||||
var mapContainer = document.getElementById('node-map');
|
||||
var mapContainer = document.getElementById(elementId);
|
||||
if (!mapContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build map options
|
||||
var mapOptions = {};
|
||||
|
||||
// Disable interactions if non-interactive
|
||||
if (!interactive) {
|
||||
mapOptions.dragging = false;
|
||||
mapOptions.touchZoom = false;
|
||||
mapOptions.scrollWheelZoom = false;
|
||||
mapOptions.doubleClickZoom = false;
|
||||
mapOptions.boxZoom = false;
|
||||
mapOptions.keyboard = false;
|
||||
mapOptions.zoomControl = false;
|
||||
mapOptions.attributionControl = false;
|
||||
}
|
||||
|
||||
// Initialize map centered on the node's location
|
||||
var map = L.map('node-map').setView([nodeLat, nodeLon], 15);
|
||||
var map = L.map(elementId, mapOptions).setView([nodeLat, nodeLon], zoomLevel);
|
||||
|
||||
// Apply offset to position node at specified location instead of center
|
||||
// offsetX/Y of 0.5 = center (no pan), 0.33 = 1/3 from left/top
|
||||
if (offsetX !== 0.5 || offsetY !== 0.5) {
|
||||
var containerWidth = mapContainer.offsetWidth;
|
||||
var containerHeight = mapContainer.offsetHeight;
|
||||
// Pan amount: how far to move the map so node appears at offset position
|
||||
// Positive X = pan right (node moves left), Positive Y = pan down (node moves up)
|
||||
var panX = (0.5 - offsetX) * containerWidth;
|
||||
var panY = (0.5 - offsetY) * containerHeight;
|
||||
map.panBy([panX, panY], { animate: false });
|
||||
}
|
||||
|
||||
// Add tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
|
||||
/**
|
||||
* Get emoji marker based on node type
|
||||
*/
|
||||
function getNodeEmoji(type) {
|
||||
var normalizedType = type ? type.toLowerCase() : null;
|
||||
if (normalizedType === 'chat') return '💬';
|
||||
if (normalizedType === 'repeater') return '📡';
|
||||
if (normalizedType === 'room') return '🪧';
|
||||
return '📍';
|
||||
// Only add marker if showMarker is true
|
||||
if (showMarker) {
|
||||
/**
|
||||
* Get emoji marker based on node type
|
||||
*/
|
||||
function getNodeEmoji(type) {
|
||||
var normalizedType = type ? type.toLowerCase() : null;
|
||||
if (normalizedType === 'chat') return '💬';
|
||||
if (normalizedType === 'repeater') return '📡';
|
||||
if (normalizedType === 'room') return '🪧';
|
||||
return '📍';
|
||||
}
|
||||
|
||||
// Create marker icon (just the emoji, no label)
|
||||
var emoji = getNodeEmoji(nodeType);
|
||||
var icon = L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: '<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">' + emoji + '</span>',
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
});
|
||||
|
||||
// Add marker
|
||||
var marker = L.marker([nodeLat, nodeLon], { icon: icon }).addTo(map);
|
||||
|
||||
// Only add popup if map is interactive
|
||||
if (interactive) {
|
||||
var typeHtml = nodeType ? '<p><span class="opacity-70">Type:</span> ' + nodeType + '</p>' : '';
|
||||
var popupContent = '<div class="p-2">' +
|
||||
'<h3 class="font-bold text-lg mb-2">' + emoji + ' ' + nodeName + '</h3>' +
|
||||
'<div class="space-y-1 text-sm">' +
|
||||
typeHtml +
|
||||
'<p><span class="opacity-70">Coordinates:</span> ' + nodeLat.toFixed(4) + ', ' + nodeLon.toFixed(4) + '</p>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
marker.bindPopup(popupContent);
|
||||
}
|
||||
}
|
||||
|
||||
// Create marker icon (just the emoji, no label)
|
||||
var emoji = getNodeEmoji(nodeType);
|
||||
var icon = L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: '<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">' + emoji + '</span>',
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
});
|
||||
|
||||
// Add marker
|
||||
var marker = L.marker([nodeLat, nodeLon], { icon: icon }).addTo(map);
|
||||
|
||||
// Build popup content
|
||||
var typeHtml = nodeType ? '<p><span class="opacity-70">Type:</span> ' + nodeType + '</p>' : '';
|
||||
var popupContent = '<div class="p-2">' +
|
||||
'<h3 class="font-bold text-lg mb-2">' + emoji + ' ' + nodeName + '</h3>' +
|
||||
'<div class="space-y-1 text-sm">' +
|
||||
typeHtml +
|
||||
'<p><span class="opacity-70">Coordinates:</span> ' + nodeLat.toFixed(4) + ', ' + nodeLon.toFixed(4) + '</p>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// Add popup (shown on click, not by default)
|
||||
marker.bindPopup(popupContent);
|
||||
})();
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* - publicKey: 64-char hex public key (required)
|
||||
* - advType: Node advertisement type (optional)
|
||||
* - containerId: ID of container element (default: 'qr-code')
|
||||
* - size: QR code size in pixels (default: 128)
|
||||
*/
|
||||
|
||||
(function() {
|
||||
@@ -25,6 +26,7 @@
|
||||
var publicKey = config.publicKey;
|
||||
var advType = config.advType || '';
|
||||
var containerId = config.containerId || 'qr-code';
|
||||
var size = config.size || 128;
|
||||
|
||||
// Map adv_type to numeric type for meshcore:// protocol
|
||||
var typeMap = {
|
||||
@@ -46,8 +48,8 @@
|
||||
try {
|
||||
new QRCode(qrContainer, {
|
||||
text: meshcoreUrl,
|
||||
width: 256,
|
||||
height: 256,
|
||||
width: size,
|
||||
height: size,
|
||||
colorDark: '#000000',
|
||||
colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.L
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<td>
|
||||
<span class="badge badge-ghost badge-sm">{{ tag.value_type }}</span>
|
||||
</td>
|
||||
<td class="text-sm opacity-70">{{ tag.updated_at[:10] if tag.updated_at else '-' }}</td>
|
||||
<td class="text-sm opacity-70">{{ tag.updated_at|localdate }}</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-ghost btn-xs btn-edit">
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Advertisements</h1>
|
||||
<span class="badge badge-lg">{{ total }} total</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if timezone and timezone != 'UTC' %}<span class="text-sm opacity-60">{{ timezone }}</span>{% endif %}
|
||||
<span class="badge badge-lg">{{ total }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if api_error %}
|
||||
@@ -86,7 +89,7 @@
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-xs opacity-60">
|
||||
{{ ad.received_at[:16].replace('T', ' ') if ad.received_at else '-' }}
|
||||
{{ ad.received_at|localtime_short }}
|
||||
</div>
|
||||
{% if ad.receivers and ad.receivers|length >= 1 %}
|
||||
<div class="flex gap-0.5 justify-end mt-1">
|
||||
@@ -133,7 +136,7 @@
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{{ ad.received_at[:19].replace('T', ' ') if ad.received_at else '-' }}
|
||||
{{ ad.received_at|localtime }}
|
||||
</td>
|
||||
<td>
|
||||
{% if ad.receivers and ad.receivers|length >= 1 %}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<title>{% block title %}{{ network_name }}{% endblock %}</title>
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
{% set default_description = network_name ~ " - MeshCore off-grid LoRa mesh network dashboard. Monitor nodes, messages, and network activity." %}
|
||||
{% set default_description = (network_name ~ " - " ~ network_welcome_text) if network_welcome_text else (network_name ~ " - MeshCore off-grid LoRa mesh network dashboard.") %}
|
||||
<meta name="description" content="{% block meta_description %}{{ default_description }}{% endblock %}">
|
||||
<meta name="generator" content="MeshCore Hub {{ version }}">
|
||||
<link rel="canonical" href="{{ request.url }}">
|
||||
@@ -113,6 +113,10 @@
|
||||
{% if network_contact_github %}
|
||||
<a href="{{ network_contact_github }}" target="_blank" rel="noopener noreferrer" class="link link-hover">GitHub</a>
|
||||
{% endif %}
|
||||
{% if (network_contact_email or network_contact_discord or network_contact_github) and network_contact_youtube %} | {% endif %}
|
||||
{% if network_contact_youtube %}
|
||||
<a href="{{ network_contact_youtube }}" target="_blank" rel="noopener noreferrer" class="link link-hover">YouTube</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-xs opacity-50 mt-2">{% if admin_enabled %}<a href="/a/" class="link link-hover">Admin</a> | {% endif %}Powered by <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
|
||||
</aside>
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right text-sm opacity-70">{{ ad.received_at.split('T')[1][:8] if ad.received_at else '-' }}</td>
|
||||
<td class="text-right text-sm opacity-70">{{ ad.received_at|localtimeonly }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -166,7 +166,7 @@
|
||||
<div class="space-y-1 pl-2 border-l-2 border-base-300">
|
||||
{% for msg in messages %}
|
||||
<div class="text-sm">
|
||||
<span class="text-xs opacity-50">{{ msg.received_at.split('T')[1][:5] if msg.received_at else '' }}</span>
|
||||
<span class="text-xs opacity-50">{{ msg.received_at|localtimeonly_short }}</span>
|
||||
<span class="break-words" style="white-space: pre-wrap;">{{ msg.text }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Map</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if timezone and timezone != 'UTC' %}<span class="text-sm opacity-60">{{ timezone }}</span>{% endif %}
|
||||
<span id="node-count" class="badge badge-lg">Loading...</span>
|
||||
<span id="filtered-count" class="badge badge-lg badge-ghost hidden"></span>
|
||||
</div>
|
||||
@@ -65,12 +66,12 @@
|
||||
<div class="mt-4 flex flex-wrap gap-4 items-center text-sm">
|
||||
<span class="opacity-70">Legend:</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<img src="{{ logo_url }}" alt="Infrastructure" class="h-5 w-5">
|
||||
<div style="width: 10px; height: 10px; background: #ef4444; border: 2px solid #b91c1c; border-radius: 50%;"></div>
|
||||
<span>Infrastructure</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div style="width: 10px; height: 10px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%;"></div>
|
||||
<span>Node</span>
|
||||
<span>Public</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if node.last_seen %}
|
||||
<time class="text-xs opacity-60 whitespace-nowrap" datetime="{{ node.last_seen }}" title="{{ node.last_seen[:19].replace('T', ' ') }}" data-relative-time>-</time>
|
||||
<time class="text-xs opacity-60 whitespace-nowrap" datetime="{{ node.last_seen }}" title="{{ node.last_seen|localtime }}" data-relative-time>-</time>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Messages</h1>
|
||||
<span class="badge badge-lg">{{ total }} total</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if timezone and timezone != 'UTC' %}<span class="text-sm opacity-60">{{ timezone }}</span>{% endif %}
|
||||
<span class="badge badge-lg">{{ total }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if api_error %}
|
||||
@@ -62,7 +65,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-xs opacity-60">
|
||||
{{ msg.received_at[:16].replace('T', ' ') if msg.received_at else '-' }}
|
||||
{{ msg.received_at|localtime_short }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,7 +108,7 @@
|
||||
{% if msg.message_type == 'channel' %}📻{% else %}👤{% endif %}
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{{ msg.received_at[:19].replace('T', ' ') if msg.received_at else '-' }}
|
||||
{{ msg.received_at|localtime }}
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{% if msg.message_type == 'channel' %}
|
||||
|
||||
@@ -34,105 +34,93 @@
|
||||
{% endif %}
|
||||
|
||||
{% if node %}
|
||||
{# Get display name from tag or node.name #}
|
||||
{% set ns = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<!-- Node Info Card -->
|
||||
{% set display_name = ns.tag_name or node.name or 'Unnamed Node' %}
|
||||
|
||||
{# Get coordinates from node model first, then fall back to tags (bug fix) #}
|
||||
{% set ns_coords = namespace(lat=node.lat, lon=node.lon) %}
|
||||
{% if not ns_coords.lat or not ns_coords.lon %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' and not ns_coords.lat %}
|
||||
{% set ns_coords.lat = tag.value|float %}
|
||||
{% elif tag.key == 'lon' and not ns_coords.lon %}
|
||||
{% set ns_coords.lon = tag.value|float %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% set has_coords = ns_coords.lat is not none and ns_coords.lon is not none %}
|
||||
|
||||
{# Node type emoji #}
|
||||
{% set type_emoji = '📍' %}
|
||||
{% if node.adv_type %}
|
||||
{% if node.adv_type|lower == 'chat' %}
|
||||
{% set type_emoji = '💬' %}
|
||||
{% elif node.adv_type|lower == 'repeater' %}
|
||||
{% set type_emoji = '📡' %}
|
||||
{% elif node.adv_type|lower == 'room' %}
|
||||
{% set type_emoji = '🪧' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Page Header -->
|
||||
<h1 class="text-3xl font-bold mb-6">
|
||||
<span title="{{ node.adv_type or 'Unknown' }}">{{ type_emoji }}</span>
|
||||
{{ display_name }}
|
||||
</h1>
|
||||
|
||||
<!-- Node Hero Panel -->
|
||||
{% if has_coords %}
|
||||
<div class="relative rounded-box overflow-hidden mb-6 shadow-xl" style="height: 180px;">
|
||||
<!-- Map container (non-interactive background) -->
|
||||
<div id="header-map" class="absolute inset-0 z-0"></div>
|
||||
|
||||
<!-- QR code overlay (right side, fills height) -->
|
||||
<div class="relative z-20 h-full p-3 flex items-center justify-end">
|
||||
<div id="qr-code" class="bg-white p-2 rounded shadow-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- QR Code Card (no map) -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body flex-row items-center gap-4">
|
||||
<div id="qr-code" class="bg-white p-1 rounded"></div>
|
||||
<p class="text-sm opacity-70">Scan to add as contact</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Node Details Card -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<!-- Title Row with Activity -->
|
||||
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<h1 class="card-title text-2xl">
|
||||
{% if node.adv_type %}
|
||||
{% if node.adv_type|lower == 'chat' %}
|
||||
<span title="Chat">💬</span>
|
||||
{% elif node.adv_type|lower == 'repeater' %}
|
||||
<span title="Repeater">📡</span>
|
||||
{% elif node.adv_type|lower == 'room' %}
|
||||
<span title="Room">🪧</span>
|
||||
{% else %}
|
||||
<span title="{{ node.adv_type }}">📍</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{{ ns.tag_name or node.name or 'Unnamed Node' }}
|
||||
</h1>
|
||||
<div class="text-sm text-right">
|
||||
<p><span class="opacity-70">First seen:</span> {{ node.first_seen[:19].replace('T', ' ') if node.first_seen else '-' }}</p>
|
||||
<p><span class="opacity-70">Last seen:</span> {{ node.last_seen[:19].replace('T', ' ') if node.last_seen else '-' }}</p>
|
||||
</div>
|
||||
<!-- Public Key -->
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Public Key</h3>
|
||||
<code class="text-sm bg-base-200 p-2 rounded block break-all">{{ node.public_key }}</code>
|
||||
</div>
|
||||
|
||||
{% set ns_map = namespace(lat=none, lon=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' %}
|
||||
{% set ns_map.lat = tag.value %}
|
||||
{% elif tag.key == 'lon' %}
|
||||
{% set ns_map.lon = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Public Key + QR Code and Map Row -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
|
||||
<!-- Public Key and QR Code -->
|
||||
<!-- First/Last Seen and Location -->
|
||||
<div class="flex flex-wrap gap-x-8 gap-y-2 mt-4 text-sm">
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Public Key</h3>
|
||||
<code class="text-sm bg-base-200 p-2 rounded block break-all">{{ node.public_key }}</code>
|
||||
<div class="mt-4">
|
||||
<div id="qr-code" class="inline-block bg-white p-3 rounded"></div>
|
||||
<p class="text-xs opacity-50 mt-2">Scan to add as contact</p>
|
||||
</div>
|
||||
<span class="opacity-70">First seen:</span>
|
||||
{{ node.first_seen|localtime }}
|
||||
</div>
|
||||
|
||||
<!-- Location Map -->
|
||||
{% if ns_map.lat and ns_map.lon %}
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Location</h3>
|
||||
<div id="node-map" class="mb-2"></div>
|
||||
<div class="text-sm opacity-70">
|
||||
<p>Coordinates: {{ ns_map.lat }}, {{ ns_map.lon }}</p>
|
||||
</div>
|
||||
<span class="opacity-70">Last seen:</span>
|
||||
{{ node.last_seen|localtime }}
|
||||
</div>
|
||||
{% if has_coords %}
|
||||
<div>
|
||||
<span class="opacity-70">Location:</span>
|
||||
{{ ns_coords.lat }}, {{ ns_coords.lon }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Tags Section -->
|
||||
{% if node.tags or (admin_enabled and is_authenticated) %}
|
||||
<div class="mt-6">
|
||||
<h3 class="font-semibold opacity-70 mb-2">Tags</h3>
|
||||
{% if node.tags %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag in node.tags %}
|
||||
<tr>
|
||||
<td class="font-mono">{{ tag.key }}</td>
|
||||
<td>{{ tag.value }}</td>
|
||||
<td class="opacity-70">{{ tag.value_type or 'string' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm opacity-70 mb-2">No tags defined.</p>
|
||||
{% endif %}
|
||||
{% if admin_enabled and is_authenticated %}
|
||||
<div class="mt-3">
|
||||
<a href="/a/node-tags?public_key={{ node.public_key }}" class="btn btn-sm btn-outline">{% if node.tags %}Edit Tags{% else %}Add Tags{% endif %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -154,7 +142,7 @@
|
||||
<tbody>
|
||||
{% for adv in advertisements %}
|
||||
<tr>
|
||||
<td class="text-xs whitespace-nowrap">{{ adv.received_at[:19].replace('T', ' ') if adv.received_at else '-' }}</td>
|
||||
<td class="text-xs whitespace-nowrap">{{ adv.received_at|localtime }}</td>
|
||||
<td>
|
||||
{% if adv.adv_type and adv.adv_type|lower == 'chat' %}
|
||||
<span title="Chat">💬</span>
|
||||
@@ -193,52 +181,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Telemetry -->
|
||||
<!-- Tags -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Recent Telemetry</h2>
|
||||
{% if telemetry %}
|
||||
<h2 class="card-title">Tags</h2>
|
||||
{% if node.tags %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Data</th>
|
||||
<th>Received By</th>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tel in telemetry %}
|
||||
{% for tag in node.tags %}
|
||||
<tr>
|
||||
<td class="text-xs whitespace-nowrap">{{ tel.received_at[:19].replace('T', ' ') if tel.received_at else '-' }}</td>
|
||||
<td class="text-xs font-mono">
|
||||
{% if tel.parsed_data %}
|
||||
{{ tel.parsed_data | tojson }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if tel.received_by %}
|
||||
<a href="/nodes/{{ tel.received_by }}" class="link link-hover">
|
||||
{% if tel.receiver_tag_name or tel.receiver_name %}
|
||||
<div class="font-medium text-sm">{{ tel.receiver_tag_name or tel.receiver_name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ tel.received_by[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-xs">{{ tel.received_by[:16] }}...</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="font-mono">{{ tag.key }}</td>
|
||||
<td>{{ tag.value }}</td>
|
||||
<td class="opacity-70">{{ tag.value_type or 'string' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="opacity-70">No telemetry recorded.</p>
|
||||
<p class="opacity-70">No tags defined.</p>
|
||||
{% endif %}
|
||||
{% if admin_enabled and is_authenticated %}
|
||||
<div class="mt-3">
|
||||
<a href="/a/node-tags?public_key={{ node.public_key }}" class="btn btn-sm btn-outline">{% if node.tags %}Edit Tags{% else %}Add Tags{% endif %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,28 +239,39 @@
|
||||
window.qrCodeConfig = {
|
||||
name: {{ (ns_qr.tag_name or node.name or 'Node') | tojson }},
|
||||
publicKey: {{ node.public_key | tojson }},
|
||||
advType: {{ (node.adv_type or '') | tojson }}
|
||||
advType: {{ (node.adv_type or '') | tojson }},
|
||||
size: 140
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static', path='js/qrcode-init.js') }}"></script>
|
||||
|
||||
{% set ns_map = namespace(lat=none, lon=none, name=none) %}
|
||||
{# Get coordinates from node model first, then fall back to tags #}
|
||||
{% set ns_map = namespace(lat=node.lat, lon=node.lon, name=none) %}
|
||||
{% if not ns_map.lat or not ns_map.lon %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' and not ns_map.lat %}
|
||||
{% set ns_map.lat = tag.value|float %}
|
||||
{% elif tag.key == 'lon' and not ns_map.lon %}
|
||||
{% set ns_map.lon = tag.value|float %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' %}
|
||||
{% set ns_map.lat = tag.value %}
|
||||
{% elif tag.key == 'lon' %}
|
||||
{% set ns_map.lon = tag.value %}
|
||||
{% elif tag.key == 'name' %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns_map.name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if ns_map.lat and ns_map.lon %}
|
||||
<script>
|
||||
window.nodeMapConfig = {
|
||||
elementId: 'header-map',
|
||||
lat: {{ ns_map.lat }},
|
||||
lon: {{ ns_map.lon }},
|
||||
name: {{ (ns_map.name or node.name or 'Unnamed Node') | tojson }},
|
||||
type: {{ (node.adv_type or '') | tojson }}
|
||||
type: {{ (node.adv_type or '') | tojson }},
|
||||
interactive: false,
|
||||
zoom: 14,
|
||||
offsetX: 0.33
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static', path='js/map-node.js') }}"></script>
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Nodes</h1>
|
||||
<span class="badge badge-lg">{{ total }} total</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if timezone and timezone != 'UTC' %}<span class="text-sm opacity-60">{{ timezone }}</span>{% endif %}
|
||||
<span class="badge badge-lg">{{ total }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if api_error %}
|
||||
@@ -85,7 +88,7 @@
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-xs opacity-60">
|
||||
{% if node.last_seen %}
|
||||
{{ node.last_seen[:10] }}
|
||||
{{ node.last_seen|localdate }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
@@ -143,7 +146,7 @@
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{% if node.last_seen %}
|
||||
{{ node.last_seen[:19].replace('T', ' ') }}
|
||||
{{ node.last_seen|localtime }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user