Release v0.7.3 — Containers under Settings, per-channel priorities, restart_on cards

- Containers tab moved into the settings rail (All containers grid + per-container entries).
- Per-platform priority references for Gotify and Pushover.
- restart_on redesigned as toggleable cards with descriptions and quick presets.
- Fix: unchecking every restart_on status now saves observe-only.
This commit is contained in:
Europium 63 committed 2026-10-27 14:45:17 -07:00
1 parent c6d2a67a4b
commit 2b0c2ef885
3 files changed
+183 -43

No files matched your search

+23
View File
@@ -7,6 +7,29 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
---
## [0.7.3] — 2026-10-27
### Changed
- **Containers moved into Settings.** The top-nav Containers tab is gone; the
settings rail now has an "All containers" entry (the tile grid at `/containers`)
plus one entry per container (`/containers/<name>`). Both views render inside
the same rail layout, and the Settings tab stays highlighted while you browse them.
- **Per-channel priority reference.** The shared priority block is replaced by one
reference per platform: Gotify (0 silent · 1–3 low · 4–7 normal · 8–10 high) and
Pushover (-2 Lowest · -1 Low · 0 Normal · 1 High · 2 Emergency), with the
Inspector → Pushover mapping noted under the Pushover one.
- **restart_on redesign.** The pill row is replaced by a grid of toggleable cards,
each with a color accent and a plain-language description of the status, plus
"Recommended" (UNHEALTHY + NOT RUNNING) and "Observe only" quick buttons.
### Fixed
- Unchecking every `restart_on` status now saves `restart_on: []` (observe only).
Previously an all-unchecked submit silently kept the old list.
---
## [0.7.2] — 2026-10-27
### Added
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.7.2"
__version__ = "0.7.3"
+159 -42
View File
@@ -714,6 +714,60 @@ a.signout:hover { color: var(--ink); border-color: var(--muted); }
}
.color-row input[type=text] { flex: 1; }
/* restart_on grid */
.restart-on-head {
display: flex; align-items: center; justify-content: space-between;
margin: 0.4rem 0 0.5rem;
}
.restart-on-head label {
font-size: 0.76rem; color: #94a3b8; font-weight: 600;
}
.restart-on-head .quick {
display: flex; gap: 0.4rem;
}
.restart-on-head .quick button {
background: transparent; color: var(--blue); border: 1px solid var(--border);
padding: 0.25rem 0.55rem; border-radius: 6px; font-size: 0.72rem;
cursor: pointer; font-family: inherit; font-weight: 600;
}
.restart-on-head .quick button:hover { background: var(--blue-bg); border-color: var(--blue); }
.restart-on-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 0.5rem; margin-bottom: 0.5rem;
}
.rcard {
position: relative; display: block;
padding: 0.65rem 0.75rem;
background: var(--bg); border: 1px solid var(--border);
border-radius: 10px; cursor: pointer; user-select: none;
transition: border-color 0.1s, background 0.1s;
}
.rcard:hover { border-color: var(--border-2, #273449); }
.rcard input { position: absolute; opacity: 0; pointer-events: none; }
.rcard.on { border-color: var(--rc-col); background: color-mix(in srgb, var(--rc-col) 10%, var(--bg)); }
.rcard .rc-head {
display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.25rem;
}
.rcard .rc-dot {
width: 8px; height: 8px; border-radius: 50%; background: var(--rc-col);
flex-shrink: 0;
}
.rcard .rc-name {
font-size: 0.82rem; font-weight: 700; color: var(--ink);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.rcard .rc-check {
margin-left: auto; width: 16px; height: 16px; border-radius: 4px;
border: 1.5px solid var(--border-2, #273449); background: transparent;
display: flex; align-items: center; justify-content: center;
color: transparent; font-weight: 900; font-size: 0.75rem;
}
.rcard.on .rc-check {
background: var(--rc-col); border-color: var(--rc-col); color: #06111f;
}
.rcard.on .rc-check::after { content: "✓"; }
.rcard .rc-desc { font-size: 0.72rem; color: var(--muted); line-height: 1.3; }
/* Danger button for delete container */
.btn-danger {
background: var(--red-bg); color: var(--red); border: 1px solid var(--red);
@@ -728,8 +782,8 @@ 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 ""
# Settings tab stays active while browsing containers — they live under settings now.
settings_class = "active" if active in ("settings", "containers") else ""
meta_text = f"v{__version__}"
return (
"<!DOCTYPE html>\n"
@@ -759,7 +813,6 @@ 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'
@@ -1195,7 +1248,7 @@ def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> N
cfg["max_restarts_per_hour"] = _form_int(form, "max_restarts_per_hour", 3)
cfg["color"] = _form_str(form, "color", "")
restart_on = form.get("restart_on", [])
if restart_on:
if restart_on or form.get("restart_on_present"):
cfg["restart_on"] = [s.upper() for s in restart_on]
containers[name] = cfg
raw["containers"] = containers
@@ -1242,7 +1295,7 @@ def _apply_container_create(config_ref: ConfigRef, form: dict) -> str:
"color": _form_str(form, "color", ""),
}
restart_on = form.get("restart_on", [])
if restart_on:
if restart_on or form.get("restart_on_present"):
cfg["restart_on"] = [s.upper() for s in restart_on]
containers[name] = cfg
raw["containers"] = containers
@@ -1458,7 +1511,10 @@ _SETTINGS_SECTIONS = [
_SETTINGS_SECTION_SLUGS = {slug for slug, *_ in _SETTINGS_SECTIONS}
def _render_settings_rail(app: AppConfig, active_slug: str, active_container: str = "") -> str:
def _render_settings_rail(
app: AppConfig, active_slug: str, active_container: str = "",
*, containers_tab_active: bool = False,
) -> str:
parts: list[str] = ['<nav class="settings-rail">\n']
current_group: str | None = None
for slug, label, icon, sub_fn, group in _SETTINGS_SECTIONS:
@@ -1474,11 +1530,19 @@ def _render_settings_rail(app: AppConfig, active_slug: str, active_container: st
f'<span class="rail-sub">{subtitle}</span>'
f"</span></a>\n"
)
# Containers group.
# Containers group — a top-level "Containers" entry (goes to the grid view)
# plus per-container entries so you can jump straight to one.
parts.append(
f'<div class="rail-group">Containers <span style="color:var(--muted); font-weight:400;">'
f'({len(app.containers)})</span></div>\n'
)
containers_active = "active" if containers_tab_active else ""
parts.append(
f'<a class="{containers_active}" href="/containers">'
'<span class="rail-ico">📦</span>'
'<span class="rail-text">All containers'
'<span class="rail-sub">grid view</span></span></a>\n'
)
for i, c in enumerate(app.containers):
color = _resolve_color(c, i)
active = "active" if c.name == active_container else ""
@@ -1620,17 +1684,27 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
)
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>&le;2 &rarr; -1</code> &middot; '
'<code>3-4 &rarr; 0</code> &middot; <code>5-6 &rarr; 1</code> &middot; '
'<code>&ge;7 &rarr; 2 (emergency)</code>'
gotify_priority_ref = (
'<div class="priority-ref"><b>Gotify priority levels</b>'
'<span class="plevel"><code>0</code> silent (no popup)</span>'
'<span class="plevel"><code>1-3</code> low (notification only)</span>'
'<span class="plevel"><code>4-7</code> normal (default <code>5</code>)</span>'
'<span class="plevel"><code>8-10</code> high (persistent)</span>'
'</div>'
)
pushover_priority_ref = (
'<div class="priority-ref"><b>Pushover priority levels</b>'
'<span class="plevel"><code>-2</code> Lowest (silent)</span>'
'<span class="plevel"><code>-1</code> Low (quiet)</span>'
'<span class="plevel"><code>0</code> Normal (default)</span>'
'<span class="plevel"><code>1</code> High (bypass quiet hours)</span>'
'<span class="plevel"><code>2</code> Emergency (retries until ack)</span>'
'<br><span style="margin-top:0.3rem; display:inline-block; font-size:0.72rem;">'
'Inspector priority maps automatically: '
'<code>&le;2 &rarr; -1</code> &middot; '
'<code>3-4 &rarr; 0</code> &middot; '
'<code>5-6 &rarr; 1</code> &middot; '
'<code>&ge;7 &rarr; 2</code>'
'</span></div>'
)
gotify_test_row = (
@@ -1648,13 +1722,14 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
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>'
+ 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>'
+ gotify_priority_ref
+ _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">Independent of Gotify. Supports priorities <code>-2</code> (lowest) through <code>2</code> (emergency, which Pushover retries until acknowledged).</p>'
'<p class="subsection-sub">Independent of Gotify. Inspector maps its internal 1–10 priority to Pushover\'s -2..2 range at send time.</p>'
+ pushover_priority_ref
+ _settings_form_with_test("pushover", "Pushover", pushover_body, pushover_test_row, csrf_token)
+ "</div>"
)
@@ -1758,20 +1833,20 @@ def _render_settings_page(app: AppConfig, config_ref: ConfigRef, csrf_token: str
return "".join(parts).encode("utf-8")
def _render_containers_page(app: AppConfig, states: dict, flash: str) -> bytes:
def _render_containers_page(app: AppConfig, config_ref: ConfigRef, states: dict, flash: str) -> bytes:
parts = [_page_head(refresh=False, active="containers")]
parts.append(_render_flash(flash))
parts.append('<div class="settings-layout">')
parts.append(_render_settings_rail(app, "", containers_tab_active=True))
parts.append('<section class="settings-pane">')
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 style="display:flex; align-items:center; justify-content:space-between; margin-bottom:0.3rem;">'
'<h3 class="pane-title" style="margin:0;">All containers</h3>'
'<a href="/containers/new" style="background:var(--blue); color:#06111f; padding:0.45rem 0.85rem; '
'border-radius:8px; font-weight:700; text-decoration:none; font-size:0.82rem;">+ New container</a>'
'</div>'
)
parts.append(
'<p class="muted" style="margin:-0.4rem 0 1rem; font-size:0.85rem;">'
"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"
'<p class="pane-sub">Click a tile to edit its restart rules. Add or remove containers directly from the UI — '
'<code>config.yml</code> is updated for you.</p>'
)
parts.append('<div class="container-grid">\n')
containers = list(app.containers)
@@ -1794,6 +1869,8 @@ def _render_containers_page(app: AppConfig, states: dict, flash: str) -> bytes:
"</div></a>\n"
)
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")
@@ -1828,26 +1905,66 @@ def _render_color_input(name: str, value: str) -> str:
)
# (status, card accent color, plain-language description) for the restart_on grid.
# Ordered most-common trigger first.
_RESTART_ON_OPTIONS = [
("UNHEALTHY", "#f87171", "Docker health check is failing."),
("NOT RUNNING", "#fb923c", "Container is stopped, exited, or crashed."),
("UNREACHABLE", "#fbbf24", "Running, but its HTTP / port check failed."),
("ERROR", "#f472b6", "Inspector hit an error while checking it."),
("STARTING", "#60a5fa", "Health check still in its start period."),
("UNKNOWN", "#a78bfa", "Status couldn't be determined."),
("NO HEALTH CHECK", "#94a3b8", "Image defines no health check."),
("CONTAINER NOT FOUND", "#94a3b8", "No container with this name exists."),
("HEALTHY", "#34d399", "Passing — rarely a restart trigger."),
]
_RESTART_ON_RECOMMENDED = ("UNHEALTHY", "NOT RUNNING")
_RESTART_ON_JS = (
"<script>window.InspectorRestartOn=function(btn,mode){"
"var rec=" + json.dumps(list(_RESTART_ON_RECOMMENDED)) + ";"
"btn.closest('.field').querySelectorAll('.rcard input').forEach(function(cb){"
"cb.checked=(mode==='recommended')&&rec.indexOf(cb.value)>=0;"
"cb.parentElement.classList.toggle('on',cb.checked);});};</script>"
)
def _render_container_form(
name: str, cfg, csrf_token: str, action: str, submit_label: str,
*, show_name_field: bool = False, show_delete: bool = False,
) -> str:
"""Build the container edit form. `cfg` may be a ContainerConfig or a dummy with defaults."""
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 current_restart_on else ""
checkboxes.append(
f'<label class="pill"><input type="checkbox" name="restart_on" '
f'value="{_escape(s)}" {checked}>{_escape(s)}</label>'
cards: list[str] = []
for status, color, desc in _RESTART_ON_OPTIONS:
is_on = status in current_restart_on
cards.append(
f'<label class="rcard{" on" if is_on else ""}" style="--rc-col:{color};">'
f'<input type="checkbox" name="restart_on" value="{_escape(status)}"'
f'{" checked" if is_on else ""} '
"onchange=\"this.parentElement.classList.toggle('on', this.checked)\">"
'<div class="rc-head"><span class="rc-dot"></span>'
f'<span class="rc-name">{_escape(status)}</span>'
'<span class="rc-check"></span></div>'
f'<div class="rc-desc">{_escape(desc)}</div>'
"</label>"
)
restart_on_html = (
'<div class="field"><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 class="field">'
# Marker so the backend knows the grid was submitted; an all-unchecked
# grid then means "observe only" rather than "leave unchanged".
'<input type="hidden" name="restart_on_present" value="1">'
'<div class="restart-on-head"><label>Restart when status is…</label>'
'<div class="quick">'
'<button type="button" onclick="InspectorRestartOn(this, \'recommended\')">Recommended</button>'
'<button type="button" onclick="InspectorRestartOn(this, \'none\')">Observe only</button>'
"</div></div>"
f'<div class="restart-on-grid">{"".join(cards)}</div>'
'<div class="field-hint">Inspector restarts the container when it sees any selected status '
"<code>max_error_count</code> times in a row. Select none for observe-only "
"(alerts without restarts).</div>"
"</div>"
+ _RESTART_ON_JS
)
name_field = ""
if show_name_field:
@@ -2150,7 +2267,7 @@ class _Handler(BaseHTTPRequestHandler):
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)
body = _render_containers_page(app, server.config_ref, server.states, flash)
self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body)
elif path == "/containers/new":
csrf = self._csrf_token_for_session()