From 48e913e5d5524a5203f36b721346e7ef7e013af7 Mon Sep 17 00:00:00 2001 From: Europium 63 Date: Mon, 26 Oct 2026 21:14:13 -0700 Subject: [PATCH] =?UTF-8?q?Release=20v0.7.1=20=E2=80=94=20Settings=20page?= =?UTF-8?q?=20redesign=20+=20/containers=20route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings page now uses a left-rail + content-pane layout. Each section is URL-addressable (/settings/inspector, /settings/notifications, ...). The rail shows a live subtitle under each item so state is readable without clicking in. - Gotify and Pushover grouped under a single Notifications section with one Save per channel. - Containers moved to a dedicated top-level route: /containers lists tiles; /containers/ is the edit form. Containers tab added to the main nav. - Saved banner is a prominent green flash with a success check. --- CHANGELOG.md | 42 +++ src/inspector/__init__.py | 2 +- src/inspector/dashboard.py | 573 ++++++++++++++++++++++++++++--------- 3 files changed, 484 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31df90e..4f6ae1c 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,52 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html --- +## [0.7.1] — 2026-10-27 + +### Changed + +- **Settings page redesigned** to a classic SaaS layout (left rail + content pane): + - Left rail groups sections under Core / Data / Scheduling with a live subtitle + under each item (e.g. "60s · dry-run on", "gotify on · pushover off", + "on · :8080") so you can read state without clicking in. + - Each section is URL-addressable: `/settings/inspector`, + `/settings/notifications`, `/settings/dashboard`, `/settings/database`, + `/settings/health_summary`, `/settings/maintenance`, `/settings/notification_mute`. + - Gotify and Pushover now share a single **Notifications** section with + one Save button per channel, so the fan-out model is visible at a glance. + - Save flash is a prominent green banner with a success check. +- **Containers moved to a dedicated top-level route** (`/containers`): a grid + of tiles showing status, enabled, max errors, cooldown, and restart_on at a + glance. Click a tile for the per-container edit form at `/containers/`. + Keeps the Settings page small when buddies have many containers. +- Top navigation adds a **Containers** tab alongside Status / Events / Settings. + +### Internal + +- `POST /settings/gotify` and `POST /settings/pushover` now redirect back to + `/settings/notifications` (the grouped page). +- `POST /containers/` added as a parallel entry point to the existing + settings backend for container edits. + +--- + ## [0.7.0] — 2026-10-26 ### Added +- **Public packaging** under `euronvault/inspector` on Gitea with a + dedicated image at `gitea.euronvault.com/euronvault/inspector:0.7.0` + (plus `:latest` and `:sha-` tags). Dev continues on + `eugene/inspector`. +- `scripts/sync_public.sh` with an explicit allowlist + (`scripts/public_allowlist.txt`) and grep-based denylist + (`scripts/public_denylist_patterns.txt`) that refuses to publish any + file matching a personal-string pattern (lab IPs, lab container + names, lab env var names, agent-workflow scaffolding paths). +- Sanitized `public/` staging tree: generic README, INSTALL guide, + MIT LICENSE (Copyright (c) 2026 Europium 63), `config.example.yml` + with two neutral example containers, and a `compose.yml` that pulls + from `gitea.euronvault.com/euronvault/inspector`. - **Web settings page** at `/settings` for editing `config.yml` without SSH: per-section forms for Inspector loop, Gotify, Pushover, Dashboard, Database, Health summary, Maintenance, Notification mute, and per-container diff --git a/src/inspector/__init__.py b/src/inspector/__init__.py index 49e0fc1..a5f830a 100755 --- a/src/inspector/__init__.py +++ b/src/inspector/__init__.py @@ -1 +1 @@ -__version__ = "0.7.0" +__version__ = "0.7.1" diff --git a/src/inspector/dashboard.py b/src/inspector/dashboard.py index 7edd4e4..e15b5fd 100755 --- a/src/inspector/dashboard.py +++ b/src/inspector/dashboard.py @@ -520,27 +520,73 @@ a.signout:hover { color: var(--ink); border-color: var(--muted); } .phase-observe { color: var(--muted); font-style: italic; } .phase-maintenance { color: var(--violet); } .phase-timer { font-variant-numeric: tabular-nums; min-width: 3.5ch; } -/* Settings */ -.settings-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); } -.settings-card { +/* Settings — Design A: left rail + content pane */ +.settings-layout { + display: grid; grid-template-columns: 240px 1fr; gap: 1.25rem; background: var(--card); border: 1px solid var(--border); border-radius: 14px; - padding: 1rem 1.25rem; + overflow: hidden; min-height: 540px; } -.settings-card h3 { margin: 0 0 0.75rem; font-size: 0.95rem; color: var(--ink); } -.settings-card .field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.75rem; } -.settings-card .field-row { flex-direction: row; align-items: center; gap: 0.5rem; } -.settings-card label { font-size: 0.78rem; color: var(--muted); font-weight: 600; } -.settings-card input[type=text], .settings-card input[type=number], .settings-card input[type=password] { +.settings-rail { padding: 1rem 0.5rem 1.25rem 1rem; border-right: 1px solid var(--border); } +.settings-rail .rail-group { + text-transform: uppercase; font-size: 0.66rem; letter-spacing: 0.08em; + color: var(--muted); margin: 0.9rem 0.7rem 0.3rem; +} +.settings-rail a { + display: flex; align-items: flex-start; gap: 0.55rem; + padding: 0.5rem 0.7rem; border-radius: 8px; color: #cbd5e1; + text-decoration: none; font-size: 0.84rem; font-weight: 500; + margin-bottom: 0.1rem; +} +.settings-rail a .rail-ico { + flex-shrink: 0; width: 1.1rem; display: inline-block; text-align: center; + font-size: 0.95rem; line-height: 1.2; +} +.settings-rail a .rail-text { flex: 1; min-width: 0; } +.settings-rail a .rail-text .rail-sub { + display: block; font-size: 0.7rem; color: var(--muted); font-weight: 400; + margin-top: 0.1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.settings-rail a:hover:not(.active) { background: #0f1829; color: var(--ink); } +.settings-rail a.active { + background: rgba(96,165,250,0.12); color: var(--blue); font-weight: 600; +} +.settings-rail a.active .rail-sub { color: var(--muted); } +.settings-pane { padding: 1.5rem 1.75rem; } +.settings-pane h3.pane-title { margin: 0 0 0.25rem; font-size: 1.02rem; } +.settings-pane p.pane-sub { color: var(--muted); margin: 0 0 1.5rem; font-size: 0.82rem; } +.settings-pane .subsection { + margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border); +} +.settings-pane .subsection:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; } +.settings-pane .subsection h4 { + margin: 0 0 0.3rem; font-size: 0.9rem; color: var(--ink); + display: flex; align-items: center; gap: 0.5rem; +} +.settings-pane .subsection .subsection-sub { + margin: 0 0 1rem; color: var(--muted); font-size: 0.78rem; +} +.settings-pane .field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.9rem; } +.settings-pane .field.field-row { flex-direction: row; align-items: center; gap: 0.6rem; } +.settings-pane .field label { font-size: 0.76rem; color: #94a3b8; font-weight: 600; } +.settings-pane .field.field-row label { font-size: 0.85rem; color: #cbd5e1; } +.settings-pane .field input[type=text], .settings-pane .field input[type=number], +.settings-pane .field input[type=password] { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; - padding: 0.4rem 0.6rem; color: var(--ink); font-size: 0.85rem; font-family: inherit; outline: none; + padding: 0.5rem 0.65rem; color: var(--ink); font-size: 0.88rem; font-family: inherit; outline: none; +} +.settings-pane .field input:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(96,165,250,0.15); } +.settings-pane .field-hint { font-size: 0.72rem; color: var(--muted); } +.settings-pane .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.1rem 1rem; } +@media (max-width: 640px) { .settings-pane .grid-2 { grid-template-columns: 1fr; } } +.settings-pane .form-actions { + display: flex; justify-content: flex-end; gap: 0.5rem; + margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); } -.settings-card input:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(96,165,250,0.15); } -.settings-card .field-hint { font-size: 0.72rem; color: var(--muted); } .restart-flag { - display: inline-block; margin-left: 0.4rem; font-size: 0.65rem; font-weight: 700; + display: inline-block; margin-left: 0.4rem; font-size: 0.63rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; background: var(--violet-bg); color: var(--violet); - padding: 0.1rem 0.4rem; border-radius: 999px; + padding: 0.1rem 0.45rem; border-radius: 999px; } .pill-row { display: flex; gap: 0.3rem; flex-wrap: wrap; } .pill { @@ -549,9 +595,39 @@ a.signout:hover { color: var(--ink); border-color: var(--muted); } font-size: 0.72rem; color: var(--ink); } .pill input { accent-color: var(--blue); } -.flash { padding: 0.6rem 0.85rem; border-radius: 10px; margin-bottom: 1rem; font-size: 0.85rem; } -.flash-ok { background: var(--green-bg); color: var(--green); border: 1px solid var(--green); } +.flash { + display: flex; align-items: center; gap: 0.5rem; + padding: 0.6rem 0.85rem; border-radius: 10px; margin-bottom: 1rem; + font-size: 0.85rem; font-weight: 600; +} +.flash-ok { + background: var(--green-bg); color: var(--green); + border: 1px solid rgba(52,211,153,0.4); +} +.flash-ok::before { content: "✓"; font-weight: 800; } .flash-error { background: var(--red-bg); color: var(--red); border: 1px solid var(--red); } +.flash-note { margin-left: auto; color: var(--muted); font-weight: 400; font-size: 0.78rem; } + +/* Container list page */ +.container-grid { + display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; +} +.container-tile { + background: var(--card); border: 1px solid var(--border); + border-color: color-mix(in srgb, var(--col) 28%, var(--border)); + border-radius: 12px; padding: 0.95rem 1.1rem; + text-decoration: none; color: var(--ink); display: block; + transition: border-color 0.15s; +} +.container-tile:hover { border-color: var(--blue); } +.container-tile .row1 { + display: flex; align-items: center; justify-content: space-between; + gap: 0.5rem; margin-bottom: 0.5rem; +} +.container-tile .ctn-name { font-weight: 700; font-size: 1rem; color: var(--col); } +.container-tile .ctn-meta { font-size: 0.75rem; color: var(--muted); display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; } +.container-tile .ctn-meta b { color: var(--ink); font-weight: 600; } +.container-tile .ctn-chev { color: var(--muted); font-size: 1rem; } """ @@ -559,6 +635,7 @@ def _page_head(refresh: bool, active: str) -> str: refresh_meta = '\n' if refresh else "" status_class = "active" if active == "status" else "" events_class = "active" if active == "events" else "" + containers_class = "active" if active == "containers" else "" settings_class = "active" if active == "settings" else "" meta_text = f"v{__version__}" if refresh: @@ -591,6 +668,7 @@ def _page_head(refresh: bool, active: str) -> str: '
\n' f' Status\n' f' Events\n' + f' Containers\n' f' Settings\n' "
\n" f'
{meta_text}Sign out
\n' @@ -1094,139 +1172,322 @@ def _settings_secret_field(label: str, name: str, has_value: bool) -> str: ) -def _render_settings_page(app: AppConfig, csrf_token: str, flash: str) -> bytes: - def _form(section: str, title: str, body: str) -> str: - return ( - f'

