From 57100d0a7be35d550ceb066b2a6e0f3578380572 Mon Sep 17 00:00:00 2001 From: Europium 63 Date: Wed, 28 Oct 2026 01:33:53 -0700 Subject: [PATCH] Inspector 0.9.3: all times follow the stack's TZ; docs explain why it matters --- CHANGELOG.md | 27 ++++++++++++++ INSTALL.md | 30 +++++++++++++++- README.md | 2 ++ compose.yml | 4 ++- src/inspector/__init__.py | 2 +- src/inspector/assets/guide/guide.html | 24 +++++++++++-- src/inspector/dashboard.py | 29 +++++++++------ src/inspector/formatting.py | 51 +++++++++++++++++++++++---- src/inspector/main.py | 8 ++--- src/inspector/recovery.py | 30 ++++++++-------- 10 files changed, 165 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9b0e85..a3397f9 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html --- +## [0.9.3] — 2026-10-28 + +### Changed + +- **Every time follows the `TZ` of the Inspector stack.** Before, the dashboard, + Events page and alerts were always in US Pacific time while the maintenance window + and logs used `TZ`, so anyone outside Pacific saw times hours off (e.g. a 02:00 + Berlin window shown as 5:00 PM). Now the dashboard, the Events page (including + where each day starts), alert times, cooldown times and logs all use `TZ`. +- Alert times include the zone abbreviation (`10-28 09:27 AM CET`), and "Cooldown + until" in alerts, event messages and logs is a local time instead of a UTC + timestamp. +- The active time zone is shown at the bottom of every Settings page, on the Events + page and on the Maintenance page. +- **If you never set `TZ`**, times are now shown in UTC instead of Pacific. Set `TZ` + in the stack (e.g. `America/New_York`). An unrecognized name logs a warning and + falls back to UTC. + +### Docs + +- INSTALL: `TZ` is now marked as needed, with a "Why the time zone matters" section + (maintenance window, alerts, dashboard, logs), how to find your zone name, and a + troubleshooting row. Guide: a Time zone section and a first-login check. Compose + and README updated. + +--- + ## [0.9.2] — 2026-10-28 Fixes from a code and security review. None of them change how containers are diff --git a/INSTALL.md b/INSTALL.md index 97ec467..b103eba 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -52,12 +52,39 @@ TZ=America/Los_Angeles |---|---|---| | `INSPECTOR_DATA` | Folder on your server where Inspector keeps its settings, logs, and history. **Unraid:** `/mnt/user/appdata/inspector`. **Other Linux:** `/opt/inspector`. Created automatically. | Yes | | `INSPECTOR_PASSWORD` | Password for the dashboard (username `admin`). Used only on first start; change it later in the dashboard. Leave it out and Inspector generates one and prints it in the container logs. | Recommended | -| `TZ` | Your [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones), so times on the dashboard are local. | Recommended | +| `TZ` | **Your time zone**, e.g. `America/New_York` or `Europe/Berlin`. Without it Inspector uses UTC, so the maintenance window, alerts and dashboard times are off by your UTC offset. See [Why the time zone matters](#why-the-time-zone-matters). | **Yes** (default `UTC`) | | `INSPECTOR_PORT` | Port on your server for the dashboard. Change it if `8080` is taken. | No (default `8080`) | | `INSPECTOR_TAG` | Version to run, e.g. `0.8.0`. See [Updating](#4-updating). | No (default `latest`) | | `PUID` / `PGID` | User and group that own Inspector's files. Unraid users often use `99` / `100` so the files match other appdata. | No (default `1000` / `1000`) | | `PORTAINER_API_TOKEN` | A Portainer access token. Only needed if you use **Portainer stack redeploy** for VPN-routed apps; see the in-app guide (Help → Portainer). | No | +### Why the time zone matters + +Inspector uses `TZ` for every time it shows or acts on. If it's wrong, things +still work, but at the wrong hour or with confusing times: + +- **The maintenance window runs in this time zone.** A window of `02:00–05:00` + with `TZ` left at UTC runs at 02:00 UTC, which is the middle of the evening in + the Americas or mid-morning in Asia. Inspector would then restart containers + during your nightly backups, and ignore real problems during the day. +- **Alerts** (Gotify / Pushover) show the time of the problem in this zone, with + its abbreviation, e.g. `10-28 03:15 AM CET`. +- **The dashboard and Events page** show times in this zone, and the Events + page's days run midnight to midnight in this zone. +- **Logs** are stamped in this zone. + +**How to set it:** use your zone's name from the +[list of time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) +("TZ identifier" column), e.g. `America/New_York`, `America/Chicago`, +`Europe/London`, `Europe/Berlin`, `Australia/Sydney`. Abbreviations like `EST` or +`CET` don't work. On Unraid, **Settings → Date and Time** shows the zone your +server uses. + +**How to check it:** the bottom of every Settings page shows +`time zone: `. If it says `UTC` and you're not on UTC, set `TZ` in the +stack and click **Update the stack**. If the name you set isn't recognized, +Inspector logs a warning and uses UTC. + ## 3. Deploy and sign in 1. Click **Deploy the stack**. @@ -116,6 +143,7 @@ starts and restarts the ones you add to it. | Logs say *Permission denied on /var/run/docker.sock* | Make sure the stack has no `user:` line, then update the stack. Inspector needs to start normally to set up socket access. | | Red **READ-ONLY** badge at the bottom of Settings | The config folder mount ends in `:ro` or the folder isn't writable. Remove `:ro` and update the stack. | | Can't sign in | Look for the generated password in the container logs. If you've lost it, open `INSPECTOR_DATA/config/config.yml` (e.g. via your Unraid share), set a new `password:` under `dashboard:`, and restart Inspector. | +| Times are a few hours off, or the maintenance window runs at the wrong time | `TZ` is missing or wrong. Check `time zone:` at the bottom of Settings, set `TZ` to your zone name (e.g. `Europe/London`), and update the stack. | | A container shows **CONTAINER NOT FOUND** | The name in Inspector doesn't match Docker. Copy the exact name from Portainer → Containers. | ## Security notes diff --git a/README.md b/README.md index 822b64c..ddc6d05 100755 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ restarts them when they break, and tells your phone about it.

