diff --git a/AGENTS.md b/AGENTS.md index 762f36c..4d50a0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 560be11..a01a1b8 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/docker-compose.yml b/docker-compose.yml index 50ca483..ef0ad3d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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')"] diff --git a/src/meshcore_hub/common/config.py b/src/meshcore_hub/common/config.py index 926ccd4..2babf38 100644 --- a/src/meshcore_hub/common/config.py +++ b/src/meshcore_hub/common/config.py @@ -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, diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py index be779ab..669c6a6 100644 --- a/src/meshcore_hub/web/app.py +++ b/src/meshcore_hub/web/app.py @@ -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, } diff --git a/src/meshcore_hub/web/templates/admin/node_tags.html b/src/meshcore_hub/web/templates/admin/node_tags.html index 5d8b7da..4886e7f 100644 --- a/src/meshcore_hub/web/templates/admin/node_tags.html +++ b/src/meshcore_hub/web/templates/admin/node_tags.html @@ -104,7 +104,7 @@ {{ tag.value_type }} - {{ tag.updated_at[:10] if tag.updated_at else '-' }} + {{ tag.updated_at|localdate }}
- {{ ad.received_at[:16].replace('T', ' ') if ad.received_at else '-' }} + {{ ad.received_at|localtime_short }}
{% if ad.receivers and ad.receivers|length >= 1 %}
@@ -133,7 +133,7 @@ - {{ ad.received_at[:19].replace('T', ' ') if ad.received_at else '-' }} + {{ ad.received_at|localtime }} {% if ad.receivers and ad.receivers|length >= 1 %} diff --git a/src/meshcore_hub/web/templates/dashboard.html b/src/meshcore_hub/web/templates/dashboard.html index 37d7e8e..27de636 100644 --- a/src/meshcore_hub/web/templates/dashboard.html +++ b/src/meshcore_hub/web/templates/dashboard.html @@ -136,7 +136,7 @@ - {% endif %} - {{ ad.received_at.split('T')[1][:8] if ad.received_at else '-' }} + {{ ad.received_at|localtimeonly }} {% endfor %} @@ -166,7 +166,7 @@
{% for msg in messages %}
- {{ msg.received_at.split('T')[1][:5] if msg.received_at else '' }} + {{ msg.received_at|localtimeonly_short }} {{ msg.text }}
{% endfor %} diff --git a/src/meshcore_hub/web/templates/members.html b/src/meshcore_hub/web/templates/members.html index b503a3f..593e896 100644 --- a/src/meshcore_hub/web/templates/members.html +++ b/src/meshcore_hub/web/templates/members.html @@ -60,7 +60,7 @@ {% endif %}
{% if node.last_seen %} - + {% endif %} {% endfor %} diff --git a/src/meshcore_hub/web/templates/messages.html b/src/meshcore_hub/web/templates/messages.html index 34fa8d5..afd74ef 100644 --- a/src/meshcore_hub/web/templates/messages.html +++ b/src/meshcore_hub/web/templates/messages.html @@ -62,7 +62,7 @@ {% endif %}
- {{ msg.received_at[:16].replace('T', ' ') if msg.received_at else '-' }} + {{ msg.received_at|localtime_short }}
@@ -105,7 +105,7 @@ {% if msg.message_type == 'channel' %}📻{% else %}👤{% endif %} - {{ msg.received_at[:19].replace('T', ' ') if msg.received_at else '-' }} + {{ msg.received_at|localtime }} {% if msg.message_type == 'channel' %} diff --git a/src/meshcore_hub/web/templates/node_detail.html b/src/meshcore_hub/web/templates/node_detail.html index e015649..3a2269b 100644 --- a/src/meshcore_hub/web/templates/node_detail.html +++ b/src/meshcore_hub/web/templates/node_detail.html @@ -108,11 +108,11 @@
First seen: - {{ node.first_seen[:19].replace('T', ' ') if node.first_seen else '-' }} + {{ node.first_seen|localtime }}
Last seen: - {{ node.last_seen[:19].replace('T', ' ') if node.last_seen else '-' }} + {{ node.last_seen|localtime }}
{% if has_coords %}
@@ -142,7 +142,7 @@ {% for adv in advertisements %} - {{ adv.received_at[:19].replace('T', ' ') if adv.received_at else '-' }} + {{ adv.received_at|localtime }} {% if adv.adv_type and adv.adv_type|lower == 'chat' %} 💬 diff --git a/src/meshcore_hub/web/templates/nodes.html b/src/meshcore_hub/web/templates/nodes.html index 6e48b1b..b415715 100644 --- a/src/meshcore_hub/web/templates/nodes.html +++ b/src/meshcore_hub/web/templates/nodes.html @@ -85,7 +85,7 @@
{% if node.last_seen %} - {{ node.last_seen[:10] }} + {{ node.last_seen|localdate }} {% else %} - {% endif %} @@ -143,7 +143,7 @@ {% if node.last_seen %} - {{ node.last_seen[:19].replace('T', ' ') }} + {{ node.last_seen|localtime }} {% else %} - {% endif %}