{_escape(title)}

' - f'
' - f'' - f'{body}' - f'' - f"
\n" - ) +# Each tuple: (slug, label, icon, subtitle_fn, group). +# Groups show up as uppercase headers in the left rail. +def _subtitle_inspector(app: AppConfig) -> str: + return f"{app.inspector.interval_seconds}s · dry-run {'on' if app.inspector.dry_run else 'off'}" - parts = [_page_head(refresh=False, active="settings")] - if flash: - if flash.startswith("ok:"): - parts.append( - f'
Saved: {_escape(flash[3:])}. ' - "Fields marked requires restart take effect after Inspector restarts.
\n" - ) - elif flash.startswith("error:"): - parts.append(f'
{_escape(flash[6:])}
\n') +def _subtitle_notifications(app: AppConfig) -> str: + g = "on" if app.gotify.enabled else "off" + p = "on" if app.pushover.enabled else "off" + return f"gotify {g} · pushover {p}" - parts.append('

Settings

\n') - parts.append('
\n') - # Inspector - insp = app.inspector - body = ( - _settings_field("interval_seconds", "interval_seconds", str(insp.interval_seconds), kind="number") - + _settings_field("log_level", "log_level", insp.log_level, hint="DEBUG, INFO, WARNING, ERROR") - + _settings_field("dry_run", "dry_run", "on" if insp.dry_run else "", kind="checkbox") - + _settings_field("heartbeat", "heartbeat", "on" if insp.heartbeat else "", kind="checkbox") - + _settings_field("log_retention_days", "log_retention_days", str(insp.log_retention_days), kind="number", requires_restart=True) - ) - parts.append(_form("inspector", "Inspector", body)) +def _subtitle_dashboard(app: AppConfig) -> str: + return f"{'on' if app.dashboard.enabled else 'off'} · :{app.dashboard.port}" - # Gotify - g = app.gotify - body = ( - _settings_field("enabled", "enabled", "on" if g.enabled else "", kind="checkbox", requires_restart=True) - + _settings_field("url", "url", g.url, requires_restart=True) - + _settings_secret_field("token", "token", bool(g.token)) - + _settings_field("priority", "priority", str(g.priority), kind="number") - ) - parts.append(_form("gotify", "Gotify notifications", body)) - # Pushover - p = app.pushover - body = ( - _settings_field("enabled", "enabled", "on" if p.enabled else "", kind="checkbox", requires_restart=True) - + _settings_secret_field("token", "token", bool(p.token)) - + _settings_secret_field("user_key", "user_key", bool(p.user_key)) - + _settings_field("priority", "priority", str(p.priority), kind="number") - + _settings_field("device", "device", p.device, hint="optional; blank = all devices") - + _settings_field("sound", "sound", p.sound, hint="optional; blank = user default") - ) - parts.append(_form("pushover", "Pushover notifications", body)) +def _subtitle_database(app: AppConfig) -> str: + return f"{'on' if app.database.enabled else 'off'} · {app.database.retention_days}d retention" - # Dashboard - d = app.dashboard - body = ( - _settings_field("enabled", "enabled", "on" if d.enabled else "", kind="checkbox", requires_restart=True) - + _settings_field("username", "username", d.username) - + _settings_secret_field("password", "password", bool(d.password)) - ) - parts.append(_form("dashboard", "Dashboard", body)) - # Database - db = app.database - body = ( - _settings_field("enabled", "enabled", "on" if db.enabled else "", kind="checkbox", requires_restart=True) - + _settings_field("retention_days", "retention_days", str(db.retention_days), kind="number", requires_restart=True) - ) - parts.append(_form("database", "Event history (SQLite)", body)) +def _subtitle_health_summary(app: AppConfig) -> str: + if not app.health_summary.enabled: + return "off" + return f"on · every {app.health_summary.interval_hours}h" - # Health summary - hs = app.health_summary - body = ( - _settings_field("enabled", "enabled", "on" if hs.enabled else "", kind="checkbox", requires_restart=True) - + _settings_field("interval_hours", "interval_hours", str(hs.interval_hours), kind="number", requires_restart=True) - + _settings_field("priority", "priority", str(hs.priority), kind="number") - ) - parts.append(_form("health_summary", "Health summary digest", body)) - # Maintenance window +def _subtitle_maintenance(app: AppConfig) -> str: m = app.maintenance - body = ( - _settings_field("enabled", "enabled", "on" if m.enabled else "", kind="checkbox", requires_restart=True) - + _settings_field("start", "start", m.start, hint="HH:MM, 24h") - + _settings_field("end", "end", m.end, hint="HH:MM, 24h") - + _settings_field("max_hours", "max_hours", str(m.max_hours), kind="number") - ) - parts.append(_form("maintenance", "Scheduled maintenance window", body)) + if not m.enabled: + return "off" + return f"on · {m.start}–{m.end}" - # Notification mute + +def _subtitle_notification_mute(app: AppConfig) -> str: nm = app.notification_mute - body = ( - _settings_field("enabled", "enabled", "on" if nm.enabled else "", kind="checkbox", requires_restart=True) - + _settings_field("default_hours", "default_hours", str(nm.default_hours), kind="number") + return f"{'on' if nm.enabled else 'off'} · {nm.default_hours}h default" + + +_SETTINGS_SECTIONS = [ + ("inspector", "Inspector loop", "⚙", _subtitle_inspector, "Core"), + ("notifications", "Notifications", "🔔", _subtitle_notifications, "Core"), + ("dashboard", "Dashboard", "🖥", _subtitle_dashboard, "Core"), + ("database", "Event history", "🗄", _subtitle_database, "Data"), + ("health_summary", "Health summary", "📋", _subtitle_health_summary, "Data"), + ("maintenance", "Maintenance", "🔧", _subtitle_maintenance, "Scheduling"), + ("notification_mute", "Notification mute","🔇", _subtitle_notification_mute, "Scheduling"), +] +_SETTINGS_SECTION_SLUGS = {slug for slug, *_ in _SETTINGS_SECTIONS} + + +def _render_settings_rail(app: AppConfig, active_slug: str) -> str: + parts: list[str] = ['\n") + return "".join(parts) + + +def _settings_form(section: str, title: str, body: str, csrf_token: str) -> str: + return ( + f'
' + f'' + f"{body}" + '
' + f'
' + "
\n" ) - parts.append(_form("notification_mute", "Notification mute", body)) - # Containers (edit of existing only; add/remove requires YAML edit + restart) - for c in app.containers: - restart_on_opts = ["HEALTHY", "UNHEALTHY", "STARTING", "NO HEALTH CHECK", - "NOT RUNNING", "CONTAINER NOT FOUND", "ERROR", "UNKNOWN", "UNREACHABLE"] - checkboxes = [] - for s in restart_on_opts: - checked = "checked" if s in c.restart_on else "" - checkboxes.append( - f'' - ) - restart_on_html = ( - '
' - f'
{"".join(checkboxes)}
' - ) + +def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str: + if section == "inspector": + insp = app.inspector body = ( - _settings_field("enabled", "enabled", "on" if c.enabled else "", kind="checkbox") - + _settings_field("max_error_count", "max_error_count", str(c.max_error_count), kind="number") - + _settings_field("cooldown_seconds", "cooldown_seconds", str(c.cooldown_seconds), kind="number") - + _settings_field("grace_period_seconds", "grace_period_seconds", str(c.grace_period_seconds), kind="number") - + _settings_field("max_restarts_per_hour", "max_restarts_per_hour", str(c.max_restarts_per_hour), kind="number") - + _settings_field("color", "color", c.color, hint="hex, e.g. #60a5fa; blank = auto") - + restart_on_html + _settings_field("interval_seconds", "interval_seconds", str(insp.interval_seconds), kind="number") + + _settings_field("log_level", "log_level", insp.log_level, hint="DEBUG, INFO, WARNING, ERROR") + + _settings_field("dry_run", "dry_run", "on" if insp.dry_run else "", kind="checkbox") + + _settings_field("heartbeat", "heartbeat", "on" if insp.heartbeat else "", kind="checkbox") + + _settings_field("log_retention_days", "log_retention_days", str(insp.log_retention_days), kind="number", requires_restart=True) + ) + return ( + '

