Release v0.8.0 — Portainer-first install, in-app guide

- Install from Portainer with no command line: paste the compose, set a
  folder and a password, deploy. Inspector creates its own config and
  detects Docker socket permissions by itself.
- Illustrated how-to guide inside the app (Help tab) and in the repo.
- Containers added in the UI are watched right away, no restart.
- maintenance_window switch on the container form.
- README with logo and screenshots; INSTALL rewritten for Portainer.
This commit is contained in:
Europium 63 committed 2026-10-27 15:45:33 -07:00
1 parent 4c05a4d565
commit c603e86e75
21 files changed
+789 -202

No files matched your search

+1 -1
View File
@@ -1 +1 @@
__version__ = "0.7.4"
__version__ = "0.8.0"
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+272
View File
@@ -0,0 +1,272 @@
<!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://&lt;your-server&gt;:8080</code> and sign in.</li>
<li>Change the password under <b>Settings → Dashboard</b> if you like.</li>
</ol>
</section>
<section id="add">
<h2>2. Add your containers</h2>
<p>Inspector only watches containers you add. Go to <b>Settings</b> and click
<b>+ Add container</b> at the bottom of the left menu (or <b>+ New container</b> on the
All containers page).</p>
<figure><img src="container-new.png" alt="New container form">
<figcaption>The New container form.</figcaption></figure>
<ol class="steps">
<li><b>name</b>: the container's name exactly as Portainer shows it
(for example <code>sonarr</code>). If it doesn't match, the card shows
<span class="tag t-amber">CONTAINER NOT FOUND</span>.</li>
<li>Leave <b>enabled</b> on.</li>
<li>Under <b>Restart when status is…</b> click <b>Recommended</b>
(restarts on <code>UNHEALTHY</code> and <code>NOT RUNNING</code>), or
<b>Observe only</b> if you just want alerts without restarts.</li>
<li>Click <b>Create container</b>. Inspector starts watching it on its next check
(within a minute by default). No restart needed.</li>
</ol>
<h3>What the numbers mean</h3>
<table>
<tr><th>Field</th><th>Meaning</th><th>Default</th></tr>
<tr><td>max_error_count</td><td>How many bad checks in a row before Inspector restarts the container. Stops one blip from causing a restart.</td><td>5</td></tr>
<tr><td>cooldown_seconds</td><td>Minimum time between two restarts of the same container.</td><td>300</td></tr>
<tr><td>grace_period_seconds</td><td>After a restart, ignore failures for this long so the app can boot.</td><td>120</td></tr>
<tr><td>max_restarts_per_hour</td><td>Safety cap. After this many restarts in an hour Inspector stops restarting and alerts you instead.</td><td>3</td></tr>
<tr><td>maintenance_window</td><td>Follow the daily maintenance window from Settings → Maintenance.</td><td>off</td></tr>
<tr><td>color</td><td>Card color on the dashboard. Pick a swatch or leave blank for automatic.</td><td>auto</td></tr>
</table>
<div class="note">With the default 60-second check interval and <code>max_error_count</code> 5, a
container has to be broken for about five minutes before Inspector restarts it. Lower the count
for faster recovery.</div>
</section>
<section id="status">
<h2>3. The Status page</h2>
<figure><img src="status.png" alt="Status page">
<figcaption>Status: one card per watched container.</figcaption></figure>
<p>The top row counts containers, healthy ones, ones with issues, and ones in maintenance.
Each card shows:</p>
<ul>
<li><b>Status badge</b>: what Docker reports right now, e.g.
<span class="tag t-green">HEALTHY</span> <span class="tag t-red">NOT RUNNING</span>
<span class="tag t-violet">NO HEALTH CHECK</span> (the image has no health check; that's normal
for many apps and only means Inspector can tell running from stopped, not healthy from unhealthy).</li>
<li><b>Errors bar</b>: bad checks in a row out of <code>max_error_count</code>. When it fills,
Inspector restarts the container.</li>
<li><b>Cooldown until / Last restart</b>: when Inspector last restarted it and when it's allowed to
again.</li>
<li><b>Observe only</b>: shown when the container has no restart triggers; Inspector alerts but
never restarts it.</li>
<li><b>Enter maintenance</b>: pause Inspector for that container while you work on it
(updates, migrations). Click again to exit.</li>
<li><b>Mute notifications</b>: keep watching and restarting, but stop alerts for a while
(12 hours by default).</li>
</ul>
<p>The page refreshes itself every 30 seconds.</p>
</section>
<section id="notifications">
<h2>4. Notifications</h2>
<p>Inspector can alert you through <a href="https://gotify.net">Gotify</a>,
<a href="https://pushover.net">Pushover</a>, or both. Set them up under
<b>Settings → Notifications</b>.</p>
<figure><img src="settings-notifications.png" alt="Notifications settings">
<figcaption>Each channel has its own priority reference, a Send test button, and a Save button.</figcaption></figure>
<ol class="steps">
<li>Turn on <b>enabled</b> for the channel you use.</li>
<li><b>Gotify</b>: enter your server URL and an <i>application</i> token (create one in Gotify
under Apps). <b>Pushover</b>: enter your application API token and your user key.</li>
<li>Click <b>Send test</b>. You should get a message on your phone within seconds. If it fails,
the reason appears next to the button.</li>
<li>Click <b>Save</b>, then restart Inspector once (see <a href="#restart">section 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 an HTTP check failed (advanced, set in config.yml).</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>
</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>
</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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+56
View File
@@ -0,0 +1,56 @@
"""First-run helpers: find config.yml and write a starter one if it's missing.
Lets Inspector be installed from Portainer with nothing on the host but an
empty folder mounted at /app/config.
"""
from __future__ import annotations
import json
import os
import secrets
from importlib import resources
from pathlib import Path
LEGACY_CONFIG_PATH = Path("/app/config.yml")
DEFAULT_CONFIG_PATH = Path("/app/config/config.yml")
def resolve_config_path(cli_value: str | None, env: dict | None = None) -> Path:
"""--config wins, then $INSPECTOR_CONFIG, then the legacy single-file
mount if present, then the folder mount."""
env = os.environ if env is None else env
if cli_value:
return Path(cli_value)
if env.get("INSPECTOR_CONFIG"):
return Path(env["INSPECTOR_CONFIG"])
if LEGACY_CONFIG_PATH.exists():
return LEGACY_CONFIG_PATH
return DEFAULT_CONFIG_PATH
def write_starter_config(path: Path, env: dict | None = None) -> str | None:
"""Write the starter config to `path`.
Dashboard credentials come from INSPECTOR_USERNAME / INSPECTOR_PASSWORD.
Returns the generated password when none was supplied (so the caller can
log it once), otherwise None.
"""
env = os.environ if env is None else env
username = env.get("INSPECTOR_USERNAME") or "admin"
password = env.get("INSPECTOR_PASSWORD") or ""
generated = None
if not password:
generated = password = secrets.token_urlsafe(12)
template = (resources.files(__package__) / "starter_config.yml").read_text(encoding="utf-8")
# json.dumps gives a valid YAML double-quoted scalar for any input.
text = template.replace("__USERNAME__", json.dumps(username)).replace(
"__PASSWORD__", json.dumps(password)
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
try:
path.chmod(0o600)
except OSError:
pass
return generated
+41 -2
View File
@@ -124,8 +124,11 @@ def _status_snapshot(
) -> list[dict]:
now = utcnow()
rows = []
for i, (name, state) in enumerate(states.items()):
cfg = containers[name]
# list() snapshot: the inspector loop adds/removes entries from another thread.
for i, (name, state) in enumerate(list(states.items())):
cfg = containers.get(name)
if cfg is None: # removed from config since the last loop iteration
continue
cooldown_until = state.cooldown_until
last_restart_at = state.last_restart_at
muted_until = notification_mute.muted_until(name)
@@ -285,6 +288,20 @@ def _load_asset(name: str) -> bytes:
_ICON_PNG = _load_asset("inspector_icon_outline.png")
_GUIDE_NAME_RE = re.compile(r"^[a-z0-9-]+\.(html|png)$")
def _load_guide_file(name: str) -> tuple[bytes | None, str]:
"""Read a file from assets/guide/. Names are whitelisted by pattern, so no
path traversal is possible."""
if not _GUIDE_NAME_RE.match(name):
return None, ""
data = _load_asset(f"guide/{name}")
if not data:
return None, ""
ctype = "text/html; charset=utf-8" if name.endswith(".html") else "image/png"
return data, ctype
# Filename -> Content-Type, for files in src/inspector/assets/ served at /static/<name>.
_STATIC_FILES = {
"icon.png": "image/png",
@@ -821,6 +838,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"
@@ -1254,6 +1272,8 @@ def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> N
cfg["grace_period_seconds"] = _form_int(form, "grace_period_seconds", 120)
cfg["max_restarts_per_hour"] = _form_int(form, "max_restarts_per_hour", 3)
cfg["color"] = _form_str(form, "color", "")
if form.get("restart_on_present"): # only our own form carries this field
cfg["maintenance_window"] = _form_bool(form, "maintenance_window")
restart_on = form.get("restart_on", [])
if restart_on or form.get("restart_on_present"):
cfg["restart_on"] = [s.upper() for s in restart_on]
@@ -1300,6 +1320,7 @@ def _apply_container_create(config_ref: ConfigRef, form: dict) -> str:
"grace_period_seconds": _form_int(form, "grace_period_seconds", 120),
"max_restarts_per_hour": _form_int(form, "max_restarts_per_hour", 3),
"color": _form_str(form, "color", ""),
"maintenance_window": _form_bool(form, "maintenance_window"),
}
restart_on = form.get("restart_on", [])
if restart_on or form.get("restart_on_present"):
@@ -1994,6 +2015,10 @@ def _render_container_form(
+ _settings_field("max_restarts_per_hour", "max_restarts_per_hour",
str(getattr(cfg, "max_restarts_per_hour", 3)), kind="number",
hint="Hourly cap — Inspector pauses restarts after this many")
+ _settings_field("maintenance_window", "maintenance_window",
"on" if getattr(cfg, "maintenance_window", False) else "", kind="switch",
hint="Skip this container during the daily maintenance window (Settings → Maintenance)",
requires_restart=True)
+ _render_color_input("color", getattr(cfg, "color", ""))
+ restart_on_html
)
@@ -2052,6 +2077,7 @@ def _render_container_edit_page(app: AppConfig, config_ref: ConfigRef, name: str
class _NewContainerDefaults:
name = ""
enabled = True
maintenance_window = False
max_error_count = 5
cooldown_seconds = 300
grace_period_seconds = 120
@@ -2250,6 +2276,19 @@ class _Handler(BaseHTTPRequestHandler):
events = []
body = _render_events_page(events, app.database.enabled, selected_day, colors)
self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body)
elif path == "/guide":
# Trailing slash so the guide's relative image paths resolve under /guide/.
self.send_response(HTTPStatus.MOVED_PERMANENTLY)
self.send_header("Location", "/guide/")
self.send_header("Content-Length", "0")
self.end_headers()
elif path.startswith("/guide/"):
name = path[len("/guide/"):] or "guide.html"
data, ctype = _load_guide_file(name)
if data is None:
self._send_bytes(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"not found")
return
self._send_bytes(HTTPStatus.OK, ctype, data)
elif path == "/api/events":
selected_day = _parse_date_param(query.get("date", [None])[0])
if app.database.enabled:
+43 -14
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse
import logging
import os
import signal
import sys
import time
@@ -9,6 +10,7 @@ from datetime import datetime
from pathlib import Path
from . import __version__
from .bootstrap import resolve_config_path, write_starter_config
from .config import AppConfig, ConfigRef, ContainerConfig, load_config, load_raw
from .dashboard import DashboardServer
from .docker_client import build_client, get_container_status
@@ -398,8 +400,9 @@ def run_loop(config_ref: ConfigRef) -> int:
except Exception as exc:
if "Permission denied" in str(exc):
log.critical(
"Permission denied on /var/run/docker.sock. Set DOCKER_GID to "
"the host docker group GID: stat -c '%g' /var/run/docker.sock"
"Permission denied on /var/run/docker.sock. Start the container "
"as root (the default) so it can detect the socket's group, or set "
"group_add to the socket's GID: stat -c '%g' /var/run/docker.sock"
)
else:
log.critical("Cannot reach Docker daemon: %s", exc)
@@ -437,17 +440,20 @@ def run_loop(config_ref: ConfigRef) -> int:
notifier = _build_notifier(app)
# Rebuild enabled_configs / check_order from current config so edits
# to per-container settings apply next cycle. Containers added via
# the settings page are logged as 'requires restart' because the
# states dict is built once at startup.
# to per-container settings apply next cycle. Containers added (or
# re-enabled) from the settings page get a fresh state here; `states`
# is shared with the dashboard, so it is updated in place.
current_enabled = {c.name: c for c in app.containers if c.enabled}
new_names = current_enabled.keys() - states.keys()
if new_names:
log.warning(
"Containers added in config require Inspector restart to begin monitoring: %s",
sorted(new_names),
)
enabled_configs = {n: c for n, c in current_enabled.items() if n in states}
for new_name in new_names:
states[new_name] = ContainerState(name=new_name)
name_width = max(name_width, *(len(n) for n in new_names))
log.info("Now watching newly added container(s): %s", sorted(new_names))
for gone in states.keys() - current_enabled.keys():
states.pop(gone, None)
log.info("Stopped watching container (removed or disabled): %s", gone)
enabled_configs = current_enabled
check_order = _topological_order(enabled_configs)
uptime_kuma_snapshot = uptime_kuma_client.fetch()
@@ -495,14 +501,37 @@ def run_loop(config_ref: ConfigRef) -> int:
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="inspector", description="Docker container health watcher")
parser.add_argument("--config", "-c", default="/app/config.yml", help="Path to config.yml")
parser.add_argument(
"--config", "-c", default=None,
help="Path to config.yml (default: $INSPECTOR_CONFIG, /app/config.yml if present, "
"else /app/config/config.yml)",
)
parser.add_argument("--version", action="version", version=f"inspector {__version__}")
args = parser.parse_args(argv)
config_path = Path(args.config)
config_path = resolve_config_path(args.config)
if not config_path.exists():
print(f"config file not found: {config_path}", file=sys.stderr)
return 2
# First run with an empty config folder: write a starter config so the
# dashboard comes up and everything else can be set from the web UI.
try:
generated_password = write_starter_config(config_path)
except OSError as exc:
print(
f"config file not found: {config_path} (and could not create a starter one: {exc})",
file=sys.stderr,
)
return 2
print(f"Created starter config at {config_path}", file=sys.stderr)
if generated_password:
print(
"=" * 64 + "\n"
" Inspector dashboard login (generated on first start)\n"
f" username: {os.environ.get('INSPECTOR_USERNAME') or 'admin'}\n"
f" password: {generated_password}\n"
" Change it under Settings -> Dashboard.\n"
+ "=" * 64,
file=sys.stderr,
)
app = load_config(config_path)
raw = load_raw(config_path)
+64
View File
@@ -0,0 +1,64 @@
# Inspector configuration.
#
# Inspector wrote this starter file on first start. You can edit everything
# from the web UI (Settings), or by hand. Containers are added from
# Settings -> Containers -> Add container.
inspector:
interval_seconds: 60
log_level: INFO
dry_run: false
log_dir: /app/logs
log_filename: inspector.log
log_retention_days: 30
heartbeat: false
notifications:
# Gotify: https://gotify.net
gotify:
enabled: false
url: ""
token: ""
priority: 5
# Pushover: https://pushover.net
pushover:
enabled: false
token: ""
user_key: ""
priority: 5
device: ""
sound: ""
# Event history (powers the Events page and the health summary digest).
database:
enabled: true
path: /app/data/events.db
retention_days: 30
health_summary:
enabled: false
interval_hours: 24
priority: 3
maintenance:
enabled: false
start: "00:00"
end: "06:00"
dir: /app/data/maintenance
max_hours: 8
notification_mute:
enabled: true
dir: /app/data/muted
default_hours: 12
dashboard:
enabled: true
host: 0.0.0.0
port: 8080
username: __USERNAME__
password: __PASSWORD__
# Containers Inspector watches. Add them from the web UI.
containers: