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
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.7.1"
|
||||
__version__ = "0.7.2"
|
||||
+566
-45
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
@@ -628,6 +629,98 @@ a.signout:hover { color: var(--ink); border-color: var(--muted); }
|
||||
.container-tile .ctn-meta { font-size: 0.75rem; color: var(--muted); display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; }
|
||||
.container-tile .ctn-meta b { color: var(--ink); font-weight: 600; }
|
||||
.container-tile .ctn-chev { color: var(--muted); font-size: 1rem; }
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle {
|
||||
position: relative; display: inline-block; width: 36px; height: 20px;
|
||||
background: #273449; border-radius: 999px; cursor: pointer; flex-shrink: 0;
|
||||
transition: background 0.15s; vertical-align: middle;
|
||||
}
|
||||
.toggle::after {
|
||||
content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px;
|
||||
background: #fff; border-radius: 50%; transition: transform 0.15s;
|
||||
}
|
||||
.toggle input { display: none; }
|
||||
.toggle.on { background: var(--blue); }
|
||||
.toggle.on::after { transform: translateX(16px); }
|
||||
.toggle-row {
|
||||
display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.9rem;
|
||||
}
|
||||
.toggle-row .toggle-label { font-size: 0.9rem; color: var(--ink); font-weight: 500; }
|
||||
|
||||
/* Priority reference box */
|
||||
.priority-ref {
|
||||
background: #0f1829; border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 0.6rem 0.8rem; margin: 0 0 0.9rem;
|
||||
font-size: 0.75rem; color: var(--muted);
|
||||
}
|
||||
.priority-ref b { color: var(--ink); font-weight: 700; margin-right: 0.3rem; }
|
||||
.priority-ref .plevel { display: inline-block; margin-right: 0.9rem; white-space: nowrap; }
|
||||
.priority-ref .plevel code {
|
||||
background: #060b16; padding: 0.05rem 0.3rem; border-radius: 4px;
|
||||
color: var(--blue); margin-right: 0.2rem; font-size: 0.72rem;
|
||||
}
|
||||
|
||||
/* Diagnostics strip at the bottom of settings pages */
|
||||
.diag-strip {
|
||||
margin-top: 1rem; padding: 0.65rem 0.9rem;
|
||||
background: #0f1829; border: 1px solid var(--border); border-radius: 10px;
|
||||
font-size: 0.75rem; color: var(--muted);
|
||||
display: flex; align-items: center; gap: 0.9rem; flex-wrap: wrap;
|
||||
}
|
||||
.diag-strip code {
|
||||
background: #060b16; padding: 0.1rem 0.4rem; border-radius: 4px;
|
||||
color: #cbd5e1; font-size: 0.72rem;
|
||||
}
|
||||
.diag-strip .diag-badge {
|
||||
display: inline-flex; align-items: center; gap: 0.25rem;
|
||||
padding: 0.08rem 0.45rem; border-radius: 999px; font-weight: 700;
|
||||
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.diag-strip .diag-badge.ok { background: var(--green-bg); color: var(--green); }
|
||||
.diag-strip .diag-badge.err { background: var(--red-bg); color: var(--red); }
|
||||
|
||||
/* Test notification button + result */
|
||||
.test-row {
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
margin: 0.6rem 0 1rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: #0f1829; border: 1px dashed var(--border-2, var(--border));
|
||||
border-radius: 10px;
|
||||
}
|
||||
.btn-ghost {
|
||||
background: transparent; color: var(--blue); border: 1px solid var(--blue);
|
||||
padding: 0.4rem 0.85rem; border-radius: 8px; font-weight: 600; cursor: pointer;
|
||||
font-family: inherit; font-size: 0.8rem;
|
||||
}
|
||||
.btn-ghost:hover { background: var(--blue-bg); }
|
||||
.btn-ghost:disabled { opacity: 0.5; cursor: wait; }
|
||||
.test-result { font-size: 0.78rem; color: var(--muted); flex: 1; min-width: 0; }
|
||||
.test-result.ok { color: var(--green); font-weight: 600; }
|
||||
.test-result.err { color: var(--red); font-weight: 600; }
|
||||
|
||||
/* Color swatches + picker */
|
||||
.color-swatches { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0.3rem 0; }
|
||||
.color-swatches .swatch {
|
||||
width: 22px; height: 22px; border-radius: 50%;
|
||||
border: 2px solid #1e293b; cursor: pointer;
|
||||
transition: transform 0.1s, border-color 0.1s;
|
||||
}
|
||||
.color-swatches .swatch:hover { transform: scale(1.1); border-color: var(--ink); }
|
||||
.color-row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.color-row input[type=color] {
|
||||
width: 32px; height: 32px; padding: 0; border: 1px solid var(--border);
|
||||
border-radius: 6px; background: transparent; cursor: pointer;
|
||||
}
|
||||
.color-row input[type=text] { flex: 1; }
|
||||
|
||||
/* Danger button for delete container */
|
||||
.btn-danger {
|
||||
background: var(--red-bg); color: var(--red); border: 1px solid var(--red);
|
||||
padding: 0.55rem 1rem; border-radius: 8px; font-weight: 700; cursor: pointer;
|
||||
font-family: inherit; font-size: 0.85rem;
|
||||
}
|
||||
.btn-danger:hover { background: var(--red); color: #130709; }
|
||||
</style>"""
|
||||
|
||||
|
||||
@@ -638,8 +731,6 @@ def _page_head(refresh: bool, active: str) -> str:
|
||||
containers_class = "active" if active == "containers" else ""
|
||||
settings_class = "active" if active == "settings" else ""
|
||||
meta_text = f"v{__version__}"
|
||||
if refresh:
|
||||
meta_text += " · auto-refreshes every 30s"
|
||||
return (
|
||||
"<!DOCTYPE html>\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)
|
||||
|
||||
|
||||
_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 = (
|
||||
'<span class="restart-flag" title="takes effect after Inspector restart">'
|
||||
"requires restart</span>"
|
||||
@@ -1152,6 +1374,20 @@ def _settings_field(
|
||||
f'<label><input type="checkbox" name="{_escape(name)}" value="on" {checked}> '
|
||||
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 (
|
||||
f'<div class="field">'
|
||||
f'<label for="s_{_escape(name)}">{label_html}</label>'
|
||||
@@ -1222,7 +1458,7 @@ _SETTINGS_SECTIONS = [
|
||||
_SETTINGS_SECTION_SLUGS = {slug for slug, *_ in _SETTINGS_SECTIONS}
|
||||
|
||||
|
||||
def _render_settings_rail(app: AppConfig, active_slug: str) -> str:
|
||||
def _render_settings_rail(app: AppConfig, active_slug: str, active_container: str = "") -> str:
|
||||
parts: list[str] = ['<nav class="settings-rail">\n']
|
||||
current_group: str | None = None
|
||||
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></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")
|
||||
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:
|
||||
if section == "inspector":
|
||||
insp = app.inspector
|
||||
body = (
|
||||
_settings_field("interval_seconds", "interval_seconds", str(insp.interval_seconds), kind="number")
|
||||
+ _settings_field("log_level", "log_level", insp.log_level, hint="DEBUG, INFO, WARNING, ERROR")
|
||||
+ _settings_field("dry_run", "dry_run", "on" if insp.dry_run else "", kind="checkbox")
|
||||
+ _settings_field("heartbeat", "heartbeat", "on" if insp.heartbeat else "", kind="checkbox")
|
||||
+ _settings_field("dry_run", "dry_run", "on" if insp.dry_run else "", kind="switch")
|
||||
+ _settings_field("heartbeat", "heartbeat", "on" if insp.heartbeat else "", kind="switch")
|
||||
+ _settings_field("log_retention_days", "log_retention_days", str(insp.log_retention_days), kind="number", requires_restart=True)
|
||||
)
|
||||
return (
|
||||
@@ -1274,39 +1605,64 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
|
||||
if section == "notifications":
|
||||
g, p = app.gotify, app.pushover
|
||||
gotify_body = (
|
||||
_settings_field("enabled", "enabled", "on" if g.enabled else "", kind="checkbox", requires_restart=True)
|
||||
_settings_field("enabled", "enabled", "on" if g.enabled else "", kind="switch", requires_restart=True)
|
||||
+ _settings_field("url", "url", g.url, hint="Base URL of your Gotify server", requires_restart=True)
|
||||
+ _settings_secret_field("token", "token", bool(g.token))
|
||||
+ _settings_field("priority", "priority", str(g.priority), kind="number")
|
||||
+ _settings_field("priority", "priority", str(g.priority), kind="number", hint="Inspector-side priority (1–10). See reference above.")
|
||||
)
|
||||
pushover_body = (
|
||||
_settings_field("enabled", "enabled", "on" if p.enabled else "", kind="checkbox", requires_restart=True)
|
||||
_settings_field("enabled", "enabled", "on" if p.enabled else "", kind="switch", requires_restart=True)
|
||||
+ _settings_secret_field("token", "token", bool(p.token))
|
||||
+ _settings_secret_field("user_key", "user_key", bool(p.user_key))
|
||||
+ _settings_field("priority", "priority", str(p.priority), kind="number", hint="Mapped at send time to Pushover's -2..2 range")
|
||||
+ _settings_field("priority", "priority", str(p.priority), kind="number", hint="Inspector-side priority (1–10). Mapped to Pushover's -2..2 at send time.")
|
||||
+ _settings_field("device", "device", p.device, hint="Optional; blank = all devices")
|
||||
+ _settings_field("sound", "sound", p.sound, hint="Optional; blank = user default")
|
||||
)
|
||||
gotify_pill = '<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>'
|
||||
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 (
|
||||
'<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>'
|
||||
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>'
|
||||
+ _settings_form("gotify", "Gotify", gotify_body, csrf_token)
|
||||
+ _settings_form_with_test("gotify", "Gotify", gotify_body, gotify_test_row, csrf_token)
|
||||
+ "</div>"
|
||||
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: '
|
||||
'<code>≤2 → -1</code>, <code>3-4 → 0</code>, <code>5-6 → 1</code>, <code>≥7 → 2</code>.</p>'
|
||||
+ _settings_form("pushover", "Pushover", pushover_body, csrf_token)
|
||||
+ f'<div class="subsection"><h4>Pushover {pushover_pill}</h4>'
|
||||
'<p class="subsection-sub">Independent of Gotify. Supports priorities <code>-2</code> (lowest) through <code>2</code> (emergency, which Pushover retries until acknowledged).</p>'
|
||||
+ _settings_form_with_test("pushover", "Pushover", pushover_body, pushover_test_row, csrf_token)
|
||||
+ "</div>"
|
||||
)
|
||||
|
||||
if section == "dashboard":
|
||||
d = app.dashboard
|
||||
body = (
|
||||
_settings_field("enabled", "enabled", "on" if d.enabled else "", kind="checkbox", requires_restart=True)
|
||||
_settings_field("enabled", "enabled", "on" if d.enabled else "", kind="switch", requires_restart=True)
|
||||
+ _settings_field("username", "username", d.username)
|
||||
+ _settings_secret_field("password", "password", bool(d.password))
|
||||
)
|
||||
@@ -1319,7 +1675,7 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
|
||||
if section == "database":
|
||||
db = app.database
|
||||
body = (
|
||||
_settings_field("enabled", "enabled", "on" if db.enabled else "", kind="checkbox", requires_restart=True)
|
||||
_settings_field("enabled", "enabled", "on" if db.enabled else "", kind="switch", requires_restart=True)
|
||||
+ _settings_field("retention_days", "retention_days", str(db.retention_days), kind="number", requires_restart=True)
|
||||
)
|
||||
return (
|
||||
@@ -1331,7 +1687,7 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
|
||||
if section == "health_summary":
|
||||
hs = app.health_summary
|
||||
body = (
|
||||
_settings_field("enabled", "enabled", "on" if hs.enabled else "", kind="checkbox", requires_restart=True)
|
||||
_settings_field("enabled", "enabled", "on" if hs.enabled else "", kind="switch", requires_restart=True)
|
||||
+ _settings_field("interval_hours", "interval_hours", str(hs.interval_hours), kind="number", requires_restart=True)
|
||||
+ _settings_field("priority", "priority", str(hs.priority), kind="number")
|
||||
)
|
||||
@@ -1344,7 +1700,7 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
|
||||
if section == "maintenance":
|
||||
m = app.maintenance
|
||||
body = (
|
||||
_settings_field("enabled", "enabled", "on" if m.enabled else "", kind="checkbox", requires_restart=True)
|
||||
_settings_field("enabled", "enabled", "on" if m.enabled else "", kind="switch", requires_restart=True)
|
||||
+ _settings_field("start", "start", m.start, hint="HH:MM, 24h")
|
||||
+ _settings_field("end", "end", m.end, hint="HH:MM, 24h")
|
||||
+ _settings_field("max_hours", "max_hours", str(m.max_hours), kind="number", hint="Safety cap for manual maintenance toggles")
|
||||
@@ -1358,7 +1714,7 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
|
||||
if section == "notification_mute":
|
||||
nm = app.notification_mute
|
||||
body = (
|
||||
_settings_field("enabled", "enabled", "on" if nm.enabled else "", kind="checkbox", requires_restart=True)
|
||||
_settings_field("enabled", "enabled", "on" if nm.enabled else "", kind="switch", requires_restart=True)
|
||||
+ _settings_field("default_hours", "default_hours", str(nm.default_hours), kind="number", hint="How long a manual mute lasts by default")
|
||||
)
|
||||
return (
|
||||
@@ -1385,7 +1741,7 @@ def _render_flash(flash: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _render_settings_page(app: AppConfig, csrf_token: str, flash: str, section: str) -> bytes:
|
||||
def _render_settings_page(app: AppConfig, config_ref: ConfigRef, csrf_token: str, flash: str, section: str) -> bytes:
|
||||
if section not in _SETTINGS_SECTION_SLUGS:
|
||||
section = "inspector"
|
||||
parts = [_page_head(refresh=False, active="settings")]
|
||||
@@ -1395,6 +1751,9 @@ def _render_settings_page(app: AppConfig, csrf_token: str, flash: str, section:
|
||||
parts.append('<section class="settings-pane">')
|
||||
parts.append(_settings_body_for(section, app, csrf_token))
|
||||
parts.append("</section></div>\n")
|
||||
parts.append(_render_diag_strip(config_ref))
|
||||
if section == "notifications":
|
||||
parts.append(_TEST_NOTIFY_JS)
|
||||
parts.append(_PAGE_TAIL)
|
||||
return "".join(parts).encode("utf-8")
|
||||
|
||||
@@ -1402,11 +1761,17 @@ def _render_settings_page(app: AppConfig, csrf_token: str, flash: str, section:
|
||||
def _render_containers_page(app: AppConfig, states: dict, flash: str) -> bytes:
|
||||
parts = [_page_head(refresh=False, active="containers")]
|
||||
parts.append(_render_flash(flash))
|
||||
parts.append('<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(
|
||||
'<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, "
|
||||
"edit <code>config.yml</code> and restart Inspector.</p>\n"
|
||||
"Click a tile to edit its restart rules, or add / remove containers from the UI "
|
||||
"(the underlying <code>config.yml</code> is updated for you).</p>\n"
|
||||
)
|
||||
parts.append('<div class="container-grid">\n')
|
||||
containers = list(app.containers)
|
||||
@@ -1433,16 +1798,47 @@ def _render_containers_page(app: AppConfig, states: dict, flash: str) -> bytes:
|
||||
return "".join(parts).encode("utf-8")
|
||||
|
||||
|
||||
def _render_container_edit_page(app: AppConfig, name: str, csrf_token: str, flash: str) -> bytes | None:
|
||||
cfg = next((c for c in app.containers if c.name == name), None)
|
||||
if cfg is None:
|
||||
return None
|
||||
# Preset color swatches offered on container edit/new forms.
|
||||
_COLOR_PALETTE = [
|
||||
"#60a5fa", "#34d399", "#fbbf24", "#f87171", "#a78bfa",
|
||||
"#f472b6", "#2dd4bf", "#fb923c", "#94a3b8", "#a3a3a3",
|
||||
]
|
||||
|
||||
|
||||
def _render_color_input(name: str, value: str) -> str:
|
||||
"""Hex text input + native color picker + preset swatches, all bound together."""
|
||||
swatches = "".join(
|
||||
f'<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",
|
||||
"NOT RUNNING", "CONTAINER NOT FOUND", "ERROR", "UNKNOWN", "UNREACHABLE"]
|
||||
current_restart_on = getattr(cfg, "restart_on", []) or []
|
||||
checkboxes: list[str] = []
|
||||
for s in restart_on_opts:
|
||||
checked = "checked" if s in cfg.restart_on else ""
|
||||
checked = "checked" if s in current_restart_on else ""
|
||||
checkboxes.append(
|
||||
f'<label class="pill"><input type="checkbox" name="restart_on" '
|
||||
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>"
|
||||
)
|
||||
name_field = ""
|
||||
if show_name_field:
|
||||
name_field = _settings_field(
|
||||
"name", "name", "",
|
||||
hint="Container name as it appears in Docker. Letters, digits, _ and - only.",
|
||||
)
|
||||
body = (
|
||||
_settings_field("enabled", "enabled", "on" if cfg.enabled else "", kind="checkbox")
|
||||
+ _settings_field("max_error_count", "max_error_count", str(cfg.max_error_count), kind="number",
|
||||
name_field
|
||||
+ _settings_field("enabled", "enabled", "on" if getattr(cfg, "enabled", True) else "", kind="switch")
|
||||
+ _settings_field("max_error_count", "max_error_count",
|
||||
str(getattr(cfg, "max_error_count", 5)), kind="number",
|
||||
hint="Consecutive failing checks before a restart is attempted")
|
||||
+ _settings_field("cooldown_seconds", "cooldown_seconds", str(cfg.cooldown_seconds), kind="number",
|
||||
+ _settings_field("cooldown_seconds", "cooldown_seconds",
|
||||
str(getattr(cfg, "cooldown_seconds", 300)), kind="number",
|
||||
hint="Minimum seconds between restarts")
|
||||
+ _settings_field("grace_period_seconds", "grace_period_seconds", str(cfg.grace_period_seconds), kind="number",
|
||||
+ _settings_field("grace_period_seconds", "grace_period_seconds",
|
||||
str(getattr(cfg, "grace_period_seconds", 120)), kind="number",
|
||||
hint="Ignore failures for this long after a restart (lets the container boot)")
|
||||
+ _settings_field("max_restarts_per_hour", "max_restarts_per_hour", str(cfg.max_restarts_per_hour), kind="number",
|
||||
+ _settings_field("max_restarts_per_hour", "max_restarts_per_hour",
|
||||
str(getattr(cfg, "max_restarts_per_hour", 3)), kind="number",
|
||||
hint="Hourly cap — Inspector pauses restarts after this many")
|
||||
+ _settings_field("color", "color", cfg.color, hint="Hex, e.g. #60a5fa. Blank uses the auto palette.")
|
||||
+ _render_color_input("color", getattr(cfg, "color", ""))
|
||||
+ restart_on_html
|
||||
)
|
||||
form_html = (
|
||||
f'<form method="post" action="/containers/{_escape(name)}">'
|
||||
delete_btn = ""
|
||||
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"{body}"
|
||||
'<div class="form-actions">'
|
||||
f"{delete_btn}"
|
||||
'<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; '
|
||||
'text-decoration:none; font-family:inherit;">Back</a>'
|
||||
'<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;">'
|
||||
"Save container</button></div>"
|
||||
f'<button class="btn" type="submit" style="background:var(--blue); color:#06111f; border:none; '
|
||||
f'border-radius:8px; padding:0.55rem 1rem; font-weight:700; cursor:pointer; font-family:inherit;">'
|
||||
f"{_escape(submit_label)}</button></div>"
|
||||
"</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.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(
|
||||
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("</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)
|
||||
return "".join(parts).encode("utf-8")
|
||||
|
||||
@@ -1676,17 +2146,22 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
section = path[len("/settings/"):].strip("/")
|
||||
csrf = self._csrf_token_for_session()
|
||||
flash = query.get("flash", [""])[0]
|
||||
body = _render_settings_page(app, csrf, flash, section)
|
||||
body = _render_settings_page(app, server.config_ref, csrf, flash, section)
|
||||
self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body)
|
||||
elif path == "/containers":
|
||||
flash = query.get("flash", [""])[0]
|
||||
body = _render_containers_page(app, server.states, flash)
|
||||
self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body)
|
||||
elif path == "/containers/new":
|
||||
csrf = self._csrf_token_for_session()
|
||||
flash = query.get("flash", [""])[0]
|
||||
body = _render_container_new_page(app, server.config_ref, csrf, flash)
|
||||
self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body)
|
||||
elif path.startswith("/containers/"):
|
||||
name = path[len("/containers/"):].strip("/")
|
||||
csrf = self._csrf_token_for_session()
|
||||
flash = query.get("flash", [""])[0]
|
||||
body = _render_container_edit_page(app, name, csrf, flash)
|
||||
body = _render_container_edit_page(app, server.config_ref, name, csrf, flash)
|
||||
if body is None:
|
||||
self._send_bytes(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"unknown container")
|
||||
return
|
||||
@@ -1822,6 +2297,44 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
# POST /containers/new — create a new container entry
|
||||
if len(parts) == 2 and parts[0] == "containers" and parts[1] == "new":
|
||||
try:
|
||||
new_name = _apply_container_create(server.config_ref, form)
|
||||
except _SettingsError as exc:
|
||||
flash = f"error:{exc}"
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self.send_header("Location", f"/containers/new?flash={_url_quote(flash)}")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self.send_header("Location", f"/containers/{_url_quote(new_name)}?flash={_url_quote('ok:' + new_name)}")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
# POST /containers/<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":
|
||||
name = parts[1]
|
||||
if name not in containers:
|
||||
@@ -1842,6 +2355,14 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
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")
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user