Inspector loop

' + '

How often Inspector polls, how it logs, and whether it actually restarts anything.

' + + _settings_form("inspector", "Inspector", body, csrf_token) ) - parts.append(_form(f"container:{c.name}", f"Container — {c.name}", body)) - parts.append("
\n") + if section == "notifications": + g, p = app.gotify, app.pushover + gotify_body = ( + _settings_field("enabled", "enabled", "on" if g.enabled else "", kind="checkbox", requires_restart=True) + + _settings_field("url", "url", g.url, hint="Base URL of your Gotify server", requires_restart=True) + + _settings_secret_field("token", "token", bool(g.token)) + + _settings_field("priority", "priority", str(g.priority), kind="number") + ) + pushover_body = ( + _settings_field("enabled", "enabled", "on" if p.enabled else "", kind="checkbox", requires_restart=True) + + _settings_secret_field("token", "token", bool(p.token)) + + _settings_secret_field("user_key", "user_key", bool(p.user_key)) + + _settings_field("priority", "priority", str(p.priority), kind="number", hint="Mapped at send time to Pushover's -2..2 range") + + _settings_field("device", "device", p.device, hint="Optional; blank = all devices") + + _settings_field("sound", "sound", p.sound, hint="Optional; blank = user default") + ) + gotify_pill = 'enabled' if g.enabled else 'disabled' + pushover_pill = 'enabled' if p.enabled else 'disabled' + return ( + '

