Release v0.7.1 — Settings page redesign + /containers route

- Settings page now uses a left-rail + content-pane layout. Each
  section is URL-addressable (/settings/inspector, /settings/notifications,
  ...). The rail shows a live subtitle under each item so state is
  readable without clicking in.
- Gotify and Pushover grouped under a single Notifications section
  with one Save per channel.
- Containers moved to a dedicated top-level route: /containers lists
  tiles; /containers/<name> is the edit form. Containers tab added to
  the main nav.
- Saved banner is a prominent green flash with a success check.
This commit is contained in:
Europium 63 committed 2026-10-26 21:14:13 -07:00
1 parent ea1103f52b
commit 48e913e5d5
3 files changed
+484 -133

No files matched your search

+42
View File
@@ -7,10 +7,52 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
--- ---
## [0.7.1] — 2026-10-27
### Changed
- **Settings page redesigned** to a classic SaaS layout (left rail + content pane):
- Left rail groups sections under Core / Data / Scheduling with a live subtitle
under each item (e.g. "60s · dry-run on", "gotify on · pushover off",
"on · :8080") so you can read state without clicking in.
- Each section is URL-addressable: `/settings/inspector`,
`/settings/notifications`, `/settings/dashboard`, `/settings/database`,
`/settings/health_summary`, `/settings/maintenance`, `/settings/notification_mute`.
- Gotify and Pushover now share a single **Notifications** section with
one Save button per channel, so the fan-out model is visible at a glance.
- Save flash is a prominent green banner with a success check.
- **Containers moved to a dedicated top-level route** (`/containers`): a grid
of tiles showing status, enabled, max errors, cooldown, and restart_on at a
glance. Click a tile for the per-container edit form at `/containers/<name>`.
Keeps the Settings page small when buddies have many containers.
- Top navigation adds a **Containers** tab alongside Status / Events / Settings.
### Internal
- `POST /settings/gotify` and `POST /settings/pushover` now redirect back to
`/settings/notifications` (the grouped page).
- `POST /containers/<name>` added as a parallel entry point to the existing
settings backend for container edits.
---
## [0.7.0] — 2026-10-26 ## [0.7.0] — 2026-10-26
### Added ### Added
- **Public packaging** under `euronvault/inspector` on Gitea with a
dedicated image at `gitea.euronvault.com/euronvault/inspector:0.7.0`
(plus `:latest` and `:sha-<short>` tags). Dev continues on
`eugene/inspector`.
- `scripts/sync_public.sh` with an explicit allowlist
(`scripts/public_allowlist.txt`) and grep-based denylist
(`scripts/public_denylist_patterns.txt`) that refuses to publish any
file matching a personal-string pattern (lab IPs, lab container
names, lab env var names, agent-workflow scaffolding paths).
- Sanitized `public/` staging tree: generic README, INSTALL guide,
MIT LICENSE (Copyright (c) 2026 Europium 63), `config.example.yml`
with two neutral example containers, and a `compose.yml` that pulls
from `gitea.euronvault.com/euronvault/inspector`.
- **Web settings page** at `/settings` for editing `config.yml` without SSH: - **Web settings page** at `/settings` for editing `config.yml` without SSH:
per-section forms for Inspector loop, Gotify, Pushover, Dashboard, per-section forms for Inspector loop, Gotify, Pushover, Dashboard,
Database, Health summary, Maintenance, Notification mute, and per-container Database, Health summary, Maintenance, Notification mute, and per-container
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.7.0" __version__ = "0.7.1"
+441 -132
View File
@@ -520,27 +520,73 @@ a.signout:hover { color: var(--ink); border-color: var(--muted); }
.phase-observe { color: var(--muted); font-style: italic; } .phase-observe { color: var(--muted); font-style: italic; }
.phase-maintenance { color: var(--violet); } .phase-maintenance { color: var(--violet); }
.phase-timer { font-variant-numeric: tabular-nums; min-width: 3.5ch; } .phase-timer { font-variant-numeric: tabular-nums; min-width: 3.5ch; }
/* Settings */ /* Settings — Design A: left rail + content pane */
.settings-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); } .settings-layout {
.settings-card { display: grid; grid-template-columns: 240px 1fr; gap: 1.25rem;
background: var(--card); border: 1px solid var(--border); border-radius: 14px; background: var(--card); border: 1px solid var(--border); border-radius: 14px;
padding: 1rem 1.25rem; overflow: hidden; min-height: 540px;
} }
.settings-card h3 { margin: 0 0 0.75rem; font-size: 0.95rem; color: var(--ink); } .settings-rail { padding: 1rem 0.5rem 1.25rem 1rem; border-right: 1px solid var(--border); }
.settings-card .field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.75rem; } .settings-rail .rail-group {
.settings-card .field-row { flex-direction: row; align-items: center; gap: 0.5rem; } text-transform: uppercase; font-size: 0.66rem; letter-spacing: 0.08em;
.settings-card label { font-size: 0.78rem; color: var(--muted); font-weight: 600; } color: var(--muted); margin: 0.9rem 0.7rem 0.3rem;
.settings-card input[type=text], .settings-card input[type=number], .settings-card input[type=password] { }
.settings-rail a {
display: flex; align-items: flex-start; gap: 0.55rem;
padding: 0.5rem 0.7rem; border-radius: 8px; color: #cbd5e1;
text-decoration: none; font-size: 0.84rem; font-weight: 500;
margin-bottom: 0.1rem;
}
.settings-rail a .rail-ico {
flex-shrink: 0; width: 1.1rem; display: inline-block; text-align: center;
font-size: 0.95rem; line-height: 1.2;
}
.settings-rail a .rail-text { flex: 1; min-width: 0; }
.settings-rail a .rail-text .rail-sub {
display: block; font-size: 0.7rem; color: var(--muted); font-weight: 400;
margin-top: 0.1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.settings-rail a:hover:not(.active) { background: #0f1829; color: var(--ink); }
.settings-rail a.active {
background: rgba(96,165,250,0.12); color: var(--blue); font-weight: 600;
}
.settings-rail a.active .rail-sub { color: var(--muted); }
.settings-pane { padding: 1.5rem 1.75rem; }
.settings-pane h3.pane-title { margin: 0 0 0.25rem; font-size: 1.02rem; }
.settings-pane p.pane-sub { color: var(--muted); margin: 0 0 1.5rem; font-size: 0.82rem; }
.settings-pane .subsection {
margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border);
}
.settings-pane .subsection:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
.settings-pane .subsection h4 {
margin: 0 0 0.3rem; font-size: 0.9rem; color: var(--ink);
display: flex; align-items: center; gap: 0.5rem;
}
.settings-pane .subsection .subsection-sub {
margin: 0 0 1rem; color: var(--muted); font-size: 0.78rem;
}
.settings-pane .field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.9rem; }
.settings-pane .field.field-row { flex-direction: row; align-items: center; gap: 0.6rem; }
.settings-pane .field label { font-size: 0.76rem; color: #94a3b8; font-weight: 600; }
.settings-pane .field.field-row label { font-size: 0.85rem; color: #cbd5e1; }
.settings-pane .field input[type=text], .settings-pane .field input[type=number],
.settings-pane .field input[type=password] {
background: var(--bg); border: 1px solid var(--border); border-radius: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
padding: 0.4rem 0.6rem; color: var(--ink); font-size: 0.85rem; font-family: inherit; outline: none; padding: 0.5rem 0.65rem; color: var(--ink); font-size: 0.88rem; font-family: inherit; outline: none;
}
.settings-pane .field input:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(96,165,250,0.15); }
.settings-pane .field-hint { font-size: 0.72rem; color: var(--muted); }
.settings-pane .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.1rem 1rem; }
@media (max-width: 640px) { .settings-pane .grid-2 { grid-template-columns: 1fr; } }
.settings-pane .form-actions {
display: flex; justify-content: flex-end; gap: 0.5rem;
margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);
} }
.settings-card input:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(96,165,250,0.15); }
.settings-card .field-hint { font-size: 0.72rem; color: var(--muted); }
.restart-flag { .restart-flag {
display: inline-block; margin-left: 0.4rem; font-size: 0.65rem; font-weight: 700; display: inline-block; margin-left: 0.4rem; font-size: 0.63rem; font-weight: 700;
text-transform: uppercase; letter-spacing: .05em; text-transform: uppercase; letter-spacing: .05em;
background: var(--violet-bg); color: var(--violet); background: var(--violet-bg); color: var(--violet);
padding: 0.1rem 0.4rem; border-radius: 999px; padding: 0.1rem 0.45rem; border-radius: 999px;
} }
.pill-row { display: flex; gap: 0.3rem; flex-wrap: wrap; } .pill-row { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.pill { .pill {
@@ -549,9 +595,39 @@ a.signout:hover { color: var(--ink); border-color: var(--muted); }
font-size: 0.72rem; color: var(--ink); font-size: 0.72rem; color: var(--ink);
} }
.pill input { accent-color: var(--blue); } .pill input { accent-color: var(--blue); }
.flash { padding: 0.6rem 0.85rem; border-radius: 10px; margin-bottom: 1rem; font-size: 0.85rem; } .flash {
.flash-ok { background: var(--green-bg); color: var(--green); border: 1px solid var(--green); } display: flex; align-items: center; gap: 0.5rem;
padding: 0.6rem 0.85rem; border-radius: 10px; margin-bottom: 1rem;
font-size: 0.85rem; font-weight: 600;
}
.flash-ok {
background: var(--green-bg); color: var(--green);
border: 1px solid rgba(52,211,153,0.4);
}
.flash-ok::before { content: "✓"; font-weight: 800; }
.flash-error { background: var(--red-bg); color: var(--red); border: 1px solid var(--red); } .flash-error { background: var(--red-bg); color: var(--red); border: 1px solid var(--red); }
.flash-note { margin-left: auto; color: var(--muted); font-weight: 400; font-size: 0.78rem; }
/* Container list page */
.container-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem;
}
.container-tile {
background: var(--card); border: 1px solid var(--border);
border-color: color-mix(in srgb, var(--col) 28%, var(--border));
border-radius: 12px; padding: 0.95rem 1.1rem;
text-decoration: none; color: var(--ink); display: block;
transition: border-color 0.15s;
}
.container-tile:hover { border-color: var(--blue); }
.container-tile .row1 {
display: flex; align-items: center; justify-content: space-between;
gap: 0.5rem; margin-bottom: 0.5rem;
}
.container-tile .ctn-name { font-weight: 700; font-size: 1rem; color: var(--col); }
.container-tile .ctn-meta { font-size: 0.75rem; color: var(--muted); display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; }
.container-tile .ctn-meta b { color: var(--ink); font-weight: 600; }
.container-tile .ctn-chev { color: var(--muted); font-size: 1rem; }
</style>""" </style>"""
@@ -559,6 +635,7 @@ def _page_head(refresh: bool, active: str) -> str:
refresh_meta = '<meta http-equiv="refresh" content="30">\n' if refresh else "" refresh_meta = '<meta http-equiv="refresh" content="30">\n' if refresh else ""
status_class = "active" if active == "status" else "" status_class = "active" if active == "status" else ""
events_class = "active" if active == "events" else "" events_class = "active" if active == "events" else ""
containers_class = "active" if active == "containers" else ""
settings_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: if refresh:
@@ -591,6 +668,7 @@ def _page_head(refresh: bool, active: str) -> str:
' <div class="tabs">\n' ' <div class="tabs">\n'
f' <a href="/" class="{status_class}">Status</a>\n' f' <a href="/" class="{status_class}">Status</a>\n'
f' <a href="/events" class="{events_class}">Events</a>\n' f' <a href="/events" class="{events_class}">Events</a>\n'
f' <a href="/containers" class="{containers_class}">Containers</a>\n'
f' <a href="/settings" class="{settings_class}">Settings</a>\n' f' <a href="/settings" class="{settings_class}">Settings</a>\n'
" </div>\n" " </div>\n"
f' <div class="meta"><span class="meta-text">{meta_text}</span><a class="signout" href="/logout">Sign out</a></div>\n' f' <div class="meta"><span class="meta-text">{meta_text}</span><a class="signout" href="/logout">Sign out</a></div>\n'
@@ -1094,139 +1172,322 @@ def _settings_secret_field(label: str, name: str, has_value: bool) -> str:
) )
def _render_settings_page(app: AppConfig, csrf_token: str, flash: str) -> bytes: # Each tuple: (slug, label, icon, subtitle_fn, group).
def _form(section: str, title: str, body: str) -> str: # Groups show up as uppercase headers in the left rail.
return ( def _subtitle_inspector(app: AppConfig) -> str:
f'<section class="settings-card"><h3>{_escape(title)}</h3>' return f"{app.inspector.interval_seconds}s · dry-run {'on' if app.inspector.dry_run else 'off'}"
f'<form method="post" action="/settings/{_escape(section)}">'
f'<input type="hidden" name="csrf" value="{_escape(csrf_token)}">'
f'{body}'
f'<button class="action" type="submit">Save {_escape(title)}</button>'
f"</form></section>\n"
)
parts = [_page_head(refresh=False, active="settings")]
if flash: def _subtitle_notifications(app: AppConfig) -> str:
if flash.startswith("ok:"): g = "on" if app.gotify.enabled else "off"
parts.append( p = "on" if app.pushover.enabled else "off"
f'<div class="flash flash-ok">Saved: {_escape(flash[3:])}. ' return f"gotify {g} · pushover {p}"
"Fields marked <em>requires restart</em> take effect after Inspector restarts.</div>\n"
)
elif flash.startswith("error:"):
parts.append(f'<div class="flash flash-error">{_escape(flash[6:])}</div>\n')
parts.append('<h2 class="section-title">Settings</h2>\n')
parts.append('<div class="settings-grid">\n')
# Inspector def _subtitle_dashboard(app: AppConfig) -> str:
insp = app.inspector return f"{'on' if app.dashboard.enabled else 'off'} · :{app.dashboard.port}"
body = (
_settings_field("interval_seconds", "interval_seconds", str(insp.interval_seconds), kind="number")
+ _settings_field("log_level", "log_level", insp.log_level, hint="DEBUG, INFO, WARNING, ERROR")
+ _settings_field("dry_run", "dry_run", "on" if insp.dry_run else "", kind="checkbox")
+ _settings_field("heartbeat", "heartbeat", "on" if insp.heartbeat else "", kind="checkbox")
+ _settings_field("log_retention_days", "log_retention_days", str(insp.log_retention_days), kind="number", requires_restart=True)
)
parts.append(_form("inspector", "Inspector", body))
# Gotify
g = app.gotify
body = (
_settings_field("enabled", "enabled", "on" if g.enabled else "", kind="checkbox", requires_restart=True)
+ _settings_field("url", "url", g.url, requires_restart=True)
+ _settings_secret_field("token", "token", bool(g.token))
+ _settings_field("priority", "priority", str(g.priority), kind="number")
)
parts.append(_form("gotify", "Gotify notifications", body))
# Pushover def _subtitle_database(app: AppConfig) -> str:
p = app.pushover return f"{'on' if app.database.enabled else 'off'} · {app.database.retention_days}d retention"
body = (
_settings_field("enabled", "enabled", "on" if p.enabled else "", kind="checkbox", requires_restart=True)
+ _settings_secret_field("token", "token", bool(p.token))
+ _settings_secret_field("user_key", "user_key", bool(p.user_key))
+ _settings_field("priority", "priority", str(p.priority), kind="number")
+ _settings_field("device", "device", p.device, hint="optional; blank = all devices")
+ _settings_field("sound", "sound", p.sound, hint="optional; blank = user default")
)
parts.append(_form("pushover", "Pushover notifications", body))
# Dashboard
d = app.dashboard
body = (
_settings_field("enabled", "enabled", "on" if d.enabled else "", kind="checkbox", requires_restart=True)
+ _settings_field("username", "username", d.username)
+ _settings_secret_field("password", "password", bool(d.password))
)
parts.append(_form("dashboard", "Dashboard", body))
# Database def _subtitle_health_summary(app: AppConfig) -> str:
db = app.database if not app.health_summary.enabled:
body = ( return "off"
_settings_field("enabled", "enabled", "on" if db.enabled else "", kind="checkbox", requires_restart=True) return f"on · every {app.health_summary.interval_hours}h"
+ _settings_field("retention_days", "retention_days", str(db.retention_days), kind="number", requires_restart=True)
)
parts.append(_form("database", "Event history (SQLite)", body))
# Health summary
hs = app.health_summary
body = (
_settings_field("enabled", "enabled", "on" if hs.enabled else "", kind="checkbox", requires_restart=True)
+ _settings_field("interval_hours", "interval_hours", str(hs.interval_hours), kind="number", requires_restart=True)
+ _settings_field("priority", "priority", str(hs.priority), kind="number")
)
parts.append(_form("health_summary", "Health summary digest", body))
# Maintenance window def _subtitle_maintenance(app: AppConfig) -> str:
m = app.maintenance m = app.maintenance
body = ( if not m.enabled:
_settings_field("enabled", "enabled", "on" if m.enabled else "", kind="checkbox", requires_restart=True) return "off"
+ _settings_field("start", "start", m.start, hint="HH:MM, 24h") return f"on · {m.start}–{m.end}"
+ _settings_field("end", "end", m.end, hint="HH:MM, 24h")
+ _settings_field("max_hours", "max_hours", str(m.max_hours), kind="number")
)
parts.append(_form("maintenance", "Scheduled maintenance window", body))
# Notification mute
def _subtitle_notification_mute(app: AppConfig) -> str:
nm = app.notification_mute nm = app.notification_mute
body = ( return f"{'on' if nm.enabled else 'off'} · {nm.default_hours}h default"
_settings_field("enabled", "enabled", "on" if nm.enabled else "", kind="checkbox", requires_restart=True)
+ _settings_field("default_hours", "default_hours", str(nm.default_hours), kind="number")
_SETTINGS_SECTIONS = [
("inspector", "Inspector loop", "⚙", _subtitle_inspector, "Core"),
("notifications", "Notifications", "🔔", _subtitle_notifications, "Core"),
("dashboard", "Dashboard", "🖥", _subtitle_dashboard, "Core"),
("database", "Event history", "🗄", _subtitle_database, "Data"),
("health_summary", "Health summary", "📋", _subtitle_health_summary, "Data"),
("maintenance", "Maintenance", "🔧", _subtitle_maintenance, "Scheduling"),
("notification_mute", "Notification mute","🔇", _subtitle_notification_mute, "Scheduling"),
]
_SETTINGS_SECTION_SLUGS = {slug for slug, *_ in _SETTINGS_SECTIONS}
def _render_settings_rail(app: AppConfig, active_slug: str) -> str:
parts: list[str] = ['<nav class="settings-rail">\n']
current_group: str | None = None
for slug, label, icon, sub_fn, group in _SETTINGS_SECTIONS:
if group != current_group:
parts.append(f'<div class="rail-group">{_escape(group)}</div>\n')
current_group = group
active = "active" if slug == active_slug else ""
subtitle = _escape(sub_fn(app))
parts.append(
f'<a class="{active}" href="/settings/{slug}">'
f'<span class="rail-ico">{icon}</span>'
f'<span class="rail-text">{_escape(label)}'
f'<span class="rail-sub">{subtitle}</span>'
f"</span></a>\n"
)
parts.append("</nav>\n")
return "".join(parts)
def _settings_form(section: str, title: str, body: str, csrf_token: str) -> str:
return (
f'<form method="post" action="/settings/{_escape(section)}">'
f'<input type="hidden" name="csrf" value="{_escape(csrf_token)}">'
f"{body}"
'<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"
) )
parts.append(_form("notification_mute", "Notification mute", body))
# Containers (edit of existing only; add/remove requires YAML edit + restart)
for c in app.containers: def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
restart_on_opts = ["HEALTHY", "UNHEALTHY", "STARTING", "NO HEALTH CHECK", if section == "inspector":
"NOT RUNNING", "CONTAINER NOT FOUND", "ERROR", "UNKNOWN", "UNREACHABLE"] insp = app.inspector
checkboxes = []
for s in restart_on_opts:
checked = "checked" if s in c.restart_on else ""
checkboxes.append(
f'<label class="pill"><input type="checkbox" name="restart_on" '
f'value="{_escape(s)}" {checked}>{_escape(s)}</label>'
)
restart_on_html = (
'<div class="field"><label>restart_on</label>'
f'<div class="pill-row">{"".join(checkboxes)}</div></div>'
)
body = ( body = (
_settings_field("enabled", "enabled", "on" if c.enabled else "", kind="checkbox") _settings_field("interval_seconds", "interval_seconds", str(insp.interval_seconds), kind="number")
+ _settings_field("max_error_count", "max_error_count", str(c.max_error_count), kind="number") + _settings_field("log_level", "log_level", insp.log_level, hint="DEBUG, INFO, WARNING, ERROR")
+ _settings_field("cooldown_seconds", "cooldown_seconds", str(c.cooldown_seconds), kind="number") + _settings_field("dry_run", "dry_run", "on" if insp.dry_run else "", kind="checkbox")
+ _settings_field("grace_period_seconds", "grace_period_seconds", str(c.grace_period_seconds), kind="number") + _settings_field("heartbeat", "heartbeat", "on" if insp.heartbeat else "", kind="checkbox")
+ _settings_field("max_restarts_per_hour", "max_restarts_per_hour", str(c.max_restarts_per_hour), kind="number") + _settings_field("log_retention_days", "log_retention_days", str(insp.log_retention_days), kind="number", requires_restart=True)
+ _settings_field("color", "color", c.color, hint="hex, e.g. #60a5fa; blank = auto") )
+ restart_on_html return (
'<h3 class="pane-title">Inspector loop</h3>'
'<p class="pane-sub">How often Inspector polls, how it logs, and whether it actually restarts anything.</p>'
+ _settings_form("inspector", "Inspector", body, csrf_token)
) )
parts.append(_form(f"container:{c.name}", f"Container — {c.name}", body))
parts.append("</div>\n") if section == "notifications":
g, p = app.gotify, app.pushover
gotify_body = (
_settings_field("enabled", "enabled", "on" if g.enabled else "", kind="checkbox", requires_restart=True)
+ _settings_field("url", "url", g.url, hint="Base URL of your Gotify server", requires_restart=True)
+ _settings_secret_field("token", "token", bool(g.token))
+ _settings_field("priority", "priority", str(g.priority), kind="number")
)
pushover_body = (
_settings_field("enabled", "enabled", "on" if p.enabled else "", kind="checkbox", requires_restart=True)
+ _settings_secret_field("token", "token", bool(p.token))
+ _settings_secret_field("user_key", "user_key", bool(p.user_key))
+ _settings_field("priority", "priority", str(p.priority), kind="number", hint="Mapped at send time to Pushover's -2..2 range")
+ _settings_field("device", "device", p.device, hint="Optional; blank = all devices")
+ _settings_field("sound", "sound", p.sound, hint="Optional; blank = user default")
)
gotify_pill = '<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>'
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>'
'<p class="subsection-sub">Push notifications via your Gotify server. Messages render as Markdown.</p>'
+ _settings_form("gotify", "Gotify", gotify_body, 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>&le;2 &rarr; -1</code>, <code>3-4 &rarr; 0</code>, <code>5-6 &rarr; 1</code>, <code>&ge;7 &rarr; 2</code>.</p>'
+ _settings_form("pushover", "Pushover", pushover_body, 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("username", "username", d.username)
+ _settings_secret_field("password", "password", bool(d.password))
)
return (
'<h3 class="pane-title">Dashboard</h3>'
'<p class="pane-sub">This web UI. Host and port are bound at startup — changes require a container restart.</p>'
+ _settings_form("dashboard", "Dashboard", body, csrf_token)
)
if section == "database":
db = app.database
body = (
_settings_field("enabled", "enabled", "on" if db.enabled else "", kind="checkbox", requires_restart=True)
+ _settings_field("retention_days", "retention_days", str(db.retention_days), kind="number", requires_restart=True)
)
return (
'<h3 class="pane-title">Event history</h3>'
'<p class="pane-sub">SQLite log of status changes, restarts, and recoveries. Powers the Events page and the health summary digest.</p>'
+ _settings_form("database", "Event history", body, csrf_token)
)
if section == "health_summary":
hs = app.health_summary
body = (
_settings_field("enabled", "enabled", "on" if hs.enabled else "", kind="checkbox", requires_restart=True)
+ _settings_field("interval_hours", "interval_hours", str(hs.interval_hours), kind="number", requires_restart=True)
+ _settings_field("priority", "priority", str(hs.priority), kind="number")
)
return (
'<h3 class="pane-title">Health summary digest</h3>'
'<p class="pane-sub">Periodic rollup of per-container activity, delivered through the enabled notification channels. Requires Event history enabled to have data.</p>'
+ _settings_form("health_summary", "Health summary", body, csrf_token)
)
if section == "maintenance":
m = app.maintenance
body = (
_settings_field("enabled", "enabled", "on" if m.enabled else "", kind="checkbox", requires_restart=True)
+ _settings_field("start", "start", m.start, hint="HH:MM, 24h")
+ _settings_field("end", "end", m.end, hint="HH:MM, 24h")
+ _settings_field("max_hours", "max_hours", str(m.max_hours), kind="number", hint="Safety cap for manual maintenance toggles")
)
return (
'<h3 class="pane-title">Scheduled maintenance window</h3>'
'<p class="pane-sub">Daily quiet hours. Containers that opt in (<code>maintenance_window: true</code> in their config) are not acted on during the window.</p>'
+ _settings_form("maintenance", "Maintenance", body, csrf_token)
)
if section == "notification_mute":
nm = app.notification_mute
body = (
_settings_field("enabled", "enabled", "on" if nm.enabled else "", kind="checkbox", requires_restart=True)
+ _settings_field("default_hours", "default_hours", str(nm.default_hours), kind="number", hint="How long a manual mute lasts by default")
)
return (
'<h3 class="pane-title">Notification mute</h3>'
'<p class="pane-sub">Lets you mute alerts for a specific container from the Status page. The toggle itself appears on container cards when this is enabled.</p>'
+ _settings_form("notification_mute", "Notification mute", body, csrf_token)
)
return f"<p>Unknown section: {_escape(section)}</p>"
def _render_flash(flash: str) -> str:
if not flash:
return ""
if flash.startswith("ok:"):
label = _escape(flash[3:])
return (
f'<div class="flash flash-ok">Saved — <b>{label}</b> changes applied.'
'<span class="flash-note">Fields marked “requires restart” take effect after Inspector restarts.</span>'
"</div>\n"
)
if flash.startswith("error:"):
return f'<div class="flash flash-error">{_escape(flash[6:])}</div>\n'
return ""
def _render_settings_page(app: AppConfig, csrf_token: str, flash: str, section: str) -> bytes:
if section not in _SETTINGS_SECTION_SLUGS:
section = "inspector"
parts = [_page_head(refresh=False, active="settings")]
parts.append(_render_flash(flash))
parts.append('<div class="settings-layout">')
parts.append(_render_settings_rail(app, section))
parts.append('<section class="settings-pane">')
parts.append(_settings_body_for(section, app, csrf_token))
parts.append("</section></div>\n")
parts.append(_PAGE_TAIL)
return "".join(parts).encode("utf-8")
def _render_containers_page(app: AppConfig, states: dict, flash: str) -> bytes:
parts = [_page_head(refresh=False, active="containers")]
parts.append(_render_flash(flash))
parts.append('<h2 class="section-title">Containers</h2>\n')
parts.append( parts.append(
'<p class="muted" style="margin-top:1rem">To add or remove a container, ' '<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" "edit <code>config.yml</code> and restart Inspector.</p>\n"
) )
parts.append('<div class="container-grid">\n')
containers = list(app.containers)
for i, c in enumerate(containers):
color = _resolve_color(c, i)
state = states.get(c.name)
status = state.current_status if state and state.current_status else "—"
restart_on = ", ".join(sorted(c.restart_on)) if c.restart_on else "observe only"
parts.append(
f'<a class="container-tile" href="/containers/{_escape(c.name)}" '
f'style="--col:{_escape(color)}">'
f'<div class="row1"><span class="ctn-name">{_escape(c.name)}</span>'
f'<span class="ctn-chev">›</span></div>'
'<div class="ctn-meta">'
f'<span>Status: <b>{_escape(status)}</b></span>'
f'<span>Enabled: <b>{"yes" if c.enabled else "no"}</b></span>'
f'<span>Max errors: <b>{c.max_error_count}</b></span>'
f'<span>Cooldown: <b>{c.cooldown_seconds}s</b></span>'
f'<span style="flex-basis:100%; margin-top:0.2rem;">Restart on: <b>{_escape(restart_on)}</b></span>'
"</div></a>\n"
)
parts.append("</div>\n")
parts.append(_PAGE_TAIL)
return "".join(parts).encode("utf-8")
def _render_container_edit_page(app: AppConfig, name: str, csrf_token: str, flash: str) -> bytes | None:
cfg = next((c for c in app.containers if c.name == name), None)
if cfg is None:
return None
restart_on_opts = ["HEALTHY", "UNHEALTHY", "STARTING", "NO HEALTH CHECK",
"NOT RUNNING", "CONTAINER NOT FOUND", "ERROR", "UNKNOWN", "UNREACHABLE"]
checkboxes: list[str] = []
for s in restart_on_opts:
checked = "checked" if s in cfg.restart_on else ""
checkboxes.append(
f'<label class="pill"><input type="checkbox" name="restart_on" '
f'value="{_escape(s)}" {checked}>{_escape(s)}</label>'
)
restart_on_html = (
'<div class="field"><label>restart_on</label>'
f'<div class="pill-row">{"".join(checkboxes)}</div>'
'<div class="field-hint">Statuses that trigger a restart. Leave all off for observe-only.</div>'
"</div>"
)
body = (
_settings_field("enabled", "enabled", "on" if cfg.enabled else "", kind="checkbox")
+ _settings_field("max_error_count", "max_error_count", str(cfg.max_error_count), kind="number",
hint="Consecutive failing checks before a restart is attempted")
+ _settings_field("cooldown_seconds", "cooldown_seconds", str(cfg.cooldown_seconds), kind="number",
hint="Minimum seconds between restarts")
+ _settings_field("grace_period_seconds", "grace_period_seconds", str(cfg.grace_period_seconds), kind="number",
hint="Ignore failures for this long after a restart (lets the container boot)")
+ _settings_field("max_restarts_per_hour", "max_restarts_per_hour", str(cfg.max_restarts_per_hour), kind="number",
hint="Hourly cap — Inspector pauses restarts after this many")
+ _settings_field("color", "color", cfg.color, hint="Hex, e.g. #60a5fa. Blank uses the auto palette.")
+ restart_on_html
)
form_html = (
f'<form method="post" action="/containers/{_escape(name)}">'
f'<input type="hidden" name="csrf" value="{_escape(csrf_token)}">'
f"{body}"
'<div class="form-actions">'
'<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>"
"</form>\n"
)
parts = [_page_head(refresh=False, active="containers")]
parts.append(_render_flash(flash))
parts.append(
f'<h2 class="section-title">Container — <code style="font-size:1em;">{_escape(name)}</code></h2>\n'
)
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(_PAGE_TAIL) parts.append(_PAGE_TAIL)
return "".join(parts).encode("utf-8") return "".join(parts).encode("utf-8")
@@ -1404,10 +1665,31 @@ class _Handler(BaseHTTPRequestHandler):
events = [] events = []
body = json.dumps(events).encode("utf-8") body = json.dumps(events).encode("utf-8")
self._send_bytes(HTTPStatus.OK, "application/json", body) self._send_bytes(HTTPStatus.OK, "application/json", body)
elif path == "/settings": elif path == "/settings" or path.startswith("/settings/"):
# /settings redirects to the first section so the URL is always addressable.
if path == "/settings":
self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", "/settings/inspector")
self.send_header("Content-Length", "0")
self.end_headers()
return
section = path[len("/settings/"):].strip("/")
csrf = self._csrf_token_for_session() csrf = self._csrf_token_for_session()
flash = query.get("flash", [""])[0] flash = query.get("flash", [""])[0]
body = _render_settings_page(app, csrf, flash) body = _render_settings_page(app, csrf, flash, section)
self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body)
elif path == "/containers":
flash = query.get("flash", [""])[0]
body = _render_containers_page(app, server.states, flash)
self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body)
elif path.startswith("/containers/"):
name = path[len("/containers/"):].strip("/")
csrf = self._csrf_token_for_session()
flash = query.get("flash", [""])[0]
body = _render_container_edit_page(app, name, csrf, flash)
if body is None:
self._send_bytes(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"unknown container")
return
self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body) self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body)
else: else:
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")
@@ -1518,17 +1800,44 @@ class _Handler(BaseHTTPRequestHandler):
if len(parts) == 2 and parts[0] == "settings": if len(parts) == 2 and parts[0] == "settings":
section = parts[1] section = parts[1]
# Where to send the browser after save. Notifications are
# Gotify+Pushover grouped under /settings/notifications; everything
# else matches the section slug.
if section in ("gotify", "pushover"):
return_path = "/settings/notifications"
else:
return_path = f"/settings/{section}"
try: try:
_apply_settings_update(server.config_ref, section, form) _apply_settings_update(server.config_ref, section, form)
except _SettingsError as exc: except _SettingsError as exc:
flash = f"error:{exc}" flash = f"error:{exc}"
self.send_response(HTTPStatus.SEE_OTHER) self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", f"/settings?flash={_url_quote(flash)}") self.send_header("Location", f"{return_path}?flash={_url_quote(flash)}")
self.send_header("Content-Length", "0") self.send_header("Content-Length", "0")
self.end_headers() self.end_headers()
return return
self.send_response(HTTPStatus.SEE_OTHER) self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", f"/settings?flash={_url_quote('ok:' + section)}") self.send_header("Location", f"{return_path}?flash={_url_quote('ok:' + section)}")
self.send_header("Content-Length", "0")
self.end_headers()
return
if len(parts) == 2 and parts[0] == "containers":
name = parts[1]
if name not in containers:
self._send_bytes(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"unknown container")
return
try:
_apply_settings_update(server.config_ref, f"container:{name}", form)
except _SettingsError as exc:
flash = f"error:{exc}"
self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", f"/containers/{_url_quote(name)}?flash={_url_quote(flash)}")
self.send_header("Content-Length", "0")
self.end_headers()
return
self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", f"/containers/{_url_quote(name)}?flash={_url_quote('ok:' + name)}")
self.send_header("Content-Length", "0") self.send_header("Content-Length", "0")
self.end_headers() self.end_headers()
return return