@@ -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,98 @@ a.signout:hover { color: var(--ink); border-color: var(--muted); }
. container - tile . ctn - meta { font - size : 0.75 rem ; color : var ( - - muted ) ; display : flex ; flex - wrap : wrap ; gap : 0.5 rem 1 rem ; }
. container - tile . ctn - meta { font - size : 0.75 rem ; color : var ( - - muted ) ; display : flex ; flex - wrap : wrap ; gap : 0.5 rem 1 rem ; }
. 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 : 1 rem ; }
. container - tile . ctn - chev { color : var ( - - muted ) ; font - size : 1 rem ; }
/ * Toggle switch * /
. toggle {
position : relative ; display : inline - block ; width : 36 px ; height : 20 px ;
background : #273449; border-radius: 999px; cursor: pointer; flex-shrink: 0;
transition : background 0.15 s ; vertical - align : middle ;
}
. toggle : : after {
content : " " ; position : absolute ; top : 2 px ; left : 2 px ; width : 16 px ; height : 16 px ;
background : #fff; border-radius: 50%; transition: transform 0.15s;
}
. toggle input { display : none ; }
. toggle . on { background : var ( - - blue ) ; }
. toggle . on : : after { transform : translateX ( 16 px ) ; }
. toggle - row {
display : flex ; align - items : center ; gap : 0.6 rem ; margin - bottom : 0.9 rem ;
}
. toggle - row . toggle - label { font - size : 0.9 rem ; color : var ( - - ink ) ; font - weight : 500 ; }
/ * Priority reference box * /
. priority - ref {
background : #0f1829; border: 1px solid var(--border); border-radius: 10px;
padding : 0.6 rem 0.8 rem ; margin : 0 0 0.9 rem ;
font - size : 0.75 rem ; color : var ( - - muted ) ;
}
. priority - ref b { color : var ( - - ink ) ; font - weight : 700 ; margin - right : 0.3 rem ; }
. priority - ref . plevel { display : inline - block ; margin - right : 0.9 rem ; white - space : nowrap ; }
. priority - ref . plevel code {
background : #060b16; padding: 0.05rem 0.3rem; border-radius: 4px;
color : var ( - - blue ) ; margin - right : 0.2 rem ; font - size : 0.72 rem ;
}
/ * Diagnostics strip at the bottom of settings pages * /
. diag - strip {
margin - top : 1 rem ; padding : 0.65 rem 0.9 rem ;
background : #0f1829; border: 1px solid var(--border); border-radius: 10px;
font - size : 0.75 rem ; color : var ( - - muted ) ;
display : flex ; align - items : center ; gap : 0.9 rem ; 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.25 rem ;
padding : 0.08 rem 0.45 rem ; border - radius : 999 px ; font - weight : 700 ;
font - size : 0.68 rem ; text - transform : uppercase ; letter - spacing : 0.04 em ;
}
. 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.6 rem ;
margin : 0.6 rem 0 1 rem ;
padding : 0.6 rem 0.75 rem ;
background : #0f1829; border: 1px dashed var(--border-2, var(--border));
border - radius : 10 px ;
}
. btn - ghost {
background : transparent ; color : var ( - - blue ) ; border : 1 px solid var ( - - blue ) ;
padding : 0.4 rem 0.85 rem ; border - radius : 8 px ; font - weight : 600 ; cursor : pointer ;
font - family : inherit ; font - size : 0.8 rem ;
}
. btn - ghost : hover { background : var ( - - blue - bg ) ; }
. btn - ghost : disabled { opacity : 0.5 ; cursor : wait ; }
. test - result { font - size : 0.78 rem ; 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.35 rem ; margin : 0.3 rem 0 ; }
. color - swatches . swatch {
width : 22 px ; height : 22 px ; border - radius : 50 % ;
border : 2 px solid #1e293b; cursor: pointer;
transition : transform 0.1 s , border - color 0.1 s ;
}
. color - swatches . swatch : hover { transform : scale ( 1.1 ) ; border - color : var ( - - ink ) ; }
. color - row { display : flex ; align - items : center ; gap : 0.5 rem ; }
. color - row input [ type = color ] {
width : 32 px ; height : 32 px ; padding : 0 ; border : 1 px solid var ( - - border ) ;
border - radius : 6 px ; background : transparent ; cursor : pointer ;
}
. color - row input [ type = text ] { flex : 1 ; }
/ * Danger button for delete container * /
. btn - danger {
background : var ( - - red - bg ) ; color : var ( - - red ) ; border : 1 px solid var ( - - red ) ;
padding : 0.55 rem 1 rem ; border - radius : 8 px ; font - weight : 700 ; cursor : pointer ;
font - family : inherit ; font - size : 0.85 rem ;
}
. btn - danger : hover { background : var ( - - red ) ; color : #130709; }
< / style > """
< / style > """
@@ -638,8 +731,6 @@ def _page_head(refresh: bool, active: str) -> str:
containers_class = " active " if active == " containers " else " "
containers_class = " active " if active == " containers " else " "
settings_class = " active " if active == " settings " else " "
settings_class = " active " if active == " settings " else " "
meta_text = f " v { __version__ } "
meta_text = f " v { __version__ } "
if refresh :
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 '
@@ -1126,6 +1217,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 :
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 +1374,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 +1458,7 @@ _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 = " " ) - > 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 +1474,27 @@ 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.
parts . append (
f ' <div class= " rail-group " >Containers <span style= " color:var(--muted); font-weight:400; " > '
f ' ( { len ( app . containers ) } )</span></div> \n '
)
for i , c in enumerate ( app . containers ) :
color = _resolve_color ( c , i )
active = " active " if c . name == active_container else " "
enabled_sub = " enabled " if c . enabled else " disabled "
parts . append (
f ' <a class= " { active } " href= " /containers/ { _escape ( c . name ) } " > '
f ' <span class= " rail-ico " style= " color: { _escape ( color ) } ; " >●</span> '
f ' <span class= " rail-text " > { _escape ( c . name ) } '
f ' <span class= " rail-sub " > { enabled_sub } · max { c . max_error_count } err</span> '
f " </span></a> \n "
)
parts . append (
' <a href= " /containers/new " style= " color:var(--blue); font-weight:600; " > '
' <span class= " rail-ico " >+</span> '
' <span class= " rail-text " >Add container</span></a> \n '
)
parts . append ( " </nav> \n " )
parts . append ( " </nav> \n " )
return " " . join ( parts )
return " " . join ( parts )
@@ -1255,14 +1512,88 @@ def _settings_form(section: str, title: str, body: str, csrf_token: str) -> str:
)
)
def _settings_form_with_test ( section : str , title : str , body : str , test_row : str , csrf_token : str ) - > str :
""" Form that includes an inline test-notification row above the Save button. """
return (
f ' <form method= " post " action= " /settings/ { _escape ( section ) } " data-channel= " { _escape ( section ) } " > '
f ' <input type= " hidden " name= " csrf " value= " { _escape ( csrf_token ) } " > '
f " { body } "
f " { test_row } "
' <div class= " form-actions " > '
f ' <button class= " btn " type= " submit " style= " background:var(--blue); color:#06111f; '
' border:none; border-radius:8px; padding:0.55rem 1rem; font-weight:700; cursor:pointer; '
f ' font-family:inherit; " >Save { _escape ( title ) } </button></div> '
" </form> \n "
)
_TEST_NOTIFY_JS = """
< script >
window . InspectorTest = function ( btn , channel ) {
var form = btn . closest ( ' form ' ) ;
var resultEl = btn . parentElement . querySelector ( ' .test-result ' ) ;
var data = new URLSearchParams ( ) ;
form . querySelectorAll ( ' input[name] ' ) . forEach ( function ( el ) {
if ( el . type == = ' checkbox ' ) { if ( el . checked ) data . append ( el . name , ' on ' ) ; }
else { data . append ( el . name , el . value ) ; }
} ) ;
btn . disabled = true ;
resultEl . className = ' test-result ' ;
resultEl . textContent = ' Sending test... ' ;
fetch ( ' /api/test-notify/ ' + channel , {
method : ' POST ' ,
headers : { ' Content-Type ' : ' application/x-www-form-urlencoded ' } ,
body : data . toString ( ) ,
} ) . then ( function ( r ) { return r . json ( ) ; } )
. then ( function ( j ) {
btn . disabled = false ;
if ( j . ok ) {
resultEl . className = ' test-result ok ' ;
resultEl . textContent = ' ✓ Sent. Check your device. ' ;
} else {
resultEl . className = ' test-result err ' ;
resultEl . textContent = ' ✗ ' + ( j . error | | ' Failed ' ) ;
}
} )
. catch ( function ( e ) {
btn . disabled = false ;
resultEl . className = ' test-result err ' ;
resultEl . textContent = ' ✗ ' + e . message ;
} ) ;
} ;
< / script >
"""
def _render_diag_strip ( config_ref : ConfigRef ) - > str :
""" Shows config path + writable status + version at the bottom of settings pages. """
path = config_ref . path
writable = os . access ( str ( path ) , os . W_OK )
badge = (
' <span class= " diag-badge ok " >writable</span> ' if writable
else ' <span class= " diag-badge err " >READ-ONLY</span> '
)
hint = " " if writable else (
' <span style= " color:var(--red); " >— the UI cannot save changes. '
" Fix the bind-mount (remove <code>:ro</code>) or the file owner.</span> "
)
return (
' <div class= " diag-strip " > '
f ' <span>config: <code> { _escape ( str ( path ) ) } </code></span> '
f " <span> { badge } { hint } </span> "
f ' <span style= " margin-left:auto; " >Inspector v { __version__ } </span> '
" </div> \n "
)
def _settings_body_for ( section : str , app : AppConfig , csrf_token : str ) - > str :
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 +1605,64 @@ 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> '
priority_ref = (
' <div class= " priority-ref " ><b>Priority reference</b> '
' <span class= " plevel " ><code>1-2</code> info / debug</span> '
' <span class= " plevel " ><code>3-4</code> normal (default)</span> '
' <span class= " plevel " ><code>5-6</code> warning</span> '
' <span class= " plevel " ><code>7-8</code> error</span> '
' <span class= " plevel " ><code>9-10</code> critical / emergency</span> '
' <br><span style= " margin-top:0.3rem; display:inline-block; " > '
' Pushover mapping: <code>≤2 → -1</code> · '
' <code>3-4 → 0</code> · <code>5-6 → 1</code> · '
' <code>≥7 → 2 (emergency)</code> '
' </span></div> '
)
gotify_test_row = (
' <div class= " test-row " > '
' <button type= " button " class= " btn-ghost " onclick= " InspectorTest(this, \' gotify \' ) " >Send test</button> '
' <span class= " test-result " >Sends a test message using the values typed above (even if unsaved).</span> '
' </div> '
)
pushover_test_row = (
' <div class= " test-row " > '
' <button type= " button " class= " btn-ghost " onclick= " InspectorTest(this, \' pushover \' ) " >Send test</button> '
' <span class= " test-result " >Sends a test message using the values typed above (even if unsaved).</span> '
' </div> '
)
return (
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> '
+ 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> '
' <p class= " subsection-sub " >Push notifications via your Gotify server. Messages render as Markdown.</p> '
+ _settings_form ( " gotify " , " Gotify " , gotify_body , csrf_token )
+ _settings_form_with_test ( " gotify " , " Gotify " , gotify_body , gotify_test_row , csrf_token )
+ " </div> "
+ " </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. Supports priorities <code>-2</code> (lowest) through <code>2</code> (emergency, which Pushover retries until acknowledged).</p> '
' <code>≤2 → -1</code>, <code>3-4 → 0</code>, <code>5-6 → 1</code>, <code>≥7 → 2</code>.</p> '
+ _settings_form_with_test ( " pushover " , " Pushover " , pushover_body , pushover_test_row , csrf_token )
+ _settings_form ( " pushover " , " Pushover " , pushover_body , 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 +1675,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 +1687,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 +1700,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 +1714,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 +1741,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,6 +1751,9 @@ 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 " )
@@ -1402,11 +1761,17 @@ def _render_settings_page(app: AppConfig, csrf_token: str, flash: str, section:
def _render_containers_page ( app : AppConfig , states : dict , flash : str ) - > bytes :
def _render_containers_page ( app : AppConfig , 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 style= " display:flex; align-items:center; justify-content:space-between; margin-bottom:1rem; " > '
' <h2 class= " section-title " style= " margin:0; " >Containers</h2> '
' <a href= " /containers/new " style= " background:var(--blue); color:#06111f; padding:0.5rem 0.9rem; '
' border-radius:8px; font-weight:700; text-decoration:none; font-size:0.85rem; " >+ New container</a> '
' </div> '
)
parts . append (
parts . append (
' <p class= " muted " style= " margin:-0.4rem 0 1rem; font-size:0.85rem; " > '
' <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, "
" Click a tile to edit its restart rules, or add / remove containers from the UI "
" edit <code>config.yml</code> and restart Inspector .</p>\n "
" (the underlying <code>config.yml</code> is updated for you) .</p>\n "
)
)
parts . append ( ' <div class= " container-grid " > \n ' )
parts . append ( ' <div class= " container-grid " > \n ' )
containers = list ( app . containers )
containers = list ( app . containers )
@@ -1433,16 +1798,47 @@ def _render_containers_page(app: AppConfig, states: dict, flash: str) -> bytes:
return " " . join ( parts ) . encode ( " utf-8 " )
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 " ,
]
def _render_color_input ( name : str , value : str ) - > str :
""" Hex text input + native color picker + preset swatches, all bound together. """
swatches = " " . join (
f ' <span class= " swatch " style= " background: { c } ; " '
f " onclick= \" document.getElementById( ' s_ { name } ' ).value= ' { c } ' ; "
f " document.getElementById( ' p_ { name } ' ).value= ' { c } ' ; \" ></span> "
for c in _COLOR_PALETTE
)
picker_value = value if value and value . startswith ( " # " ) else " #60a5fa "
return (
f ' <div class= " field " ><label>color</label> '
f ' <div class= " color-row " > '
f ' <input id= " p_ { name } " type= " color " value= " { _escape ( picker_value ) } " '
f " onchange= \" document.getElementById( ' s_ { name } ' ).value=this.value; \" > "
f ' <input id= " s_ { name } " type= " text " name= " { _escape ( name ) } " value= " { _escape ( value ) } " '
f ' placeholder= " #60a5fa or blank for auto " > '
" </div> "
f ' <div class= " color-swatches " > { swatches } </div> '
' <div class= " field-hint " >Pick a swatch, use the picker, or type a hex code. Blank = auto palette.</div> '
" </div> "
)
def _render_container_form (
name : str , cfg , csrf_token : str , action : str , submit_label : str ,
* , show_name_field : bool = False , show_delete : bool = False ,
) - > str :
""" Build the container edit form. `cfg` may be a ContainerConfig or a dummy with defaults. """
restart_on_opts = [ " HEALTHY " , " UNHEALTHY " , " STARTING " , " NO HEALTH CHECK " ,
restart_on_opts = [ " HEALTHY " , " UNHEALTHY " , " STARTING " , " NO HEALTH CHECK " ,
" NOT RUNNING " , " CONTAINER NOT FOUND " , " ERROR " , " UNKNOWN " , " UNREACHABLE " ]
" NOT RUNNING " , " CONTAINER NOT FOUND " , " ERROR " , " UNKNOWN " , " UNREACHABLE " ]
current_restart_on = getattr ( cfg , " restart_on " , [ ] ) or [ ]
checkboxes : list [ str ] = [ ]
checkboxes : list [ str ] = [ ]
for s in restart_on_opts :
for s in restart_on_opts :
checked = " checked " if s in cfg . restart_on else " "
checked = " checked " if s in current_restart_on else " "
checkboxes . append (
checkboxes . append (
f ' <label class= " pill " ><input type= " checkbox " name= " restart_on " '
f ' <label class= " pill " ><input type= " checkbox " name= " restart_on " '
f ' value= " { _escape ( s ) } " { checked } > { _escape ( s ) } </label> '
f ' value= " { _escape ( s ) } " { checked } > { _escape ( s ) } </label> '
@@ -1453,41 +1849,115 @@ def _render_container_edit_page(app: AppConfig, name: str, csrf_token: str, flas
' <div class= " field-hint " >Statuses that trigger a restart. Leave all off for observe-only.</div> '
' <div class= " field-hint " >Statuses that trigger a restart. Leave all off for observe-only.</div> '
" </div> "
" </div> "
)
)
name_field = " "
if show_name_field :
name_field = _settings_field (
" name " , " name " , " " ,
hint = " Container name as it appears in Docker. Letters, digits, _ and - only. " ,
)
body = (
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 +2146,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 . states , 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 == " /containers/new " :
csrf = self . _csrf_token_for_session ( )
flash = query . get ( " flash " , [ " " ] ) [ 0 ]
body = _render_container_new_page ( app , server . config_ref , csrf , flash )
self . _send_bytes ( HTTPStatus . OK , " text/html; charset=utf-8 " , body )
elif path . startswith ( " /containers/ " ) :
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 +2297,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 +2355,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 " )