Notifications

' + '

Enable one or both channels. If both are enabled, every alert fans out to both.

' + f'

Gotify {gotify_pill}

' + '

Push notifications via your Gotify server. Messages render as Markdown.

' + + _settings_form("gotify", "Gotify", gotify_body, csrf_token) + + "
" + f'

Pushover {pushover_pill}

' + '

Pushover is independent of Gotify. Inspector priority is mapped to Pushover\'s -2..2 range: ' + '≤2 → -1, 3-4 → 0, 5-6 → 1, ≥7 → 2.

' + + _settings_form("pushover", "Pushover", pushover_body, csrf_token) + + "
" + ) + + if section == "dashboard": + d = app.dashboard + body = ( + _settings_field("enabled", "enabled", "on" if d.enabled else "", kind="checkbox", requires_restart=True) + + _settings_field("username", "username", d.username) + + _settings_secret_field("password", "password", bool(d.password)) + ) + return ( + '

Dashboard

' + '

This web UI. Host and port are bound at startup — changes require a container restart.

' + + _settings_form("dashboard", "Dashboard", body, csrf_token) + ) + + if section == "database": + db = app.database + body = ( + _settings_field("enabled", "enabled", "on" if db.enabled else "", kind="checkbox", requires_restart=True) + + _settings_field("retention_days", "retention_days", str(db.retention_days), kind="number", requires_restart=True) + ) + return ( + '

