diff --git a/CHANGELOG.md b/CHANGELOG.md index cd990a9..1ad3566 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### Added diff --git a/README.md b/README.md index 04a17d9..b30faf4 100755 --- a/README.md +++ b/README.md @@ -69,18 +69,20 @@ download it and open it in a browser to read it here). ## Advanced options -Most people never need these. They live in `config.yml` (in your -`INSPECTOR_DATA/config` folder) rather than the dashboard; see -[`config.example.yml`](./config.example.yml) for every option with comments. +Most people never need these. They are all on each container's page in the +dashboard (and in `config.yml`, see +[`config.example.yml`](./config.example.yml) for every option with comments). - **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). - **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 relies on (e.g. a VPN) is down. - **Portainer stack redeploy**: for containers that share a VPN container's 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 diff --git a/src/inspector/__init__.py b/src/inspector/__init__.py index 777f190..3e2f46a 100755 --- a/src/inspector/__init__.py +++ b/src/inspector/__init__.py @@ -1 +1 @@ -__version__ = "0.8.0" +__version__ = "0.9.0" diff --git a/src/inspector/assets/guide/container-advanced.png b/src/inspector/assets/guide/container-advanced.png new file mode 100755 index 0000000..6ebb681 Binary files /dev/null and b/src/inspector/assets/guide/container-advanced.png differ diff --git a/src/inspector/assets/guide/guide.html b/src/inspector/assets/guide/guide.html index 89db1bb..e46ee8e 100755 --- a/src/inspector/assets/guide/guide.html +++ b/src/inspector/assets/guide/guide.html @@ -197,11 +197,51 @@ does not touch the container itself.

StatusWhen it happens UNHEALTHYThe container's own health check is failing. NOT RUNNINGStopped, exited, or crashed. - UNREACHABLERunning, but an HTTP check failed (advanced, set in config.yml). + UNREACHABLERunning, but its HTTP check or Uptime Kuma monitor says it's down (see below). ERRORInspector couldn't check it (Docker API error). STARTINGStill in its health-check start period. Usually leave this off. HEALTHY / NO HEALTH CHECKNormal states; almost never a reason to restart. + +

Reachability: catching apps that look fine but aren't

+

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 Reachability on the container page you can add:

+ +

A failed check reports UNREACHABLE. Tick UNREACHABLE in the restart triggers if you want +Inspector to act on it.

+
Reachability, Dependencies and Recovery groups +
Bottom of a container's page: an app behind a VPN container with an HTTP check and stack-redeploy recovery.
+ +

Apps behind a VPN container

+

If an app shares a VPN container's network (network_mode: container:vpn or similar), set these on the app's page:

+
    +
  1. Dependencies → depends_on: pick the VPN container, and turn on vpn_routed. + Inspector checks the VPN first and waits for it to recover before touching the app.
  2. +
  3. Recovery → recovery_action: choose Restart, then redeploy stack if that fails + and enter the stack name exactly as Portainer shows it. When the VPN container gets + recreated, a plain restart of the app fails; redeploying the stack reconnects it.
  4. +
+

Stack redeploys need the Portainer connection in Settings → Portainer:

+
    +
  1. In Portainer, open My account → Access tokens and create a token.
  2. +
  3. Easiest and safest: add it to the Inspector stack as an environment variable (for example + PORTAINER_API_TOKEN) and redeploy Inspector. Or choose Saved in config.yml + and paste it in.
  4. +
  5. In Settings → Portainer, 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 Test connection: it should list your stacks.
  6. +
  7. Turn Portainer on and save.
  8. +
+
Portainer settings +
Settings → Portainer with one environment; the token comes from an environment variable.
@@ -225,6 +265,8 @@ maintenance. Use Prev day / Next day to look back.

Health summaryA periodic digest (e.g. daily) of what happened, sent through your notification channels. MaintenanceA daily quiet window (e.g. 02:00–05:00) so nightly backups or updates don't trigger restarts. Turn on maintenance_window on each container that should follow it. Notification muteTurns the per-container Mute notifications buttons on or off, and sets how long a mute lasts. + Uptime KumaConnection to your Uptime Kuma status page, used by container reachability checks. + PortainerConnection to Portainer, used to redeploy stacks. See Apps behind a VPN container.

At the bottom of every settings page is a strip showing where the config file lives and whether Inspector can write to it.

