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.
This commit is contained in:
1 parent
48e913e5d5
commit
c6d2a67a4b
3 files changed
+611
-46
No files matched your search
@@ -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/<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 @@
|
|||||||
__version__ = "0.7.1"
|
__version__ = "0.7.2"
|
||||||
+566
-45
@@ -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,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 { 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; }
|
||||||
|
|
||||||
|
/* 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>"""
|
||||||
|
|
||||||
|
|
||||||
@@ -638,8 +731,6 @@ def _page_head(refresh: bool, active: str) -> str:
|
|||||||
containers_class = "active" if active == "containers" else ""
|
containers_class = "active" if active == "containers" else ""
|
||||||
settings_class = "active" if active == "settings" else ""
|
settings_class = "active" if active == "settings" else ""
|
||||||
meta_text = f"v{__version__}"
|
meta_text = f"v{__version__}"
|
||||||
if refresh:
|
|
||||||
meta_text += " · auto-refreshes every 30s"
|
|
||||||
return (
|
return (
|
||||||
"<!DOCTYPE html>\n"
|
"<!DOCTYPE html>\n"
|
||||||
'<html lang="en">\n<head>\n'
|
'<html lang="en">\n<head>\n'
|
||||||
@@ -1126,6 +1217,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:
|
||||||
|
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 +1374,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 +1458,7 @@ _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 = "") -> 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 +1474,27 @@ 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.
|
||||||
|
parts.append(
|
||||||
|
f'<div class="rail-group">Containers <span style="color:var(--muted); font-weight:400;">'
|
||||||
|
f'({len(app.containers)})</span></div>\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 +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'<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 +1605,64 @@ 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>'
|
||||||
|
priority_ref = (
|
||||||
|
'<div class="priority-ref"><b>Priority reference</b>'
|
||||||
|
'<span class="plevel"><code>1-2</code> info / debug</span>'
|
||||||
|
'<span class="plevel"><code>3-4</code> normal (default)</span>'
|
||||||
|
'<span class="plevel"><code>5-6</code> warning</span>'
|
||||||
|
'<span class="plevel"><code>7-8</code> error</span>'
|
||||||
|
'<span class="plevel"><code>9-10</code> critical / emergency</span>'
|
||||||
|
'<br><span style="margin-top:0.3rem; display:inline-block;">'
|
||||||
|
'Pushover mapping: <code>≤2 → -1</code> · '
|
||||||
|
'<code>3-4 → 0</code> · <code>5-6 → 1</code> · '
|
||||||
|
'<code>≥7 → 2 (emergency)</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>'
|
+ priority_ref
|
||||||
|
+ 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)
|
+ _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. Supports priorities <code>-2</code> (lowest) through <code>2</code> (emergency, which Pushover retries until acknowledged).</p>'
|
||||||
'<code>≤2 → -1</code>, <code>3-4 → 0</code>, <code>5-6 → 1</code>, <code>≥7 → 2</code>.</p>'
|
+ _settings_form_with_test("pushover", "Pushover", pushover_body, pushover_test_row, csrf_token)
|
||||||
+ _settings_form("pushover", "Pushover", pushover_body, 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 +1675,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 +1687,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 +1700,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 +1714,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 +1741,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,6 +1751,9 @@ 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")
|
||||||
|
|
||||||
@@ -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:
|
def _render_containers_page(app: AppConfig, 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 style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1rem;">'
|
||||||
|
'<h2 class="section-title" style="margin:0;">Containers</h2>'
|
||||||
|
'<a href="/containers/new" style="background:var(--blue); color:#06111f; padding:0.5rem 0.9rem; '
|
||||||
|
'border-radius:8px; font-weight:700; text-decoration:none; font-size:0.85rem;">+ New container</a>'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
parts.append(
|
parts.append(
|
||||||
'<p class="muted" style="margin:-0.4rem 0 1rem; font-size:0.85rem;">'
|
'<p class="muted" style="margin:-0.4rem 0 1rem; font-size:0.85rem;">'
|
||||||
"Click a container to edit its restart rules. To add or remove a container, "
|
"Click a tile to edit its restart rules, or add / remove containers from the UI "
|
||||||
"edit <code>config.yml</code> and restart Inspector.</p>\n"
|
"(the underlying <code>config.yml</code> is updated for you).</p>\n"
|
||||||
)
|
)
|
||||||
parts.append('<div class="container-grid">\n')
|
parts.append('<div class="container-grid">\n')
|
||||||
containers = list(app.containers)
|
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")
|
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",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _render_color_input(name: str, value: str) -> str:
|
||||||
|
"""Hex text input + native color picker + preset swatches, all bound together."""
|
||||||
|
swatches = "".join(
|
||||||
|
f'<span class="swatch" style="background:{c};" '
|
||||||
|
f"onclick=\"document.getElementById('s_{name}').value='{c}'; "
|
||||||
|
f"document.getElementById('p_{name}').value='{c}';\"></span>"
|
||||||
|
for c in _COLOR_PALETTE
|
||||||
|
)
|
||||||
|
picker_value = value if value and value.startswith("#") else "#60a5fa"
|
||||||
|
return (
|
||||||
|
f'<div class="field"><label>color</label>'
|
||||||
|
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>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
restart_on_opts = ["HEALTHY", "UNHEALTHY", "STARTING", "NO HEALTH CHECK",
|
||||||
"NOT RUNNING", "CONTAINER NOT FOUND", "ERROR", "UNKNOWN", "UNREACHABLE"]
|
"NOT RUNNING", "CONTAINER NOT FOUND", "ERROR", "UNKNOWN", "UNREACHABLE"]
|
||||||
|
current_restart_on = getattr(cfg, "restart_on", []) or []
|
||||||
checkboxes: list[str] = []
|
checkboxes: list[str] = []
|
||||||
for s in restart_on_opts:
|
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(
|
checkboxes.append(
|
||||||
f'<label class="pill"><input type="checkbox" name="restart_on" '
|
f'<label class="pill"><input type="checkbox" name="restart_on" '
|
||||||
f'value="{_escape(s)}" {checked}>{_escape(s)}</label>'
|
f'value="{_escape(s)}" {checked}>{_escape(s)}</label>'
|
||||||
@@ -1453,41 +1849,115 @@ def _render_container_edit_page(app: AppConfig, name: str, csrf_token: str, flas
|
|||||||
'<div class="field-hint">Statuses that trigger a restart. Leave all off for observe-only.</div>'
|
'<div class="field-hint">Statuses that trigger a restart. Leave all off for observe-only.</div>'
|
||||||
"</div>"
|
"</div>"
|
||||||
)
|
)
|
||||||
|
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 +2146,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.states, 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 == "/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/"):
|
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 +2297,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 +2355,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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user