Event history

' + '

SQLite log of status changes, restarts, and recoveries. Powers the Events page and the health summary digest.

' + + _settings_form("database", "Event history", body, csrf_token) + ) + + if section == "health_summary": + hs = app.health_summary + body = ( + _settings_field("enabled", "enabled", "on" if hs.enabled else "", kind="checkbox", requires_restart=True) + + _settings_field("interval_hours", "interval_hours", str(hs.interval_hours), kind="number", requires_restart=True) + + _settings_field("priority", "priority", str(hs.priority), kind="number") + ) + return ( + '

Health summary digest

' + '

Periodic rollup of per-container activity, delivered through the enabled notification channels. Requires Event history enabled to have data.

' + + _settings_form("health_summary", "Health summary", body, csrf_token) + ) + + if section == "maintenance": + m = app.maintenance + body = ( + _settings_field("enabled", "enabled", "on" if m.enabled else "", kind="checkbox", requires_restart=True) + + _settings_field("start", "start", m.start, hint="HH:MM, 24h") + + _settings_field("end", "end", m.end, hint="HH:MM, 24h") + + _settings_field("max_hours", "max_hours", str(m.max_hours), kind="number", hint="Safety cap for manual maintenance toggles") + ) + return ( + '

Scheduled maintenance window

' + '