diff --git a/src/inspector/assets/guide/settings-portainer.png b/src/inspector/assets/guide/settings-portainer.png new file mode 100755 index 0000000..edfc67e Binary files /dev/null and b/src/inspector/assets/guide/settings-portainer.png differ diff --git a/src/inspector/dashboard.py b/src/inspector/dashboard.py index 6a8f665..acc0bde 100755 --- a/src/inspector/dashboard.py +++ b/src/inspector/dashboard.py @@ -21,6 +21,8 @@ from .config import ( AppConfig, ConfigRef, ContainerConfig, + HttpCheckConfig, + UptimeKumaMonitorConfig, build_config_from_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.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] { +.settings-pane .field input[type=password], .settings-pane .field select { 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; } .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-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; } @media (max-width: 640px) { .settings-pane .grid-2 { grid-template-columns: 1fr; } } .settings-pane .form-actions { @@ -1186,6 +1201,18 @@ class _SettingsError(ValueError): 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: 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 +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: """Validate the form, merge into the raw config, write atomically, swap ref.""" 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["grace_period_seconds"] = _form_int(form, "grace_period_seconds", 120) 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 - cfg["maintenance_window"] = _form_bool(form, "maintenance_window") - restart_on = form.get("restart_on", []) - if restart_on or form.get("restart_on_present"): - cfg["restart_on"] = [s.upper() for s in restart_on] + _set_or_drop(cfg, "maintenance_window", _form_bool(form, "maintenance_window")) + restart_on = [s.upper() for s in form.get("restart_on", [])] + existing = cfg.get("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 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: 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", []) if restart_on or form.get("restart_on_present"): cfg["restart_on"] = [s.upper() for s in restart_on] + _apply_container_advanced(raw, name, cfg, form) containers[name] = cfg 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}"} +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 = ( '' "requires restart" @@ -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'' + for v, text in options + ) + on = f' onchange="{onchange}"' if onchange else "" + hint_html = f'
{_escape(hint)}
' if hint else "" + return ( + f'
' + f'{hint_html}
' + ) + + +def _warn(text: str) -> str: + return f'
⚠ {text}
' + + # Each tuple: (slug, label, icon, subtitle_fn, group). # Groups show up as uppercase headers in the left rail. 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" +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 = [ ("inspector", "Inspector loop", "⚙", _subtitle_inspector, "Core"), ("notifications", "Notifications", "🔔", _subtitle_notifications, "Core"), @@ -1535,6 +1835,8 @@ _SETTINGS_SECTIONS = [ ("health_summary", "Health summary", "📋", _subtitle_health_summary, "Data"), ("maintenance", "Maintenance", "🔧", _subtitle_maintenance, "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} @@ -1621,18 +1923,18 @@ def _settings_form_with_test(section: str, title: str, body: str, test_row: str, _TEST_NOTIFY_JS = """ " +) + + +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'' + '
' + + _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)") + + "
" + + _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'
' + + _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.") + + "
" + + f'
' + + _settings_secret_field("api_token", "api_token", bool(env and env.api_token)) + + "
" + + _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 = ( + '" + ) + label = "environment" if env is not None else "new environment" + return ( + '
' + f'

{_escape(env_name) if env is not None else "Add environment"}

' + f'' + f"{body}" + f'
{delete}' + '
" + ) + + +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") + + '
' + + _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") + + "
" + ) + 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'

Containers using stack redeploy: {_escape(", ".join(users))}

' + if users else '

No container uses stack redeploy yet. Set it per container under Recovery.

' + ) + add_form = _portainer_env_form("", None, csrf_token, suggest_name="" if pt.environments else "local") + return ( + '

Portainer

' + '

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.

" + + '
' + warn + + _settings_form("portainer", "Portainer", body, csrf_token) + "
" + + '

Environments

' + used_by + + envs + add_form + "
" + + _TOKEN_SOURCE_JS + ) + + def _render_flash(flash: str) -> str: if not flash: 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("
\n") parts.append(_render_diag_strip(config_ref)) - if section == "notifications": + if section in ("notifications", "uptime_kuma", "portainer"): parts.append(_TEST_NOTIFY_JS) parts.append(_PAGE_TAIL) 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: parts = [_page_head(refresh=False, active="containers")] 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) 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" + extras = _container_extras(c) + extras_html = f'Extras: {_escape(extras)}' if extras else "" parts.append( f'' @@ -1894,7 +2341,8 @@ def _render_containers_page(app: AppConfig, config_ref: ConfigRef, states: dict, f'Max errors: {c.max_error_count}' f'Cooldown: {c.cooldown_seconds}s' f'Restart on: {_escape(restart_on)}' - "\n" + + extras_html + + "\n" ) parts.append("\n") parts.append("\n") @@ -1957,9 +2405,106 @@ _RESTART_ON_JS = ( ) +_RECOVERY_JS = ( + "" +) + + +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 ' + 'Settings → Uptime Kuma or this check is ignored.') + reach = ( + '

Reachability

' + '

Docker can say a container is healthy when nobody can reach it, for example when ' + "its VPN tunnel dropped. A failed check reports UNREACHABLE; tick UNREACHABLE above to restart on it.

" + '

HTTP check

' + + _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.") + + '
' + + _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)") + + "
" + '

Uptime Kuma monitor

' + + _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 + + "
" + ) + + 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 = ( + '

Dependencies

' + '

Check this container after the one it relies on, and hold off recovering it ' + "while that one is still failing.

" + + _settings_select("depends_on", "depends_on", dep_value, dep_options, + hint="e.g. the VPN container, for apps that use network_mode: container:") + + _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.") + + "
" + ) + + action_help = '" + 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 ' + 'Settings → Portainer before relying on a redeploy.') + elif not pt.enabled: + pt_warn = _warn('The Portainer integration is off. Turn it on in ' + 'Settings → Portainer or redeploys will fail.') + hidden = "" if uses_portainer else " hidden" + recovery = ( + '

