diff --git a/CHANGELOG.md b/CHANGELOG.md index ede49a9..cd990a9 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,47 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html --- +## [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 diff --git a/Dockerfile b/Dockerfile index b884186..081dc68 100755 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/INSTALL.md b/INSTALL.md index ef6ccec..36a40a7 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,70 +1,135 @@ -# 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 + ```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} + ``` -# 3. Set your Docker group GID so the container can read the socket. -echo "DOCKER_GID=$(getent group docker | cut -d: -f3)" > .env +## 2. Set the environment variables -# 4. Pull and run -docker compose pull -docker compose up -d -docker compose logs -f inspector +Scroll down to **Environment variables**. Click **Advanced mode** and paste, +then edit the values: + +``` +INSPECTOR_DATA=/mnt/user/appdata/inspector +INSPECTOR_PASSWORD=pick-a-password +TZ=America/Los_Angeles ``` -If `dashboard.enabled: true` in your config, visit -`http://:8080/` after startup to see the dashboard, and -`http://:8080/settings` to edit the config from the browser. +| 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`) | -## Pinning a version +## 3. Deploy and sign in -Prefer an immutable tag over `latest` in production: +1. Click **Deploy the stack**. +2. Open `http://: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). -```bash -# .env -INSPECTOR_TAG=0.7.0 -``` +That's it. Inspector picks up new containers on its next check (within a minute). -Images are published at `gitea.euronvault.com/euronvault/inspector`. +--- -## Upgrading +## 4. Updating -```bash -docker compose pull -docker compose up -d -``` +1. **Stacks → inspector → Editor**. +2. Click **Update the stack** and turn on **Re-pull image and redeploy**. +3. Confirm. -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). +Your settings and history live in `INSPECTOR_DATA`, so updates never touch them. -## Uninstall +**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). -```bash -docker compose down -rm -rf logs data config.yml compose.yml .env -``` +**Rolling back.** Set `INSPECTOR_TAG` to the previous version and update the +stack with **Re-pull image and redeploy** on. -Inspector doesn't store anything outside the directory it was started -from. +## 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 `/config/`, replace the stack with +the compose above, and update the stack. diff --git a/README.md b/README.md index e1a1b0e..a03378b 100755 --- a/README.md +++ b/README.md @@ -1,139 +1,104 @@ -# Inspector +

+ Inspector logo +

-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. +

Inspector

-Built for home-lab and small-fleet operators who want "it just works" -container recovery without standing up a full observability stack. +

Monitor. Restart. Report.
+A small Docker watchdog for home servers: it watches the containers you pick, +restarts them when they break, and tells your phone about it.

-## What Inspector does +

+ Install with Portainer · + How-to guide · + Changelog +