- **Stays out of the way when you're working.** Put a container in maintenance or mute its alerts with one click, or set a nightly maintenance window for backups and updates. +- **Works in your time zone.** Set `TZ` in the stack and the maintenance window, + alerts, dashboard and logs all follow it. ## Install diff --git a/compose.yml b/compose.yml index dac2070..cb58bd1 100755 --- a/compose.yml +++ b/compose.yml @@ -7,7 +7,9 @@ # Unraid: /mnt/user/appdata/inspector Other: /opt/inspector # INSPECTOR_PASSWORD Dashboard password (username is "admin"). # Only used on first start; change it later in Settings. -# TZ Your timezone, e.g. America/Los_Angeles +# TZ Your time zone name, e.g. America/New_York, Europe/Berlin. +# Important: the maintenance window, alerts, dashboard and +# logs all use it. Left unset, everything is in UTC. # # Optional: # INSPECTOR_PORT Port for the dashboard on your server (default 8080). diff --git a/src/inspector/__init__.py b/src/inspector/__init__.py index a2fecb4..c598173 100755 --- a/src/inspector/__init__.py +++ b/src/inspector/__init__.py @@ -1 +1 @@ -__version__ = "0.9.2" +__version__ = "0.9.3" diff --git a/src/inspector/assets/guide/guide.html b/src/inspector/assets/guide/guide.html index c7e8ccd..14ac34b 100755 --- a/src/inspector/assets/guide/guide.html +++ b/src/inspector/assets/guide/guide.html @@ -103,6 +103,8 @@ dashboard; you never need to touch a config file.