Daily quiet hours. Containers that opt in (maintenance_window: true in their config) are not acted on during the window.

' + + _settings_form("maintenance", "Maintenance", body, csrf_token) + ) + + if section == "notification_mute": + nm = app.notification_mute + body = ( + _settings_field("enabled", "enabled", "on" if nm.enabled else "", kind="checkbox", requires_restart=True) + + _settings_field("default_hours", "default_hours", str(nm.default_hours), kind="number", hint="How long a manual mute lasts by default") + ) + return ( + '

Notification mute

' + '

Lets you mute alerts for a specific container from the Status page. The toggle itself appears on container cards when this is enabled.

' + + _settings_form("notification_mute", "Notification mute", body, csrf_token) + ) + + return f"

Unknown section: {_escape(section)}

" + + +def _render_flash(flash: str) -> str: + if not flash: + return "" + if flash.startswith("ok:"): + label = _escape(flash[3:]) + return ( + f'
Saved — {label} changes applied.' + 'Fields marked “requires restart” take effect after Inspector restarts.' + "
\n" + ) + if flash.startswith("error:"): + return f'
{_escape(flash[6:])}
\n' + return "" + + +def _render_settings_page(app: AppConfig, csrf_token: str, flash: str, section: str) -> bytes: + if section not in _SETTINGS_SECTION_SLUGS: + section = "inspector" + parts = [_page_head(refresh=False, active="settings")] + parts.append(_render_flash(flash)) + parts.append('
') + parts.append(_render_settings_rail(app, section)) + parts.append('
') + parts.append(_settings_body_for(section, app, csrf_token)) + parts.append("
\n") + parts.append(_PAGE_TAIL) + return "".join(parts).encode("utf-8") + + +def _render_containers_page(app: AppConfig, states: dict, flash: str) -> bytes: + parts = [_page_head(refresh=False, active="containers")] + parts.append(_render_flash(flash)) + parts.append('

