Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd78835f4a | ||
|
|
7013a13cba | ||
|
|
c603e86e75 |
No files matched your search
@@ -7,6 +7,82 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
|
||||
|
||||
---
|
||||
|
||||
## [0.9.0] — 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Every container option is now in the dashboard.** The container page has three
|
||||
new groups:
|
||||
- **Reachability**: HTTP check (URL, timeout, expected status) and Uptime Kuma
|
||||
monitor.
|
||||
- **Dependencies**: `depends_on` (pick from your containers) and `vpn_routed`.
|
||||
- **Recovery**: `recovery_action` with plain-language descriptions, plus the
|
||||
Portainer environment and stack name for stack redeploys.
|
||||
- **Integrations settings.** New **Uptime Kuma** and **Portainer** pages under
|
||||
Settings. Portainer environments can be added, edited, renamed (containers
|
||||
that use them are updated) and deleted (refused while a container uses one).
|
||||
The API token can be an environment variable name (recommended) or a masked
|
||||
value saved in `config.yml`.
|
||||
- **Test connection** buttons for Uptime Kuma (lists monitors) and each Portainer
|
||||
environment (lists stacks on the endpoint), using the values in the form even
|
||||
before saving.
|
||||
- The All containers grid shows each container's extras (HTTP check, Uptime Kuma,
|
||||
dependency, recovery action).
|
||||
- Guide: new sections on reachability checks and apps behind a VPN container.
|
||||
|
||||
### Changed
|
||||
|
||||
- Uptime Kuma and Portainer changes take effect on the next check; Inspector
|
||||
rebuilds those clients when their settings change instead of needing a restart.
|
||||
- Saving a container form unchanged now leaves `config.yml` byte-identical. Blank
|
||||
optional fields are removed instead of written as empty values (`color: ''`,
|
||||
`maintenance_window: false`), and an unchanged `restart_on` list keeps its
|
||||
order and the comments after it. An explicit `recovery_action: restart_container`
|
||||
is kept.
|
||||
|
||||
---
|
||||
|
||||
## [0.8.0] — 2026-10-27
|
||||
|
||||
### Added
|
||||
|
||||
- **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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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://<host>:8080/` after startup to see the dashboard, and
|
||||
`http://<host>: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://<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).
|
||||
|
||||
```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 `<INSPECTOR_DATA>/config/`, replace the stack with
|
||||
the compose above, and update the stack.
|
||||
@@ -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.
|
||||

