Compare commits

..

13 Commits
v0.6.7 ... main

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

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

Timestamps remain stored as UTC; only display is converted.

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 23:50:44 +00:00
Louis King
0e6cbc8094 Optimised GitHub CI workflow triggers 2026-02-08 23:40:35 +00:00
JingleManSweep
76630f0bb0 Merge pull request #84 from ipnet-mesh/chore/youtube-link
Add NETWORK_CONTACT_YOUTUBE config for footer link
2026-02-08 23:39:35 +00:00
Louis King
8fbac2cbd6 Add NETWORK_CONTACT_YOUTUBE config for footer link
Add YouTube channel URL configuration option alongside existing
GitHub/Discord/Email contact links. Also crop logo SVG to content
bounds and pass YouTube env var through docker-compose.
2026-02-08 23:36:40 +00:00
Louis King
fcac5e01dc Use network welcome text for SEO meta description
Meta description now uses NETWORK_WELCOME_TEXT prefixed with network
name for better SEO, falling back to generic message if not set.
2026-02-08 23:21:17 +00:00
Louis King
b6f3b2d864 Redesign node detail page with hero map header
- Add hero panel with non-interactive map background when GPS coords exist
- Fix coordinate detection: check node model fields before falling back to tags
- Move node name to standard page header above hero panel
- QR code displayed in hero panel (right side, 140px)
- Map pans to show node at 1/3 horizontal position (avoiding QR overlap)
- Replace Telemetry section with Tags card in grid layout
- Consolidate First Seen, Last Seen, Location into single row
- Add configurable offset support to map-node.js (offsetX, offsetY)
- Add configurable size support to qrcode-init.js
2026-02-08 23:16:13 +00:00
22 changed files with 407 additions and 193 deletions

View File

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

View File

@@ -1,8 +1,6 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -88,6 +88,13 @@ import click
envvar="NETWORK_CONTACT_GITHUB",
help="GitHub repository URL",
)
@click.option(
"--network-contact-youtube",
type=str,
default=None,
envvar="NETWORK_CONTACT_YOUTUBE",
help="YouTube channel URL",
)
@click.option(
"--network-welcome-text",
type=str,
@@ -116,6 +123,7 @@ def web(
network_contact_email: str | None,
network_contact_discord: str | None,
network_contact_github: str | None,
network_contact_youtube: str | None,
network_welcome_text: str | None,
reload: bool,
) -> None:
@@ -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,
)

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 553 B

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

View File

@@ -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: '&copy; <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);
})();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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' %}

View File

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

View File

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