Merge pull request #86 from ipnet-mesh/feat/timezones

Add timezone support for web dashboard date/time display
This commit is contained in:
JingleManSweep
2026-02-09 00:38:43 +00:00
committed by GitHub
12 changed files with 98 additions and 13 deletions

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

@@ -262,6 +262,7 @@ services:
- 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,

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 %Z",
) -> 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 %Z") -> 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 %Z"),
"localdate": format_date,
"localtimeonly": format_time,
"localtimeonly_short": lambda v: format_time(v, "%H:%M %Z"),
}
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan handler."""
@@ -137,6 +208,13 @@ 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
app.state.templates = templates
# Initialize page loader for custom markdown pages
@@ -320,4 +398,5 @@ def get_network_context(request: Request) -> dict:
"custom_pages": custom_pages,
"logo_url": request.app.state.logo_url,
"version": __version__,
"timezone": request.app.state.timezone,
}

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

@@ -86,7 +86,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 +133,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

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

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

@@ -62,7 +62,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 +105,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

@@ -108,11 +108,11 @@
<div class="flex flex-wrap gap-x-8 gap-y-2 mt-4 text-sm">
<div>
<span class="opacity-70">First seen:</span>
{{ node.first_seen[:19].replace('T', ' ') if node.first_seen else '-' }}
{{ node.first_seen|localtime }}
</div>
<div>
<span class="opacity-70">Last seen:</span>
{{ node.last_seen[:19].replace('T', ' ') if node.last_seen else '-' }}
{{ node.last_seen|localtime }}
</div>
{% if has_coords %}
<div>
@@ -142,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>

View File

@@ -85,7 +85,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 +143,7 @@
</td>
<td class="text-sm whitespace-nowrap">
{% if node.last_seen %}
{{ node.last_seen[:19].replace('T', ' ') }}
{{ node.last_seen|localtime }}
{% else %}
-
{% endif %}