From c6d2a67a4b81e58ce498ee99110c90ebbbcc4e9b Mon Sep 17 00:00:00 2001 From: Europium 63 Date: Tue, 27 Oct 2026 10:30:07 -0700 Subject: [PATCH] =?UTF-8?q?Release=20v0.7.2=20=E2=80=94=20Settings=20polis?= =?UTF-8?q?h=20+=20container=20CRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Containers can now be added, edited, and removed from the UI (/containers/new and the delete button on /containers/). No more manual config.yml edits for normal container work. - Test-notification buttons on Gotify and Pushover sections. Uses live form values and surfaces HTTP errors. - Toggle switches replace enabled checkboxes. - Priority reference block with Pushover mapping. - Config diagnostics strip (path + writable badge + version) on every settings page — catches read-only bind mounts. - Containers listed in the settings left rail. - Color input: 10 swatches + native color picker + hex text. - Status page no longer prints the "auto-refreshes" text. --- CHANGELOG.md | 44 +++ src/inspector/__init__.py | 2 +- src/inspector/dashboard.py | 611 ++++++++++++++++++++++++++++++++++--- 3 files changed, 611 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f6ae1c..ba9fd42 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,50 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html --- +## [0.7.2] — 2026-10-27 + +### Added + +- **Container CRUD from the UI.** `/containers/new` adds a new container to + `config.yml`; a delete button on `/containers/` removes one. The config + change is picked up on the next polling cycle without a restart. +- **Test-notification buttons** on the Gotify and Pushover sub-sections. The + button posts the current (unsaved) form values to `POST /api/test-notify/` + and shows the result inline. Unlike the regular notifier it does not swallow + errors — HTTP failures are reported back to the UI. +- **Priority reference** block on the Notifications section, including the + Inspector → Pushover `-2..2` mapping. +- **Config diagnostics strip** at the bottom of every settings page: shows the + live `config.yml` path, whether it's writable, and the Inspector version. If + the bind mount is read-only (a common deployment misconfiguration — a `:ro` + mount means saves fail silently) a red `READ-ONLY` badge appears with a hint. +- **Containers listed in the settings left rail** under their own group, plus a + "+ Add container" link, so you can jump between sections and containers from + the same navigation. +- **Color input upgrade** on container edit / new forms: ten preset color swatches, + a native color picker, and the existing hex text field, all bound together. + +### Changed + +- **Toggle switches** replace the stock checkbox + "requires restart" layout for + all `enabled`-style fields (notifications, dashboard, database, health summary, + maintenance, notification mute, container enabled, Inspector `dry_run` / + `heartbeat`). Reads much cleaner. +- **Status page** no longer prints "auto-refreshes every 30s" (the `` + still ticks every 30s). + +### Internal + +- `_apply_container_create()` + `_apply_container_delete()` helpers alongside the + existing `_apply_settings_update()`; container names are validated against + `^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$` and rejected if duplicate. +- `_render_diag_strip()` uses `os.access(path, os.W_OK)` to detect read-only mounts. +- Test-notification bypasses `GotifyNotifier.notify()` / `PushoverNotifier.notify()` + so HTTP failures are surfaced (the real notifier intentionally swallows them to + avoid breaking the inspector loop). + +--- + ## [0.7.1] — 2026-10-27 ### Changed diff --git a/src/inspector/__init__.py b/src/inspector/__init__.py index a5f830a..bc8c296 100755 --- a/src/inspector/__init__.py +++ b/src/inspector/__init__.py @@ -1 +1 @@ -__version__ = "0.7.1" +__version__ = "0.7.2" diff --git a/src/inspector/dashboard.py b/src/inspector/dashboard.py index e15b5fd..4cc8ffc 100755 --- a/src/inspector/dashboard.py +++ b/src/inspector/dashboard.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import logging +import os import re import secrets import sqlite3 @@ -628,6 +629,98 @@ a.signout:hover { color: var(--ink); border-color: var(--muted); } .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; } + +/* Toggle switch */ +.toggle { + position: relative; display: inline-block; width: 36px; height: 20px; + background: #273449; border-radius: 999px; cursor: pointer; flex-shrink: 0; + transition: background 0.15s; vertical-align: middle; +} +.toggle::after { + content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; + background: #fff; border-radius: 50%; transition: transform 0.15s; +} +.toggle input { display: none; } +.toggle.on { background: var(--blue); } +.toggle.on::after { transform: translateX(16px); } +.toggle-row { + display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.9rem; +} +.toggle-row .toggle-label { font-size: 0.9rem; color: var(--ink); font-weight: 500; } + +/* Priority reference box */ +.priority-ref { + background: #0f1829; border: 1px solid var(--border); border-radius: 10px; + padding: 0.6rem 0.8rem; margin: 0 0 0.9rem; + font-size: 0.75rem; color: var(--muted); +} +.priority-ref b { color: var(--ink); font-weight: 700; margin-right: 0.3rem; } +.priority-ref .plevel { display: inline-block; margin-right: 0.9rem; white-space: nowrap; } +.priority-ref .plevel code { + background: #060b16; padding: 0.05rem 0.3rem; border-radius: 4px; + color: var(--blue); margin-right: 0.2rem; font-size: 0.72rem; +} + +/* Diagnostics strip at the bottom of settings pages */ +.diag-strip { + margin-top: 1rem; padding: 0.65rem 0.9rem; + background: #0f1829; border: 1px solid var(--border); border-radius: 10px; + font-size: 0.75rem; color: var(--muted); + display: flex; align-items: center; gap: 0.9rem; flex-wrap: wrap; +} +.diag-strip code { + background: #060b16; padding: 0.1rem 0.4rem; border-radius: 4px; + color: #cbd5e1; font-size: 0.72rem; +} +.diag-strip .diag-badge { + display: inline-flex; align-items: center; gap: 0.25rem; + padding: 0.08rem 0.45rem; border-radius: 999px; font-weight: 700; + font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; +} +.diag-strip .diag-badge.ok { background: var(--green-bg); color: var(--green); } +.diag-strip .diag-badge.err { background: var(--red-bg); color: var(--red); } + +/* Test notification button + result */ +.test-row { + display: flex; align-items: center; gap: 0.6rem; + margin: 0.6rem 0 1rem; + padding: 0.6rem 0.75rem; + background: #0f1829; border: 1px dashed var(--border-2, var(--border)); + border-radius: 10px; +} +.btn-ghost { + background: transparent; color: var(--blue); border: 1px solid var(--blue); + padding: 0.4rem 0.85rem; border-radius: 8px; font-weight: 600; cursor: pointer; + font-family: inherit; font-size: 0.8rem; +} +.btn-ghost:hover { background: var(--blue-bg); } +.btn-ghost:disabled { opacity: 0.5; cursor: wait; } +.test-result { font-size: 0.78rem; color: var(--muted); flex: 1; min-width: 0; } +.test-result.ok { color: var(--green); font-weight: 600; } +.test-result.err { color: var(--red); font-weight: 600; } + +/* Color swatches + picker */ +.color-swatches { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0.3rem 0; } +.color-swatches .swatch { + width: 22px; height: 22px; border-radius: 50%; + border: 2px solid #1e293b; cursor: pointer; + transition: transform 0.1s, border-color 0.1s; +} +.color-swatches .swatch:hover { transform: scale(1.1); border-color: var(--ink); } +.color-row { display: flex; align-items: center; gap: 0.5rem; } +.color-row input[type=color] { + width: 32px; height: 32px; padding: 0; border: 1px solid var(--border); + border-radius: 6px; background: transparent; cursor: pointer; +} +.color-row input[type=text] { flex: 1; } + +/* Danger button for delete container */ +.btn-danger { + background: var(--red-bg); color: var(--red); border: 1px solid var(--red); + padding: 0.55rem 1rem; border-radius: 8px; font-weight: 700; cursor: pointer; + font-family: inherit; font-size: 0.85rem; +} +.btn-danger:hover { background: var(--red); color: #130709; } """ @@ -638,8 +731,6 @@ def _page_head(refresh: bool, active: str) -> str: containers_class = "active" if active == "containers" else "" settings_class = "active" if active == "settings" else "" meta_text = f"v{__version__}" - if refresh: - meta_text += " · auto-refreshes every 30s" return ( "\n" '\n\n' @@ -1126,6 +1217,137 @@ def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> N log.info("Settings saved: section=%s", section) +_CONTAINER_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$") + + +def _apply_container_create(config_ref: ConfigRef, form: dict) -> str: + """Insert a new container under raw['containers']. Returns the new name.""" + name = _form_str(form, "name", "").strip() + if not name: + raise _SettingsError("container name is required") + if not _CONTAINER_NAME_RE.match(name): + raise _SettingsError("container name must start with a letter/digit and use only letters, digits, _ or -") + + raw = config_ref.get_raw() + containers = raw.get("containers") or {} + if name in containers: + raise _SettingsError(f"container {name!r} already exists") + + cfg = { + "enabled": _form_bool(form, "enabled"), + "max_error_count": _form_int(form, "max_error_count", 5), + "cooldown_seconds": _form_int(form, "cooldown_seconds", 300), + "grace_period_seconds": _form_int(form, "grace_period_seconds", 120), + "max_restarts_per_hour": _form_int(form, "max_restarts_per_hour", 3), + "color": _form_str(form, "color", ""), + } + restart_on = form.get("restart_on", []) + if restart_on: + cfg["restart_on"] = [s.upper() for s in restart_on] + containers[name] = cfg + raw["containers"] = containers + + try: + new_app = build_config_from_raw(raw) + except Exception as exc: + raise _SettingsError(str(exc)) from exc + + try: + write_config_raw(config_ref.path, raw) + except OSError as exc: + raise _SettingsError(f"could not write config.yml: {exc}") from exc + + config_ref.swap(raw, new_app) + log.info("Container added: %s", name) + return name + + +def _apply_container_delete(config_ref: ConfigRef, name: str) -> None: + raw = config_ref.get_raw() + containers = raw.get("containers") or {} + if name not in containers: + raise _SettingsError(f"unknown container {name!r}") + del containers[name] + raw["containers"] = containers + + try: + new_app = build_config_from_raw(raw) + except Exception as exc: + raise _SettingsError(str(exc)) from exc + + try: + write_config_raw(config_ref.path, raw) + except OSError as exc: + raise _SettingsError(f"could not write config.yml: {exc}") from exc + + config_ref.swap(raw, new_app) + log.info("Container removed: %s", name) + + +def _run_test_notification(channel: str, form: dict, app: AppConfig) -> dict: + """Send a one-shot test using form values, surfacing HTTP errors to the caller. + + Secrets submitted as the __UNCHANGED__ sentinel fall back to the saved config + (otherwise the user couldn't test without re-typing the secret). This bypasses + the regular notifier.notify() path because that one swallows failures; the + whole point of the test button is to surface them. + """ + import requests + + def _secret(form_val: str, saved: str) -> str: + return saved if form_val == _UNCHANGED_SECRET_PLACEHOLDER else form_val + + title = "Inspector test" + message = "This is a test notification from Inspector." + + if channel == "gotify": + url = _form_str(form, "url", app.gotify.url).rstrip("/") + token = _secret(_form_str(form, "token", ""), app.gotify.token) + priority = _form_int(form, "priority", app.gotify.priority) + if not url or not token: + return {"ok": False, "error": "URL and token required"} + try: + resp = requests.post( + f"{url}/message", + params={"token": token}, + json={"title": title, "message": message, "priority": priority}, + timeout=5.0, + ) + except requests.RequestException as exc: + return {"ok": False, "error": f"request failed: {exc}"} + if resp.status_code >= 400: + return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:150]}"} + return {"ok": True} + + if channel == "pushover": + token = _secret(_form_str(form, "token", ""), app.pushover.token) + user_key = _secret(_form_str(form, "user_key", ""), app.pushover.user_key) + if not token or not user_key: + return {"ok": False, "error": "token and user_key required"} + data = { + "token": token, + "user": user_key, + "title": title, + "message": message, + "priority": 0, # neutral priority for the test; avoids emergency retry loop + } + device = _form_str(form, "device", "") + sound = _form_str(form, "sound", "") + if device: + data["device"] = device + if sound: + data["sound"] = sound + try: + resp = requests.post("https://api.pushover.net/1/messages.json", data=data, timeout=5.0) + except requests.RequestException as exc: + return {"ok": False, "error": f"request failed: {exc}"} + if resp.status_code >= 400: + return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:150]}"} + return {"ok": True} + + return {"ok": False, "error": f"unknown channel {channel!r}"} + + _RESTART_LABEL = ( '' "requires restart" @@ -1152,6 +1374,20 @@ def _settings_field( f'{hint_html}" ) + if kind == "switch": + is_on = value in ("1", "True", "true", "on") + on_class = "on" if is_on else "" + checked = "checked" if is_on else "" + esc_name = _escape(name) + return ( + f'
' + f'' + f'{label_html}' + f"{hint_html}
" + ) return ( f'
' f'' @@ -1222,7 +1458,7 @@ _SETTINGS_SECTIONS = [ _SETTINGS_SECTION_SLUGS = {slug for slug, *_ in _SETTINGS_SECTIONS} -def _render_settings_rail(app: AppConfig, active_slug: str) -> str: +def _render_settings_rail(app: AppConfig, active_slug: str, active_container: str = "") -> str: parts: list[str] = ['\n") return "".join(parts) @@ -1255,14 +1512,88 @@ def _settings_form(section: str, title: str, body: str, csrf_token: str) -> str: ) +def _settings_form_with_test(section: str, title: str, body: str, test_row: str, csrf_token: str) -> str: + """Form that includes an inline test-notification row above the Save button.""" + return ( + f'
' + f'' + f"{body}" + f"{test_row}" + '
' + f'
' + "
\n" + ) + + +_TEST_NOTIFY_JS = """ + +""" + + +def _render_diag_strip(config_ref: ConfigRef) -> str: + """Shows config path + writable status + version at the bottom of settings pages.""" + path = config_ref.path + writable = os.access(str(path), os.W_OK) + badge = ( + 'writable' if writable + else 'READ-ONLY' + ) + hint = "" if writable else ( + ' — the UI cannot save changes. ' + "Fix the bind-mount (remove :ro) or the file owner." + ) + return ( + '
' + f'config: {_escape(str(path))}' + f"{badge}{hint}" + f'Inspector v{__version__}' + "
\n" + ) + + def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str: if section == "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("dry_run", "dry_run", "on" if insp.dry_run else "", kind="switch") + + _settings_field("heartbeat", "heartbeat", "on" if insp.heartbeat else "", kind="switch") + _settings_field("log_retention_days", "log_retention_days", str(insp.log_retention_days), kind="number", requires_restart=True) ) return ( @@ -1274,39 +1605,64 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str: 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("enabled", "enabled", "on" if g.enabled else "", kind="switch", 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") + + _settings_field("priority", "priority", str(g.priority), kind="number", hint="Inspector-side priority (1–10). See reference above.") ) pushover_body = ( - _settings_field("enabled", "enabled", "on" if p.enabled else "", kind="checkbox", requires_restart=True) + _settings_field("enabled", "enabled", "on" if p.enabled else "", kind="switch", 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("priority", "priority", str(p.priority), kind="number", hint="Inspector-side priority (1–10). Mapped to Pushover's -2..2 at send time.") + _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' + priority_ref = ( + '
Priority reference' + '1-2 info / debug' + '3-4 normal (default)' + '5-6 warning' + '7-8 error' + '9-10 critical / emergency' + '
' + 'Pushover mapping: ≤2 → -1 · ' + '3-4 → 0 · 5-6 → 1 · ' + '≥7 → 2 (emergency)' + '
' + ) + gotify_test_row = ( + '
' + '' + 'Sends a test message using the values typed above (even if unsaved).' + '
' + ) + pushover_test_row = ( + '
' + '' + 'Sends a test message using the values typed above (even if unsaved).' + '
' + ) return ( '

Notifications

' '

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

' - f'

Gotify {gotify_pill}

' + + priority_ref + + f'

Gotify {gotify_pill}

' '

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

' - + _settings_form("gotify", "Gotify", gotify_body, csrf_token) + + _settings_form_with_test("gotify", "Gotify", gotify_body, gotify_test_row, 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) + + f'

Pushover {pushover_pill}

' + '

Independent of Gotify. Supports priorities -2 (lowest) through 2 (emergency, which Pushover retries until acknowledged).

' + + _settings_form_with_test("pushover", "Pushover", pushover_body, pushover_test_row, 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("enabled", "enabled", "on" if d.enabled else "", kind="switch", requires_restart=True) + _settings_field("username", "username", d.username) + _settings_secret_field("password", "password", bool(d.password)) ) @@ -1319,7 +1675,7 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str: if section == "database": db = app.database body = ( - _settings_field("enabled", "enabled", "on" if db.enabled else "", kind="checkbox", requires_restart=True) + _settings_field("enabled", "enabled", "on" if db.enabled else "", kind="switch", requires_restart=True) + _settings_field("retention_days", "retention_days", str(db.retention_days), kind="number", requires_restart=True) ) return ( @@ -1331,7 +1687,7 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str: 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("enabled", "enabled", "on" if hs.enabled else "", kind="switch", 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") ) @@ -1344,7 +1700,7 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str: if section == "maintenance": m = app.maintenance body = ( - _settings_field("enabled", "enabled", "on" if m.enabled else "", kind="checkbox", requires_restart=True) + _settings_field("enabled", "enabled", "on" if m.enabled else "", kind="switch", 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") @@ -1358,7 +1714,7 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str: 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("enabled", "enabled", "on" if nm.enabled else "", kind="switch", 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 ( @@ -1385,7 +1741,7 @@ def _render_flash(flash: str) -> str: return "" -def _render_settings_page(app: AppConfig, csrf_token: str, flash: str, section: str) -> bytes: +def _render_settings_page(app: AppConfig, config_ref: ConfigRef, csrf_token: str, flash: str, section: str) -> bytes: if section not in _SETTINGS_SECTION_SLUGS: section = "inspector" parts = [_page_head(refresh=False, active="settings")] @@ -1395,6 +1751,9 @@ def _render_settings_page(app: AppConfig, csrf_token: str, flash: str, section: parts.append('
') parts.append(_settings_body_for(section, app, csrf_token)) parts.append("
\n") + parts.append(_render_diag_strip(config_ref)) + if section == "notifications": + parts.append(_TEST_NOTIFY_JS) parts.append(_PAGE_TAIL) return "".join(parts).encode("utf-8") @@ -1402,11 +1761,17 @@ def _render_settings_page(app: AppConfig, csrf_token: str, flash: str, section: 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( + '
' + '

Containers

' + '+ New container' + '
' + ) parts.append( '

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

\n" + "Click a tile to edit its restart rules, or add / remove containers from the UI " + "(the underlying config.yml is updated for you).

\n" ) parts.append('
\n') containers = list(app.containers) @@ -1433,16 +1798,47 @@ def _render_containers_page(app: AppConfig, states: dict, flash: str) -> bytes: 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 +# Preset color swatches offered on container edit/new forms. +_COLOR_PALETTE = [ + "#60a5fa", "#34d399", "#fbbf24", "#f87171", "#a78bfa", + "#f472b6", "#2dd4bf", "#fb923c", "#94a3b8", "#a3a3a3", +] + +def _render_color_input(name: str, value: str) -> str: + """Hex text input + native color picker + preset swatches, all bound together.""" + swatches = "".join( + f'" + for c in _COLOR_PALETTE + ) + picker_value = value if value and value.startswith("#") else "#60a5fa" + return ( + f'
' + f'
' + f'" + f'' + "
" + f'
{swatches}
' + '
Pick a swatch, use the picker, or type a hex code. Blank = auto palette.
' + "
" + ) + + +def _render_container_form( + name: str, cfg, csrf_token: str, action: str, submit_label: str, + *, show_name_field: bool = False, show_delete: bool = False, +) -> str: + """Build the container edit form. `cfg` may be a ContainerConfig or a dummy with defaults.""" restart_on_opts = ["HEALTHY", "UNHEALTHY", "STARTING", "NO HEALTH CHECK", "NOT RUNNING", "CONTAINER NOT FOUND", "ERROR", "UNKNOWN", "UNREACHABLE"] + current_restart_on = getattr(cfg, "restart_on", []) or [] checkboxes: list[str] = [] for s in restart_on_opts: - checked = "checked" if s in cfg.restart_on else "" + checked = "checked" if s in current_restart_on else "" checkboxes.append( f'' @@ -1453,41 +1849,115 @@ def _render_container_edit_page(app: AppConfig, name: str, csrf_token: str, flas '
Statuses that trigger a restart. Leave all off for observe-only.
' "
" ) + name_field = "" + if show_name_field: + name_field = _settings_field( + "name", "name", "", + hint="Container name as it appears in Docker. Letters, digits, _ and - 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", + name_field + + _settings_field("enabled", "enabled", "on" if getattr(cfg, "enabled", True) else "", kind="switch") + + _settings_field("max_error_count", "max_error_count", + str(getattr(cfg, "max_error_count", 5)), kind="number", hint="Consecutive failing checks before a restart is attempted") - + _settings_field("cooldown_seconds", "cooldown_seconds", str(cfg.cooldown_seconds), kind="number", + + _settings_field("cooldown_seconds", "cooldown_seconds", + str(getattr(cfg, "cooldown_seconds", 300)), kind="number", hint="Minimum seconds between restarts") - + _settings_field("grace_period_seconds", "grace_period_seconds", str(cfg.grace_period_seconds), kind="number", + + _settings_field("grace_period_seconds", "grace_period_seconds", + str(getattr(cfg, "grace_period_seconds", 120)), 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", + + _settings_field("max_restarts_per_hour", "max_restarts_per_hour", + str(getattr(cfg, "max_restarts_per_hour", 3)), 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.") + + _render_color_input("color", getattr(cfg, "color", "")) + restart_on_html ) - form_html = ( - f'
' + delete_btn = "" + if show_delete: + delete_btn = ( + f'" + f'' + '' + "
" + ) + return ( + f'
' f'' f"{body}" '
' + f"{delete_btn}" 'Back' - '
" + f'
" "\n" ) + +def _render_container_edit_page(app: AppConfig, config_ref: ConfigRef, 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 + form_html = _render_container_form( + name, cfg, csrf_token, + action=f"/containers/{_escape(name)}", + submit_label="Save container", + show_delete=True, + ) parts = [_page_head(refresh=False, active="containers")] parts.append(_render_flash(flash)) + parts.append('
') + parts.append(_render_settings_rail(app, "", active_container=name)) + parts.append('
') parts.append( - f'

Container — {_escape(name)}

\n' + f'

Container — {_escape(name)}

' + '

Restart rules Inspector applies when this container\'s health changes.

' ) - parts.append('
') parts.append(form_html) - parts.append("
\n") + parts.append("
\n") + parts.append(_render_diag_strip(config_ref)) + parts.append(_PAGE_TAIL) + return "".join(parts).encode("utf-8") + + +class _NewContainerDefaults: + name = "" + enabled = True + max_error_count = 5 + cooldown_seconds = 300 + grace_period_seconds = 120 + max_restarts_per_hour = 3 + color = "" + restart_on: list[str] = [] + + +def _render_container_new_page(app: AppConfig, config_ref: ConfigRef, csrf_token: str, flash: str) -> bytes: + form_html = _render_container_form( + "", _NewContainerDefaults(), csrf_token, + action="/containers/new", + submit_label="Create container", + show_name_field=True, + ) + parts = [_page_head(refresh=False, active="containers")] + parts.append(_render_flash(flash)) + parts.append('
') + parts.append(_render_settings_rail(app, "")) + parts.append('
') + parts.append( + '

New container

' + '

Add a container to config.yml. ' + "The new entry is picked up automatically on the next polling cycle; " + "advanced fields (Portainer stack, http_check, uptime_kuma, depends_on, " + "vpn_routed) remain YAML-only for now.

" + ) + parts.append(form_html) + parts.append("
\n") + parts.append(_render_diag_strip(config_ref)) parts.append(_PAGE_TAIL) return "".join(parts).encode("utf-8") @@ -1676,17 +2146,22 @@ class _Handler(BaseHTTPRequestHandler): section = path[len("/settings/"):].strip("/") csrf = self._csrf_token_for_session() flash = query.get("flash", [""])[0] - body = _render_settings_page(app, csrf, flash, section) + body = _render_settings_page(app, server.config_ref, 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 == "/containers/new": + csrf = self._csrf_token_for_session() + flash = query.get("flash", [""])[0] + body = _render_container_new_page(app, server.config_ref, csrf, 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) + body = _render_container_edit_page(app, server.config_ref, name, csrf, flash) if body is None: self._send_bytes(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"unknown container") return @@ -1822,6 +2297,44 @@ class _Handler(BaseHTTPRequestHandler): self.end_headers() return + # POST /containers/new — create a new container entry + if len(parts) == 2 and parts[0] == "containers" and parts[1] == "new": + try: + new_name = _apply_container_create(server.config_ref, form) + except _SettingsError as exc: + flash = f"error:{exc}" + self.send_response(HTTPStatus.SEE_OTHER) + self.send_header("Location", f"/containers/new?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(new_name)}?flash={_url_quote('ok:' + new_name)}") + self.send_header("Content-Length", "0") + self.end_headers() + return + + # POST /containers//delete — remove a container + if len(parts) == 3 and parts[0] == "containers" and parts[2] == "delete": + 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_container_delete(server.config_ref, name) + 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?flash={_url_quote('ok:removed ' + name)}") + 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: @@ -1842,6 +2355,14 @@ class _Handler(BaseHTTPRequestHandler): self.end_headers() return + # POST /api/test-notify/ — send a one-shot test using form values + if len(parts) == 3 and parts[0] == "api" and parts[1] == "test-notify": + channel = parts[2] + payload = _run_test_notification(channel, form, app) + body = json.dumps(payload).encode("utf-8") + self._send_bytes(HTTPStatus.OK, "application/json", body) + return + self._send_bytes(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"not found")