|
||||
|
||||
## 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 |
|
||||
| ------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
|  |  |
|
||||
|
||||
### Dashboard and settings page
|
||||
| Notifications | Events history |
|
||||
| ------------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
|  |  |
|
||||
|
||||
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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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" "$@"
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -1 +1 @@
|
||||
__version__ = "0.7.4"
|
||||
__version__ = "0.9.0"
|
||||
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,314 @@
|
||||
<!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="#events">6. Events history</a>
|
||||
<a href="#settings">7. Other settings</a>
|
||||
<a href="#restart">8. "Requires restart" fields</a>
|
||||
<a href="#trouble">9. 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://<your-server>: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 8</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>
|
||||
|
||||
<h3>Apps behind a VPN container</h3>
|
||||
<p>If an app shares a VPN container's network (<code>network_mode: container:vpn</code> or similar), set these on the app's page:</p>
|
||||
<ol class="steps">
|
||||
<li><b>Dependencies → depends_on</b>: pick the VPN container, and turn on <b>vpn_routed</b>.
|
||||
Inspector checks the VPN first and waits for it to recover before touching the app.</li>
|
||||
<li><b>Recovery → recovery_action</b>: choose <b>Restart, then redeploy stack if that fails</b>
|
||||
and enter the <b>stack name</b> exactly as Portainer shows it. When the VPN container gets
|
||||
recreated, a plain restart of the app fails; redeploying the stack reconnects it.</li>
|
||||
</ol>
|
||||
<p>Stack redeploys need the Portainer connection in <b>Settings → Portainer</b>:</p>
|
||||
<ol class="steps">
|
||||
<li>In Portainer, open <b>My account → Access tokens</b> and create a token.</li>
|
||||
<li>Easiest and safest: add it to the Inspector stack as an environment variable (for example
|
||||
<code>PORTAINER_API_TOKEN</code>) and redeploy Inspector. Or choose <b>Saved in config.yml</b>
|
||||
and paste it in.</li>
|
||||
<li>In <b>Settings → Portainer</b>, add an environment with Portainer's URL and endpoint ID
|
||||
(usually 1 or 2; it's the number in the URL when you open the environment in Portainer).
|
||||
Click <b>Test connection</b>: it should list your stacks.</li>
|
||||
<li>Turn Portainer on and save.</li>
|
||||
</ol>
|
||||
<figure><img src="settings-portainer.png" alt="Portainer settings">
|
||||
<figcaption>Settings → Portainer with one environment; the token comes from an environment variable.</figcaption></figure>
|
||||
</section>
|
||||
|
||||
<section id="events">
|
||||
<h2>6. 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>7. 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="#containers">Apps behind a VPN container</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>8. "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>9. 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>
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 33 KiB |
@@ -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
|
||||
@@ -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",
|
||||
@@ -578,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 {
|
||||
@@ -821,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"
|
||||
@@ -1168,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]
|
||||
|
||||
@@ -1191,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()
|
||||
@@ -1253,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}")
|
||||
|
||||
@@ -1300,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
|
||||
|
||||
@@ -1408,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>"
|
||||
@@ -1468,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:
|
||||
@@ -1506,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"),
|
||||
@@ -1514,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}
|
||||
|
||||
@@ -1600,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(),
|
||||
@@ -1620,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');
|
||||
@@ -1805,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="Portainer → Environments; the number in the URL (usually 1 or 2)")
|
||||
+ "</div>"
|
||||
+ _settings_field("base_url", "base_url", env.base_url if env else "",
|
||||
hint="e.g. http://192.168.1.10:9000 or https://portainer.example.com")
|
||||
+ _settings_select(
|
||||
"API token stored as", "token_source", source,
|
||||
[("env", "Environment variable (recommended)"), ("inline", "Saved in config.yml")],
|
||||
hint="Create a token in Portainer → My account → Access tokens.",
|
||||
onchange="InspectorTokenSrc(this)",
|
||||
)
|
||||
+ f'<div class="token-src" data-src="env"{" hidden" if inline else ""}>'
|
||||
+ _settings_field("environment variable name", "api_token_env",
|
||||
(env.api_token_env if env else "") or "PORTAINER_API_TOKEN",
|
||||
hint="Set this variable on the Inspector container (Portainer → stack → Environment variables), then redeploy Inspector.")
|
||||
+ "</div>"
|
||||
+ f'<div class="token-src" data-src="inline"{"" if inline else " hidden"}>'
|
||||
+ _settings_secret_field("api_token", "api_token", bool(env and env.api_token))
|
||||
+ "</div>"
|
||||
+ _settings_field("verify_ssl", "verify_ssl", "on" if (env.verify_ssl if env else False) else "", kind="switch",
|
||||
hint="Turn off for Portainer's default self-signed certificate or plain http")
|
||||
+ _test_row("portainer", "Lists this environment's stacks using the values above (even if unsaved).")
|
||||
)
|
||||
delete = ""
|
||||
if env is not None:
|
||||
delete = (
|
||||
'<button type="submit" name="delete" value="1" class="btn-danger" '
|
||||
f"onclick=\"return confirm('Delete Portainer environment {_escape(env_name)}?');\">Delete</button>"
|
||||
)
|
||||
label = "environment" if env is not None else "new environment"
|
||||
return (
|
||||
'<form method="post" action="/settings/portainer_env" class="form-group">'
|
||||
f'<h4>{_escape(env_name) if env is not None else "Add environment"}</h4>'
|
||||
f'<input type="hidden" name="csrf" value="{_escape(csrf_token)}">'
|
||||
f"{body}"
|
||||
f'<div class="form-actions">{delete}'
|
||||
'<button class="btn" type="submit" style="background:var(--blue); color:#06111f; border:none; '
|
||||
'border-radius:8px; padding:0.55rem 1rem; font-weight:700; cursor:pointer; font-family:inherit;">'
|
||||
f"Save {label}</button></div></form>"
|
||||
)
|
||||
|
||||
|
||||
def _portainer_section_body(app: AppConfig, csrf_token: str) -> str:
|
||||
pt = app.integrations.portainer
|
||||
env_options = [("", "Automatic (only if one environment exists)")] + [(n, n) for n in pt.environments]
|
||||
body = (
|
||||
_settings_field("enabled", "enabled", "on" if pt.enabled else "", kind="switch")
|
||||
+ _settings_select("default_environment", "default_environment", pt.default_environment, env_options,
|
||||
hint="Used by containers that don't pick an environment")
|
||||
+ '<div class="grid-2">'
|
||||
+ _settings_field("stack_redeploy_cooldown_seconds", "stack_redeploy_cooldown_seconds",
|
||||
str(pt.stack_redeploy_cooldown_seconds), kind="number",
|
||||
hint="Minimum seconds between redeploys of the same stack")
|
||||
+ _settings_field("post_redeploy_wait_seconds", "post_redeploy_wait_seconds",
|
||||
str(pt.post_redeploy_wait_seconds), kind="number",
|
||||
hint="Wait this long after a redeploy before checking again")
|
||||
+ "</div>"
|
||||
)
|
||||
users = [c.name for c in app.containers if c.recovery_action in _PORTAINER_ACTIONS]
|
||||
warn = ""
|
||||
if pt.enabled and not pt.environments:
|
||||
warn = _warn("Enabled, but no environment is configured yet. Add one below.")
|
||||
elif not pt.enabled and users:
|
||||
warn = _warn(f"Off, but {_escape(', '.join(users))} use stack redeploy. Their redeploys will fail until this is enabled.")
|
||||
envs = "".join(_portainer_env_form(n, e, csrf_token) for n, e in pt.environments.items())
|
||||
used_by = (
|
||||
f'<p class="subsection-sub">Containers using stack redeploy: <b>{_escape(", ".join(users))}</b></p>'
|
||||
if users else '<p class="subsection-sub">No container uses stack redeploy yet. Set it per container under <b>Recovery</b>.</p>'
|
||||
)
|
||||
add_form = _portainer_env_form("", None, csrf_token, suggest_name="" if pt.environments else "local")
|
||||
return (
|
||||
'<h3 class="pane-title">Portainer</h3>'
|
||||
'<p class="pane-sub">Lets Inspector redeploy a whole Portainer stack. Needed for containers that share '
|
||||
"another container's network (for example apps routed through a VPN container): when the VPN container is "
|
||||
"recreated, a plain restart fails and only a redeploy reconnects them.</p>"
|
||||
+ '<div class="subsection">' + warn
|
||||
+ _settings_form("portainer", "Portainer", body, csrf_token) + "</div>"
|
||||
+ '<div class="subsection"><h4>Environments</h4>' + used_by
|
||||
+ envs + add_form + "</div>"
|
||||
+ _TOKEN_SOURCE_JS
|
||||
)
|
||||
|
||||
|
||||
def _render_flash(flash: str) -> str:
|
||||
if not flash:
|
||||
return ""
|
||||
@@ -1834,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))
|
||||
@@ -1862,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)}">'
|
||||
@@ -1873,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")
|
||||
@@ -1936,9 +2405,106 @@ _RESTART_ON_JS = (
|
||||
)
|
||||
|
||||
|
||||
_RECOVERY_JS = (
|
||||
"<script>window.InspectorRecovery=function(sel){"
|
||||
"var on=" + json.dumps(list(_PORTAINER_ACTIONS)) + ".indexOf(sel.value)>=0;"
|
||||
"sel.closest('form').querySelectorAll('.portainer-only').forEach(function(el){el.hidden=!on;});};</script>"
|
||||
)
|
||||
|
||||
|
||||
def _render_container_advanced(name: str, cfg, app: AppConfig) -> str:
|
||||
"""Reachability / Dependencies / Recovery groups for the container form."""
|
||||
hc = getattr(cfg, "http_check", None) or HttpCheckConfig()
|
||||
ukm = getattr(cfg, "uptime_kuma", None) or UptimeKumaMonitorConfig()
|
||||
uk_global = app.integrations.uptime_kuma
|
||||
pt = app.integrations.portainer
|
||||
action = getattr(cfg, "recovery_action", "") or ""
|
||||
if action == "restart_container": # same as the default option
|
||||
action = ""
|
||||
uses_portainer = action in _PORTAINER_ACTIONS
|
||||
timeout = hc.timeout_seconds
|
||||
timeout_str = str(int(timeout)) if float(timeout).is_integer() else str(timeout)
|
||||
|
||||
uk_warn = ""
|
||||
if not (uk_global.enabled and uk_global.url and uk_global.status_page_slug):
|
||||
uk_warn = _warn('The Uptime Kuma integration is off. Set it up in '
|
||||
'<a href="/settings/uptime_kuma">Settings → Uptime Kuma</a> or this check is ignored.')
|
||||
reach = (
|
||||
'<div class="form-group"><h4>Reachability</h4>'
|
||||
'<p class="group-sub">Docker can say a container is healthy when nobody can reach it, for example when '
|
||||
"its VPN tunnel dropped. A failed check reports <b>UNREACHABLE</b>; tick UNREACHABLE above to restart on it.</p>"
|
||||
'<div class="form-group"><h4>HTTP check</h4>'
|
||||
+ _settings_field("enabled", "http_check_enabled", "on" if hc.enabled else "", kind="switch")
|
||||
+ _settings_field("url", "http_check_url", hc.url,
|
||||
hint="Address Inspector requests, e.g. http://192.168.1.10:9696/ping. Use an IP or a name Inspector can resolve.")
|
||||
+ '<div class="grid-2">'
|
||||
+ _settings_field("timeout_seconds", "http_check_timeout_seconds", timeout_str, kind="number")
|
||||
+ _settings_field("expected_status", "http_check_expected_status", str(hc.expected_status), kind="number",
|
||||
hint="Exact HTTP code a healthy service returns (often 200; some apps return 401 or 302)")
|
||||
+ "</div></div>"
|
||||
'<div class="form-group"><h4>Uptime Kuma monitor</h4>'
|
||||
+ _settings_field("enabled", "uptime_kuma_enabled", "on" if ukm.enabled else "", kind="switch")
|
||||
+ _settings_field("monitor_name", "uptime_kuma_monitor_name", ukm.monitor_name,
|
||||
hint="Exact monitor name as shown on your status page. If both checks are on, the HTTP check wins.")
|
||||
+ uk_warn
|
||||
+ "</div></div>"
|
||||
)
|
||||
|
||||
others = [c.name for c in app.containers if c.name != name]
|
||||
dep_value = getattr(cfg, "depends_on", "") or ""
|
||||
dep_options = [("", "— none —")] + [(n, n) for n in others]
|
||||
if dep_value and dep_value not in others:
|
||||
dep_options.append((dep_value, f"{dep_value} (not configured)"))
|
||||
deps = (
|
||||
'<div class="form-group"><h4>Dependencies</h4>'
|
||||
'<p class="group-sub">Check this container after the one it relies on, and hold off recovering it '
|
||||
"while that one is still failing.</p>"
|
||||
+ _settings_select("depends_on", "depends_on", dep_value, dep_options,
|
||||
hint="e.g. the VPN container, for apps that use network_mode: container:<vpn>")
|
||||
+ _settings_field("vpn_routed", "vpn_routed", "on" if getattr(cfg, "vpn_routed", False) else "", kind="switch",
|
||||
hint="Routes through the container above. Logs why recovery is deferred while it is down.")
|
||||
+ "</div>"
|
||||
)
|
||||
|
||||
action_help = '<ul class="action-help">' + "".join(
|
||||
f"<li><b>{_escape(label)}</b> — {_escape(desc)}</li>" for _, label, desc in _RECOVERY_ACTION_OPTIONS
|
||||
) + "</ul>"
|
||||
env_value = getattr(cfg, "portainer_environment", "") or ""
|
||||
default_label = f"Default ({pt.default_environment})" if pt.default_environment else "Default"
|
||||
env_options = [("", default_label)] + [(n, n) for n in pt.environments]
|
||||
if env_value and env_value not in pt.environments:
|
||||
env_options.append((env_value, f"{env_value} (not configured)"))
|
||||
pt_warn = ""
|
||||
if not pt.environments:
|
||||
pt_warn = _warn('Portainer isn\'t set up yet. Add an environment in '
|
||||
'<a href="/settings/portainer">Settings → Portainer</a> before relying on a redeploy.')
|
||||
elif not pt.enabled:
|
||||
pt_warn = _warn('The Portainer integration is off. Turn it on in '
|
||||
'<a href="/settings/portainer">Settings → Portainer</a> or redeploys will fail.')
|
||||
hidden = "" if uses_portainer else " hidden"
|
||||
recovery = (
|
||||
'<div class="form-group"><h4>Recovery</h4>'
|
||||
'<p class="group-sub">What Inspector does once the restart conditions above are met.</p>'
|
||||
+ _settings_select("recovery_action", "recovery_action", action,
|
||||
[(v, label) for v, label, _ in _RECOVERY_ACTION_OPTIONS],
|
||||
onchange="InspectorRecovery(this)")
|
||||
+ action_help
|
||||
+ f'<div class="portainer-only"{hidden}>'
|
||||
+ '<div class="grid-2">'
|
||||
+ _settings_select("portainer_environment", "portainer_environment", env_value, env_options)
|
||||
+ _settings_field("portainer_stack_name", "portainer_stack_name",
|
||||
getattr(cfg, "portainer_stack_name", "") or "",
|
||||
hint="Stack name exactly as shown in Portainer → Stacks")
|
||||
+ "</div>" + pt_warn + "</div>"
|
||||
+ "</div>"
|
||||
+ _RECOVERY_JS
|
||||
)
|
||||
return '<input type="hidden" name="advanced_present" value="1">' + reach + deps + recovery
|
||||
|
||||
|
||||
def _render_container_form(
|
||||
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 []
|
||||
@@ -1994,8 +2560,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:
|
||||
@@ -2031,6 +2602,7 @@ def _render_container_edit_page(app: AppConfig, config_ref: ConfigRef, name: str
|
||||
name, cfg, csrf_token,
|
||||
action=f"/containers/{_escape(name)}",
|
||||
submit_label="Save container",
|
||||
app=app,
|
||||
show_delete=True,
|
||||
)
|
||||
parts = [_page_head(refresh=False, active="containers")]
|
||||
@@ -2040,7 +2612,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")
|
||||
@@ -2052,6 +2624,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
|
||||
@@ -2065,6 +2638,7 @@ def _render_container_new_page(app: AppConfig, config_ref: ConfigRef, csrf_token
|
||||
"", _NewContainerDefaults(), csrf_token,
|
||||
action="/containers/new",
|
||||
submit_label="Create container",
|
||||
app=app,
|
||||
show_name_field=True,
|
||||
)
|
||||
parts = [_page_head(refresh=False, active="containers")]
|
||||
@@ -2075,9 +2649,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")
|
||||
@@ -2250,6 +2822,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:
|
||||
@@ -2404,6 +2989,8 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
# else matches the section slug.
|
||||
if section in ("gotify", "pushover"):
|
||||
return_path = "/settings/notifications"
|
||||
elif section == "portainer_env":
|
||||
return_path = "/settings/portainer"
|
||||
else:
|
||||
return_path = f"/settings/{section}"
|
||||
try:
|
||||
@@ -2415,8 +3002,9 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return
|
||||
ok_label = "Portainer environment" if section == "portainer_env" else section
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self.send_header("Location", f"{return_path}?flash={_url_quote('ok:' + section)}")
|
||||
self.send_header("Location", f"{return_path}?flash={_url_quote('ok:' + ok_label)}")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return
|
||||
@@ -2487,6 +3075,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")
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -414,6 +417,7 @@ def run_loop(config_ref: ConfigRef) -> int:
|
||||
|
||||
check_order = _topological_order(enabled_configs)
|
||||
portainer_client = PortainerClient(app.integrations.portainer)
|
||||
integrations_cfg = app.integrations
|
||||
stack_cooldowns: dict[str, datetime] = {}
|
||||
|
||||
dashboard = DashboardServer(
|
||||
@@ -437,19 +441,30 @@ 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)
|
||||
|
||||
# Integration clients hold their config; rebuild them when the
|
||||
# settings page changes Uptime Kuma / Portainer.
|
||||
if app.integrations != integrations_cfg:
|
||||
integrations_cfg = app.integrations
|
||||
uptime_kuma_client = UptimeKumaClient(integrations_cfg.uptime_kuma)
|
||||
portainer_client = PortainerClient(integrations_cfg.portainer)
|
||||
log.info("Integration settings changed; reloaded Uptime Kuma / Portainer clients")
|
||||
|
||||
uptime_kuma_snapshot = uptime_kuma_client.fetch()
|
||||
for name in check_order:
|
||||
cfg = enabled_configs[name]
|
||||
@@ -495,14 +510,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)
|
||||
|
||||
@@ -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:
|
||||
@@ -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": "..."}]}],
|
||||
|
||||