Inspector 0.9.3: all times follow the stack's TZ; docs explain why it matters
This commit is contained in:
1 parent
b4da527732
commit
0c69f17512
10 files changed
+165
-42
No files matched your search
@@ -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
|
## [0.9.2] — 2026-10-28
|
||||||
|
|
||||||
Fixes from a code and security review. None of them change how containers are
|
Fixes from a code and security review. None of them change how containers are
|
||||||
|
|||||||
+29
-1
@@ -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_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 |
|
| `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_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`) |
|
| `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`) |
|
| `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 |
|
| `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: <name>`. 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
|
## 3. Deploy and sign in
|
||||||
|
|
||||||
1. Click **Deploy the stack**.
|
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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| A container shows **CONTAINER NOT FOUND** | The name in Inspector doesn't match Docker. Copy the exact name from Portainer → Containers. |
|
||||||
|
|
||||||
## Security notes
|
## Security notes
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ restarts them when they break, and tells your phone about it.</p>
|
|||||||
- **Stays out of the way when you're working.** Put a container in maintenance or
|
- **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
|
mute its alerts with one click, or set a nightly maintenance window for backups
|
||||||
and updates.
|
and updates.
|
||||||
|
- **Works in your time zone.** Set `TZ` in the stack and the maintenance window,
|
||||||
|
alerts, dashboard and logs all follow it.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -7,7 +7,9 @@
|
|||||||
# Unraid: /mnt/user/appdata/inspector Other: /opt/inspector
|
# Unraid: /mnt/user/appdata/inspector Other: /opt/inspector
|
||||||
# INSPECTOR_PASSWORD Dashboard password (username is "admin").
|
# INSPECTOR_PASSWORD Dashboard password (username is "admin").
|
||||||
# Only used on first start; change it later in Settings.
|
# 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:
|
# Optional:
|
||||||
# INSPECTOR_PORT Port for the dashboard on your server (default 8080).
|
# INSPECTOR_PORT Port for the dashboard on your server (default 8080).
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.9.2"
|
__version__ = "0.9.3"
|
||||||
@@ -103,6 +103,8 @@ dashboard; you never need to touch a config file.</p>
|
|||||||
<i>Inspector dashboard login</i> with the username and password.</li>
|
<i>Inspector dashboard login</i> with the username and password.</li>
|
||||||
<li>Open <code>http://<your-server>:8080</code> and sign in.</li>
|
<li>Open <code>http://<your-server>:8080</code> and sign in.</li>
|
||||||
<li>Change the password under <b>Settings → Dashboard</b> if you like.</li>
|
<li>Change the password under <b>Settings → Dashboard</b> if you like.</li>
|
||||||
|
<li>Check the <b>time zone</b>: at the bottom of any Settings page it says
|
||||||
|
<code>time zone: …</code>. If that's not your zone, see <a href="#settings">Time zone</a>.</li>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -392,13 +394,29 @@ maintenance. Use <b>Prev day</b> / <b>Next day</b> to look back.</p>
|
|||||||
<tr><td>Dashboard</td><td>Login username and password for this web UI.</td></tr>
|
<tr><td>Dashboard</td><td>Login username and password for this web UI.</td></tr>
|
||||||
<tr><td>Event history</td><td>How many days of events to keep.</td></tr>
|
<tr><td>Event history</td><td>How many days of events to keep.</td></tr>
|
||||||
<tr><td>Health summary</td><td>A periodic digest (e.g. daily) of what happened, sent through your notification channels.</td></tr>
|
<tr><td>Health summary</td><td>A periodic digest (e.g. daily) of what happened, sent through your notification channels.</td></tr>
|
||||||
<tr><td>Maintenance</td><td>A daily quiet window (e.g. 02:00–05:00) so nightly backups or updates don't trigger restarts. Turn on <b>maintenance_window</b> on each container that should follow it.</td></tr>
|
<tr><td>Maintenance</td><td>A daily quiet window (e.g. 02:00–05:00, in your <a href="#settings">time zone</a>) so nightly backups or updates don't trigger restarts. Turn on <b>maintenance_window</b> on each container that should follow it.</td></tr>
|
||||||
<tr><td>Notification mute</td><td>Turns the per-container <b>Mute notifications</b> buttons on or off, and sets how long a mute lasts.</td></tr>
|
<tr><td>Notification mute</td><td>Turns the per-container <b>Mute notifications</b> buttons on or off, and sets how long a mute lasts.</td></tr>
|
||||||
<tr><td>Uptime Kuma</td><td>Connection to your Uptime Kuma status page, used by container reachability checks.</td></tr>
|
<tr><td>Uptime Kuma</td><td>Connection to your Uptime Kuma status page, used by container reachability checks.</td></tr>
|
||||||
<tr><td>Portainer</td><td>Connection to Portainer, used to redeploy stacks. See <a href="#portainer">section 7</a>.</td></tr>
|
<tr><td>Portainer</td><td>Connection to Portainer, used to redeploy stacks. See <a href="#portainer">section 7</a>.</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<p>At the bottom of every settings page is a strip showing where the config file lives and
|
<p>At the bottom of every settings page is a strip showing where the config file lives,
|
||||||
whether Inspector can write to it.</p>
|
whether Inspector can write to it, and which time zone it is using.</p>
|
||||||
|
|
||||||
|
<h3>Time zone</h3>
|
||||||
|
<p>Inspector uses the <code>TZ</code> setting of its Portainer stack for every time it shows or acts on:</p>
|
||||||
|
<ul>
|
||||||
|
<li><b>The maintenance window runs in this zone.</b> A 02:00–05:00 window with the wrong zone
|
||||||
|
runs hours off, so Inspector could restart containers during your backups and ignore
|
||||||
|
problems at other times.</li>
|
||||||
|
<li>Alert times (with the zone, e.g. <code>03:15 AM CET</code>), dashboard times, the Events
|
||||||
|
page's days (midnight to midnight), and logs.</li>
|
||||||
|
</ul>
|
||||||
|
<p>To change it: in Portainer open <b>Stacks → inspector → Editor</b>, set the <code>TZ</code>
|
||||||
|
environment variable to your zone's name, e.g. <code>America/New_York</code>,
|
||||||
|
<code>Europe/London</code> or <code>Australia/Sydney</code> (names from the
|
||||||
|
<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">list of time zones</a>;
|
||||||
|
abbreviations like <code>EST</code> don't work), then click <b>Update the stack</b>. Without
|
||||||
|
<code>TZ</code>, Inspector uses UTC.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="restart">
|
<section id="restart">
|
||||||
|
|||||||
+18
-11
@@ -30,7 +30,8 @@ from .config import (
|
|||||||
valid_hhmm,
|
valid_hhmm,
|
||||||
write_config_raw,
|
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 .health import CONTAINER_NOT_FOUND, ERROR, HEALTHY, NOT_RUNNING, UNHEALTHY, UNREACHABLE
|
||||||
from .maintenance import MaintenanceTracker
|
from .maintenance import MaintenanceTracker
|
||||||
from .notification_mute import NotificationMuteTracker
|
from .notification_mute import NotificationMuteTracker
|
||||||
@@ -180,20 +181,20 @@ def _status_snapshot(
|
|||||||
|
|
||||||
|
|
||||||
def _parse_date_param(value: str | None) -> date:
|
def _parse_date_param(value: str | None) -> date:
|
||||||
"""Parse a ?date=YYYY-MM-DD query param; falls back to "today" in
|
"""Parse a ?date=YYYY-MM-DD query param; falls back to "today" in the
|
||||||
America/Los_Angeles on missing/invalid input."""
|
configured time zone on missing/invalid input."""
|
||||||
if value:
|
if value:
|
||||||
try:
|
try:
|
||||||
return date.fromisoformat(value)
|
return date.fromisoformat(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
return datetime.now(PACIFIC).date()
|
return datetime.now(formatting.LOCAL_TZ).date()
|
||||||
|
|
||||||
|
|
||||||
def _day_bounds_utc(day: date) -> tuple[datetime, datetime]:
|
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."""
|
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)
|
start = (midnight - timedelta(hours=1)).astimezone(timezone.utc)
|
||||||
end = (midnight + timedelta(days=1)).astimezone(timezone.utc)
|
end = (midnight + timedelta(days=1)).astimezone(timezone.utc)
|
||||||
return start, end
|
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]:
|
def _hour_markers(day: date) -> list[datetime]:
|
||||||
"""24 Pacific-local hour boundaries for `day`, 00:00..23:00 ascending."""
|
"""24 local hour boundaries for `day`, 00:00..23:00 ascending."""
|
||||||
midnight = datetime.combine(day, datetime.min.time(), tzinfo=PACIFIC)
|
midnight = datetime.combine(day, datetime.min.time(), tzinfo=formatting.LOCAL_TZ)
|
||||||
return [midnight + timedelta(hours=h) for h in range(24)]
|
return [midnight + timedelta(hours=h) for h in range(24)]
|
||||||
|
|
||||||
|
|
||||||
@@ -239,7 +240,7 @@ def _group_events_by_hour(
|
|||||||
|
|
||||||
for ev in events:
|
for ev in events:
|
||||||
try:
|
try:
|
||||||
ts = datetime.fromisoformat(ev["ts"]).astimezone(PACIFIC)
|
ts = datetime.fromisoformat(ev["ts"]).astimezone(formatting.LOCAL_TZ)
|
||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
continue
|
continue
|
||||||
if prev_hour_start <= ts < markers[0]:
|
if prev_hour_start <= ts < markers[0]:
|
||||||
@@ -1111,12 +1112,15 @@ def _render_events_page(
|
|||||||
|
|
||||||
prev_day = (selected_day - timedelta(days=1)).isoformat()
|
prev_day = (selected_day - timedelta(days=1)).isoformat()
|
||||||
next_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")
|
day_label = selected_day.strftime("%A, %B %-d, %Y")
|
||||||
|
|
||||||
parts.append('<div class="events-nav">\n')
|
parts.append('<div class="events-nav">\n')
|
||||||
parts.append(f'<a class="day-nav" href="/events?date={prev_day}">← Prev day</a>\n')
|
parts.append(f'<a class="day-nav" href="/events?date={prev_day}">← Prev day</a>\n')
|
||||||
parts.append(f'<span class="day-label">{_escape(day_label)}</span>\n')
|
parts.append(
|
||||||
|
f'<span class="day-label">{_escape(day_label)}'
|
||||||
|
f' <span style="color:var(--muted); font-weight:400; font-size:0.8em;">· times in {_escape(formatting.LOCAL_TZ_NAME)}</span></span>\n'
|
||||||
|
)
|
||||||
if selected_day >= today:
|
if selected_day >= today:
|
||||||
parts.append('<span class="day-nav disabled">Next day →</span>\n')
|
parts.append('<span class="day-nav disabled">Next day →</span>\n')
|
||||||
else:
|
else:
|
||||||
@@ -2062,6 +2066,7 @@ def _render_diag_strip(config_ref: ConfigRef) -> str:
|
|||||||
return (
|
return (
|
||||||
'<div class="diag-strip">'
|
'<div class="diag-strip">'
|
||||||
f'<span>config: <code>{_escape(str(path))}</code></span>'
|
f'<span>config: <code>{_escape(str(path))}</code></span>'
|
||||||
|
f'<span>time zone: <code>{_escape(formatting.LOCAL_TZ_NAME)}</code></span>'
|
||||||
f"<span>{badge}{hint}</span>"
|
f"<span>{badge}{hint}</span>"
|
||||||
f'<span style="margin-left:auto;">Inspector v{__version__}</span>'
|
f'<span style="margin-left:auto;">Inspector v{__version__}</span>'
|
||||||
"</div>\n"
|
"</div>\n"
|
||||||
@@ -2201,6 +2206,8 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
|
|||||||
return (
|
return (
|
||||||
'<h3 class="pane-title">Scheduled maintenance window</h3>'
|
'<h3 class="pane-title">Scheduled maintenance window</h3>'
|
||||||
'<p class="pane-sub">Daily quiet hours. Containers that opt in (<code>maintenance_window: true</code> in their config) are not acted on during the window.</p>'
|
'<p class="pane-sub">Daily quiet hours. Containers that opt in (<code>maintenance_window: true</code> in their config) are not acted on during the window.</p>'
|
||||||
|
f'<p class="pane-sub" style="margin-top:-1rem;">Times are in <b>{_escape(formatting.LOCAL_TZ_NAME)}</b>, '
|
||||||
|
"from the <code>TZ</code> setting of the Inspector stack. If that's not your time zone, the window runs at the wrong hours.</p>"
|
||||||
+ _settings_form("maintenance", "Maintenance", body, csrf_token)
|
+ _settings_form("maintenance", "Maintenance", body, csrf_token)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,49 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
import logging
|
||||||
from zoneinfo import ZoneInfo
|
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:
|
def _resolve_local_tz(env: dict | None = None) -> tuple[tzinfo, str]:
|
||||||
"""Render a timestamp in US Pacific time, e.g. '06-14 03:45 PM'.
|
"""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 /
|
Accepts a datetime, an ISO 8601 string (as stored by EventStore /
|
||||||
ContainerState), or None/"" (returns ""). Naive datetimes/strings are
|
ContainerState), or None/"" (returns ""). Naive datetimes/strings are
|
||||||
@@ -24,4 +59,8 @@ def format_timestamp(value: datetime | str | None) -> str:
|
|||||||
return value
|
return value
|
||||||
if value.tzinfo is None:
|
if value.tzinfo is None:
|
||||||
value = value.replace(tzinfo=timezone.utc)
|
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
|
||||||
@@ -170,7 +170,7 @@ def _check_one(
|
|||||||
message=(
|
message=(
|
||||||
f"**{name}** is back to **HEALTHY** (was *{state.previous_status}*).\n\n"
|
f"**{name}** is back to **HEALTHY** (was *{state.previous_status}*).\n\n"
|
||||||
f"- Errors before recovery: **{state.error_count}**\n"
|
f"- Errors before recovery: **{state.error_count}**\n"
|
||||||
f"- Time: {format_timestamp(now)}"
|
f"- Time: {format_timestamp(now, with_zone=True)}"
|
||||||
),
|
),
|
||||||
priority=3,
|
priority=3,
|
||||||
)
|
)
|
||||||
@@ -196,7 +196,7 @@ def _check_one(
|
|||||||
f"**{name}** is missing from Docker.\n\n"
|
f"**{name}** is missing from Docker.\n\n"
|
||||||
+ (f"- Detail: {result.message}\n" if result.message else "")
|
+ (f"- Detail: {result.message}\n" if result.message else "")
|
||||||
+ f"- Last known status: *{state.previous_status or 'unknown'}*\n"
|
+ 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,
|
priority=7,
|
||||||
)
|
)
|
||||||
@@ -240,7 +240,7 @@ def _check_one(
|
|||||||
+ ".\n\n"
|
+ ".\n\n"
|
||||||
f"- Error count: **{state.error_count}/{container_config.max_error_count}**\n"
|
f"- Error count: **{state.error_count}/{container_config.max_error_count}**\n"
|
||||||
f"- Previous status: *{state.previous_status or 'unknown'}*\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,
|
priority=5,
|
||||||
)
|
)
|
||||||
@@ -276,7 +276,7 @@ def _check_one(
|
|||||||
f"**{container_config.max_restarts_per_hour}** restart(s)/hour.\n\n"
|
f"**{container_config.max_restarts_per_hour}** restart(s)/hour.\n\n"
|
||||||
f"- Current status: **{result.status}**\n"
|
f"- Current status: **{result.status}**\n"
|
||||||
f"- Restarts paused until the hourly window clears\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,
|
priority=9,
|
||||||
)
|
)
|
||||||
|
|||||||
+15
-15
@@ -228,7 +228,7 @@ def _perform_restart_then_redeploy(
|
|||||||
f"**{result.status}** check(s).\n\n"
|
f"**{result.status}** check(s).\n\n"
|
||||||
f"- If it doesn't recover, Inspector escalates to a Portainer stack redeploy\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"- Cooldown: **{container_config.cooldown_seconds}s** after restart\n"
|
||||||
f"- Time: {format_timestamp(now)}"
|
f"- Time: {format_timestamp(now, with_zone=True)}"
|
||||||
),
|
),
|
||||||
priority=6,
|
priority=6,
|
||||||
)
|
)
|
||||||
@@ -242,7 +242,7 @@ def _perform_restart_then_redeploy(
|
|||||||
state.restart_limit_notified = False
|
state.restart_limit_notified = False
|
||||||
log.info(
|
log.info(
|
||||||
"%-*s restart succeeded; cooldown until %s (will redeploy if still failing)",
|
"%-*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(
|
eventstore.record(
|
||||||
container=name,
|
container=name,
|
||||||
@@ -250,15 +250,15 @@ def _perform_restart_then_redeploy(
|
|||||||
old_status=state.previous_status,
|
old_status=state.previous_status,
|
||||||
new_status=result.status,
|
new_status=result.status,
|
||||||
error_count=state.error_count,
|
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(
|
notifier.notify(
|
||||||
title=f"✅ Inspector: {name} restart completed",
|
title=f"✅ Inspector: {name} restart completed",
|
||||||
message=(
|
message=(
|
||||||
f"**{name}** was successfully restarted by Docker.\n\n"
|
f"**{name}** was successfully restarted by Docker.\n\n"
|
||||||
f"- Previous status: *{result.status}*\n"
|
f"- Previous status: *{result.status}*\n"
|
||||||
f"- Cooldown until: {state.cooldown_until.isoformat()}\n"
|
f"- Cooldown until: {format_timestamp(state.cooldown_until, with_zone=True)}\n"
|
||||||
f"- Time: {format_timestamp(now)}"
|
f"- Time: {format_timestamp(now, with_zone=True)}"
|
||||||
),
|
),
|
||||||
priority=3,
|
priority=3,
|
||||||
)
|
)
|
||||||
@@ -321,7 +321,7 @@ def _perform_docker_action(
|
|||||||
f"{verb.capitalize()}ing **{name}** after **{state.error_count}** consecutive "
|
f"{verb.capitalize()}ing **{name}** after **{state.error_count}** consecutive "
|
||||||
f"**{result.status}** check(s).\n\n"
|
f"**{result.status}** check(s).\n\n"
|
||||||
f"- Cooldown: **{container_config.cooldown_seconds}s** after {verb}\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,
|
priority=6,
|
||||||
)
|
)
|
||||||
@@ -335,22 +335,22 @@ def _perform_docker_action(
|
|||||||
state.record_restart(now, container_config.cooldown_seconds)
|
state.record_restart(now, container_config.cooldown_seconds)
|
||||||
state.last_notification_state = "RESTARTED"
|
state.last_notification_state = "RESTARTED"
|
||||||
state.restart_limit_notified = False
|
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(
|
eventstore.record(
|
||||||
container=name,
|
container=name,
|
||||||
event_type=EVENT_RESTART,
|
event_type=EVENT_RESTART,
|
||||||
old_status=state.previous_status,
|
old_status=state.previous_status,
|
||||||
new_status=result.status,
|
new_status=result.status,
|
||||||
error_count=state.error_count,
|
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(
|
notifier.notify(
|
||||||
title=f"✅ Inspector: {name} {verb} completed",
|
title=f"✅ Inspector: {name} {verb} completed",
|
||||||
message=(
|
message=(
|
||||||
f"**{name}** was successfully {verb}ed by Docker.\n\n"
|
f"**{name}** was successfully {verb}ed by Docker.\n\n"
|
||||||
f"- Previous status: *{result.status}*\n"
|
f"- Previous status: *{result.status}*\n"
|
||||||
f"- Cooldown until: {state.cooldown_until.isoformat()}\n"
|
f"- Cooldown until: {format_timestamp(state.cooldown_until, with_zone=True)}\n"
|
||||||
f"- Time: {format_timestamp(now)}"
|
f"- Time: {format_timestamp(now, with_zone=True)}"
|
||||||
),
|
),
|
||||||
priority=3,
|
priority=3,
|
||||||
)
|
)
|
||||||
@@ -362,7 +362,7 @@ def _perform_docker_action(
|
|||||||
f"{verb.capitalize()} of **{name}** failed.\n\n"
|
f"{verb.capitalize()} of **{name}** failed.\n\n"
|
||||||
f"- Status: **{result.status}**\n"
|
f"- Status: **{result.status}**\n"
|
||||||
f"- Error count: **{state.error_count}**\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```"
|
f"```\n{err}\n```"
|
||||||
),
|
),
|
||||||
priority=9,
|
priority=9,
|
||||||
@@ -469,7 +469,7 @@ def _perform_portainer_redeploy(
|
|||||||
f"Redeploying Portainer stack **{stack_name}** (environment **{environment}**) "
|
f"Redeploying Portainer stack **{stack_name}** (environment **{environment}**) "
|
||||||
f"to recover **{name}**.\n\n"
|
f"to recover **{name}**.\n\n"
|
||||||
f"- Status: **{result.status}**\n"
|
f"- Status: **{result.status}**\n"
|
||||||
f"- Time: {format_timestamp(now)}"
|
f"- Time: {format_timestamp(now, with_zone=True)}"
|
||||||
),
|
),
|
||||||
priority=6,
|
priority=6,
|
||||||
)
|
)
|
||||||
@@ -483,7 +483,7 @@ def _perform_portainer_redeploy(
|
|||||||
message=(
|
message=(
|
||||||
f"Portainer redeploy of **{stack_name}** (environment **{environment}**) failed.\n\n"
|
f"Portainer redeploy of **{stack_name}** (environment **{environment}**) failed.\n\n"
|
||||||
f"- Triggered by: **{name}** ({result.status})\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```"
|
f"```\n{redeploy_result.message}\n```"
|
||||||
),
|
),
|
||||||
priority=9,
|
priority=9,
|
||||||
@@ -516,7 +516,7 @@ def _perform_portainer_redeploy(
|
|||||||
f"successfully redeployed to recover **{name}**.\n\n"
|
f"successfully redeployed to recover **{name}**.\n\n"
|
||||||
f"- Triggered by: *{result.status}*\n"
|
f"- Triggered by: *{result.status}*\n"
|
||||||
f"- Post-redeploy wait: {wait_seconds}s\n"
|
f"- Post-redeploy wait: {wait_seconds}s\n"
|
||||||
f"- Time: {format_timestamp(now)}"
|
f"- Time: {format_timestamp(now, with_zone=True)}"
|
||||||
),
|
),
|
||||||
priority=4,
|
priority=4,
|
||||||
)
|
)
|
||||||
@@ -536,7 +536,7 @@ def _perform_portainer_redeploy(
|
|||||||
f"Portainer stack **{stack_name}** was redeployed, but the HTTP recheck "
|
f"Portainer stack **{stack_name}** was redeployed, but the HTTP recheck "
|
||||||
f"for **{name}** still reports a failure.\n\n"
|
f"for **{name}** still reports a failure.\n\n"
|
||||||
f"- Recheck result: *{recheck.message}*\n"
|
f"- Recheck result: *{recheck.message}*\n"
|
||||||
f"- Time: {format_timestamp(now)}"
|
f"- Time: {format_timestamp(now, with_zone=True)}"
|
||||||
),
|
),
|
||||||
priority=6,
|
priority=6,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user