2 Commits
10 changed files with 684 additions and 34 deletions

No files matched your search

+35
View File
@@ -7,6 +7,41 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
--- ---
## [0.9.0] — 2026-10-28
### Added
- **Every container option is now in the dashboard.** The container page has three
new groups:
- **Reachability**: HTTP check (URL, timeout, expected status) and Uptime Kuma
monitor.
- **Dependencies**: `depends_on` (pick from your containers) and `vpn_routed`.
- **Recovery**: `recovery_action` with plain-language descriptions, plus the
Portainer environment and stack name for stack redeploys.
- **Integrations settings.** New **Uptime Kuma** and **Portainer** pages under
Settings. Portainer environments can be added, edited, renamed (containers
that use them are updated) and deleted (refused while a container uses one).
The API token can be an environment variable name (recommended) or a masked
value saved in `config.yml`.
- **Test connection** buttons for Uptime Kuma (lists monitors) and each Portainer
environment (lists stacks on the endpoint), using the values in the form even
before saving.
- The All containers grid shows each container's extras (HTTP check, Uptime Kuma,
dependency, recovery action).
- Guide: new sections on reachability checks and apps behind a VPN container.
### Changed
- Uptime Kuma and Portainer changes take effect on the next check; Inspector
rebuilds those clients when their settings change instead of needing a restart.
- Saving a container form unchanged now leaves `config.yml` byte-identical. Blank
optional fields are removed instead of written as empty values (`color: ''`,
`maintenance_window: false`), and an unchanged `restart_on` list keeps its
order and the comments after it. An explicit `recovery_action: restart_container`
is kept.
---
## [0.8.0] — 2026-10-27 ## [0.8.0] — 2026-10-27
### Added ### Added
+9 -7
View File
@@ -1,5 +1,5 @@
<p align="center"> <p align="center">
<img src="docs/logo.png" alt="Inspector logo" width="140"> <img src="docs/logo_outline.png" alt="Inspector logo" width="140">
</p> </p>
<h1 align="center">Inspector</h1> <h1 align="center">Inspector</h1>
@@ -52,11 +52,11 @@ guide (the same guide is in this repo at
download it and open it in a browser to read it here). download it and open it in a browser to read it here).
| Add a container | Restart rules | | Add a container | Restart rules |
|---|---| | ------------------------------------------------------------------- | -------------------------------------------------------------------- |
| ![New container form](src/inspector/assets/guide/container-new.png) | ![Container settings](src/inspector/assets/guide/container-edit.png) | | ![New container form](src/inspector/assets/guide/container-new.png) | ![Container settings](src/inspector/assets/guide/container-edit.png) |
| Notifications | Events history | | Notifications | Events history |
|---|---| | ------------------------------------------------------------------------------- | --------------------------------------------------------- |
| ![Notification settings](src/inspector/assets/guide/settings-notifications.png) | ![Events timeline](src/inspector/assets/guide/events.png) | | ![Notification settings](src/inspector/assets/guide/settings-notifications.png) | ![Events timeline](src/inspector/assets/guide/events.png) |
## What it isn't ## What it isn't
@@ -69,18 +69,20 @@ download it and open it in a browser to read it here).
## Advanced options ## Advanced options
Most people never need these. They live in `config.yml` (in your Most people never need these. They are all on each container's page in the
`INSPECTOR_DATA/config` folder) rather than the dashboard; see dashboard (and in `config.yml`, see
[`config.example.yml`](./config.example.yml) for every option with comments. [`config.example.yml`](./config.example.yml) for every option with comments).
- **HTTP checks**: mark a container `UNREACHABLE` when a URL stops answering, - **HTTP checks**: mark a container `UNREACHABLE` when a URL stops answering,
even if Docker says it's healthy (useful for apps behind a VPN container). even if Docker says it's healthy (useful for apps behind a VPN container).
- **Uptime Kuma**: use a Kuma status page as an extra reachability signal. - **Uptime Kuma**: use a Kuma status page as an extra reachability signal.
**Test connection** lists the monitors Inspector can see.
- **Dependencies** (`depends_on`): don't restart an app while the container it - **Dependencies** (`depends_on`): don't restart an app while the container it
relies on (e.g. a VPN) is down. relies on (e.g. a VPN) is down.
- **Portainer stack redeploy**: for containers that share a VPN container's - **Portainer stack redeploy**: for containers that share a VPN container's
network, recover by redeploying the whole stack instead of restarting one network, recover by redeploying the whole stack instead of restarting one
container. container. Set up the Portainer connection under **Settings → Portainer**;
**Test connection** lists your stacks.
## Security ## Security
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+1 -1
View File
@@ -1 +1 @@
__version__ = "0.8.0" __version__ = "0.9.0"
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+43 -1
View File
@@ -197,11 +197,51 @@ does <i>not</i> touch the container itself.</p>
<tr><th>Status</th><th>When it happens</th></tr> <tr><th>Status</th><th>When it happens</th></tr>
<tr><td>UNHEALTHY</td><td>The container's own health check is failing.</td></tr> <tr><td>UNHEALTHY</td><td>The container's own health check is failing.</td></tr>
<tr><td>NOT RUNNING</td><td>Stopped, exited, or crashed.</td></tr> <tr><td>NOT RUNNING</td><td>Stopped, exited, or crashed.</td></tr>
<tr><td>UNREACHABLE</td><td>Running, but an HTTP check failed (advanced, set in config.yml).</td></tr> <tr><td>UNREACHABLE</td><td>Running, but its HTTP check or Uptime Kuma monitor says it's down (see below).</td></tr>
<tr><td>ERROR</td><td>Inspector couldn't check it (Docker API error).</td></tr> <tr><td>ERROR</td><td>Inspector couldn't check it (Docker API error).</td></tr>
<tr><td>STARTING</td><td>Still in its health-check start period. Usually leave this off.</td></tr> <tr><td>STARTING</td><td>Still in its health-check start period. Usually leave this off.</td></tr>
<tr><td>HEALTHY / NO HEALTH CHECK</td><td>Normal states; almost never a reason to restart.</td></tr> <tr><td>HEALTHY / NO HEALTH CHECK</td><td>Normal states; almost never a reason to restart.</td></tr>
</table> </table>
<h3>Reachability: catching apps that look fine but aren't</h3>
<p>Docker only knows whether a container is running and whether its own health check passes. An
app can pass both and still be unusable, for example when it routes through a VPN container
whose tunnel dropped. Under <b>Reachability</b> on the container page you can add:</p>
<ul>
<li><b>HTTP check</b>: a URL Inspector requests every check (for example
<code>http://192.168.1.10:9696/ping</code>). Set <b>expected_status</b> to the code the app
returns when it's healthy; most return 200, some return 401 or 302.</li>
<li><b>Uptime Kuma monitor</b>: if you already run Uptime Kuma, name one of its monitors. Set up
the connection first in <b>Settings → Uptime Kuma</b>; <b>Test connection</b> lists the
monitor names it can see.</li>
</ul>
<p>A failed check reports <b>UNREACHABLE</b>. Tick UNREACHABLE in the restart triggers if you want
Inspector to act on it.</p>
<figure><img src="container-advanced.png" alt="Reachability, Dependencies and Recovery groups">
<figcaption>Bottom of a container's page: an app behind a VPN container with an HTTP check and stack-redeploy recovery.</figcaption></figure>
<h3>Apps behind a VPN container</h3>
<p>If an app shares a VPN container's network (<code>network_mode: container:vpn</code> or similar), set these on the app's page:</p>
<ol class="steps">
<li><b>Dependencies → depends_on</b>: pick the VPN container, and turn on <b>vpn_routed</b>.
Inspector checks the VPN first and waits for it to recover before touching the app.</li>
<li><b>Recovery → recovery_action</b>: choose <b>Restart, then redeploy stack if that fails</b>
and enter the <b>stack name</b> exactly as Portainer shows it. When the VPN container gets
recreated, a plain restart of the app fails; redeploying the stack reconnects it.</li>
</ol>
<p>Stack redeploys need the Portainer connection in <b>Settings → Portainer</b>:</p>
<ol class="steps">
<li>In Portainer, open <b>My account → Access tokens</b> and create a token.</li>
<li>Easiest and safest: add it to the Inspector stack as an environment variable (for example
<code>PORTAINER_API_TOKEN</code>) and redeploy Inspector. Or choose <b>Saved in config.yml</b>
and paste it in.</li>
<li>In <b>Settings → Portainer</b>, add an environment with Portainer's URL and endpoint ID
(usually 1 or 2; it's the number in the URL when you open the environment in Portainer).
Click <b>Test connection</b>: it should list your stacks.</li>
<li>Turn Portainer on and save.</li>
</ol>
<figure><img src="settings-portainer.png" alt="Portainer settings">
<figcaption>Settings → Portainer with one environment; the token comes from an environment variable.</figcaption></figure>
</section> </section>
<section id="events"> <section id="events">
@@ -225,6 +265,8 @@ maintenance. Use <b>Prev day</b> / <b>Next day</b> to look back.</p>
<tr><td>Health summary</td><td>A periodic digest (e.g. daily) of what happened, sent through your notification channels.</td></tr> <tr><td>Health summary</td><td>A periodic digest (e.g. daily) of what happened, sent through your notification channels.</td></tr>
<tr><td>Maintenance</td><td>A daily quiet window (e.g. 02:00–05:00) so nightly backups or updates don't trigger restarts. Turn on <b>maintenance_window</b> on each container that should follow it.</td></tr> <tr><td>Maintenance</td><td>A daily quiet window (e.g. 02:00–05:00) so nightly backups or updates don't trigger restarts. Turn on <b>maintenance_window</b> on each container that should follow it.</td></tr>
<tr><td>Notification mute</td><td>Turns the per-container <b>Mute notifications</b> buttons on or off, and sets how long a mute lasts.</td></tr> <tr><td>Notification mute</td><td>Turns the per-container <b>Mute notifications</b> buttons on or off, and sets how long a mute lasts.</td></tr>
<tr><td>Uptime Kuma</td><td>Connection to your Uptime Kuma status page, used by container reachability checks.</td></tr>
<tr><td>Portainer</td><td>Connection to Portainer, used to redeploy stacks. See <a href="#containers">Apps behind a VPN container</a>.</td></tr>
</table> </table>
<p>At the bottom of every settings page is a strip showing where the config file lives and <p>At the bottom of every settings page is a strip showing where the config file lives and
whether Inspector can write to it.</p> whether Inspector can write to it.</p>
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+575 -19
View File
@@ -21,6 +21,8 @@ from .config import (
AppConfig, AppConfig,
ConfigRef, ConfigRef,
ContainerConfig, ContainerConfig,
HttpCheckConfig,
UptimeKumaMonitorConfig,
build_config_from_raw, build_config_from_raw,
write_config_raw, write_config_raw,
) )
@@ -595,12 +597,25 @@ a.signout:hover { color: var(--ink); border-color: var(--muted); }
.settings-pane .field label { font-size: 0.76rem; color: #94a3b8; font-weight: 600; } .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.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=text], .settings-pane .field input[type=number],
.settings-pane .field input[type=password] { .settings-pane .field input[type=password], .settings-pane .field select {
background: var(--bg); border: 1px solid var(--border); border-radius: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
padding: 0.5rem 0.65rem; color: var(--ink); font-size: 0.88rem; font-family: inherit; outline: none; padding: 0.5rem 0.65rem; color: var(--ink); font-size: 0.88rem; 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 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 .field-hint { font-size: 0.72rem; color: var(--muted); }
.settings-pane .field-warn { font-size: 0.72rem; color: #fbbf24; }
.settings-pane .field-warn a { color: inherit; text-decoration: underline; }
.settings-pane .action-help { margin: -0.4rem 0 0.9rem; padding-left: 1rem; font-size: 0.72rem; color: var(--muted); }
.settings-pane .action-help b { color: #cbd5e1; font-weight: 600; }
/* Grouped blocks on the container form (Reachability / Dependencies / Recovery). */
.settings-pane .form-group {
border: 1px solid var(--border); border-radius: 10px; padding: 0.9rem 1rem 0.2rem;
margin: 1.2rem 0;
}
.settings-pane .form-group > h4 { margin: 0 0 0.2rem; font-size: 0.88rem; color: var(--ink); }
.settings-pane .form-group > .group-sub { margin: 0 0 0.9rem; color: var(--muted); font-size: 0.75rem; }
.settings-pane .form-group .form-group { margin: 0.4rem 0 0.9rem; background: rgba(15,24,41,0.5); }
.settings-pane .portainer-only[hidden], .settings-pane .token-src[hidden] { display: none; }
.settings-pane .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.1rem 1rem; } .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; } } @media (max-width: 640px) { .settings-pane .grid-2 { grid-template-columns: 1fr; } }
.settings-pane .form-actions { .settings-pane .form-actions {
@@ -1186,6 +1201,18 @@ class _SettingsError(ValueError):
pass pass
# (value, label, description) for the recovery_action dropdown. "" = default.
_RECOVERY_ACTION_OPTIONS = [
("", "Restart container (default)", "docker restart. Right for most containers."),
("start_container", "Start container", "docker start. Only starts a stopped container; never restarts a running one."),
("portainer_stack_redeploy", "Redeploy Portainer stack",
"Redeploys the whole stack through Portainer. For containers whose network is tied to a VPN container."),
("restart_then_redeploy", "Restart, then redeploy stack if that fails",
"Tries docker restart first; escalates to a stack redeploy if the restart errors or the container is still failing next check."),
]
_PORTAINER_ACTIONS = ("portainer_stack_redeploy", "restart_then_redeploy")
def _form_str(form: dict, key: str, default: str = "") -> str: def _form_str(form: dict, key: str, default: str = "") -> str:
return form.get(key, [default])[0] return form.get(key, [default])[0]
@@ -1209,6 +1236,146 @@ def _apply_secret(form: dict, raw_section: dict, form_key: str, yaml_key: str) -
raw_section[yaml_key] = submitted raw_section[yaml_key] = submitted
def _form_float(form: dict, key: str, default: float = 0.0) -> float:
try:
return float(_form_str(form, key, str(default)))
except (TypeError, ValueError):
return default
def _set_or_drop(section: dict, key: str, value) -> None:
"""Store value, or remove the key when it is blank/False so config.yml
doesn't fill up with empty defaults."""
if value in ("", None, False):
section.pop(key, None)
else:
section[key] = value
def _apply_container_advanced(raw: dict, name: str, cfg: dict, form: dict) -> None:
"""Merge the Reachability / Dependencies / Recovery fields into `cfg`.
Only our own container form carries `advanced_present`; without it the
keys are left untouched so older clients can't wipe YAML-only settings.
"""
if not form.get("advanced_present"):
return
hc_enabled = _form_bool(form, "http_check_enabled")
hc_url = _form_str(form, "http_check_url", "").strip()
if hc_enabled and not hc_url.startswith(("http://", "https://")):
raise _SettingsError("HTTP check needs a URL starting with http:// or https://")
if hc_enabled or hc_url:
hc = cfg.get("http_check") or {}
hc["enabled"] = hc_enabled
hc["url"] = hc_url
timeout = _form_float(form, "http_check_timeout_seconds", 5.0)
hc["timeout_seconds"] = int(timeout) if timeout.is_integer() else timeout
hc["expected_status"] = _form_int(form, "http_check_expected_status", 200)
cfg["http_check"] = hc
else:
cfg.pop("http_check", None)
uk_enabled = _form_bool(form, "uptime_kuma_enabled")
uk_monitor = _form_str(form, "uptime_kuma_monitor_name", "").strip()
if uk_enabled and not uk_monitor:
raise _SettingsError("Uptime Kuma check needs a monitor name")
if uk_enabled or uk_monitor:
uk = cfg.get("uptime_kuma") or {}
uk["enabled"] = uk_enabled
uk["monitor_name"] = uk_monitor
cfg["uptime_kuma"] = uk
else:
cfg.pop("uptime_kuma", None)
depends_on = _form_str(form, "depends_on", "").strip()
if depends_on == name:
raise _SettingsError("a container can't depend on itself")
_set_or_drop(cfg, "depends_on", depends_on)
_set_or_drop(cfg, "vpn_routed", _form_bool(form, "vpn_routed"))
action = _form_str(form, "recovery_action", "").strip()
# "" (the dropdown's default) and an explicit restart_container behave the
# same; keep the explicit value if the YAML already has it.
if action or cfg.get("recovery_action") != "restart_container":
_set_or_drop(cfg, "recovery_action", action)
if action in _PORTAINER_ACTIONS:
env = _form_str(form, "portainer_environment", "").strip()
envs = ((raw.get("integrations") or {}).get("portainer") or {}).get("environments") or {}
if env and env not in envs:
raise _SettingsError(f"unknown Portainer environment {env!r}")
stack = _form_str(form, "portainer_stack_name", "").strip()
if not stack:
raise _SettingsError("Portainer recovery needs a stack name")
_set_or_drop(cfg, "portainer_environment", env)
cfg["portainer_stack_name"] = stack
else:
cfg.pop("portainer_environment", None)
cfg.pop("portainer_stack_name", None)
def _apply_portainer_env(raw: dict, form: dict) -> None:
"""Add, edit, rename or delete one integrations.portainer.environments entry."""
pt = raw.setdefault("integrations", {}).setdefault("portainer", {})
envs = pt.get("environments") or {}
original = _form_str(form, "env_original", "").strip()
containers = raw.get("containers") or {}
def _users(env_name: str) -> list[str]:
return sorted(n for n, c in containers.items() if (c or {}).get("portainer_environment") == env_name)
if original and original not in envs:
raise _SettingsError(f"unknown Portainer environment {original!r}")
if _form_bool(form, "delete"):
users = _users(original)
if users:
raise _SettingsError(
f"environment {original!r} is used by {', '.join(users)}; change those containers first"
)
del envs[original]
if pt.get("default_environment") == original:
pt.pop("default_environment", None)
pt["environments"] = envs
return
env_name = _form_str(form, "env_name", "").strip()
if not _CONTAINER_NAME_RE.match(env_name):
raise _SettingsError("environment name must start with a letter/digit and use only letters, digits, _ or -")
if env_name != original and env_name in envs:
raise _SettingsError(f"environment {env_name!r} already exists")
base_url = _form_str(form, "base_url", "").strip().rstrip("/")
if not base_url.startswith(("http://", "https://")):
raise _SettingsError("Portainer URL must start with http:// or https://")
env = envs.get(original) or {}
env["base_url"] = base_url
env["endpoint_id"] = _form_int(form, "endpoint_id", 1)
env["verify_ssl"] = _form_bool(form, "verify_ssl")
if _form_str(form, "token_source", "env") == "inline":
_apply_secret(form, env, "api_token", "api_token")
if not env.get("api_token"):
raise _SettingsError("enter the Portainer API token, or choose an environment variable")
env.pop("api_token_env", None)
else:
token_env = _form_str(form, "api_token_env", "").strip()
if not token_env:
raise _SettingsError("enter the name of the environment variable holding the API token")
env["api_token_env"] = token_env
env.pop("api_token", None)
if original and env_name != original:
# Rename: rebuild the mapping in order and repoint references.
envs = {(env_name if k == original else k): (env if k == original else v) for k, v in envs.items()}
for cname in _users(original):
containers[cname]["portainer_environment"] = env_name
if pt.get("default_environment") == original:
pt["default_environment"] = env_name
else:
envs[env_name] = env
pt["environments"] = envs
def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> None: def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> None:
"""Validate the form, merge into the raw config, write atomically, swap ref.""" """Validate the form, merge into the raw config, write atomically, swap ref."""
raw = config_ref.get_raw() raw = config_ref.get_raw()
@@ -1271,14 +1438,39 @@ def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> N
cfg["cooldown_seconds"] = _form_int(form, "cooldown_seconds", 300) cfg["cooldown_seconds"] = _form_int(form, "cooldown_seconds", 300)
cfg["grace_period_seconds"] = _form_int(form, "grace_period_seconds", 120) cfg["grace_period_seconds"] = _form_int(form, "grace_period_seconds", 120)
cfg["max_restarts_per_hour"] = _form_int(form, "max_restarts_per_hour", 3) cfg["max_restarts_per_hour"] = _form_int(form, "max_restarts_per_hour", 3)
cfg["color"] = _form_str(form, "color", "") _set_or_drop(cfg, "color", _form_str(form, "color", "").strip())
if form.get("restart_on_present"): # only our own form carries this field if form.get("restart_on_present"): # only our own form carries this field
cfg["maintenance_window"] = _form_bool(form, "maintenance_window") _set_or_drop(cfg, "maintenance_window", _form_bool(form, "maintenance_window"))
restart_on = form.get("restart_on", []) restart_on = [s.upper() for s in form.get("restart_on", [])]
if restart_on or form.get("restart_on_present"): existing = cfg.get("restart_on")
cfg["restart_on"] = [s.upper() for s in restart_on] # Keep the existing list (its order and trailing YAML comments) when
# the selection didn't change.
same = existing is not None and {str(s).upper() for s in existing} == set(restart_on)
if (restart_on or form.get("restart_on_present")) and not same:
cfg["restart_on"] = restart_on
_apply_container_advanced(raw, name, cfg, form)
containers[name] = cfg containers[name] = cfg
raw["containers"] = containers raw["containers"] = containers
elif section == "uptime_kuma":
integ = raw.setdefault("integrations", {})
uk = integ.setdefault("uptime_kuma", {})
uk["enabled"] = _form_bool(form, "enabled")
uk["url"] = _form_str(form, "url", "").strip()
uk["status_page_slug"] = _form_str(form, "status_page_slug", "").strip()
if uk["enabled"] and not (uk["url"] and uk["status_page_slug"]):
raise _SettingsError("Uptime Kuma needs both a URL and a status page slug")
elif section == "portainer":
integ = raw.setdefault("integrations", {})
pt = integ.setdefault("portainer", {})
pt["enabled"] = _form_bool(form, "enabled")
default_env = _form_str(form, "default_environment", "").strip()
if default_env and default_env not in (pt.get("environments") or {}):
raise _SettingsError(f"unknown Portainer environment {default_env!r}")
_set_or_drop(pt, "default_environment", default_env)
pt["stack_redeploy_cooldown_seconds"] = _form_int(form, "stack_redeploy_cooldown_seconds", 300)
pt["post_redeploy_wait_seconds"] = _form_int(form, "post_redeploy_wait_seconds", 90)
elif section == "portainer_env":
_apply_portainer_env(raw, form)
else: else:
raise _SettingsError(f"unknown settings section {section!r}") raise _SettingsError(f"unknown settings section {section!r}")
@@ -1325,6 +1517,7 @@ def _apply_container_create(config_ref: ConfigRef, form: dict) -> str:
restart_on = form.get("restart_on", []) restart_on = form.get("restart_on", [])
if restart_on or form.get("restart_on_present"): if restart_on or form.get("restart_on_present"):
cfg["restart_on"] = [s.upper() for s in restart_on] cfg["restart_on"] = [s.upper() for s in restart_on]
_apply_container_advanced(raw, name, cfg, form)
containers[name] = cfg containers[name] = cfg
raw["containers"] = containers raw["containers"] = containers
@@ -1429,6 +1622,79 @@ def _run_test_notification(channel: str, form: dict, app: AppConfig) -> dict:
return {"ok": False, "error": f"unknown channel {channel!r}"} return {"ok": False, "error": f"unknown channel {channel!r}"}
def _run_test_integration(kind: str, form: dict, app: AppConfig) -> dict:
"""Check an Uptime Kuma or Portainer connection using form values.
Returns {"ok": bool, "message"/"error": str}. Never echoes tokens.
"""
import requests
from .config import UptimeKumaConfig
from .uptime_kuma import UptimeKumaClient
if kind == "uptime_kuma":
url = _form_str(form, "url", "").strip().rstrip("/")
slug = _form_str(form, "status_page_slug", "").strip()
if not url or not slug:
return {"ok": False, "error": "URL and status page slug required"}
client = UptimeKumaClient(UptimeKumaConfig(enabled=True, url=url, status_page_slug=slug))
try:
snapshot = client.fetch_or_raise()
except requests.HTTPError as exc:
return {"ok": False, "error": f"HTTP {exc.response.status_code} — check the URL and slug"}
except (ValueError, AttributeError): # requests' JSONDecodeError is a ValueError too
return {"ok": False, "error": "response was not Uptime Kuma status-page JSON"}
except requests.RequestException as exc:
return {"ok": False, "error": f"request failed: {exc}"}
names = sorted(snapshot.up_by_monitor)
if not names:
return {"ok": True, "message": "Connected, but the status page lists no monitors with heartbeats."}
shown = ", ".join(names[:15]) + (" …" if len(names) > 15 else "")
return {"ok": True, "message": f"Connected — {len(names)} monitor(s): {shown}"}
if kind == "portainer":
base_url = _form_str(form, "base_url", "").strip().rstrip("/")
if not base_url:
return {"ok": False, "error": "URL required"}
if _form_str(form, "token_source", "env") == "inline":
token = _form_str(form, "api_token", "")
if token == _UNCHANGED_SECRET_PLACEHOLDER:
saved = app.integrations.portainer.environments.get(_form_str(form, "env_original", ""))
token = saved.api_token if saved else ""
if not token:
return {"ok": False, "error": "API token required"}
else:
var = _form_str(form, "api_token_env", "").strip()
token = os.environ.get(var, "") if var else ""
if not token:
return {"ok": False, "error": f"environment variable {var!r} is not set in the Inspector container"}
endpoint_id = _form_int(form, "endpoint_id", 1)
try:
resp = requests.get(
f"{base_url}/api/stacks", headers={"X-API-Key": token},
timeout=10.0, verify=_form_bool(form, "verify_ssl"),
)
except requests.RequestException as exc:
return {"ok": False, "error": f"request failed: {exc}"}
if resp.status_code in (401, 403):
return {"ok": False, "error": f"HTTP {resp.status_code} — the API token was rejected"}
if resp.status_code >= 400:
return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:150]}"}
try:
stacks = resp.json() or []
except ValueError:
return {"ok": False, "error": "response was not Portainer API JSON — check the URL"}
if not isinstance(stacks, list):
return {"ok": False, "error": "response was not Portainer API JSON — check the URL"}
names = sorted(st.get("Name", "?") for st in stacks if st.get("EndpointId") == endpoint_id)
if not names:
return {"ok": True, "message": f"Connected, but no stacks found on endpoint {endpoint_id}. Check the endpoint ID."}
shown = ", ".join(names[:15]) + (" …" if len(names) > 15 else "")
return {"ok": True, "message": f"Connected — {len(names)} stack(s) on endpoint {endpoint_id}: {shown}"}
return {"ok": False, "error": f"unknown integration {kind!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>"
@@ -1489,6 +1755,26 @@ def _settings_secret_field(label: str, name: str, has_value: bool) -> str:
) )
def _settings_select(
label: str, name: str, value: str, options: list[tuple[str, str]],
*, hint: str = "", onchange: str = "",
) -> str:
opts = "".join(
f'<option value="{_escape(v)}"{" selected" if v == value else ""}>{_escape(text)}</option>'
for v, text in options
)
on = f' onchange="{onchange}"' if onchange else ""
hint_html = f'<div class="field-hint">{_escape(hint)}</div>' if hint else ""
return (
f'<div class="field"><label for="s_{_escape(name)}">{_escape(label)}</label>'
f'<select id="s_{_escape(name)}" name="{_escape(name)}"{on}>{opts}</select>{hint_html}</div>'
)
def _warn(text: str) -> str:
return f'<div class="field-warn">⚠ {text}</div>'
# Each tuple: (slug, label, icon, subtitle_fn, group). # Each tuple: (slug, label, icon, subtitle_fn, group).
# Groups show up as uppercase headers in the left rail. # Groups show up as uppercase headers in the left rail.
def _subtitle_inspector(app: AppConfig) -> str: def _subtitle_inspector(app: AppConfig) -> str:
@@ -1527,6 +1813,20 @@ def _subtitle_notification_mute(app: AppConfig) -> str:
return f"{'on' if nm.enabled else 'off'} · {nm.default_hours}h default" return f"{'on' if nm.enabled else 'off'} · {nm.default_hours}h default"
def _subtitle_uptime_kuma(app: AppConfig) -> str:
uk = app.integrations.uptime_kuma
if not uk.enabled:
return "off"
n = sum(1 for c in app.containers if c.uptime_kuma.enabled)
return f"on · {n} container(s)"
def _subtitle_portainer(app: AppConfig) -> str:
pt = app.integrations.portainer
n = len(pt.environments)
return f"{'on' if pt.enabled else 'off'} · {n} environment{'s' if n != 1 else ''}"
_SETTINGS_SECTIONS = [ _SETTINGS_SECTIONS = [
("inspector", "Inspector loop", "⚙", _subtitle_inspector, "Core"), ("inspector", "Inspector loop", "⚙", _subtitle_inspector, "Core"),
("notifications", "Notifications", "🔔", _subtitle_notifications, "Core"), ("notifications", "Notifications", "🔔", _subtitle_notifications, "Core"),
@@ -1535,6 +1835,8 @@ _SETTINGS_SECTIONS = [
("health_summary", "Health summary", "📋", _subtitle_health_summary, "Data"), ("health_summary", "Health summary", "📋", _subtitle_health_summary, "Data"),
("maintenance", "Maintenance", "🔧", _subtitle_maintenance, "Scheduling"), ("maintenance", "Maintenance", "🔧", _subtitle_maintenance, "Scheduling"),
("notification_mute", "Notification mute","🔇", _subtitle_notification_mute, "Scheduling"), ("notification_mute", "Notification mute","🔇", _subtitle_notification_mute, "Scheduling"),
("uptime_kuma", "Uptime Kuma", "📡", _subtitle_uptime_kuma, "Integrations"),
("portainer", "Portainer", "🐳", _subtitle_portainer, "Integrations"),
] ]
_SETTINGS_SECTION_SLUGS = {slug for slug, *_ in _SETTINGS_SECTIONS} _SETTINGS_SECTION_SLUGS = {slug for slug, *_ in _SETTINGS_SECTIONS}
@@ -1621,18 +1923,18 @@ def _settings_form_with_test(section: str, title: str, body: str, test_row: str,
_TEST_NOTIFY_JS = """ _TEST_NOTIFY_JS = """
<script> <script>
window.InspectorTest = function(btn, channel) { window.InspectorTest = function(btn, channel, endpoint) {
var form = btn.closest('form'); var form = btn.closest('form');
var resultEl = btn.parentElement.querySelector('.test-result'); var resultEl = btn.parentElement.querySelector('.test-result');
var data = new URLSearchParams(); var data = new URLSearchParams();
form.querySelectorAll('input[name]').forEach(function(el) { form.querySelectorAll('input[name], select[name]').forEach(function(el) {
if (el.type === 'checkbox') { if (el.checked) data.append(el.name, 'on'); } if (el.type === 'checkbox') { if (el.checked) data.append(el.name, 'on'); }
else { data.append(el.name, el.value); } else { data.append(el.name, el.value); }
}); });
btn.disabled = true; btn.disabled = true;
resultEl.className = 'test-result'; resultEl.className = 'test-result';
resultEl.textContent = 'Sending test...'; resultEl.textContent = endpoint ? 'Testing...' : 'Sending test...';
fetch('/api/test-notify/' + channel, { fetch((endpoint || '/api/test-notify/') + channel, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'}, headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: data.toString(), body: data.toString(),
@@ -1641,7 +1943,7 @@ window.InspectorTest = function(btn, channel) {
btn.disabled = false; btn.disabled = false;
if (j.ok) { if (j.ok) {
resultEl.className = 'test-result ok'; resultEl.className = 'test-result ok';
resultEl.textContent = '✓ Sent. Check your device.'; resultEl.textContent = '✓ ' + (j.message || 'Sent. Check your device.');
} else { } else {
resultEl.className = 'test-result err'; resultEl.className = 'test-result err';
resultEl.textContent = '✗ ' + (j.error || 'Failed'); resultEl.textContent = '✗ ' + (j.error || 'Failed');
@@ -1826,9 +2128,137 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
+ _settings_form("notification_mute", "Notification mute", body, csrf_token) + _settings_form("notification_mute", "Notification mute", body, csrf_token)
) )
if section == "uptime_kuma":
uk = app.integrations.uptime_kuma
body = (
_settings_field("enabled", "enabled", "on" if uk.enabled else "", kind="switch")
+ _settings_field("url", "url", uk.url, hint="Base URL of your Uptime Kuma, e.g. https://status.example.com")
+ _settings_field("status_page_slug", "status_page_slug", uk.status_page_slug,
hint="The slug from the status page URL (/status/<slug>). The page must list the monitors you want to use.")
+ _test_row("uptime_kuma", "Fetches the status page using the values above and lists its monitors.")
)
return (
'<h3 class="pane-title">Uptime Kuma</h3>'
'<p class="pane-sub">Use an Uptime Kuma monitor as a second opinion: when the monitor is down, '
"Inspector reports the container as <b>UNREACHABLE</b> even if Docker says it is healthy. "
"Turn it on per container under <b>Reachability</b>.</p>"
+ _settings_form("uptime_kuma", "Uptime Kuma", body, csrf_token)
)
if section == "portainer":
return _portainer_section_body(app, csrf_token)
return f"<p>Unknown section: {_escape(section)}</p>" return f"<p>Unknown section: {_escape(section)}</p>"
def _test_row(kind: str, note: str) -> str:
return (
'<div class="test-row">'
f'<button type="button" class="btn-ghost" onclick="InspectorTest(this, \'{kind}\', \'/api/test-integration/\')">'
"Test connection</button>"
f'<span class="test-result">{_escape(note)}</span></div>'
)
_TOKEN_SOURCE_JS = (
"<script>window.InspectorTokenSrc=function(sel){"
"sel.closest('form').querySelectorAll('.token-src').forEach(function(el){"
"el.hidden=el.dataset.src!==sel.value;});};</script>"
)
def _portainer_env_form(env_name: str, env, csrf_token: str, *, suggest_name: str = "") -> str:
"""Add/edit form for one Portainer environment. `env` is None for the add form."""
inline = bool(env and env.api_token and not env.api_token_env)
source = "inline" if inline else "env"
body = (
f'<input type="hidden" name="env_original" value="{_escape(env_name)}">'
'<div class="grid-2">'
+ _settings_field("name", "env_name", env_name or suggest_name,
hint="Your own label, referenced by containers")
+ _settings_field("endpoint_id", "endpoint_id", str(env.endpoint_id if env else 1), kind="number",
hint="Portainer → Environments; the number in the URL (usually 1 or 2)")
+ "</div>"
+ _settings_field("base_url", "base_url", env.base_url if env else "",
hint="e.g. http://192.168.1.10:9000 or https://portainer.example.com")
+ _settings_select(
"API token stored as", "token_source", source,
[("env", "Environment variable (recommended)"), ("inline", "Saved in config.yml")],
hint="Create a token in Portainer → My account → Access tokens.",
onchange="InspectorTokenSrc(this)",
)
+ f'<div class="token-src" data-src="env"{" hidden" if inline else ""}>'
+ _settings_field("environment variable name", "api_token_env",
(env.api_token_env if env else "") or "PORTAINER_API_TOKEN",
hint="Set this variable on the Inspector container (Portainer → stack → Environment variables), then redeploy Inspector.")
+ "</div>"
+ f'<div class="token-src" data-src="inline"{"" if inline else " hidden"}>'
+ _settings_secret_field("api_token", "api_token", bool(env and env.api_token))
+ "</div>"
+ _settings_field("verify_ssl", "verify_ssl", "on" if (env.verify_ssl if env else False) else "", kind="switch",
hint="Turn off for Portainer's default self-signed certificate or plain http")
+ _test_row("portainer", "Lists this environment's stacks using the values above (even if unsaved).")
)
delete = ""
if env is not None:
delete = (
'<button type="submit" name="delete" value="1" class="btn-danger" '
f"onclick=\"return confirm('Delete Portainer environment {_escape(env_name)}?');\">Delete</button>"
)
label = "environment" if env is not None else "new environment"
return (
'<form method="post" action="/settings/portainer_env" class="form-group">'
f'<h4>{_escape(env_name) if env is not None else "Add environment"}</h4>'
f'<input type="hidden" name="csrf" value="{_escape(csrf_token)}">'
f"{body}"
f'<div class="form-actions">{delete}'
'<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"Save {label}</button></div></form>"
)
def _portainer_section_body(app: AppConfig, csrf_token: str) -> str:
pt = app.integrations.portainer
env_options = [("", "Automatic (only if one environment exists)")] + [(n, n) for n in pt.environments]
body = (
_settings_field("enabled", "enabled", "on" if pt.enabled else "", kind="switch")
+ _settings_select("default_environment", "default_environment", pt.default_environment, env_options,
hint="Used by containers that don't pick an environment")
+ '<div class="grid-2">'
+ _settings_field("stack_redeploy_cooldown_seconds", "stack_redeploy_cooldown_seconds",
str(pt.stack_redeploy_cooldown_seconds), kind="number",
hint="Minimum seconds between redeploys of the same stack")
+ _settings_field("post_redeploy_wait_seconds", "post_redeploy_wait_seconds",
str(pt.post_redeploy_wait_seconds), kind="number",
hint="Wait this long after a redeploy before checking again")
+ "</div>"
)
users = [c.name for c in app.containers if c.recovery_action in _PORTAINER_ACTIONS]
warn = ""
if pt.enabled and not pt.environments:
warn = _warn("Enabled, but no environment is configured yet. Add one below.")
elif not pt.enabled and users:
warn = _warn(f"Off, but {_escape(', '.join(users))} use stack redeploy. Their redeploys will fail until this is enabled.")
envs = "".join(_portainer_env_form(n, e, csrf_token) for n, e in pt.environments.items())
used_by = (
f'<p class="subsection-sub">Containers using stack redeploy: <b>{_escape(", ".join(users))}</b></p>'
if users else '<p class="subsection-sub">No container uses stack redeploy yet. Set it per container under <b>Recovery</b>.</p>'
)
add_form = _portainer_env_form("", None, csrf_token, suggest_name="" if pt.environments else "local")
return (
'<h3 class="pane-title">Portainer</h3>'
'<p class="pane-sub">Lets Inspector redeploy a whole Portainer stack. Needed for containers that share '
"another container's network (for example apps routed through a VPN container): when the VPN container is "
"recreated, a plain restart fails and only a redeploy reconnects them.</p>"
+ '<div class="subsection">' + warn
+ _settings_form("portainer", "Portainer", body, csrf_token) + "</div>"
+ '<div class="subsection"><h4>Environments</h4>' + used_by
+ envs + add_form + "</div>"
+ _TOKEN_SOURCE_JS
)
def _render_flash(flash: str) -> str: def _render_flash(flash: str) -> str:
if not flash: if not flash:
return "" return ""
@@ -1855,12 +2285,27 @@ def _render_settings_page(app: AppConfig, config_ref: ConfigRef, csrf_token: str
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)) parts.append(_render_diag_strip(config_ref))
if section == "notifications": if section in ("notifications", "uptime_kuma", "portainer"):
parts.append(_TEST_NOTIFY_JS) parts.append(_TEST_NOTIFY_JS)
parts.append(_PAGE_TAIL) parts.append(_PAGE_TAIL)
return "".join(parts).encode("utf-8") return "".join(parts).encode("utf-8")
def _container_extras(c: ContainerConfig) -> str:
"""One-line summary of the non-default checks / recovery for the grid tile."""
bits = []
if c.http_check.enabled:
bits.append("HTTP check")
if c.uptime_kuma.enabled:
bits.append("Uptime Kuma")
if c.depends_on:
bits.append(f"after {c.depends_on}" + (" (VPN)" if c.vpn_routed else ""))
label = next((lbl for v, lbl, _ in _RECOVERY_ACTION_OPTIONS if v == c.recovery_action and v), "")
if label:
bits.append(label.lower())
return " · ".join(bits)
def _render_containers_page(app: AppConfig, config_ref: ConfigRef, states: dict, flash: str) -> bytes: def _render_containers_page(app: AppConfig, config_ref: ConfigRef, states: dict, flash: str) -> bytes:
parts = [_page_head(refresh=False, active="containers")] parts = [_page_head(refresh=False, active="containers")]
parts.append(_render_flash(flash)) parts.append(_render_flash(flash))
@@ -1883,6 +2328,8 @@ def _render_containers_page(app: AppConfig, config_ref: ConfigRef, states: dict,
state = states.get(c.name) state = states.get(c.name)
status = state.current_status if state and state.current_status else "—" 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" restart_on = ", ".join(sorted(c.restart_on)) if c.restart_on else "observe only"
extras = _container_extras(c)
extras_html = f'<span style="flex-basis:100%;">Extras: <b>{_escape(extras)}</b></span>' if extras else ""
parts.append( parts.append(
f'<a class="container-tile" href="/containers/{_escape(c.name)}" ' f'<a class="container-tile" href="/containers/{_escape(c.name)}" '
f'style="--col:{_escape(color)}">' f'style="--col:{_escape(color)}">'
@@ -1894,7 +2341,8 @@ def _render_containers_page(app: AppConfig, config_ref: ConfigRef, states: dict,
f'<span>Max errors: <b>{c.max_error_count}</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>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>' f'<span style="flex-basis:100%; margin-top:0.2rem;">Restart on: <b>{_escape(restart_on)}</b></span>'
"</div></a>\n" + extras_html
+ "</div></a>\n"
) )
parts.append("</div>\n") parts.append("</div>\n")
parts.append("</section></div>\n") parts.append("</section></div>\n")
@@ -1957,9 +2405,106 @@ _RESTART_ON_JS = (
) )
_RECOVERY_JS = (
"<script>window.InspectorRecovery=function(sel){"
"var on=" + json.dumps(list(_PORTAINER_ACTIONS)) + ".indexOf(sel.value)>=0;"
"sel.closest('form').querySelectorAll('.portainer-only').forEach(function(el){el.hidden=!on;});};</script>"
)
def _render_container_advanced(name: str, cfg, app: AppConfig) -> str:
"""Reachability / Dependencies / Recovery groups for the container form."""
hc = getattr(cfg, "http_check", None) or HttpCheckConfig()
ukm = getattr(cfg, "uptime_kuma", None) or UptimeKumaMonitorConfig()
uk_global = app.integrations.uptime_kuma
pt = app.integrations.portainer
action = getattr(cfg, "recovery_action", "") or ""
if action == "restart_container": # same as the default option
action = ""
uses_portainer = action in _PORTAINER_ACTIONS
timeout = hc.timeout_seconds
timeout_str = str(int(timeout)) if float(timeout).is_integer() else str(timeout)
uk_warn = ""
if not (uk_global.enabled and uk_global.url and uk_global.status_page_slug):
uk_warn = _warn('The Uptime Kuma integration is off. Set it up in '
'<a href="/settings/uptime_kuma">Settings → Uptime Kuma</a> or this check is ignored.')
reach = (
'<div class="form-group"><h4>Reachability</h4>'
'<p class="group-sub">Docker can say a container is healthy when nobody can reach it, for example when '
"its VPN tunnel dropped. A failed check reports <b>UNREACHABLE</b>; tick UNREACHABLE above to restart on it.</p>"
'<div class="form-group"><h4>HTTP check</h4>'
+ _settings_field("enabled", "http_check_enabled", "on" if hc.enabled else "", kind="switch")
+ _settings_field("url", "http_check_url", hc.url,
hint="Address Inspector requests, e.g. http://192.168.1.10:9696/ping. Use an IP or a name Inspector can resolve.")
+ '<div class="grid-2">'
+ _settings_field("timeout_seconds", "http_check_timeout_seconds", timeout_str, kind="number")
+ _settings_field("expected_status", "http_check_expected_status", str(hc.expected_status), kind="number",
hint="Exact HTTP code a healthy service returns (often 200; some apps return 401 or 302)")
+ "</div></div>"
'<div class="form-group"><h4>Uptime Kuma monitor</h4>'
+ _settings_field("enabled", "uptime_kuma_enabled", "on" if ukm.enabled else "", kind="switch")
+ _settings_field("monitor_name", "uptime_kuma_monitor_name", ukm.monitor_name,
hint="Exact monitor name as shown on your status page. If both checks are on, the HTTP check wins.")
+ uk_warn
+ "</div></div>"
)
others = [c.name for c in app.containers if c.name != name]
dep_value = getattr(cfg, "depends_on", "") or ""
dep_options = [("", "— none —")] + [(n, n) for n in others]
if dep_value and dep_value not in others:
dep_options.append((dep_value, f"{dep_value} (not configured)"))
deps = (
'<div class="form-group"><h4>Dependencies</h4>'
'<p class="group-sub">Check this container after the one it relies on, and hold off recovering it '
"while that one is still failing.</p>"
+ _settings_select("depends_on", "depends_on", dep_value, dep_options,
hint="e.g. the VPN container, for apps that use network_mode: container:<vpn>")
+ _settings_field("vpn_routed", "vpn_routed", "on" if getattr(cfg, "vpn_routed", False) else "", kind="switch",
hint="Routes through the container above. Logs why recovery is deferred while it is down.")
+ "</div>"
)
action_help = '<ul class="action-help">' + "".join(
f"<li><b>{_escape(label)}</b> — {_escape(desc)}</li>" for _, label, desc in _RECOVERY_ACTION_OPTIONS
) + "</ul>"
env_value = getattr(cfg, "portainer_environment", "") or ""
default_label = f"Default ({pt.default_environment})" if pt.default_environment else "Default"
env_options = [("", default_label)] + [(n, n) for n in pt.environments]
if env_value and env_value not in pt.environments:
env_options.append((env_value, f"{env_value} (not configured)"))
pt_warn = ""
if not pt.environments:
pt_warn = _warn('Portainer isn\'t set up yet. Add an environment in '
'<a href="/settings/portainer">Settings → Portainer</a> before relying on a redeploy.')
elif not pt.enabled:
pt_warn = _warn('The Portainer integration is off. Turn it on in '
'<a href="/settings/portainer">Settings → Portainer</a> or redeploys will fail.')
hidden = "" if uses_portainer else " hidden"
recovery = (
'<div class="form-group"><h4>Recovery</h4>'
'<p class="group-sub">What Inspector does once the restart conditions above are met.</p>'
+ _settings_select("recovery_action", "recovery_action", action,
[(v, label) for v, label, _ in _RECOVERY_ACTION_OPTIONS],
onchange="InspectorRecovery(this)")
+ action_help
+ f'<div class="portainer-only"{hidden}>'
+ '<div class="grid-2">'
+ _settings_select("portainer_environment", "portainer_environment", env_value, env_options)
+ _settings_field("portainer_stack_name", "portainer_stack_name",
getattr(cfg, "portainer_stack_name", "") or "",
hint="Stack name exactly as shown in Portainer → Stacks")
+ "</div>" + pt_warn + "</div>"
+ "</div>"
+ _RECOVERY_JS
)
return '<input type="hidden" name="advanced_present" value="1">' + reach + deps + recovery
def _render_container_form( def _render_container_form(
name: str, cfg, csrf_token: str, action: str, submit_label: str, name: str, cfg, csrf_token: str, action: str, submit_label: str,
*, show_name_field: bool = False, show_delete: bool = False, *, app: AppConfig, show_name_field: bool = False, show_delete: bool = False,
) -> str: ) -> str:
"""Build the container edit form. `cfg` may be a ContainerConfig or a dummy with defaults.""" """Build the container edit form. `cfg` may be a ContainerConfig or a dummy with defaults."""
current_restart_on = getattr(cfg, "restart_on", []) or [] current_restart_on = getattr(cfg, "restart_on", []) or []
@@ -2021,6 +2566,7 @@ def _render_container_form(
requires_restart=True) requires_restart=True)
+ _render_color_input("color", getattr(cfg, "color", "")) + _render_color_input("color", getattr(cfg, "color", ""))
+ restart_on_html + restart_on_html
+ _render_container_advanced(name, cfg, app)
) )
delete_btn = "" delete_btn = ""
if show_delete: if show_delete:
@@ -2056,6 +2602,7 @@ def _render_container_edit_page(app: AppConfig, config_ref: ConfigRef, name: str
name, cfg, csrf_token, name, cfg, csrf_token,
action=f"/containers/{_escape(name)}", action=f"/containers/{_escape(name)}",
submit_label="Save container", submit_label="Save container",
app=app,
show_delete=True, show_delete=True,
) )
parts = [_page_head(refresh=False, active="containers")] parts = [_page_head(refresh=False, active="containers")]
@@ -2065,7 +2612,7 @@ def _render_container_edit_page(app: AppConfig, config_ref: ConfigRef, name: str
parts.append('<section class="settings-pane">') parts.append('<section class="settings-pane">')
parts.append( parts.append(
f'<h3 class="pane-title">Container — <code style="font-size:0.95em;">{_escape(name)}</code></h3>' f'<h3 class="pane-title">Container — <code style="font-size:0.95em;">{_escape(name)}</code></h3>'
'<p class="pane-sub">Restart rules Inspector applies when this container\'s health changes.</p>' '<p class="pane-sub">How Inspector checks this container and what it does when it fails.</p>'
) )
parts.append(form_html) parts.append(form_html)
parts.append("</section></div>\n") parts.append("</section></div>\n")
@@ -2091,6 +2638,7 @@ def _render_container_new_page(app: AppConfig, config_ref: ConfigRef, csrf_token
"", _NewContainerDefaults(), csrf_token, "", _NewContainerDefaults(), csrf_token,
action="/containers/new", action="/containers/new",
submit_label="Create container", submit_label="Create container",
app=app,
show_name_field=True, show_name_field=True,
) )
parts = [_page_head(refresh=False, active="containers")] parts = [_page_head(refresh=False, active="containers")]
@@ -2101,9 +2649,7 @@ def _render_container_new_page(app: AppConfig, config_ref: ConfigRef, csrf_token
parts.append( parts.append(
'<h3 class="pane-title">New container</h3>' '<h3 class="pane-title">New container</h3>'
'<p class="pane-sub">Add a container to <code>config.yml</code>. ' '<p class="pane-sub">Add a container to <code>config.yml</code>. '
"The new entry is picked up automatically on the next polling cycle; " "The new entry is picked up automatically on the next polling cycle.</p>"
"advanced fields (Portainer stack, http_check, uptime_kuma, depends_on, "
"vpn_routed) remain YAML-only for now.</p>"
) )
parts.append(form_html) parts.append(form_html)
parts.append("</section></div>\n") parts.append("</section></div>\n")
@@ -2443,6 +2989,8 @@ class _Handler(BaseHTTPRequestHandler):
# else matches the section slug. # else matches the section slug.
if section in ("gotify", "pushover"): if section in ("gotify", "pushover"):
return_path = "/settings/notifications" return_path = "/settings/notifications"
elif section == "portainer_env":
return_path = "/settings/portainer"
else: else:
return_path = f"/settings/{section}" return_path = f"/settings/{section}"
try: try:
@@ -2454,8 +3002,9 @@ class _Handler(BaseHTTPRequestHandler):
self.send_header("Content-Length", "0") self.send_header("Content-Length", "0")
self.end_headers() self.end_headers()
return return
ok_label = "Portainer environment" if section == "portainer_env" else section
self.send_response(HTTPStatus.SEE_OTHER) self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", f"{return_path}?flash={_url_quote('ok:' + section)}") self.send_header("Location", f"{return_path}?flash={_url_quote('ok:' + ok_label)}")
self.send_header("Content-Length", "0") self.send_header("Content-Length", "0")
self.end_headers() self.end_headers()
return return
@@ -2526,6 +3075,13 @@ class _Handler(BaseHTTPRequestHandler):
self._send_bytes(HTTPStatus.OK, "application/json", body) self._send_bytes(HTTPStatus.OK, "application/json", body)
return return
# POST /api/test-integration/<kind> — check an Uptime Kuma / Portainer connection
if len(parts) == 3 and parts[0] == "api" and parts[1] == "test-integration":
payload = _run_test_integration(parts[2], 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")
+9
View File
@@ -417,6 +417,7 @@ def run_loop(config_ref: ConfigRef) -> int:
check_order = _topological_order(enabled_configs) check_order = _topological_order(enabled_configs)
portainer_client = PortainerClient(app.integrations.portainer) portainer_client = PortainerClient(app.integrations.portainer)
integrations_cfg = app.integrations
stack_cooldowns: dict[str, datetime] = {} stack_cooldowns: dict[str, datetime] = {}
dashboard = DashboardServer( dashboard = DashboardServer(
@@ -456,6 +457,14 @@ def run_loop(config_ref: ConfigRef) -> int:
enabled_configs = current_enabled enabled_configs = current_enabled
check_order = _topological_order(enabled_configs) check_order = _topological_order(enabled_configs)
# Integration clients hold their config; rebuild them when the
# settings page changes Uptime Kuma / Portainer.
if app.integrations != integrations_cfg:
integrations_cfg = app.integrations
uptime_kuma_client = UptimeKumaClient(integrations_cfg.uptime_kuma)
portainer_client = PortainerClient(integrations_cfg.portainer)
log.info("Integration settings changed; reloaded Uptime Kuma / Portainer clients")
uptime_kuma_snapshot = uptime_kuma_client.fetch() uptime_kuma_snapshot = uptime_kuma_client.fetch()
for name in check_order: for name in check_order:
cfg = enabled_configs[name] cfg = enabled_configs[name]
+12 -6
View File
@@ -41,20 +41,26 @@ class UptimeKumaClient:
and bool(self._config.status_page_slug) and bool(self._config.status_page_slug)
) )
def _heartbeat_url(self) -> str:
return f"{self._config.url}/api/status-page/heartbeat/{self._config.status_page_slug}"
def fetch(self) -> HeartbeatSnapshot: def fetch(self) -> HeartbeatSnapshot:
if not self.enabled: if not self.enabled:
return _EMPTY return _EMPTY
url = f"{self._config.url}/api/status-page/heartbeat/{self._config.status_page_slug}"
try: try:
resp = requests.get(url, timeout=self._timeout) return self.fetch_or_raise()
resp.raise_for_status()
data = resp.json()
return self._parse(data)
except Exception as exc: except Exception as exc:
log.warning("Uptime Kuma heartbeat fetch failed (%s): %s", url, exc) log.warning("Uptime Kuma heartbeat fetch failed (%s): %s", self._heartbeat_url(), exc)
return _EMPTY return _EMPTY
def fetch_or_raise(self) -> HeartbeatSnapshot:
"""Like fetch(), but lets request/parse errors propagate (used by the
settings page's "Test connection" button)."""
resp = requests.get(self._heartbeat_url(), timeout=self._timeout)
resp.raise_for_status()
return self._parse(resp.json())
def _parse(self, data: dict) -> HeartbeatSnapshot: def _parse(self, data: dict) -> HeartbeatSnapshot:
# Expected shape: # Expected shape:
# {"publicGroupList": [{"monitorList": [{"id": 1, "name": "..."}]}], # {"publicGroupList": [{"monitorList": [{"id": 1, "name": "..."}]}],