@@ -548,6 +548,13 @@ a.signout:hover { color: var(--ink); border-color: var(--muted); }
margin-top: 0.1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.settings-rail a:hover:not(.active) { background: #0f1829; color: var(--ink); }
/* Per-container entries sit under " All containers " : indented, one line each. */
.settings-rail a.rail-child { margin-left: 1.1rem; padding-top: 0.32rem; padding-bottom: 0.32rem; align-items: center; }
.settings-rail a.rail-child .rail-ico { font-size: 0.7rem; }
.settings-rail a.rail-child .rail-text { display: flex; align-items: baseline; min-width: 0; white-space: nowrap; }
.settings-rail a.rail-child .rail-name { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.settings-rail a.rail-child .rail-inline-sub { flex-shrink: 0; }
.settings-rail a .rail-inline-sub { font-size: 0.7rem; color: var(--muted); font-weight: 400; margin-left: 0.4rem; }
.settings-rail a.active {
background: rgba(96,165,250,0.12); color: var(--blue); font-weight: 600;
}
@@ -714,6 +721,60 @@ a.signout:hover { color: var(--ink); border-color: var(--muted); }
}
.color-row input[type=text] { flex: 1; }
/* restart_on grid */
.restart-on-head {
display: flex; align-items: center; justify-content: space-between;
margin: 0.4rem 0 0.5rem;
}
.restart-on-head label {
font-size: 0.76rem; color: #94a3b8; font-weight: 600;
}
.restart-on-head .quick {
display: flex; gap: 0.4rem;
}
.restart-on-head .quick button {
background: transparent; color: var(--blue); border: 1px solid var(--border);
padding: 0.25rem 0.55rem; border-radius: 6px; font-size: 0.72rem;
cursor: pointer; font-family: inherit; font-weight: 600;
}
.restart-on-head .quick button:hover { background: var(--blue-bg); border-color: var(--blue); }
.restart-on-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 0.5rem; margin-bottom: 0.5rem;
}
.rcard {
position: relative; display: block;
padding: 0.65rem 0.75rem;
background: var(--bg); border: 1px solid var(--border);
border-radius: 10px; cursor: pointer; user-select: none;
transition: border-color 0.1s, background 0.1s;
}
.rcard:hover { border-color: var(--border-2, #273449); }
.rcard input { position: absolute; opacity: 0; pointer-events: none; }
.rcard.on { border-color: var(--rc-col); background: color-mix(in srgb, var(--rc-col) 10 % , var(--bg)); }
.rcard .rc-head {
display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.25rem;
}
.rcard .rc-dot {
width: 8px; height: 8px; border-radius: 50 % ; background: var(--rc-col);
flex-shrink: 0;
}
.rcard .rc-name {
font-size: 0.82rem; font-weight: 700; color: var(--ink);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.rcard .rc-check {
margin-left: auto; width: 16px; height: 16px; border-radius: 4px;
border: 1.5px solid var(--border-2, #273449); background: transparent;
display: flex; align-items: center; justify-content: center;
color: transparent; font-weight: 900; font-size: 0.75rem;
}
.rcard.on .rc-check {
background: var(--rc-col); border-color: var(--rc-col); color: #06111f;
}
.rcard.on .rc-check::after { content: " ✓ " ; }
.rcard .rc-desc { font-size: 0.72rem; color: var(--muted); line-height: 1.3; }
/* Danger button for delete container */
.btn-danger {
background: var(--red-bg); color: var(--red); border: 1px solid var(--red);
@@ -728,8 +789,8 @@ def _page_head(refresh: bool, active: str) -> str:
refresh_meta = ' <meta http-equiv= " refresh " content= " 30 " > \n ' if refresh else " "
status_class = " active " if active == " status " else " "
events_class = " active " if active == " events " else " "
containers_class = " active " if active == " containers " else " "
settings_class = " active " if active == " settings " else " "
# Settings tab stays active while browsing containers — they live under settings now.
settings_class = " active " if active in ( " settings " , " containers " ) else " "
meta_text = f " v { __version__ } "
return (
" <!DOCTYPE html> \n "
@@ -759,7 +820,6 @@ def _page_head(refresh: bool, active: str) -> str:
' <div class= " tabs " > \n '
f ' <a href= " / " class= " { status_class } " >Status</a> \n '
f ' <a href= " /events " class= " { events_class } " >Events</a> \n '
f ' <a href= " /containers " class= " { containers_class } " >Containers</a> \n '
f ' <a href= " /settings " class= " { settings_class } " >Settings</a> \n '
" </div> \n "
f ' <div class= " meta " ><span class= " meta-text " > { meta_text } </span><a class= " signout " href= " /logout " >Sign out</a></div> \n '
@@ -1195,7 +1255,7 @@ def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> N
cfg [ " max_restarts_per_hour " ] = _form_int ( form , " max_restarts_per_hour " , 3 )
cfg [ " color " ] = _form_str ( form , " color " , " " )
restart_on = form . get ( " restart_on " , [ ] )
if restart_on :
if restart_on or form . get ( " restart_on_present " ) :
cfg [ " restart_on " ] = [ s . upper ( ) for s in restart_on ]
containers [ name ] = cfg
raw [ " containers " ] = containers
@@ -1242,7 +1302,7 @@ def _apply_container_create(config_ref: ConfigRef, form: dict) -> str:
" color " : _form_str ( form , " color " , " " ) ,
}
restart_on = form . get ( " restart_on " , [ ] )
if restart_on :
if restart_on or form . get ( " restart_on_present " ) :
cfg [ " restart_on " ] = [ s . upper ( ) for s in restart_on ]
containers [ name ] = cfg
raw [ " containers " ] = containers
@@ -1458,7 +1518,10 @@ _SETTINGS_SECTIONS = [
_SETTINGS_SECTION_SLUGS = { slug for slug , * _ in _SETTINGS_SECTIONS }
def _render_settings_rail ( app : AppConfig , active_slug : str , active_container : str = " " ) - > str :
def _render_settings_rail (
app : AppConfig , active_slug : str , active_container : str = " " ,
* , containers_tab_active : bool = False ,
) - > str :
parts : list [ str ] = [ ' <nav class= " settings-rail " > \n ' ]
current_group : str | None = None
for slug , label , icon , sub_fn , group in _SETTINGS_SECTIONS :
@@ -1474,24 +1537,32 @@ def _render_settings_rail(app: AppConfig, active_slug: str, active_container: st
f ' <span class= " rail-sub " > { subtitle } </span> '
f " </span></a> \n "
)
# Containers group.
# Containers group — a top-level "Containers" entry (goes to the grid view)
# plus per-container entries so you can jump straight to one.
parts . append (
f ' <div class= " rail-group " >Containers <span style= " color:var(--muted); font-weight:400; " > '
f ' ( { len ( app . containers ) } )</span></div> \n '
)
containers_active = " active " if containers_tab_active else " "
parts . append (
f ' <a class= " { containers_active } " href= " /containers " > '
' <span class= " rail-ico " >📦</span> '
' <span class= " rail-text " >All containers '
' <span class= " rail-sub " >grid view</span></span></a> \n '
)
for i , c in enumerate ( app . containers ) :
color = _resolve_color ( c , i )
active = " active " if c . name == active_container else " "
enabled_sub = " enabled " if c . enabled else " disabled "
parts . append (
f ' <a class= " { active } " href= " /containers/ { _escape ( c . name ) } " > '
f ' <a class= " rail-child { 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 class= " rail-text " ><span class= " rail-name " > { _escape ( c . name ) } </span> '
f ' <span class= " rail-inline-sub " > { enabled_sub } </span> '
f " </span></a> \n "
)
parts . append (
' <a href= " /containers/new " style= " color:var(--blue); font-weight:600; " > '
' <a class= " rail-child " href= " /containers/new " style= " color:var(--blue); font-weight:600; " > '
' <span class= " rail-ico " >+</span> '
' <span class= " rail-text " >Add container</span></a> \n '
)
@@ -1620,17 +1691,27 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
)
gotify_pill = ' <span class= " pill " style= " background:var(--green-bg); color:var(--green); " >enabled</span> ' if g . enabled else ' <span class= " pill " style= " background:var(--border); color:var(--muted); " >disabled</span> '
pushover_pill = ' <span class= " pill " style= " background:var(--green-bg); color:var(--green); " >enabled</span> ' if p . enabled else ' <span class= " pill " style= " background:var(--border); color:var(--muted); " >disabled</span> '
priority_ref = (
' <div class= " priority-ref " ><b>Priority reference </b> '
' <span class= " plevel " ><code>1-2 </code> info / debug </span> '
' <span class= " plevel " ><code>3-4 </code> normal (default )</span> '
' <span class= " plevel " ><code>5-6 </code> warning </span> '
' <span class= " plevel " ><code>7-8 </code> error </span> '
' <span class= " plevel " ><code>9-10</code> critical / emergency</span >'
' <br><span style= " margin-top:0.3rem; display:inline-block; " > '
' Pushover mapping: <code>≤2 → -1</code> · '
' <code>3-4 → 0</code> · <code>5-6 → 1</code> · '
' <code>≥7 → 2 (emergency)</code > '
gotify_priority_ref = (
' <div class= " priority-ref " ><b>Gotify priority levels </b> '
' <span class= " plevel " ><code>0 </code> silent (no popup) </span> '
' <span class= " plevel " ><code>1-3 </code> low (notification only )</span> '
' <span class= " plevel " ><code>4-7 </code> normal (default <code>5</code>) </span> '
' <span class= " plevel " ><code>8-10 </code> high (persistent) </span> '
' </div > '
)
pushover_priority_ref = (
' <div class= " priority-ref " ><b>Pushover priority levels</b> '
' <span class= " plevel " ><code>-2</code> Lowest (silent)</span >'
' <span class= " plevel " ><code>-1</code> Low (quiet)</span> '
' <span class= " plevel " ><code>0</code> Normal (default)</span> '
' <span class= " plevel " ><code>1</code> High (bypass quiet hours)</span> '
' <span class= " plevel " ><code>2</code> Emergency (retries until ack)</span> '
' <br><span style= " margin-top:0.3rem; display:inline-block; font-size:0.72rem; " > '
' Inspector priority maps automatically: '
' <code>≤2 → -1</code> · '
' <code>3-4 → 0</code> · '
' <code>5-6 → 1</code> · '
' <code>≥7 → 2</code> '
' </span></div> '
)
gotify_test_row = (
@@ -1648,13 +1729,14 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
return (
' <h3 class= " pane-title " >Notifications</h3> '
' <p class= " pane-sub " >Enable one or both channels. If both are enabled, every alert fans out to both.</p> '
+ priority_ref
+ f ' <div class= " subsection " ><h4>Gotify { gotify_pill } </h4> '
' <p class= " subsection-sub " >Push notifications via your Gotify server. Messages render as Markdown.</p> '
+ gotify_priority_ref
+ _settings_form_with_test ( " gotify " , " Gotify " , gotify_body , gotify_test_row , csrf_token )
+ " </div> "
+ f ' <div class= " subsection " ><h4>Pushover { pushover_pill } </h4> '
' <p class= " subsection-sub " >Independent of Gotify. Supports priorities <code>-2</code> (lowest) through <code>2</code> (emergency, which Pushover retries until acknowledged) .</p> '
' <p class= " subsection-sub " >Independent of Gotify. Inspector maps its internal 1– 10 priority to Pushover \' s -2..2 range at send time .</p>'
+ pushover_priority_ref
+ _settings_form_with_test ( " pushover " , " Pushover " , pushover_body , pushover_test_row , csrf_token )
+ " </div> "
)
@@ -1758,20 +1840,20 @@ def _render_settings_page(app: AppConfig, config_ref: ConfigRef, csrf_token: str
return " " . join ( parts ) . encode ( " utf-8 " )
def _render_containers_page ( app : AppConfig , states : dict , flash : str ) - > bytes :
def _render_containers_page ( app : AppConfig , config_ref : ConfigRef , states : dict , flash : str ) - > bytes :
parts = [ _page_head ( refresh = False , active = " containers " ) ]
parts . append ( _render_flash ( flash ) )
parts . append ( ' <div class= " settings-layout " > ' )
parts . append ( _render_settings_rail ( app , " " , containers_tab_active = True ) )
parts . append ( ' <section class= " settings-pane " > ' )
parts . append (
' <div style= " display:flex; align-items:center; justify-content:space-between; margin-bottom:1 rem; " > '
' <h2 class= " section-title " style= " margin:0; " >C ontainers</h2 > '
' <a href= " /containers/new " style= " background:var(--blue); color:#06111f; padding:0.5rem 0.9 rem; '
' border-radius:8px; font-weight:700; text-decoration:none; font-size:0.85 rem; " >+ New container</a> '
' <div style= " display:flex; align-items:center; justify-content:space-between; margin-bottom:0.3 rem; " > '
' <h3 class= " pane-title " style= " margin:0; " >All c ontainers</h3 > '
' <a href= " /containers/new " style= " background:var(--blue); color:#06111f; padding:0.4 5rem 0.85 rem; '
' border-radius:8px; font-weight:700; text-decoration:none; font-size:0.82 rem; " >+ New container</a> '
' </div> '
)
parts . append (
' <p class= " muted " style= " margin:-0.4rem 0 1rem; font-size:0.85rem; " > '
" Click a tile to edit its restart rules, or add / remove containers from the UI "
" (the underlying <code>config.yml</code> is updated for you).</p> \n "
' <p class= " pane-sub " >Click a tile to edit its restart rules. Add or remove containers directly from the UI — '
' <code>config.yml</code> is updated for you.</p> '
)
parts . append ( ' <div class= " container-grid " > \n ' )
containers = list ( app . containers )
@@ -1794,6 +1876,8 @@ def _render_containers_page(app: AppConfig, states: dict, flash: str) -> bytes:
" </div></a> \n "
)
parts . append ( " </div> \n " )
parts . append ( " </section></div> \n " )
parts . append ( _render_diag_strip ( config_ref ) )
parts . append ( _PAGE_TAIL )
return " " . join ( parts ) . encode ( " utf-8 " )
@@ -1828,26 +1912,66 @@ def _render_color_input(name: str, value: str) -> str:
)
# (status, card accent color, plain-language description) for the restart_on grid.
# Ordered most-common trigger first.
_RESTART_ON_OPTIONS = [
( " UNHEALTHY " , " #f87171 " , " Docker health check is failing. " ) ,
( " NOT RUNNING " , " #fb923c " , " Container is stopped, exited, or crashed. " ) ,
( " UNREACHABLE " , " #fbbf24 " , " Running, but its HTTP / port check failed. " ) ,
( " ERROR " , " #f472b6 " , " Inspector hit an error while checking it. " ) ,
( " STARTING " , " #60a5fa " , " Health check still in its start period. " ) ,
( " UNKNOWN " , " #a78bfa " , " Status couldn ' t be determined. " ) ,
( " NO HEALTH CHECK " , " #94a3b8 " , " Image defines no health check. " ) ,
( " CONTAINER NOT FOUND " , " #94a3b8 " , " No container with this name exists. " ) ,
( " HEALTHY " , " #34d399 " , " Passing — rarely a restart trigger. " ) ,
]
_RESTART_ON_RECOMMENDED = ( " UNHEALTHY " , " NOT RUNNING " )
_RESTART_ON_JS = (
" <script>window.InspectorRestartOn=function(btn,mode) { "
" var rec= " + json . dumps ( list ( _RESTART_ON_RECOMMENDED ) ) + " ; "
" btn.closest( ' .field ' ).querySelectorAll( ' .rcard input ' ).forEach(function(cb) { "
" cb.checked=(mode=== ' recommended ' )&&rec.indexOf(cb.value)>=0; "
" cb.parentElement.classList.toggle( ' on ' ,cb.checked);});};</script> "
)
def _render_container_form (
name : str , cfg , csrf_token : str , action : str , submit_label : str ,
* , show_name_field : bool = False , show_delete : bool = False ,
) - > str :
""" Build the container edit form. `cfg` may be a ContainerConfig or a dummy with defaults. """
restart_on_opts = [ " HEALTHY " , " UNHEALTHY " , " STARTING " , " NO HEALTH CHECK " ,
" NOT RUNNING " , " CONTAINER NOT FOUND " , " ERROR " , " UNKNOWN " , " UNREACHABLE " ]
current_restart_on = getattr ( cfg , " restart_on " , [ ] ) or [ ]
checkboxes : list [ str ] = [ ]
for s in restart_on_opts :
checked = " checked " if s in current_restart_on else " "
checkboxes . append (
f ' <label class= " pill " ><input typ e=" checkbox " name= " restart_on " '
f ' value= " { _escape ( s ) } " { checked } > { _escape ( s ) } </label> '
cards : list [ str ] = [ ]
for status , color , desc in _RESTART_ON_OPTIONS :
is_on = status in current_restart_on
cards . append (
f ' <label class= " rcard { " on " if is_on else " " } " s tyl e=" --rc-col: { color } ; " > '
f ' <input type= " checkbox " name= " restart_on " value= " { _escape ( status ) } " '
f ' { " checked " if is_on else " " } '
" onchange= \" this.parentElement.classList.toggle( ' on ' , this.checked) \" > "
' <div class= " rc-head " ><span class= " rc-dot " ></span> '
f ' <span class= " rc-name " > { _escape ( status ) } </span> '
' <span class= " rc-check " ></span></div> '
f ' <div class= " rc-desc " > { _escape ( desc ) } </div> '
" </label> "
)
restart_on_html = (
' <div class= " field " ><label>restart_on</label> '
f ' <div class= " pill-row " > { " " . join ( checkboxes ) } </div> '
' <div class= " field-hint " >Statuses that trigger a restart. Leave all off for observe-only.</div> '
' <div class= " field " > '
# Marker so the backend knows the grid was submitted; an all-unchecked
# grid then means "observe only" rather than "leave unchanged".
' <input type= " hidden " name= " restart_on_present " value= " 1 " > '
' <div class= " restart-on-head " ><label>Restart when status is…</label> '
' <div class= " quick " > '
' <button type= " button " onclick= " InspectorRestartOn(this, \' recommended \' ) " >Recommended</button> '
' <button type= " button " onclick= " InspectorRestartOn(this, \' none \' ) " >Observe only</button> '
" </div></div> "
f ' <div class= " restart-on-grid " > { " " . join ( cards ) } </div> '
' <div class= " field-hint " >Inspector restarts the container when it sees any selected status '
" <code>max_error_count</code> times in a row. Select none for observe-only "
" (alerts without restarts).</div> "
" </div> "
+ _RESTART_ON_JS
)
name_field = " "
if show_name_field :
@@ -2150,7 +2274,7 @@ class _Handler(BaseHTTPRequestHandler):
self . _send_bytes ( HTTPStatus . OK , " text/html; charset=utf-8 " , body )
elif path == " /containers " :
flash = query . get ( " flash " , [ " " ] ) [ 0 ]
body = _render_containers_page ( app , server . states , flash )
body = _render_containers_page ( app , server . config_ref , server . states , flash )
self . _send_bytes ( HTTPStatus . OK , " text/html; charset=utf-8 " , body )
elif path == " /containers/new " :
csrf = self . _csrf_token_for_session ( )