Containers

\n') parts.append( - '

To add or remove a container, ' + '

' + "Click a container to edit its restart rules. To add or remove a container, " "edit config.yml and restart Inspector.

\n" ) + parts.append('
\n') + containers = list(app.containers) + for i, c in enumerate(containers): + color = _resolve_color(c, i) + state = states.get(c.name) + status = state.current_status if state and state.current_status else "—" + restart_on = ", ".join(sorted(c.restart_on)) if c.restart_on else "observe only" + parts.append( + f'' + f'
{_escape(c.name)}' + f'›
' + '
' + f'Status: {_escape(status)}' + f'Enabled: {"yes" if c.enabled else "no"}' + f'Max errors: {c.max_error_count}' + f'Cooldown: {c.cooldown_seconds}s' + f'Restart on: {_escape(restart_on)}' + "
\n" + ) + parts.append("
\n") + parts.append(_PAGE_TAIL) + return "".join(parts).encode("utf-8") + + +def _render_container_edit_page(app: AppConfig, name: str, csrf_token: str, flash: str) -> bytes | None: + cfg = next((c for c in app.containers if c.name == name), None) + if cfg is None: + return None + + restart_on_opts = ["HEALTHY", "UNHEALTHY", "STARTING", "NO HEALTH CHECK", + "NOT RUNNING", "CONTAINER NOT FOUND", "ERROR", "UNKNOWN", "UNREACHABLE"] + checkboxes: list[str] = [] + for s in restart_on_opts: + checked = "checked" if s in cfg.restart_on else "" + checkboxes.append( + f'' + ) + restart_on_html = ( + '
' + f'
{"".join(checkboxes)}
' + '
Statuses that trigger a restart. Leave all off for observe-only.
' + "
" + ) + body = ( + _settings_field("enabled", "enabled", "on" if cfg.enabled else "", kind="checkbox") + + _settings_field("max_error_count", "max_error_count", str(cfg.max_error_count), kind="number", + hint="Consecutive failing checks before a restart is attempted") + + _settings_field("cooldown_seconds", "cooldown_seconds", str(cfg.cooldown_seconds), kind="number", + hint="Minimum seconds between restarts") + + _settings_field("grace_period_seconds", "grace_period_seconds", str(cfg.grace_period_seconds), kind="number", + hint="Ignore failures for this long after a restart (lets the container boot)") + + _settings_field("max_restarts_per_hour", "max_restarts_per_hour", str(cfg.max_restarts_per_hour), kind="number", + hint="Hourly cap — Inspector pauses restarts after this many") + + _settings_field("color", "color", cfg.color, hint="Hex, e.g. #60a5fa. Blank uses the auto palette.") + + restart_on_html + ) + form_html = ( + f'
' + f'' + f"{body}" + '
' + 'Back' + '
" + "
\n" + ) + + parts = [_page_head(refresh=False, active="containers")] + parts.append(_render_flash(flash)) + parts.append( + f'

