5 Commits
Author SHA1 Message Date
Europium 63 284ba2ea15 Inspector 0.9.1: guide explains VPN apps and Portainer setup; dependency and token fixes 2026-10-28 00:30:40 -07:00
Europium 63 bd78835f4a Inspector 0.9.0: edit every container option and the Uptime Kuma / Portainer integrations in the dashboard 2026-10-28 00:03:19 -07:00
Europium 63 7013a13cba Update logo in README 2026-10-27 16:44:33 -07:00
Europium 63 c603e86e75 Release v0.8.0 — Portainer-first install, in-app guide
- Install from Portainer with no command line: paste the compose, set a
  folder and a password, deploy. Inspector creates its own config and
  detects Docker socket permissions by itself.
- Illustrated how-to guide inside the app (Help tab) and in the repo.
- Containers added in the UI are watched right away, no restart.
- maintenance_window switch on the container form.
- README with logo and screenshots; INSTALL rewritten for Portainer.
2026-10-27 15:45:33 -07:00
Europium 63 4c05a4d565 Release v0.7.4 — compact container list in settings rail 2026-10-27 15:11:35 -07:00
25 changed files with 1660 additions and 234 deletions

No files matched your search

+114
View File
@@ -7,6 +7,120 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
---
## [0.9.1] — 2026-10-28
### Fixed
- **A dependency on a disabled container no longer blocks recovery forever.** If
`depends_on` named a container that is disabled in Inspector, the dependent app was
never restarted. Inspector now only waits for dependencies it is watching, and the
container page warns when the selected dependency is disabled.
- **`PORTAINER_API_TOKEN` now reaches the container.** The public `compose.yml` didn't
pass it in, so setting it as a Portainer stack variable had no effect. Existing
stacks need the line `- PORTAINER_API_TOKEN=${PORTAINER_API_TOKEN:-}` added under
`environment:` (Stacks → inspector → Editor).
- The "depends_on … is disabled or not found" warning was logged on every check
(since 0.8.0). It is now logged once, and again only when the container settings
change.
### Changed
- Guide: new sections **6. Apps behind a VPN container** (why depends_on matters, how to
spot a VPN-routed app, setup, what Inspector does) and **7. Portainer stack redeploy**
(why it's needed, what a redeploy does to the whole stack, requirements, where to find
every value, adding the token, the other settings, Test connection errors).
- Clearer form hints: `vpn_routed` only affects logging; `depends_on` works for any
dependency; where to find the endpoint ID.
---
## [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
- **Install from Portainer with no command line.** Mount one folder per purpose
(`/app/config`, `/app/logs`, `/app/data`) and deploy. If there's no config yet,
Inspector writes a starter `config.yml` (dashboard on, event history on, no
containers). Dashboard login comes from `INSPECTOR_USERNAME` / `INSPECTOR_PASSWORD`;
without a password, one is generated and printed once in the container logs.
- **Docker socket access is automatic.** A new entrypoint starts as root only long
enough to read the socket's group and fix ownership of the mounted folders, then
runs Inspector as `PUID:PGID` (default `1000:1000`) with that group added.
`DOCKER_GID` / `group_add` are no longer needed.
- **In-app guide** at `/guide/` with screenshots, linked from a new **Help** tab
in the header. Also in the repo at `src/inspector/assets/guide/guide.html`.
- **`maintenance_window` switch** on the container form, so containers can opt in
to the daily maintenance window without editing YAML.
### Fixed
- Containers added from the UI are now watched on the next check. Before, the
loop only logged that a restart was needed, despite what the UI said.
Containers removed or disabled from the UI are dropped the same way.
- Status page could fail to render while a container was being added or removed
(it iterated the live state dict and assumed every entry was still in config).
### Changed
- Config path: `--config`, then `$INSPECTOR_CONFIG`, then `/app/config.yml` if
present (existing single-file setups keep working unchanged), then
`/app/config/config.yml`.
- Public `compose.yml`, `INSTALL.md`, and `README.md` rewritten for Portainer
stacks; README gains the logo and screenshots.
### Upgrade notes
- Existing deployments need no changes. To adopt the simpler layout, move
`config.yml` into the new config folder and switch to the new compose file.
---
## [0.7.4] — 2026-10-27
### Changed
- **Settings rail container list is compact.** Per-container entries are indented
under "All containers" and fit on one line: colored dot, name, then enabled /
disabled in small muted text. The max-error stat is gone. Long names truncate
with an ellipsis, and the status stays visible.
---
## [0.7.3] — 2026-10-27
### Changed
+9 -4
View File
@@ -19,9 +19,14 @@ ARG INSPECTOR_UID=1000
ARG INSPECTOR_GID=1000
RUN groupadd --gid ${INSPECTOR_GID} inspector \
&& useradd --uid ${INSPECTOR_UID} --gid inspector --home-dir /app --shell /usr/sbin/nologin inspector \
&& mkdir -p /app/logs /app/data \
&& chown -R inspector:inspector /app/logs /app/data
&& mkdir -p /app/config /app/logs /app/data \
&& chown -R inspector:inspector /app/config /app/logs /app/data
USER inspector
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod 755 /usr/local/bin/docker-entrypoint.sh
CMD ["python", "-m", "inspector.main", "--config", "/app/config.yml"]
# Starts as root only to detect the Docker socket's group and fix mounted
# folder ownership, then drops to PUID:PGID (default 1000:1000).
# See docker-entrypoint.sh.
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["python", "-m", "inspector.main"]
+118 -52
View File
@@ -1,70 +1,136 @@
# Install
# Installing Inspector with Portainer
Inspector watches Docker containers, restarts the ones you tell it to,
and notifies you via Gotify and/or Pushover. All configuration lives in
a single `config.yml` that you can edit by hand or from the built-in web
settings page.
This guide uses only the Portainer web UI. You don't need SSH, a terminal, or a
config file: Inspector creates its own settings on first start and everything
else is done in its dashboard.
## Requirements
**You need:** a Docker host managed by Portainer (CE or BE, 2.x), and a few
minutes. Gotify or Pushover are optional, for phone alerts.
- Docker Engine 24+ and `docker compose` v2.
- A machine that can reach `/var/run/docker.sock` (any Docker host).
- Optional: a Gotify server and/or a Pushover account for notifications.
---
## Quickstart
## 1. Create the stack
```bash
# 1. Grab the compose file and example config
mkdir -p ~/docker/inspector && cd ~/docker/inspector
curl -L -o compose.yml https://gitea.euronvault.com/euronvault/inspector/raw/branch/main/compose.yml
curl -L -o config.yml https://gitea.euronvault.com/euronvault/inspector/raw/branch/main/config.example.yml
1. In Portainer, open your environment (usually **local**) and click
**Stacks → + Add stack**.
2. **Name:** `inspector`
3. **Build method:** *Web editor*. Paste the contents of
[`compose.yml`](./compose.yml):
# 2. Edit config.yml — at minimum, list the containers you want watched
# under the `containers:` key.
$EDITOR config.yml
# 3. Set your Docker group GID so the container can read the socket.
echo "DOCKER_GID=$(getent group docker | cut -d: -f3)" > .env
# 4. Pull and run
docker compose pull
docker compose up -d
docker compose logs -f inspector
```yaml
services:
inspector:
image: gitea.euronvault.com/euronvault/inspector:${INSPECTOR_TAG:-latest}
container_name: inspector
restart: unless-stopped
ports:
- "${INSPECTOR_PORT:-8080}:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${INSPECTOR_DATA:-/opt/inspector}/config:/app/config
- ${INSPECTOR_DATA:-/opt/inspector}/logs:/app/logs
- ${INSPECTOR_DATA:-/opt/inspector}/data:/app/data
environment:
- TZ=${TZ:-UTC}
- INSPECTOR_PASSWORD=${INSPECTOR_PASSWORD:-}
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
```
If `dashboard.enabled: true` in your config, visit
`http://<host>:8080/` after startup to see the dashboard, and
`http://<host>:8080/settings` to edit the config from the browser.
## 2. Set the environment variables
## Pinning a version
Scroll down to **Environment variables**. Click **Advanced mode** and paste,
then edit the values:
Prefer an immutable tag over `latest` in production:
```bash
# .env
INSPECTOR_TAG=0.7.0
```
INSPECTOR_DATA=/mnt/user/appdata/inspector
INSPECTOR_PASSWORD=pick-a-password
TZ=America/Los_Angeles
```
Images are published at `gitea.euronvault.com/euronvault/inspector`.
| Variable | What to put | Required |
|---|---|---|
| `INSPECTOR_DATA` | Folder on your server where Inspector keeps its settings, logs, and history. **Unraid:** `/mnt/user/appdata/inspector`. **Other Linux:** `/opt/inspector`. Created automatically. | Yes |
| `INSPECTOR_PASSWORD` | Password for the dashboard (username `admin`). Used only on first start; change it later in the dashboard. Leave it out and Inspector generates one and prints it in the container logs. | Recommended |
| `TZ` | Your [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones), so times on the dashboard are local. | Recommended |
| `INSPECTOR_PORT` | Port on your server for the dashboard. Change it if `8080` is taken. | No (default `8080`) |
| `INSPECTOR_TAG` | Version to run, e.g. `0.8.0`. See [Updating](#4-updating). | No (default `latest`) |
| `PUID` / `PGID` | User and group that own Inspector's files. Unraid users often use `99` / `100` so the files match other appdata. | No (default `1000` / `1000`) |
| `PORTAINER_API_TOKEN` | A Portainer access token. Only needed if you use **Portainer stack redeploy** for VPN-routed apps; see the in-app guide (Help → Portainer). | No |
## Upgrading
## 3. Deploy and sign in
```bash
docker compose pull
docker compose up -d
```
1. Click **Deploy the stack**.
2. Open `http://<your-server-ip>:8080` (or the port you chose).
3. Sign in as `admin` with your `INSPECTOR_PASSWORD`.
- No password set? Go to **Containers → inspector → Logs** in Portainer. Near
the top is a box labeled *Inspector dashboard login* with the generated
password.
4. Click **Help** in the dashboard header for the full how-to guide. The short
version:
- **Settings → + Add container**: add each container you want watched, using
its exact name from Portainer, and click **Recommended** for restart rules.
- **Settings → Notifications**: turn on Gotify and/or Pushover, click
**Send test**, then **Save**.
- Restart Inspector once after changing anything marked
**requires restart** (Portainer → Containers → tick `inspector` → Restart).
Your `config.yml` is persisted outside the container, so upgrades don't
touch it. The settings page keeps a `.bak` of the previous file next to
`config.yml` on every save (when the directory is bind-mounted; see the
README for the Docker caveat).
That's it. Inspector picks up new containers on its next check (within a minute).
## Uninstall
---
```bash
docker compose down
rm -rf logs data config.yml compose.yml .env
```
## 4. Updating
Inspector doesn't store anything outside the directory it was started
from.
1. **Stacks → inspector → Editor**.
2. Click **Update the stack** and turn on **Re-pull image and redeploy**.
3. Confirm.
Your settings and history live in `INSPECTOR_DATA`, so updates never touch them.
**Pinning a version.** `latest` always gets the newest release. To stay on a
specific version, set `INSPECTOR_TAG` (e.g. `0.8.0`) in the stack's environment
variables, then update the stack. Available versions are on the
[releases page](https://gitea.euronvault.com/euronvault/inspector/releases)
and in the [CHANGELOG](./CHANGELOG.md).
**Rolling back.** Set `INSPECTOR_TAG` to the previous version and update the
stack with **Re-pull image and redeploy** on.
## 5. Uninstalling
1. **Stacks → inspector → Delete this stack**.
2. Delete the `INSPECTOR_DATA` folder (on Unraid, through the appdata share) if
you don't want to keep the settings and history.
Inspector never changes your other containers' files or settings. It only
starts and restarts the ones you add to it.
---
## Troubleshooting
| What you see | What to do |
|---|---|
| Stack fails with `pull access denied` or `not found` | Check the image line is exactly `gitea.euronvault.com/euronvault/inspector:...` and that `INSPECTOR_TAG` (if set) is a real version. |
| Port already in use | Set `INSPECTOR_PORT` to a free port (e.g. `8095`) and update the stack. |
| Logs say *Permission denied on /var/run/docker.sock* | Make sure the stack has no `user:` line, then update the stack. Inspector needs to start normally to set up socket access. |
| Red **READ-ONLY** badge at the bottom of Settings | The config folder mount ends in `:ro` or the folder isn't writable. Remove `:ro` and update the stack. |
| Can't sign in | Look for the generated password in the container logs. If you've lost it, open `INSPECTOR_DATA/config/config.yml` (e.g. via your Unraid share), set a new `password:` under `dashboard:`, and restart Inspector. |
| A container shows **CONTAINER NOT FOUND** | The name in Inspector doesn't match Docker. Copy the exact name from Portainer → Containers. |
## Security notes
- Inspector needs the Docker socket to restart containers. Anything with access
to that socket effectively has full control of the host, so keep the
dashboard on your home network (or behind a VPN / reverse proxy with auth)
and use a real password.
- Inspector's process runs as an unprivileged user. It starts as root only long
enough to detect the Docker socket's group and fix ownership of its own
folders.
## Moving from an older (0.7.x) setup
Older setups mounted a single file at `/app/config.yml` and set `group_add` /
`DOCKER_GID`. That still works unchanged. To switch to the simpler layout:
move your `config.yml` into `<INSPECTOR_DATA>/config/`, replace the stack with
the compose above, and update the stack.
+79 -112
View File
@@ -1,139 +1,106 @@
# Inspector
<p align="center">
<img src="docs/logo_outline.png" alt="Inspector logo" width="140">
</p>
Inspector is a small, opinionated Docker-watchdog. It polls the Docker
daemon, applies per-container restart rules, pings Gotify and/or
Pushover when things go wrong, and gives you a dashboard that shows
what's happening right now and what happened earlier today.
<h1 align="center">Inspector</h1>
Built for home-lab and small-fleet operators who want "it just works"
container recovery without standing up a full observability stack.
<p align="center"><b>Monitor. Restart. Report.</b><br>
A small Docker watchdog for home servers: it watches the containers you pick,
restarts them when they break, and tells your phone about it.</p>
## What Inspector does
<p align="center">
<a href="./INSTALL.md">Install with Portainer</a> ·
<a href="./src/inspector/assets/guide/guide.html">How-to guide</a> ·
<a href="./CHANGELOG.md">Changelog</a>
</p>
- Polls each configured container on a fixed interval and classifies it:
`HEALTHY`, `UNHEALTHY`, `STARTING`, `NOT RUNNING`, `ERROR`,
`UNREACHABLE`, `CONTAINER NOT FOUND`, `NO HEALTH CHECK`, `UNKNOWN`.
- Restarts a container when it crosses a configured error threshold —
with cooldowns, grace periods, and an hourly restart cap so a flapping
container doesn't DDoS your host.
- Opt-in HTTP reachability checks per container (useful for VPN-routed
containers whose tunnel drops while Docker still reports "healthy").
- Opt-in Uptime Kuma integration as a second reachability source.
- Advanced recovery via Portainer stack redeploy for containers whose
network namespace is tied to a VPN sidecar.
- Sends notifications on transitions, not on every poll — one line when
something breaks, one line when it recovers.
- Optional SQLite event history + a daily/weekly digest.
- Scheduled maintenance windows (don't act during 2–6 AM).
- Manual notification mute per container, from the dashboard.
- Embedded web dashboard + events timeline + **web settings page** for
editing `config.yml` without SSH.
![Inspector status page](src/inspector/assets/guide/status.png)
## What Inspector is not
## What it does
- Not a metrics system. It doesn't scrape Prometheus or push
dashboards; use Grafana / Netdata / your real monitoring stack for
that.
- Not an orchestrator. It only acts on containers you've explicitly
listed in `config.yml`.
- Not multi-host. One Inspector per Docker socket.
- **Watches the containers you choose.** Each check sorts a container into
healthy, unhealthy, not running, starting, and so on.
- **Restarts them when they break**, with sensible brakes: a container has to fail
several checks in a row first, there's a cooldown between restarts, a grace
period while it boots, and an hourly cap so a crash-looping app can't take over
your server.
- **Alerts you** through [Gotify](https://gotify.net) and/or
[Pushover](https://pushover.net): once when something breaks, once when it's
restarted, once when it recovers. Not on every check.
- **Is set up entirely in the browser.** Add, edit, and remove containers, set up
notifications (with a **Send test** button), and change every setting from the
dashboard. No YAML editing needed.
- **Keeps a history.** The Events page shows a day-by-day timeline of failures,
restarts, and recoveries, and an optional digest summarizes it for you.
- **Stays out of the way when you're working.** Put a container in maintenance or
mute its alerts with one click, or set a nightly maintenance window for backups
and updates.
## Install
See [`INSTALL.md`](./INSTALL.md). TL;DR:
Inspector is built to be installed from **Portainer**: paste one compose file into
**Stacks → Add stack**, set a folder and a password, deploy. Inspector creates its
own config and sets up Docker access by itself.
```bash
mkdir -p ~/docker/inspector && cd ~/docker/inspector
curl -L -o compose.yml https://gitea.euronvault.com/euronvault/inspector/raw/branch/main/compose.yml
curl -L -o config.yml https://gitea.euronvault.com/euronvault/inspector/raw/branch/main/config.example.yml
$EDITOR config.yml
echo "DOCKER_GID=$(getent group docker | cut -d: -f3)" > .env
docker compose up -d
```
**→ [Step-by-step Portainer install](./INSTALL.md)**
## Configuration
## Using it
Everything lives in `config.yml` next to the compose file. Start from
[`config.example.yml`](./config.example.yml) — every key is commented.
Highlights:
Once it's running, click **Help** in the dashboard header for the illustrated
guide (the same guide is in this repo at
[`src/inspector/assets/guide/guide.html`](./src/inspector/assets/guide/guide.html);
download it and open it in a browser to read it here).
- `inspector.interval_seconds` — poll cadence.
- `inspector.dry_run` — classify + log, never restart.
- `notifications.gotify` / `notifications.pushover` — enable either,
both, or neither.
- `containers.<name>.max_error_count` / `cooldown_seconds` /
`grace_period_seconds` / `max_restarts_per_hour` — the restart knobs.
- `containers.<name>.restart_on` — which statuses trigger a restart
(leave empty for observe-only).
- `containers.<name>.http_check` — opt-in HTTP ping.
- `containers.<name>.recovery_action` — `restart_container` (default)
or the advanced Portainer-based options.
| Add a container | Restart rules |
| ------------------------------------------------------------------- | -------------------------------------------------------------------- |
| ![New container form](src/inspector/assets/guide/container-new.png) | ![Container settings](src/inspector/assets/guide/container-edit.png) |
### Dashboard and settings page
| Notifications | Events history |
| ------------------------------------------------------------------------------- | --------------------------------------------------------- |
| ![Notification settings](src/inspector/assets/guide/settings-notifications.png) | ![Events timeline](src/inspector/assets/guide/events.png) |
If `dashboard.enabled: true`, Inspector exposes a small web UI on
`:8080`:
## What it isn't
- `/` — container cards with current status, error bar, phase
(cooldown / grace / failing / observe only / maintenance), and
maintenance / mute toggles.
- `/events` — per-day timeline of status changes, restarts, recoveries,
and failures (requires `database.enabled: true`).
- `/settings` — forms for every top-level config section plus one card
per container. Saves preserve comments and key order in `config.yml`
and keep a `.bak`. Fields that take effect on the next loop iteration
(per-container thresholds, `dry_run`, `heartbeat`, `interval_seconds`)
hot-apply; fields bound at startup are labeled **requires restart**.
- **Not a monitoring/metrics system.** No graphs of CPU or memory; use Grafana,
Netdata, or Uptime Kuma for that.
- **Not an orchestrator.** It only touches the containers you add, and only to
restart them.
- **One Docker host per Inspector.** Run one copy on each server.
Note on `.bak` inside Docker: when `config.yml` is bind-mounted as a
single file (the compose default), the `.bak` lives in the container's
overlay fs — the live file still updates cleanly, but the backup is
lost when the container is removed. To keep the `.bak` on the host,
bind-mount the directory instead of the single file.
## Advanced options
### Notifications
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).
Both channels are independent. If both are enabled, every alert goes to
both. Inspector sends on transitions:
- **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. Set up the Portainer connection under **Settings → Portainer**;
**Test connection** lists your stacks.
```
🚨 first failure threshold crossed
🔁 container restart triggered
✅ restart / start completed
✅ container recovered
🛑 restart failed / hourly limit exceeded
❓ configured container not found
```
## Security
Gotify messages are Markdown; Pushover doesn't render Markdown so the
`**bold**` passthrough is cosmetic — bodies are identical.
Inspector needs the Docker socket (`/var/run/docker.sock`) to restart containers,
and that socket is effectively root on your server. So:
Pushover priorities map from Inspector's Gotify-style scale (default 5)
to Pushover's `-2..2` range: `<=2 → -1`, `3..4 → 0`, `5..6 → 1`,
`>=7 → 2`. Priority 2 (emergency) ships default `retry` / `expire` and
your Pushover client prompts for acknowledgement.
- Keep the dashboard on your home network, or put it behind a VPN or a reverse
proxy with authentication. Don't expose port 8080 to the internet.
- Use a real dashboard password.
- Inspector itself runs as an unprivileged user; it starts as root only to detect
the socket's permissions and fix ownership of its own folders.
## Docker socket security
## Versions
Inspector talks to `/var/run/docker.sock`. That socket is effectively
root on your host: anything that can write to it can start, stop,
inspect, and (through bind mounts) read any file as root. Inspector
mounts it read-write because it needs to restart containers.
Follow the usual home-lab precautions:
- Don't publish the dashboard port on an untrusted network. Use a
reverse proxy with auth, a tailnet, or SSH tunneling.
- Set a strong `dashboard.password`.
- Store Portainer / Gotify / Pushover tokens in the config file on a
host-only path with permissions `600`.
## Versioning
Semantic versioning (`MAJOR.MINOR.PATCH`). Image tags are published for
every release; `latest` tracks the newest tag. Pin a specific version
in production — see `INSTALL.md`.
Releases follow `MAJOR.MINOR.PATCH`. Every release is published as an image tag;
`latest` is the newest. See the [CHANGELOG](./CHANGELOG.md) for what changed, and
[INSTALL.md](./INSTALL.md#4-updating) for updating, pinning, and rolling back.
## License
[MIT](./LICENSE).
[MIT](./LICENSE) © 2026 Europium 63
+29 -17
View File
@@ -1,29 +1,41 @@
# Inspector compose file for buddies / self-hosters.
# Pulls the published image from gitea.euronvault.com.
# Inspector — Portainer stack
#
# Before `docker compose up -d`:
# 1. Copy config.example.yml -> config.yml and edit it.
# 2. Set DOCKER_GID to your host's docker group GID:
# getent group docker | cut -d: -f3
# Export it in your shell or put it in a .env file next to this file.
# 3. Optionally set INSPECTOR_TAG to pin a version
# (default: latest). Prefer a pinned X.Y.Z for production.
# Paste this into Portainer: Stacks -> Add stack -> Web editor.
# Then set these under "Environment variables" (Advanced mode lets you paste them):
#
# INSPECTOR_DATA Folder on your server for Inspector's files.
# Unraid: /mnt/user/appdata/inspector Other: /opt/inspector
# INSPECTOR_PASSWORD Dashboard password (username is "admin").
# Only used on first start; change it later in Settings.
# TZ Your timezone, e.g. America/Los_Angeles
#
# Optional:
# INSPECTOR_PORT Port for the dashboard on your server (default 8080).
# INSPECTOR_TAG Image version to run (default "latest"; e.g. 0.8.0 to pin).
# PUID / PGID User/group Inspector runs as (default 1000/1000).
# Unraid users often prefer 99 / 100.
# PORTAINER_API_TOKEN Portainer access token, only if you use stack redeploy
# (Settings -> Portainer in the dashboard).
#
# No config file to create, no docker group ID to look up: Inspector creates
# its config on first start and detects Docker socket permissions by itself.
services:
inspector:
image: gitea.euronvault.com/euronvault/inspector:${INSPECTOR_TAG:-latest}
container_name: inspector
restart: unless-stopped
# GID of the docker group on the host so the non-root user inside the
# container can read /var/run/docker.sock. Required.
group_add:
- "${DOCKER_GID:-999}"
ports:
# Only published if dashboard.enabled is true in config.yml.
- "${INSPECTOR_PORT:-8080}:8080"
volumes:
# Lets Inspector see and restart your containers. Required.
- /var/run/docker.sock:/var/run/docker.sock
- ./config.yml:/app/config.yml
- ./logs:/app/logs
- ./data:/app/data
# Settings (config.yml), logs, and event history. Kept across updates.
- ${INSPECTOR_DATA:-/opt/inspector}/config:/app/config
- ${INSPECTOR_DATA:-/opt/inspector}/logs:/app/logs
- ${INSPECTOR_DATA:-/opt/inspector}/data:/app/data
environment:
- TZ=${TZ:-UTC}
- INSPECTOR_PASSWORD=${INSPECTOR_PASSWORD:-}
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- PORTAINER_API_TOKEN=${PORTAINER_API_TOKEN:-}
+6 -2
View File
@@ -1,5 +1,9 @@
# Inspector example configuration.
# Copy to config.yml, edit, and mount into the container at /app/config.yml.
# Inspector example configuration: every option, with comments.
#
# You normally don't need this file. Inspector writes its own config.yml to
# INSPECTOR_DATA/config/ on first start, and the dashboard edits it for you.
# Use this as a reference for the advanced options that are YAML-only
# (http_check, uptime_kuma, depends_on, Portainer stack redeploy).
inspector:
interval_seconds: 60
+38
View File
@@ -0,0 +1,38 @@
#!/bin/sh
# Inspector container entrypoint.
#
# Started as root (the default): find the group that owns the Docker socket,
# make the mounted folders writable, then drop to an unprivileged user with
# that group added. No DOCKER_GID / group_add needed.
#
# Started as non-root (compose `user:` / older group_add setups): run as-is.
set -e
if [ "$(id -u)" != "0" ]; then
exec "$@"
fi
PUID="${PUID:-1000}"
PGID="${PGID:-1000}"
SOCK=/var/run/docker.sock
groups="$PGID"
if [ -S "$SOCK" ]; then
sock_gid="$(stat -c %g "$SOCK")"
groups="$groups,$sock_gid"
else
echo "warning: $SOCK is not mounted; Inspector cannot see any containers" >&2
fi
# Mounted folders are often created by Docker as root. Hand them (not their
# contents) to the runtime user so Inspector can write config, logs and data.
for d in /app/config /app/logs /app/data; do
if [ -d "$d" ] && [ "$(stat -c %u "$d")" != "$PUID" ]; then
chown "$PUID:$PGID" "$d" 2>/dev/null || echo "warning: could not chown $d" >&2
fi
done
if [ -f /app/config/config.yml ] && [ "$(stat -c %u /app/config/config.yml)" != "$PUID" ]; then
chown "$PUID:$PGID" /app/config/config.yml 2>/dev/null || true
fi
exec setpriv --reuid="$PUID" --regid="$PGID" --groups="$groups" "$@"
Executable
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+1 -1
View File
@@ -1 +1 @@
__version__ = "0.7.3"
__version__ = "0.9.1"
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+443
View File
@@ -0,0 +1,443 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Inspector — How to use</title>
<style>
:root {
--bg: #0b1220; --card: #111c30; --card-2: #0f1829; --border: #1e293b;
--ink: #e2e8f0; --ink-2: #cbd5e1; --muted: #8494a9;
--blue: #60a5fa; --green: #34d399; --amber: #fbbf24; --red: #f87171; --violet: #a78bfa;
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--ink);
font-family: Inter, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
font-size: 16px; line-height: 1.6; }
a { color: var(--blue); }
.top { position: sticky; top: 0; z-index: 5; background: #060b16; border-bottom: 1px solid var(--border); }
.top .in { max-width: 1100px; margin: 0 auto; padding: 0.8rem 1rem; display: flex; align-items: center; gap: 1rem; }
.top .brand { font-weight: 800; font-size: 1.05rem; }
.top .brand span { color: var(--muted); font-weight: 400; margin-left: 0.4rem; }
.top a.back { margin-left: auto; font-size: 0.9rem; text-decoration: none; border: 1px solid var(--border);
padding: 0.35rem 0.8rem; border-radius: 8px; color: var(--ink-2); }
.wrap { max-width: 1100px; margin: 0 auto; padding: 2rem 1rem 4rem; display: grid;
grid-template-columns: 220px minmax(0, 1fr); gap: 2.5rem; }
nav.toc { position: sticky; top: 4.5rem; align-self: start; font-size: 0.88rem; }
nav.toc b { display: block; color: var(--muted); text-transform: uppercase; font-size: 0.7rem;
letter-spacing: 0.08em; margin-bottom: 0.5rem; }
nav.toc a { display: block; color: var(--ink-2); text-decoration: none; padding: 0.25rem 0; }
nav.toc a:hover { color: var(--blue); }
main { min-width: 0; }
h1 { font-size: 1.9rem; margin: 0 0 0.4rem; line-height: 1.2; }
.lede { color: var(--ink-2); font-size: 1.05rem; margin: 0 0 2rem; }
section { margin-bottom: 3rem; scroll-margin-top: 4.5rem; }
h2 { font-size: 1.35rem; margin: 0 0 0.6rem; padding-top: 0.5rem; border-top: 1px solid var(--border); }
h3 { font-size: 1.02rem; margin: 1.4rem 0 0.4rem; }
p, li { color: var(--ink-2); }
code { background: var(--card-2); border: 1px solid var(--border); padding: 0.05em 0.35em;
border-radius: 5px; font-size: 0.88em; color: var(--ink); }
figure { margin: 1rem 0 1.4rem; }
figure img { width: 100%; height: auto; display: block; border-radius: 12px; border: 1px solid var(--border); }
figcaption { color: var(--muted); font-size: 0.82rem; margin-top: 0.4rem; }
.steps { counter-reset: s; list-style: none; padding: 0; }
.steps li { counter-increment: s; position: relative; padding-left: 2.3rem; margin-bottom: 0.7rem; }
.steps li::before { content: counter(s); position: absolute; left: 0; top: 0.05rem; width: 1.6rem; height: 1.6rem;
border-radius: 50%; background: rgba(96,165,250,0.15); color: var(--blue); font-weight: 700;
font-size: 0.85rem; display: flex; align-items: center; justify-content: center; }
.note { background: var(--card); border: 1px solid var(--border); border-left: 3px solid var(--blue);
border-radius: 10px; padding: 0.75rem 1rem; margin: 1rem 0; color: var(--ink-2); }
.note.warn { border-left-color: var(--amber); }
table { width: 100%; border-collapse: collapse; margin: 0.8rem 0 1.2rem; font-size: 0.92rem; }
th, td { text-align: left; padding: 0.5rem 0.7rem; border-bottom: 1px solid var(--border); vertical-align: top; }
th { color: var(--muted); font-weight: 600; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; }
td:first-child { white-space: nowrap; }
.tag { display: inline-block; font-size: 0.72rem; font-weight: 700; padding: 0.05rem 0.45rem; border-radius: 999px; }
.t-green { background: rgba(52,211,153,0.15); color: var(--green); }
.t-red { background: rgba(248,113,113,0.15); color: var(--red); }
.t-amber { background: rgba(251,191,36,0.15); color: var(--amber); }
.t-blue { background: rgba(96,165,250,0.15); color: var(--blue); }
.t-violet { background: rgba(167,139,250,0.15); color: var(--violet); }
@media (max-width: 820px) {
.wrap { grid-template-columns: 1fr; gap: 1rem; padding-top: 1.2rem; }
nav.toc { position: static; }
}
</style>
</head>
<body>
<div class="top"><div class="in">
<div class="brand">Inspector<span>How to use</span></div>
<a class="back" href="/">Open dashboard</a>
</div></div>
<div class="wrap">
<nav class="toc">
<b>On this page</b>
<a href="#first-login">1. First login</a>
<a href="#add">2. Add your containers</a>
<a href="#status">3. The Status page</a>
<a href="#notifications">4. Notifications</a>
<a href="#containers">5. Managing containers</a>
<a href="#vpn">6. Apps behind a VPN</a>
<a href="#portainer">7. Portainer stack redeploy</a>
<a href="#events">8. Events history</a>
<a href="#settings">9. Other settings</a>
<a href="#restart">10. "Requires restart" fields</a>
<a href="#trouble">11. Troubleshooting</a>
</nav>
<main>
<h1>Using Inspector</h1>
<p class="lede">Inspector watches the Docker containers you pick, restarts them when they break,
and pings your phone when something goes wrong or recovers. Everything is set from this web
dashboard; you never need to touch a config file.</p>
<section id="first-login">
<h2>1. First login</h2>
<p>When Inspector starts for the first time it creates its config file and a dashboard login.</p>
<ol class="steps">
<li>If you set <code>INSPECTOR_PASSWORD</code> in the Portainer stack, sign in with
<code>admin</code> (or your <code>INSPECTOR_USERNAME</code>) and that password.</li>
<li>If you didn't, Inspector generated a password. In Portainer open
<b>Containers → inspector → Logs</b>. Near the top you'll see a box labeled
<i>Inspector dashboard login</i> with the username and password.</li>
<li>Open <code>http://&lt;your-server&gt;:8080</code> and sign in.</li>
<li>Change the password under <b>Settings → Dashboard</b> if you like.</li>
</ol>
</section>
<section id="add">
<h2>2. Add your containers</h2>
<p>Inspector only watches containers you add. Go to <b>Settings</b> and click
<b>+ Add container</b> at the bottom of the left menu (or <b>+ New container</b> on the
All containers page).</p>
<figure><img src="container-new.png" alt="New container form">
<figcaption>The New container form.</figcaption></figure>
<ol class="steps">
<li><b>name</b>: the container's name exactly as Portainer shows it
(for example <code>sonarr</code>). If it doesn't match, the card shows
<span class="tag t-amber">CONTAINER NOT FOUND</span>.</li>
<li>Leave <b>enabled</b> on.</li>
<li>Under <b>Restart when status is…</b> click <b>Recommended</b>
(restarts on <code>UNHEALTHY</code> and <code>NOT RUNNING</code>), or
<b>Observe only</b> if you just want alerts without restarts.</li>
<li>Click <b>Create container</b>. Inspector starts watching it on its next check
(within a minute by default). No restart needed.</li>
</ol>
<h3>What the numbers mean</h3>
<table>
<tr><th>Field</th><th>Meaning</th><th>Default</th></tr>
<tr><td>max_error_count</td><td>How many bad checks in a row before Inspector restarts the container. Stops one blip from causing a restart.</td><td>5</td></tr>
<tr><td>cooldown_seconds</td><td>Minimum time between two restarts of the same container.</td><td>300</td></tr>
<tr><td>grace_period_seconds</td><td>After a restart, ignore failures for this long so the app can boot.</td><td>120</td></tr>
<tr><td>max_restarts_per_hour</td><td>Safety cap. After this many restarts in an hour Inspector stops restarting and alerts you instead.</td><td>3</td></tr>
<tr><td>maintenance_window</td><td>Follow the daily maintenance window from Settings → Maintenance.</td><td>off</td></tr>
<tr><td>color</td><td>Card color on the dashboard. Pick a swatch or leave blank for automatic.</td><td>auto</td></tr>
</table>
<div class="note">With the default 60-second check interval and <code>max_error_count</code> 5, a
container has to be broken for about five minutes before Inspector restarts it. Lower the count
for faster recovery.</div>
</section>
<section id="status">
<h2>3. The Status page</h2>
<figure><img src="status.png" alt="Status page">
<figcaption>Status: one card per watched container.</figcaption></figure>
<p>The top row counts containers, healthy ones, ones with issues, and ones in maintenance.
Each card shows:</p>
<ul>
<li><b>Status badge</b>: what Docker reports right now, e.g.
<span class="tag t-green">HEALTHY</span> <span class="tag t-red">NOT RUNNING</span>
<span class="tag t-violet">NO HEALTH CHECK</span> (the image has no health check; that's normal
for many apps and only means Inspector can tell running from stopped, not healthy from unhealthy).</li>
<li><b>Errors bar</b>: bad checks in a row out of <code>max_error_count</code>. When it fills,
Inspector restarts the container.</li>
<li><b>Cooldown until / Last restart</b>: when Inspector last restarted it and when it's allowed to
again.</li>
<li><b>Observe only</b>: shown when the container has no restart triggers; Inspector alerts but
never restarts it.</li>
<li><b>Enter maintenance</b>: pause Inspector for that container while you work on it
(updates, migrations). Click again to exit.</li>
<li><b>Mute notifications</b>: keep watching and restarting, but stop alerts for a while
(12 hours by default).</li>
</ul>
<p>The page refreshes itself every 30 seconds.</p>
</section>
<section id="notifications">
<h2>4. Notifications</h2>
<p>Inspector can alert you through <a href="https://gotify.net">Gotify</a>,
<a href="https://pushover.net">Pushover</a>, or both. Set them up under
<b>Settings → Notifications</b>.</p>
<figure><img src="settings-notifications.png" alt="Notifications settings">
<figcaption>Each channel has its own priority reference, a Send test button, and a Save button.</figcaption></figure>
<ol class="steps">
<li>Turn on <b>enabled</b> for the channel you use.</li>
<li><b>Gotify</b>: enter your server URL and an <i>application</i> token (create one in Gotify
under Apps). <b>Pushover</b>: enter your application API token and your user key.</li>
<li>Click <b>Send test</b>. You should get a message on your phone within seconds. If it fails,
the reason appears next to the button.</li>
<li>Click <b>Save</b>, then restart Inspector once (see <a href="#restart">section 10</a>),
because turning a channel on or off is a "requires restart" field.</li>
</ol>
<p>You'll get one alert when a container first fails, when Inspector restarts it, and when it
recovers. You won't get one on every check.</p>
</section>
<section id="containers">
<h2>5. Managing containers</h2>
<p><b>Settings → All containers</b> shows every watched container as a tile. Click a tile (or a
name in the left menu) to edit it.</p>
<figure><img src="containers.png" alt="All containers grid">
<figcaption>All containers.</figcaption></figure>
<figure><img src="container-edit.png" alt="Container settings">
<figcaption>Editing one container. Each restart trigger is a card you click on or off.</figcaption></figure>
<p>Changes apply on the next check. <b>Delete container</b> stops Inspector watching it; it
does <i>not</i> touch the container itself.</p>
<h3>Restart triggers</h3>
<table>
<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>NOT RUNNING</td><td>Stopped, exited, or crashed.</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>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>
</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>
</section>
<section id="vpn">
<h2>6. Apps behind a VPN container</h2>
<h3>Why these apps need special handling</h3>
<p>A common setup routes download and indexer apps through a VPN container. Those apps don't get a
network of their own: their compose file says <code>network_mode: container:&lt;vpn&gt;</code> (or
<code>service:&lt;vpn&gt;</code>) and all their traffic goes through the VPN container. That causes
two problems for a watchdog:</p>
<ul>
<li><b>When the VPN breaks, every app behind it breaks at the same time.</b> Restarting the apps
does nothing while the tunnel is down. Without help, Inspector would restart each app over and
over, use up their hourly restart limits, and flood you with alerts, all for a problem in a
different container.</li>
<li><b>When the VPN container is recreated</b> (an image update, or someone redeploys its stack),
it gets a new container ID. The apps still point at the old one, so a normal restart fails with
errors like <i>"No such container"</i> or <i>"joining network namespace"</i>. The only fix is
to recreate the apps too, which is what a Portainer stack redeploy does
(<a href="#portainer">section 7</a>).</li>
</ul>
<h3>How to tell if an app is behind a VPN</h3>
<ul>
<li>In Portainer open <b>Stacks → your stack → Editor</b> and look for
<code>network_mode: container:…</code> or <code>network_mode: service:…</code> on the app.</li>
<li>Or open <b>Containers → the app</b>: under <b>Network</b> it shows the VPN container instead
of a normal network, and it has no ports of its own (they're published on the VPN container).</li>
</ul>
<h3>Setting it up</h3>
<ol class="steps">
<li><b>Add the VPN container to Inspector and keep it enabled</b> (restart on UNHEALTHY and NOT
RUNNING is a good start). Inspector can only wait for containers it is watching: if the VPN
container is disabled, its apps are recovered normally, as if there were no dependency, and the
container page shows a warning.</li>
<li>On each app behind it, set <b>Dependencies → depends_on</b> to the VPN container.</li>
<li>Turn on <b>vpn_routed</b>. This only changes the log: Inspector writes down when it holds off
on the app because the VPN is down. The waiting itself comes from depends_on.</li>
<li>Add an <b>HTTP check</b> (<a href="#containers">section 5</a>) using your server's IP and the
port published on the VPN container, and tick <b>UNREACHABLE</b>. Docker often still reports
these apps as healthy when the tunnel is down; the HTTP check notices.</li>
<li>Under <b>Recovery</b>, choose <b>Restart, then redeploy stack if that fails</b> and enter the
stack name. This needs the Portainer connection from <a href="#portainer">section 7</a>.</li>
</ol>
<h3>What Inspector then does</h3>
<p>Every check, it looks at the VPN container first. While the VPN is anything other than healthy
(or starting, or running without a health check), the apps that depend on it are not restarted;
Inspector fixes the VPN first. Once the VPN is healthy again, the apps are handled normally: a
restart first, and a stack redeploy if the restart fails or doesn't help.</p>
<p class="note"><b>depends_on isn't only for VPNs.</b> Use it for any "B needs A" pair, such as an
app and its database: Inspector won't restart the app while its database is down.</p>
</section>
<section id="portainer">
<h2>7. Portainer stack redeploy</h2>
<h3>Why Inspector needs Portainer</h3>
<p>Inspector talks to Docker directly, so it can restart or start a container. It can't
<i>recreate</i> one: that needs the container's compose definition, which lives in Portainer. A
stack redeploy asks Portainer to do exactly what its <b>Update the stack</b> button does. That's
how VPN-routed apps get reattached to a recreated VPN container. You only need this if you use one
of the two "redeploy" recovery actions.</p>
<p class="note warn"><b>A redeploy affects the whole stack.</b> Portainer pulls the latest images
and recreates <i>every</i> container in that stack, not just the failing app, so all of them blink
off for a moment. That's why <b>Restart, then redeploy stack if that fails</b> is the recommended
choice: most problems are fixed by the plain restart, and the redeploy is only used when that
doesn't work.</p>
<h3>Requirements</h3>
<ul>
<li>The app must belong to a stack that was <b>created in Portainer</b>. In <b>Stacks</b>, stacks
created elsewhere (for example with <code>docker compose</code> on the command line) are marked
<b>Limited</b>, and Portainer can't redeploy them.</li>
<li>An access token for a Portainer user who can manage that stack. An administrator's token is
the simplest.</li>
</ul>
<h3>Step 1: find the values</h3>
<table>
<tr><th>Setting</th><th>Where to find it</th></tr>
<tr><td>base_url</td><td>The address you open Portainer at, without anything after the port:
usually <code>http://&lt;server-ip&gt;:9000</code> or <code>https://&lt;server-ip&gt;:9443</code>.
Use the server's IP, not <code>localhost</code>: Inspector runs in its own container.</td></tr>
<tr><td>endpoint_id</td><td>On Portainer's <b>Home</b> page, click your environment (often
<b>local</b>). The browser address then ends like <code>#!/2/docker/dashboard</code>: the
number after <code>#!/</code> is the endpoint ID (here <code>2</code>).</td></tr>
<tr><td>stack name</td><td><b>Stacks</b> list, <b>Name</b> column, spelled exactly the same.
This goes on each app's container page, not on the Portainer page.</td></tr>
<tr><td>verify_ssl</td><td>Leave off for <code>http://</code> and for Portainer's built-in
self-signed certificate on 9443. Turn on only if Portainer has a real certificate.</td></tr>
<tr><td>API token</td><td>Click your username (top right) → <b>My account</b> →
<b>Access tokens</b> → <b>Add access token</b>. Name it <code>inspector</code> and copy the
token straight away: Portainer shows it only once.</td></tr>
</table>
<h3>Step 2: give Inspector the token</h3>
<p><b>Recommended: an environment variable.</b> The token then never lands in
<code>config.yml</code> or its backups.</p>
<ol class="steps">
<li>In Portainer open <b>Stacks → inspector → Editor</b>.</li>
<li>Check that the compose file has this line under <code>environment:</code> (it's in the
compose file from version 0.9.1 on; add it if your stack is older):
<code>- PORTAINER_API_TOKEN=${PORTAINER_API_TOKEN:-}</code></li>
<li>Under <b>Environment variables</b> click <b>Add an environment variable</b>: name
<code>PORTAINER_API_TOKEN</code>, value the token.</li>
<li>Click <b>Update the stack</b>. Inspector restarts with the token available.</li>
</ol>
<p><b>Alternative: save it in Inspector.</b> In the next step choose <b>Saved in config.yml</b>
and paste the token. It is masked in the dashboard, but anyone who can read Inspector's config
folder (or a backup of it) can read the token.</p>
<h3>Step 3: connect Inspector</h3>
<ol class="steps">
<li>Open <b>Settings → Portainer</b>. Under <b>Add environment</b>, give it a name (your own
label, e.g. <code>home</code>) and fill in base_url, endpoint_id and verify_ssl.</li>
<li><b>API token stored as</b>: <i>Environment variable</i> with the name
<code>PORTAINER_API_TOKEN</code>, or <i>Saved in config.yml</i> with the token.</li>
<li>Click <b>Test connection</b>. It should list the stacks on that endpoint; check that your
app's stack is there. Then <b>Save new environment</b>.</li>
<li>At the top of the page turn <b>enabled</b> on, pick the <b>default_environment</b>, and
<b>Save Portainer</b>.</li>
<li>On each app's container page, set the recovery action and stack name
(<a href="#vpn">section 6</a>). Leave <b>portainer_environment</b> on <i>Default</i> unless
you connected more than one Portainer.</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>
<h3>The other settings on the page</h3>
<table>
<tr><th>Setting</th><th>What it does</th></tr>
<tr><td>default_environment</td><td>Which connection apps use when they don't pick one. With a
single environment you can leave it on Automatic.</td></tr>
<tr><td>stack_redeploy_cooldown_seconds</td><td>Minimum time between two redeploys of the same
stack (default 300). Several apps often share a stack; this stops them triggering a redeploy
each.</td></tr>
<tr><td>post_redeploy_wait_seconds</td><td>How long Inspector waits after a redeploy for the
stack to come up before judging it again (default 90).</td></tr>
</table>
<h3>If Test connection fails</h3>
<table>
<tr><th>Message</th><th>Fix</th></tr>
<tr><td>environment variable … is not set</td><td>The compose line from step 2 is missing, or
the stack wasn't updated after adding the variable.</td></tr>
<tr><td>the API token was rejected</td><td>Token copied wrong, deleted, or from a user without
access. Create a new one.</td></tr>
<tr><td>no stacks found on endpoint N</td><td>Wrong endpoint ID; check the number in the
Portainer address again.</td></tr>
<tr><td>request failed …</td><td>Wrong address or port, <code>http</code> vs <code>https</code>
mixed up, or <code>localhost</code> used instead of the server IP. With https and a
self-signed certificate, turn verify_ssl off.</td></tr>
</table>
</section>
<section id="events">
<h2>8. Events history</h2>
<p>The <b>Events</b> tab is a day-by-day timeline of failures, restarts, recoveries, mutes, and
maintenance. Use <b>Prev day</b> / <b>Next day</b> to look back.</p>
<figure><img src="events.png" alt="Events timeline">
<figcaption>Here a container stopped, Inspector restarted it, and it recovered a few seconds later.</figcaption></figure>
</section>
<section id="settings">
<h2>9. Other settings</h2>
<figure><img src="settings-inspector.png" alt="Inspector loop settings">
<figcaption>Settings → Inspector loop.</figcaption></figure>
<table>
<tr><th>Section</th><th>What it's for</th></tr>
<tr><td>Inspector loop</td><td><b>interval_seconds</b>: how often to check (60 is fine).
<b>dry_run</b>: log and alert but never actually restart; handy while you're trying it out.</td></tr>
<tr><td>Dashboard</td><td>Login username and password for this web UI.</td></tr>
<tr><td>Event history</td><td>How many days of events to keep.</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>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="#portainer">section 7</a>.</td></tr>
</table>
<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>
</section>
<section id="restart">
<h2>10. "Requires restart" fields</h2>
<p>Most changes apply on the next check. Fields marked
<span class="tag t-violet">REQUIRES RESTART</span> (like turning a notification channel or the
dashboard on or off) only take effect after Inspector restarts:</p>
<ol class="steps">
<li>In Portainer go to <b>Containers</b>.</li>
<li>Tick <b>inspector</b> and click <b>Restart</b>.</li>
</ol>
<p>Your settings and history are kept; they live in the folders mounted into the container.</p>
</section>
<section id="trouble">
<h2>11. Troubleshooting</h2>
<table>
<tr><th>Problem</th><th>Fix</th></tr>
<tr><td>Red <b>READ-ONLY</b> badge at the bottom of Settings</td>
<td>Inspector can't save changes. In the stack, make sure the config folder mount does not end in
<code>:ro</code>, then update the stack.</td></tr>
<tr><td>A card says <b>CONTAINER NOT FOUND</b></td>
<td>The name doesn't match a container. Check the exact name in Portainer → Containers, delete
the entry and add it again with the right name.</td></tr>
<tr><td>Logs say <i>Permission denied on /var/run/docker.sock</i></td>
<td>Remove any <code>user:</code> line from the stack so Inspector starts normally (it detects the
socket permissions itself), then update the stack.</td></tr>
<tr><td>Send test fails</td>
<td>The message next to the button says why: wrong URL, wrong token (HTTP 401), or the server can't
be reached from your Docker host.</td></tr>
<tr><td>Forgot the dashboard password</td>
<td>Open <code>config.yml</code> in the stack's config folder (e.g. through your Unraid share),
set <code>dashboard: password:</code> to something new, and restart Inspector.</td></tr>
<tr><td>No alerts arrive</td>
<td>Check the channel is enabled, Send test works, Inspector was restarted after enabling it,
and the container isn't muted or in maintenance.</td></tr>
</table>
</section>
</main>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+56
View File
@@ -0,0 +1,56 @@
"""First-run helpers: find config.yml and write a starter one if it's missing.
Lets Inspector be installed from Portainer with nothing on the host but an
empty folder mounted at /app/config.
"""
from __future__ import annotations
import json
import os
import secrets
from importlib import resources
from pathlib import Path
LEGACY_CONFIG_PATH = Path("/app/config.yml")
DEFAULT_CONFIG_PATH = Path("/app/config/config.yml")
def resolve_config_path(cli_value: str | None, env: dict | None = None) -> Path:
"""--config wins, then $INSPECTOR_CONFIG, then the legacy single-file
mount if present, then the folder mount."""
env = os.environ if env is None else env
if cli_value:
return Path(cli_value)
if env.get("INSPECTOR_CONFIG"):
return Path(env["INSPECTOR_CONFIG"])
if LEGACY_CONFIG_PATH.exists():
return LEGACY_CONFIG_PATH
return DEFAULT_CONFIG_PATH
def write_starter_config(path: Path, env: dict | None = None) -> str | None:
"""Write the starter config to `path`.
Dashboard credentials come from INSPECTOR_USERNAME / INSPECTOR_PASSWORD.
Returns the generated password when none was supplied (so the caller can
log it once), otherwise None.
"""
env = os.environ if env is None else env
username = env.get("INSPECTOR_USERNAME") or "admin"
password = env.get("INSPECTOR_PASSWORD") or ""
generated = None
if not password:
generated = password = secrets.token_urlsafe(12)
template = (resources.files(__package__) / "starter_config.yml").read_text(encoding="utf-8")
# json.dumps gives a valid YAML double-quoted scalar for any input.
text = template.replace("__USERNAME__", json.dumps(username)).replace(
"__PASSWORD__", json.dumps(password)
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
try:
path.chmod(0o600)
except OSError:
pass
return generated
+633 -24
View File
@@ -21,6 +21,8 @@ from .config import (
AppConfig,
ConfigRef,
ContainerConfig,
HttpCheckConfig,
UptimeKumaMonitorConfig,
build_config_from_raw,
write_config_raw,
)
@@ -124,8 +126,11 @@ def _status_snapshot(
) -> list[dict]:
now = utcnow()
rows = []
for i, (name, state) in enumerate(states.items()):
cfg = containers[name]
# list() snapshot: the inspector loop adds/removes entries from another thread.
for i, (name, state) in enumerate(list(states.items())):
cfg = containers.get(name)
if cfg is None: # removed from config since the last loop iteration
continue
cooldown_until = state.cooldown_until
last_restart_at = state.last_restart_at
muted_until = notification_mute.muted_until(name)
@@ -285,6 +290,20 @@ def _load_asset(name: str) -> bytes:
_ICON_PNG = _load_asset("inspector_icon_outline.png")
_GUIDE_NAME_RE = re.compile(r"^[a-z0-9-]+\.(html|png)$")
def _load_guide_file(name: str) -> tuple[bytes | None, str]:
"""Read a file from assets/guide/. Names are whitelisted by pattern, so no
path traversal is possible."""
if not _GUIDE_NAME_RE.match(name):
return None, ""
data = _load_asset(f"guide/{name}")
if not data:
return None, ""
ctype = "text/html; charset=utf-8" if name.endswith(".html") else "image/png"
return data, ctype
# Filename -> Content-Type, for files in src/inspector/assets/ served at /static/<name>.
_STATIC_FILES = {
"icon.png": "image/png",
@@ -548,6 +567,13 @@ a.signout:hover { color: var(--ink); border-color: var(--muted); }
margin-top: 0.1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.settings-rail a:hover:not(.active) { background: #0f1829; color: var(--ink); }
/* Per-container entries sit under "All containers": indented, one line each. */
.settings-rail a.rail-child { margin-left: 1.1rem; padding-top: 0.32rem; padding-bottom: 0.32rem; align-items: center; }
.settings-rail a.rail-child .rail-ico { font-size: 0.7rem; }
.settings-rail a.rail-child .rail-text { display: flex; align-items: baseline; min-width: 0; white-space: nowrap; }
.settings-rail a.rail-child .rail-name { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.settings-rail a.rail-child .rail-inline-sub { flex-shrink: 0; }
.settings-rail a .rail-inline-sub { font-size: 0.7rem; color: var(--muted); font-weight: 400; margin-left: 0.4rem; }
.settings-rail a.active {
background: rgba(96,165,250,0.12); color: var(--blue); font-weight: 600;
}
@@ -571,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 {
@@ -814,6 +853,7 @@ def _page_head(refresh: bool, active: str) -> str:
f' <a href="/" class="{status_class}">Status</a>\n'
f' <a href="/events" class="{events_class}">Events</a>\n'
f' <a href="/settings" class="{settings_class}">Settings</a>\n'
' <a href="/guide/">Help</a>\n'
" </div>\n"
f' <div class="meta"><span class="meta-text">{meta_text}</span><a class="signout" href="/logout">Sign out</a></div>\n'
"</header>\n"
@@ -1161,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]
@@ -1184,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()
@@ -1246,12 +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", "")
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, "color", _form_str(form, "color", "").strip())
if form.get("restart_on_present"): # only our own form carries this field
_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}")
@@ -1293,10 +1512,12 @@ def _apply_container_create(config_ref: ConfigRef, form: dict) -> str:
"grace_period_seconds": _form_int(form, "grace_period_seconds", 120),
"max_restarts_per_hour": _form_int(form, "max_restarts_per_hour", 3),
"color": _form_str(form, "color", ""),
"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]
_apply_container_advanced(raw, name, cfg, form)
containers[name] = cfg
raw["containers"] = containers
@@ -1401,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 = (
'<span class="restart-flag" title="takes effect after Inspector restart">'
"requires restart</span>"
@@ -1461,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).
# Groups show up as uppercase headers in the left rail.
def _subtitle_inspector(app: AppConfig) -> str:
@@ -1499,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"),
@@ -1507,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}
@@ -1548,14 +1878,14 @@ def _render_settings_rail(
active = "active" if c.name == active_container else ""
enabled_sub = "enabled" if c.enabled else "disabled"
parts.append(
f'<a class="{active}" href="/containers/{_escape(c.name)}">'
f'<a class="rail-child {active}" href="/containers/{_escape(c.name)}">'
f'<span class="rail-ico" style="color:{_escape(color)};">●</span>'
f'<span class="rail-text">{_escape(c.name)}'
f'<span class="rail-sub">{enabled_sub} · max {c.max_error_count} err</span>'
f'<span class="rail-text"><span class="rail-name">{_escape(c.name)}</span>'
f'<span class="rail-inline-sub">{enabled_sub}</span>'
f"</span></a>\n"
)
parts.append(
'<a href="/containers/new" style="color:var(--blue); font-weight:600;">'
'<a class="rail-child" href="/containers/new" style="color:var(--blue); font-weight:600;">'
'<span class="rail-ico">+</span>'
'<span class="rail-text">Add container</span></a>\n'
)
@@ -1593,18 +1923,18 @@ def _settings_form_with_test(section: str, title: str, body: str, test_row: str,
_TEST_NOTIFY_JS = """
<script>
window.InspectorTest = function(btn, channel) {
window.InspectorTest = function(btn, channel, endpoint) {
var form = btn.closest('form');
var resultEl = btn.parentElement.querySelector('.test-result');
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'); }
else { data.append(el.name, el.value); }
});
btn.disabled = true;
resultEl.className = 'test-result';
resultEl.textContent = 'Sending test...';
fetch('/api/test-notify/' + channel, {
resultEl.textContent = endpoint ? 'Testing...' : 'Sending test...';
fetch((endpoint || '/api/test-notify/') + channel, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: data.toString(),
@@ -1613,7 +1943,7 @@ window.InspectorTest = function(btn, channel) {
btn.disabled = false;
if (j.ok) {
resultEl.className = 'test-result ok';
resultEl.textContent = '✓ Sent. Check your device.';
resultEl.textContent = '✓ ' + (j.message || 'Sent. Check your device.');
} else {
resultEl.className = 'test-result err';
resultEl.textContent = '✗ ' + (j.error || 'Failed');
@@ -1798,9 +2128,137 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
+ _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>"
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="Open the environment from Portainer's Home page; the number after #!/ in the address (often 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="Add it to the Inspector stack's environment variables; the compose file must pass it in. Step by step: Help → Portainer stack redeploy, step 2.")
+ "</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:
if not flash:
return ""
@@ -1827,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("</section></div>\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))
@@ -1855,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'<span style="flex-basis:100%;">Extras: <b>{_escape(extras)}</b></span>' if extras else ""
parts.append(
f'<a class="container-tile" href="/containers/{_escape(c.name)}" '
f'style="--col:{_escape(color)}">'
@@ -1866,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>Cooldown: <b>{c.cooldown_seconds}s</b></span>'
f'<span style="flex-basis:100%; margin-top:0.2rem;">Restart on: <b>{_escape(restart_on)}</b></span>'
"</div></a>\n"
+ extras_html
+ "</div></a>\n"
)
parts.append("</div>\n")
parts.append("</section></div>\n")
@@ -1929,9 +2405,113 @@ _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]
disabled = {c.name for c in app.containers if not c.enabled}
dep_value = getattr(cfg, "depends_on", "") or ""
dep_options = [("", "— none —")] + [(n, n + (" (disabled)" if n in disabled else "")) for n in others]
if dep_value and dep_value not in others:
dep_options.append((dep_value, f"{dep_value} (not configured)"))
dep_warn = ""
if dep_value in disabled:
dep_warn = _warn(f"{_escape(dep_value)} is disabled, so Inspector can't see its status and won't wait for it. "
"Enable it to make this dependency count.")
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>. "
"Also works for any other dependency, like an app and its database.")
+ dep_warn
+ _settings_field("vpn_routed", "vpn_routed", "on" if getattr(cfg, "vpn_routed", False) else "", kind="switch",
hint="Only affects logging: says in the log when recovery waits on the VPN container. The waiting itself comes from depends_on.")
+ "</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(
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 []
@@ -1987,8 +2567,13 @@ def _render_container_form(
+ _settings_field("max_restarts_per_hour", "max_restarts_per_hour",
str(getattr(cfg, "max_restarts_per_hour", 3)), kind="number",
hint="Hourly cap — Inspector pauses restarts after this many")
+ _settings_field("maintenance_window", "maintenance_window",
"on" if getattr(cfg, "maintenance_window", False) else "", kind="switch",
hint="Skip this container during the daily maintenance window (Settings → Maintenance)",
requires_restart=True)
+ _render_color_input("color", getattr(cfg, "color", ""))
+ restart_on_html
+ _render_container_advanced(name, cfg, app)
)
delete_btn = ""
if show_delete:
@@ -2024,6 +2609,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")]
@@ -2033,7 +2619,7 @@ def _render_container_edit_page(app: AppConfig, config_ref: ConfigRef, name: str
parts.append('<section class="settings-pane">')
parts.append(
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("</section></div>\n")
@@ -2045,6 +2631,7 @@ def _render_container_edit_page(app: AppConfig, config_ref: ConfigRef, name: str
class _NewContainerDefaults:
name = ""
enabled = True
maintenance_window = False
max_error_count = 5
cooldown_seconds = 300
grace_period_seconds = 120
@@ -2058,6 +2645,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")]
@@ -2068,9 +2656,7 @@ def _render_container_new_page(app: AppConfig, config_ref: ConfigRef, csrf_token
parts.append(
'<h3 class="pane-title">New container</h3>'
'<p class="pane-sub">Add a container to <code>config.yml</code>. '
"The new entry is picked up automatically on the next polling cycle; "
"advanced fields (Portainer stack, http_check, uptime_kuma, depends_on, "
"vpn_routed) remain YAML-only for now.</p>"
"The new entry is picked up automatically on the next polling cycle.</p>"
)
parts.append(form_html)
parts.append("</section></div>\n")
@@ -2243,6 +2829,19 @@ class _Handler(BaseHTTPRequestHandler):
events = []
body = _render_events_page(events, app.database.enabled, selected_day, colors)
self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body)
elif path == "/guide":
# Trailing slash so the guide's relative image paths resolve under /guide/.
self.send_response(HTTPStatus.MOVED_PERMANENTLY)
self.send_header("Location", "/guide/")
self.send_header("Content-Length", "0")
self.end_headers()
elif path.startswith("/guide/"):
name = path[len("/guide/"):] or "guide.html"
data, ctype = _load_guide_file(name)
if data is None:
self._send_bytes(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"not found")
return
self._send_bytes(HTTPStatus.OK, ctype, data)
elif path == "/api/events":
selected_day = _parse_date_param(query.get("date", [None])[0])
if app.database.enabled:
@@ -2397,6 +2996,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:
@@ -2408,8 +3009,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
@@ -2480,6 +3082,13 @@ class _Handler(BaseHTTPRequestHandler):
self._send_bytes(HTTPStatus.OK, "application/json", body)
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")
+58 -16
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse
import logging
import os
import signal
import sys
import time
@@ -9,6 +10,7 @@ from datetime import datetime
from pathlib import Path
from . import __version__
from .bootstrap import resolve_config_path, write_starter_config
from .config import AppConfig, ConfigRef, ContainerConfig, load_config, load_raw
from .dashboard import DashboardServer
from .docker_client import build_client, get_container_status
@@ -291,8 +293,10 @@ def _check_one(
if ok:
dep = container_config.depends_on
if dep:
dep_status = states[dep].current_status if dep in states else None
# Only wait on a dependency Inspector is watching. A disabled one has
# no state, and waiting on it would block recovery forever.
if dep and dep in states:
dep_status = states[dep].current_status
if dep_status not in (HEALTHY, STARTING, NO_HEALTH_CHECK):
if container_config.vpn_routed:
log.info(
@@ -398,8 +402,9 @@ def run_loop(config_ref: ConfigRef) -> int:
except Exception as exc:
if "Permission denied" in str(exc):
log.critical(
"Permission denied on /var/run/docker.sock. Set DOCKER_GID to "
"the host docker group GID: stat -c '%g' /var/run/docker.sock"
"Permission denied on /var/run/docker.sock. Start the container "
"as root (the default) so it can detect the socket's group, or set "
"group_add to the socket's GID: stat -c '%g' /var/run/docker.sock"
)
else:
log.critical("Cannot reach Docker daemon: %s", exc)
@@ -414,6 +419,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(
@@ -437,18 +443,31 @@ def run_loop(config_ref: ConfigRef) -> int:
notifier = _build_notifier(app)
# Rebuild enabled_configs / check_order from current config so edits
# to per-container settings apply next cycle. Containers added via
# the settings page are logged as 'requires restart' because the
# states dict is built once at startup.
# to per-container settings apply next cycle. Containers added (or
# re-enabled) from the settings page get a fresh state here; `states`
# is shared with the dashboard, so it is updated in place.
current_enabled = {c.name: c for c in app.containers if c.enabled}
new_names = current_enabled.keys() - states.keys()
if new_names:
log.warning(
"Containers added in config require Inspector restart to begin monitoring: %s",
sorted(new_names),
)
enabled_configs = {n: c for n, c in current_enabled.items() if n in states}
check_order = _topological_order(enabled_configs)
for new_name in new_names:
states[new_name] = ContainerState(name=new_name)
name_width = max(name_width, *(len(n) for n in new_names))
log.info("Now watching newly added container(s): %s", sorted(new_names))
for gone in states.keys() - current_enabled.keys():
states.pop(gone, None)
log.info("Stopped watching container (removed or disabled): %s", gone)
if current_enabled != enabled_configs:
# Only re-order (and re-log ordering warnings) when the config changed.
check_order = _topological_order(current_enabled)
enabled_configs = current_enabled
# 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:
@@ -495,14 +514,37 @@ def run_loop(config_ref: ConfigRef) -> int:
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="inspector", description="Docker container health watcher")
parser.add_argument("--config", "-c", default="/app/config.yml", help="Path to config.yml")
parser.add_argument(
"--config", "-c", default=None,
help="Path to config.yml (default: $INSPECTOR_CONFIG, /app/config.yml if present, "
"else /app/config/config.yml)",
)
parser.add_argument("--version", action="version", version=f"inspector {__version__}")
args = parser.parse_args(argv)
config_path = Path(args.config)
config_path = resolve_config_path(args.config)
if not config_path.exists():
print(f"config file not found: {config_path}", file=sys.stderr)
# First run with an empty config folder: write a starter config so the
# dashboard comes up and everything else can be set from the web UI.
try:
generated_password = write_starter_config(config_path)
except OSError as exc:
print(
f"config file not found: {config_path} (and could not create a starter one: {exc})",
file=sys.stderr,
)
return 2
print(f"Created starter config at {config_path}", file=sys.stderr)
if generated_password:
print(
"=" * 64 + "\n"
" Inspector dashboard login (generated on first start)\n"
f" username: {os.environ.get('INSPECTOR_USERNAME') or 'admin'}\n"
f" password: {generated_password}\n"
" Change it under Settings -> Dashboard.\n"
+ "=" * 64,
file=sys.stderr,
)
app = load_config(config_path)
raw = load_raw(config_path)
+64
View File
@@ -0,0 +1,64 @@
# Inspector configuration.
#
# Inspector wrote this starter file on first start. You can edit everything
# from the web UI (Settings), or by hand. Containers are added from
# Settings -> Containers -> Add container.
inspector:
interval_seconds: 60
log_level: INFO
dry_run: false
log_dir: /app/logs
log_filename: inspector.log
log_retention_days: 30
heartbeat: false
notifications:
# Gotify: https://gotify.net
gotify:
enabled: false
url: ""
token: ""
priority: 5
# Pushover: https://pushover.net
pushover:
enabled: false
token: ""
user_key: ""
priority: 5
device: ""
sound: ""
# Event history (powers the Events page and the health summary digest).
database:
enabled: true
path: /app/data/events.db
retention_days: 30
health_summary:
enabled: false
interval_hours: 24
priority: 3
maintenance:
enabled: false
start: "00:00"
end: "06:00"
dir: /app/data/maintenance
max_hours: 8
notification_mute:
enabled: true
dir: /app/data/muted
default_hours: 12
dashboard:
enabled: true
host: 0.0.0.0
port: 8080
username: __USERNAME__
password: __PASSWORD__
# Containers Inspector watches. Add them from the web UI.
containers:
+12 -6
View File
@@ -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": "..."}]}],