2 Commits
Author SHA1 Message Date
Europium 63 88f6d06df9 Release v0.7.3 — Containers under Settings, per-channel priorities, restart_on cards
- Containers tab moved into the settings rail (All containers grid + per-container entries).
- Per-platform priority references for Gotify and Pushover.
- restart_on redesigned as toggleable cards with descriptions and quick presets.
- Fix: unchecking every restart_on status now saves observe-only.
2026-10-27 14:45:17 -07:00
Europium 63 c6d2a67a4b Release v0.7.2 — Settings polish + container CRUD
- Containers can now be added, edited, and removed from the UI
  (/containers/new and the delete button on /containers/<name>).
  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.
2026-10-27 10:30:07 -07:00
3 changed files with 769 additions and 64 deletions

No files matched your search

+67
View File
@@ -7,6 +7,73 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
--- ---
## [0.7.3] — 2026-10-27
### Changed
- **Containers moved into Settings.** The top-nav Containers tab is gone; the
settings rail now has an "All containers" entry (the tile grid at `/containers`)
plus one entry per container (`/containers/<name>`). Both views render inside
the same rail layout, and the Settings tab stays highlighted while you browse them.
- **Per-channel priority reference.** The shared priority block is replaced by one
reference per platform: Gotify (0 silent · 1–3 low · 4–7 normal · 8–10 high) and
Pushover (-2 Lowest · -1 Low · 0 Normal · 1 High · 2 Emergency), with the
Inspector → Pushover mapping noted under the Pushover one.
- **restart_on redesign.** The pill row is replaced by a grid of toggleable cards,
each with a color accent and a plain-language description of the status, plus
"Recommended" (UNHEALTHY + NOT RUNNING) and "Observe only" quick buttons.
### Fixed
- Unchecking every `restart_on` status now saves `restart_on: []` (observe only).
Previously an all-unchecked submit silently kept the old list.
---
## [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/<name>` 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/<channel>`
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 `<meta refresh>`
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 ## [0.7.1] — 2026-10-27
### Changed ### Changed
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.7.1" __version__ = "0.7.3"
+701 -63
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
import logging import logging
import os
import re import re
import secrets import secrets
import sqlite3 import sqlite3
@@ -628,6 +629,152 @@ 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 { 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-meta b { color: var(--ink); font-weight: 600; }
.container-tile .ctn-chev { color: var(--muted); font-size: 1rem; } .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; }
/* restart_on grid */
.restart-on-head {
display: flex; align-items: center; justify-content: space-between;
margin: 0.4rem 0 0.5rem;
}
.restart-on-head label {
font-size: 0.76rem; color: #94a3b8; font-weight: 600;
}
.restart-on-head .quick {
display: flex; gap: 0.4rem;
}
.restart-on-head .quick button {
background: transparent; color: var(--blue); border: 1px solid var(--border);
padding: 0.25rem 0.55rem; border-radius: 6px; font-size: 0.72rem;
cursor: pointer; font-family: inherit; font-weight: 600;
}
.restart-on-head .quick button:hover { background: var(--blue-bg); border-color: var(--blue); }
.restart-on-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 0.5rem; margin-bottom: 0.5rem;
}
.rcard {
position: relative; display: block;
padding: 0.65rem 0.75rem;
background: var(--bg); border: 1px solid var(--border);
border-radius: 10px; cursor: pointer; user-select: none;
transition: border-color 0.1s, background 0.1s;
}
.rcard:hover { border-color: var(--border-2, #273449); }
.rcard input { position: absolute; opacity: 0; pointer-events: none; }
.rcard.on { border-color: var(--rc-col); background: color-mix(in srgb, var(--rc-col) 10%, var(--bg)); }
.rcard .rc-head {
display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.25rem;
}
.rcard .rc-dot {
width: 8px; height: 8px; border-radius: 50%; background: var(--rc-col);
flex-shrink: 0;
}
.rcard .rc-name {
font-size: 0.82rem; font-weight: 700; color: var(--ink);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.rcard .rc-check {
margin-left: auto; width: 16px; height: 16px; border-radius: 4px;
border: 1.5px solid var(--border-2, #273449); background: transparent;
display: flex; align-items: center; justify-content: center;
color: transparent; font-weight: 900; font-size: 0.75rem;
}
.rcard.on .rc-check {
background: var(--rc-col); border-color: var(--rc-col); color: #06111f;
}
.rcard.on .rc-check::after { content: "✓"; }
.rcard .rc-desc { font-size: 0.72rem; color: var(--muted); line-height: 1.3; }
/* 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; }
</style>""" </style>"""
@@ -635,11 +782,9 @@ def _page_head(refresh: bool, active: str) -> str:
refresh_meta = '<meta http-equiv="refresh" content="30">\n' if refresh else "" refresh_meta = '<meta http-equiv="refresh" content="30">\n' if refresh else ""
status_class = "active" if active == "status" else "" status_class = "active" if active == "status" else ""
events_class = "active" if active == "events" else "" events_class = "active" if active == "events" else ""
containers_class = "active" if active == "containers" else "" # Settings tab stays active while browsing containers — they live under settings now.
settings_class = "active" if active == "settings" else "" settings_class = "active" if active in ("settings", "containers") else ""
meta_text = f"v{__version__}" meta_text = f"v{__version__}"
if refresh:
meta_text += " &middot; auto-refreshes every 30s"
return ( return (
"<!DOCTYPE html>\n" "<!DOCTYPE html>\n"
'<html lang="en">\n<head>\n' '<html lang="en">\n<head>\n'
@@ -668,7 +813,6 @@ def _page_head(refresh: bool, active: str) -> str:
' <div class="tabs">\n' ' <div class="tabs">\n'
f' <a href="/" class="{status_class}">Status</a>\n' f' <a href="/" class="{status_class}">Status</a>\n'
f' <a href="/events" class="{events_class}">Events</a>\n' f' <a href="/events" class="{events_class}">Events</a>\n'
f' <a href="/containers" class="{containers_class}">Containers</a>\n'
f' <a href="/settings" class="{settings_class}">Settings</a>\n' f' <a href="/settings" class="{settings_class}">Settings</a>\n'
" </div>\n" " </div>\n"
f' <div class="meta"><span class="meta-text">{meta_text}</span><a class="signout" href="/logout">Sign out</a></div>\n' f' <div class="meta"><span class="meta-text">{meta_text}</span><a class="signout" href="/logout">Sign out</a></div>\n'
@@ -1104,7 +1248,7 @@ def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> N
cfg["max_restarts_per_hour"] = _form_int(form, "max_restarts_per_hour", 3) cfg["max_restarts_per_hour"] = _form_int(form, "max_restarts_per_hour", 3)
cfg["color"] = _form_str(form, "color", "") cfg["color"] = _form_str(form, "color", "")
restart_on = form.get("restart_on", []) restart_on = form.get("restart_on", [])
if restart_on: if restart_on or form.get("restart_on_present"):
cfg["restart_on"] = [s.upper() for s in restart_on] cfg["restart_on"] = [s.upper() for s in restart_on]
containers[name] = cfg containers[name] = cfg
raw["containers"] = containers raw["containers"] = containers
@@ -1126,6 +1270,137 @@ def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> N
log.info("Settings saved: section=%s", section) 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 or form.get("restart_on_present"):
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 = ( _RESTART_LABEL = (
'<span class="restart-flag" title="takes effect after Inspector restart">' '<span class="restart-flag" title="takes effect after Inspector restart">'
"requires restart</span>" "requires restart</span>"
@@ -1152,6 +1427,20 @@ def _settings_field(
f'<label><input type="checkbox" name="{_escape(name)}" value="on" {checked}> ' f'<label><input type="checkbox" name="{_escape(name)}" value="on" {checked}> '
f"{label_html}</label>{hint_html}</div>" f"{label_html}</label>{hint_html}</div>"
) )
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'<div class="toggle-row">'
f'<label class="toggle {on_class}" onclick="(function(e){{var cb=e.currentTarget.querySelector(\'input\');'
f'cb.checked=!cb.checked;e.currentTarget.classList.toggle(\'on\',cb.checked);}})(event)">'
f'<input type="checkbox" name="{esc_name}" value="on" {checked}>'
f'</label>'
f'<span class="toggle-label">{label_html}</span>'
f"{hint_html}</div>"
)
return ( return (
f'<div class="field">' f'<div class="field">'
f'<label for="s_{_escape(name)}">{label_html}</label>' f'<label for="s_{_escape(name)}">{label_html}</label>'
@@ -1222,7 +1511,10 @@ _SETTINGS_SECTIONS = [
_SETTINGS_SECTION_SLUGS = {slug for slug, *_ in _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 = "",
*, containers_tab_active: bool = False,
) -> str:
parts: list[str] = ['<nav class="settings-rail">\n'] parts: list[str] = ['<nav class="settings-rail">\n']
current_group: str | None = None current_group: str | None = None
for slug, label, icon, sub_fn, group in _SETTINGS_SECTIONS: for slug, label, icon, sub_fn, group in _SETTINGS_SECTIONS:
@@ -1238,6 +1530,35 @@ def _render_settings_rail(app: AppConfig, active_slug: str) -> str:
f'<span class="rail-sub">{subtitle}</span>' f'<span class="rail-sub">{subtitle}</span>'
f"</span></a>\n" f"</span></a>\n"
) )
# Containers group — a top-level "Containers" entry (goes to the grid view)
# plus per-container entries so you can jump straight to one.
parts.append(
f'<div class="rail-group">Containers <span style="color:var(--muted); font-weight:400;">'
f'({len(app.containers)})</span></div>\n'
)
containers_active = "active" if containers_tab_active else ""
parts.append(
f'<a class="{containers_active}" href="/containers">'
'<span class="rail-ico">📦</span>'
'<span class="rail-text">All containers'
'<span class="rail-sub">grid view</span></span></a>\n'
)
for i, c in enumerate(app.containers):
color = _resolve_color(c, i)
active = "active" if c.name == active_container else ""
enabled_sub = "enabled" if c.enabled else "disabled"
parts.append(
f'<a class="{active}" href="/containers/{_escape(c.name)}">'
f'<span class="rail-ico" style="color:{_escape(color)};">●</span>'
f'<span class="rail-text">{_escape(c.name)}'
f'<span class="rail-sub">{enabled_sub} · max {c.max_error_count} err</span>'
f"</span></a>\n"
)
parts.append(
'<a href="/containers/new" style="color:var(--blue); font-weight:600;">'
'<span class="rail-ico">+</span>'
'<span class="rail-text">Add container</span></a>\n'
)
parts.append("</nav>\n") parts.append("</nav>\n")
return "".join(parts) return "".join(parts)
@@ -1255,14 +1576,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'<form method="post" action="/settings/{_escape(section)}" data-channel="{_escape(section)}">'
f'<input type="hidden" name="csrf" value="{_escape(csrf_token)}">'
f"{body}"
f"{test_row}"
'<div class="form-actions">'
f'<button class="btn" type="submit" style="background:var(--blue); color:#06111f; '
'border:none; border-radius:8px; padding:0.55rem 1rem; font-weight:700; cursor:pointer; '
f'font-family:inherit;">Save {_escape(title)}</button></div>'
"</form>\n"
)
_TEST_NOTIFY_JS = """
<script>
window.InspectorTest = function(btn, channel) {
var form = btn.closest('form');
var resultEl = btn.parentElement.querySelector('.test-result');
var data = new URLSearchParams();
form.querySelectorAll('input[name]').forEach(function(el) {
if (el.type === 'checkbox') { if (el.checked) data.append(el.name, 'on'); }
else { data.append(el.name, el.value); }
});
btn.disabled = true;
resultEl.className = 'test-result';
resultEl.textContent = 'Sending test...';
fetch('/api/test-notify/' + channel, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: data.toString(),
}).then(function(r) { return r.json(); })
.then(function(j) {
btn.disabled = false;
if (j.ok) {
resultEl.className = 'test-result ok';
resultEl.textContent = '✓ Sent. Check your device.';
} else {
resultEl.className = 'test-result err';
resultEl.textContent = '✗ ' + (j.error || 'Failed');
}
})
.catch(function(e) {
btn.disabled = false;
resultEl.className = 'test-result err';
resultEl.textContent = '✗ ' + e.message;
});
};
</script>
"""
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 = (
'<span class="diag-badge ok">writable</span>' if writable
else '<span class="diag-badge err">READ-ONLY</span>'
)
hint = "" if writable else (
' <span style="color:var(--red);">— the UI cannot save changes. '
"Fix the bind-mount (remove <code>:ro</code>) or the file owner.</span>"
)
return (
'<div class="diag-strip">'
f'<span>config: <code>{_escape(str(path))}</code></span>'
f"<span>{badge}{hint}</span>"
f'<span style="margin-left:auto;">Inspector v{__version__}</span>'
"</div>\n"
)
def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str: def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
if section == "inspector": if section == "inspector":
insp = app.inspector insp = app.inspector
body = ( body = (
_settings_field("interval_seconds", "interval_seconds", str(insp.interval_seconds), kind="number") _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("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("dry_run", "dry_run", "on" if insp.dry_run else "", kind="switch")
+ _settings_field("heartbeat", "heartbeat", "on" if insp.heartbeat else "", kind="checkbox") + _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) + _settings_field("log_retention_days", "log_retention_days", str(insp.log_retention_days), kind="number", requires_restart=True)
) )
return ( return (
@@ -1274,39 +1669,75 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
if section == "notifications": if section == "notifications":
g, p = app.gotify, app.pushover g, p = app.gotify, app.pushover
gotify_body = ( 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_field("url", "url", g.url, hint="Base URL of your Gotify server", requires_restart=True)
+ _settings_secret_field("token", "token", bool(g.token)) + _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 = ( 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("token", "token", bool(p.token))
+ _settings_secret_field("user_key", "user_key", bool(p.user_key)) + _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("device", "device", p.device, hint="Optional; blank = all devices")
+ _settings_field("sound", "sound", p.sound, hint="Optional; blank = user default") + _settings_field("sound", "sound", p.sound, hint="Optional; blank = user default")
) )
gotify_pill = '<span class="pill" style="background:var(--green-bg); color:var(--green);">enabled</span>' if g.enabled else '<span class="pill" style="background:var(--border); color:var(--muted);">disabled</span>' gotify_pill = '<span class="pill" style="background:var(--green-bg); color:var(--green);">enabled</span>' if g.enabled else '<span class="pill" style="background:var(--border); color:var(--muted);">disabled</span>'
pushover_pill = '<span class="pill" style="background:var(--green-bg); color:var(--green);">enabled</span>' if p.enabled else '<span class="pill" style="background:var(--border); color:var(--muted);">disabled</span>' pushover_pill = '<span class="pill" style="background:var(--green-bg); color:var(--green);">enabled</span>' if p.enabled else '<span class="pill" style="background:var(--border); color:var(--muted);">disabled</span>'
gotify_priority_ref = (
'<div class="priority-ref"><b>Gotify priority levels</b>'
'<span class="plevel"><code>0</code> silent (no popup)</span>'
'<span class="plevel"><code>1-3</code> low (notification only)</span>'
'<span class="plevel"><code>4-7</code> normal (default <code>5</code>)</span>'
'<span class="plevel"><code>8-10</code> high (persistent)</span>'
'</div>'
)
pushover_priority_ref = (
'<div class="priority-ref"><b>Pushover priority levels</b>'
'<span class="plevel"><code>-2</code> Lowest (silent)</span>'
'<span class="plevel"><code>-1</code> Low (quiet)</span>'
'<span class="plevel"><code>0</code> Normal (default)</span>'
'<span class="plevel"><code>1</code> High (bypass quiet hours)</span>'
'<span class="plevel"><code>2</code> Emergency (retries until ack)</span>'
'<br><span style="margin-top:0.3rem; display:inline-block; font-size:0.72rem;">'
'Inspector priority maps automatically: '
'<code>&le;2 &rarr; -1</code> &middot; '
'<code>3-4 &rarr; 0</code> &middot; '
'<code>5-6 &rarr; 1</code> &middot; '
'<code>&ge;7 &rarr; 2</code>'
'</span></div>'
)
gotify_test_row = (
'<div class="test-row">'
'<button type="button" class="btn-ghost" onclick="InspectorTest(this, \'gotify\')">Send test</button>'
'<span class="test-result">Sends a test message using the values typed above (even if unsaved).</span>'
'</div>'
)
pushover_test_row = (
'<div class="test-row">'
'<button type="button" class="btn-ghost" onclick="InspectorTest(this, \'pushover\')">Send test</button>'
'<span class="test-result">Sends a test message using the values typed above (even if unsaved).</span>'
'</div>'
)
return ( return (
'<h3 class="pane-title">Notifications</h3>' '<h3 class="pane-title">Notifications</h3>'
'<p class="pane-sub">Enable one or both channels. If both are enabled, every alert fans out to both.</p>' '<p class="pane-sub">Enable one or both channels. If both are enabled, every alert fans out to both.</p>'
f'<div class="subsection"><h4>Gotify {gotify_pill}</h4>' + f'<div class="subsection"><h4>Gotify {gotify_pill}</h4>'
'<p class="subsection-sub">Push notifications via your Gotify server. Messages render as Markdown.</p>' '<p class="subsection-sub">Push notifications via your Gotify server. Messages render as Markdown.</p>'
+ _settings_form("gotify", "Gotify", gotify_body, csrf_token) + gotify_priority_ref
+ _settings_form_with_test("gotify", "Gotify", gotify_body, gotify_test_row, csrf_token)
+ "</div>" + "</div>"
f'<div class="subsection"><h4>Pushover {pushover_pill}</h4>' + f'<div class="subsection"><h4>Pushover {pushover_pill}</h4>'
'<p class="subsection-sub">Pushover is independent of Gotify. Inspector priority is mapped to Pushover\'s <code>-2..2</code> range: ' '<p class="subsection-sub">Independent of Gotify. Inspector maps its internal 1–10 priority to Pushover\'s -2..2 range at send time.</p>'
'<code>&le;2 &rarr; -1</code>, <code>3-4 &rarr; 0</code>, <code>5-6 &rarr; 1</code>, <code>&ge;7 &rarr; 2</code>.</p>' + pushover_priority_ref
+ _settings_form("pushover", "Pushover", pushover_body, csrf_token) + _settings_form_with_test("pushover", "Pushover", pushover_body, pushover_test_row, csrf_token)
+ "</div>" + "</div>"
) )
if section == "dashboard": if section == "dashboard":
d = app.dashboard d = app.dashboard
body = ( 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_field("username", "username", d.username)
+ _settings_secret_field("password", "password", bool(d.password)) + _settings_secret_field("password", "password", bool(d.password))
) )
@@ -1319,7 +1750,7 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
if section == "database": if section == "database":
db = app.database db = app.database
body = ( 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) + _settings_field("retention_days", "retention_days", str(db.retention_days), kind="number", requires_restart=True)
) )
return ( return (
@@ -1331,7 +1762,7 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
if section == "health_summary": if section == "health_summary":
hs = app.health_summary hs = app.health_summary
body = ( 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("interval_hours", "interval_hours", str(hs.interval_hours), kind="number", requires_restart=True)
+ _settings_field("priority", "priority", str(hs.priority), kind="number") + _settings_field("priority", "priority", str(hs.priority), kind="number")
) )
@@ -1344,7 +1775,7 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
if section == "maintenance": if section == "maintenance":
m = app.maintenance m = app.maintenance
body = ( 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("start", "start", m.start, hint="HH:MM, 24h")
+ _settings_field("end", "end", m.end, 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") + _settings_field("max_hours", "max_hours", str(m.max_hours), kind="number", hint="Safety cap for manual maintenance toggles")
@@ -1358,7 +1789,7 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
if section == "notification_mute": if section == "notification_mute":
nm = app.notification_mute nm = app.notification_mute
body = ( 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") + _settings_field("default_hours", "default_hours", str(nm.default_hours), kind="number", hint="How long a manual mute lasts by default")
) )
return ( return (
@@ -1385,7 +1816,7 @@ def _render_flash(flash: str) -> str:
return "" 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: if section not in _SETTINGS_SECTION_SLUGS:
section = "inspector" section = "inspector"
parts = [_page_head(refresh=False, active="settings")] parts = [_page_head(refresh=False, active="settings")]
@@ -1395,18 +1826,27 @@ def _render_settings_page(app: AppConfig, csrf_token: str, flash: str, section:
parts.append('<section class="settings-pane">') parts.append('<section class="settings-pane">')
parts.append(_settings_body_for(section, app, csrf_token)) parts.append(_settings_body_for(section, app, csrf_token))
parts.append("</section></div>\n") parts.append("</section></div>\n")
parts.append(_render_diag_strip(config_ref))
if section == "notifications":
parts.append(_TEST_NOTIFY_JS)
parts.append(_PAGE_TAIL) parts.append(_PAGE_TAIL)
return "".join(parts).encode("utf-8") return "".join(parts).encode("utf-8")
def _render_containers_page(app: AppConfig, states: dict, flash: str) -> bytes: def _render_containers_page(app: AppConfig, config_ref: ConfigRef, states: dict, flash: str) -> bytes:
parts = [_page_head(refresh=False, active="containers")] parts = [_page_head(refresh=False, active="containers")]
parts.append(_render_flash(flash)) parts.append(_render_flash(flash))
parts.append('<h2 class="section-title">Containers</h2>\n') parts.append('<div class="settings-layout">')
parts.append(_render_settings_rail(app, "", containers_tab_active=True))
parts.append('<section class="settings-pane">')
parts.append( parts.append(
'<p class="muted" style="margin:-0.4rem 0 1rem; font-size:0.85rem;">' '<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:0.3rem;">'
"Click a container to edit its restart rules. To add or remove a container, " '<h3 class="pane-title" style="margin:0;">All containers</h3>'
"edit <code>config.yml</code> and restart Inspector.</p>\n" '<a href="/containers/new" style="background:var(--blue); color:#06111f; padding:0.45rem 0.85rem; '
'border-radius:8px; font-weight:700; text-decoration:none; font-size:0.82rem;">+ New container</a>'
'</div>'
'<p class="pane-sub">Click a tile to edit its restart rules. Add or remove containers directly from the UI — '
'<code>config.yml</code> is updated for you.</p>'
) )
parts.append('<div class="container-grid">\n') parts.append('<div class="container-grid">\n')
containers = list(app.containers) containers = list(app.containers)
@@ -1429,65 +1869,212 @@ def _render_containers_page(app: AppConfig, states: dict, flash: str) -> bytes:
"</div></a>\n" "</div></a>\n"
) )
parts.append("</div>\n") parts.append("</div>\n")
parts.append("</section></div>\n")
parts.append(_render_diag_strip(config_ref))
parts.append(_PAGE_TAIL) parts.append(_PAGE_TAIL)
return "".join(parts).encode("utf-8") return "".join(parts).encode("utf-8")
def _render_container_edit_page(app: AppConfig, name: str, csrf_token: str, flash: str) -> bytes | None: # Preset color swatches offered on container edit/new forms.
cfg = next((c for c in app.containers if c.name == name), None) _COLOR_PALETTE = [
if cfg is None: "#60a5fa", "#34d399", "#fbbf24", "#f87171", "#a78bfa",
return None "#f472b6", "#2dd4bf", "#fb923c", "#94a3b8", "#a3a3a3",
]
restart_on_opts = ["HEALTHY", "UNHEALTHY", "STARTING", "NO HEALTH CHECK",
"NOT RUNNING", "CONTAINER NOT FOUND", "ERROR", "UNKNOWN", "UNREACHABLE"] def _render_color_input(name: str, value: str) -> str:
checkboxes: list[str] = [] """Hex text input + native color picker + preset swatches, all bound together."""
for s in restart_on_opts: swatches = "".join(
checked = "checked" if s in cfg.restart_on else "" f'<span class="swatch" style="background:{c};" '
checkboxes.append( f"onclick=\"document.getElementById('s_{name}').value='{c}'; "
f'<label class="pill"><input type="checkbox" name="restart_on" ' f"document.getElementById('p_{name}').value='{c}';\"></span>"
f'value="{_escape(s)}" {checked}>{_escape(s)}</label>' for c in _COLOR_PALETTE
) )
restart_on_html = ( picker_value = value if value and value.startswith("#") else "#60a5fa"
'<div class="field"><label>restart_on</label>' return (
f'<div class="pill-row">{"".join(checkboxes)}</div>' f'<div class="field"><label>color</label>'
'<div class="field-hint">Statuses that trigger a restart. Leave all off for observe-only.</div>' f'<div class="color-row">'
f'<input id="p_{name}" type="color" value="{_escape(picker_value)}" '
f"onchange=\"document.getElementById('s_{name}').value=this.value;\">"
f'<input id="s_{name}" type="text" name="{_escape(name)}" value="{_escape(value)}" '
f'placeholder="#60a5fa or blank for auto">'
"</div>"
f'<div class="color-swatches">{swatches}</div>'
'<div class="field-hint">Pick a swatch, use the picker, or type a hex code. Blank = auto palette.</div>'
"</div>" "</div>"
) )
# (status, card accent color, plain-language description) for the restart_on grid.
# Ordered most-common trigger first.
_RESTART_ON_OPTIONS = [
("UNHEALTHY", "#f87171", "Docker health check is failing."),
("NOT RUNNING", "#fb923c", "Container is stopped, exited, or crashed."),
("UNREACHABLE", "#fbbf24", "Running, but its HTTP / port check failed."),
("ERROR", "#f472b6", "Inspector hit an error while checking it."),
("STARTING", "#60a5fa", "Health check still in its start period."),
("UNKNOWN", "#a78bfa", "Status couldn't be determined."),
("NO HEALTH CHECK", "#94a3b8", "Image defines no health check."),
("CONTAINER NOT FOUND", "#94a3b8", "No container with this name exists."),
("HEALTHY", "#34d399", "Passing — rarely a restart trigger."),
]
_RESTART_ON_RECOMMENDED = ("UNHEALTHY", "NOT RUNNING")
_RESTART_ON_JS = (
"<script>window.InspectorRestartOn=function(btn,mode){"
"var rec=" + json.dumps(list(_RESTART_ON_RECOMMENDED)) + ";"
"btn.closest('.field').querySelectorAll('.rcard input').forEach(function(cb){"
"cb.checked=(mode==='recommended')&&rec.indexOf(cb.value)>=0;"
"cb.parentElement.classList.toggle('on',cb.checked);});};</script>"
)
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."""
current_restart_on = getattr(cfg, "restart_on", []) or []
cards: list[str] = []
for status, color, desc in _RESTART_ON_OPTIONS:
is_on = status in current_restart_on
cards.append(
f'<label class="rcard{" on" if is_on else ""}" style="--rc-col:{color};">'
f'<input type="checkbox" name="restart_on" value="{_escape(status)}"'
f'{" checked" if is_on else ""} '
"onchange=\"this.parentElement.classList.toggle('on', this.checked)\">"
'<div class="rc-head"><span class="rc-dot"></span>'
f'<span class="rc-name">{_escape(status)}</span>'
'<span class="rc-check"></span></div>'
f'<div class="rc-desc">{_escape(desc)}</div>'
"</label>"
)
restart_on_html = (
'<div class="field">'
# Marker so the backend knows the grid was submitted; an all-unchecked
# grid then means "observe only" rather than "leave unchanged".
'<input type="hidden" name="restart_on_present" value="1">'
'<div class="restart-on-head"><label>Restart when status is…</label>'
'<div class="quick">'
'<button type="button" onclick="InspectorRestartOn(this, \'recommended\')">Recommended</button>'
'<button type="button" onclick="InspectorRestartOn(this, \'none\')">Observe only</button>'
"</div></div>"
f'<div class="restart-on-grid">{"".join(cards)}</div>'
'<div class="field-hint">Inspector restarts the container when it sees any selected status '
"<code>max_error_count</code> times in a row. Select none for observe-only "
"(alerts without restarts).</div>"
"</div>"
+ _RESTART_ON_JS
)
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 = ( body = (
_settings_field("enabled", "enabled", "on" if cfg.enabled else "", kind="checkbox") name_field
+ _settings_field("max_error_count", "max_error_count", str(cfg.max_error_count), kind="number", + _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") 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") 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)") 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") 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 + restart_on_html
) )
form_html = ( delete_btn = ""
f'<form method="post" action="/containers/{_escape(name)}">' if show_delete:
delete_btn = (
f'<form method="post" action="/containers/{_escape(name)}/delete" '
'style="display:inline;" '
f"onsubmit=\"return confirm('Delete container {_escape(name)} from config.yml?');\">"
f'<input type="hidden" name="csrf" value="{_escape(csrf_token)}">'
'<button type="submit" class="btn-danger">Delete container</button>'
"</form>"
)
return (
f'<form method="post" action="{action}">'
f'<input type="hidden" name="csrf" value="{_escape(csrf_token)}">' f'<input type="hidden" name="csrf" value="{_escape(csrf_token)}">'
f"{body}" f"{body}"
'<div class="form-actions">' '<div class="form-actions">'
f"{delete_btn}"
'<a class="btn secondary" href="/containers" style="background:transparent; color:#cbd5e1; ' '<a class="btn secondary" href="/containers" style="background:transparent; color:#cbd5e1; '
'border:1px solid var(--border); border-radius:8px; padding:0.55rem 1rem; font-weight:700; ' 'border:1px solid var(--border); border-radius:8px; padding:0.55rem 1rem; font-weight:700; '
'text-decoration:none; font-family:inherit;">Back</a>' 'text-decoration:none; font-family:inherit;">Back</a>'
'<button class="btn" type="submit" style="background:var(--blue); color:#06111f; border:none; ' f'<button class="btn" type="submit" style="background:var(--blue); color:#06111f; border:none; '
'border-radius:8px; padding:0.55rem 1rem; font-weight:700; cursor:pointer; font-family:inherit;">' f'border-radius:8px; padding:0.55rem 1rem; font-weight:700; cursor:pointer; font-family:inherit;">'
"Save container</button></div>" f"{_escape(submit_label)}</button></div>"
"</form>\n" "</form>\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 = [_page_head(refresh=False, active="containers")]
parts.append(_render_flash(flash)) parts.append(_render_flash(flash))
parts.append('<div class="settings-layout">')
parts.append(_render_settings_rail(app, "", active_container=name))
parts.append('<section class="settings-pane">')
parts.append( parts.append(
f'<h2 class="section-title">Container — <code style="font-size:1em;">{_escape(name)}</code></h2>\n' f'<h3 class="pane-title">Container — <code style="font-size:0.95em;">{_escape(name)}</code></h3>'
'<p class="pane-sub">Restart rules Inspector applies when this container\'s health changes.</p>'
) )
parts.append('<div class="settings-pane" style="background:var(--card); border:1px solid var(--border); border-radius:14px; padding:1.5rem 1.75rem;">')
parts.append(form_html) parts.append(form_html)
parts.append("</div>\n") parts.append("</section></div>\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('<div class="settings-layout">')
parts.append(_render_settings_rail(app, ""))
parts.append('<section class="settings-pane">')
parts.append(
'<h3 class="pane-title">New container</h3>'
'<p class="pane-sub">Add a container to <code>config.yml</code>. '
"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.</p>"
)
parts.append(form_html)
parts.append("</section></div>\n")
parts.append(_render_diag_strip(config_ref))
parts.append(_PAGE_TAIL) parts.append(_PAGE_TAIL)
return "".join(parts).encode("utf-8") return "".join(parts).encode("utf-8")
@@ -1676,17 +2263,22 @@ class _Handler(BaseHTTPRequestHandler):
section = path[len("/settings/"):].strip("/") section = path[len("/settings/"):].strip("/")
csrf = self._csrf_token_for_session() csrf = self._csrf_token_for_session()
flash = query.get("flash", [""])[0] 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) self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body)
elif path == "/containers": elif path == "/containers":
flash = query.get("flash", [""])[0] flash = query.get("flash", [""])[0]
body = _render_containers_page(app, server.states, flash) body = _render_containers_page(app, server.config_ref, 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) self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body)
elif path.startswith("/containers/"): elif path.startswith("/containers/"):
name = path[len("/containers/"):].strip("/") name = path[len("/containers/"):].strip("/")
csrf = self._csrf_token_for_session() csrf = self._csrf_token_for_session()
flash = query.get("flash", [""])[0] 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: if body is None:
self._send_bytes(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"unknown container") self._send_bytes(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"unknown container")
return return
@@ -1822,6 +2414,44 @@ class _Handler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
return 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/<name>/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": if len(parts) == 2 and parts[0] == "containers":
name = parts[1] name = parts[1]
if name not in containers: if name not in containers:
@@ -1842,6 +2472,14 @@ class _Handler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
return return
# POST /api/test-notify/<channel> — 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") self._send_bytes(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"not found")