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:
1 parent
ea1103f52b
commit
9c98bf8056
3 files changed
+435
-84
No files matched your search
@@ -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
|
||||
|
||||
### 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:
|
||||
per-section forms for Inspector loop, Gotify, Pushover, Dashboard,
|
||||
Database, Health summary, Maintenance, Notification mute, and per-container
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.7.0"
|
||||
__version__ = "0.7.1"
|
||||
+392
-83
@@ -520,27 +520,73 @@ a.signout:hover { color: var(--ink); border-color: var(--muted); }
|
||||
.phase-observe { color: var(--muted); font-style: italic; }
|
||||
.phase-maintenance { color: var(--violet); }
|
||||
.phase-timer { font-variant-numeric: tabular-nums; min-width: 3.5ch; }
|
||||
/* Settings */
|
||||
.settings-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
|
||||
.settings-card {
|
||||
/* Settings — Design A: left rail + content pane */
|
||||
.settings-layout {
|
||||
display: grid; grid-template-columns: 240px 1fr; gap: 1.25rem;
|
||||
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-card .field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.75rem; }
|
||||
.settings-card .field-row { flex-direction: row; align-items: center; gap: 0.5rem; }
|
||||
.settings-card label { font-size: 0.78rem; color: var(--muted); font-weight: 600; }
|
||||
.settings-card input[type=text], .settings-card input[type=number], .settings-card input[type=password] {
|
||||
.settings-rail { padding: 1rem 0.5rem 1.25rem 1rem; border-right: 1px solid var(--border); }
|
||||
.settings-rail .rail-group {
|
||||
text-transform: uppercase; font-size: 0.66rem; letter-spacing: 0.08em;
|
||||
color: var(--muted); margin: 0.9rem 0.7rem 0.3rem;
|
||||
}
|
||||
.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;
|
||||
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 {
|
||||
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;
|
||||
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 {
|
||||
@@ -549,9 +595,39 @@ a.signout:hover { color: var(--ink); border-color: var(--muted); }
|
||||
font-size: 0.72rem; color: var(--ink);
|
||||
}
|
||||
.pill input { accent-color: var(--blue); }
|
||||
.flash { padding: 0.6rem 0.85rem; border-radius: 10px; margin-bottom: 1rem; font-size: 0.85rem; }
|
||||
.flash-ok { background: var(--green-bg); color: var(--green); border: 1px solid var(--green); }
|
||||
.flash {
|
||||
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-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>"""
|
||||
|
||||
|
||||
@@ -559,6 +635,7 @@ def _page_head(refresh: bool, active: str) -> str:
|
||||
refresh_meta = '<meta http-equiv="refresh" content="30">\n' if refresh else ""
|
||||
status_class = "active" if active == "status" else ""
|
||||
events_class = "active" if active == "events" else ""
|
||||
containers_class = "active" if active == "containers" else ""
|
||||
settings_class = "active" if active == "settings" else ""
|
||||
meta_text = f"v{__version__}"
|
||||
if refresh:
|
||||
@@ -591,6 +668,7 @@ def _page_head(refresh: bool, active: str) -> str:
|
||||
' <div class="tabs">\n'
|
||||
f' <a href="/" class="{status_class}">Status</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'
|
||||
" </div>\n"
|
||||
f' <div class="meta"><span class="meta-text">{meta_text}</span><a class="signout" href="/logout">Sign out</a></div>\n'
|
||||
@@ -1094,32 +1172,91 @@ def _settings_secret_field(label: str, name: str, has_value: bool) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _render_settings_page(app: AppConfig, csrf_token: str, flash: str) -> bytes:
|
||||
def _form(section: str, title: str, body: str) -> str:
|
||||
# Each tuple: (slug, label, icon, subtitle_fn, group).
|
||||
# Groups show up as uppercase headers in the left rail.
|
||||
def _subtitle_inspector(app: AppConfig) -> str:
|
||||
return f"{app.inspector.interval_seconds}s · dry-run {'on' if app.inspector.dry_run else 'off'}"
|
||||
|
||||
|
||||
def _subtitle_notifications(app: AppConfig) -> str:
|
||||
g = "on" if app.gotify.enabled else "off"
|
||||
p = "on" if app.pushover.enabled else "off"
|
||||
return f"gotify {g} · pushover {p}"
|
||||
|
||||
|
||||
def _subtitle_dashboard(app: AppConfig) -> str:
|
||||
return f"{'on' if app.dashboard.enabled else 'off'} · :{app.dashboard.port}"
|
||||
|
||||
|
||||
def _subtitle_database(app: AppConfig) -> str:
|
||||
return f"{'on' if app.database.enabled else 'off'} · {app.database.retention_days}d retention"
|
||||
|
||||
|
||||
def _subtitle_health_summary(app: AppConfig) -> str:
|
||||
if not app.health_summary.enabled:
|
||||
return "off"
|
||||
return f"on · every {app.health_summary.interval_hours}h"
|
||||
|
||||
|
||||
def _subtitle_maintenance(app: AppConfig) -> str:
|
||||
m = app.maintenance
|
||||
if not m.enabled:
|
||||
return "off"
|
||||
return f"on · {m.start}–{m.end}"
|
||||
|
||||
|
||||
def _subtitle_notification_mute(app: AppConfig) -> str:
|
||||
nm = app.notification_mute
|
||||
return f"{'on' if nm.enabled else 'off'} · {nm.default_hours}h default"
|
||||
|
||||
|
||||
_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'<section class="settings-card"><h3>{_escape(title)}</h3>'
|
||||
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"
|
||||
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 = [_page_head(refresh=False, active="settings")]
|
||||
|
||||
if flash:
|
||||
if flash.startswith("ok:"):
|
||||
parts.append(
|
||||
f'<div class="flash flash-ok">Saved: {_escape(flash[3:])}. '
|
||||
"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 _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")
|
||||
@@ -1128,105 +1265,229 @@ def _render_settings_page(app: AppConfig, csrf_token: str, flash: str) -> bytes:
|
||||
+ _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))
|
||||
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)
|
||||
)
|
||||
|
||||
# Gotify
|
||||
g = app.gotify
|
||||
body = (
|
||||
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, 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")
|
||||
)
|
||||
parts.append(_form("gotify", "Gotify notifications", body))
|
||||
|
||||
# Pushover
|
||||
p = app.pushover
|
||||
body = (
|
||||
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")
|
||||
+ _settings_field("device", "device", p.device, hint="optional; blank = all devices")
|
||||
+ _settings_field("sound", "sound", p.sound, hint="optional; blank = user default")
|
||||
+ _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>≤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)
|
||||
+ "</div>"
|
||||
)
|
||||
parts.append(_form("pushover", "Pushover notifications", body))
|
||||
|
||||
# Dashboard
|
||||
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))
|
||||
)
|
||||
parts.append(_form("dashboard", "Dashboard", body))
|
||||
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)
|
||||
)
|
||||
|
||||
# Database
|
||||
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)
|
||||
)
|
||||
parts.append(_form("database", "Event history (SQLite)", body))
|
||||
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)
|
||||
)
|
||||
|
||||
# Health summary
|
||||
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")
|
||||
)
|
||||
parts.append(_form("health_summary", "Health summary digest", body))
|
||||
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)
|
||||
)
|
||||
|
||||
# Maintenance window
|
||||
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")
|
||||
+ _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)
|
||||
)
|
||||
parts.append(_form("maintenance", "Scheduled maintenance window", body))
|
||||
|
||||
# Notification mute
|
||||
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")
|
||||
+ _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)
|
||||
)
|
||||
parts.append(_form("notification_mute", "Notification mute", body))
|
||||
|
||||
# Containers (edit of existing only; add/remove requires YAML edit + restart)
|
||||
for c in app.containers:
|
||||
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(
|
||||
'<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"
|
||||
)
|
||||
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 = []
|
||||
checkboxes: list[str] = []
|
||||
for s in restart_on_opts:
|
||||
checked = "checked" if s in c.restart_on else ""
|
||||
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>'
|
||||
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 c.enabled else "", kind="checkbox")
|
||||
+ _settings_field("max_error_count", "max_error_count", str(c.max_error_count), kind="number")
|
||||
+ _settings_field("cooldown_seconds", "cooldown_seconds", str(c.cooldown_seconds), kind="number")
|
||||
+ _settings_field("grace_period_seconds", "grace_period_seconds", str(c.grace_period_seconds), kind="number")
|
||||
+ _settings_field("max_restarts_per_hour", "max_restarts_per_hour", str(c.max_restarts_per_hour), kind="number")
|
||||
+ _settings_field("color", "color", c.color, hint="hex, e.g. #60a5fa; blank = auto")
|
||||
_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
|
||||
)
|
||||
parts.append(_form(f"container:{c.name}", f"Container — {c.name}", body))
|
||||
|
||||
parts.append("</div>\n")
|
||||
parts.append(
|
||||
'<p class="muted" style="margin-top:1rem">To add or remove a container, '
|
||||
"edit <code>config.yml</code> and restart Inspector.</p>\n"
|
||||
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)
|
||||
return "".join(parts).encode("utf-8")
|
||||
|
||||
@@ -1404,10 +1665,31 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
events = []
|
||||
body = json.dumps(events).encode("utf-8")
|
||||
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()
|
||||
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)
|
||||
else:
|
||||
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":
|
||||
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:
|
||||
_apply_settings_update(server.config_ref, section, form)
|
||||
except _SettingsError as exc:
|
||||
flash = f"error:{exc}"
|
||||
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.end_headers()
|
||||
return
|
||||
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.end_headers()
|
||||
return
|
||||
|
||||
Reference in new issue
Block a user