Container — {_escape(name)}

\n' + ) + parts.append('
') + parts.append(form_html) + parts.append("
\n") parts.append(_PAGE_TAIL) return "".join(parts).encode("utf-8") @@ -1404,10 +1665,31 @@ class _Handler(BaseHTTPRequestHandler): events = [] body = json.dumps(events).encode("utf-8") self._send_bytes(HTTPStatus.OK, "application/json", body) - elif path == "/settings": + elif path == "/settings" or path.startswith("/settings/"): + # /settings redirects to the first section so the URL is always addressable. + if path == "/settings": + self.send_response(HTTPStatus.SEE_OTHER) + self.send_header("Location", "/settings/inspector") + self.send_header("Content-Length", "0") + self.end_headers() + return + section = path[len("/settings/"):].strip("/") csrf = self._csrf_token_for_session() flash = query.get("flash", [""])[0] - body = _render_settings_page(app, csrf, flash) + body = _render_settings_page(app, csrf, flash, section) + self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body) + elif path == "/containers": + flash = query.get("flash", [""])[0] + body = _render_containers_page(app, server.states, flash) + self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body) + elif path.startswith("/containers/"): + name = path[len("/containers/"):].strip("/") + csrf = self._csrf_token_for_session() + flash = query.get("flash", [""])[0] + body = _render_container_edit_page(app, name, csrf, flash) + if body is None: + self._send_bytes(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"unknown container") + return self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body) else: self._send_bytes(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"not found") @@ -1518,17 +1800,44 @@ class _Handler(BaseHTTPRequestHandler): if len(parts) == 2 and parts[0] == "settings": section = parts[1] + # Where to send the browser after save. Notifications are + # Gotify+Pushover grouped under /settings/notifications; everything + # else matches the section slug. + if section in ("gotify", "pushover"): + return_path = "/settings/notifications" + else: + return_path = f"/settings/{section}" try: _apply_settings_update(server.config_ref, section, form) except _SettingsError as exc: flash = f"error:{exc}" self.send_response(HTTPStatus.SEE_OTHER) - self.send_header("Location", f"/settings?flash={_url_quote(flash)}") + self.send_header("Location", f"{return_path}?flash={_url_quote(flash)}") self.send_header("Content-Length", "0") self.end_headers() return self.send_response(HTTPStatus.SEE_OTHER) - self.send_header("Location", f"/settings?flash={_url_quote('ok:' + section)}") + self.send_header("Location", f"{return_path}?flash={_url_quote('ok:' + section)}") + self.send_header("Content-Length", "0") + self.end_headers() + return + + if len(parts) == 2 and parts[0] == "containers": + name = parts[1] + if name not in containers: + self._send_bytes(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"unknown container") + return + try: + _apply_settings_update(server.config_ref, f"container:{name}", form) + except _SettingsError as exc: + flash = f"error:{exc}" + self.send_response(HTTPStatus.SEE_OTHER) + self.send_header("Location", f"/containers/{_url_quote(name)}?flash={_url_quote(flash)}") + self.send_header("Content-Length", "0") + self.end_headers() + return + self.send_response(HTTPStatus.SEE_OTHER) + self.send_header("Location", f"/containers/{_url_quote(name)}?flash={_url_quote('ok:' + name)}") self.send_header("Content-Length", "0") self.end_headers() return