Files
FabledSteward/steward/templates/settings/thresholds.html
T
bvandeusenandClaude Opus 5.5 b75b883326
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m42s
CI / publish (push) Successful in 1m9s
feat(traefik): a 4xx share is worth a look only once it is a lot of 4xx (#6105)
Three 404s on a quiet router read 7.5% and were raised as an anomaly; most of
the 4xx anomalies on review stood for 3 to 7 errors in 15 minutes. A new
Settings → Thresholds minimum, traefik.threshold.min_4xx (default 20, more
than one a minute held for the window), applies to both the router's grade
on its page and to anomaly detection, as min_5xx does for 5xx.

learn_normal takes `fewest`: for a share gated on a per-second rate, the
fewest events (rate × window × share) it must stand for to be judged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:02:17 -04:00

246 lines
16 KiB
HTML

{# steward/templates/settings/thresholds.html — tunable degraded/critical cutoffs #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% block title %}Settings — Thresholds & Retention — Steward{% endblock %}
{% from "settings/_tabs.html" import settings_tabs %}
{% block section_tabs %}{{ settings_tabs("thresholds") }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Thresholds & retention", "")]) }}{% endblock %}
{% block content %}
{% macro pair(label, warn_field, warn_key, crit_field, crit_key, unit, hint, step="1") %}
<div class="form-group" style="margin-bottom:1.1rem;">
<label>{{ label }} <span style="color:var(--text-muted);font-size:0.8rem;">({{ unit }})</span></label>
<div style="display:flex;gap:1.25rem;flex-wrap:wrap;margin-top:0.25rem;">
<span style="display:flex;align-items:center;gap:0.4rem;">
<span style="color:var(--yellow);font-size:0.8rem;font-weight:500;">warn</span>
<input type="number" name="{{ warn_field }}" step="{{ step }}"
value="{{ settings[warn_key] }}" style="max-width:110px;">
</span>
<span style="display:flex;align-items:center;gap:0.4rem;">
<span style="color:var(--red);font-size:0.8rem;font-weight:500;">crit</span>
<input type="number" name="{{ crit_field }}" step="{{ step }}"
value="{{ settings[crit_key] }}" style="max-width:110px;">
</span>
</div>
{% if hint %}<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">{{ hint }}</div>{% endif %}
</div>
{% endmacro %}
<form method="post" action="/settings/thresholds/">{{ csrf_field() }}
<div class="card" style="max-width:640px;">
<h2 class="section-title" style="margin-bottom:0.5rem;">Monitoring thresholds</h2>
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.25rem;">
Values at or beyond <span style="color:var(--yellow);">warn</span> show amber; at or beyond
<span style="color:var(--red);">crit</span> show red — across dashboard widgets and host views.
Higher is worse for everything except uptime, where the cutoffs are floors.
</p>
{{ pair("CPU usage", "cpu_warn", "thresholds.cpu_warn", "cpu_crit", "thresholds.cpu_crit", "%", "Average CPU utilization per host. A host or container with its own normal is judged against that instead (see Normals below).") }}
{{ pair("Memory usage", "mem_warn", "thresholds.mem_warn", "mem_crit", "thresholds.mem_crit", "%", "Memory in use per host, or a container's share of its limit. Judged against a normal once there is one.") }}
{{ pair("Disk usage", "disk_warn", "thresholds.disk_warn", "disk_crit", "thresholds.disk_crit", "%", "Filesystem usage.") }}
{{ pair("Load", "load_warn", "thresholds.load_warn", "load_crit", "thresholds.load_crit", "% per core", "Load average normalized by core count (100% = one full core per core). Judged against a host's normal once it has one.") }}
{{ pair("Temperature", "temp_warn", "thresholds.temp_warn", "temp_crit", "thresholds.temp_crit", "°C", "Hottest reported sensor per host.") }}
{{ pair("Response time", "latency_warn", "ping.threshold.good_ms", "latency_crit", "ping.threshold.warn_ms", "ms", "How long a monitor's check takes to answer. Also grades Traefik routers' p95 and the UniFi WAN round trip. A monitor or router with its own normal is judged against that instead.") }}
{{ pair("Uptime / SLA", "uptime_warn", "thresholds.uptime_warn", "uptime_crit", "thresholds.uptime_crit", "%", "Lower is worse — amber below warn, red below crit.", step="0.1") }}
{{ pair("TLS certificate expiry", "tls_warn", "thresholds.tls_warn_days", "tls_crit", "thresholds.tls_crit_days", "days left", "HTTPS monitors whose certificate expires within these many days are flagged. Lower is worse.") }}
{{ pair("Clock drift", "clock_warn", "thresholds.clock_warn_s", "clock_crit", "thresholds.clock_crit_s", "seconds", "How far a host's clock may be from Steward's, either way, before its page and the Hosts list call it out.") }}
<div class="form-group" style="margin-bottom:0.25rem;">
<label>Container crash loop <span style="color:var(--text-muted);font-size:0.8rem;">(deaths per hour)</span></label>
<div style="margin-top:0.25rem;">
<input type="number" name="crash_loop_deaths" min="1" step="1"
value="{{ settings['thresholds.crash_loop_deaths'] }}" style="max-width:110px;">
</div>
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">
A container that dies (or is OOM-killed) this many times within an hour is reported as crash-looping.
</div>
</div>
</div>
{# Traefik's router cutoffs, while the plugin runs; its page links here (#5380). #}
{% if traefik_on %}
<div class="card" id="traefik" style="max-width:640px;margin-top:1.25rem;">
<h2 class="section-title" style="margin-bottom:0.5rem;">Traefik routers</h2>
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.25rem;">
How much of a router's traffic, over the last 15 minutes, may end in an error before the
<a href="/plugins/traefik/">Traefik page</a> and the Routers widget list it.
</p>
<div class="form-group" style="margin-bottom:1.1rem;">
<label for="error_5xx_pct">Server errors (5xx) <span style="color:var(--text-muted);font-size:0.8rem;">(% of responses)</span></label>
<input type="number" id="error_5xx_pct" name="error_5xx_pct" min="0" max="100" step="0.01"
value="{{ settings['traefik.threshold.error_5xx_pct'] }}" style="max-width:110px;margin-top:0.25rem;">
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">Above this share, a router is failing (red), whatever its history.</div>
</div>
<div class="form-group" style="margin-bottom:0.25rem;">
<label for="error_4xx_pct">Client errors (4xx) <span style="color:var(--text-muted);font-size:0.8rem;">(% of responses)</span></label>
<input type="number" id="error_4xx_pct" name="error_4xx_pct" min="0" max="100" step="0.1"
value="{{ settings['traefik.threshold.error_4xx_pct'] }}" style="max-width:110px;margin-top:0.25rem;">
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">Above this share, a router is worth a look (amber): missing routes, failed sign-ins.
Once a router has 5 days of history it is judged against its own normal for the hour instead, as is its p95,
so one that always answers some 4xx stops being listed.</div>
</div>
<div class="form-group" style="margin:1.1rem 0 0.25rem;">
<label for="min_requests">Fewest requests to judge 4xx and 5xx <span style="color:var(--text-muted);font-size:0.8rem;">(in 15 minutes)</span></label>
<input type="number" id="min_requests" name="min_requests" min="0" step="1"
value="{{ settings['traefik.threshold.min_requests'] }}" style="max-width:110px;margin-top:0.25rem;">
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">Below this, a router's 4xx and 5xx shares aren't judged, on its page or as an anomaly: one 404 of three requests is 33%, two 500s of sixteen 12%.
A quiet router that fails every request is left to its uptime monitor until it has this many.</div>
</div>
<div class="form-group" style="margin:1.1rem 0 0.25rem;">
<label for="min_5xx">Fewest server errors to fail <span style="color:var(--text-muted);font-size:0.8rem;">(5xx in 15 minutes)</span></label>
<input type="number" id="min_5xx" name="min_5xx" min="1" step="1"
value="{{ settings['traefik.threshold.min_5xx'] }}" style="max-width:110px;margin-top:0.25rem;">
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">A router past the 5xx share above is failing only once it has answered this many 5xx: one 502 among 70 requests is 1.4%, but it is one error.</div>
</div>
<div class="form-group" style="margin:1.1rem 0 0.25rem;">
<label for="min_4xx">Fewest client errors worth a look <span style="color:var(--text-muted);font-size:0.8rem;">(4xx in 15 minutes)</span></label>
<input type="number" id="min_4xx" name="min_4xx" min="1" step="1"
value="{{ settings['traefik.threshold.min_4xx'] }}" style="max-width:110px;margin-top:0.25rem;">
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">A router's 4xx share is flagged, on its page or as an anomaly, only once it has answered this many 4xx: a few 404s and failed sign-ins are part of a normal day; a lot of them is what needs acting on.</div>
</div>
</div>
{% endif %}
{% macro days(label, field, key, hint) %}
<div class="form-group" style="margin-bottom:1.1rem;">
<label>{{ label }} <span style="color:var(--text-muted);font-size:0.8rem;">(days)</span></label>
<div style="margin-top:0.25rem;">
<input type="number" name="{{ field }}" min="1" step="1"
value="{{ settings[key] }}" style="max-width:110px;">
</div>
{% if hint %}<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">{{ hint }}</div>{% endif %}
</div>
{% endmacro %}
{# Each normal's floor (#5742): the smallest departure from it worth calling an anomaly. #}
{% macro floor(label, key, unit, hint, step="1") %}
<div class="form-group" style="margin-bottom:1.1rem;">
<label for="{{ key }}">{{ label }} <span style="color:var(--text-muted);font-size:0.8rem;">({{ unit }})</span></label>
<input type="number" id="{{ key }}" name="{{ key }}" min="0" step="{{ step }}"
value="{{ settings[key] }}" style="max-width:110px;margin-top:0.25rem;display:block;">
{% if hint %}<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">{{ hint }}</div>{% endif %}
</div>
{% endmacro %}
<div class="card" id="normals" style="max-width:640px;margin-top:1.5rem;">
<h2 class="section-title" style="margin-bottom:0.5rem;">Normals</h2>
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.25rem;">
Once one has 5 days of history, these learn what is normal for each host, container, monitor or router
at each hour, and are judged against that instead of the cutoffs above: a reading over a cutoff but usual
for that hour shows healthy, and one well above its usual level is a warning and an anomaly to review.
Disk space, a monitor that is down, an agent that stopped reporting and a router's 5xx share are failures
and always keep their cutoffs. A reading counts as out of its normal only when it is a quarter above its
usual level <em>and</em> at least this much above it:
</p>
{% if "host_agent" in (plugins_on or ()) %}
{{ floor("Host CPU", "norms.floor.host_cpu_pct", "% points", "CPU is bursty: a build or a backup moves it this much without anything being wrong.") }}
{{ floor("Host memory", "norms.floor.host_mem_pct", "% points", "Memory moves slowly, so a smaller step already means something changed.") }}
{{ floor("Host load per core", "norms.floor.host_load_pct", "% points", "Bursty, like CPU.") }}
{% endif %}
{% if "docker" in (plugins_on or ()) %}
{{ floor("Container CPU", "norms.floor.container_cpu_pct", "% of one core", "10 is a tenth of a core; with many containers, smaller steps are everyday noise.") }}
{{ floor("Container memory", "norms.floor.container_mem_pct", "% points", "Of its limit, or of the host's memory when it has none, so a container without a limit only counts large growth.") }}
{% endif %}
{{ floor("Monitor response time", "norms.floor.monitor_ms", "ms", "One check per interval is noisy; a slower reply than this over its usual is worth a look.") }}
{% if traefik_on %}
{{ floor("Router 4xx share", "norms.floor.router_4xx_pct", "% points", "", step="0.1") }}
{{ floor("Router p95", "norms.floor.router_p95_ms", "ms", "") }}
{% endif %}
</div>
<div class="card" style="max-width:640px;margin-top:1.5rem;">
<h2 class="section-title" style="margin-bottom:0.5rem;">Metric anomalies</h2>
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.25rem;">
A metric that leaves what is normal for it at that hour is listed under
Alerts → Anomalies to review. One nobody marks normal or real within this
window, once it has ended, lapses: it leaves the list and is kept as not
reviewed, and its hours are never learned.
</p>
{{ days("Review window", "anomalies_review_days", "anomalies.review_days",
"An anomaly still going never lapses.") }}
</div>
<div class="card" style="max-width:640px;margin-top:1.5rem;">
<h2 class="section-title" style="margin-bottom:0.5rem;">Docker lifecycle events</h2>
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.25rem;">
How long container start, stop, die and health changes are kept. Container CPU
and memory are kept with the other metrics, under Metric retention below.
Applied by the hourly cleanup task — a change takes effect on its next run.
</p>
{{ days("Lifecycle events", "docker_events_days", "docker.retention.events_days",
"Keep container start/stop/die/health history this long.") }}
</div>
<div class="card" style="max-width:640px;margin-top:1rem;">
<h2 class="section-title" style="margin-bottom:0.5rem;">Container logs</h2>
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.25rem;">
The host agent tails each container's logs and pushes them here, viewable per
container. On by default for every container; storage is bounded by a
per-container ring (oldest lines rotate out once the age or size cap is hit,
whichever comes first). Applied by the hourly cleanup and on ingest.
</p>
<div class="form-group" style="margin-bottom:1.1rem;">
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;">
<input type="checkbox" name="docker_logs_enabled" style="width:auto;"
{% if settings["docker.logs.enabled"] %}checked{% endif %}>
Collect container logs
</label>
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">
Global kill-switch. When off, pushed log lines are dropped and no new logs are stored.
</div>
</div>
<div class="form-group" style="margin-bottom:1.1rem;">
<label>Exclude containers</label>
<div style="margin-top:0.25rem;">
<input type="text" name="docker_logs_exclude"
value="{{ settings['docker.logs.exclude'] | join(', ') }}"
placeholder="watchtower, some-chatty-service" style="width:100%;max-width:420px;">
</div>
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">
Comma-separated container names whose logs are dropped on ingest (e.g. known-noisy ones).
</div>
</div>
{{ days("Log retention", "docker_logs_retention_days", "docker.logs.retention_days",
"Keep each container's log lines at most this long before rotating them out.") }}
<div class="form-group" style="margin-bottom:0.25rem;">
<label>Max size per container <span style="color:var(--text-muted);font-size:0.8rem;">(MB)</span></label>
<div style="margin-top:0.25rem;">
<input type="number" name="docker_logs_max_mb" min="1" step="1"
value="{{ (settings['docker.logs.max_bytes_per_container'] // 1000000) or 1 }}"
style="max-width:110px;">
</div>
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">
Newest lines are kept up to this size per container; older lines rotate out first.
</div>
</div>
</div>
<div class="card" style="max-width:640px;margin-top:1rem;">
<h2 class="section-title" style="margin-bottom:0.5rem;">Metric retention</h2>
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.25rem;">
Bounds how much metric history is stored: hosts (CPU, memory, disk, network,
temps, …), containers (CPU and memory), monitors and the other sources. Raw
per-sample points are kept for the raw window, then rolled up into hourly
figures (average, peak, and the level held) kept for the rollup window. Charts
read raw for the recent part of a range and hourly for older data. Applied by
the hourly cleanup.
</p>
{{ days("Raw metrics", "metrics_raw_days", "metrics.retention.raw_days",
"Keep per-sample metrics this long, then roll up to hourly.") }}
{{ days("Rolled-up metrics", "metrics_rollup_days", "metrics.retention.rollup_days",
"Keep the hourly-averaged series this long for multi-week history.") }}
</div>
<div style="margin-top:1rem;display:flex;align-items:center;gap:1rem;">
<button type="submit" class="btn">Save</button>
<span style="font-size:0.82rem;color:var(--text-muted);">Takes effect immediately — no restart.</span>
</div>
</form>
{% endblock %}