Inspector dashboard login with the username and password.
  • Open http://<your-server>:8080 and sign in.
  • Change the password under Settings → Dashboard if you like.
  • +
  • Check the time zone: at the bottom of any Settings page it says + time zone: …. If that's not your zone, see Time zone.
  • @@ -392,13 +394,29 @@ maintenance. Use Prev day / Next day to look back.

    DashboardLogin username and password for this web UI. Event historyHow many days of events to keep. Health summaryA periodic digest (e.g. daily) of what happened, sent through your notification channels. - MaintenanceA daily quiet window (e.g. 02:00–05:00) so nightly backups or updates don't trigger restarts. Turn on maintenance_window on each container that should follow it. + MaintenanceA daily quiet window (e.g. 02:00–05:00, in your time zone) so nightly backups or updates don't trigger restarts. Turn on maintenance_window on each container that should follow it. Notification muteTurns the per-container Mute notifications buttons on or off, and sets how long a mute lasts. Uptime KumaConnection to your Uptime Kuma status page, used by container reachability checks. PortainerConnection to Portainer, used to redeploy stacks. See section 7. -

    At the bottom of every settings page is a strip showing where the config file lives and -whether Inspector can write to it.

    +

    At the bottom of every settings page is a strip showing where the config file lives, +whether Inspector can write to it, and which time zone it is using.

    + +

    Time zone

    +

    Inspector uses the TZ setting of its Portainer stack for every time it shows or acts on:

    + +

    To change it: in Portainer open Stacks → inspector → Editor, set the TZ +environment variable to your zone's name, e.g. America/New_York, +Europe/London or Australia/Sydney (names from the +list of time zones; +abbreviations like EST don't work), then click Update the stack. Without +TZ, Inspector uses UTC.

    diff --git a/src/inspector/dashboard.py b/src/inspector/dashboard.py index 2b8dcd1..0bc950e 100755 --- a/src/inspector/dashboard.py +++ b/src/inspector/dashboard.py @@ -30,7 +30,8 @@ from .config import ( valid_hhmm, write_config_raw, ) -from .formatting import PACIFIC, format_timestamp +from . import formatting +from .formatting import format_timestamp from .health import CONTAINER_NOT_FOUND, ERROR, HEALTHY, NOT_RUNNING, UNHEALTHY, UNREACHABLE from .maintenance import MaintenanceTracker from .notification_mute import NotificationMuteTracker @@ -180,20 +181,20 @@ def _status_snapshot( def _parse_date_param(value: str | None) -> date: - """Parse a ?date=YYYY-MM-DD query param; falls back to "today" in - America/Los_Angeles on missing/invalid input.""" + """Parse a ?date=YYYY-MM-DD query param; falls back to "today" in the + configured time zone on missing/invalid input.""" if value: try: return date.fromisoformat(value) except ValueError: pass - return datetime.now(PACIFIC).date() + return datetime.now(formatting.LOCAL_TZ).date() def _day_bounds_utc(day: date) -> tuple[datetime, datetime]: - """UTC [start, end) bounds for a Pacific calendar day, extended one hour + """UTC [start, end) bounds for a local calendar day, extended one hour into the previous day for the lookback-overlap.""" - midnight = datetime.combine(day, datetime.min.time(), tzinfo=PACIFIC) + midnight = datetime.combine(day, datetime.min.time(), tzinfo=formatting.LOCAL_TZ) start = (midnight - timedelta(hours=1)).astimezone(timezone.utc) end = (midnight + timedelta(days=1)).astimezone(timezone.utc) return start, end @@ -218,8 +219,8 @@ def _read_events_range(db_path: str, start: datetime, end: datetime) -> list[dic def _hour_markers(day: date) -> list[datetime]: - """24 Pacific-local hour boundaries for `day`, 00:00..23:00 ascending.""" - midnight = datetime.combine(day, datetime.min.time(), tzinfo=PACIFIC) + """24 local hour boundaries for `day`, 00:00..23:00 ascending.""" + midnight = datetime.combine(day, datetime.min.time(), tzinfo=formatting.LOCAL_TZ) return [midnight + timedelta(hours=h) for h in range(24)] @@ -239,7 +240,7 @@ def _group_events_by_hour( for ev in events: try: - ts = datetime.fromisoformat(ev["ts"]).astimezone(PACIFIC) + ts = datetime.fromisoformat(ev["ts"]).astimezone(formatting.LOCAL_TZ) except (ValueError, KeyError): continue if prev_hour_start <= ts < markers[0]: @@ -1111,12 +1112,15 @@ def _render_events_page( prev_day = (selected_day - timedelta(days=1)).isoformat() next_day = (selected_day + timedelta(days=1)).isoformat() - today = datetime.now(PACIFIC).date() + today = datetime.now(formatting.LOCAL_TZ).date() day_label = selected_day.strftime("%A, %B %-d, %Y") parts.append('
    \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, )