@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import json
import logging
import logging
import os
import re
import re
import secrets
import secrets
import sqlite3
import sqlite3
@@ -628,6 +629,152 @@ a.signout:hover { color: var(--ink); border-color: var(--muted); }
.container-tile .ctn-meta { font-size: 0.75rem; color: var(--muted); display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; }
.container-tile .ctn-meta { font-size: 0.75rem; color: var(--muted); display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; }
.container-tile .ctn-meta b { color: var(--ink); font-weight: 600; }
.container-tile .ctn-meta b { color: var(--ink); font-weight: 600; }
.container-tile .ctn-chev { color: var(--muted); font-size: 1rem; }
.container-tile .ctn-chev { color: var(--muted); font-size: 1rem; }
/* Toggle switch */
.toggle {
position: relative; display: inline-block; width: 36px; height: 20px;
background: #273449; border-radius: 999px; cursor: pointer; flex-shrink: 0;
transition: background 0.15s; vertical-align: middle;
}
.toggle::after {
content: " " ; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px;
background: #fff; border-radius: 50 % ; transition: transform 0.15s;
}
.toggle input { display: none; }
.toggle.on { background: var(--blue); }
.toggle.on::after { transform: translateX(16px); }
.toggle-row {
display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.9rem;
}
.toggle-row .toggle-label { font-size: 0.9rem; color: var(--ink); font-weight: 500; }
/* Priority reference box */
.priority-ref {
background: #0f1829; border: 1px solid var(--border); border-radius: 10px;
padding: 0.6rem 0.8rem; margin: 0 0 0.9rem;
font-size: 0.75rem; color: var(--muted);
}
.priority-ref b { color: var(--ink); font-weight: 700; margin-right: 0.3rem; }
.priority-ref .plevel { display: inline-block; margin-right: 0.9rem; white-space: nowrap; }
.priority-ref .plevel code {
background: #060b16; padding: 0.05rem 0.3rem; border-radius: 4px;
color: var(--blue); margin-right: 0.2rem; font-size: 0.72rem;
}
/* Diagnostics strip at the bottom of settings pages */
.diag-strip {
margin-top: 1rem; padding: 0.65rem 0.9rem;
background: #0f1829; border: 1px solid var(--border); border-radius: 10px;
font-size: 0.75rem; color: var(--muted);
display: flex; align-items: center; gap: 0.9rem; flex-wrap: wrap;
}
.diag-strip code {
background: #060b16; padding: 0.1rem 0.4rem; border-radius: 4px;
color: #cbd5e1; font-size: 0.72rem;
}
.diag-strip .diag-badge {
display: inline-flex; align-items: center; gap: 0.25rem;
padding: 0.08rem 0.45rem; border-radius: 999px; font-weight: 700;
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em;
}
.diag-strip .diag-badge.ok { background: var(--green-bg); color: var(--green); }
.diag-strip .diag-badge.err { background: var(--red-bg); color: var(--red); }
/* Test notification button + result */
.test-row {
display: flex; align-items: center; gap: 0.6rem;
margin: 0.6rem 0 1rem;
padding: 0.6rem 0.75rem;
background: #0f1829; border: 1px dashed var(--border-2, var(--border));
border-radius: 10px;
}
.btn-ghost {
background: transparent; color: var(--blue); border: 1px solid var(--blue);
padding: 0.4rem 0.85rem; border-radius: 8px; font-weight: 600; cursor: pointer;
font-family: inherit; font-size: 0.8rem;
}
.btn-ghost:hover { background: var(--blue-bg); }
.btn-ghost:disabled { opacity: 0.5; cursor: wait; }
.test-result { font-size: 0.78rem; color: var(--muted); flex: 1; min-width: 0; }
.test-result.ok { color: var(--green); font-weight: 600; }
.test-result.err { color: var(--red); font-weight: 600; }
/* Color swatches + picker */
.color-swatches { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0.3rem 0; }
.color-swatches .swatch {
width: 22px; height: 22px; border-radius: 50 % ;
border: 2px solid #1e293b; cursor: pointer;
transition: transform 0.1s, border-color 0.1s;
}
.color-swatches .swatch:hover { transform: scale(1.1); border-color: var(--ink); }
.color-row { display: flex; align-items: center; gap: 0.5rem; }
.color-row input[type=color] {
width: 32px; height: 32px; padding: 0; border: 1px solid var(--border);
border-radius: 6px; background: transparent; cursor: pointer;
}
.color-row input[type=text] { flex: 1; }
/* 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);
padding: 0.55rem 1rem; border-radius: 8px; font-weight: 700; cursor: pointer;
font-family: inherit; font-size: 0.85rem;
}
.btn-danger:hover { background: var(--red); color: #130709; }
</style> """
</style> """
@@ -635,11 +782,9 @@ 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 tab stays active while browsing containers — they live under settings now.
settings_class = " active " if active == " settings " else " "
settings_class = " active " if active in ( " settings " , " containers " ) else " "
meta_text = f " v { __version__ } "
meta_text = f " v { __version__ } "
if refresh :
meta_text + = " · auto-refreshes every 30s "
return (
return (
" <!DOCTYPE html> \n "
" <!DOCTYPE html> \n "
' <html lang= " en " > \n <head> \n '
' <html lang= " en " > \n <head> \n '
@@ -668,7 +813,6 @@ 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 '
@@ -1104,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 [ " max_restarts_per_hour " ] = _form_int ( form , " max_restarts_per_hour " , 3 )
cfg [ " color " ] = _form_str ( form , " color " , " " )
cfg [ " color " ] = _form_str ( form , " color " , " " )
restart_on = form . get ( " restart_on " , [ ] )
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 ]
cfg [ " restart_on " ] = [ s . upper ( ) for s in restart_on ]
containers [ name ] = cfg
containers [ name ] = cfg
raw [ " containers " ] = containers
raw [ " containers " ] = containers
@@ -1126,6 +1270,137 @@ def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> N
log . info ( " Settings saved: section= %s " , section )
log . info ( " Settings saved: section= %s " , section )
_CONTAINER_NAME_RE = re . compile ( r " ^[a-zA-Z0-9][a-zA-Z0-9_-] { 0,62}$ " )
def _apply_container_create ( config_ref : ConfigRef , form : dict ) - > str :
""" Insert a new container under raw[ ' containers ' ]. Returns the new name. """
name = _form_str ( form , " name " , " " ) . strip ( )
if not name :
raise _SettingsError ( " container name is required " )
if not _CONTAINER_NAME_RE . match ( name ) :
raise _SettingsError ( " container name must start with a letter/digit and use only letters, digits, _ or - " )
raw = config_ref . get_raw ( )
containers = raw . get ( " containers " ) or { }
if name in containers :
raise _SettingsError ( f " container { name !r} already exists " )
cfg = {
" enabled " : _form_bool ( form , " enabled " ) ,
" max_error_count " : _form_int ( form , " max_error_count " , 5 ) ,
" cooldown_seconds " : _form_int ( form , " cooldown_seconds " , 300 ) ,
" grace_period_seconds " : _form_int ( form , " grace_period_seconds " , 120 ) ,
" max_restarts_per_hour " : _form_int ( form , " max_restarts_per_hour " , 3 ) ,
" color " : _form_str ( form , " color " , " " ) ,
}
restart_on = form . get ( " restart_on " , [ ] )
if restart_on or form . get ( " restart_on_present " ) :
cfg [ " restart_on " ] = [ s . upper ( ) for s in restart_on ]
containers [ name ] = cfg
raw [ " containers " ] = containers
try :
new_app = build_config_from_raw ( raw )
except Exception as exc :
raise _SettingsError ( str ( exc ) ) from exc
try :
write_config_raw ( config_ref . path , raw )
except OSError as exc :
raise _SettingsError ( f " could not write config.yml: { exc } " ) from exc
config_ref . swap ( raw , new_app )
log . info ( " Container added: %s " , name )
return name
def _apply_container_delete ( config_ref : ConfigRef , name : str ) - > None :
raw = config_ref . get_raw ( )
containers = raw . get ( " containers " ) or { }
if name not in containers :
raise _SettingsError ( f " unknown container { name !r} " )
del containers [ name ]
raw [ " containers " ] = containers
try :
new_app = build_config_from_raw ( raw )
except Exception as exc :
raise _SettingsError ( str ( exc ) ) from exc
try :
write_config_raw ( config_ref . path , raw )
except OSError as exc :
raise _SettingsError ( f " could not write config.yml: { exc } " ) from exc
config_ref . swap ( raw , new_app )
log . info ( " Container removed: %s " , name )
def _run_test_notification ( channel : str , form : dict , app : AppConfig ) - > dict :
""" Send a one-shot test using form values, surfacing HTTP errors to the caller.
Secrets submitted as the __UNCHANGED__ sentinel fall back to the saved config
(otherwise the user couldn ' t test without re-typing the secret). This bypasses
the regular notifier.notify() path because that one swallows failures; the
whole point of the test button is to surface them.
"""
import requests
def _secret ( form_val : str , saved : str ) - > str :
return saved if form_val == _UNCHANGED_SECRET_PLACEHOLDER else form_val
title = " Inspector test "
message = " This is a test notification from Inspector. "
if channel == " gotify " :
url = _form_str ( form , " url " , app . gotify . url ) . rstrip ( " / " )
token = _secret ( _form_str ( form , " token " , " " ) , app . gotify . token )
priority = _form_int ( form , " priority " , app . gotify . priority )
if not url or not token :
return { " ok " : False , " error " : " URL and token required " }
try :
resp = requests . post (
f " { url } /message " ,
params = { " token " : token } ,
json = { " title " : title , " message " : message , " priority " : priority } ,
timeout = 5.0 ,
)
except requests . RequestException as exc :
return { " ok " : False , " error " : f " request failed: { exc } " }
if resp . status_code > = 400 :
return { " ok " : False , " error " : f " HTTP { resp . status_code } : { resp . text [ : 150 ] } " }
return { " ok " : True }
if channel == " pushover " :
token = _secret ( _form_str ( form , " token " , " " ) , app . pushover . token )
user_key = _secret ( _form_str ( form , " user_key " , " " ) , app . pushover . user_key )
if not token or not user_key :
return { " ok " : False , " error " : " token and user_key required " }
data = {
" token " : token ,
" user " : user_key ,
" title " : title ,
" message " : message ,
" priority " : 0 , # neutral priority for the test; avoids emergency retry loop
}
device = _form_str ( form , " device " , " " )
sound = _form_str ( form , " sound " , " " )
if device :
data [ " device " ] = device
if sound :
data [ " sound " ] = sound
try :
resp = requests . post ( " https://api.pushover.net/1/messages.json " , data = data , timeout = 5.0 )
except requests . RequestException as exc :
return { " ok " : False , " error " : f " request failed: { exc } " }
if resp . status_code > = 400 :
return { " ok " : False , " error " : f " HTTP { resp . status_code } : { resp . text [ : 150 ] } " }
return { " ok " : True }
return { " ok " : False , " error " : f " unknown channel { channel !r} " }
_RESTART_LABEL = (
_RESTART_LABEL = (
' <span class= " restart-flag " title= " takes effect after Inspector restart " > '
' <span class= " restart-flag " title= " takes effect after Inspector restart " > '
" requires restart</span> "
" requires restart</span> "
@@ -1152,6 +1427,20 @@ def _settings_field(
f ' <label><input type= " checkbox " name= " { _escape ( name ) } " value= " on " { checked } > '
f ' <label><input type= " checkbox " name= " { _escape ( name ) } " value= " on " { checked } > '
f " { label_html } </label> { hint_html } </div> "
f " { label_html } </label> { hint_html } </div> "
)
)
if kind == " switch " :
is_on = value in ( " 1 " , " True " , " true " , " on " )
on_class = " on " if is_on else " "
checked = " checked " if is_on else " "
esc_name = _escape ( name )
return (
f ' <div class= " toggle-row " > '
f ' <label class= " toggle { on_class } " onclick= " (function(e) {{ var cb=e.currentTarget.querySelector( \' input \' ); '
f ' cb.checked=!cb.checked;e.currentTarget.classList.toggle( \' on \' ,cb.checked); }} )(event) " > '
f ' <input type= " checkbox " name= " { esc_name } " value= " on " { checked } > '
f ' </label> '
f ' <span class= " toggle-label " > { label_html } </span> '
f " { hint_html } </div> "
)
return (
return (
f ' <div class= " field " > '
f ' <div class= " field " > '
f ' <label for= " s_ { _escape ( name ) } " > { label_html } </label> '
f ' <label for= " s_ { _escape ( name ) } " > { label_html } </label> '
@@ -1222,7 +1511,10 @@ _SETTINGS_SECTIONS = [
_SETTINGS_SECTION_SLUGS = { slug for slug , * _ in _SETTINGS_SECTIONS }
_SETTINGS_SECTION_SLUGS = { slug for slug , * _ in _SETTINGS_SECTIONS }
def _render_settings_rail ( app : AppConfig , active_slug : str ) - > str :
def _render_settings_rail (
app : AppConfig , active_slug : str , active_container : str = " " ,
* , containers_tab_active : bool = False ,
) - > str :
parts : list [ str ] = [ ' <nav class= " settings-rail " > \n ' ]
parts : list [ str ] = [ ' <nav class= " settings-rail " > \n ' ]
current_group : str | None = None
current_group : str | None = None
for slug , label , icon , sub_fn , group in _SETTINGS_SECTIONS :
for slug , label , icon , sub_fn , group in _SETTINGS_SECTIONS :
@@ -1238,6 +1530,35 @@ def _render_settings_rail(app: AppConfig, active_slug: str) -> str:
f ' <span class= " rail-sub " > { subtitle } </span> '
f ' <span class= " rail-sub " > { subtitle } </span> '
f " </span></a> \n "
f " </span></a> \n "
)
)
# Containers group — 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 " "
enabled_sub = " enabled " if c . enabled else " disabled "
parts . append (
f ' <a class= " { active } " href= " /containers/ { _escape ( c . name ) } " > '
f ' <span class= " rail-ico " style= " color: { _escape ( color ) } ; " >●</span> '
f ' <span class= " rail-text " > { _escape ( c . name ) } '
f ' <span class= " rail-sub " > { enabled_sub } · max { c . max_error_count } err</span> '
f " </span></a> \n "
)
parts . append (
' <a href= " /containers/new " style= " color:var(--blue); font-weight:600; " > '
' <span class= " rail-ico " >+</span> '
' <span class= " rail-text " >Add container</span></a> \n '
)
parts . append ( " </nav> \n " )
parts . append ( " </nav> \n " )
return " " . join ( parts )
return " " . join ( parts )
@@ -1255,14 +1576,88 @@ def _settings_form(section: str, title: str, body: str, csrf_token: str) -> str:
)
)
def _settings_form_with_test ( section : str , title : str , body : str , test_row : str , csrf_token : str ) - > str :
""" Form that includes an inline test-notification row above the Save button. """
return (
f ' <form method= " post " action= " /settings/ { _escape ( section ) } " data-channel= " { _escape ( section ) } " > '
f ' <input type= " hidden " name= " csrf " value= " { _escape ( csrf_token ) } " > '
f " { body } "
f " { test_row } "
' <div class= " form-actions " > '
f ' <button class= " btn " type= " submit " style= " background:var(--blue); color:#06111f; '
' border:none; border-radius:8px; padding:0.55rem 1rem; font-weight:700; cursor:pointer; '
f ' font-family:inherit; " >Save { _escape ( title ) } </button></div> '
" </form> \n "
)
_TEST_NOTIFY_JS = """
<script>
window.InspectorTest = function(btn, channel) {
var form = btn.closest( ' form ' );
var resultEl = btn.parentElement.querySelector( ' .test-result ' );
var data = new URLSearchParams();
form.querySelectorAll( ' input[name] ' ).forEach(function(el) {
if (el.type === ' checkbox ' ) { if (el.checked) data.append(el.name, ' on ' ); }
else { data.append(el.name, el.value); }
});
btn.disabled = true;
resultEl.className = ' test-result ' ;
resultEl.textContent = ' Sending test... ' ;
fetch( ' /api/test-notify/ ' + channel, {
method: ' POST ' ,
headers: { ' Content-Type ' : ' application/x-www-form-urlencoded ' },
body: data.toString(),
}).then(function(r) { return r.json(); })
.then(function(j) {
btn.disabled = false;
if (j.ok) {
resultEl.className = ' test-result ok ' ;
resultEl.textContent = ' ✓ Sent. Check your device. ' ;
} else {
resultEl.className = ' test-result err ' ;
resultEl.textContent = ' ✗ ' + (j.error || ' Failed ' );
}
})
.catch(function(e) {
btn.disabled = false;
resultEl.className = ' test-result err ' ;
resultEl.textContent = ' ✗ ' + e.message;
});
};
</script>
"""
def _render_diag_strip ( config_ref : ConfigRef ) - > str :
""" Shows config path + writable status + version at the bottom of settings pages. """
path = config_ref . path
writable = os . access ( str ( path ) , os . W_OK )
badge = (
' <span class= " diag-badge ok " >writable</span> ' if writable
else ' <span class= " diag-badge err " >READ-ONLY</span> '
)
hint = " " if writable else (
' <span style= " color:var(--red); " >— the UI cannot save changes. '
" Fix the bind-mount (remove <code>:ro</code>) or the file owner.</span> "
)
return (
' <div class= " diag-strip " > '
f ' <span>config: <code> { _escape ( str ( path ) ) } </code></span> '
f " <span> { badge } { hint } </span> "
f ' <span style= " margin-left:auto; " >Inspector v { __version__ } </span> '
" </div> \n "
)
def _settings_body_for ( section : str , app : AppConfig , csrf_token : str ) - > str :
def _settings_body_for ( section : str , app : AppConfig , csrf_token : str ) - > str :
if section == " inspector " :
if section == " inspector " :
insp = app . inspector
insp = app . inspector
body = (
body = (
_settings_field ( " interval_seconds " , " interval_seconds " , str ( insp . interval_seconds ) , kind = " number " )
_settings_field ( " interval_seconds " , " interval_seconds " , str ( insp . interval_seconds ) , kind = " number " )
+ _settings_field ( " log_level " , " log_level " , insp . log_level , hint = " DEBUG, INFO, WARNING, ERROR " )
+ _settings_field ( " log_level " , " log_level " , insp . log_level , hint = " DEBUG, INFO, WARNING, ERROR " )
+ _settings_field ( " dry_run " , " dry_run " , " on " if insp . dry_run else " " , kind = " checkbox " )
+ _settings_field ( " dry_run " , " dry_run " , " on " if insp . dry_run else " " , kind = " switch " )
+ _settings_field ( " heartbeat " , " heartbeat " , " on " if insp . heartbeat else " " , kind = " checkbox " )
+ _settings_field ( " heartbeat " , " heartbeat " , " on " if insp . heartbeat else " " , kind = " switch " )
+ _settings_field ( " log_retention_days " , " log_retention_days " , str ( insp . log_retention_days ) , kind = " number " , requires_restart = True )
+ _settings_field ( " log_retention_days " , " log_retention_days " , str ( insp . log_retention_days ) , kind = " number " , requires_restart = True )
)
)
return (
return (
@@ -1274,39 +1669,75 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
if section == " notifications " :
if section == " notifications " :
g , p = app . gotify , app . pushover
g , p = app . gotify , app . pushover
gotify_body = (
gotify_body = (
_settings_field ( " enabled " , " enabled " , " on " if g . enabled else " " , kind = " checkbox " , requires_restart = True )
_settings_field ( " enabled " , " enabled " , " on " if g . enabled else " " , kind = " switch " , requires_restart = True )
+ _settings_field ( " url " , " url " , g . url , hint = " Base URL of your Gotify server " , requires_restart = True )
+ _settings_field ( " url " , " url " , g . url , hint = " Base URL of your Gotify server " , requires_restart = True )
+ _settings_secret_field ( " token " , " token " , bool ( g . token ) )
+ _settings_secret_field ( " token " , " token " , bool ( g . token ) )
+ _settings_field ( " priority " , " priority " , str ( g . priority ) , kind = " number " )
+ _settings_field ( " priority " , " priority " , str ( g . priority ) , kind = " number " , hint = " Inspector-side priority (1– 10). See reference above. " )
)
)
pushover_body = (
pushover_body = (
_settings_field ( " enabled " , " enabled " , " on " if p . enabled else " " , kind = " checkbox " , requires_restart = True )
_settings_field ( " enabled " , " enabled " , " on " if p . enabled else " " , kind = " switch " , requires_restart = True )
+ _settings_secret_field ( " token " , " token " , bool ( p . token ) )
+ _settings_secret_field ( " token " , " token " , bool ( p . token ) )
+ _settings_secret_field ( " user_key " , " user_key " , bool ( p . user_key ) )
+ _settings_secret_field ( " user_key " , " user_key " , bool ( p . user_key ) )
+ _settings_field ( " priority " , " priority " , str ( p . priority ) , kind = " number " , hint = " Mapped at send time to Pushover' s -2..2 range " )
+ _settings_field ( " priority " , " priority " , str ( p . priority ) , kind = " number " , hint = " Inspector-side priority (1– 10). Mapped to Pushover' s -2..2 at send time. " )
+ _settings_field ( " device " , " device " , p . device , hint = " Optional; blank = all devices " )
+ _settings_field ( " device " , " device " , p . device , hint = " Optional; blank = all devices " )
+ _settings_field ( " sound " , " sound " , p . sound , hint = " Optional; blank = user default " )
+ _settings_field ( " sound " , " sound " , p . sound , hint = " Optional; blank = user default " )
)
)
gotify_pill = ' <span class= " pill " style= " background:var(--green-bg); color:var(--green); " >enabled</span> ' if g . enabled else ' <span class= " pill " style= " background:var(--border); color:var(--muted); " >disabled</span> '
gotify_pill = ' <span class= " pill " style= " background:var(--green-bg); color:var(--green); " >enabled</span> ' if g . enabled else ' <span class= " pill " style= " background:var(--border); color:var(--muted); " >disabled</span> '
pushover_pill = ' <span class= " pill " style= " background:var(--green-bg); color:var(--green); " >enabled</span> ' if p . enabled else ' <span class= " pill " style= " background:var(--border); color:var(--muted); " >disabled</span> '
pushover_pill = ' <span class= " pill " style= " background:var(--green-bg); color:var(--green); " >enabled</span> ' if p . enabled else ' <span class= " pill " style= " background:var(--border); color:var(--muted); " >disabled</span> '
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>≤2 → -1</code> · '
' <code>3-4 → 0</code> · '
' <code>5-6 → 1</code> · '
' <code>≥7 → 2</code> '
' </span></div> '
)
gotify_test_row = (
' <div class= " test-row " > '
' <button type= " button " class= " btn-ghost " onclick= " InspectorTest(this, \' gotify \' ) " >Send test</button> '
' <span class= " test-result " >Sends a test message using the values typed above (even if unsaved).</span> '
' </div> '
)
pushover_test_row = (
' <div class= " test-row " > '
' <button type= " button " class= " btn-ghost " onclick= " InspectorTest(this, \' pushover \' ) " >Send test</button> '
' <span class= " test-result " >Sends a test message using the values typed above (even if unsaved).</span> '
' </div> '
)
return (
return (
' <h3 class= " pane-title " >Notifications</h3> '
' <h3 class= " pane-title " >Notifications</h3> '
' <p class= " pane-sub " >Enable one or both channels. If both are enabled, every alert fans out to both.</p> '
' <p class= " pane-sub " >Enable one or both channels. If both are enabled, every alert fans out to both.</p> '
f ' <div class= " subsection " ><h4>Gotify { gotify_pill } </h4> '
+ f ' <div class= " subsection " ><h4>Gotify { gotify_pill } </h4> '
' <p class= " subsection-sub " >Push notifications via your Gotify server. Messages render as Markdown.</p> '
' <p class= " subsection-sub " >Push notifications via your Gotify server. Messages render as Markdown.</p> '
+ _settings_form ( " gotify " , " Gotify " , gotify_body , csrf_token )
+ gotify_priority_ref
+ _settings_form_with_test ( " gotify " , " Gotify " , gotify_body , gotify_test_row , csrf_token )
+ " </div> "
+ " </div> "
f ' <div class= " subsection " ><h4>Pushover { pushover_pill } </h4> '
+ f ' <div class= " subsection " ><h4>Pushover { pushover_pill } </h4> '
' <p class= " subsection-sub " >Pushover is i ndependent of Gotify. Inspector priority is mapped to Pushover \' s <code>-2..2</code> range: '
' <p class= " subsection-sub " >I ndependent of Gotify. Inspector maps its internal 1– 10 priority to Pushover \' s -2..2 range at send time.</p> '
' <code>≤2 → -1</code>, <code>3-4 → 0</code>, <code>5-6 → 1</code>, <code>≥7 → 2</code>.</p> '
+ pushover_priority_ref
+ _settings_form ( " pushover " , " Pushover " , pushover_body , csrf_token )
+ _settings_form_with_test ( " pushover " , " Pushover " , pushover_body , pushover_test_row , csrf_token )
+ " </div> "
+ " </div> "
)
)
if section == " dashboard " :
if section == " dashboard " :
d = app . dashboard
d = app . dashboard
body = (
body = (
_settings_field ( " enabled " , " enabled " , " on " if d . enabled else " " , kind = " checkbox " , requires_restart = True )
_settings_field ( " enabled " , " enabled " , " on " if d . enabled else " " , kind = " switch " , requires_restart = True )
+ _settings_field ( " username " , " username " , d . username )
+ _settings_field ( " username " , " username " , d . username )
+ _settings_secret_field ( " password " , " password " , bool ( d . password ) )
+ _settings_secret_field ( " password " , " password " , bool ( d . password ) )
)
)
@@ -1319,7 +1750,7 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
if section == " database " :
if section == " database " :
db = app . database
db = app . database
body = (
body = (
_settings_field ( " enabled " , " enabled " , " on " if db . enabled else " " , kind = " checkbox " , requires_restart = True )
_settings_field ( " enabled " , " enabled " , " on " if db . enabled else " " , kind = " switch " , requires_restart = True )
+ _settings_field ( " retention_days " , " retention_days " , str ( db . retention_days ) , kind = " number " , requires_restart = True )
+ _settings_field ( " retention_days " , " retention_days " , str ( db . retention_days ) , kind = " number " , requires_restart = True )
)
)
return (
return (
@@ -1331,7 +1762,7 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
if section == " health_summary " :
if section == " health_summary " :
hs = app . health_summary
hs = app . health_summary
body = (
body = (
_settings_field ( " enabled " , " enabled " , " on " if hs . enabled else " " , kind = " checkbox " , requires_restart = True )
_settings_field ( " enabled " , " enabled " , " on " if hs . enabled else " " , kind = " switch " , requires_restart = True )
+ _settings_field ( " interval_hours " , " interval_hours " , str ( hs . interval_hours ) , kind = " number " , requires_restart = True )
+ _settings_field ( " interval_hours " , " interval_hours " , str ( hs . interval_hours ) , kind = " number " , requires_restart = True )
+ _settings_field ( " priority " , " priority " , str ( hs . priority ) , kind = " number " )
+ _settings_field ( " priority " , " priority " , str ( hs . priority ) , kind = " number " )
)
)
@@ -1344,7 +1775,7 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
if section == " maintenance " :
if section == " maintenance " :
m = app . maintenance
m = app . maintenance
body = (
body = (
_settings_field ( " enabled " , " enabled " , " on " if m . enabled else " " , kind = " checkbox " , requires_restart = True )
_settings_field ( " enabled " , " enabled " , " on " if m . enabled else " " , kind = " switch " , requires_restart = True )
+ _settings_field ( " start " , " start " , m . start , hint = " HH:MM, 24h " )
+ _settings_field ( " start " , " start " , m . start , hint = " HH:MM, 24h " )
+ _settings_field ( " end " , " end " , m . end , hint = " HH:MM, 24h " )
+ _settings_field ( " end " , " end " , m . end , hint = " HH:MM, 24h " )
+ _settings_field ( " max_hours " , " max_hours " , str ( m . max_hours ) , kind = " number " , hint = " Safety cap for manual maintenance toggles " )
+ _settings_field ( " max_hours " , " max_hours " , str ( m . max_hours ) , kind = " number " , hint = " Safety cap for manual maintenance toggles " )
@@ -1358,7 +1789,7 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
if section == " notification_mute " :
if section == " notification_mute " :
nm = app . notification_mute
nm = app . notification_mute
body = (
body = (
_settings_field ( " enabled " , " enabled " , " on " if nm . enabled else " " , kind = " checkbox " , requires_restart = True )
_settings_field ( " enabled " , " enabled " , " on " if nm . enabled else " " , kind = " switch " , requires_restart = True )
+ _settings_field ( " default_hours " , " default_hours " , str ( nm . default_hours ) , kind = " number " , hint = " How long a manual mute lasts by default " )
+ _settings_field ( " default_hours " , " default_hours " , str ( nm . default_hours ) , kind = " number " , hint = " How long a manual mute lasts by default " )
)
)
return (
return (
@@ -1385,7 +1816,7 @@ def _render_flash(flash: str) -> str:
return " "
return " "
def _render_settings_page ( app : AppConfig , csrf_token : str , flash : str , section : str ) - > bytes :
def _render_settings_page ( app : AppConfig , config_ref : ConfigRef , csrf_token : str , flash : str , section : str ) - > bytes :
if section not in _SETTINGS_SECTION_SLUGS :
if section not in _SETTINGS_SECTION_SLUGS :
section = " inspector "
section = " inspector "
parts = [ _page_head ( refresh = False , active = " settings " ) ]
parts = [ _page_head ( refresh = False , active = " settings " ) ]
@@ -1395,18 +1826,27 @@ def _render_settings_page(app: AppConfig, csrf_token: str, flash: str, section:
parts . append ( ' <section class= " settings-pane " > ' )
parts . append ( ' <section class= " settings-pane " > ' )
parts . append ( _settings_body_for ( section , app , csrf_token ) )
parts . append ( _settings_body_for ( section , app , csrf_token ) )
parts . append ( " </section></div> \n " )
parts . append ( " </section></div> \n " )
parts . append ( _render_diag_strip ( config_ref ) )
if section == " notifications " :
parts . append ( _TEST_NOTIFY_JS )
parts . append ( _PAGE_TAIL )
parts . append ( _PAGE_TAIL )
return " " . join ( parts ) . encode ( " utf-8 " )
return " " . join ( parts ) . encode ( " utf-8 " )
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 = [ _page_head ( refresh = False , active = " containers " ) ]
parts . append ( _render_flash ( flash ) )
parts . append ( _render_flash ( flash ) )
parts . append ( ' <h2 class= " section-title " >Containers</h2> \n ' )
parts . append ( ' <div class= " settings-layout " > ' )
parts . append ( _render_settings_rail ( app , " " , containers_tab_active = True ) )
parts . append ( ' <section class= " settings-pane " > ' )
parts . append (
parts . append (
' <p class= " muted " style= " margin:-0.4rem 0 1rem; font-size :0.85 rem;" > '
' <div style= " display:flex; align-items:center; justify-content:space-between; margin-bottom :0.3 rem;" > '
" Click a container to edit its restart rules. To add or remove a container, "
' <h3 class= " pane-title " style= " margin:0; " >All containers</h3> '
" edit <code>config.yml</code> and restart Inspector.</p> \n "
' <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> '
' <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 ' )
parts . append ( ' <div class= " container-grid " > \n ' )
containers = list ( app . containers )
containers = list ( app . containers )
@@ -1429,65 +1869,212 @@ def _render_containers_page(app: AppConfig, states: dict, flash: str) -> bytes:
" </div></a> \n "
" </div></a> \n "
)
)
parts . append ( " </div> \n " )
parts . append ( " </div> \n " )
parts . append ( " </section></div> \n " )
parts . append ( _render_diag_strip ( config_ref ) )
parts . append ( _PAGE_TAIL )
parts . append ( _PAGE_TAIL )
return " " . join ( parts ) . encode ( " utf-8 " )
return " " . join ( parts ) . encode ( " utf-8 " )
def _render_container_edit_page ( app : AppConfig , name : str , csrf_token : str , flash : str ) - > bytes | None :
# Preset color swatches offered on container edit/new forms.
cfg = next ( ( c for c in app . containers if c . name == name ) , None )
_COLOR_PALETTE = [
if cfg is None :
" #60a5fa " , " #34d399 " , " #fbbf24 " , " #f87171 " , " #a78bfa " ,
return None
" #f472b6 " , " #2dd4bf " , " #fb923c " , " #94a3b8 " , " #a3a3a3 " ,
]
restart_on_opts = [ " HEALTHY " , " UNHEALTHY " , " STARTING " , " NO HEALTH CHECK " ,
" NOT RUNNING " , " CONTAINER NOT FOUND " , " ERROR " , " UNKNOWN " , " UNREACHABLE " ]
def _render_color_input ( name : str , value : str ) - > str :
checkboxes : list [ str ] = [ ]
""" Hex text input + native color picker + preset swatches, all bound together. """
for s in restart_on_opts :
swatches = " " . join (
checked = " checked " if s in cfg . restart_on else " "
f ' <span class= " swatch " style= " background: { c } ; " '
checkboxes . append (
f " onclick= \" document.getElementById( ' s_ { name } ' ).value= ' { c } ' ; "
f ' <label class= " pill " ><input type= " checkbox " name= " restart_on " '
f " document.getElementById( ' p_ { name } ' ).value= ' { c } ' ; \" ></span> "
f ' value= " { _escape ( s ) } " { checked } > { _escape ( s ) } </label> '
for c in _COLOR_PALETTE
)
)
restart_on_html = (
picker_value = value if value and value . startswith ( " # " ) else " #60a5fa "
' <div class= " field " ><label>restart_on</label> '
return (
f ' <div class= " pill-row " > { " " . join ( checkboxes ) } </div >'
f ' <div class= " field " ><label>color</label > '
' <div class= " field-hint " >Statuses that trigger a restart. Leave all off for observe-only.</div> '
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> "
" </div> "
)
)
# (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. """
current_restart_on = getattr ( cfg , " restart_on " , [ ] ) or [ ]
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 " > '
# 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 :
name_field = _settings_field (
" name " , " name " , " " ,
hint = " Container name as it appears in Docker. Letters, digits, _ and - only. " ,
)
body = (
body = (
_settings_field ( " enabled " , " enabled " , " on " if cfg . enabled else " " , kind = " checkbox " )
name_field
+ _settings_field ( " max_error_count " , " max_error_count " , str ( cfg . max_error_count ) , kind = " number " ,
+ _settings_field ( " enabled " , " enabled " , " on " if getattr ( cfg , " enabled " , True ) else " " , kind = " switch " )
+ _settings_field ( " max_error_count " , " max_error_count " ,
str ( getattr ( cfg , " max_error_count " , 5 ) ) , kind = " number " ,
hint = " Consecutive failing checks before a restart is attempted " )
hint = " Consecutive failing checks before a restart is attempted " )
+ _settings_field ( " cooldown_seconds " , " cooldown_seconds " , str ( cfg . cooldown_seconds ) , kind = " number " ,
+ _settings_field ( " cooldown_seconds " , " cooldown_seconds " ,
str ( getattr ( cfg , " cooldown_seconds " , 300 ) ) , kind = " number " ,
hint = " Minimum seconds between restarts " )
hint = " Minimum seconds between restarts " )
+ _settings_field ( " grace_period_seconds " , " grace_period_seconds " , str ( cfg . grace_period_seconds ) , kind = " number " ,
+ _settings_field ( " grace_period_seconds " , " grace_period_seconds " ,
str ( getattr ( cfg , " grace_period_seconds " , 120 ) ) , kind = " number " ,
hint = " Ignore failures for this long after a restart (lets the container boot) " )
hint = " Ignore failures for this long after a restart (lets the container boot) " )
+ _settings_field ( " max_restarts_per_hour " , " max_restarts_per_hour " , str ( cfg . max_restarts_per_hour ) , kind = " number " ,
+ _settings_field ( " max_restarts_per_hour " , " max_restarts_per_hour " ,
str ( getattr ( cfg , " max_restarts_per_hour " , 3 ) ) , kind = " number " ,
hint = " Hourly cap — Inspector pauses restarts after this many " )
hint = " Hourly cap — Inspector pauses restarts after this many " )
+ _settings_field ( " color " , " color " , cfg . color , hint = " Hex, e.g. #60a5fa. Blank uses the auto palette. " )
+ _render_color_input ( " color " , getattr ( cfg , " color " , " " ) )
+ restart_on_html
+ restart_on_html
)
)
form_html = (
delete_btn = " "
f ' <form method= " post " action= " /containers/ { _escape ( name ) } " > '
if show_delete :
delete_btn = (
f ' <form method= " post " action= " /containers/ { _escape ( name ) } /delete " '
' style= " display:inline; " '
f " onsubmit= \" return confirm( ' Delete container { _escape ( name ) } from config.yml? ' ); \" > "
f ' <input type= " hidden " name= " csrf " value= " { _escape ( csrf_token ) } " > '
' <button type= " submit " class= " btn-danger " >Delete container</button> '
" </form> "
)
return (
f ' <form method= " post " action= " { action } " > '
f ' <input type= " hidden " name= " csrf " value= " { _escape ( csrf_token ) } " > '
f ' <input type= " hidden " name= " csrf " value= " { _escape ( csrf_token ) } " > '
f " { body } "
f " { body } "
' <div class= " form-actions " > '
' <div class= " form-actions " > '
f " { delete_btn } "
' <a class= " btn secondary " href= " /containers " style= " background:transparent; color:#cbd5e1; '
' <a class= " btn secondary " href= " /containers " style= " background:transparent; color:#cbd5e1; '
' border:1px solid var(--border); border-radius:8px; padding:0.55rem 1rem; font-weight:700; '
' border:1px solid var(--border); border-radius:8px; padding:0.55rem 1rem; font-weight:700; '
' text-decoration:none; font-family:inherit; " >Back</a> '
' text-decoration:none; font-family:inherit; " >Back</a> '
' <button class= " btn " type= " submit " style= " background:var(--blue); color:#06111f; border:none; '
f ' <button class= " btn " type= " submit " style= " background:var(--blue); color:#06111f; border:none; '
' border-radius:8px; padding:0.55rem 1rem; font-weight:700; cursor:pointer; font-family:inherit; " > '
f ' border-radius:8px; padding:0.55rem 1rem; font-weight:700; cursor:pointer; font-family:inherit; " > '
" Save container </button></div>"
f " { _escape ( submit_label ) } </button></div> "
" </form> \n "
" </form> \n "
)
)
def _render_container_edit_page ( app : AppConfig , config_ref : ConfigRef , name : str , csrf_token : str , flash : str ) - > bytes | None :
cfg = next ( ( c for c in app . containers if c . name == name ) , None )
if cfg is None :
return None
form_html = _render_container_form (
name , cfg , csrf_token ,
action = f " /containers/ { _escape ( name ) } " ,
submit_label = " Save container " ,
show_delete = True ,
)
parts = [ _page_head ( refresh = False , active = " containers " ) ]
parts = [ _page_head ( refresh = False , active = " containers " ) ]
parts . append ( _render_flash ( flash ) )
parts . append ( _render_flash ( flash ) )
parts . append ( ' <div class= " settings-layout " > ' )
parts . append ( _render_settings_rail ( app , " " , active_container = name ) )
parts . append ( ' <section class= " settings-pane " > ' )
parts . append (
parts . append (
f ' <h2 class= " section-title " >Container — <code style= " font-size:1 em; " > { _escape ( name ) } </code></h2 > \n '
f ' <h3 class= " pane-title " >Container — <code style= " font-size:0.95 em; " > { _escape ( name ) } </code></h3 > '
' <p class= " pane-sub " >Restart rules Inspector applies when this container \' s health changes.</p> '
)
)
parts . append ( ' <div class= " settings-pane " style= " background:var(--card); border:1px solid var(--border); border-radius:14px; padding:1.5rem 1.75rem; " > ' )
parts . append ( form_html )
parts . append ( form_html )
parts . append ( " </div> \n " )
parts . append ( " </section></ div> \n " )
parts . append ( _render_diag_strip ( config_ref ) )
parts . append ( _PAGE_TAIL )
return " " . join ( parts ) . encode ( " utf-8 " )
class _NewContainerDefaults :
name = " "
enabled = True
max_error_count = 5
cooldown_seconds = 300
grace_period_seconds = 120
max_restarts_per_hour = 3
color = " "
restart_on : list [ str ] = [ ]
def _render_container_new_page ( app : AppConfig , config_ref : ConfigRef , csrf_token : str , flash : str ) - > bytes :
form_html = _render_container_form (
" " , _NewContainerDefaults ( ) , csrf_token ,
action = " /containers/new " ,
submit_label = " Create container " ,
show_name_field = True ,
)
parts = [ _page_head ( refresh = False , active = " containers " ) ]
parts . append ( _render_flash ( flash ) )
parts . append ( ' <div class= " settings-layout " > ' )
parts . append ( _render_settings_rail ( app , " " ) )
parts . append ( ' <section class= " settings-pane " > ' )
parts . append (
' <h3 class= " pane-title " >New container</h3> '
' <p class= " pane-sub " >Add a container to <code>config.yml</code>. '
" The new entry is picked up automatically on the next polling cycle; "
" advanced fields (Portainer stack, http_check, uptime_kuma, depends_on, "
" vpn_routed) remain YAML-only for now.</p> "
)
parts . append ( form_html )
parts . append ( " </section></div> \n " )
parts . append ( _render_diag_strip ( config_ref ) )
parts . append ( _PAGE_TAIL )
parts . append ( _PAGE_TAIL )
return " " . join ( parts ) . encode ( " utf-8 " )
return " " . join ( parts ) . encode ( " utf-8 " )
@@ -1676,17 +2263,22 @@ class _Handler(BaseHTTPRequestHandler):
section = path [ len ( " /settings/ " ) : ] . strip ( " / " )
section = path [ len ( " /settings/ " ) : ] . strip ( " / " )
csrf = self . _csrf_token_for_session ( )
csrf = self . _csrf_token_for_session ( )
flash = query . get ( " flash " , [ " " ] ) [ 0 ]
flash = query . get ( " flash " , [ " " ] ) [ 0 ]
body = _render_settings_page ( app , csrf , flash , section )
body = _render_settings_page ( app , server . config_ref , csrf , flash , section )
self . _send_bytes ( HTTPStatus . OK , " text/html; charset=utf-8 " , body )
self . _send_bytes ( HTTPStatus . OK , " text/html; charset=utf-8 " , body )
elif path == " /containers " :
elif path == " /containers " :
flash = query . get ( " flash " , [ " " ] ) [ 0 ]
flash = query . get ( " flash " , [ " " ] ) [ 0 ]
body = _render_containers_page ( app , server . states , flash )
body = _render_containers_page ( app , server . 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 ( )
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 )
self . _send_bytes ( HTTPStatus . OK , " text/html; charset=utf-8 " , body )
elif path . startswith ( " /containers/ " ) :
elif path . startswith ( " /containers/ " ) :
name = path [ len ( " /containers/ " ) : ] . strip ( " / " )
name = path [ len ( " /containers/ " ) : ] . strip ( " / " )
csrf = self . _csrf_token_for_session ( )
csrf = self . _csrf_token_for_session ( )
flash = query . get ( " flash " , [ " " ] ) [ 0 ]
flash = query . get ( " flash " , [ " " ] ) [ 0 ]
body = _render_container_edit_page ( app , name , csrf , flash )
body = _render_container_edit_page ( app , server . config_ref , name , csrf , flash )
if body is None :
if body is None :
self . _send_bytes ( HTTPStatus . NOT_FOUND , " text/plain; charset=utf-8 " , b " unknown container " )
self . _send_bytes ( HTTPStatus . NOT_FOUND , " text/plain; charset=utf-8 " , b " unknown container " )
return
return
@@ -1822,6 +2414,44 @@ class _Handler(BaseHTTPRequestHandler):
self . end_headers ( )
self . end_headers ( )
return
return
# POST /containers/new — create a new container entry
if len ( parts ) == 2 and parts [ 0 ] == " containers " and parts [ 1 ] == " new " :
try :
new_name = _apply_container_create ( server . config_ref , form )
except _SettingsError as exc :
flash = f " error: { exc } "
self . send_response ( HTTPStatus . SEE_OTHER )
self . send_header ( " Location " , f " /containers/new?flash= { _url_quote ( flash ) } " )
self . send_header ( " Content-Length " , " 0 " )
self . end_headers ( )
return
self . send_response ( HTTPStatus . SEE_OTHER )
self . send_header ( " Location " , f " /containers/ { _url_quote ( new_name ) } ?flash= { _url_quote ( ' ok: ' + new_name ) } " )
self . send_header ( " Content-Length " , " 0 " )
self . end_headers ( )
return
# POST /containers/<name>/delete — remove a container
if len ( parts ) == 3 and parts [ 0 ] == " containers " and parts [ 2 ] == " delete " :
name = parts [ 1 ]
if name not in containers :
self . _send_bytes ( HTTPStatus . NOT_FOUND , " text/plain; charset=utf-8 " , b " unknown container " )
return
try :
_apply_container_delete ( server . config_ref , name )
except _SettingsError as exc :
flash = f " error: { exc } "
self . send_response ( HTTPStatus . SEE_OTHER )
self . send_header ( " Location " , f " /containers/ { _url_quote ( name ) } ?flash= { _url_quote ( flash ) } " )
self . send_header ( " Content-Length " , " 0 " )
self . end_headers ( )
return
self . send_response ( HTTPStatus . SEE_OTHER )
self . send_header ( " Location " , f " /containers?flash= { _url_quote ( ' ok:removed ' + name ) } " )
self . send_header ( " Content-Length " , " 0 " )
self . end_headers ( )
return
if len ( parts ) == 2 and parts [ 0 ] == " containers " :
if len ( parts ) == 2 and parts [ 0 ] == " containers " :
name = parts [ 1 ]
name = parts [ 1 ]
if name not in containers :
if name not in containers :
@@ -1842,6 +2472,14 @@ class _Handler(BaseHTTPRequestHandler):
self . end_headers ( )
self . end_headers ( )
return
return
# POST /api/test-notify/<channel> — send a one-shot test using form values
if len ( parts ) == 3 and parts [ 0 ] == " api " and parts [ 1 ] == " test-notify " :
channel = parts [ 2 ]
payload = _run_test_notification ( channel , form , app )
body = json . dumps ( payload ) . encode ( " utf-8 " )
self . _send_bytes ( HTTPStatus . OK , " application/json " , body )
return
self . _send_bytes ( HTTPStatus . NOT_FOUND , " text/plain; charset=utf-8 " , b " not found " )
self . _send_bytes ( HTTPStatus . NOT_FOUND , " text/plain; charset=utf-8 " , b " not found " )