Recovery

' + '

What Inspector does once the restart conditions above are met.

' + + _settings_select("recovery_action", "recovery_action", action, + [(v, label) for v, label, _ in _RECOVERY_ACTION_OPTIONS], + onchange="InspectorRecovery(this)") + + action_help + + f'
' + + '
' + + _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") + + "
" + pt_warn + "
" + + "
" + + _RECOVERY_JS + ) + return '' + reach + deps + recovery + + def _render_container_form( 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: """Build the container edit form. `cfg` may be a ContainerConfig or a dummy with defaults.""" current_restart_on = getattr(cfg, "restart_on", []) or [] @@ -2021,6 +2566,7 @@ def _render_container_form( requires_restart=True) + _render_color_input("color", getattr(cfg, "color", "")) + restart_on_html + + _render_container_advanced(name, cfg, app) ) delete_btn = "" if show_delete: @@ -2056,6 +2602,7 @@ def _render_container_edit_page(app: AppConfig, config_ref: ConfigRef, name: str name, cfg, csrf_token, action=f"/containers/{_escape(name)}", submit_label="Save container", + app=app, show_delete=True, ) 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('
') parts.append( f'

Container — {_escape(name)}

' - '

Restart rules Inspector applies when this container\'s health changes.

' + '

How Inspector checks this container and what it does when it fails.

' ) parts.append(form_html) parts.append("
\n") @@ -2091,6 +2638,7 @@ def _render_container_new_page(app: AppConfig, config_ref: ConfigRef, csrf_token "", _NewContainerDefaults(), csrf_token, action="/containers/new", submit_label="Create container", + app=app, show_name_field=True, ) 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( '

New container

' '

Add a container to config.yml. ' - "The new entry is picked up automatically on the next polling cycle; " - "advanced fields (Portainer stack, http_check, uptime_kuma, depends_on, " - "vpn_routed) remain YAML-only for now.

" + "The new entry is picked up automatically on the next polling cycle.

" ) parts.append(form_html) parts.append("\n") @@ -2443,6 +2989,8 @@ class _Handler(BaseHTTPRequestHandler): # else matches the section slug. if section in ("gotify", "pushover"): return_path = "/settings/notifications" + elif section == "portainer_env": + return_path = "/settings/portainer" else: return_path = f"/settings/{section}" try: @@ -2454,8 +3002,9 @@ class _Handler(BaseHTTPRequestHandler): self.send_header("Content-Length", "0") self.end_headers() return + ok_label = "Portainer environment" if section == "portainer_env" else section 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.end_headers() return @@ -2526,6 +3075,13 @@ class _Handler(BaseHTTPRequestHandler): self._send_bytes(HTTPStatus.OK, "application/json", body) return + # POST /api/test-integration/ — 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") diff --git a/src/inspector/main.py b/src/inspector/main.py index 302686f..bcc0635 100755 --- a/src/inspector/main.py +++ b/src/inspector/main.py @@ -417,6 +417,7 @@ def run_loop(config_ref: ConfigRef) -> int: check_order = _topological_order(enabled_configs) portainer_client = PortainerClient(app.integrations.portainer) + integrations_cfg = app.integrations stack_cooldowns: dict[str, datetime] = {} dashboard = DashboardServer( @@ -456,6 +457,14 @@ def run_loop(config_ref: ConfigRef) -> int: enabled_configs = current_enabled 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() for name in check_order: cfg = enabled_configs[name] diff --git a/src/inspector/uptime_kuma.py b/src/inspector/uptime_kuma.py index fb82546..9772dd4 100755 --- a/src/inspector/uptime_kuma.py +++ b/src/inspector/uptime_kuma.py @@ -41,20 +41,26 @@ class UptimeKumaClient: 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: if not self.enabled: return _EMPTY - url = f"{self._config.url}/api/status-page/heartbeat/{self._config.status_page_slug}" try: - resp = requests.get(url, timeout=self._timeout) - resp.raise_for_status() - data = resp.json() - return self._parse(data) + return self.fetch_or_raise() 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 + 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: # Expected shape: # {"publicGroupList": [{"monitorList": [{"id": 1, "name": "..."}]}],