-- 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..max_error_count` / `cooldown_seconds` / - `grace_period_seconds` / `max_restarts_per_hour` — the restart knobs. -- `containers..restart_on` — which statuses trigger a restart - (leave empty for observe-only). -- `containers..http_check` — opt-in HTTP ping. -- `containers..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 live in `config.yml` (in your +`INSPECTOR_DATA/config` folder) rather than the dashboard; see +[`config.example.yml`](./config.example.yml) for every option with comments. -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. +- **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. -``` -🚨 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 diff --git a/compose.yml b/compose.yml index 1f5e391..487e495 100755 --- a/compose.yml +++ b/compose.yml @@ -1,29 +1,38 @@ -# 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. +# +# 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} diff --git a/config.example.yml b/config.example.yml index b7f77be..59b1df2 100755 --- a/config.example.yml +++ b/config.example.yml @@ -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 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..1d91415 --- /dev/null +++ b/docker-entrypoint.sh @@ -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" "$@" diff --git a/docs/logo.png b/docs/logo.png new file mode 100755 index 0000000..96d2793 Binary files /dev/null and b/docs/logo.png differ diff --git a/src/inspector/__init__.py b/src/inspector/__init__.py index ed9d4d8..777f190 100755 --- a/src/inspector/__init__.py +++ b/src/inspector/__init__.py @@ -1 +1 @@ -__version__ = "0.7.4" +__version__ = "0.8.0" diff --git a/src/inspector/assets/guide/container-edit.png b/src/inspector/assets/guide/container-edit.png new file mode 100755 index 0000000..cdeb73e Binary files /dev/null and b/src/inspector/assets/guide/container-edit.png differ diff --git a/src/inspector/assets/guide/container-new.png b/src/inspector/assets/guide/container-new.png new file mode 100755 index 0000000..d0775da Binary files /dev/null and b/src/inspector/assets/guide/container-new.png differ diff --git a/src/inspector/assets/guide/containers.png b/src/inspector/assets/guide/containers.png new file mode 100755 index 0000000..769c0bf Binary files /dev/null and b/src/inspector/assets/guide/containers.png differ diff --git a/src/inspector/assets/guide/events.png b/src/inspector/assets/guide/events.png new file mode 100755 index 0000000..bab6897 Binary files /dev/null and b/src/inspector/assets/guide/events.png differ diff --git a/src/inspector/assets/guide/guide.html b/src/inspector/assets/guide/guide.html new file mode 100755 index 0000000..89db1bb --- /dev/null +++ b/src/inspector/assets/guide/guide.html @@ -0,0 +1,272 @@ + + + + + +Inspector — How to use + + + +
+
InspectorHow to use
+ Open dashboard +
+ +
+ + +
+

Using Inspector

+

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.

+ +
+

1. First login

+

When Inspector starts for the first time it creates its config file and a dashboard login.

+
    +
  1. If you set INSPECTOR_PASSWORD in the Portainer stack, sign in with + admin (or your INSPECTOR_USERNAME) and that password.
  2. +
  3. If you didn't, Inspector generated a password. In Portainer open + Containers → inspector → Logs. Near the top you'll see a box labeled + Inspector dashboard login with the username and password.
  4. +
  5. Open http://<your-server>:8080 and sign in.
  6. +
  7. Change the password under Settings → Dashboard if you like.
  8. +
+
+ +
+

2. Add your containers

+

Inspector only watches containers you add. Go to Settings and click ++ Add container at the bottom of the left menu (or + New container on the +All containers page).

+
New container form +
The New container form.
+
    +
  1. name: the container's name exactly as Portainer shows it + (for example sonarr). If it doesn't match, the card shows + CONTAINER NOT FOUND.
  2. +
  3. Leave enabled on.
  4. +
  5. Under Restart when status is… click Recommended + (restarts on UNHEALTHY and NOT RUNNING), or + Observe only if you just want alerts without restarts.
  6. +
  7. Click Create container. Inspector starts watching it on its next check + (within a minute by default). No restart needed.
  8. +
+

What the numbers mean

+ + + + + + + + +
FieldMeaningDefault
max_error_countHow many bad checks in a row before Inspector restarts the container. Stops one blip from causing a restart.5
cooldown_secondsMinimum time between two restarts of the same container.300
grace_period_secondsAfter a restart, ignore failures for this long so the app can boot.120
max_restarts_per_hourSafety cap. After this many restarts in an hour Inspector stops restarting and alerts you instead.3
maintenance_windowFollow the daily maintenance window from Settings → Maintenance.off
colorCard color on the dashboard. Pick a swatch or leave blank for automatic.auto
+
With the default 60-second check interval and max_error_count 5, a +container has to be broken for about five minutes before Inspector restarts it. Lower the count +for faster recovery.
+
+ +
+

3. The Status page

+
Status page +
Status: one card per watched container.
+

The top row counts containers, healthy ones, ones with issues, and ones in maintenance. +Each card shows:

+
    +
  • Status badge: what Docker reports right now, e.g. + HEALTHY NOT RUNNING + NO HEALTH CHECK (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).
  • +
  • Errors bar: bad checks in a row out of max_error_count. When it fills, + Inspector restarts the container.
  • +
  • Cooldown until / Last restart: when Inspector last restarted it and when it's allowed to + again.
  • +
  • Observe only: shown when the container has no restart triggers; Inspector alerts but + never restarts it.
  • +
  • Enter maintenance: pause Inspector for that container while you work on it + (updates, migrations). Click again to exit.
  • +
  • Mute notifications: keep watching and restarting, but stop alerts for a while + (12 hours by default).
  • +
+

The page refreshes itself every 30 seconds.

+
+ +
+

4. Notifications

+

Inspector can alert you through Gotify, +Pushover, or both. Set them up under +Settings → Notifications.

+
Notifications settings +
Each channel has its own priority reference, a Send test button, and a Save button.
+
    +
  1. Turn on enabled for the channel you use.
  2. +
  3. Gotify: enter your server URL and an application token (create one in Gotify + under Apps). Pushover: enter your application API token and your user key.
  4. +
  5. Click Send test. You should get a message on your phone within seconds. If it fails, + the reason appears next to the button.
  6. +
  7. Click Save, then restart Inspector once (see section 8), + because turning a channel on or off is a "requires restart" field.
  8. +
+

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.

+
+ +
+

5. Managing containers

+

Settings → All containers shows every watched container as a tile. Click a tile (or a +name in the left menu) to edit it.

+
All containers grid +
All containers.
+
Container settings +
Editing one container. Each restart trigger is a card you click on or off.
+

Changes apply on the next check. Delete container stops Inspector watching it; it +does not touch the container itself.

+

Restart triggers

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

6. Events history

+

The Events tab is a day-by-day timeline of failures, restarts, recoveries, mutes, and +maintenance. Use Prev day / Next day to look back.

+
Events timeline +
Here a container stopped, Inspector restarted it, and it recovered a few seconds later.
+
+ +
+

7. Other settings

+
Inspector loop settings +
Settings → Inspector loop.
+ + + + + + + + +
SectionWhat it's for
Inspector loopinterval_seconds: how often to check (60 is fine). + dry_run: log and alert but never actually restart; handy while you're trying it out.
DashboardLogin username and password for this web UI.
Event historyHow many days of events to keep.
Health summaryA periodic digest (e.g. daily) of what happened, sent through your notification channels.
MaintenanceA daily quiet window (e.g. 02:00–05:00) so nightly backups or updates don't trigger restarts. Turn on maintenance_window on each container that should follow it.
Notification muteTurns the per-container Mute notifications buttons on or off, and sets how long a mute lasts.
+

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

+
+ +
+

8. "Requires restart" fields

+

Most changes apply on the next check. Fields marked +REQUIRES RESTART (like turning a notification channel or the +dashboard on or off) only take effect after Inspector restarts:

+
    +
  1. In Portainer go to Containers.
  2. +
  3. Tick inspector and click Restart.
  4. +
+

Your settings and history are kept; they live in the folders mounted into the container.

+
+ +
+

9. Troubleshooting

+ + + + + + + + + + + + + + +
ProblemFix
Red READ-ONLY badge at the bottom of SettingsInspector can't save changes. In the stack, make sure the config folder mount does not end in + :ro, then update the stack.
A card says CONTAINER NOT FOUNDThe name doesn't match a container. Check the exact name in Portainer → Containers, delete + the entry and add it again with the right name.
Logs say Permission denied on /var/run/docker.sockRemove any user: line from the stack so Inspector starts normally (it detects the + socket permissions itself), then update the stack.
Send test failsThe message next to the button says why: wrong URL, wrong token (HTTP 401), or the server can't + be reached from your Docker host.
Forgot the dashboard passwordOpen config.yml in the stack's config folder (e.g. through your Unraid share), + set dashboard: password: to something new, and restart Inspector.
No alerts arriveCheck the channel is enabled, Send test works, Inspector was restarted after enabling it, + and the container isn't muted or in maintenance.
+
+
+
+ + diff --git a/src/inspector/assets/guide/settings-inspector.png b/src/inspector/assets/guide/settings-inspector.png new file mode 100755 index 0000000..3d09847 Binary files /dev/null and b/src/inspector/assets/guide/settings-inspector.png differ diff --git a/src/inspector/assets/guide/settings-notifications.png b/src/inspector/assets/guide/settings-notifications.png new file mode 100755 index 0000000..4b102db Binary files /dev/null and b/src/inspector/assets/guide/settings-notifications.png differ diff --git a/src/inspector/assets/guide/status.png b/src/inspector/assets/guide/status.png new file mode 100755 index 0000000..e5721be Binary files /dev/null and b/src/inspector/assets/guide/status.png differ diff --git a/src/inspector/bootstrap.py b/src/inspector/bootstrap.py new file mode 100755 index 0000000..bba447e --- /dev/null +++ b/src/inspector/bootstrap.py @@ -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 diff --git a/src/inspector/dashboard.py b/src/inspector/dashboard.py index 8c2a8f9..6a8f665 100755 --- a/src/inspector/dashboard.py +++ b/src/inspector/dashboard.py @@ -124,8 +124,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 +288,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/. _STATIC_FILES = { "icon.png": "image/png", @@ -821,6 +838,7 @@ def _page_head(refresh: bool, active: str) -> str: f' Status\n' f' Events\n' f' Settings\n' + ' Help\n' " \n" f'
{meta_text}Sign out
\n' "\n" @@ -1254,6 +1272,8 @@ def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> N 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", "") + if form.get("restart_on_present"): # only our own form carries this field + cfg["maintenance_window"] = _form_bool(form, "maintenance_window") restart_on = form.get("restart_on", []) if restart_on or form.get("restart_on_present"): cfg["restart_on"] = [s.upper() for s in restart_on] @@ -1300,6 +1320,7 @@ 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"): @@ -1994,6 +2015,10 @@ 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 ) @@ -2052,6 +2077,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 @@ -2250,6 +2276,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: diff --git a/src/inspector/main.py b/src/inspector/main.py index c0c55a8..302686f 100755 --- a/src/inspector/main.py +++ b/src/inspector/main.py @@ -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 @@ -398,8 +400,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) @@ -437,17 +440,20 @@ 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} + 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) + enabled_configs = current_enabled check_order = _topological_order(enabled_configs) uptime_kuma_snapshot = uptime_kuma_client.fetch() @@ -495,14 +501,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) - return 2 + # 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) diff --git a/src/inspector/starter_config.yml b/src/inspector/starter_config.yml new file mode 100755 index 0000000..5296f93 --- /dev/null +++ b/src/inspector/starter_config.yml @@ -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: