Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
284ba2ea15 | ||
|
|
bd78835f4a | ||
|
|
7013a13cba |
No files matched your search
@@ -7,6 +7,68 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [0.9.1] — 2026-10-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **A dependency on a disabled container no longer blocks recovery forever.** If
|
||||||
|
`depends_on` named a container that is disabled in Inspector, the dependent app was
|
||||||
|
never restarted. Inspector now only waits for dependencies it is watching, and the
|
||||||
|
container page warns when the selected dependency is disabled.
|
||||||
|
- **`PORTAINER_API_TOKEN` now reaches the container.** The public `compose.yml` didn't
|
||||||
|
pass it in, so setting it as a Portainer stack variable had no effect. Existing
|
||||||
|
stacks need the line `- PORTAINER_API_TOKEN=${PORTAINER_API_TOKEN:-}` added under
|
||||||
|
`environment:` (Stacks → inspector → Editor).
|
||||||
|
- The "depends_on … is disabled or not found" warning was logged on every check
|
||||||
|
(since 0.8.0). It is now logged once, and again only when the container settings
|
||||||
|
change.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Guide: new sections **6. Apps behind a VPN container** (why depends_on matters, how to
|
||||||
|
spot a VPN-routed app, setup, what Inspector does) and **7. Portainer stack redeploy**
|
||||||
|
(why it's needed, what a redeploy does to the whole stack, requirements, where to find
|
||||||
|
every value, adding the token, the other settings, Test connection errors).
|
||||||
|
- Clearer form hints: `vpn_routed` only affects logging; `depends_on` works for any
|
||||||
|
dependency; where to find the endpoint ID.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.9.0] — 2026-10-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Every container option is now in the dashboard.** The container page has three
|
||||||
|
new groups:
|
||||||
|
- **Reachability**: HTTP check (URL, timeout, expected status) and Uptime Kuma
|
||||||
|
monitor.
|
||||||
|
- **Dependencies**: `depends_on` (pick from your containers) and `vpn_routed`.
|
||||||
|
- **Recovery**: `recovery_action` with plain-language descriptions, plus the
|
||||||
|
Portainer environment and stack name for stack redeploys.
|
||||||
|
- **Integrations settings.** New **Uptime Kuma** and **Portainer** pages under
|
||||||
|
Settings. Portainer environments can be added, edited, renamed (containers
|
||||||
|
that use them are updated) and deleted (refused while a container uses one).
|
||||||
|
The API token can be an environment variable name (recommended) or a masked
|
||||||
|
value saved in `config.yml`.
|
||||||
|
- **Test connection** buttons for Uptime Kuma (lists monitors) and each Portainer
|
||||||
|
environment (lists stacks on the endpoint), using the values in the form even
|
||||||
|
before saving.
|
||||||
|
- The All containers grid shows each container's extras (HTTP check, Uptime Kuma,
|
||||||
|
dependency, recovery action).
|
||||||
|
- Guide: new sections on reachability checks and apps behind a VPN container.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Uptime Kuma and Portainer changes take effect on the next check; Inspector
|
||||||
|
rebuilds those clients when their settings change instead of needing a restart.
|
||||||
|
- Saving a container form unchanged now leaves `config.yml` byte-identical. Blank
|
||||||
|
optional fields are removed instead of written as empty values (`color: ''`,
|
||||||
|
`maintenance_window: false`), and an unchanged `restart_on` list keeps its
|
||||||
|
order and the comments after it. An explicit `recovery_action: restart_container`
|
||||||
|
is kept.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.8.0] — 2026-10-27
|
## [0.8.0] — 2026-10-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ TZ=America/Los_Angeles
|
|||||||
| `INSPECTOR_PORT` | Port on your server for the dashboard. Change it if `8080` is taken. | No (default `8080`) |
|
| `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`) |
|
| `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`) |
|
| `PUID` / `PGID` | User and group that own Inspector's files. Unraid users often use `99` / `100` so the files match other appdata. | No (default `1000` / `1000`) |
|
||||||
|
| `PORTAINER_API_TOKEN` | A Portainer access token. Only needed if you use **Portainer stack redeploy** for VPN-routed apps; see the in-app guide (Help → Portainer). | No |
|
||||||
|
|
||||||
## 3. Deploy and sign in
|
## 3. Deploy and sign in
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/logo.png" alt="Inspector logo" width="140">
|
<img src="docs/logo_outline.png" alt="Inspector logo" width="140">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">Inspector</h1>
|
<h1 align="center">Inspector</h1>
|
||||||
@@ -52,11 +52,11 @@ guide (the same guide is in this repo at
|
|||||||
download it and open it in a browser to read it here).
|
download it and open it in a browser to read it here).
|
||||||
|
|
||||||
| Add a container | Restart rules |
|
| Add a container | Restart rules |
|
||||||
|---|---|
|
| ------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
| Notifications | Events history |
|
| Notifications | Events history |
|
||||||
|---|---|
|
| ------------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
## What it isn't
|
## What it isn't
|
||||||
@@ -69,18 +69,20 @@ download it and open it in a browser to read it here).
|
|||||||
|
|
||||||
## Advanced options
|
## Advanced options
|
||||||
|
|
||||||
Most people never need these. They live in `config.yml` (in your
|
Most people never need these. They are all on each container's page in the
|
||||||
`INSPECTOR_DATA/config` folder) rather than the dashboard; see
|
dashboard (and in `config.yml`, see
|
||||||
[`config.example.yml`](./config.example.yml) for every option with comments.
|
[`config.example.yml`](./config.example.yml) for every option with comments).
|
||||||
|
|
||||||
- **HTTP checks**: mark a container `UNREACHABLE` when a URL stops answering,
|
- **HTTP checks**: mark a container `UNREACHABLE` when a URL stops answering,
|
||||||
even if Docker says it's healthy (useful for apps behind a VPN container).
|
even if Docker says it's healthy (useful for apps behind a VPN container).
|
||||||
- **Uptime Kuma**: use a Kuma status page as an extra reachability signal.
|
- **Uptime Kuma**: use a Kuma status page as an extra reachability signal.
|
||||||
|
**Test connection** lists the monitors Inspector can see.
|
||||||
- **Dependencies** (`depends_on`): don't restart an app while the container it
|
- **Dependencies** (`depends_on`): don't restart an app while the container it
|
||||||
relies on (e.g. a VPN) is down.
|
relies on (e.g. a VPN) is down.
|
||||||
- **Portainer stack redeploy**: for containers that share a VPN container's
|
- **Portainer stack redeploy**: for containers that share a VPN container's
|
||||||
network, recover by redeploying the whole stack instead of restarting one
|
network, recover by redeploying the whole stack instead of restarting one
|
||||||
container.
|
container. Set up the Portainer connection under **Settings → Portainer**;
|
||||||
|
**Test connection** lists your stacks.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
# INSPECTOR_TAG Image version to run (default "latest"; e.g. 0.8.0 to pin).
|
# 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).
|
# PUID / PGID User/group Inspector runs as (default 1000/1000).
|
||||||
# Unraid users often prefer 99 / 100.
|
# Unraid users often prefer 99 / 100.
|
||||||
|
# PORTAINER_API_TOKEN Portainer access token, only if you use stack redeploy
|
||||||
|
# (Settings -> Portainer in the dashboard).
|
||||||
#
|
#
|
||||||
# No config file to create, no docker group ID to look up: Inspector creates
|
# 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.
|
# its config on first start and detects Docker socket permissions by itself.
|
||||||
@@ -36,3 +38,4 @@ services:
|
|||||||
- INSPECTOR_PASSWORD=${INSPECTOR_PASSWORD:-}
|
- INSPECTOR_PASSWORD=${INSPECTOR_PASSWORD:-}
|
||||||
- PUID=${PUID:-1000}
|
- PUID=${PUID:-1000}
|
||||||
- PGID=${PGID:-1000}
|
- PGID=${PGID:-1000}
|
||||||
|
- PORTAINER_API_TOKEN=${PORTAINER_API_TOKEN:-}
|
||||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -1 +1 @@
|
|||||||
__version__ = "0.8.0"
|
__version__ = "0.9.1"
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
@@ -78,10 +78,12 @@
|
|||||||
<a href="#status">3. The Status page</a>
|
<a href="#status">3. The Status page</a>
|
||||||
<a href="#notifications">4. Notifications</a>
|
<a href="#notifications">4. Notifications</a>
|
||||||
<a href="#containers">5. Managing containers</a>
|
<a href="#containers">5. Managing containers</a>
|
||||||
<a href="#events">6. Events history</a>
|
<a href="#vpn">6. Apps behind a VPN</a>
|
||||||
<a href="#settings">7. Other settings</a>
|
<a href="#portainer">7. Portainer stack redeploy</a>
|
||||||
<a href="#restart">8. "Requires restart" fields</a>
|
<a href="#events">8. Events history</a>
|
||||||
<a href="#trouble">9. Troubleshooting</a>
|
<a href="#settings">9. Other settings</a>
|
||||||
|
<a href="#restart">10. "Requires restart" fields</a>
|
||||||
|
<a href="#trouble">11. Troubleshooting</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
@@ -175,7 +177,7 @@ Each card shows:</p>
|
|||||||
under Apps). <b>Pushover</b>: enter your application API token and your user key.</li>
|
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,
|
<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>
|
the reason appears next to the button.</li>
|
||||||
<li>Click <b>Save</b>, then restart Inspector once (see <a href="#restart">section 8</a>),
|
<li>Click <b>Save</b>, then restart Inspector once (see <a href="#restart">section 10</a>),
|
||||||
because turning a channel on or off is a "requires restart" field.</li>
|
because turning a channel on or off is a "requires restart" field.</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p>You'll get one alert when a container first fails, when Inspector restarts it, and when it
|
<p>You'll get one alert when a container first fails, when Inspector restarts it, and when it
|
||||||
@@ -197,15 +199,182 @@ does <i>not</i> touch the container itself.</p>
|
|||||||
<tr><th>Status</th><th>When it happens</th></tr>
|
<tr><th>Status</th><th>When it happens</th></tr>
|
||||||
<tr><td>UNHEALTHY</td><td>The container's own health check is failing.</td></tr>
|
<tr><td>UNHEALTHY</td><td>The container's own health check is failing.</td></tr>
|
||||||
<tr><td>NOT RUNNING</td><td>Stopped, exited, or crashed.</td></tr>
|
<tr><td>NOT RUNNING</td><td>Stopped, exited, or crashed.</td></tr>
|
||||||
<tr><td>UNREACHABLE</td><td>Running, but an HTTP check failed (advanced, set in config.yml).</td></tr>
|
<tr><td>UNREACHABLE</td><td>Running, but its HTTP check or Uptime Kuma monitor says it's down (see below).</td></tr>
|
||||||
<tr><td>ERROR</td><td>Inspector couldn't check it (Docker API error).</td></tr>
|
<tr><td>ERROR</td><td>Inspector couldn't check it (Docker API error).</td></tr>
|
||||||
<tr><td>STARTING</td><td>Still in its health-check start period. Usually leave this off.</td></tr>
|
<tr><td>STARTING</td><td>Still in its health-check start period. Usually leave this off.</td></tr>
|
||||||
<tr><td>HEALTHY / NO HEALTH CHECK</td><td>Normal states; almost never a reason to restart.</td></tr>
|
<tr><td>HEALTHY / NO HEALTH CHECK</td><td>Normal states; almost never a reason to restart.</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<h3>Reachability: catching apps that look fine but aren't</h3>
|
||||||
|
<p>Docker only knows whether a container is running and whether its own health check passes. An
|
||||||
|
app can pass both and still be unusable, for example when it routes through a VPN container
|
||||||
|
whose tunnel dropped. Under <b>Reachability</b> on the container page you can add:</p>
|
||||||
|
<ul>
|
||||||
|
<li><b>HTTP check</b>: a URL Inspector requests every check (for example
|
||||||
|
<code>http://192.168.1.10:9696/ping</code>). Set <b>expected_status</b> to the code the app
|
||||||
|
returns when it's healthy; most return 200, some return 401 or 302.</li>
|
||||||
|
<li><b>Uptime Kuma monitor</b>: if you already run Uptime Kuma, name one of its monitors. Set up
|
||||||
|
the connection first in <b>Settings → Uptime Kuma</b>; <b>Test connection</b> lists the
|
||||||
|
monitor names it can see.</li>
|
||||||
|
</ul>
|
||||||
|
<p>A failed check reports <b>UNREACHABLE</b>. Tick UNREACHABLE in the restart triggers if you want
|
||||||
|
Inspector to act on it.</p>
|
||||||
|
<figure><img src="container-advanced.png" alt="Reachability, Dependencies and Recovery groups">
|
||||||
|
<figcaption>Bottom of a container's page: an app behind a VPN container with an HTTP check and stack-redeploy recovery.</figcaption></figure>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="vpn">
|
||||||
|
<h2>6. Apps behind a VPN container</h2>
|
||||||
|
<h3>Why these apps need special handling</h3>
|
||||||
|
<p>A common setup routes download and indexer apps through a VPN container. Those apps don't get a
|
||||||
|
network of their own: their compose file says <code>network_mode: container:<vpn></code> (or
|
||||||
|
<code>service:<vpn></code>) and all their traffic goes through the VPN container. That causes
|
||||||
|
two problems for a watchdog:</p>
|
||||||
|
<ul>
|
||||||
|
<li><b>When the VPN breaks, every app behind it breaks at the same time.</b> Restarting the apps
|
||||||
|
does nothing while the tunnel is down. Without help, Inspector would restart each app over and
|
||||||
|
over, use up their hourly restart limits, and flood you with alerts, all for a problem in a
|
||||||
|
different container.</li>
|
||||||
|
<li><b>When the VPN container is recreated</b> (an image update, or someone redeploys its stack),
|
||||||
|
it gets a new container ID. The apps still point at the old one, so a normal restart fails with
|
||||||
|
errors like <i>"No such container"</i> or <i>"joining network namespace"</i>. The only fix is
|
||||||
|
to recreate the apps too, which is what a Portainer stack redeploy does
|
||||||
|
(<a href="#portainer">section 7</a>).</li>
|
||||||
|
</ul>
|
||||||
|
<h3>How to tell if an app is behind a VPN</h3>
|
||||||
|
<ul>
|
||||||
|
<li>In Portainer open <b>Stacks → your stack → Editor</b> and look for
|
||||||
|
<code>network_mode: container:…</code> or <code>network_mode: service:…</code> on the app.</li>
|
||||||
|
<li>Or open <b>Containers → the app</b>: under <b>Network</b> it shows the VPN container instead
|
||||||
|
of a normal network, and it has no ports of its own (they're published on the VPN container).</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Setting it up</h3>
|
||||||
|
<ol class="steps">
|
||||||
|
<li><b>Add the VPN container to Inspector and keep it enabled</b> (restart on UNHEALTHY and NOT
|
||||||
|
RUNNING is a good start). Inspector can only wait for containers it is watching: if the VPN
|
||||||
|
container is disabled, its apps are recovered normally, as if there were no dependency, and the
|
||||||
|
container page shows a warning.</li>
|
||||||
|
<li>On each app behind it, set <b>Dependencies → depends_on</b> to the VPN container.</li>
|
||||||
|
<li>Turn on <b>vpn_routed</b>. This only changes the log: Inspector writes down when it holds off
|
||||||
|
on the app because the VPN is down. The waiting itself comes from depends_on.</li>
|
||||||
|
<li>Add an <b>HTTP check</b> (<a href="#containers">section 5</a>) using your server's IP and the
|
||||||
|
port published on the VPN container, and tick <b>UNREACHABLE</b>. Docker often still reports
|
||||||
|
these apps as healthy when the tunnel is down; the HTTP check notices.</li>
|
||||||
|
<li>Under <b>Recovery</b>, choose <b>Restart, then redeploy stack if that fails</b> and enter the
|
||||||
|
stack name. This needs the Portainer connection from <a href="#portainer">section 7</a>.</li>
|
||||||
|
</ol>
|
||||||
|
<h3>What Inspector then does</h3>
|
||||||
|
<p>Every check, it looks at the VPN container first. While the VPN is anything other than healthy
|
||||||
|
(or starting, or running without a health check), the apps that depend on it are not restarted;
|
||||||
|
Inspector fixes the VPN first. Once the VPN is healthy again, the apps are handled normally: a
|
||||||
|
restart first, and a stack redeploy if the restart fails or doesn't help.</p>
|
||||||
|
<p class="note"><b>depends_on isn't only for VPNs.</b> Use it for any "B needs A" pair, such as an
|
||||||
|
app and its database: Inspector won't restart the app while its database is down.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="portainer">
|
||||||
|
<h2>7. Portainer stack redeploy</h2>
|
||||||
|
<h3>Why Inspector needs Portainer</h3>
|
||||||
|
<p>Inspector talks to Docker directly, so it can restart or start a container. It can't
|
||||||
|
<i>recreate</i> one: that needs the container's compose definition, which lives in Portainer. A
|
||||||
|
stack redeploy asks Portainer to do exactly what its <b>Update the stack</b> button does. That's
|
||||||
|
how VPN-routed apps get reattached to a recreated VPN container. You only need this if you use one
|
||||||
|
of the two "redeploy" recovery actions.</p>
|
||||||
|
<p class="note warn"><b>A redeploy affects the whole stack.</b> Portainer pulls the latest images
|
||||||
|
and recreates <i>every</i> container in that stack, not just the failing app, so all of them blink
|
||||||
|
off for a moment. That's why <b>Restart, then redeploy stack if that fails</b> is the recommended
|
||||||
|
choice: most problems are fixed by the plain restart, and the redeploy is only used when that
|
||||||
|
doesn't work.</p>
|
||||||
|
<h3>Requirements</h3>
|
||||||
|
<ul>
|
||||||
|
<li>The app must belong to a stack that was <b>created in Portainer</b>. In <b>Stacks</b>, stacks
|
||||||
|
created elsewhere (for example with <code>docker compose</code> on the command line) are marked
|
||||||
|
<b>Limited</b>, and Portainer can't redeploy them.</li>
|
||||||
|
<li>An access token for a Portainer user who can manage that stack. An administrator's token is
|
||||||
|
the simplest.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Step 1: find the values</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>Setting</th><th>Where to find it</th></tr>
|
||||||
|
<tr><td>base_url</td><td>The address you open Portainer at, without anything after the port:
|
||||||
|
usually <code>http://<server-ip>:9000</code> or <code>https://<server-ip>:9443</code>.
|
||||||
|
Use the server's IP, not <code>localhost</code>: Inspector runs in its own container.</td></tr>
|
||||||
|
<tr><td>endpoint_id</td><td>On Portainer's <b>Home</b> page, click your environment (often
|
||||||
|
<b>local</b>). The browser address then ends like <code>#!/2/docker/dashboard</code>: the
|
||||||
|
number after <code>#!/</code> is the endpoint ID (here <code>2</code>).</td></tr>
|
||||||
|
<tr><td>stack name</td><td><b>Stacks</b> list, <b>Name</b> column, spelled exactly the same.
|
||||||
|
This goes on each app's container page, not on the Portainer page.</td></tr>
|
||||||
|
<tr><td>verify_ssl</td><td>Leave off for <code>http://</code> and for Portainer's built-in
|
||||||
|
self-signed certificate on 9443. Turn on only if Portainer has a real certificate.</td></tr>
|
||||||
|
<tr><td>API token</td><td>Click your username (top right) → <b>My account</b> →
|
||||||
|
<b>Access tokens</b> → <b>Add access token</b>. Name it <code>inspector</code> and copy the
|
||||||
|
token straight away: Portainer shows it only once.</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Step 2: give Inspector the token</h3>
|
||||||
|
<p><b>Recommended: an environment variable.</b> The token then never lands in
|
||||||
|
<code>config.yml</code> or its backups.</p>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>In Portainer open <b>Stacks → inspector → Editor</b>.</li>
|
||||||
|
<li>Check that the compose file has this line under <code>environment:</code> (it's in the
|
||||||
|
compose file from version 0.9.1 on; add it if your stack is older):
|
||||||
|
<code>- PORTAINER_API_TOKEN=${PORTAINER_API_TOKEN:-}</code></li>
|
||||||
|
<li>Under <b>Environment variables</b> click <b>Add an environment variable</b>: name
|
||||||
|
<code>PORTAINER_API_TOKEN</code>, value the token.</li>
|
||||||
|
<li>Click <b>Update the stack</b>. Inspector restarts with the token available.</li>
|
||||||
|
</ol>
|
||||||
|
<p><b>Alternative: save it in Inspector.</b> In the next step choose <b>Saved in config.yml</b>
|
||||||
|
and paste the token. It is masked in the dashboard, but anyone who can read Inspector's config
|
||||||
|
folder (or a backup of it) can read the token.</p>
|
||||||
|
|
||||||
|
<h3>Step 3: connect Inspector</h3>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>Open <b>Settings → Portainer</b>. Under <b>Add environment</b>, give it a name (your own
|
||||||
|
label, e.g. <code>home</code>) and fill in base_url, endpoint_id and verify_ssl.</li>
|
||||||
|
<li><b>API token stored as</b>: <i>Environment variable</i> with the name
|
||||||
|
<code>PORTAINER_API_TOKEN</code>, or <i>Saved in config.yml</i> with the token.</li>
|
||||||
|
<li>Click <b>Test connection</b>. It should list the stacks on that endpoint; check that your
|
||||||
|
app's stack is there. Then <b>Save new environment</b>.</li>
|
||||||
|
<li>At the top of the page turn <b>enabled</b> on, pick the <b>default_environment</b>, and
|
||||||
|
<b>Save Portainer</b>.</li>
|
||||||
|
<li>On each app's container page, set the recovery action and stack name
|
||||||
|
(<a href="#vpn">section 6</a>). Leave <b>portainer_environment</b> on <i>Default</i> unless
|
||||||
|
you connected more than one Portainer.</li>
|
||||||
|
</ol>
|
||||||
|
<figure><img src="settings-portainer.png" alt="Portainer settings">
|
||||||
|
<figcaption>Settings → Portainer with one environment; the token comes from an environment variable.</figcaption></figure>
|
||||||
|
|
||||||
|
<h3>The other settings on the page</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>Setting</th><th>What it does</th></tr>
|
||||||
|
<tr><td>default_environment</td><td>Which connection apps use when they don't pick one. With a
|
||||||
|
single environment you can leave it on Automatic.</td></tr>
|
||||||
|
<tr><td>stack_redeploy_cooldown_seconds</td><td>Minimum time between two redeploys of the same
|
||||||
|
stack (default 300). Several apps often share a stack; this stops them triggering a redeploy
|
||||||
|
each.</td></tr>
|
||||||
|
<tr><td>post_redeploy_wait_seconds</td><td>How long Inspector waits after a redeploy for the
|
||||||
|
stack to come up before judging it again (default 90).</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>If Test connection fails</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>Message</th><th>Fix</th></tr>
|
||||||
|
<tr><td>environment variable … is not set</td><td>The compose line from step 2 is missing, or
|
||||||
|
the stack wasn't updated after adding the variable.</td></tr>
|
||||||
|
<tr><td>the API token was rejected</td><td>Token copied wrong, deleted, or from a user without
|
||||||
|
access. Create a new one.</td></tr>
|
||||||
|
<tr><td>no stacks found on endpoint N</td><td>Wrong endpoint ID; check the number in the
|
||||||
|
Portainer address again.</td></tr>
|
||||||
|
<tr><td>request failed …</td><td>Wrong address or port, <code>http</code> vs <code>https</code>
|
||||||
|
mixed up, or <code>localhost</code> used instead of the server IP. With https and a
|
||||||
|
self-signed certificate, turn verify_ssl off.</td></tr>
|
||||||
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="events">
|
<section id="events">
|
||||||
<h2>6. Events history</h2>
|
<h2>8. Events history</h2>
|
||||||
<p>The <b>Events</b> tab is a day-by-day timeline of failures, restarts, recoveries, mutes, and
|
<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>
|
maintenance. Use <b>Prev day</b> / <b>Next day</b> to look back.</p>
|
||||||
<figure><img src="events.png" alt="Events timeline">
|
<figure><img src="events.png" alt="Events timeline">
|
||||||
@@ -213,7 +382,7 @@ maintenance. Use <b>Prev day</b> / <b>Next day</b> to look back.</p>
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="settings">
|
<section id="settings">
|
||||||
<h2>7. Other settings</h2>
|
<h2>9. Other settings</h2>
|
||||||
<figure><img src="settings-inspector.png" alt="Inspector loop settings">
|
<figure><img src="settings-inspector.png" alt="Inspector loop settings">
|
||||||
<figcaption>Settings → Inspector loop.</figcaption></figure>
|
<figcaption>Settings → Inspector loop.</figcaption></figure>
|
||||||
<table>
|
<table>
|
||||||
@@ -225,13 +394,15 @@ maintenance. Use <b>Prev day</b> / <b>Next day</b> to look back.</p>
|
|||||||
<tr><td>Health summary</td><td>A periodic digest (e.g. daily) of what happened, sent through your notification channels.</td></tr>
|
<tr><td>Health summary</td><td>A periodic digest (e.g. daily) of what happened, sent through your notification channels.</td></tr>
|
||||||
<tr><td>Maintenance</td><td>A daily quiet window (e.g. 02:00–05:00) so nightly backups or updates don't trigger restarts. Turn on <b>maintenance_window</b> on each container that should follow it.</td></tr>
|
<tr><td>Maintenance</td><td>A daily quiet window (e.g. 02:00–05:00) so nightly backups or updates don't trigger restarts. Turn on <b>maintenance_window</b> on each container that should follow it.</td></tr>
|
||||||
<tr><td>Notification mute</td><td>Turns the per-container <b>Mute notifications</b> buttons on or off, and sets how long a mute lasts.</td></tr>
|
<tr><td>Notification mute</td><td>Turns the per-container <b>Mute notifications</b> buttons on or off, and sets how long a mute lasts.</td></tr>
|
||||||
|
<tr><td>Uptime Kuma</td><td>Connection to your Uptime Kuma status page, used by container reachability checks.</td></tr>
|
||||||
|
<tr><td>Portainer</td><td>Connection to Portainer, used to redeploy stacks. See <a href="#portainer">section 7</a>.</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<p>At the bottom of every settings page is a strip showing where the config file lives and
|
<p>At the bottom of every settings page is a strip showing where the config file lives and
|
||||||
whether Inspector can write to it.</p>
|
whether Inspector can write to it.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="restart">
|
<section id="restart">
|
||||||
<h2>8. "Requires restart" fields</h2>
|
<h2>10. "Requires restart" fields</h2>
|
||||||
<p>Most changes apply on the next check. Fields marked
|
<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
|
<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>
|
dashboard on or off) only take effect after Inspector restarts:</p>
|
||||||
@@ -243,7 +414,7 @@ dashboard on or off) only take effect after Inspector restarts:</p>
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="trouble">
|
<section id="trouble">
|
||||||
<h2>9. Troubleshooting</h2>
|
<h2>11. Troubleshooting</h2>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>Problem</th><th>Fix</th></tr>
|
<tr><th>Problem</th><th>Fix</th></tr>
|
||||||
<tr><td>Red <b>READ-ONLY</b> badge at the bottom of Settings</td>
|
<tr><td>Red <b>READ-ONLY</b> badge at the bottom of Settings</td>
|
||||||
|
|||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
+582
-19
@@ -21,6 +21,8 @@ from .config import (
|
|||||||
AppConfig,
|
AppConfig,
|
||||||
ConfigRef,
|
ConfigRef,
|
||||||
ContainerConfig,
|
ContainerConfig,
|
||||||
|
HttpCheckConfig,
|
||||||
|
UptimeKumaMonitorConfig,
|
||||||
build_config_from_raw,
|
build_config_from_raw,
|
||||||
write_config_raw,
|
write_config_raw,
|
||||||
)
|
)
|
||||||
@@ -595,12 +597,25 @@ a.signout:hover { color: var(--ink); border-color: var(--muted); }
|
|||||||
.settings-pane .field label { font-size: 0.76rem; color: #94a3b8; font-weight: 600; }
|
.settings-pane .field label { font-size: 0.76rem; color: #94a3b8; font-weight: 600; }
|
||||||
.settings-pane .field.field-row label { font-size: 0.85rem; color: #cbd5e1; }
|
.settings-pane .field.field-row label { font-size: 0.85rem; color: #cbd5e1; }
|
||||||
.settings-pane .field input[type=text], .settings-pane .field input[type=number],
|
.settings-pane .field input[type=text], .settings-pane .field input[type=number],
|
||||||
.settings-pane .field input[type=password] {
|
.settings-pane .field input[type=password], .settings-pane .field select {
|
||||||
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
||||||
padding: 0.5rem 0.65rem; color: var(--ink); font-size: 0.88rem; font-family: inherit; outline: none;
|
padding: 0.5rem 0.65rem; color: var(--ink); font-size: 0.88rem; font-family: inherit; outline: none;
|
||||||
}
|
}
|
||||||
.settings-pane .field input:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(96,165,250,0.15); }
|
.settings-pane .field input:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(96,165,250,0.15); }
|
||||||
.settings-pane .field-hint { font-size: 0.72rem; color: var(--muted); }
|
.settings-pane .field-hint { font-size: 0.72rem; color: var(--muted); }
|
||||||
|
.settings-pane .field-warn { font-size: 0.72rem; color: #fbbf24; }
|
||||||
|
.settings-pane .field-warn a { color: inherit; text-decoration: underline; }
|
||||||
|
.settings-pane .action-help { margin: -0.4rem 0 0.9rem; padding-left: 1rem; font-size: 0.72rem; color: var(--muted); }
|
||||||
|
.settings-pane .action-help b { color: #cbd5e1; font-weight: 600; }
|
||||||
|
/* Grouped blocks on the container form (Reachability / Dependencies / Recovery). */
|
||||||
|
.settings-pane .form-group {
|
||||||
|
border: 1px solid var(--border); border-radius: 10px; padding: 0.9rem 1rem 0.2rem;
|
||||||
|
margin: 1.2rem 0;
|
||||||
|
}
|
||||||
|
.settings-pane .form-group > h4 { margin: 0 0 0.2rem; font-size: 0.88rem; color: var(--ink); }
|
||||||
|
.settings-pane .form-group > .group-sub { margin: 0 0 0.9rem; color: var(--muted); font-size: 0.75rem; }
|
||||||
|
.settings-pane .form-group .form-group { margin: 0.4rem 0 0.9rem; background: rgba(15,24,41,0.5); }
|
||||||
|
.settings-pane .portainer-only[hidden], .settings-pane .token-src[hidden] { display: none; }
|
||||||
.settings-pane .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.1rem 1rem; }
|
.settings-pane .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.1rem 1rem; }
|
||||||
@media (max-width: 640px) { .settings-pane .grid-2 { grid-template-columns: 1fr; } }
|
@media (max-width: 640px) { .settings-pane .grid-2 { grid-template-columns: 1fr; } }
|
||||||
.settings-pane .form-actions {
|
.settings-pane .form-actions {
|
||||||
@@ -1186,6 +1201,18 @@ class _SettingsError(ValueError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# (value, label, description) for the recovery_action dropdown. "" = default.
|
||||||
|
_RECOVERY_ACTION_OPTIONS = [
|
||||||
|
("", "Restart container (default)", "docker restart. Right for most containers."),
|
||||||
|
("start_container", "Start container", "docker start. Only starts a stopped container; never restarts a running one."),
|
||||||
|
("portainer_stack_redeploy", "Redeploy Portainer stack",
|
||||||
|
"Redeploys the whole stack through Portainer. For containers whose network is tied to a VPN container."),
|
||||||
|
("restart_then_redeploy", "Restart, then redeploy stack if that fails",
|
||||||
|
"Tries docker restart first; escalates to a stack redeploy if the restart errors or the container is still failing next check."),
|
||||||
|
]
|
||||||
|
_PORTAINER_ACTIONS = ("portainer_stack_redeploy", "restart_then_redeploy")
|
||||||
|
|
||||||
|
|
||||||
def _form_str(form: dict, key: str, default: str = "") -> str:
|
def _form_str(form: dict, key: str, default: str = "") -> str:
|
||||||
return form.get(key, [default])[0]
|
return form.get(key, [default])[0]
|
||||||
|
|
||||||
@@ -1209,6 +1236,146 @@ def _apply_secret(form: dict, raw_section: dict, form_key: str, yaml_key: str) -
|
|||||||
raw_section[yaml_key] = submitted
|
raw_section[yaml_key] = submitted
|
||||||
|
|
||||||
|
|
||||||
|
def _form_float(form: dict, key: str, default: float = 0.0) -> float:
|
||||||
|
try:
|
||||||
|
return float(_form_str(form, key, str(default)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _set_or_drop(section: dict, key: str, value) -> None:
|
||||||
|
"""Store value, or remove the key when it is blank/False so config.yml
|
||||||
|
doesn't fill up with empty defaults."""
|
||||||
|
if value in ("", None, False):
|
||||||
|
section.pop(key, None)
|
||||||
|
else:
|
||||||
|
section[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_container_advanced(raw: dict, name: str, cfg: dict, form: dict) -> None:
|
||||||
|
"""Merge the Reachability / Dependencies / Recovery fields into `cfg`.
|
||||||
|
|
||||||
|
Only our own container form carries `advanced_present`; without it the
|
||||||
|
keys are left untouched so older clients can't wipe YAML-only settings.
|
||||||
|
"""
|
||||||
|
if not form.get("advanced_present"):
|
||||||
|
return
|
||||||
|
|
||||||
|
hc_enabled = _form_bool(form, "http_check_enabled")
|
||||||
|
hc_url = _form_str(form, "http_check_url", "").strip()
|
||||||
|
if hc_enabled and not hc_url.startswith(("http://", "https://")):
|
||||||
|
raise _SettingsError("HTTP check needs a URL starting with http:// or https://")
|
||||||
|
if hc_enabled or hc_url:
|
||||||
|
hc = cfg.get("http_check") or {}
|
||||||
|
hc["enabled"] = hc_enabled
|
||||||
|
hc["url"] = hc_url
|
||||||
|
timeout = _form_float(form, "http_check_timeout_seconds", 5.0)
|
||||||
|
hc["timeout_seconds"] = int(timeout) if timeout.is_integer() else timeout
|
||||||
|
hc["expected_status"] = _form_int(form, "http_check_expected_status", 200)
|
||||||
|
cfg["http_check"] = hc
|
||||||
|
else:
|
||||||
|
cfg.pop("http_check", None)
|
||||||
|
|
||||||
|
uk_enabled = _form_bool(form, "uptime_kuma_enabled")
|
||||||
|
uk_monitor = _form_str(form, "uptime_kuma_monitor_name", "").strip()
|
||||||
|
if uk_enabled and not uk_monitor:
|
||||||
|
raise _SettingsError("Uptime Kuma check needs a monitor name")
|
||||||
|
if uk_enabled or uk_monitor:
|
||||||
|
uk = cfg.get("uptime_kuma") or {}
|
||||||
|
uk["enabled"] = uk_enabled
|
||||||
|
uk["monitor_name"] = uk_monitor
|
||||||
|
cfg["uptime_kuma"] = uk
|
||||||
|
else:
|
||||||
|
cfg.pop("uptime_kuma", None)
|
||||||
|
|
||||||
|
depends_on = _form_str(form, "depends_on", "").strip()
|
||||||
|
if depends_on == name:
|
||||||
|
raise _SettingsError("a container can't depend on itself")
|
||||||
|
_set_or_drop(cfg, "depends_on", depends_on)
|
||||||
|
_set_or_drop(cfg, "vpn_routed", _form_bool(form, "vpn_routed"))
|
||||||
|
|
||||||
|
action = _form_str(form, "recovery_action", "").strip()
|
||||||
|
# "" (the dropdown's default) and an explicit restart_container behave the
|
||||||
|
# same; keep the explicit value if the YAML already has it.
|
||||||
|
if action or cfg.get("recovery_action") != "restart_container":
|
||||||
|
_set_or_drop(cfg, "recovery_action", action)
|
||||||
|
if action in _PORTAINER_ACTIONS:
|
||||||
|
env = _form_str(form, "portainer_environment", "").strip()
|
||||||
|
envs = ((raw.get("integrations") or {}).get("portainer") or {}).get("environments") or {}
|
||||||
|
if env and env not in envs:
|
||||||
|
raise _SettingsError(f"unknown Portainer environment {env!r}")
|
||||||
|
stack = _form_str(form, "portainer_stack_name", "").strip()
|
||||||
|
if not stack:
|
||||||
|
raise _SettingsError("Portainer recovery needs a stack name")
|
||||||
|
_set_or_drop(cfg, "portainer_environment", env)
|
||||||
|
cfg["portainer_stack_name"] = stack
|
||||||
|
else:
|
||||||
|
cfg.pop("portainer_environment", None)
|
||||||
|
cfg.pop("portainer_stack_name", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_portainer_env(raw: dict, form: dict) -> None:
|
||||||
|
"""Add, edit, rename or delete one integrations.portainer.environments entry."""
|
||||||
|
pt = raw.setdefault("integrations", {}).setdefault("portainer", {})
|
||||||
|
envs = pt.get("environments") or {}
|
||||||
|
original = _form_str(form, "env_original", "").strip()
|
||||||
|
containers = raw.get("containers") or {}
|
||||||
|
|
||||||
|
def _users(env_name: str) -> list[str]:
|
||||||
|
return sorted(n for n, c in containers.items() if (c or {}).get("portainer_environment") == env_name)
|
||||||
|
|
||||||
|
if original and original not in envs:
|
||||||
|
raise _SettingsError(f"unknown Portainer environment {original!r}")
|
||||||
|
|
||||||
|
if _form_bool(form, "delete"):
|
||||||
|
users = _users(original)
|
||||||
|
if users:
|
||||||
|
raise _SettingsError(
|
||||||
|
f"environment {original!r} is used by {', '.join(users)}; change those containers first"
|
||||||
|
)
|
||||||
|
del envs[original]
|
||||||
|
if pt.get("default_environment") == original:
|
||||||
|
pt.pop("default_environment", None)
|
||||||
|
pt["environments"] = envs
|
||||||
|
return
|
||||||
|
|
||||||
|
env_name = _form_str(form, "env_name", "").strip()
|
||||||
|
if not _CONTAINER_NAME_RE.match(env_name):
|
||||||
|
raise _SettingsError("environment name must start with a letter/digit and use only letters, digits, _ or -")
|
||||||
|
if env_name != original and env_name in envs:
|
||||||
|
raise _SettingsError(f"environment {env_name!r} already exists")
|
||||||
|
base_url = _form_str(form, "base_url", "").strip().rstrip("/")
|
||||||
|
if not base_url.startswith(("http://", "https://")):
|
||||||
|
raise _SettingsError("Portainer URL must start with http:// or https://")
|
||||||
|
|
||||||
|
env = envs.get(original) or {}
|
||||||
|
env["base_url"] = base_url
|
||||||
|
env["endpoint_id"] = _form_int(form, "endpoint_id", 1)
|
||||||
|
env["verify_ssl"] = _form_bool(form, "verify_ssl")
|
||||||
|
if _form_str(form, "token_source", "env") == "inline":
|
||||||
|
_apply_secret(form, env, "api_token", "api_token")
|
||||||
|
if not env.get("api_token"):
|
||||||
|
raise _SettingsError("enter the Portainer API token, or choose an environment variable")
|
||||||
|
env.pop("api_token_env", None)
|
||||||
|
else:
|
||||||
|
token_env = _form_str(form, "api_token_env", "").strip()
|
||||||
|
if not token_env:
|
||||||
|
raise _SettingsError("enter the name of the environment variable holding the API token")
|
||||||
|
env["api_token_env"] = token_env
|
||||||
|
env.pop("api_token", None)
|
||||||
|
|
||||||
|
if original and env_name != original:
|
||||||
|
# Rename: rebuild the mapping in order and repoint references.
|
||||||
|
envs = {(env_name if k == original else k): (env if k == original else v) for k, v in envs.items()}
|
||||||
|
for cname in _users(original):
|
||||||
|
containers[cname]["portainer_environment"] = env_name
|
||||||
|
if pt.get("default_environment") == original:
|
||||||
|
pt["default_environment"] = env_name
|
||||||
|
else:
|
||||||
|
envs[env_name] = env
|
||||||
|
pt["environments"] = envs
|
||||||
|
|
||||||
|
|
||||||
def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> None:
|
def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> None:
|
||||||
"""Validate the form, merge into the raw config, write atomically, swap ref."""
|
"""Validate the form, merge into the raw config, write atomically, swap ref."""
|
||||||
raw = config_ref.get_raw()
|
raw = config_ref.get_raw()
|
||||||
@@ -1271,14 +1438,39 @@ def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> N
|
|||||||
cfg["cooldown_seconds"] = _form_int(form, "cooldown_seconds", 300)
|
cfg["cooldown_seconds"] = _form_int(form, "cooldown_seconds", 300)
|
||||||
cfg["grace_period_seconds"] = _form_int(form, "grace_period_seconds", 120)
|
cfg["grace_period_seconds"] = _form_int(form, "grace_period_seconds", 120)
|
||||||
cfg["max_restarts_per_hour"] = _form_int(form, "max_restarts_per_hour", 3)
|
cfg["max_restarts_per_hour"] = _form_int(form, "max_restarts_per_hour", 3)
|
||||||
cfg["color"] = _form_str(form, "color", "")
|
_set_or_drop(cfg, "color", _form_str(form, "color", "").strip())
|
||||||
if form.get("restart_on_present"): # only our own form carries this field
|
if form.get("restart_on_present"): # only our own form carries this field
|
||||||
cfg["maintenance_window"] = _form_bool(form, "maintenance_window")
|
_set_or_drop(cfg, "maintenance_window", _form_bool(form, "maintenance_window"))
|
||||||
restart_on = form.get("restart_on", [])
|
restart_on = [s.upper() for s in form.get("restart_on", [])]
|
||||||
if restart_on or form.get("restart_on_present"):
|
existing = cfg.get("restart_on")
|
||||||
cfg["restart_on"] = [s.upper() for s in restart_on]
|
# Keep the existing list (its order and trailing YAML comments) when
|
||||||
|
# the selection didn't change.
|
||||||
|
same = existing is not None and {str(s).upper() for s in existing} == set(restart_on)
|
||||||
|
if (restart_on or form.get("restart_on_present")) and not same:
|
||||||
|
cfg["restart_on"] = restart_on
|
||||||
|
_apply_container_advanced(raw, name, cfg, form)
|
||||||
containers[name] = cfg
|
containers[name] = cfg
|
||||||
raw["containers"] = containers
|
raw["containers"] = containers
|
||||||
|
elif section == "uptime_kuma":
|
||||||
|
integ = raw.setdefault("integrations", {})
|
||||||
|
uk = integ.setdefault("uptime_kuma", {})
|
||||||
|
uk["enabled"] = _form_bool(form, "enabled")
|
||||||
|
uk["url"] = _form_str(form, "url", "").strip()
|
||||||
|
uk["status_page_slug"] = _form_str(form, "status_page_slug", "").strip()
|
||||||
|
if uk["enabled"] and not (uk["url"] and uk["status_page_slug"]):
|
||||||
|
raise _SettingsError("Uptime Kuma needs both a URL and a status page slug")
|
||||||
|
elif section == "portainer":
|
||||||
|
integ = raw.setdefault("integrations", {})
|
||||||
|
pt = integ.setdefault("portainer", {})
|
||||||
|
pt["enabled"] = _form_bool(form, "enabled")
|
||||||
|
default_env = _form_str(form, "default_environment", "").strip()
|
||||||
|
if default_env and default_env not in (pt.get("environments") or {}):
|
||||||
|
raise _SettingsError(f"unknown Portainer environment {default_env!r}")
|
||||||
|
_set_or_drop(pt, "default_environment", default_env)
|
||||||
|
pt["stack_redeploy_cooldown_seconds"] = _form_int(form, "stack_redeploy_cooldown_seconds", 300)
|
||||||
|
pt["post_redeploy_wait_seconds"] = _form_int(form, "post_redeploy_wait_seconds", 90)
|
||||||
|
elif section == "portainer_env":
|
||||||
|
_apply_portainer_env(raw, form)
|
||||||
else:
|
else:
|
||||||
raise _SettingsError(f"unknown settings section {section!r}")
|
raise _SettingsError(f"unknown settings section {section!r}")
|
||||||
|
|
||||||
@@ -1325,6 +1517,7 @@ def _apply_container_create(config_ref: ConfigRef, form: dict) -> str:
|
|||||||
restart_on = form.get("restart_on", [])
|
restart_on = form.get("restart_on", [])
|
||||||
if restart_on or form.get("restart_on_present"):
|
if restart_on or form.get("restart_on_present"):
|
||||||
cfg["restart_on"] = [s.upper() for s in restart_on]
|
cfg["restart_on"] = [s.upper() for s in restart_on]
|
||||||
|
_apply_container_advanced(raw, name, cfg, form)
|
||||||
containers[name] = cfg
|
containers[name] = cfg
|
||||||
raw["containers"] = containers
|
raw["containers"] = containers
|
||||||
|
|
||||||
@@ -1429,6 +1622,79 @@ def _run_test_notification(channel: str, form: dict, app: AppConfig) -> dict:
|
|||||||
return {"ok": False, "error": f"unknown channel {channel!r}"}
|
return {"ok": False, "error": f"unknown channel {channel!r}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_test_integration(kind: str, form: dict, app: AppConfig) -> dict:
|
||||||
|
"""Check an Uptime Kuma or Portainer connection using form values.
|
||||||
|
|
||||||
|
Returns {"ok": bool, "message"/"error": str}. Never echoes tokens.
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .config import UptimeKumaConfig
|
||||||
|
from .uptime_kuma import UptimeKumaClient
|
||||||
|
|
||||||
|
if kind == "uptime_kuma":
|
||||||
|
url = _form_str(form, "url", "").strip().rstrip("/")
|
||||||
|
slug = _form_str(form, "status_page_slug", "").strip()
|
||||||
|
if not url or not slug:
|
||||||
|
return {"ok": False, "error": "URL and status page slug required"}
|
||||||
|
client = UptimeKumaClient(UptimeKumaConfig(enabled=True, url=url, status_page_slug=slug))
|
||||||
|
try:
|
||||||
|
snapshot = client.fetch_or_raise()
|
||||||
|
except requests.HTTPError as exc:
|
||||||
|
return {"ok": False, "error": f"HTTP {exc.response.status_code} — check the URL and slug"}
|
||||||
|
except (ValueError, AttributeError): # requests' JSONDecodeError is a ValueError too
|
||||||
|
return {"ok": False, "error": "response was not Uptime Kuma status-page JSON"}
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
return {"ok": False, "error": f"request failed: {exc}"}
|
||||||
|
names = sorted(snapshot.up_by_monitor)
|
||||||
|
if not names:
|
||||||
|
return {"ok": True, "message": "Connected, but the status page lists no monitors with heartbeats."}
|
||||||
|
shown = ", ".join(names[:15]) + (" …" if len(names) > 15 else "")
|
||||||
|
return {"ok": True, "message": f"Connected — {len(names)} monitor(s): {shown}"}
|
||||||
|
|
||||||
|
if kind == "portainer":
|
||||||
|
base_url = _form_str(form, "base_url", "").strip().rstrip("/")
|
||||||
|
if not base_url:
|
||||||
|
return {"ok": False, "error": "URL required"}
|
||||||
|
if _form_str(form, "token_source", "env") == "inline":
|
||||||
|
token = _form_str(form, "api_token", "")
|
||||||
|
if token == _UNCHANGED_SECRET_PLACEHOLDER:
|
||||||
|
saved = app.integrations.portainer.environments.get(_form_str(form, "env_original", ""))
|
||||||
|
token = saved.api_token if saved else ""
|
||||||
|
if not token:
|
||||||
|
return {"ok": False, "error": "API token required"}
|
||||||
|
else:
|
||||||
|
var = _form_str(form, "api_token_env", "").strip()
|
||||||
|
token = os.environ.get(var, "") if var else ""
|
||||||
|
if not token:
|
||||||
|
return {"ok": False, "error": f"environment variable {var!r} is not set in the Inspector container"}
|
||||||
|
endpoint_id = _form_int(form, "endpoint_id", 1)
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
f"{base_url}/api/stacks", headers={"X-API-Key": token},
|
||||||
|
timeout=10.0, verify=_form_bool(form, "verify_ssl"),
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
return {"ok": False, "error": f"request failed: {exc}"}
|
||||||
|
if resp.status_code in (401, 403):
|
||||||
|
return {"ok": False, "error": f"HTTP {resp.status_code} — the API token was rejected"}
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:150]}"}
|
||||||
|
try:
|
||||||
|
stacks = resp.json() or []
|
||||||
|
except ValueError:
|
||||||
|
return {"ok": False, "error": "response was not Portainer API JSON — check the URL"}
|
||||||
|
if not isinstance(stacks, list):
|
||||||
|
return {"ok": False, "error": "response was not Portainer API JSON — check the URL"}
|
||||||
|
names = sorted(st.get("Name", "?") for st in stacks if st.get("EndpointId") == endpoint_id)
|
||||||
|
if not names:
|
||||||
|
return {"ok": True, "message": f"Connected, but no stacks found on endpoint {endpoint_id}. Check the endpoint ID."}
|
||||||
|
shown = ", ".join(names[:15]) + (" …" if len(names) > 15 else "")
|
||||||
|
return {"ok": True, "message": f"Connected — {len(names)} stack(s) on endpoint {endpoint_id}: {shown}"}
|
||||||
|
|
||||||
|
return {"ok": False, "error": f"unknown integration {kind!r}"}
|
||||||
|
|
||||||
|
|
||||||
_RESTART_LABEL = (
|
_RESTART_LABEL = (
|
||||||
'<span class="restart-flag" title="takes effect after Inspector restart">'
|
'<span class="restart-flag" title="takes effect after Inspector restart">'
|
||||||
"requires restart</span>"
|
"requires restart</span>"
|
||||||
@@ -1489,6 +1755,26 @@ def _settings_secret_field(label: str, name: str, has_value: bool) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_select(
|
||||||
|
label: str, name: str, value: str, options: list[tuple[str, str]],
|
||||||
|
*, hint: str = "", onchange: str = "",
|
||||||
|
) -> str:
|
||||||
|
opts = "".join(
|
||||||
|
f'<option value="{_escape(v)}"{" selected" if v == value else ""}>{_escape(text)}</option>'
|
||||||
|
for v, text in options
|
||||||
|
)
|
||||||
|
on = f' onchange="{onchange}"' if onchange else ""
|
||||||
|
hint_html = f'<div class="field-hint">{_escape(hint)}</div>' if hint else ""
|
||||||
|
return (
|
||||||
|
f'<div class="field"><label for="s_{_escape(name)}">{_escape(label)}</label>'
|
||||||
|
f'<select id="s_{_escape(name)}" name="{_escape(name)}"{on}>{opts}</select>{hint_html}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _warn(text: str) -> str:
|
||||||
|
return f'<div class="field-warn">⚠ {text}</div>'
|
||||||
|
|
||||||
|
|
||||||
# Each tuple: (slug, label, icon, subtitle_fn, group).
|
# Each tuple: (slug, label, icon, subtitle_fn, group).
|
||||||
# Groups show up as uppercase headers in the left rail.
|
# Groups show up as uppercase headers in the left rail.
|
||||||
def _subtitle_inspector(app: AppConfig) -> str:
|
def _subtitle_inspector(app: AppConfig) -> str:
|
||||||
@@ -1527,6 +1813,20 @@ def _subtitle_notification_mute(app: AppConfig) -> str:
|
|||||||
return f"{'on' if nm.enabled else 'off'} · {nm.default_hours}h default"
|
return f"{'on' if nm.enabled else 'off'} · {nm.default_hours}h default"
|
||||||
|
|
||||||
|
|
||||||
|
def _subtitle_uptime_kuma(app: AppConfig) -> str:
|
||||||
|
uk = app.integrations.uptime_kuma
|
||||||
|
if not uk.enabled:
|
||||||
|
return "off"
|
||||||
|
n = sum(1 for c in app.containers if c.uptime_kuma.enabled)
|
||||||
|
return f"on · {n} container(s)"
|
||||||
|
|
||||||
|
|
||||||
|
def _subtitle_portainer(app: AppConfig) -> str:
|
||||||
|
pt = app.integrations.portainer
|
||||||
|
n = len(pt.environments)
|
||||||
|
return f"{'on' if pt.enabled else 'off'} · {n} environment{'s' if n != 1 else ''}"
|
||||||
|
|
||||||
|
|
||||||
_SETTINGS_SECTIONS = [
|
_SETTINGS_SECTIONS = [
|
||||||
("inspector", "Inspector loop", "⚙", _subtitle_inspector, "Core"),
|
("inspector", "Inspector loop", "⚙", _subtitle_inspector, "Core"),
|
||||||
("notifications", "Notifications", "🔔", _subtitle_notifications, "Core"),
|
("notifications", "Notifications", "🔔", _subtitle_notifications, "Core"),
|
||||||
@@ -1535,6 +1835,8 @@ _SETTINGS_SECTIONS = [
|
|||||||
("health_summary", "Health summary", "📋", _subtitle_health_summary, "Data"),
|
("health_summary", "Health summary", "📋", _subtitle_health_summary, "Data"),
|
||||||
("maintenance", "Maintenance", "🔧", _subtitle_maintenance, "Scheduling"),
|
("maintenance", "Maintenance", "🔧", _subtitle_maintenance, "Scheduling"),
|
||||||
("notification_mute", "Notification mute","🔇", _subtitle_notification_mute, "Scheduling"),
|
("notification_mute", "Notification mute","🔇", _subtitle_notification_mute, "Scheduling"),
|
||||||
|
("uptime_kuma", "Uptime Kuma", "📡", _subtitle_uptime_kuma, "Integrations"),
|
||||||
|
("portainer", "Portainer", "🐳", _subtitle_portainer, "Integrations"),
|
||||||
]
|
]
|
||||||
_SETTINGS_SECTION_SLUGS = {slug for slug, *_ in _SETTINGS_SECTIONS}
|
_SETTINGS_SECTION_SLUGS = {slug for slug, *_ in _SETTINGS_SECTIONS}
|
||||||
|
|
||||||
@@ -1621,18 +1923,18 @@ def _settings_form_with_test(section: str, title: str, body: str, test_row: str,
|
|||||||
|
|
||||||
_TEST_NOTIFY_JS = """
|
_TEST_NOTIFY_JS = """
|
||||||
<script>
|
<script>
|
||||||
window.InspectorTest = function(btn, channel) {
|
window.InspectorTest = function(btn, channel, endpoint) {
|
||||||
var form = btn.closest('form');
|
var form = btn.closest('form');
|
||||||
var resultEl = btn.parentElement.querySelector('.test-result');
|
var resultEl = btn.parentElement.querySelector('.test-result');
|
||||||
var data = new URLSearchParams();
|
var data = new URLSearchParams();
|
||||||
form.querySelectorAll('input[name]').forEach(function(el) {
|
form.querySelectorAll('input[name], select[name]').forEach(function(el) {
|
||||||
if (el.type === 'checkbox') { if (el.checked) data.append(el.name, 'on'); }
|
if (el.type === 'checkbox') { if (el.checked) data.append(el.name, 'on'); }
|
||||||
else { data.append(el.name, el.value); }
|
else { data.append(el.name, el.value); }
|
||||||
});
|
});
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
resultEl.className = 'test-result';
|
resultEl.className = 'test-result';
|
||||||
resultEl.textContent = 'Sending test...';
|
resultEl.textContent = endpoint ? 'Testing...' : 'Sending test...';
|
||||||
fetch('/api/test-notify/' + channel, {
|
fetch((endpoint || '/api/test-notify/') + channel, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
body: data.toString(),
|
body: data.toString(),
|
||||||
@@ -1641,7 +1943,7 @@ window.InspectorTest = function(btn, channel) {
|
|||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
if (j.ok) {
|
if (j.ok) {
|
||||||
resultEl.className = 'test-result ok';
|
resultEl.className = 'test-result ok';
|
||||||
resultEl.textContent = '✓ Sent. Check your device.';
|
resultEl.textContent = '✓ ' + (j.message || 'Sent. Check your device.');
|
||||||
} else {
|
} else {
|
||||||
resultEl.className = 'test-result err';
|
resultEl.className = 'test-result err';
|
||||||
resultEl.textContent = '✗ ' + (j.error || 'Failed');
|
resultEl.textContent = '✗ ' + (j.error || 'Failed');
|
||||||
@@ -1826,9 +2128,137 @@ def _settings_body_for(section: str, app: AppConfig, csrf_token: str) -> str:
|
|||||||
+ _settings_form("notification_mute", "Notification mute", body, csrf_token)
|
+ _settings_form("notification_mute", "Notification mute", body, csrf_token)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if section == "uptime_kuma":
|
||||||
|
uk = app.integrations.uptime_kuma
|
||||||
|
body = (
|
||||||
|
_settings_field("enabled", "enabled", "on" if uk.enabled else "", kind="switch")
|
||||||
|
+ _settings_field("url", "url", uk.url, hint="Base URL of your Uptime Kuma, e.g. https://status.example.com")
|
||||||
|
+ _settings_field("status_page_slug", "status_page_slug", uk.status_page_slug,
|
||||||
|
hint="The slug from the status page URL (/status/<slug>). The page must list the monitors you want to use.")
|
||||||
|
+ _test_row("uptime_kuma", "Fetches the status page using the values above and lists its monitors.")
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<h3 class="pane-title">Uptime Kuma</h3>'
|
||||||
|
'<p class="pane-sub">Use an Uptime Kuma monitor as a second opinion: when the monitor is down, '
|
||||||
|
"Inspector reports the container as <b>UNREACHABLE</b> even if Docker says it is healthy. "
|
||||||
|
"Turn it on per container under <b>Reachability</b>.</p>"
|
||||||
|
+ _settings_form("uptime_kuma", "Uptime Kuma", body, csrf_token)
|
||||||
|
)
|
||||||
|
|
||||||
|
if section == "portainer":
|
||||||
|
return _portainer_section_body(app, csrf_token)
|
||||||
|
|
||||||
return f"<p>Unknown section: {_escape(section)}</p>"
|
return f"<p>Unknown section: {_escape(section)}</p>"
|
||||||
|
|
||||||
|
|
||||||
|
def _test_row(kind: str, note: str) -> str:
|
||||||
|
return (
|
||||||
|
'<div class="test-row">'
|
||||||
|
f'<button type="button" class="btn-ghost" onclick="InspectorTest(this, \'{kind}\', \'/api/test-integration/\')">'
|
||||||
|
"Test connection</button>"
|
||||||
|
f'<span class="test-result">{_escape(note)}</span></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN_SOURCE_JS = (
|
||||||
|
"<script>window.InspectorTokenSrc=function(sel){"
|
||||||
|
"sel.closest('form').querySelectorAll('.token-src').forEach(function(el){"
|
||||||
|
"el.hidden=el.dataset.src!==sel.value;});};</script>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _portainer_env_form(env_name: str, env, csrf_token: str, *, suggest_name: str = "") -> str:
|
||||||
|
"""Add/edit form for one Portainer environment. `env` is None for the add form."""
|
||||||
|
inline = bool(env and env.api_token and not env.api_token_env)
|
||||||
|
source = "inline" if inline else "env"
|
||||||
|
body = (
|
||||||
|
f'<input type="hidden" name="env_original" value="{_escape(env_name)}">'
|
||||||
|
'<div class="grid-2">'
|
||||||
|
+ _settings_field("name", "env_name", env_name or suggest_name,
|
||||||
|
hint="Your own label, referenced by containers")
|
||||||
|
+ _settings_field("endpoint_id", "endpoint_id", str(env.endpoint_id if env else 1), kind="number",
|
||||||
|
hint="Open the environment from Portainer's Home page; the number after #!/ in the address (often 1 or 2)")
|
||||||
|
+ "</div>"
|
||||||
|
+ _settings_field("base_url", "base_url", env.base_url if env else "",
|
||||||
|
hint="e.g. http://192.168.1.10:9000 or https://portainer.example.com")
|
||||||
|
+ _settings_select(
|
||||||
|
"API token stored as", "token_source", source,
|
||||||
|
[("env", "Environment variable (recommended)"), ("inline", "Saved in config.yml")],
|
||||||
|
hint="Create a token in Portainer → My account → Access tokens.",
|
||||||
|
onchange="InspectorTokenSrc(this)",
|
||||||
|
)
|
||||||
|
+ f'<div class="token-src" data-src="env"{" hidden" if inline else ""}>'
|
||||||
|
+ _settings_field("environment variable name", "api_token_env",
|
||||||
|
(env.api_token_env if env else "") or "PORTAINER_API_TOKEN",
|
||||||
|
hint="Add it to the Inspector stack's environment variables; the compose file must pass it in. Step by step: Help → Portainer stack redeploy, step 2.")
|
||||||
|
+ "</div>"
|
||||||
|
+ f'<div class="token-src" data-src="inline"{"" if inline else " hidden"}>'
|
||||||
|
+ _settings_secret_field("api_token", "api_token", bool(env and env.api_token))
|
||||||
|
+ "</div>"
|
||||||
|
+ _settings_field("verify_ssl", "verify_ssl", "on" if (env.verify_ssl if env else False) else "", kind="switch",
|
||||||
|
hint="Turn off for Portainer's default self-signed certificate or plain http")
|
||||||
|
+ _test_row("portainer", "Lists this environment's stacks using the values above (even if unsaved).")
|
||||||
|
)
|
||||||
|
delete = ""
|
||||||
|
if env is not None:
|
||||||
|
delete = (
|
||||||
|
'<button type="submit" name="delete" value="1" class="btn-danger" '
|
||||||
|
f"onclick=\"return confirm('Delete Portainer environment {_escape(env_name)}?');\">Delete</button>"
|
||||||
|
)
|
||||||
|
label = "environment" if env is not None else "new environment"
|
||||||
|
return (
|
||||||
|
'<form method="post" action="/settings/portainer_env" class="form-group">'
|
||||||
|
f'<h4>{_escape(env_name) if env is not None else "Add environment"}</h4>'
|
||||||
|
f'<input type="hidden" name="csrf" value="{_escape(csrf_token)}">'
|
||||||
|
f"{body}"
|
||||||
|
f'<div class="form-actions">{delete}'
|
||||||
|
'<button class="btn" type="submit" style="background:var(--blue); color:#06111f; border:none; '
|
||||||
|
'border-radius:8px; padding:0.55rem 1rem; font-weight:700; cursor:pointer; font-family:inherit;">'
|
||||||
|
f"Save {label}</button></div></form>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _portainer_section_body(app: AppConfig, csrf_token: str) -> str:
|
||||||
|
pt = app.integrations.portainer
|
||||||
|
env_options = [("", "Automatic (only if one environment exists)")] + [(n, n) for n in pt.environments]
|
||||||
|
body = (
|
||||||
|
_settings_field("enabled", "enabled", "on" if pt.enabled else "", kind="switch")
|
||||||
|
+ _settings_select("default_environment", "default_environment", pt.default_environment, env_options,
|
||||||
|
hint="Used by containers that don't pick an environment")
|
||||||
|
+ '<div class="grid-2">'
|
||||||
|
+ _settings_field("stack_redeploy_cooldown_seconds", "stack_redeploy_cooldown_seconds",
|
||||||
|
str(pt.stack_redeploy_cooldown_seconds), kind="number",
|
||||||
|
hint="Minimum seconds between redeploys of the same stack")
|
||||||
|
+ _settings_field("post_redeploy_wait_seconds", "post_redeploy_wait_seconds",
|
||||||
|
str(pt.post_redeploy_wait_seconds), kind="number",
|
||||||
|
hint="Wait this long after a redeploy before checking again")
|
||||||
|
+ "</div>"
|
||||||
|
)
|
||||||
|
users = [c.name for c in app.containers if c.recovery_action in _PORTAINER_ACTIONS]
|
||||||
|
warn = ""
|
||||||
|
if pt.enabled and not pt.environments:
|
||||||
|
warn = _warn("Enabled, but no environment is configured yet. Add one below.")
|
||||||
|
elif not pt.enabled and users:
|
||||||
|
warn = _warn(f"Off, but {_escape(', '.join(users))} use stack redeploy. Their redeploys will fail until this is enabled.")
|
||||||
|
envs = "".join(_portainer_env_form(n, e, csrf_token) for n, e in pt.environments.items())
|
||||||
|
used_by = (
|
||||||
|
f'<p class="subsection-sub">Containers using stack redeploy: <b>{_escape(", ".join(users))}</b></p>'
|
||||||
|
if users else '<p class="subsection-sub">No container uses stack redeploy yet. Set it per container under <b>Recovery</b>.</p>'
|
||||||
|
)
|
||||||
|
add_form = _portainer_env_form("", None, csrf_token, suggest_name="" if pt.environments else "local")
|
||||||
|
return (
|
||||||
|
'<h3 class="pane-title">Portainer</h3>'
|
||||||
|
'<p class="pane-sub">Lets Inspector redeploy a whole Portainer stack. Needed for containers that share '
|
||||||
|
"another container's network (for example apps routed through a VPN container): when the VPN container is "
|
||||||
|
"recreated, a plain restart fails and only a redeploy reconnects them.</p>"
|
||||||
|
+ '<div class="subsection">' + warn
|
||||||
|
+ _settings_form("portainer", "Portainer", body, csrf_token) + "</div>"
|
||||||
|
+ '<div class="subsection"><h4>Environments</h4>' + used_by
|
||||||
|
+ envs + add_form + "</div>"
|
||||||
|
+ _TOKEN_SOURCE_JS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_flash(flash: str) -> str:
|
def _render_flash(flash: str) -> str:
|
||||||
if not flash:
|
if not flash:
|
||||||
return ""
|
return ""
|
||||||
@@ -1855,12 +2285,27 @@ def _render_settings_page(app: AppConfig, config_ref: ConfigRef, csrf_token: str
|
|||||||
parts.append(_settings_body_for(section, app, csrf_token))
|
parts.append(_settings_body_for(section, app, csrf_token))
|
||||||
parts.append("</section></div>\n")
|
parts.append("</section></div>\n")
|
||||||
parts.append(_render_diag_strip(config_ref))
|
parts.append(_render_diag_strip(config_ref))
|
||||||
if section == "notifications":
|
if section in ("notifications", "uptime_kuma", "portainer"):
|
||||||
parts.append(_TEST_NOTIFY_JS)
|
parts.append(_TEST_NOTIFY_JS)
|
||||||
parts.append(_PAGE_TAIL)
|
parts.append(_PAGE_TAIL)
|
||||||
return "".join(parts).encode("utf-8")
|
return "".join(parts).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _container_extras(c: ContainerConfig) -> str:
|
||||||
|
"""One-line summary of the non-default checks / recovery for the grid tile."""
|
||||||
|
bits = []
|
||||||
|
if c.http_check.enabled:
|
||||||
|
bits.append("HTTP check")
|
||||||
|
if c.uptime_kuma.enabled:
|
||||||
|
bits.append("Uptime Kuma")
|
||||||
|
if c.depends_on:
|
||||||
|
bits.append(f"after {c.depends_on}" + (" (VPN)" if c.vpn_routed else ""))
|
||||||
|
label = next((lbl for v, lbl, _ in _RECOVERY_ACTION_OPTIONS if v == c.recovery_action and v), "")
|
||||||
|
if label:
|
||||||
|
bits.append(label.lower())
|
||||||
|
return " · ".join(bits)
|
||||||
|
|
||||||
|
|
||||||
def _render_containers_page(app: AppConfig, config_ref: ConfigRef, states: dict, flash: str) -> bytes:
|
def _render_containers_page(app: AppConfig, config_ref: ConfigRef, states: dict, flash: str) -> bytes:
|
||||||
parts = [_page_head(refresh=False, active="containers")]
|
parts = [_page_head(refresh=False, active="containers")]
|
||||||
parts.append(_render_flash(flash))
|
parts.append(_render_flash(flash))
|
||||||
@@ -1883,6 +2328,8 @@ def _render_containers_page(app: AppConfig, config_ref: ConfigRef, states: dict,
|
|||||||
state = states.get(c.name)
|
state = states.get(c.name)
|
||||||
status = state.current_status if state and state.current_status else "—"
|
status = state.current_status if state and state.current_status else "—"
|
||||||
restart_on = ", ".join(sorted(c.restart_on)) if c.restart_on else "observe only"
|
restart_on = ", ".join(sorted(c.restart_on)) if c.restart_on else "observe only"
|
||||||
|
extras = _container_extras(c)
|
||||||
|
extras_html = f'<span style="flex-basis:100%;">Extras: <b>{_escape(extras)}</b></span>' if extras else ""
|
||||||
parts.append(
|
parts.append(
|
||||||
f'<a class="container-tile" href="/containers/{_escape(c.name)}" '
|
f'<a class="container-tile" href="/containers/{_escape(c.name)}" '
|
||||||
f'style="--col:{_escape(color)}">'
|
f'style="--col:{_escape(color)}">'
|
||||||
@@ -1894,7 +2341,8 @@ def _render_containers_page(app: AppConfig, config_ref: ConfigRef, states: dict,
|
|||||||
f'<span>Max errors: <b>{c.max_error_count}</b></span>'
|
f'<span>Max errors: <b>{c.max_error_count}</b></span>'
|
||||||
f'<span>Cooldown: <b>{c.cooldown_seconds}s</b></span>'
|
f'<span>Cooldown: <b>{c.cooldown_seconds}s</b></span>'
|
||||||
f'<span style="flex-basis:100%; margin-top:0.2rem;">Restart on: <b>{_escape(restart_on)}</b></span>'
|
f'<span style="flex-basis:100%; margin-top:0.2rem;">Restart on: <b>{_escape(restart_on)}</b></span>'
|
||||||
"</div></a>\n"
|
+ extras_html
|
||||||
|
+ "</div></a>\n"
|
||||||
)
|
)
|
||||||
parts.append("</div>\n")
|
parts.append("</div>\n")
|
||||||
parts.append("</section></div>\n")
|
parts.append("</section></div>\n")
|
||||||
@@ -1957,9 +2405,113 @@ _RESTART_ON_JS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_RECOVERY_JS = (
|
||||||
|
"<script>window.InspectorRecovery=function(sel){"
|
||||||
|
"var on=" + json.dumps(list(_PORTAINER_ACTIONS)) + ".indexOf(sel.value)>=0;"
|
||||||
|
"sel.closest('form').querySelectorAll('.portainer-only').forEach(function(el){el.hidden=!on;});};</script>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_container_advanced(name: str, cfg, app: AppConfig) -> str:
|
||||||
|
"""Reachability / Dependencies / Recovery groups for the container form."""
|
||||||
|
hc = getattr(cfg, "http_check", None) or HttpCheckConfig()
|
||||||
|
ukm = getattr(cfg, "uptime_kuma", None) or UptimeKumaMonitorConfig()
|
||||||
|
uk_global = app.integrations.uptime_kuma
|
||||||
|
pt = app.integrations.portainer
|
||||||
|
action = getattr(cfg, "recovery_action", "") or ""
|
||||||
|
if action == "restart_container": # same as the default option
|
||||||
|
action = ""
|
||||||
|
uses_portainer = action in _PORTAINER_ACTIONS
|
||||||
|
timeout = hc.timeout_seconds
|
||||||
|
timeout_str = str(int(timeout)) if float(timeout).is_integer() else str(timeout)
|
||||||
|
|
||||||
|
uk_warn = ""
|
||||||
|
if not (uk_global.enabled and uk_global.url and uk_global.status_page_slug):
|
||||||
|
uk_warn = _warn('The Uptime Kuma integration is off. Set it up in '
|
||||||
|
'<a href="/settings/uptime_kuma">Settings → Uptime Kuma</a> or this check is ignored.')
|
||||||
|
reach = (
|
||||||
|
'<div class="form-group"><h4>Reachability</h4>'
|
||||||
|
'<p class="group-sub">Docker can say a container is healthy when nobody can reach it, for example when '
|
||||||
|
"its VPN tunnel dropped. A failed check reports <b>UNREACHABLE</b>; tick UNREACHABLE above to restart on it.</p>"
|
||||||
|
'<div class="form-group"><h4>HTTP check</h4>'
|
||||||
|
+ _settings_field("enabled", "http_check_enabled", "on" if hc.enabled else "", kind="switch")
|
||||||
|
+ _settings_field("url", "http_check_url", hc.url,
|
||||||
|
hint="Address Inspector requests, e.g. http://192.168.1.10:9696/ping. Use an IP or a name Inspector can resolve.")
|
||||||
|
+ '<div class="grid-2">'
|
||||||
|
+ _settings_field("timeout_seconds", "http_check_timeout_seconds", timeout_str, kind="number")
|
||||||
|
+ _settings_field("expected_status", "http_check_expected_status", str(hc.expected_status), kind="number",
|
||||||
|
hint="Exact HTTP code a healthy service returns (often 200; some apps return 401 or 302)")
|
||||||
|
+ "</div></div>"
|
||||||
|
'<div class="form-group"><h4>Uptime Kuma monitor</h4>'
|
||||||
|
+ _settings_field("enabled", "uptime_kuma_enabled", "on" if ukm.enabled else "", kind="switch")
|
||||||
|
+ _settings_field("monitor_name", "uptime_kuma_monitor_name", ukm.monitor_name,
|
||||||
|
hint="Exact monitor name as shown on your status page. If both checks are on, the HTTP check wins.")
|
||||||
|
+ uk_warn
|
||||||
|
+ "</div></div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
others = [c.name for c in app.containers if c.name != name]
|
||||||
|
disabled = {c.name for c in app.containers if not c.enabled}
|
||||||
|
dep_value = getattr(cfg, "depends_on", "") or ""
|
||||||
|
dep_options = [("", "— none —")] + [(n, n + (" (disabled)" if n in disabled else "")) for n in others]
|
||||||
|
if dep_value and dep_value not in others:
|
||||||
|
dep_options.append((dep_value, f"{dep_value} (not configured)"))
|
||||||
|
dep_warn = ""
|
||||||
|
if dep_value in disabled:
|
||||||
|
dep_warn = _warn(f"{_escape(dep_value)} is disabled, so Inspector can't see its status and won't wait for it. "
|
||||||
|
"Enable it to make this dependency count.")
|
||||||
|
deps = (
|
||||||
|
'<div class="form-group"><h4>Dependencies</h4>'
|
||||||
|
'<p class="group-sub">Check this container after the one it relies on, and hold off recovering it '
|
||||||
|
"while that one is still failing.</p>"
|
||||||
|
+ _settings_select("depends_on", "depends_on", dep_value, dep_options,
|
||||||
|
hint="e.g. the VPN container, for apps that use network_mode: container:<vpn>. "
|
||||||
|
"Also works for any other dependency, like an app and its database.")
|
||||||
|
+ dep_warn
|
||||||
|
+ _settings_field("vpn_routed", "vpn_routed", "on" if getattr(cfg, "vpn_routed", False) else "", kind="switch",
|
||||||
|
hint="Only affects logging: says in the log when recovery waits on the VPN container. The waiting itself comes from depends_on.")
|
||||||
|
+ "</div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
action_help = '<ul class="action-help">' + "".join(
|
||||||
|
f"<li><b>{_escape(label)}</b> — {_escape(desc)}</li>" for _, label, desc in _RECOVERY_ACTION_OPTIONS
|
||||||
|
) + "</ul>"
|
||||||
|
env_value = getattr(cfg, "portainer_environment", "") or ""
|
||||||
|
default_label = f"Default ({pt.default_environment})" if pt.default_environment else "Default"
|
||||||
|
env_options = [("", default_label)] + [(n, n) for n in pt.environments]
|
||||||
|
if env_value and env_value not in pt.environments:
|
||||||
|
env_options.append((env_value, f"{env_value} (not configured)"))
|
||||||
|
pt_warn = ""
|
||||||
|
if not pt.environments:
|
||||||
|
pt_warn = _warn('Portainer isn\'t set up yet. Add an environment in '
|
||||||
|
'<a href="/settings/portainer">Settings → Portainer</a> before relying on a redeploy.')
|
||||||
|
elif not pt.enabled:
|
||||||
|
pt_warn = _warn('The Portainer integration is off. Turn it on in '
|
||||||
|
'<a href="/settings/portainer">Settings → Portainer</a> or redeploys will fail.')
|
||||||
|
hidden = "" if uses_portainer else " hidden"
|
||||||
|
recovery = (
|
||||||
|
'<div class="form-group"><h4>Recovery</h4>'
|
||||||
|
'<p class="group-sub">What Inspector does once the restart conditions above are met.</p>'
|
||||||
|
+ _settings_select("recovery_action", "recovery_action", action,
|
||||||
|
[(v, label) for v, label, _ in _RECOVERY_ACTION_OPTIONS],
|
||||||
|
onchange="InspectorRecovery(this)")
|
||||||
|
+ action_help
|
||||||
|
+ f'<div class="portainer-only"{hidden}>'
|
||||||
|
+ '<div class="grid-2">'
|
||||||
|
+ _settings_select("portainer_environment", "portainer_environment", env_value, env_options)
|
||||||
|
+ _settings_field("portainer_stack_name", "portainer_stack_name",
|
||||||
|
getattr(cfg, "portainer_stack_name", "") or "",
|
||||||
|
hint="Stack name exactly as shown in Portainer → Stacks")
|
||||||
|
+ "</div>" + pt_warn + "</div>"
|
||||||
|
+ "</div>"
|
||||||
|
+ _RECOVERY_JS
|
||||||
|
)
|
||||||
|
return '<input type="hidden" name="advanced_present" value="1">' + reach + deps + recovery
|
||||||
|
|
||||||
|
|
||||||
def _render_container_form(
|
def _render_container_form(
|
||||||
name: str, cfg, csrf_token: str, action: str, submit_label: str,
|
name: str, cfg, csrf_token: str, action: str, submit_label: str,
|
||||||
*, show_name_field: bool = False, show_delete: bool = False,
|
*, app: AppConfig, show_name_field: bool = False, show_delete: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build the container edit form. `cfg` may be a ContainerConfig or a dummy with defaults."""
|
"""Build the container edit form. `cfg` may be a ContainerConfig or a dummy with defaults."""
|
||||||
current_restart_on = getattr(cfg, "restart_on", []) or []
|
current_restart_on = getattr(cfg, "restart_on", []) or []
|
||||||
@@ -2021,6 +2573,7 @@ def _render_container_form(
|
|||||||
requires_restart=True)
|
requires_restart=True)
|
||||||
+ _render_color_input("color", getattr(cfg, "color", ""))
|
+ _render_color_input("color", getattr(cfg, "color", ""))
|
||||||
+ restart_on_html
|
+ restart_on_html
|
||||||
|
+ _render_container_advanced(name, cfg, app)
|
||||||
)
|
)
|
||||||
delete_btn = ""
|
delete_btn = ""
|
||||||
if show_delete:
|
if show_delete:
|
||||||
@@ -2056,6 +2609,7 @@ def _render_container_edit_page(app: AppConfig, config_ref: ConfigRef, name: str
|
|||||||
name, cfg, csrf_token,
|
name, cfg, csrf_token,
|
||||||
action=f"/containers/{_escape(name)}",
|
action=f"/containers/{_escape(name)}",
|
||||||
submit_label="Save container",
|
submit_label="Save container",
|
||||||
|
app=app,
|
||||||
show_delete=True,
|
show_delete=True,
|
||||||
)
|
)
|
||||||
parts = [_page_head(refresh=False, active="containers")]
|
parts = [_page_head(refresh=False, active="containers")]
|
||||||
@@ -2065,7 +2619,7 @@ def _render_container_edit_page(app: AppConfig, config_ref: ConfigRef, name: str
|
|||||||
parts.append('<section class="settings-pane">')
|
parts.append('<section class="settings-pane">')
|
||||||
parts.append(
|
parts.append(
|
||||||
f'<h3 class="pane-title">Container — <code style="font-size:0.95em;">{_escape(name)}</code></h3>'
|
f'<h3 class="pane-title">Container — <code style="font-size:0.95em;">{_escape(name)}</code></h3>'
|
||||||
'<p class="pane-sub">Restart rules Inspector applies when this container\'s health changes.</p>'
|
'<p class="pane-sub">How Inspector checks this container and what it does when it fails.</p>'
|
||||||
)
|
)
|
||||||
parts.append(form_html)
|
parts.append(form_html)
|
||||||
parts.append("</section></div>\n")
|
parts.append("</section></div>\n")
|
||||||
@@ -2091,6 +2645,7 @@ def _render_container_new_page(app: AppConfig, config_ref: ConfigRef, csrf_token
|
|||||||
"", _NewContainerDefaults(), csrf_token,
|
"", _NewContainerDefaults(), csrf_token,
|
||||||
action="/containers/new",
|
action="/containers/new",
|
||||||
submit_label="Create container",
|
submit_label="Create container",
|
||||||
|
app=app,
|
||||||
show_name_field=True,
|
show_name_field=True,
|
||||||
)
|
)
|
||||||
parts = [_page_head(refresh=False, active="containers")]
|
parts = [_page_head(refresh=False, active="containers")]
|
||||||
@@ -2101,9 +2656,7 @@ def _render_container_new_page(app: AppConfig, config_ref: ConfigRef, csrf_token
|
|||||||
parts.append(
|
parts.append(
|
||||||
'<h3 class="pane-title">New container</h3>'
|
'<h3 class="pane-title">New container</h3>'
|
||||||
'<p class="pane-sub">Add a container to <code>config.yml</code>. '
|
'<p class="pane-sub">Add a container to <code>config.yml</code>. '
|
||||||
"The new entry is picked up automatically on the next polling cycle; "
|
"The new entry is picked up automatically on the next polling cycle.</p>"
|
||||||
"advanced fields (Portainer stack, http_check, uptime_kuma, depends_on, "
|
|
||||||
"vpn_routed) remain YAML-only for now.</p>"
|
|
||||||
)
|
)
|
||||||
parts.append(form_html)
|
parts.append(form_html)
|
||||||
parts.append("</section></div>\n")
|
parts.append("</section></div>\n")
|
||||||
@@ -2443,6 +2996,8 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
# else matches the section slug.
|
# else matches the section slug.
|
||||||
if section in ("gotify", "pushover"):
|
if section in ("gotify", "pushover"):
|
||||||
return_path = "/settings/notifications"
|
return_path = "/settings/notifications"
|
||||||
|
elif section == "portainer_env":
|
||||||
|
return_path = "/settings/portainer"
|
||||||
else:
|
else:
|
||||||
return_path = f"/settings/{section}"
|
return_path = f"/settings/{section}"
|
||||||
try:
|
try:
|
||||||
@@ -2454,8 +3009,9 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_header("Content-Length", "0")
|
self.send_header("Content-Length", "0")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
return
|
return
|
||||||
|
ok_label = "Portainer environment" if section == "portainer_env" else section
|
||||||
self.send_response(HTTPStatus.SEE_OTHER)
|
self.send_response(HTTPStatus.SEE_OTHER)
|
||||||
self.send_header("Location", f"{return_path}?flash={_url_quote('ok:' + section)}")
|
self.send_header("Location", f"{return_path}?flash={_url_quote('ok:' + ok_label)}")
|
||||||
self.send_header("Content-Length", "0")
|
self.send_header("Content-Length", "0")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
return
|
return
|
||||||
@@ -2526,6 +3082,13 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
self._send_bytes(HTTPStatus.OK, "application/json", body)
|
self._send_bytes(HTTPStatus.OK, "application/json", body)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# POST /api/test-integration/<kind> — check an Uptime Kuma / Portainer connection
|
||||||
|
if len(parts) == 3 and parts[0] == "api" and parts[1] == "test-integration":
|
||||||
|
payload = _run_test_integration(parts[2], form, app)
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
self._send_bytes(HTTPStatus.OK, "application/json", body)
|
||||||
|
return
|
||||||
|
|
||||||
self._send_bytes(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"not found")
|
self._send_bytes(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"not found")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+16
-3
@@ -293,8 +293,10 @@ def _check_one(
|
|||||||
|
|
||||||
if ok:
|
if ok:
|
||||||
dep = container_config.depends_on
|
dep = container_config.depends_on
|
||||||
if dep:
|
# Only wait on a dependency Inspector is watching. A disabled one has
|
||||||
dep_status = states[dep].current_status if dep in states else None
|
# no state, and waiting on it would block recovery forever.
|
||||||
|
if dep and dep in states:
|
||||||
|
dep_status = states[dep].current_status
|
||||||
if dep_status not in (HEALTHY, STARTING, NO_HEALTH_CHECK):
|
if dep_status not in (HEALTHY, STARTING, NO_HEALTH_CHECK):
|
||||||
if container_config.vpn_routed:
|
if container_config.vpn_routed:
|
||||||
log.info(
|
log.info(
|
||||||
@@ -417,6 +419,7 @@ def run_loop(config_ref: ConfigRef) -> int:
|
|||||||
|
|
||||||
check_order = _topological_order(enabled_configs)
|
check_order = _topological_order(enabled_configs)
|
||||||
portainer_client = PortainerClient(app.integrations.portainer)
|
portainer_client = PortainerClient(app.integrations.portainer)
|
||||||
|
integrations_cfg = app.integrations
|
||||||
stack_cooldowns: dict[str, datetime] = {}
|
stack_cooldowns: dict[str, datetime] = {}
|
||||||
|
|
||||||
dashboard = DashboardServer(
|
dashboard = DashboardServer(
|
||||||
@@ -453,8 +456,18 @@ def run_loop(config_ref: ConfigRef) -> int:
|
|||||||
for gone in states.keys() - current_enabled.keys():
|
for gone in states.keys() - current_enabled.keys():
|
||||||
states.pop(gone, None)
|
states.pop(gone, None)
|
||||||
log.info("Stopped watching container (removed or disabled): %s", gone)
|
log.info("Stopped watching container (removed or disabled): %s", gone)
|
||||||
|
if current_enabled != enabled_configs:
|
||||||
|
# Only re-order (and re-log ordering warnings) when the config changed.
|
||||||
|
check_order = _topological_order(current_enabled)
|
||||||
enabled_configs = current_enabled
|
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()
|
uptime_kuma_snapshot = uptime_kuma_client.fetch()
|
||||||
for name in check_order:
|
for name in check_order:
|
||||||
|
|||||||
@@ -41,20 +41,26 @@ class UptimeKumaClient:
|
|||||||
and bool(self._config.status_page_slug)
|
and bool(self._config.status_page_slug)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _heartbeat_url(self) -> str:
|
||||||
|
return f"{self._config.url}/api/status-page/heartbeat/{self._config.status_page_slug}"
|
||||||
|
|
||||||
def fetch(self) -> HeartbeatSnapshot:
|
def fetch(self) -> HeartbeatSnapshot:
|
||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
return _EMPTY
|
return _EMPTY
|
||||||
|
|
||||||
url = f"{self._config.url}/api/status-page/heartbeat/{self._config.status_page_slug}"
|
|
||||||
try:
|
try:
|
||||||
resp = requests.get(url, timeout=self._timeout)
|
return self.fetch_or_raise()
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
return self._parse(data)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("Uptime Kuma heartbeat fetch failed (%s): %s", url, exc)
|
log.warning("Uptime Kuma heartbeat fetch failed (%s): %s", self._heartbeat_url(), exc)
|
||||||
return _EMPTY
|
return _EMPTY
|
||||||
|
|
||||||
|
def fetch_or_raise(self) -> HeartbeatSnapshot:
|
||||||
|
"""Like fetch(), but lets request/parse errors propagate (used by the
|
||||||
|
settings page's "Test connection" button)."""
|
||||||
|
resp = requests.get(self._heartbeat_url(), timeout=self._timeout)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return self._parse(resp.json())
|
||||||
|
|
||||||
def _parse(self, data: dict) -> HeartbeatSnapshot:
|
def _parse(self, data: dict) -> HeartbeatSnapshot:
|
||||||
# Expected shape:
|
# Expected shape:
|
||||||
# {"publicGroupList": [{"monitorList": [{"id": 1, "name": "..."}]}],
|
# {"publicGroupList": [{"monitorList": [{"id": 1, "name": "..."}]}],
|
||||||
|
|||||||
Reference in new issue
Block a user