@@ -520,27 +520,73 @@ a.signout:hover { color: var(--ink); border-color: var(--muted); }
.phase-observe { color: var(--muted); font-style: italic; }
.phase-observe { color: var(--muted); font-style: italic; }
.phase-maintenance { color: var(--violet); }
.phase-maintenance { color: var(--violet); }
.phase-timer { font-variant-numeric: tabular-nums; min-width: 3.5ch; }
.phase-timer { font-variant-numeric: tabular-nums; min-width: 3.5ch; }
/* Settings */
/* Settings — Design A: left rail + content pane */
.settings-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
.settings-layout {
.settings-card {
display: grid; grid-template-columns: 240px 1fr; gap: 1.25rem;
background: var(--card); border: 1px solid var(--border); border-radius: 14px;
background: var(--card); border: 1px solid var(--border); border-radius: 14px;
padding: 1rem 1.25rem ;
overflow: hidden; min-height: 540px ;
}
}
.settings-card h3 { marg in: 0 0 0.75rem; font-size: 0.95rem; color: var(--ink ); }
.settings-rail { padd ing : 1rem 0.5rem 1.25rem 1rem; border-right: 1px solid var(--border ); }
.settings-card .field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.75rem; }
.settings-rail .rail-group {
.settings-card .field-row { flex-direction: row; align-items: center; gap : 0.5r em; }
text-transform: uppercase; font-size: 0.66rem; letter-spacing : 0.08 em;
.settings-card label { font-size: 0.78rem; color: var(--muted); font-weight: 600; }
color: var(--muted); margin: 0.9rem 0.7rem 0.3rem;
.settings-card input[type=text], .settings-card input[type=number], .settings-card input[type=password] {
}
.settings-rail a {
display: flex; align-items: flex-start; gap: 0.55rem;
padding: 0.5rem 0.7rem; border-radius: 8px; color: #cbd5e1;
text-decoration: none; font-size: 0.84rem; font-weight: 500;
margin-bottom: 0.1rem;
}
.settings-rail a .rail-ico {
flex-shrink: 0; width: 1.1rem; display: inline-block; text-align: center;
font-size: 0.95rem; line-height: 1.2;
}
.settings-rail a .rail-text { flex: 1; min-width: 0; }
.settings-rail a .rail-text .rail-sub {
display: block; font-size: 0.7rem; color: var(--muted); font-weight: 400;
margin-top: 0.1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.settings-rail a:hover:not(.active) { background: #0f1829; color: var(--ink); }
.settings-rail a.active {
background: rgba(96,165,250,0.12); color: var(--blue); font-weight: 600;
}
.settings-rail a.active .rail-sub { color: var(--muted); }
.settings-pane { padding: 1.5rem 1.75rem; }
.settings-pane h3.pane-title { margin: 0 0 0.25rem; font-size: 1.02rem; }
.settings-pane p.pane-sub { color: var(--muted); margin: 0 0 1.5rem; font-size: 0.82rem; }
.settings-pane .subsection {
margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border);
}
.settings-pane .subsection:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
.settings-pane .subsection h4 {
margin: 0 0 0.3rem; font-size: 0.9rem; color: var(--ink);
display: flex; align-items: center; gap: 0.5rem;
}
.settings-pane .subsection .subsection-sub {
margin: 0 0 1rem; color: var(--muted); font-size: 0.78rem;
}
.settings-pane .field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.9rem; }
.settings-pane .field.field-row { flex-direction: row; align-items: center; gap: 0.6rem; }
.settings-pane .field label { font-size: 0.76rem; color: #94a3b8; font-weight: 600; }
.settings-pane .field.field-row label { font-size: 0.85rem; color: #cbd5e1; }
.settings-pane .field input[type=text], .settings-pane .field input[type=number],
.settings-pane .field input[type=password] {
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
padding: 0.4 rem 0.6rem; color: var(--ink); font-size: 0.85 rem; font-family: inherit; outline: none;
padding: 0.5 rem 0.65 rem; color: var(--ink); font-size: 0.88 rem; font-family: inherit; outline: none;
}
.settings-pane .field input:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(96,165,250,0.15); }
.settings-pane .field-hint { font-size: 0.72rem; color: var(--muted); }
.settings-pane .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.1rem 1rem; }
@media (max-width: 640px) { .settings-pane .grid-2 { grid-template-columns: 1fr; } }
.settings-pane .form-actions {
display: flex; justify-content: flex-end; gap: 0.5rem;
margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);
}
}
.settings-card input:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(96,165,250,0.15); }
.settings-card .field-hint { font-size: 0.72rem; color: var(--muted); }
.restart-flag {
.restart-flag {
display: inline-block; margin-left: 0.4rem; font-size: 0.65 rem; font-weight: 700;
display: inline-block; margin-left: 0.4rem; font-size: 0.63 rem; font-weight: 700;
text-transform: uppercase; letter-spacing: .05em;
text-transform: uppercase; letter-spacing: .05em;
background: var(--violet-bg); color: var(--violet);
background: var(--violet-bg); color: var(--violet);
padding: 0.1rem 0.4rem; border-radius: 999px;
padding: 0.1rem 0.45 rem; border-radius: 999px;
}
}
.pill-row { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.pill-row { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.pill {
.pill {
@@ -549,9 +595,39 @@ a.signout:hover { color: var(--ink); border-color: var(--muted); }
font-size: 0.72rem; color: var(--ink);
font-size: 0.72rem; color: var(--ink);
}
}
.pill input { accent-color: var(--blue); }
.pill input { accent-color: var(--blue); }
.flash { padding: 0.6rem 0.85rem; border-radius: 10px; margin-bottom: 1rem; font-size: 0.85rem; }
.flash {
.flash-ok { background: var(--green-bg); color: var(--green); border: 1px solid var(--green); }
display: flex; align-items: center; gap: 0.5rem;
padding: 0.6rem 0.85rem; border-radius: 10px; margin-bottom: 1rem;
font-size: 0.85rem; font-weight: 600;
}
.flash-ok {
background: var(--green-bg); color: var(--green);
border: 1px solid rgba(52,211,153,0.4);
}
.flash-ok::before { content: " ✓ " ; font-weight: 800; }
.flash-error { background: var(--red-bg); color: var(--red); border: 1px solid var(--red); }
.flash-error { background: var(--red-bg); color: var(--red); border: 1px solid var(--red); }
.flash-note { margin-left: auto; color: var(--muted); font-weight: 400; font-size: 0.78rem; }
/* Container list page */
.container-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem;
}
.container-tile {
background: var(--card); border: 1px solid var(--border);
border-color: color-mix(in srgb, var(--col) 28 % , var(--border));
border-radius: 12px; padding: 0.95rem 1.1rem;
text-decoration: none; color: var(--ink); display: block;
transition: border-color 0.15s;
}
.container-tile:hover { border-color: var(--blue); }
.container-tile .row1 {
display: flex; align-items: center; justify-content: space-between;
gap: 0.5rem; margin-bottom: 0.5rem;
}
.container-tile .ctn-name { font-weight: 700; font-size: 1rem; color: var(--col); }
.container-tile .ctn-meta { font-size: 0.75rem; color: var(--muted); display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; }
.container-tile .ctn-meta b { color: var(--ink); font-weight: 600; }
.container-tile .ctn-chev { color: var(--muted); font-size: 1rem; }
</style> """
</style> """
@@ -559,6 +635,7 @@ def _page_head(refresh: bool, active: str) -> str:
refresh_meta = ' <meta http-equiv= " refresh " content= " 30 " > \n ' if refresh else " "
refresh_meta = ' <meta http-equiv= " refresh " content= " 30 " > \n ' if refresh else " "
status_class = " active " if active == " status " else " "
status_class = " active " if active == " status " else " "
events_class = " active " if active == " events " else " "
events_class = " active " if active == " events " else " "
containers_class = " active " if active == " containers " else " "
settings_class = " active " if active == " settings " else " "
settings_class = " active " if active == " settings " else " "
meta_text = f " v { __version__ } "
meta_text = f " v { __version__ } "
if refresh :
if refresh :
@@ -591,6 +668,7 @@ def _page_head(refresh: bool, active: str) -> str:
' <div class= " tabs " > \n '
' <div class= " tabs " > \n '
f ' <a href= " / " class= " { status_class } " >Status</a> \n '
f ' <a href= " / " class= " { status_class } " >Status</a> \n '
f ' <a href= " /events " class= " { events_class } " >Events</a> \n '
f ' <a href= " /events " class= " { events_class } " >Events</a> \n '
f ' <a href= " /containers " class= " { containers_class } " >Containers</a> \n '
f ' <a href= " /settings " class= " { settings_class } " >Settings</a> \n '
f ' <a href= " /settings " class= " { settings_class } " >Settings</a> \n '
" </div> \n "
" </div> \n "
f ' <div class= " meta " ><span class= " meta-text " > { meta_text } </span><a class= " signout " href= " /logout " >Sign out</a></div> \n '
f ' <div class= " meta " ><span class= " meta-text " > { meta_text } </span><a class= " signout " href= " /logout " >Sign out</a></div> \n '
@@ -1094,139 +1172,322 @@ def _settings_secret_field(label: str, name: str, has_value: bool) -> str:
)
)
def _render_settings_page ( app : AppConfig , csrf_token : str , flash : str ) - > bytes :
# Each tuple: (slug, label, icon, subtitle_fn, group).
def _form ( section : str , title : str , body : str ) - > str :
# Groups show up as uppercase headers in the left rail.
return (
def _subtitle_inspector ( app : AppConfig ) - > str :
f ' <section class= " settings-card " ><h3> { _escape ( title ) } </h3> '
return f " { app . inspector . interval_seconds } s · dry-run { ' on ' if app . inspector . dry_run else ' off ' } "
f ' <form method= " post " action= " /settings/ { _escape ( section ) } " > '
f ' <input type= " hidden " name= " csrf " value= " { _escape ( csrf_token ) } " > '
f ' { body } '
f ' <button class= " action " type= " submit " >Save { _escape ( title ) } </button> '
f " </form></section> \n "
)
parts = [ _page_head ( refresh = False , active = " settings " ) ]
if flash :
def _subtitle_notifications ( app : AppConfig ) - > str :
if flash . startswith ( " ok: " ) :
g = " on " if app . gotify . enabled else " off "
parts . append (
p = " on " if app . pushover . enabled else " off "
f ' <div class= " flash flash-ok " >Saved: { _escape ( flash [ 3 : ] ) } . '
return f " gotify { g } · pushover { p } "
" Fields marked <em>requires restart</em> take effect after Inspector restarts.</div> \n "
)
elif flash . startswith ( " error: " ) :
parts . append ( f ' <div class= " flash flash-error " > { _escape ( flash [ 6 : ] ) } </div> \n ' )
parts . append ( ' <h2 class= " section-title " >Settings</h2> \n ' )
parts . append ( ' <div class= " settings-grid " > \n ' )
# Inspector
def _subtitle_dashboard ( app : AppConfig ) - > str :
insp = app . inspector
return f " { ' on ' if app . dashboard . enabled else ' off ' } · : { app . dashboard . port } "
body = (
_settings_field ( " interval_seconds " , " interval_seconds " , str ( insp . interval_seconds ) , kind = " number " )
+ _settings_field ( " log_level " , " log_level " , insp . log_level , hint = " DEBUG, INFO, WARNING, ERROR " )
+ _settings_field ( " dry_run " , " dry_run " , " on " if insp . dry_run else " " , kind = " checkbox " )
+ _settings_field ( " heartbeat " , " heartbeat " , " on " if insp . heartbeat else " " , kind = " checkbox " )
+ _settings_field ( " log_retention_days " , " log_retention_days " , str ( insp . log_retention_days ) , kind = " number " , requires_restart = True )
)
parts . append ( _form ( " inspector " , " Inspector " , body ) )
# Gotify
g = app . gotify
body = (
_settings_field ( " enabled " , " enabled " , " on " if g . enabled else " " , kind = " checkbox " , requires_restart = True )
+ _settings_field ( " url " , " url " , g . url , requires_restart = True )
+ _settings_secret_field ( " token " , " token " , bool ( g . token ) )
+ _settings_field ( " priority " , " priority " , str ( g . priority ) , kind = " number " )
)
parts . append ( _form ( " gotify " , " Gotify notifications " , body ) )
# Pushover
def _subtitle_database ( app : AppConfig ) - > str :
p = app . pushover
return f " { ' on ' if app . database . enabled else ' off ' } · { app . database . retention_days } d retention "
body = (
_settings_field ( " enabled " , " enabled " , " on " if p . enabled else " " , kind = " checkbox " , requires_restart = True )
+ _settings_secret_field ( " token " , " token " , bool ( p . token ) )
+ _settings_secret_field ( " user_key " , " user_key " , bool ( p . user_key ) )
+ _settings_field ( " priority " , " priority " , str ( p . priority ) , kind = " number " )
+ _settings_field ( " device " , " device " , p . device , hint = " optional; blank = all devices " )
+ _settings_field ( " sound " , " sound " , p . sound , hint = " optional; blank = user default " )
)
parts . append ( _form ( " pushover " , " Pushover notifications " , body ) )
# Dashboard
d = app . dashboard
body = (
_settings_field ( " enabled " , " enabled " , " on " if d . enabled else " " , kind = " checkbox " , requires_restart = True )
+ _settings_field ( " username " , " username " , d . username )
+ _settings_secret_field ( " password " , " password " , bool ( d . password ) )
)
parts . append ( _form ( " dashboard " , " Dashboard " , body ) )
# Database
def _subtitle_health_summary ( app : AppConfig ) - > str :
db = app . database
if not app . health_summary . enabled :
body = (
return " off "
_settings_field ( " enabled " , " enabled " , " on " if db . enabled else " " , kind = " checkbox " , requires_restart = True )
return f " on · every { app . health_summary . interval_hours } h "
+ _settings_field ( " retention_days " , " retention_days " , str ( db . retention_days ) , kind = " number " , requires_restart = True )
)
parts . append ( _form ( " database " , " Event history (SQLite) " , body ) )
# Health summary
hs = app . health_summary
body = (
_settings_field ( " enabled " , " enabled " , " on " if hs . enabled else " " , kind = " checkbox " , requires_restart = True )
+ _settings_field ( " interval_hours " , " interval_hours " , str ( hs . interval_hours ) , kind = " number " , requires_restart = True )
+ _settings_field ( " priority " , " priority " , str ( hs . priority ) , kind = " number " )
)
parts . append ( _form ( " health_summary " , " Health summary digest " , body ) )
# Maintenance window
def _subtitle_maintenance ( app : AppConfig ) - > str :
m = app . maintenance
m = app . maintenance
body = (
if not m . enabled :
_settings_field ( " enabled " , " enabled " , " on " if m . enabled else " " , kind = " checkbox " , requires_restart = True )
return " off "
+ _settings_field ( " start " , " start " , m . start , hint = " HH:MM, 24h " )
return f " on · { m . start } – { m . end } "
+ _settings_field ( " end " , " end " , m . end , hint = " HH:MM, 24h " )
+ _settings_field ( " max_hours " , " max_hours " , str ( m . max_hours ) , kind = " number " )
)
parts . append ( _form ( " maintenance " , " Scheduled maintenance window " , body ) )
# Notification mute
def _subtitle_notification_mute ( app : AppConfig ) - > str :
nm = app . notification_mute
nm = app . notification_mute
body = (
return f " { ' on ' if nm . enabled else ' off ' } · { nm . default_hours } h default "
_settings_field ( " enabled " , " enabled " , " on " if nm . enabled else " " , kind = " checkbox " , requires_restart = True )
+ _settings_field ( " default_hours " , " default_hours " , str ( nm . default_hours ) , kind = " number " )
_SETTINGS_SECTIONS = [
( " inspector " , " Inspector loop " , " ⚙ " , _subtitle_inspector , " Core " ) ,
( " notifications " , " Notifications " , " 🔔 " , _subtitle_notifications , " Core " ) ,
( " dashboard " , " Dashboard " , " 🖥 " , _subtitle_dashboard , " Core " ) ,
( " database " , " Event history " , " 🗄 " , _subtitle_database , " Data " ) ,
( " health_summary " , " Health summary " , " 📋 " , _subtitle_health_summary , " Data " ) ,
( " maintenance " , " Maintenance " , " 🔧 " , _subtitle_maintenance , " Scheduling " ) ,
( " notification_mute " , " Notification mute " , " 🔇 " , _subtitle_notification_mute , " Scheduling " ) ,
]
_SETTINGS_SECTION_SLUGS = { slug for slug , * _ in _SETTINGS_SECTIONS }
def _render_settings_rail ( app : AppConfig , active_slug : str ) - > str :
parts : list [ str ] = [ ' <nav class= " settings-rail " > \n ' ]
current_group : str | None = None
for slug , label , icon , sub_fn , group in _SETTINGS_SECTIONS :
if group != current_group :
parts . append ( f ' <div class= " rail-group " > { _escape ( group ) } </div> \n ' )
current_group = group
active = " active " if slug == active_slug else " "
subtitle = _escape ( sub_fn ( app ) )
parts . append (
f ' <a class= " { active } " href= " /settings/ { slug } " > '
f ' <span class= " rail-ico " > { icon } </span> '
f ' <span class= " rail-text " > { _escape ( label ) } '
f ' <span class= " rail-sub " > { subtitle } </span> '
f " </span></a> \n "
)
parts . append ( " </nav> \n " )
return " " . join ( parts )
def _settings_form ( section : str , title : str , body : str , csrf_token : str ) - > str :
return (
f ' <form method= " post " action= " /settings/ { _escape ( section ) } " > '
f ' <input type= " hidden " name= " csrf " value= " { _escape ( csrf_token ) } " > '
f " { body } "
' <div class= " form-actions " > '
f ' <button class= " btn " type= " submit " style= " background:var(--blue); color:#06111f; '
' border:none; border-radius:8px; padding:0.55rem 1rem; font-weight:700; cursor:pointer; '
f ' font-family:inherit; " >Save { _escape ( title ) } </button></div> '
" </form> \n "
)
)
parts . append ( _form ( " notification_mute " , " Notification mute " , body ) )
# Containers (edit of existing only; add/remove requires YAML edit + restart)
for c in app . containers :
def _settings_body_for ( section : str , app : AppConfig , csrf_token : str ) - > str :
restart_on_opts = [ " HEALTHY " , " UNHEALTHY " , " STARTING " , " NO HEALTH CHECK " ,
if section == " inspector " :
" NOT RUNNING " , " CONTAINER NOT FOUND " , " ERROR " , " UNKNOWN " , " UNREACHABLE " ]
insp = app . inspector
checkboxes = [ ]
for s in restart_on_opts :
checked = " checked " if s in c . restart_on else " "
checkboxes . append (
f ' <label class= " pill " ><input type= " checkbox " name= " restart_on " '
f ' value= " { _escape ( s ) } " { checked } > { _escape ( s ) } </label> '
)
restart_on_html = (
' <div class= " field " ><label>restart_on</label> '
f ' <div class= " pill-row " > { " " . join ( checkboxes ) } </div></div> '
)
body = (
body = (
_settings_field ( " enabled " , " enabled " , " on " if c . enabled else " " , kind = " checkbox " )
_settings_field ( " interval_seconds " , " interval_seconds " , str ( insp . interval_seconds ) , kind = " number " )
+ _settings_field ( " max_error_count " , " max_error_count " , str ( c . max_error_count ) , kind = " number " )
+ _settings_field ( " log_level " , " log_level " , insp . log_level , hint = " DEBUG, INFO, WARNING, ERROR " )
+ _settings_field ( " cooldown_seconds " , " cooldown_seconds " , str ( c . cooldown_seconds ) , kind = " number " )
+ _settings_field ( " dry_run " , " dry_run " , " on " if insp . dry_run else " " , kind = " checkbox " )
+ _settings_field ( " grace_period_seconds " , " grace_period_seconds " , str ( c . grace_period_seconds ) , kind = " number " )
+ _settings_field ( " heartbeat " , " heartbeat " , " on " if insp . heartbeat else " " , kind = " checkbox " )
+ _settings_field ( " max_restarts_per_hour " , " max_restarts_per_hour " , str ( c . max_restarts_per_hour ) , kind = " number " )
+ _settings_field ( " log_retention_days " , " log_retention_days " , str ( insp . log_retention_days ) , kind = " number " , requires_restart = True )
+ _settings_field ( " color " , " color " , c . color , hint = " hex, e.g. #60a5fa; blank = auto " )
)
+ restart_on_html
return (
' <h3 class= " pane-title " >Inspector loop</h3> '
' <p class= " pane-sub " >How often Inspector polls, how it logs, and whether it actually restarts anything.</p> '
+ _settings_form ( " inspector " , " Inspector " , body , csrf_token )
)
)
parts . append ( _form ( f " container: { c . name } " , f " Container — { c . name } " , body ) )
parts . append ( " </div> \n " )
if section == " notifications " :
g , p = app . gotify , app . pushover
gotify_body = (
_settings_field ( " enabled " , " enabled " , " on " if g . enabled else " " , kind = " checkbox " , requires_restart = True )
+ _settings_field ( " url " , " url " , g . url , hint = " Base URL of your Gotify server " , requires_restart = True )
+ _settings_secret_field ( " token " , " token " , bool ( g . token ) )
+ _settings_field ( " priority " , " priority " , str ( g . priority ) , kind = " number " )
)
pushover_body = (
_settings_field ( " enabled " , " enabled " , " on " if p . enabled else " " , kind = " checkbox " , requires_restart = True )
+ _settings_secret_field ( " token " , " token " , bool ( p . token ) )
+ _settings_secret_field ( " user_key " , " user_key " , bool ( p . user_key ) )
+ _settings_field ( " priority " , " priority " , str ( p . priority ) , kind = " number " , hint = " Mapped at send time to Pushover ' s -2..2 range " )
+ _settings_field ( " device " , " device " , p . device , hint = " Optional; blank = all devices " )
+ _settings_field ( " sound " , " sound " , p . sound , hint = " Optional; blank = user default " )
)
gotify_pill = ' <span class= " pill " style= " background:var(--green-bg); color:var(--green); " >enabled</span> ' if g . enabled else ' <span class= " pill " style= " background:var(--border); color:var(--muted); " >disabled</span> '
pushover_pill = ' <span class= " pill " style= " background:var(--green-bg); color:var(--green); " >enabled</span> ' if p . enabled else ' <span class= " pill " style= " background:var(--border); color:var(--muted); " >disabled</span> '
return (
' <h3 class= " pane-title " >Notifications</h3> '
' <p class= " pane-sub " >Enable one or both channels. If both are enabled, every alert fans out to both.</p> '
f ' <div class= " subsection " ><h4>Gotify { gotify_pill } </h4> '
' <p class= " subsection-sub " >Push notifications via your Gotify server. Messages render as Markdown.</p> '
+ _settings_form ( " gotify " , " Gotify " , gotify_body , csrf_token )
+ " </div> "
f ' <div class= " subsection " ><h4>Pushover { pushover_pill } </h4> '
' <p class= " subsection-sub " >Pushover is independent of Gotify. Inspector priority is mapped to Pushover \' s <code>-2..2</code> range: '
' <code>≤2 → -1</code>, <code>3-4 → 0</code>, <code>5-6 → 1</code>, <code>≥7 → 2</code>.</p> '
+ _settings_form ( " pushover " , " Pushover " , pushover_body , csrf_token )
+ " </div> "
)
if section == " dashboard " :
d = app . dashboard
body = (
_settings_field ( " enabled " , " enabled " , " on " if d . enabled else " " , kind = " checkbox " , requires_restart = True )
+ _settings_field ( " username " , " username " , d . username )
+ _settings_secret_field ( " password " , " password " , bool ( d . password ) )
)
return (
' <h3 class= " pane-title " >Dashboard</h3> '
' <p class= " pane-sub " >This web UI. Host and port are bound at startup — changes require a container restart.</p> '
+ _settings_form ( " dashboard " , " Dashboard " , body , csrf_token )
)
if section == " database " :
db = app . database
body = (
_settings_field ( " enabled " , " enabled " , " on " if db . enabled else " " , kind = " checkbox " , requires_restart = True )
+ _settings_field ( " retention_days " , " retention_days " , str ( db . retention_days ) , kind = " number " , requires_restart = True )
)
return (
' <h3 class= " pane-title " >Event history</h3> '
' <p class= " pane-sub " >SQLite log of status changes, restarts, and recoveries. Powers the Events page and the health summary digest.</p> '
+ _settings_form ( " database " , " Event history " , body , csrf_token )
)
if section == " health_summary " :
hs = app . health_summary
body = (
_settings_field ( " enabled " , " enabled " , " on " if hs . enabled else " " , kind = " checkbox " , requires_restart = True )
+ _settings_field ( " interval_hours " , " interval_hours " , str ( hs . interval_hours ) , kind = " number " , requires_restart = True )
+ _settings_field ( " priority " , " priority " , str ( hs . priority ) , kind = " number " )
)
return (
' <h3 class= " pane-title " >Health summary digest</h3> '
' <p class= " pane-sub " >Periodic rollup of per-container activity, delivered through the enabled notification channels. Requires Event history enabled to have data.</p> '
+ _settings_form ( " health_summary " , " Health summary " , body , csrf_token )
)
if section == " maintenance " :
m = app . maintenance
body = (
_settings_field ( " enabled " , " enabled " , " on " if m . enabled else " " , kind = " checkbox " , requires_restart = True )
+ _settings_field ( " start " , " start " , m . start , hint = " HH:MM, 24h " )
+ _settings_field ( " end " , " end " , m . end , hint = " HH:MM, 24h " )
+ _settings_field ( " max_hours " , " max_hours " , str ( m . max_hours ) , kind = " number " , hint = " Safety cap for manual maintenance toggles " )
)
return (
' <h3 class= " pane-title " >Scheduled maintenance window</h3> '
' <p class= " pane-sub " >Daily quiet hours. Containers that opt in (<code>maintenance_window: true</code> in their config) are not acted on during the window.</p> '
+ _settings_form ( " maintenance " , " Maintenance " , body , csrf_token )
)
if section == " notification_mute " :
nm = app . notification_mute
body = (
_settings_field ( " enabled " , " enabled " , " on " if nm . enabled else " " , kind = " checkbox " , requires_restart = True )
+ _settings_field ( " default_hours " , " default_hours " , str ( nm . default_hours ) , kind = " number " , hint = " How long a manual mute lasts by default " )
)
return (
' <h3 class= " pane-title " >Notification mute</h3> '
' <p class= " pane-sub " >Lets you mute alerts for a specific container from the Status page. The toggle itself appears on container cards when this is enabled.</p> '
+ _settings_form ( " notification_mute " , " Notification mute " , body , csrf_token )
)
return f " <p>Unknown section: { _escape ( section ) } </p> "
def _render_flash ( flash : str ) - > str :
if not flash :
return " "
if flash . startswith ( " ok: " ) :
label = _escape ( flash [ 3 : ] )
return (
f ' <div class= " flash flash-ok " >Saved — <b> { label } </b> changes applied. '
' <span class= " flash-note " >Fields marked “requires restart” take effect after Inspector restarts.</span> '
" </div> \n "
)
if flash . startswith ( " error: " ) :
return f ' <div class= " flash flash-error " > { _escape ( flash [ 6 : ] ) } </div> \n '
return " "
def _render_settings_page ( app : AppConfig , csrf_token : str , flash : str , section : str ) - > bytes :
if section not in _SETTINGS_SECTION_SLUGS :
section = " inspector "
parts = [ _page_head ( refresh = False , active = " settings " ) ]
parts . append ( _render_flash ( flash ) )
parts . append ( ' <div class= " settings-layout " > ' )
parts . append ( _render_settings_rail ( app , section ) )
parts . append ( ' <section class= " settings-pane " > ' )
parts . append ( _settings_body_for ( section , app , csrf_token ) )
parts . append ( " </section></div> \n " )
parts . append ( _PAGE_TAIL )
return " " . join ( parts ) . encode ( " utf-8 " )
def _render_containers_page ( app : AppConfig , states : dict , flash : str ) - > bytes :
parts = [ _page_head ( refresh = False , active = " containers " ) ]
parts . append ( _render_flash ( flash ) )
parts . append ( ' <h2 class= " section-title " >Containers</h2> \n ' )
parts . append (
parts . append (
' <p class= " muted " style= " margin-top:1rem " >To add or remove a container, '
' <p class= " muted " style= " margin:-0.4rem 0 1rem; font-size:0.85rem; " > '
" Click a container to edit its restart rules. To add or remove a container, "
" edit <code>config.yml</code> and restart Inspector.</p> \n "
" edit <code>config.yml</code> and restart Inspector.</p> \n "
)
)
parts . append ( ' <div class= " container-grid " > \n ' )
containers = list ( app . containers )
for i , c in enumerate ( containers ) :
color = _resolve_color ( c , i )
state = states . get ( c . name )
status = state . current_status if state and state . current_status else " — "
restart_on = " , " . join ( sorted ( c . restart_on ) ) if c . restart_on else " observe only "
parts . append (
f ' <a class= " container-tile " href= " /containers/ { _escape ( c . name ) } " '
f ' style= " --col: { _escape ( color ) } " > '
f ' <div class= " row1 " ><span class= " ctn-name " > { _escape ( c . name ) } </span> '
f ' <span class= " ctn-chev " >› </span></div> '
' <div class= " ctn-meta " > '
f ' <span>Status: <b> { _escape ( status ) } </b></span> '
f ' <span>Enabled: <b> { " yes " if c . enabled else " no " } </b></span> '
f ' <span>Max errors: <b> { c . max_error_count } </b></span> '
f ' <span>Cooldown: <b> { c . cooldown_seconds } s</b></span> '
f ' <span style= " flex-basis:100%; margin-top:0.2rem; " >Restart on: <b> { _escape ( restart_on ) } </b></span> '
" </div></a> \n "
)
parts . append ( " </div> \n " )
parts . append ( _PAGE_TAIL )
return " " . join ( parts ) . encode ( " utf-8 " )
def _render_container_edit_page ( app : AppConfig , name : str , csrf_token : str , flash : str ) - > bytes | None :
cfg = next ( ( c for c in app . containers if c . name == name ) , None )
if cfg is None :
return None
restart_on_opts = [ " HEALTHY " , " UNHEALTHY " , " STARTING " , " NO HEALTH CHECK " ,
" NOT RUNNING " , " CONTAINER NOT FOUND " , " ERROR " , " UNKNOWN " , " UNREACHABLE " ]
checkboxes : list [ str ] = [ ]
for s in restart_on_opts :
checked = " checked " if s in cfg . restart_on else " "
checkboxes . append (
f ' <label class= " pill " ><input type= " checkbox " name= " restart_on " '
f ' value= " { _escape ( s ) } " { checked } > { _escape ( s ) } </label> '
)
restart_on_html = (
' <div class= " field " ><label>restart_on</label> '
f ' <div class= " pill-row " > { " " . join ( checkboxes ) } </div> '
' <div class= " field-hint " >Statuses that trigger a restart. Leave all off for observe-only.</div> '
" </div> "
)
body = (
_settings_field ( " enabled " , " enabled " , " on " if cfg . enabled else " " , kind = " checkbox " )
+ _settings_field ( " max_error_count " , " max_error_count " , str ( cfg . max_error_count ) , kind = " number " ,
hint = " Consecutive failing checks before a restart is attempted " )
+ _settings_field ( " cooldown_seconds " , " cooldown_seconds " , str ( cfg . cooldown_seconds ) , kind = " number " ,
hint = " Minimum seconds between restarts " )
+ _settings_field ( " grace_period_seconds " , " grace_period_seconds " , str ( cfg . grace_period_seconds ) , kind = " number " ,
hint = " Ignore failures for this long after a restart (lets the container boot) " )
+ _settings_field ( " max_restarts_per_hour " , " max_restarts_per_hour " , str ( cfg . max_restarts_per_hour ) , kind = " number " ,
hint = " Hourly cap — Inspector pauses restarts after this many " )
+ _settings_field ( " color " , " color " , cfg . color , hint = " Hex, e.g. #60a5fa. Blank uses the auto palette. " )
+ restart_on_html
)
form_html = (
f ' <form method= " post " action= " /containers/ { _escape ( name ) } " > '
f ' <input type= " hidden " name= " csrf " value= " { _escape ( csrf_token ) } " > '
f " { body } "
' <div class= " form-actions " > '
' <a class= " btn secondary " href= " /containers " style= " background:transparent; color:#cbd5e1; '
' border:1px solid var(--border); border-radius:8px; padding:0.55rem 1rem; font-weight:700; '
' text-decoration:none; font-family:inherit; " >Back</a> '
' <button class= " btn " type= " submit " style= " background:var(--blue); color:#06111f; border:none; '
' border-radius:8px; padding:0.55rem 1rem; font-weight:700; cursor:pointer; font-family:inherit; " > '
" Save container</button></div> "
" </form> \n "
)
parts = [ _page_head ( refresh = False , active = " containers " ) ]
parts . append ( _render_flash ( flash ) )
parts . append (
f ' <h2 class= " section-title " >Container — <code style= " font-size:1em; " > { _escape ( name ) } </code></h2> \n '
)
parts . append ( ' <div class= " settings-pane " style= " background:var(--card); border:1px solid var(--border); border-radius:14px; padding:1.5rem 1.75rem; " > ' )
parts . append ( form_html )
parts . append ( " </div> \n " )
parts . append ( _PAGE_TAIL )
parts . append ( _PAGE_TAIL )
return " " . join ( parts ) . encode ( " utf-8 " )
return " " . join ( parts ) . encode ( " utf-8 " )
@@ -1404,10 +1665,31 @@ class _Handler(BaseHTTPRequestHandler):
events = [ ]
events = [ ]
body = json . dumps ( events ) . encode ( " utf-8 " )
body = json . dumps ( events ) . encode ( " utf-8 " )
self . _send_bytes ( HTTPStatus . OK , " application/json " , body )
self . _send_bytes ( HTTPStatus . OK , " application/json " , body )
elif path == " /settings " :
elif path == " /settings " or path . startswith ( " /settings/ " ) :
# /settings redirects to the first section so the URL is always addressable.
if path == " /settings " :
self . send_response ( HTTPStatus . SEE_OTHER )
self . send_header ( " Location " , " /settings/inspector " )
self . send_header ( " Content-Length " , " 0 " )
self . end_headers ( )
return
section = path [ len ( " /settings/ " ) : ] . strip ( " / " )
csrf = self . _csrf_token_for_session ( )
csrf = self . _csrf_token_for_session ( )
flash = query . get ( " flash " , [ " " ] ) [ 0 ]
flash = query . get ( " flash " , [ " " ] ) [ 0 ]
body = _render_settings_page ( app , csrf , flash )
body = _render_settings_page ( app , csrf , flash , section )
self . _send_bytes ( HTTPStatus . OK , " text/html; charset=utf-8 " , body )
elif path == " /containers " :
flash = query . get ( " flash " , [ " " ] ) [ 0 ]
body = _render_containers_page ( app , server . states , flash )
self . _send_bytes ( HTTPStatus . OK , " text/html; charset=utf-8 " , body )
elif path . startswith ( " /containers/ " ) :
name = path [ len ( " /containers/ " ) : ] . strip ( " / " )
csrf = self . _csrf_token_for_session ( )
flash = query . get ( " flash " , [ " " ] ) [ 0 ]
body = _render_container_edit_page ( app , name , csrf , flash )
if body is None :
self . _send_bytes ( HTTPStatus . NOT_FOUND , " text/plain; charset=utf-8 " , b " unknown container " )
return
self . _send_bytes ( HTTPStatus . OK , " text/html; charset=utf-8 " , body )
self . _send_bytes ( HTTPStatus . OK , " text/html; charset=utf-8 " , body )
else :
else :
self . _send_bytes ( HTTPStatus . NOT_FOUND , " text/plain; charset=utf-8 " , b " not found " )
self . _send_bytes ( HTTPStatus . NOT_FOUND , " text/plain; charset=utf-8 " , b " not found " )
@@ -1518,17 +1800,44 @@ class _Handler(BaseHTTPRequestHandler):
if len ( parts ) == 2 and parts [ 0 ] == " settings " :
if len ( parts ) == 2 and parts [ 0 ] == " settings " :
section = parts [ 1 ]
section = parts [ 1 ]
# Where to send the browser after save. Notifications are
# Gotify+Pushover grouped under /settings/notifications; everything
# else matches the section slug.
if section in ( " gotify " , " pushover " ) :
return_path = " /settings/notifications "
else :
return_path = f " /settings/ { section } "
try :
try :
_apply_settings_update ( server . config_ref , section , form )
_apply_settings_update ( server . config_ref , section , form )
except _SettingsError as exc :
except _SettingsError as exc :
flash = f " error: { exc } "
flash = f " error: { exc } "
self . send_response ( HTTPStatus . SEE_OTHER )
self . send_response ( HTTPStatus . SEE_OTHER )
self . send_header ( " Location " , f " /settings ?flash={ _url_quote ( flash ) } " )
self . send_header ( " Location " , f " { return_path } ?flash= { _url_quote ( flash ) } " )
self . send_header ( " Content-Length " , " 0 " )
self . send_header ( " Content-Length " , " 0 " )
self . end_headers ( )
self . end_headers ( )
return
return
self . send_response ( HTTPStatus . SEE_OTHER )
self . send_response ( HTTPStatus . SEE_OTHER )
self . send_header ( " Location " , f " /settings ?flash={ _url_quote ( ' ok: ' + section ) } " )
self . send_header ( " Location " , f " { return_path } ?flash= { _url_quote ( ' ok: ' + section ) } " )
self . send_header ( " Content-Length " , " 0 " )
self . end_headers ( )
return
if len ( parts ) == 2 and parts [ 0 ] == " containers " :
name = parts [ 1 ]
if name not in containers :
self . _send_bytes ( HTTPStatus . NOT_FOUND , " text/plain; charset=utf-8 " , b " unknown container " )
return
try :
_apply_settings_update ( server . config_ref , f " container: { name } " , form )
except _SettingsError as exc :
flash = f " error: { exc } "
self . send_response ( HTTPStatus . SEE_OTHER )
self . send_header ( " Location " , f " /containers/ { _url_quote ( name ) } ?flash= { _url_quote ( flash ) } " )
self . send_header ( " Content-Length " , " 0 " )
self . end_headers ( )
return
self . send_response ( HTTPStatus . SEE_OTHER )
self . send_header ( " Location " , f " /containers/ { _url_quote ( name ) } ?flash= { _url_quote ( ' ok: ' + name ) } " )
self . send_header ( " Content-Length " , " 0 " )
self . send_header ( " Content-Length " , " 0 " )
self . end_headers ( )
self . end_headers ( )
return
return