\n')
parts.append(f'
← Prev day\n')
- parts.append(f'
{_escape(day_label)}\n')
+ parts.append(
+ f'
{_escape(day_label)}'
+ f' · times in {_escape(formatting.LOCAL_TZ_NAME)}\n'
+ )
if selected_day >= today:
parts.append('
Next day →\n')
else:
@@ -2062,6 +2066,7 @@ def _render_diag_strip(config_ref: ConfigRef) -> str:
return (
'
'
f'config: {_escape(str(path))}'
+ f'time zone: {_escape(formatting.LOCAL_TZ_NAME)}'
f"{badge}{hint}"
f'Inspector v{__version__}'
"
\n"
@@ -2201,6 +2206,8 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
return (
'
Scheduled maintenance window
'
'
Daily quiet hours. Containers that opt in (maintenance_window: true in their config) are not acted on during the window.
'
+ f'
Times are in {_escape(formatting.LOCAL_TZ_NAME)}, '
+ "from the TZ setting of the Inspector stack. If that's not your time zone, the window runs at the wrong hours.
"
+ _settings_form("maintenance", "Maintenance", body, csrf_token)
)
diff --git a/src/inspector/formatting.py b/src/inspector/formatting.py
index c4655ae..d8e4757 100755
--- a/src/inspector/formatting.py
+++ b/src/inspector/formatting.py
@@ -1,14 +1,49 @@
from __future__ import annotations
-from datetime import datetime, timezone
-from zoneinfo import ZoneInfo
+import logging
+import os
+from datetime import datetime, timezone, tzinfo
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
-PACIFIC = ZoneInfo("America/Los_Angeles")
+log = logging.getLogger("inspector.formatting")
-def format_timestamp(value: datetime | str | None) -> str:
- """Render a timestamp in US Pacific time, e.g. '06-14 03:45 PM'.
+def _resolve_local_tz(env: dict | None = None) -> tuple[tzinfo, str]:
+ """The zone every displayed time uses, plus its name.
+
+ Follows the same rules as the C library (which drives log timestamps and
+ the maintenance window), so all times Inspector shows agree:
+ TZ set to a zone name -> that zone; TZ unset -> the container's
+ /etc/localtime; anything else -> UTC.
+ """
+ env = os.environ if env is None else env
+ name = (env.get("TZ") or "").strip().lstrip(":")
+ if name:
+ try:
+ return ZoneInfo(name), name
+ except (ZoneInfoNotFoundError, ValueError):
+ log.warning(
+ "TZ=%r is not a known time zone name (e.g. Europe/Berlin); showing times in UTC",
+ name,
+ )
+ return timezone.utc, "UTC"
+ try:
+ with open("/etc/localtime", "rb") as fh:
+ zone = ZoneInfo.from_file(fh, key="localtime")
+ label = datetime.now(zone).tzname() or "localtime"
+ return zone, label
+ except (OSError, ValueError):
+ return timezone.utc, "UTC"
+
+
+# Resolved once at import; the container's TZ doesn't change while it runs.
+LOCAL_TZ, LOCAL_TZ_NAME = _resolve_local_tz()
+
+
+def format_timestamp(value: datetime | str | None, *, with_zone: bool = False) -> str:
+ """Render a timestamp in the configured local zone, e.g. '06-14 03:45 PM'
+ (with_zone adds the abbreviation: '06-14 03:45 PM CET').
Accepts a datetime, an ISO 8601 string (as stored by EventStore /
ContainerState), or None/"" (returns ""). Naive datetimes/strings are
@@ -24,4 +59,8 @@ def format_timestamp(value: datetime | str | None) -> str:
return value
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
- return value.astimezone(PACIFIC).strftime("%m-%d %I:%M %p")
+ local = value.astimezone(LOCAL_TZ)
+ text = local.strftime("%m-%d %I:%M %p")
+ if with_zone:
+ text += f" {local.tzname()}"
+ return text
diff --git a/src/inspector/main.py b/src/inspector/main.py
index eab5044..1a53dfe 100755
--- a/src/inspector/main.py
+++ b/src/inspector/main.py
@@ -170,7 +170,7 @@ def _check_one(
message=(
f"**{name}** is back to **HEALTHY** (was *{state.previous_status}*).\n\n"
f"- Errors before recovery: **{state.error_count}**\n"
- f"- Time: {format_timestamp(now)}"
+ f"- Time: {format_timestamp(now, with_zone=True)}"
),
priority=3,
)
@@ -196,7 +196,7 @@ def _check_one(
f"**{name}** is missing from Docker.\n\n"
+ (f"- Detail: {result.message}\n" if result.message else "")
+ f"- Last known status: *{state.previous_status or 'unknown'}*\n"
- f"- Time: {format_timestamp(now)}"
+ f"- Time: {format_timestamp(now, with_zone=True)}"
),
priority=7,
)
@@ -240,7 +240,7 @@ def _check_one(
+ ".\n\n"
f"- Error count: **{state.error_count}/{container_config.max_error_count}**\n"
f"- Previous status: *{state.previous_status or 'unknown'}*\n"
- f"- Time: {format_timestamp(now)}"
+ f"- Time: {format_timestamp(now, with_zone=True)}"
),
priority=5,
)
@@ -276,7 +276,7 @@ def _check_one(
f"**{container_config.max_restarts_per_hour}** restart(s)/hour.\n\n"
f"- Current status: **{result.status}**\n"
f"- Restarts paused until the hourly window clears\n"
- f"- Time: {format_timestamp(now)}"
+ f"- Time: {format_timestamp(now, with_zone=True)}"
),
priority=9,
)
diff --git a/src/inspector/recovery.py b/src/inspector/recovery.py
index a66f58b..71045d0 100755
--- a/src/inspector/recovery.py
+++ b/src/inspector/recovery.py
@@ -228,7 +228,7 @@ def _perform_restart_then_redeploy(
f"**{result.status}** check(s).\n\n"
f"- If it doesn't recover, Inspector escalates to a Portainer stack redeploy\n"
f"- Cooldown: **{container_config.cooldown_seconds}s** after restart\n"
- f"- Time: {format_timestamp(now)}"
+ f"- Time: {format_timestamp(now, with_zone=True)}"
),
priority=6,
)
@@ -242,7 +242,7 @@ def _perform_restart_then_redeploy(
state.restart_limit_notified = False
log.info(
"%-*s restart succeeded; cooldown until %s (will redeploy if still failing)",
- width, name, state.cooldown_until.isoformat(),
+ width, name, format_timestamp(state.cooldown_until, with_zone=True),
)
eventstore.record(
container=name,
@@ -250,15 +250,15 @@ def _perform_restart_then_redeploy(
old_status=state.previous_status,
new_status=result.status,
error_count=state.error_count,
- message=f"restart succeeded; cooldown until {state.cooldown_until.isoformat()}",
+ message=f"restart succeeded; cooldown until {format_timestamp(state.cooldown_until, with_zone=True)}",
)
notifier.notify(
title=f"✅ Inspector: {name} restart completed",
message=(
f"**{name}** was successfully restarted by Docker.\n\n"
f"- Previous status: *{result.status}*\n"
- f"- Cooldown until: {state.cooldown_until.isoformat()}\n"
- f"- Time: {format_timestamp(now)}"
+ f"- Cooldown until: {format_timestamp(state.cooldown_until, with_zone=True)}\n"
+ f"- Time: {format_timestamp(now, with_zone=True)}"
),
priority=3,
)
@@ -321,7 +321,7 @@ def _perform_docker_action(
f"{verb.capitalize()}ing **{name}** after **{state.error_count}** consecutive "
f"**{result.status}** check(s).\n\n"
f"- Cooldown: **{container_config.cooldown_seconds}s** after {verb}\n"
- f"- Time: {format_timestamp(now)}"
+ f"- Time: {format_timestamp(now, with_zone=True)}"
),
priority=6,
)
@@ -335,22 +335,22 @@ def _perform_docker_action(
state.record_restart(now, container_config.cooldown_seconds)
state.last_notification_state = "RESTARTED"
state.restart_limit_notified = False
- log.info("%-*s %s succeeded; cooldown until %s", width, name, verb, state.cooldown_until.isoformat())
+ log.info("%-*s %s succeeded; cooldown until %s", width, name, verb, format_timestamp(state.cooldown_until, with_zone=True))
eventstore.record(
container=name,
event_type=EVENT_RESTART,
old_status=state.previous_status,
new_status=result.status,
error_count=state.error_count,
- message=f"{verb} succeeded; cooldown until {state.cooldown_until.isoformat()}",
+ message=f"{verb} succeeded; cooldown until {format_timestamp(state.cooldown_until, with_zone=True)}",
)
notifier.notify(
title=f"✅ Inspector: {name} {verb} completed",
message=(
f"**{name}** was successfully {verb}ed by Docker.\n\n"
f"- Previous status: *{result.status}*\n"
- f"- Cooldown until: {state.cooldown_until.isoformat()}\n"
- f"- Time: {format_timestamp(now)}"
+ f"- Cooldown until: {format_timestamp(state.cooldown_until, with_zone=True)}\n"
+ f"- Time: {format_timestamp(now, with_zone=True)}"
),
priority=3,
)
@@ -362,7 +362,7 @@ def _perform_docker_action(
f"{verb.capitalize()} of **{name}** failed.\n\n"
f"- Status: **{result.status}**\n"
f"- Error count: **{state.error_count}**\n"
- f"- Time: {format_timestamp(now)}\n\n"
+ f"- Time: {format_timestamp(now, with_zone=True)}\n\n"
f"```\n{err}\n```"
),
priority=9,
@@ -469,7 +469,7 @@ def _perform_portainer_redeploy(
f"Redeploying Portainer stack **{stack_name}** (environment **{environment}**) "
f"to recover **{name}**.\n\n"
f"- Status: **{result.status}**\n"
- f"- Time: {format_timestamp(now)}"
+ f"- Time: {format_timestamp(now, with_zone=True)}"
),
priority=6,
)
@@ -483,7 +483,7 @@ def _perform_portainer_redeploy(
message=(
f"Portainer redeploy of **{stack_name}** (environment **{environment}**) failed.\n\n"
f"- Triggered by: **{name}** ({result.status})\n"
- f"- Time: {format_timestamp(now)}\n\n"
+ f"- Time: {format_timestamp(now, with_zone=True)}\n\n"
f"```\n{redeploy_result.message}\n```"
),
priority=9,
@@ -516,7 +516,7 @@ def _perform_portainer_redeploy(
f"successfully redeployed to recover **{name}**.\n\n"
f"- Triggered by: *{result.status}*\n"
f"- Post-redeploy wait: {wait_seconds}s\n"
- f"- Time: {format_timestamp(now)}"
+ f"- Time: {format_timestamp(now, with_zone=True)}"
),
priority=4,
)
@@ -536,7 +536,7 @@ def _perform_portainer_redeploy(
f"Portainer stack **{stack_name}** was redeployed, but the HTTP recheck "
f"for **{name}** still reports a failure.\n\n"
f"- Recheck result: *{recheck.message}*\n"
- f"- Time: {format_timestamp(now)}"
+ f"- Time: {format_timestamp(now, with_zone=True)}"
),
priority=6,
)