34 Commits

Author SHA1 Message Date
bvandeusen 001cbafe56 Merge pull request 'Ansible schedule form: structured playbook-variable fields + first-item dropdown defaults' (#2) from dev into main
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 46s
CI / integration (push) Successful in 2m21s
CI / publish (push) Successful in 4s
2026-06-30 23:44:04 -04:00
bvandeusen 524fd1f509 feat(ansible): default source/playbook dropdowns to the first item
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 46s
CI / integration (push) Successful in 2m18s
CI / publish (push) Successful in 1m0s
The schedule and host run forms opened their Source/Playbook <select>s on a
"— choose … —" placeholder, forcing an extra click. Default them to the first
item and cascade the dependent fields so the initial state is real:

- Remove the placeholder options (schedules source+playbook, host source+
  playbook, and the shared _playbook_options fragment).
- schedules(): pass sel_source (first source, or the edited schedule's) and
  that source's pre-rendered playbooks so the playbook dropdown starts populated
  (create mode too, no longer the all-sources union).
- Cascade on load/change: the source <select> fires playbook-options then
  (hx-on::after-settle) re-triggers the playbook <select>, which loads that
  playbook's variable fields. Host form drives the whole chain from a load
  trigger; schedule create mode loads vars via the playbook <select>'s own load
  trigger, while edit mode keeps its server-rendered prefill untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:35:01 -04:00
bvandeusen efc11c1837 feat(ansible): structured playbook-variable fields on the schedule form
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 45s
CI / integration (push) Successful in 2m20s
CI / publish (push) Successful in 1m14s
The Ansible Schedules form only offered a free-form extra-vars textarea,
unlike the browse/host run forms which auto-populate a field per declared
playbook variable (vars:/vars_prompt:) plus the playbook description. Wire
the schedule form to the same shared `_playbook_vars.html` partial:

- Playbook <select> now loads /ansible/playbook-vars?schedule=1 on change
  into a #sch-playbook-vars container (mirrors the host detail form).
- Edit mode pre-renders the saved playbook's variables server-side with
  their stored values; the extra-vars textarea keeps only leftover
  (non-declared) vars.
- Partial gains optional `values` (prefill) and `schedule` flags. In the
  schedule context secret vars render disabled ("secrets can't be
  scheduled") since they're dropped downstream, and the destructive-confirm
  gate is shown but not required.

Backend already parsed var__* fields for schedules, so this is the
front-end/partial parity fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:03:54 -04:00
bvandeusen f40063a74d feat(reliability): fd-leak rails — self-watchdog, poll-overlap guard, fd tests
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 46s
CI / integration (push) Successful in 2m19s
CI / publish (push) Successful in 1m3s
Guardrails so the fd-leak class of bug (Errno 24 lockup; recent SNMP + UniFi
fixes) surfaces early or can't compound, instead of silently killing the app.

- Self-fd watchdog (steward/core/self_monitor.py): records open_fds and
  open_fds_pct (% of soft RLIMIT_NOFILE) as "steward"/"process" metrics each
  minute through the normal alert pipeline, so the operator can alert on them
  via the existing alert-rules UI. Built-in WARNING floor at 80% gives a
  zero-config early signal. Stdlib-only (/proc + resource); degrades to a no-op
  off Linux. Registered as a core ScheduledTask in app.py.

- Poll-overlap guard (steward/core/scheduler.py): extract a pure _DueTracker
  that skips a tick while a task's prior run is still in flight, so a hung poll
  can't stack overlapping runs (which amplify per-poll resource/fd use). A
  skipped task isn't penalised — it retries the next tick after it completes.

- fd-stability tests (tests/core/): _DueTracker overlap policy, the watchdog
  metric/warning/degradation paths, and a real-fd canary that hammers tcp_check
  and asserts /proc/self/fd doesn't grow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:36:53 -04:00
bvandeusen 0c5a1573da fix(unifi): close the cached client before discarding it (fd leak)
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 47s
CI / integration (push) Successful in 2m19s
CI / publish (push) Successful in 8s
The UniFi poller caches a UnifiClient holding a long-lived httpx.AsyncClient
(a real socket pool). On a login failure or a mid-poll error it set
`_client = None` to force re-auth, but never called close() — orphaning the
connection pool until GC. Under a flapping/unreachable controller that leaks
a file descriptor every failure tick: the same class of bug as the SNMP
engine leak (OSError: [Errno 24] Too many open files).

Add _drop_client() that aclose()s the client (best-effort) before nulling
the cache, and route both discard paths through it. Regression tests cover
both failure paths plus the noop/close-error contracts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:38:41 -04:00
bvandeusen 2df5fc94a3 fix(snmp): close SnmpEngine after each poll to stop fd leak
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 45s
CI / integration (push) Successful in 2m20s
CI / publish (push) Successful in 1m2s
A fresh SnmpEngine() was created on every poll_device() call and never
closed. pysnmp opens a UDP transport socket per engine and doesn't release
it on GC, so each scheduler tick (default 60s, per device) leaked a file
descriptor. Over hours of polling the process hit its fd ceiling and the
listening socket could no longer accept connections — OSError: [Errno 24]
Too many open files on socket.accept(), locking up the app.

Wrap the engine in try/finally and release its transport socket via a new
_close_engine() helper that probes both pysnmp API shapes (6.2.x lextudio
camelCase, canonical 7.x snake_case); all close paths are best-effort so a
failed close never breaks the poll loop. Regression tests cover both shapes
and the never-raises contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:10:30 -04:00
bvandeusen b1aabb7dfa feat(snmp): explicit host binding (host_id / steward_host) with implicit fallback
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 45s
CI / integration (push) Successful in 2m18s
CI / publish (push) Successful in 1m2s
SNMP devices map onto a Steward host's detail page via _devices_for_host,
which previously matched the device's `host` field (the SNMP poll target)
against the Host's address/name — a coincidence of strings that breaks when
the poll target differs from how the Host is recorded.

Add two optional per-device config fields that decouple the binding from the
poll target:
  • host_id      — exact Steward Host UUID match (the explicit link)
  • steward_host — friendly bind by Host name or address (case-insensitive)

An explicit binding is exclusive: a device bound to host A never implicitly
matches host B by a coincidental address. No explicit binding → existing
implicit `host`-string match (fully backward compatible).

host_panel passes host.id and badges explicitly-bound devices. plugin.yaml
documents the new fields. Tests cover explicit id/name, exclusivity, wrong
uuid, and legacy fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-21 13:47:20 -04:00
bvandeusen 24df8458ea feat(docker): swarm-aware container views — collapse replicas, show host, surface agent-less tasks
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 45s
CI / integration (push) Successful in 2m19s
CI / publish (push) Successful in 1m3s
The container list and dashboard widget listed each swarm task as its own cryptic
row (svc.1.<taskid>) and could only show tasks on nodes that run a Steward agent.
Make both views service-centric and cluster-complete.

- swarm_view.build_swarm_services(): model-free builder that merges real container
  rows (collected local-per-node, so host_id = the node a task runs on) with the
  managers' placement (DockerSwarmService.placement_json). Where placement counts
  exceed the local rows on a node — i.e. nodes with no agent — it synthesizes
  "ghost" replicas so the replica list matches the cluster. Non-swarm containers
  pass through grouped by host.
- /rows + rows.html: a Swarm-services panel collapses each service to one block,
  every replica a host chip (status + cpu); ghosts render dashed "N · no agent".
  Non-swarm/compose containers keep the per-host table below. New Services stat.
- /widget + widget.html: same service collapse with host chips; standalone
  containers stay grouped by host.
- Unit tests for the builder (real+ghost merge, full coverage, partial agent-less,
  synthesized service, down state).

No migration — node_id, placement_json and swarm-node hostnames are already stored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-21 12:50:15 -04:00
bvandeusen 431f804037 fix(docker): move dedup helper to a model-free module (CI green)
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 45s
CI / integration (push) Successful in 2m18s
CI / publish (push) Successful in 1m2s
The widget-dedup test imported plugins.docker.routes, which re-imports
plugins.docker.models after the app already loaded the docker plugin —
"Table 'docker_containers' is already defined". Same plugin-loader gotcha the
host_agent query helpers avoid.

Extract dedup_by_container_id into plugins/docker/dedup.py (imports no ORM
models, so it's safe to import in either environment); routes.py imports it
from there and the test targets the model-free module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-20 23:07:33 -04:00
bvandeusen e289b6c49f fix(docker): dedup widget counts across swarm managers; show failures not stopped
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 46s
CI / integration (push) Failing after 2m19s
CI / publish (push) Has been skipped
The running count was inflated: swarm-aware agents on every manager list
cluster-wide tasks, so the same container (identical container_id) was reported
once per manager and the dashboard widgets summed them. The earlier swarm dedup
only covered the Swarm topology page — the widgets read raw rows.

- _dedup_by_container_id(): collapse rows sharing a non-empty container_id
  (globally unique, so only true duplicates merge); first occurrence wins under
  the running-first ordering. Applied in both the containers and resources
  widgets before counting/limiting. Rows without a container_id (older agents)
  are kept as-is.
- Replaced the static "stopped" tally — not actionable — with "failed (24h)":
  distinct containers with a die/oom event in the last 24h (DISTINCT so the
  managers' duplicate events don't double it), rendered red when non-zero.
- widget.html empty-state now keys off total_count.
- Regression test: same container_id from two managers counts once; id-less rows
  all kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-20 23:02:19 -04:00
bvandeusen 0c055ed6fa fix(docker): reap vanished containers; clarify the resources widget
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 44s
CI / integration (push) Successful in 2m20s
CI / publish (push) Successful in 1m0s
The container persister upserted current state keyed (host_id, name) but — unlike
the image/swarm/disk persisters — never deleted containers that vanished from the
host's latest listing. Every removed one-shot container (CI job runners, buildkit
builders, codex jobs) left a permanent "stopped" row, so the dashboard counts
ballooned (e.g. 856 stopped) and read as "not dedup'd". It wasn't dedup — it was
a missing reaper.

- ingest.py: after the upsert loop, delete this host's containers whose name is
  notin the newest snapshot (the listing is authoritative — event derivation
  already treats absence as removal). Mirrors images/swarm/disk. The existing
  `if not snapshots: return` keeps swarm/disk-only samples from touching
  containers; an empty container snapshot legitimately means none exist.
- widget_resources.html: this widget shows the busiest running containers by CPU.
  Replaced the two unlabeled hairline bars with labeled CPU/MEM bars (label · bar ·
  % value, warn/crit colors) so it's self-explanatory.
- Regression test: a container present then absent across snapshots is reaped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-20 22:06:43 -04:00
bvandeusen 51682f130a feat(snmp): surface SNMP device readings on the matching host page
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 46s
CI / integration (push) Successful in 2m17s
CI / publish (push) Successful in 53s
SNMP devices are config-defined by IP/hostname, not Steward Host records, so
they had no presence on a host's page. Map them by address and embed a fragment
(mirrors the Docker per-host fragment).

- _devices_for_host(devices_cfg, address, name): case-insensitive match of a
  device's configured host to the Steward host's address or name (tolerates
  non-dict / host-less entries).
- Route /plugins/snmp/host/<id>: renders the matched device(s) + latest readings,
  or nothing when none map (so hosts without SNMP carry no empty card).
- snmp/host_panel.html: per-device card (name · address · reachability) with a
  readings grid (K/M scaling) and a History link to the full device page.
- hosts/detail.html embeds it after the Docker fragment, gated on snmp enabled.
- Unit test for the matching helper (by address, by name, no-match, blank).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-20 21:22:39 -04:00
bvandeusen 8af297670e feat(metrics): roll plugin_metrics up to hourly to bound storage
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 47s
CI / integration (push) Successful in 2m18s
CI / publish (push) Successful in 1m6s
plugin_metrics grows by (sources × resources × ~30s cadence); keeping 90d of raw
is a large table. Add a raw→hourly rollup (mirroring the Docker plugin) so only a
short raw window is kept at full resolution, with hourly averages archived longer.

- PluginMetricHourly model + core migration 0024 (plugin_metrics_hourly: avg/max/
  count per source/resource/metric/hour, unique bucket constraint + lookup index).
- steward/core/metrics_retention.rollup_plugin_metrics: date_trunc('hour') agg of
  raw older than the hour-aligned raw window, idempotent pg upsert into hourly,
  delete the rolled raw, prune hourly beyond the rollup window.
- cleanup.py: plugin_metrics is no longer blanket-deleted at data.retention_days;
  _run_metrics_retention drives the rollup with windows read live from settings.
- Settings: metrics.retention.raw_days (7) + rollup_days (90), tunable on the
  Thresholds & Retention page (new "Host metrics retention" card).
- Chart read: _history_for_host merges the hourly rollup (older part of the range)
  with raw date_bin (recent part, capped ≤1h), so 30d charts keep working —
  recent at full resolution, older at hourly. Route passes raw_days from settings.
- Tests: unit (cutoff helpers) + integration (rollup aggregates/prunes; history
  merges hourly + raw) against Postgres.

Speed was already handled by the indexes + SQL aggregation; this is the storage
lever (raw window ~10x smaller).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-20 21:08:30 -04:00
bvandeusen b0d3e83bdd feat(hosts): true live vitals strip; agent panel becomes management-only
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 46s
CI / integration (push) Successful in 2m22s
CI / publish (push) Successful in 1m0s
Realizes the chosen host-summary layout: a thin live vitals bar at the very top,
separate from the agent management panel (no more duplicated CPU/MEM/DISK/LOAD).

- New fragment _host_vitals.html + route /plugins/host_agent/vitals/<id>: compact
  CPU / Memory / Disk(/) / Load-per-core (threshold-coloured + sparkline) +
  Pressure + live/stale·version·last-seen. Polled every 15s; renders nothing
  until the agent reports.
- The metric computation (latest snapshot + 6h sparkline query + load/core + PSI)
  moves from host_panel into host_vitals. host_panel slims to management only
  (reg/target/reporting/stale/ansible) and no longer queries metrics; panel.html
  drops the gauge row + pressure block, keeping status + lifecycle actions.
- hosts/detail.html: vitals strip on top (full width, live), then a 2-col
  [Monitors | Agent] grid, Ansible full width below, docker fragment last.

UI only. Templates parse; plugin-template parse test covers the new fragment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-20 19:49:01 -04:00
bvandeusen a35c369dd4 polish(ui): sidebar nav a11y + scroll/focus refinements (nav slice 3)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 45s
CI / integration (push) Successful in 2m17s
CI / publish (push) Successful in 59s
- Only the nav list scrolls when long (min-height:0 + overflow on .side-nav);
  the brand and user/logout stay pinned top/bottom.
- Keyboard focus rings (:focus-visible) on every interactive sidebar element
  (brand, nav links, user/logout) and the mobile ☰ toggle.
- Mobile toggle is now a real toggle: aria-controls/aria-expanded kept in sync,
  Escape closes the off-canvas sidebar, scrim close stays in sync too.
- prefers-reduced-motion disables the sidebar slide + link transitions.
- Confirmed no dead top-nav CSS/markup remnants from the old top bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-20 19:14:49 -04:00
bvandeusen 28c9a4dd2f fix(docker): collapse Swarm view per-cluster so multi-manager doesn't duplicate
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 49s
CI / integration (push) Successful in 2m24s
CI / publish (push) Successful in 1m4s
Swarm services/nodes are cluster-global (every manager's API returns the same
list), but each manager reports them independently (rows keyed by host_id) and
the Swarm page grouped by reporting manager — so two managers in one cluster
listed every service and node twice.

Group the reporting managers into swarms in the swarm() view (managers sharing
any node_id are the same cluster, via union-find over node-set intersection),
then dedup within each: one service per name and one node per node_id, keeping
the freshest. Render one section per swarm ("reported by N managers · names").
node_id is globally unique so node dedup is always safe; grouping by node
overlap also keeps it correct if two separate swarms are ever monitored.

View-layer only — no schema/agent change. The main per-host container page is
unaffected (those are genuinely distinct per-node tasks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-20 12:46:57 -04:00
bvandeusen 10dfd8ffd2 fix(host_agent): update full-metrics charts in place to kill refresh flicker
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 47s
CI / integration (push) Successful in 2m21s
CI / publish (push) Successful in 7s
The live refresh re-created the Chart.js charts each poll, which blanked the
canvases and re-ran the grow-in animation — a jarring flash/jump every cycle.

Make the canvases persistent in the page shell and poll only the data:
- The 3 chart canvases + their Chart instances are created once in the shell,
  with animation disabled.
- /charts is now a data-only fragment swapped into a hidden div; it calls
  window.applyHostSeries(series, range), which sets each dataset's data and
  calls chart.update("none") — in-place, no re-create, no animation, fixed
  height. Range labels update via .hm-chart-range spans.
- Current-state fragment keeps its atomic innerHTML poll into fixed-height
  cards, so numbers update without a layout jump.

Net: live updates morph smoothly with no flicker or layout shift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-20 12:25:51 -04:00
bvandeusen 9e4f1983f8 feat(host_agent): lazy-load full-metrics charts + live-poll current state
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 51s
CI / integration (push) Successful in 2m21s
CI / publish (push) Successful in 1m10s
The full-metrics page rendered once server-side with the history query inline,
so the charts blocked first paint and nothing refreshed without a reload (it
reads the latest snapshot the server holds — the agent pushes ~every 30s).

Split it into a shell + two HTMX fragments:
- Shell (host_detail.html): header + shared time-range toggle + two containers;
  paints instantly.
- Current state (/<id>/metrics → _host_metrics.html): identity + gauges +
  per-core + filesystems + interfaces/disks + temps, from the DISTINCT ON latest
  query. hx-trigger "load, every 15s" → live numbers at ~the agent cadence.
- History charts (/<id>/charts → _host_charts.html): the 3 charts + Chart.js,
  from the date_bin history query. hx-trigger "load, every 60s, rangeChange" so
  they lazy-load (never block paint), refresh slowly, and follow the range
  selector. Leak-safe: previous Chart instances are destroyed before re-render.
- Range toggle switched from full-reload links to the shared _time_range.html
  (setTimeRange + rangeChange), so only the fragments refetch.
- routes: host_detail (shell) + host_detail_metrics + host_detail_charts, with a
  _split_host_metrics helper.
- tests/test_templates_parse.py now also parses plugin templates (these
  fragments aren't rendered in the unit lane).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-20 12:18:39 -04:00
bvandeusen a78af23793 refactor(host_agent): extract metric-query helpers to a model-free module
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 48s
CI / integration (push) Successful in 2m23s
CI / publish (push) Successful in 1m6s
The previous commit's integration test failed at collection-time import:
importing plugins.host_agent.routes (which imports the host_agent ORM models at
top level) double-registers host_agent_registrations against the app-loaded
plugin's metadata ("Table already defined").

Move the two pure read helpers (_latest_metrics_for_host, _history_for_host)
plus SOURCE_MODULE / HISTORY_METRICS into plugins/host_agent/metrics_query.py,
which imports only the core PluginMetric model — no plugin models. routes.py
imports them back. The integration test now imports from metrics_query and no
longer trips the loader's registration guard. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-19 23:32:32 -04:00
bvandeusen aff0c36d37 perf(host_agent): aggregate metric history in SQL; DISTINCT ON for latest
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 46s
CI / integration (push) Failing after 2m23s
CI / publish (push) Has been skipped
Follow-on to the plugin_metrics indexes. plugin_metrics is already retention-
bounded (core cleanup prunes > data.retention_days, default 90d) and charts top
out at 30d, so the cost wasn't growth — it was the read path shipping raw rows
to Python.

- _history_for_host: bucket + average in SQL via date_bin (epoch-aligned, ~120
  buckets) instead of fetching every raw sample (a 30d range was hundreds of
  thousands of rows) and downsampling in Python. Uses the new
  (source_module, resource_name, recorded_at) index.
- _latest_metrics_for_host: DISTINCT ON (resource_name, metric_name) ORDER BY
  recorded_at DESC — newest row per group in one index-ordered pass, replacing
  the GROUP-BY-max subquery self-joined back to the whole history.
- Integration test validates both against Postgres.

Deliberately not a materialized raw→hourly rollup: these query-side changes
deliver the speed; a rollup would additionally cut storage and remains a future
option if scale demands it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-19 23:23:45 -04:00
bvandeusen 6d08db0d89 feat(hosts): rework host summary layout to use horizontal space
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 48s
CI / integration (push) Successful in 2m19s
CI / publish (push) Successful in 51s
The host detail page stacked three full-width cards (Monitors, Agent, Ansible)
left-aligned, using only ~40% of the width with lots of vertical whitespace.

Rework toward the operator-chosen "vitals on top + 2-column" layout:
- The agent panel (CPU/MEM/DISK/LOAD vitals + sparklines + lifecycle) moves to
  the top, full width, so a host's vitals lead the page.
- Monitors and Ansible sit side by side in a responsive 2-column grid
  (auto-fit, stacks under ~420px), filling the width.
- The Docker per-host fragment moves below, full width.

Note: the vitals live in the host_agent fragment (loaded across the plugin
boundary) and uptime in the host-detail context, so a single literal top "strip"
mixing both isn't clean — the agent card on top is the faithful, contained
realization. UI-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-19 23:12:13 -04:00
bvandeusen 3a54d6d71d perf(metrics): index plugin_metrics for host/dashboard reads
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 48s
CI / integration (push) Successful in 2m19s
CI / publish (push) Successful in 1m2s
plugin_metrics had only a PK on id, so every host-detail, full-metrics, and
dashboard-widget load sequentially scanned the entire time-series table — which
grows by (sources × resources × sample cadence), so the host views got slower
over time (operator-reported "blocked/slow" loads). monitor_results was already
indexed, so the uptime aggregation wasn't the bottleneck.

Add two composite indexes matching the hot query shapes:
- (source_module, resource_name, recorded_at) — history range scans, fleet/
  widget queries, and the 'host:%' sub-resource prefix.
- (source_module, resource_name, metric_name, recorded_at) — the latest-value-
  per-metric group-by/self-join.

Model __table_args__ + core migration 0023. Integration lane validates the
migration via `alembic upgrade head`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-19 23:08:29 -04:00
bvandeusen 7d144780df fix(host_agent): TB/PB byte scaling + rebalance the full-metrics layout
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 47s
CI / integration (push) Successful in 2m23s
CI / publish (push) Successful in 7s
Two issues on the host full-metrics view:
- fmt_bytes capped at GB, so large filesystems read "25369.0 GB" instead of
  "24.8 TB". Add TB and PB tiers.
- Interfaces/Disks/Temperatures shared one 3-column grid, so all three cards
  stretched to the Temperatures height — a 40-core CPU made one very tall column
  and left Interfaces/Disks with large empty space. Split them: Interfaces +
  Disks sit side-by-side at natural height (align-items:start); Temperatures
  becomes a full-width card whose readings flow into a compact multi-column grid
  (wide-and-short instead of one tall column).

UI-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-19 23:01:56 -04:00
bvandeusen 2545e8c6ce fix(docker): widen memory byte columns to BIGINT (int32 overflow on ingest)
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 46s
CI / integration (push) Successful in 2m22s
CI / publish (push) Successful in 1m6s
docker_metrics.mem_usage_bytes and docker_containers.mem_usage_bytes /
mem_limit_bytes were int4 (max 2,147,483,647). A container using >2.1 GB of RAM
(e.g. 8.18 GB) overflowed the column, so asyncpg raised "value out of int32
range" and the entire docker ingest batch failed — no metrics stored for any
host with a large container.

The I/O counters and the milestone-77 rollup/disk tables already used
BigInteger; this trio (docker_001-era) was missed.

- models.py: DockerMetric.mem_usage_bytes, DockerContainer.mem_usage_bytes,
  DockerContainer.mem_limit_bytes → BigInteger.
- migration docker_008_bigint_mem: ALTER COLUMN ... TYPE BIGINT (safe in-place
  int4→int8 promotion).
- integration regression test: persist an ~8 GB container, assert the
  current-state and time-series rows round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-19 22:50:32 -04:00
bvandeusen c8b6719b37 feat(ui): plugin get_nav() hook → sidebar Infrastructure links (slice 2)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 46s
CI / integration (push) Successful in 2m20s
CI / publish (push) Successful in 1m16s
Plugin UIs had no nav home (only dashboard widgets / typed URLs). Add an
optional get_nav() plugin export and surface it in the sidebar.

- plugin_manager: _PLUGIN_NAV registry + get_plugin_nav() getter + a tolerant
  _collect_plugin_nav() (missing hook = fine; raising/malformed = logged &
  skipped; idempotent per plugin so hot-reload re-runs cleanly). Collected in
  both the startup load path and the hot-reload path.
- app.py: inject plugin_nav into the template context, filtered to enabled
  plugins so a hot-disabled plugin's link can't linger before restart.
- base.html: render plugin links under the Infrastructure group, with the same
  request.path active-state treatment as core links.
- docker/snmp/unifi/traefik: each exports get_nav() → its /plugins/<name>/ view.
- Tests: collector behavior (collect, missing hook, reload-replace, malformed
  item, raising hook, sorted output).

With docker enabled, "Docker" now appears under Infrastructure → its fleet view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-19 18:00:12 -04:00
bvandeusen 95ebdf7045 feat(ui): replace flat top nav with a grouped left sidebar (slice 1)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 45s
CI / integration (push) Successful in 2m18s
CI / publish (push) Successful in 1m18s
The flat top bar was a set of ungrouped peers and gave plugin data no home.
Move to a persistent left sidebar with grouped sections, per the navigation
redesign (operator-chosen shell).

- base.html: top <nav> → left <aside class="sidebar"> + content column.
  Groups: Overview (Dashboard, Status), Infrastructure (Hosts; plugin links
  arrive in slice 2), Monitoring (Monitors, Alerts), Automation (Ansible),
  Admin (Settings, Audit; admin-only). Brand on top, user/logout at the bottom.
- Active link highlighted by request.path prefix (aria-current).
- Responsive: sidebar slides off-canvas under 900px via a ☰ toggle + scrim
  (inline class toggle, no new JS deps). Candle-glow preserved.
- Logged-out pages (login/setup) render without the sidebar (gated on
  session.user_id), content area full-width and centered as before.
- Add tests/test_templates_parse.py: syntax-parses every steward template so a
  broken tag fails the unit lane (only the login page renders there today).

Plugin nav links (Docker/SNMP/UniFi/Traefik) come next in slice 2 via a get_nav
hook. UI-only; no behavior/route changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-19 15:34:47 -04:00
bvandeusen 0940dc6972 feat(crypto): fail loudly on unpersistable secret key; flag undecryptable secrets
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 47s
CI / integration (push) Successful in 2m21s
CI / publish (push) Successful in 1m2s
Follow-up to the incident where an unwritable /data made Steward silently mint a
fresh ephemeral secret key on every boot, orphaning all encrypted secrets — only
discovered when an Ansible run failed. Make both failure modes loud and visible.

- config._resolve_secret_key: if a new key must be generated but can't be
  persisted (no STEWARD_SECRET_KEY, unwritable /data), raise RuntimeError and
  refuse to start, with an actionable fix (set STEWARD_SECRET_KEY, or make /data
  writable by uid 1000). Also raises a clear error if an existing key file can't
  be read. First-run on a writable volume still generates + persists normally.
- core.settings: detect secrets stored as ciphertext that won't decrypt with the
  current key (scan_undecryptable_secrets at startup; _is_undecryptable helper).
  Cached in memory; set_setting discards a key on a fresh write so the banner
  clears without a restart.
- app.py: run the scan after migrate_plaintext_secrets, log a warning, and inject
  undecryptable_secrets into the template context.
- base.html: admin banner naming which secrets to re-enter, each linked to its
  settings tab.
- compose.deploy.yml: document the uid-1000 /data write requirement and the
  STEWARD_SECRET_KEY option for multi-node Swarm.
- Tests: secret-key resolver (reuse existing file, generate when writable, raise
  when unpersistable) and the undecryptable-secret detection helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-19 13:52:27 -04:00
bvandeusen 4fc8c96c41 fix(snmp): bundle pysnmp in image and port poller to the asyncio HLAPI
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 46s
CI / integration (push) Successful in 2m21s
CI / publish (push) Successful in 1m16s
The SNMP plugin ships in the image but logged "pysnmp not installed — SNMP
polling disabled" on every poll, so polling never worked. Two coupled defects:

1. The Dockerfile installed only `.[ansible]`, so the `snmp` extra (pysnmp)
   was never bundled even though the plugin is first-party and shipped.
2. poller.py used the synchronous pysnmp HLAPI (`next(getCmd(...))`), which
   pysnmp-lextudio 6.x removed — it's asyncio-only now — so even with the dep
   present, polling would have thrown and silently returned nothing. The 5.x
   line that still has the sync API isn't safe on the image's Python 3.13.

Fix:
- Dockerfile: install `.[ansible,snmp]`.
- poller.py: `poll_device_sync` → `async def poll_device` on the asyncio HLAPI,
  with a dual-version import (pysnmp 7.x `pysnmp.hlapi.v3arch.asyncio`/`get_cmd`
  + async `UdpTransportTarget.create`; pysnmp-lextudio 6.2.x
  `pysnmp.hlapi.asyncio`/`getCmd` + direct `UdpTransportTarget`) so a dependency
  bump can't silently re-break it.
- scheduler.py: await poll_device directly; drop the run_in_executor wrapper
  and the now-unused asyncio import.
- Add tests/plugins/snmp/test_poller.py covering the version→mpModel mapping,
  that the poller is a coroutine, and the graceful no-pysnmp path.

Note: CI confirms import/load and the no-pysnmp path, but has no SNMP target —
live polling against real devices is verified after deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-19 13:28:07 -04:00
bvandeusen 88091936c5 fix(ui): unify header/breadcrumb treatment, drop redundant back buttons
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 47s
CI / integration (push) Successful in 2m19s
CI / publish (push) Successful in 1m10s
The page-top chrome was inconsistent: most nested views used the breadcrumb
kicker + page-title pattern, but plugin sub-pages used ad-hoc "← back" links,
~11 pages stacked a breadcrumb AND a redundant ancestor back button, and two
(monitors/edit, hosts/uptime) had a back button but no breadcrumb. The
settings/plugin_detail page stacked all of it at once.

Unify on the breadcrumb-led model:
- settings/_tabs.html: drop the hardcoded "Settings" h1; the breadcrumb
  ("Settings › …") plus the tab strip is the header.
- settings/plugin_detail: drop the "← Plugins" back button.
- docker container_detail/swarm/disk + snmp/device: replace ad-hoc back links
  with the standard crumbs() breadcrumb.
- host_agent, ansible/*, alerts/maintenance: remove redundant ancestor back
  buttons (the breadcrumb's parent crumbs already link there); keep lateral
  shortcuts (Inventory/Schedules/Browse/Targets/Groups/New).
- monitors/edit, hosts/uptime: add the missing breadcrumb, drop the back link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-18 23:07:12 -04:00
bvandeusen 626ba69934 test(docker): parse-check templates + smoke routes/_human_bytes (milestone 77 #943)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 48s
CI / integration (push) Successful in 2m16s
CI / publish (push) Successful in 1m14s
CI never renders docker templates through the app (the unit-lane app uses
testing=True, which skips plugin loading), so a Jinja syntax error could ship
green. Add unit tests that parse every docker template, smoke-import the routes
module + confirm the #942 view functions exist, and cover the _human_bytes /
_human_uptime presentation helpers. Closes the render-regression gap the new UI
pages introduced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-18 22:04:11 -04:00
bvandeusen 9615f9abcd feat(docker): group containers by compose/swarm + enrich widget (milestone 77 #942)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 46s
CI / integration (push) Successful in 2m21s
CI / publish (push) Successful in 8s
Container list now sub-groups each host's containers by compose project (or
swarm service) with a small subheading, preserving the running-first order;
hosts with no such labels render flat as before. The dashboard status widget
links each container to its detail page and surfaces enriched state inline —
health dot (healthy/unhealthy), restart count, and last non-zero exit code for
stopped containers. Completes the #942 UI surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-18 22:01:12 -04:00
bvandeusen 114262dbf9 feat(docker): swarm topology view + image/disk usage page (milestone 77 #942)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 48s
CI / integration (push) Successful in 2m21s
CI / publish (push) Successful in 8s
Two new read-only sub-pages, linked from the Docker index header only when the
data exists (non-swarm / Docker-less installs aren't offered empty pages):

- /plugins/docker/swarm — services with replica health (running/desired,
  colour-coded green/amber/red), Swarm nodes (role/availability/status, leader
  badge), and task→node placement with node ids resolved to hostnames. Grouped
  by reporting manager host. Empty state explains manager-only collection.

- /plugins/docker/disk — per-host reclaimable space, image/layer/container/
  volume/build-cache sizes, stopped-container count, and a per-image table
  (size, shared, ref count, reclaimable badge). Notes prune actions are deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-18 21:58:19 -04:00
bvandeusen 3e4e35de96 feat(docker): container detail page + lifecycle timeline (milestone 77 #942)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 47s
CI / integration (push) Successful in 2m21s
CI / publish (push) Successful in 7s
New /plugins/docker/container/<host_id>/<name> detail page (v1 quality):
status/health badge, uptime, CPU/mem, restart count, last exit code (+OOM),
net + block I/O (humanised), image/compose/swarm-service/node/ports, a
range-toggled CPU/mem history graph (HTMX fragment reusing the time-range
selector), and a lifecycle timeline rendered from docker_events (glyph+colour
per event kind). Not-found and empty-history/empty-timeline states included.
Container names in the main list and the per-host hub panel now link to it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-18 21:54:54 -04:00
bvandeusen 277eb40165 fix(docker): close read txn before second begin in disk-usage test
test_disk_usage_persisted opened a second session.begin() after interleaved
SELECTs, which autobegin a transaction → "A transaction is already begun".
Roll back the read transaction before the re-report write. Restores the
integration lane green for the /system/df slice (a840d6f).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-18 21:54:54 -04:00
84 changed files with 3698 additions and 589 deletions
+4 -2
View File
@@ -19,8 +19,10 @@ WORKDIR /app
COPY pyproject.toml .
COPY steward/ steward/
# .[ansible] pulls the full Ansible package so the playbook runner works in-image.
RUN pip install --no-cache-dir '.[ansible]'
# Bundle the extras whose first-party plugins ship in the image: [ansible] for
# the playbook runner, [snmp] (pysnmp) for the SNMP poller. Without them those
# bundled plugins load but silently no-op for want of their runtime dependency.
RUN pip install --no-cache-dir '.[ansible,snmp]'
COPY alembic.ini .
# First-party plugins ship inside the image (bundled root at /app/plugins).
+11 -1
View File
@@ -22,12 +22,22 @@ services:
# /data holds the auto-generated secret key, third-party plugins, and the
# Ansible playbook cache — all of which must survive image updates. No
# source bind-mounts: core + first-party plugins live inside the image.
#
# The container runs as the unprivileged 'app' user (uid 1000), so /data
# MUST be writable by uid 1000. A named docker volume (below) gets that
# automatically. If you bind-mount a host/NFS path instead, chown it to
# 1000:1000 (and mind NFS root_squash) — otherwise the app can't persist
# its secret key, mints a fresh one each boot, and every encrypted secret
# (managed SSH key, SMTP/OIDC/LDAP creds) becomes unrecoverable. The app
# now refuses to start in that state rather than silently degrade.
- app_data:/data
environment:
- STEWARD_DATABASE_URL=postgresql+asyncpg://steward:${POSTGRES_PASSWORD:-steward}@db/steward
# STEWARD_SECRET_KEY is intentionally absent — the app generates a random
# key on first boot and persists it to /data/secret.key, which the app_data
# volume keeps across image updates.
# volume keeps across image updates. On multi-node Swarm with a shared bind
# mount, pin STEWARD_SECRET_KEY (env or a docker secret) instead, so the key
# never depends on filesystem ownership being correct on every node.
depends_on:
db:
condition: service_healthy
+5
View File
@@ -47,3 +47,8 @@ def get_scheduled_tasks() -> list:
def get_blueprint():
from .routes import docker_bp
return docker_bp
def get_nav() -> list[dict]:
# Surfaces in the sidebar's Infrastructure group (fleet-wide container view).
return [{"label": "Docker", "href": "/plugins/docker/"}]
+27
View File
@@ -0,0 +1,27 @@
"""Model-free Docker view helpers.
Kept separate from routes.py/models.py so tests can import it directly: importing
a module that re-imports plugins.docker.models AFTER the app has loaded the docker
plugin raises "Table 'docker_containers' is already defined". This module touches
no ORM models, so it's safe to import in either environment.
"""
from __future__ import annotations
def dedup_by_container_id(containers: list) -> list:
"""Collapse containers double-reported across hosts. Swarm-aware agents on
every manager list cluster-wide tasks, so the same container (identical
container_id) arrives once per manager and would otherwise be counted N times.
Keep the first occurrence per non-empty container_id — callers order
running-first, so a running report wins over a stale stopped one. Rows without
a container_id (older agents) can't be matched, so they're kept as-is."""
seen: set[str] = set()
out = []
for c in containers:
cid = (c.container_id or "").strip()
if cid:
if cid in seen:
continue
seen.add(cid)
out.append(c)
return out
+13
View File
@@ -321,3 +321,16 @@ async def persist_host_docker(session, host, snapshots, swarm=None, disk=None) -
resource_name=resource, metric_name="is_healthy",
value=1.0 if health == "healthy" else 0.0,
)
# Reap containers that vanished from the host's latest listing. Without this,
# removed one-shot containers (CI job runners, buildkit builders, codex jobs)
# accumulate forever as permanent "stopped" rows and inflate every count.
# The listing is authoritative (event derivation already treats absence as
# removal), so anything not in the newest snapshot no longer exists on the
# host. Mirrors the image/swarm/disk persisters. An empty snapshot legitimately
# means the host has no containers, so the unfiltered delete is correct.
seen_names = {c["name"] for c in latest_containers if c.get("name")}
reap = delete(DockerContainer).where(DockerContainer.host_id == host.id)
if seen_names:
reap = reap.where(DockerContainer.name.notin_(seen_names))
await session.execute(reap)
@@ -0,0 +1,33 @@
"""Widen memory byte columns to BIGINT
docker_metrics.mem_usage_bytes and docker_containers.mem_usage_bytes /
mem_limit_bytes were int4, which overflows for any container using >2.1 GB
(asyncpg: "value out of int32 range"), failing the whole ingest batch. Widen to
BIGINT — a safe in-place int4→int8 promotion, no data rewrite.
Revision ID: docker_008_bigint_mem
Revises: docker_007_disk_usage
Create Date: 2026-06-20
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "docker_008_bigint_mem"
down_revision: Union[str, None] = "docker_007_disk_usage"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.alter_column("docker_metrics", "mem_usage_bytes", type_=sa.BigInteger())
op.alter_column("docker_containers", "mem_usage_bytes", type_=sa.BigInteger())
op.alter_column("docker_containers", "mem_limit_bytes", type_=sa.BigInteger())
def downgrade() -> None:
# Narrowing back to int4 will error if any stored value exceeds 2^31 — that's
# the very condition this migration fixed, so downgrade is best-effort.
op.alter_column("docker_containers", "mem_limit_bytes", type_=sa.Integer())
op.alter_column("docker_containers", "mem_usage_bytes", type_=sa.Integer())
op.alter_column("docker_metrics", "mem_usage_bytes", type_=sa.Integer())
+6 -3
View File
@@ -29,8 +29,10 @@ class DockerContainer(Base):
status: Mapped[str] = mapped_column(String(32), nullable=False, default="unknown")
# running | stopped | paused | exited | dead
cpu_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
mem_usage_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
mem_limit_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
# BigInteger: a container's memory usage/limit routinely exceeds 2^31 bytes
# (~2.1 GB), which overflows int4 and fails the insert.
mem_usage_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
mem_limit_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
mem_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
restart_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
ports_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
@@ -78,7 +80,8 @@ class DockerMetric(Base):
)
cpu_pct: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
mem_pct: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
mem_usage_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# BigInteger: per-container memory usage exceeds 2^31 bytes (~2.1 GB) routinely.
mem_usage_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
# Per-container history lookups filter on (host_id, container_name) then sort
# by time — a composite index keeps the rows() sparkline queries cheap.
+329 -24
View File
@@ -1,20 +1,39 @@
# plugins/docker/routes.py
from __future__ import annotations
import json
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from quart import Blueprint, current_app, render_template, request
from sqlalchemy import select
from sqlalchemy import func, select
from steward.auth.middleware import require_role
from steward.models.users import UserRole
from steward.models.hosts import Host
from steward.core.time_range import parse_range, DEFAULT_RANGE
from .models import DockerContainer, DockerMetric
from .dedup import dedup_by_container_id
from .swarm_view import build_swarm_services
from .models import (
DockerContainer, DockerDiskUsage, DockerEvent, DockerImage, DockerMetric,
DockerSwarmNode, DockerSwarmService,
)
docker_bp = Blueprint("docker", __name__, template_folder="templates")
def _human_bytes(n: int | None) -> str:
"""Compact binary size string (e.g. '1.4 GiB', '512 MiB', '0 B')."""
if n is None:
return ""
size = float(n)
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if abs(size) < 1024.0 or unit == "TiB":
if unit == "B":
return f"{int(size)} {unit}"
return f"{size:.1f} {unit}"
size /= 1024.0
return f"{size:.1f} TiB"
def _human_uptime(started_at: datetime | None) -> str | None:
"""Compact 'how long running' string (e.g. '3d 4h', '5h 12m', '8m')."""
if started_at is None:
@@ -92,10 +111,19 @@ async def _container_history(db, host_id: str, name: str, since) -> tuple[list,
async def index():
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
current_range = request.args.get("range", DEFAULT_RANGE)
# Show the Swarm link only when a manager has actually reported topology, so
# non-swarm installs aren't offered an always-empty page.
async with current_app.db_sessionmaker() as db:
has_swarm = (await db.execute(
select(DockerSwarmService.host_id).limit(1))).first() is not None
has_disk = (await db.execute(
select(DockerDiskUsage.host_id).limit(1))).first() is not None
return await render_template(
"docker/index.html",
poll_interval=poll_interval,
current_range=current_range,
has_swarm=has_swarm,
has_disk=has_disk,
)
@@ -111,7 +139,8 @@ async def _host_map(db, host_ids: set[str]) -> dict[str, Host]:
@docker_bp.get("/rows")
@require_role(UserRole.viewer)
async def rows():
"""HTMX fragment: containers grouped by host, with resource sparklines."""
"""HTMX fragment: swarm services (collapsed, cluster-complete) on top, then
non-swarm containers grouped by host with resource sparklines."""
since, range_key = parse_range(request.args.get("range"))
async with current_app.db_sessionmaker() as db:
@@ -121,12 +150,24 @@ async def rows():
DockerContainer.name,
)
)).scalars())
hosts = await _host_map(db, {c.host_id for c in containers})
services = list((await db.execute(select(DockerSwarmService))).scalars())
nodes = list((await db.execute(select(DockerSwarmNode))).scalars())
hosts = await _host_map(
db,
{c.host_id for c in containers}
| {n.host_id for n in nodes} | {s.host_id for s in services},
)
# Group by host so each container is clearly attributed to the box it
# runs on (names are only unique within a host now).
# Swarm tasks collapse into per-service rows (each replica labelled with
# its host, plus "ghost" replicas for tasks on agent-less nodes, drawn
# from the managers' placement). Non-swarm containers keep the host view.
node_hostname = {n.node_id: n.hostname for n in nodes if n.hostname}
host_name = {h.id: h.name for h in hosts.values()}
swarm_view = build_swarm_services(containers, services, node_hostname, host_name)
standalone = [c for c in containers if not c.service_name]
groups: dict[str, dict] = {}
for c in containers:
for c in standalone:
g = groups.get(c.host_id)
if g is None:
host = hosts.get(c.host_id)
@@ -149,15 +190,34 @@ async def rows():
else:
g["stopped"] += 1
# Sub-group each host's containers by compose project, preserving the
# running-first ordering. Containers with no project fall into an unlabelled
# bucket rendered flat, so plain hosts look unchanged.
for g in groups.values():
subs: dict[str, dict] = {}
order: list[str] = []
for item in g["containers"]:
c = item["container"]
label = c.compose_project or None
key = label or ""
if key not in subs:
subs[key] = {"label": label, "containers": []}
order.append(key)
subs[key]["containers"].append(item)
g["subgroups"] = [subs[k] for k in order]
g["grouped"] = any(s["label"] for s in g["subgroups"])
host_groups = sorted(groups.values(), key=lambda g: g["host_name"].lower())
running = sum(g["running"] for g in host_groups)
total = len(containers)
swarm_services = swarm_view["services"]
standalone_running = sum(g["running"] for g in host_groups)
ghost_total = sum(r["count"] for s in swarm_services for r in s["replicas"] if r["ghost"])
return await render_template(
"docker/rows.html",
host_groups=host_groups,
running=running,
stopped=total - running,
total=total,
swarm_services=swarm_services,
running=standalone_running + sum(s["running"] for s in swarm_services),
stopped=len(standalone) - standalone_running,
total=len(containers) + ghost_total,
range_key=range_key,
)
@@ -168,26 +228,46 @@ async def widget():
"""HTMX dashboard widget: container status overview, grouped by host."""
show_stopped = request.args.get("show_stopped", "no") == "yes"
widget_id = request.args.get("wid", "0")
since_24h = datetime.now(timezone.utc) - timedelta(hours=24)
async with current_app.db_sessionmaker() as db:
all_containers = list((await db.execute(
all_containers = dedup_by_container_id(list((await db.execute(
select(DockerContainer).order_by(
(DockerContainer.status == "running").desc(),
DockerContainer.name,
)
)).scalars())
hosts = await _host_map(db, {c.host_id for c in all_containers})
)).scalars()))
services = list((await db.execute(select(DockerSwarmService))).scalars())
nodes = list((await db.execute(select(DockerSwarmNode))).scalars())
# Recent failures are more actionable than a static "stopped" tally. Count
# DISTINCT container names so the two managers' duplicate die/oom events
# don't double it.
failed_24h = (await db.execute(
select(func.count(func.distinct(DockerEvent.container_name)))
.where(DockerEvent.event.in_(("die", "oom")), DockerEvent.at >= since_24h)
)).scalar() or 0
hosts = await _host_map(
db,
{c.host_id for c in all_containers}
| {n.host_id for n in nodes} | {s.host_id for s in services},
)
running = [c for c in all_containers if c.status == "running"]
stopped = [c for c in all_containers if c.status != "running"]
display = all_containers if show_stopped else running
# Swarm tasks collapse into per-service rows (each replica tagged with its
# host); non-swarm containers stay grouped by host.
node_hostname = {n.node_id: n.hostname for n in nodes if n.hostname}
host_name = {h.id: h.name for h in hosts.values()}
swarm_services = build_swarm_services(all_containers, services, node_hostname, host_name)["services"]
standalone = [c for c in all_containers if not c.service_name]
running = [c for c in standalone if c.status == "running"]
display = standalone if show_stopped else running
# Group for display so multi-host fleets read clearly; single-host stays flat.
groups: dict[str, dict] = {}
for c in display:
g = groups.setdefault(c.host_id, {
"host_id": c.host_id,
"host_name": hosts[c.host_id].name if c.host_id in hosts else c.host_id,
"host_name": host_name.get(c.host_id, c.host_id),
"containers": [],
})
g["containers"].append(c)
@@ -197,8 +277,10 @@ async def widget():
"docker/widget.html",
host_groups=host_groups,
multi_host=len(host_groups) > 1,
running_count=len(running),
stopped_count=len(stopped),
swarm_services=swarm_services,
running_count=len(running) + sum(s["running"] for s in swarm_services),
failed_24h=failed_24h,
total_count=len(all_containers) + len(swarm_services),
show_stopped=show_stopped,
widget_id=widget_id,
)
@@ -212,11 +294,13 @@ async def widget_resources():
widget_id = request.args.get("wid", "0")
async with current_app.db_sessionmaker() as db:
containers = list((await db.execute(
# Dedup before the limit, else swarm tasks reported by both managers can
# fill the list with duplicates of the same container.
containers = dedup_by_container_id(list((await db.execute(
select(DockerContainer)
.where(DockerContainer.status == "running")
.order_by(DockerContainer.cpu_pct.desc().nullslast())
)).scalars())[:limit]
)).scalars()))[:limit]
hosts = await _host_map(db, {c.host_id for c in containers})
rows_data = [
@@ -258,3 +342,224 @@ async def host_panel(host_id: str):
running=running,
stopped=len(containers) - running,
)
# Glyph + colour per lifecycle event, so the timeline reads at a glance.
_EVENT_STYLE = {
"start": ("", "var(--green)"),
"stop": ("", "var(--text-muted)"),
"die": ("", "var(--red)"),
"oom": ("", "var(--red)"),
"health_change": ("", "var(--orange)"),
}
@docker_bp.get("/container/<host_id>/<name>")
@require_role(UserRole.viewer)
async def container_detail(host_id: str, name: str):
"""Full detail page for one container: facts, resource history, lifecycle."""
async with current_app.db_sessionmaker() as db:
c = await db.get(DockerContainer, (host_id, name))
host = await db.get(Host, host_id)
events = []
if c is not None:
events = list((await db.execute(
select(DockerEvent)
.where(DockerEvent.host_id == host_id)
.where(DockerEvent.container_name == name)
.order_by(DockerEvent.at.desc())
.limit(50)
)).scalars())
if c is None:
return await render_template(
"docker/container_detail.html",
container=None, host_id=host_id, name=name,
), 404
return await render_template(
"docker/container_detail.html",
container=c,
host=host,
host_id=host_id,
name=name,
ports=json.loads(c.ports_json) if c.ports_json else [],
uptime=_human_uptime(c.started_at) if c.status == "running" else None,
net_rx=_human_bytes(c.net_rx_bytes), net_tx=_human_bytes(c.net_tx_bytes),
blk_read=_human_bytes(c.blk_read_bytes), blk_write=_human_bytes(c.blk_write_bytes),
events=[
{"event": e.event, "detail": e.detail, "at": e.at,
"glyph": _EVENT_STYLE.get(e.event, ("", "var(--text-muted)"))[0],
"colour": _EVENT_STYLE.get(e.event, ("", "var(--text-muted)"))[1]}
for e in events
],
current_range=request.args.get("range", DEFAULT_RANGE),
)
@docker_bp.get("/swarm")
@require_role(UserRole.viewer)
async def swarm():
"""Swarm topology page: services with replica health, nodes, placement.
Swarm services/nodes are cluster-global — every manager's API returns the
same list — but each manager reports them independently (rows keyed by
host_id). So we group the reporting managers into swarms (managers that share
any node_id are the same cluster) and dedup within each: one row per service
(by name) and per node (by node_id), keeping the freshest. Without this, two
managers in one cluster list every service/node twice.
"""
async with current_app.db_sessionmaker() as db:
services = list((await db.execute(
select(DockerSwarmService).order_by(DockerSwarmService.service_name)
)).scalars())
nodes = list((await db.execute(
select(DockerSwarmNode).order_by(
DockerSwarmNode.role, DockerSwarmNode.hostname)
)).scalars())
hosts = await _host_map(db, {s.host_id for s in services} | {n.host_id for n in nodes})
# ── Group reporting managers into swarms by shared node membership ──────────
manager_ids = {s.host_id for s in services} | {n.host_id for n in nodes}
nodes_by_mgr: dict[str, set[str]] = {}
for n in nodes:
nodes_by_mgr.setdefault(n.host_id, set()).add(n.node_id)
parent = {m: m for m in manager_ids}
def _find(x: str) -> str:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
mgrs = list(manager_ids)
for i in range(len(mgrs)):
for j in range(i + 1, len(mgrs)):
a, b = mgrs[i], mgrs[j]
if nodes_by_mgr.get(a) and nodes_by_mgr.get(b) and (nodes_by_mgr[a] & nodes_by_mgr[b]):
parent[_find(a)] = _find(b)
swarm_members: dict[str, set[str]] = {}
for m in manager_ids:
swarm_members.setdefault(_find(m), set()).add(m)
# ── Dedup services (by name) + nodes (by node_id) within each swarm ─────────
swarms = []
for members in swarm_members.values():
svc_by_name: dict[str, DockerSwarmService] = {}
for s in services:
if s.host_id in members and (
s.service_name not in svc_by_name
or s.updated_at > svc_by_name[s.service_name].updated_at
):
svc_by_name[s.service_name] = s
node_by_id: dict[str, DockerSwarmNode] = {}
for n in nodes:
if n.host_id in members and (
n.node_id not in node_by_id
or n.updated_at > node_by_id[n.node_id].updated_at
):
node_by_id[n.node_id] = n
node_names = {nid: (nrow.hostname or nid) for nid, nrow in node_by_id.items()}
svc_dicts = []
for s in sorted(svc_by_name.values(), key=lambda s: s.service_name):
placement = []
for p in (json.loads(s.placement_json) if s.placement_json else []):
nid = p.get("node_id", "")
placement.append({
"node": node_names.get(nid, nid[:12] or "?"),
"running": p.get("running", 0),
})
svc_dicts.append({
"name": s.service_name, "mode": s.mode,
"running": s.running, "desired": s.desired, "image": s.image,
"healthy": s.running >= s.desired and s.desired > 0,
"degraded": 0 < s.running < s.desired,
"down": s.running == 0 and s.desired > 0,
"placement": placement,
})
node_rows = sorted(
node_by_id.values(), key=lambda n: (n.role, (n.hostname or n.node_id).lower())
)
manager_names = sorted(hosts[m].name if m in hosts else m for m in members)
swarms.append({
"managers": manager_names,
"services": svc_dicts,
"nodes": node_rows,
})
swarms.sort(key=lambda g: g["managers"][0].lower() if g["managers"] else "")
return await render_template("docker/swarm.html", swarms=swarms)
@docker_bp.get("/disk")
@require_role(UserRole.viewer)
async def disk():
"""Image/disk usage page: reclaimable space, per-image sizes, stopped count.
Read-only — prune actions are deferred to the cleanup-actions milestone, so
this surfaces the numbers and notes where reclaim lives.
"""
async with current_app.db_sessionmaker() as db:
summaries = list((await db.execute(select(DockerDiskUsage))).scalars())
images = list((await db.execute(
select(DockerImage).order_by(DockerImage.size.desc())
)).scalars())
# Stopped-container count per host, surfaced alongside reclaimable space.
stopped_rows = (await db.execute(
select(DockerContainer.host_id)
.where(DockerContainer.status != "running")
)).scalars()
stopped_by_host: dict[str, int] = {}
for hid in stopped_rows:
stopped_by_host[hid] = stopped_by_host.get(hid, 0) + 1
hosts = await _host_map(db, {s.host_id for s in summaries})
images_by_host: dict[str, list] = {}
for im in images:
images_by_host.setdefault(im.host_id, []).append({
"repo_tag": im.repo_tag, "size": _human_bytes(im.size),
"shared": _human_bytes(im.shared_size), "containers": im.containers,
"reclaimable": im.containers == 0,
})
host_groups = [
{
"host_id": s.host_id,
"host_name": hosts[s.host_id].name if s.host_id in hosts else s.host_id,
"summary": {
"images_total": s.images_total, "images_active": s.images_active,
"images_size": _human_bytes(s.images_size),
"images_reclaimable": _human_bytes(s.images_reclaimable),
"layers_size": _human_bytes(s.layers_size),
"containers_count": s.containers_count,
"containers_size": _human_bytes(s.containers_size),
"volumes_count": s.volumes_count,
"volumes_size": _human_bytes(s.volumes_size),
"build_cache_size": _human_bytes(s.build_cache_size),
},
"stopped": stopped_by_host.get(s.host_id, 0),
"images": images_by_host.get(s.host_id, []),
}
for s in summaries
]
host_groups.sort(key=lambda g: g["host_name"].lower())
return await render_template("docker/disk.html", host_groups=host_groups)
@docker_bp.get("/container/<host_id>/<name>/history")
@require_role(UserRole.viewer)
async def container_history(host_id: str, name: str):
"""HTMX fragment: CPU + memory sparklines for the selected time range."""
since, range_key = parse_range(request.args.get("range"))
async with current_app.db_sessionmaker() as db:
cpu_hist, mem_hist = await _container_history(db, host_id, name, since)
return await render_template(
"docker/_container_history.html",
sparkline_cpu=_sparkline(cpu_hist, width=320, height=48),
sparkline_mem=_sparkline(mem_hist, width=320, height=48),
have_data=len(cpu_hist) >= 2,
range_key=range_key,
)
+140
View File
@@ -0,0 +1,140 @@
"""Model-free builder for the swarm-aware container view.
Merges two sources into one service-grouped, cluster-complete picture:
• docker_containers — real container rows, collected LOCAL-per-node, so each
carries the host (= node) it runs on plus its swarm service/node labels.
• DockerSwarmService.placement_json — the managers' cluster-wide task→node
counts, which include tasks on nodes that run no Steward agent (and so have
no container row of their own).
For every swarm service we list the real replicas we have detail for, then add
"ghost" replicas for the remaining placement count on each node (agent-less
nodes). Non-swarm containers pass through grouped by host, unchanged.
Kept model-free (no ORM imports) so it's unit-testable and safe to import in
tests without the plugin-loader "table already defined" gotcha.
"""
from __future__ import annotations
import json
def _service_state(running: int, desired: int) -> str:
if desired > 0 and running >= desired:
return "healthy"
if running > 0:
return "degraded"
return "down"
def _placement(service) -> list[dict]:
raw = getattr(service, "placement_json", None) or "[]"
try:
data = json.loads(raw)
except (ValueError, TypeError):
return []
return [p for p in data if isinstance(p, dict)]
def build_swarm_services(containers, services, node_hostname: dict, host_name: dict) -> dict:
"""Return {"services": [...], "standalone": [...]}.
services[i] = {name, mode, desired, running, state, replicas:[...]}
real replica = {ghost:False, host, host_id, name, status, cpu_pct, mem_pct,
health, restart_count}
ghost replica = {ghost:True, host, count} # placement with no local row
standalone[i] = {host_id, host, containers:[...]} # non-swarm, by host
"""
containers = list(containers)
swarm_cs = [c for c in containers if getattr(c, "service_name", None)]
standalone_cs = [c for c in containers if not getattr(c, "service_name", None)]
# Dedup service rows by name (every manager reports the same cluster-global
# set); keep the first — placement is identical across managers.
svc_by_name: dict[str, object] = {}
for s in services:
name = getattr(s, "service_name", None)
if name and name not in svc_by_name:
svc_by_name[name] = s
# Real containers grouped by service name.
cs_by_service: dict[str, list] = {}
for c in swarm_cs:
cs_by_service.setdefault(c.service_name, []).append(c)
def _host_of(c):
return (host_name.get(getattr(c, "host_id", None))
or node_hostname.get(getattr(c, "node_id", None))
or getattr(c, "host_id", None) or "?")
out_services = []
for name in sorted(set(svc_by_name) | set(cs_by_service)):
svc = svc_by_name.get(name)
reals = cs_by_service.get(name, [])
replicas = []
for c in sorted(reals, key=lambda c: (_host_of(c).lower(), c.name)):
replicas.append({
"ghost": False,
"host": _host_of(c),
"host_id": getattr(c, "host_id", None),
"name": c.name,
"status": getattr(c, "status", "unknown"),
"cpu_pct": getattr(c, "cpu_pct", None),
"mem_pct": getattr(c, "mem_pct", None),
"health": getattr(c, "health", None),
"restart_count": getattr(c, "restart_count", 0) or 0,
})
# Real running replicas per node, to subtract from placement.
real_running_by_node: dict[str, int] = {}
for c in reals:
if (getattr(c, "status", "") or "").lower() == "running":
nid = getattr(c, "node_id", None)
if nid:
real_running_by_node[nid] = real_running_by_node.get(nid, 0) + 1
ghosts = []
for p in _placement(svc) if svc is not None else []:
nid = p.get("node_id", "")
placed = int(p.get("running", 0) or 0)
ghost = placed - real_running_by_node.get(nid, 0)
if ghost > 0:
ghosts.append({
"ghost": True,
"host": node_hostname.get(nid) or (nid[:12] if nid else "?"),
"count": ghost,
})
ghosts.sort(key=lambda g: g["host"].lower())
replicas.extend(ghosts)
running = getattr(svc, "running", None) if svc is not None else None
desired = getattr(svc, "desired", None) if svc is not None else None
# No service row (manager didn't report it): infer from what we can see.
if running is None:
running = sum(1 for r in replicas
if (not r["ghost"] and (r["status"] or "").lower() == "running"))
if desired is None:
desired = len(replicas)
out_services.append({
"name": name,
"mode": getattr(svc, "mode", "replicated") if svc is not None else "replicated",
"running": running,
"desired": desired,
"state": _service_state(int(running or 0), int(desired or 0)),
"replicas": replicas,
})
# Non-swarm containers grouped by host, running-first order preserved.
groups: dict[str, dict] = {}
for c in standalone_cs:
hid = getattr(c, "host_id", None)
g = groups.get(hid)
if g is None:
g = groups[hid] = {"host_id": hid, "host": host_name.get(hid) or hid or "?",
"containers": []}
g["containers"].append(c)
standalone = sorted(groups.values(), key=lambda g: g["host"].lower())
return {"services": out_services, "standalone": standalone}
@@ -0,0 +1,17 @@
{# docker/_container_history.html — CPU/mem sparklines for the selected range #}
{% if have_data %}
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1.5rem;">
<div>
<div class="section-title" style="margin-bottom:0.4rem;">CPU %</div>
{{ sparkline_cpu | safe }}
</div>
<div>
<div class="section-title" style="margin-bottom:0.4rem;">Memory %</div>
{{ sparkline_mem | safe }}
</div>
</div>
{% else %}
<div style="color:var(--text-muted);font-size:0.85rem;">
Not enough samples in this range yet — history fills in as the agent reports.
</div>
{% endif %}
@@ -0,0 +1,110 @@
{# docker/container_detail.html — full detail page for one container #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% block title %}{{ name }} — Docker — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), (name, "")]) }}{% endblock %}
{% block content %}
{% if container is none %}
<div class="card" style="text-align:center;padding:3rem;">
<div style="font-weight:600;margin-bottom:0.4rem;">Container not found</div>
<div style="color:var(--text-muted);font-size:0.9rem;">
No container named <code>{{ name }}</code> is currently reported for this host.
It may have been removed, or the host agent hasn't reported recently.
</div>
</div>
{% else %}
{# ── Header ──────────────────────────────────────────────────────────────── #}
<div style="display:flex;align-items:center;gap:0.6rem;margin-bottom:0.35rem;flex-wrap:wrap;">
<span class="dot {% if container.status == 'running' %}dot-up{% elif container.status == 'paused' %}dot-warn{% else %}dot-down{% endif %}"></span>
<h1 class="page-title" style="margin-bottom:0;">{{ container.name }}</h1>
{% if container.health == 'healthy' %}<span style="font-size:0.72rem;color:var(--green);border:1px solid var(--green);border-radius:3px;padding:0.05rem 0.4rem;">healthy</span>
{% elif container.health == 'unhealthy' %}<span style="font-size:0.72rem;color:var(--red);border:1px solid var(--red);border-radius:3px;padding:0.05rem 0.4rem;">unhealthy</span>
{% elif container.health == 'starting' %}<span style="font-size:0.72rem;color:var(--orange);border:1px solid var(--orange);border-radius:3px;padding:0.05rem 0.4rem;">starting</span>{% endif %}
</div>
<div style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.5rem;">
{{ container.status }}{% if uptime %} · up {{ uptime }}{% endif %}
{% if host %} · on <a href="/hosts/{{ host.id }}" style="color:var(--text-muted);">{{ host.name }}</a>{% endif %}
</div>
{# ── Facts grid ──────────────────────────────────────────────────────────── #}
{% macro fact(label, value, colour="") %}
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">{{ label }}</div>
<div style="font-size:0.95rem;{% if colour %}color:{{ colour }};{% endif %}font-family:ui-monospace,monospace;">{{ value }}</div>
</div>
{% endmacro %}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.75rem;margin-bottom:1.5rem;">
{{ fact("CPU", "%.1f%%" | format(container.cpu_pct) if container.cpu_pct is not none else "—") }}
{{ fact("Memory", "%.1f%%" | format(container.mem_pct) if container.mem_pct is not none else "—") }}
{{ fact("Restarts", container.restart_count, "var(--orange)" if container.restart_count else "") }}
{{ fact("Last exit", (container.exit_code ~ (" (OOM)" if container.oom_killed else "")) if container.exit_code is not none else "—",
"var(--red)" if (container.exit_code is not none and container.exit_code != 0) else "") }}
{{ fact("Net in", net_rx) }}
{{ fact("Net out", net_tx) }}
{{ fact("Block read", blk_read) }}
{{ fact("Block write", blk_write) }}
</div>
{# ── Image / placement ───────────────────────────────────────────────────── #}
<div class="card" style="margin-bottom:1.5rem;">
<div style="display:grid;grid-template-columns:max-content 1fr;gap:0.4rem 1.25rem;font-size:0.86rem;">
<span style="color:var(--text-muted);">Image</span>
<span style="font-family:ui-monospace,monospace;word-break:break-all;">{{ container.image or "—" }}</span>
{% if container.compose_project %}
<span style="color:var(--text-muted);">Compose project</span><span>{{ container.compose_project }}</span>
{% endif %}
{% if container.service_name %}
<span style="color:var(--text-muted);">Swarm service</span><span>{{ container.service_name }}</span>
{% endif %}
{% if container.node_id %}
<span style="color:var(--text-muted);">Node</span><span style="font-family:ui-monospace,monospace;">{{ container.node_id }}</span>
{% endif %}
{% if ports %}
<span style="color:var(--text-muted);">Ports</span>
<span style="font-family:ui-monospace,monospace;">{% for p in ports %}{{ p.host_port }}:{{ p.container_port }}/{{ p.protocol }}{% if not loop.last %}, {% endif %}{% endfor %}</span>
{% endif %}
</div>
</div>
{# ── Resource history (range-toggled) ────────────────────────────────────── #}
<div class="card" style="margin-bottom:1.5rem;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:0.75rem;flex-wrap:wrap;">
<h3 class="section-title" style="margin-bottom:0;">Resource history</h3>
{% include "_time_range.html" %}
</div>
<div id="container-history"
hx-get="/plugins/docker/container/{{ host_id }}/{{ name }}/history"
hx-trigger="load, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML">
<div style="color:var(--text-muted);font-size:0.85rem;">Loading…</div>
</div>
</div>
{# ── Lifecycle timeline ──────────────────────────────────────────────────── #}
<div class="card">
<h3 class="section-title" style="margin-bottom:0.75rem;">Lifecycle</h3>
{% if events %}
<div style="display:grid;gap:0.5rem;">
{% for e in events %}
<div style="display:flex;align-items:baseline;gap:0.6rem;font-size:0.85rem;">
<span style="color:{{ e.colour }};width:1rem;text-align:center;flex-shrink:0;">{{ e.glyph }}</span>
<span style="font-weight:500;width:6.5rem;flex-shrink:0;">{{ e.event }}</span>
<span style="color:var(--text-muted);flex:1;min-width:0;">{{ e.detail or "" }}</span>
<span style="color:var(--text-dim);font-size:0.78rem;white-space:nowrap;" title="{{ e.at }}">{{ e.at.strftime("%Y-%m-%d %H:%M") }}</span>
</div>
{% endfor %}
</div>
{% else %}
<div style="color:var(--text-muted);font-size:0.85rem;">
No lifecycle events recorded yet. Start/stop/health changes appear here as the
agent reports them over time.
</div>
{% endif %}
</div>
{% endif %}
{% endblock %}
+83
View File
@@ -0,0 +1,83 @@
{# docker/disk.html — image/disk usage: reclaimable space, per-image sizes #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% block title %}Disk — Docker — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), ("Image & disk usage", "")]) }}{% endblock %}
{% block content %}
<h1 class="page-title" style="margin-bottom:0.4rem;">Image &amp; disk usage</h1>
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.5rem;">
Reclaimable = space held by images no container references. Cleanup actions
(prune) arrive in a later release — these are read-only figures for now.
</p>
{% if host_groups %}
{% for g in host_groups %}
<div style="margin-bottom:2rem;">
<div style="font-weight:600;font-size:0.95rem;margin-bottom:0.75rem;">{{ g.host_name }}</div>
{# ── Summary stats ────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.75rem;margin-bottom:1rem;">
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Reclaimable</div>
<span class="stat-val" style="color:{% if g.summary.images_reclaimable != '0 B' %}var(--orange){% else %}var(--green){% endif %};">{{ g.summary.images_reclaimable }}</span>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Images</div>
<span class="stat-val">{{ g.summary.images_size }}</span>
<div style="font-size:0.74rem;color:var(--text-muted);">{{ g.summary.images_active }}/{{ g.summary.images_total }} in use</div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Stopped</div>
<span class="stat-val" style="{% if g.stopped %}color:var(--text-muted){% endif %};">{{ g.stopped }}</span>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Writable layers</div>
<span class="stat-val">{{ g.summary.containers_size }}</span>
<div style="font-size:0.74rem;color:var(--text-muted);">{{ g.summary.containers_count }} containers</div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Volumes</div>
<span class="stat-val">{{ g.summary.volumes_size }}</span>
<div style="font-size:0.74rem;color:var(--text-muted);">{{ g.summary.volumes_count }} volumes</div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Build cache</div>
<span class="stat-val">{{ g.summary.build_cache_size }}</span>
</div>
</div>
{# ── Per-image table ──────────────────────────────────────────────────── #}
<div class="card-flush">
<table class="table">
<thead>
<tr><th>Image</th><th>Size</th><th>Shared</th><th>Containers</th></tr>
</thead>
<tbody>
{% for im in g.images %}
<tr>
<td style="font-size:0.84rem;font-family:ui-monospace,monospace;max-width:340px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
{{ im.repo_tag }}
{% if im.reclaimable %}<span title="not referenced by any container" style="font-size:0.68rem;color:var(--orange);border:1px solid var(--orange);border-radius:3px;padding:0 0.3rem;margin-left:0.4rem;">reclaimable</span>{% endif %}
</td>
<td style="font-family:ui-monospace,monospace;font-size:0.86rem;">{{ im.size }}</td>
<td style="font-family:ui-monospace,monospace;font-size:0.86rem;color:var(--text-muted);">{{ im.shared }}</td>
<td style="font-size:0.86rem;color:{% if im.containers %}var(--text){% else %}var(--text-muted){% endif %};">{{ im.containers }}</td>
</tr>
{% else %}
<tr><td colspan="4" style="color:var(--text-muted);font-size:0.85rem;text-align:center;padding:1.5rem;">No images reported.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
{% else %}
<div class="card" style="text-align:center;padding:3rem;">
<div style="color:var(--text-muted);font-size:0.9rem;">
No disk usage reported yet. The host agent reports image/disk usage from
<code>docker system df</code> on hosts running Docker.
</div>
</div>
{% endif %}
{% endblock %}
@@ -11,7 +11,7 @@
{% for c in containers %}
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.85rem;padding:0.2rem 0;">
<span class="dot {% if c.status == 'running' %}dot-up{% elif c.status == 'paused' %}dot-warn{% else %}dot-down{% endif %}"></span>
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="{{ c.image }}">{{ c.name }}</span>
<a href="/plugins/docker/container/{{ c.host_id }}/{{ c.name }}" style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:inherit;text-decoration:none;" title="{{ c.image }}">{{ c.name }}</a>
<span style="font-size:0.74rem;color:var(--text-muted);font-family:ui-monospace,monospace;flex-shrink:0;">
{% if c.cpu_pct is not none %}{{ "%.1f" | format(c.cpu_pct) }}%{% endif %}
{% if c.mem_pct is not none %} · {{ "%.1f" | format(c.mem_pct) }}%{% endif %}
+9 -1
View File
@@ -2,7 +2,15 @@
{% block title %}Docker — Steward{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
<h1 class="page-title" style="margin-bottom:0;">Docker</h1>
<div style="display:flex;align-items:baseline;gap:1rem;">
<h1 class="page-title" style="margin-bottom:0;">Docker</h1>
{% if has_swarm %}
<a href="/plugins/docker/swarm" style="font-size:0.85rem;color:var(--accent);text-decoration:none;">Swarm →</a>
{% endif %}
{% if has_disk %}
<a href="/plugins/docker/disk" style="font-size:0.85rem;color:var(--accent);text-decoration:none;">Disk →</a>
{% endif %}
</div>
{% include "_time_range.html" %}
</div>
+57 -3
View File
@@ -14,12 +14,61 @@
<div class="section-title" style="margin-bottom:0.35rem;">Total</div>
<span class="stat-val">{{ total }}</span>
</div>
{% if swarm_services %}
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;">Services</div>
<span class="stat-val">{{ swarm_services | length }}</span>
</div>
{% endif %}
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;">Hosts</div>
<span class="stat-val">{{ host_groups | length }}</span>
</div>
</div>
{# ── Swarm services: collapsed per-service, each replica labelled with its host.
Ghost replicas (tasks on nodes with no Steward agent) come from the managers'
placement data so the picture is cluster-complete. ──────────────────────── #}
{% if swarm_services %}
<div class="card-flush" style="margin-bottom:1.5rem;">
<div style="display:flex;align-items:baseline;gap:0.6rem;padding:0.5rem 0.75rem;border-bottom:1px solid var(--border);">
<span style="font-weight:600;font-size:0.95rem;">Swarm services</span>
<a href="/plugins/docker/swarm" style="font-size:0.78rem;color:var(--text-muted);">Topology →</a>
</div>
<div style="padding:0.5rem 0.75rem;display:grid;gap:0.85rem;">
{% for s in swarm_services %}
<div>
<div style="display:flex;align-items:center;gap:0.55rem;flex-wrap:wrap;margin-bottom:0.35rem;">
<span class="dot {% if s.state == 'healthy' %}dot-up{% elif s.state == 'degraded' %}dot-warn{% else %}dot-down{% endif %}"></span>
<span style="font-weight:600;font-size:0.9rem;">{{ s.name }}</span>
<span style="font-size:0.72rem;color:var(--text-dim);">{{ s.mode }}</span>
<span style="font-size:0.78rem;color:{% if s.state == 'healthy' %}var(--green){% elif s.state == 'degraded' %}var(--orange){% else %}var(--red){% endif %};font-variant-numeric:tabular-nums;">
{{ s.running }}/{{ s.desired }} running
</span>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:0.4rem;padding-left:1.1rem;">
{% for r in s.replicas %}
{% if r.ghost %}
<div style="display:flex;align-items:center;gap:0.4rem;font-size:0.8rem;background:var(--bg-elevated);border:1px dashed var(--border-mid);border-radius:5px;padding:0.3rem 0.55rem;" title="Running on a node with no Steward agent — detail unavailable">
<span class="dot dot-warn" style="opacity:0.6;"></span>
<span style="font-weight:500;">{{ r.host }}</span>
<span style="color:var(--text-dim);margin-left:auto;">{{ r.count }} · no agent</span>
</div>
{% else %}
<div style="display:flex;align-items:center;gap:0.4rem;font-size:0.8rem;background:var(--bg-elevated);border:1px solid var(--border);border-radius:5px;padding:0.3rem 0.55rem;">
<span class="dot {% if r.status == 'running' %}dot-up{% elif r.status == 'paused' %}dot-warn{% else %}dot-down{% endif %}"></span>
<a href="/plugins/docker/container/{{ r.host_id }}/{{ r.name }}" style="font-weight:500;color:inherit;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="{{ r.name }}">{{ r.host }}</a>
{% if r.cpu_pct is not none %}<span style="color:var(--text-muted);font-family:ui-monospace,monospace;margin-left:auto;">{{ "%.0f" | format(r.cpu_pct) }}%</span>{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# ── Container tables, one per host ───────────────────────────────────────── #}
{% if host_groups %}
{% for g in host_groups %}
@@ -47,7 +96,11 @@
</tr>
</thead>
<tbody>
{% for item in g.containers %}
{% for sub in g.subgroups %}
{% if g.grouped and sub.label %}
<tr><td colspan="7" style="padding:0.4rem 0.75rem 0.2rem;font-size:0.72rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;font-weight:600;">{{ sub.label }}</td></tr>
{% endif %}
{% for item in sub.containers %}
{% set c = item.container %}
<tr>
<td>
@@ -55,7 +108,7 @@
<span class="dot {% if c.status == 'running' %}dot-up{% elif c.status == 'paused' %}dot-warn{% else %}dot-down{% endif %}"></span>
<div>
<div style="font-weight:500;font-size:0.9rem;">
{{ c.name }}
<a href="/plugins/docker/container/{{ c.host_id }}/{{ c.name }}" style="color:inherit;text-decoration:none;border-bottom:1px dotted var(--border-mid);">{{ c.name }}</a>
{% if c.health == 'healthy' %}<span title="healthy" style="color:var(--green);font-size:0.7rem;"></span>
{% elif c.health == 'unhealthy' %}<span title="unhealthy" style="color:var(--red);font-size:0.7rem;"></span>
{% elif c.health == 'starting' %}<span title="health: starting" style="color:var(--orange);font-size:0.7rem;"></span>{% endif %}
@@ -105,11 +158,12 @@
<td>{{ item.sparkline_mem | safe }}</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
{% else %}
{% elif not swarm_services %}
<div class="card" style="text-align:center;padding:3rem;">
<div style="color:var(--text-muted);font-size:0.9rem;">
No containers reported yet. Containers are collected by the Steward host agent —
@@ -0,0 +1,87 @@
{# docker/swarm.html — Swarm topology: services, replica health, nodes, placement #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% block title %}Swarm — Docker — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), ("Swarm", "")]) }}{% endblock %}
{% block content %}
<h1 class="page-title" style="margin-bottom:1.5rem;">Swarm</h1>
{% if swarms %}
{% for g in swarms %}
<div style="margin-bottom:2rem;">
<div style="display:flex;align-items:baseline;gap:0.6rem;margin-bottom:0.75rem;flex-wrap:wrap;">
<span style="font-weight:600;font-size:0.95rem;">Swarm</span>
<span style="font-size:0.78rem;color:var(--text-muted);">
reported by {{ g.managers | length }} manager{{ 's' if g.managers | length != 1 }} · {{ g.managers | join(", ") }}
</span>
</div>
{# ── Services ─────────────────────────────────────────────────────────── #}
<div class="card-flush" style="margin-bottom:1rem;">
<table class="table">
<thead>
<tr>
<th>Service</th><th>Mode</th><th>Replicas</th><th>Image</th><th>Placement</th>
</tr>
</thead>
<tbody>
{% for s in g.services %}
<tr>
<td style="font-weight:500;font-size:0.9rem;">{{ s.name }}</td>
<td style="font-size:0.82rem;color:var(--text-muted);">{{ s.mode }}</td>
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">
<span style="color:{% if s.healthy %}var(--green){% elif s.down %}var(--red){% elif s.degraded %}var(--orange){% else %}var(--text-muted){% endif %};">
{{ s.running }}/{{ s.desired }}
</span>
</td>
<td style="font-size:0.8rem;color:var(--text-muted);max-width:220px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ s.image or "—" }}</td>
<td style="font-size:0.78rem;color:var(--text-muted);">
{% for p in s.placement %}{{ p.node }} ({{ p.running }}){% if not loop.last %}, {% endif %}{% else %}—{% endfor %}
</td>
</tr>
{% else %}
<tr><td colspan="5" style="color:var(--text-muted);font-size:0.85rem;text-align:center;padding:1.5rem;">No services in this swarm.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{# ── Nodes ────────────────────────────────────────────────────────────── #}
<div class="card-flush">
<table class="table">
<thead>
<tr><th>Node</th><th>Role</th><th>Availability</th><th>Status</th></tr>
</thead>
<tbody>
{% for n in g.nodes %}
<tr>
<td style="font-weight:500;font-size:0.9rem;">
{{ n.hostname or n.node_id }}
{% if n.leader %}<span title="cluster leader" style="font-size:0.68rem;color:var(--accent);border:1px solid var(--accent);border-radius:3px;padding:0 0.3rem;margin-left:0.3rem;">leader</span>{% endif %}
</td>
<td style="font-size:0.82rem;color:var(--text-muted);">{{ n.role }}</td>
<td style="font-size:0.82rem;color:{% if n.availability == 'active' %}var(--text){% else %}var(--orange){% endif %};">{{ n.availability }}</td>
<td style="font-size:0.82rem;">
<span class="dot {% if n.status == 'ready' %}dot-up{% else %}dot-down{% endif %}"></span>
<span style="color:{% if n.status == 'ready' %}var(--green){% else %}var(--red){% endif %};">{{ n.status }}</span>
</td>
</tr>
{% else %}
<tr><td colspan="4" style="color:var(--text-muted);font-size:0.85rem;text-align:center;padding:1.5rem;">No nodes reported.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
{% else %}
<div class="card" style="text-align:center;padding:3rem;">
<div style="color:var(--text-muted);font-size:0.9rem;">
No Swarm topology reported. A Steward host agent running on a Swarm
<strong>manager</strong> reports services, nodes, and task placement here —
workers and non-swarm hosts contribute only their local containers.
</div>
</div>
{% endif %}
{% endblock %}
+37 -10
View File
@@ -1,21 +1,45 @@
{# docker/widget.html — dashboard widget: container status overview, by host #}
{% if running_count == 0 and stopped_count == 0 %}
{% if total_count == 0 %}
<p class="empty" style="padding:0.5rem 0;font-size:0.85rem;">No containers reported yet.</p>
{% else %}
<div style="display:flex;gap:1rem;margin-bottom:0.65rem;">
<div style="display:flex;gap:1.25rem;margin-bottom:0.65rem;">
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--green);">{{ running_count }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">running</span>
</div>
{% if stopped_count %}
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--text-muted);">{{ stopped_count }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">stopped</span>
<div title="Distinct containers with a die or OOM event in the last 24h">
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:{% if failed_24h %}var(--red){% else %}var(--text-muted){% endif %};">{{ failed_24h }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">failed (24h)</span>
</div>
{% endif %}
</div>
{# Swarm services — collapsed, each with its replicas' host chips (dashed = a
node with no Steward agent, count-only from placement). #}
{% if swarm_services %}
<div style="font-size:0.72rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;margin:0.5rem 0 0.2rem;">Swarm services</div>
<div style="display:grid;gap:0.4rem;margin-bottom:0.4rem;">
{% for s in swarm_services %}
<div>
<div style="display:flex;align-items:center;gap:0.4rem;font-size:0.82rem;">
<span class="dot {% if s.state == 'healthy' %}dot-up{% elif s.state == 'degraded' %}dot-warn{% else %}dot-down{% endif %}"></span>
<span style="font-weight:500;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ s.name }}</span>
<span style="color:var(--text-muted);font-family:ui-monospace,monospace;font-size:0.74rem;margin-left:auto;flex-shrink:0;">{{ s.running }}/{{ s.desired }}</span>
</div>
<div style="display:flex;flex-wrap:wrap;gap:0.25rem;margin:0.2rem 0 0 1rem;">
{% for r in s.replicas %}
{% if r.ghost %}
<span title="no Steward agent on this node — count from swarm placement" style="font-size:0.68rem;color:var(--text-dim);background:var(--bg-elevated);border:1px dashed var(--border-mid);border-radius:4px;padding:0.05rem 0.35rem;">{{ r.host }} ×{{ r.count }}</span>
{% else %}
<span style="font-size:0.68rem;color:var(--text-muted);background:var(--bg-elevated);border:1px solid var(--border);border-radius:4px;padding:0.05rem 0.35rem;">{{ r.host }}</span>
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% for g in host_groups %}
{% if multi_host %}
<div style="font-size:0.72rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;margin:0.5rem 0 0.2rem;">{{ g.host_name }}</div>
@@ -24,9 +48,12 @@
{% for c in g.containers %}
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;padding:0.15rem 0;">
<span class="dot {% if c.status == 'running' %}dot-up{% elif c.status == 'paused' %}dot-warn{% else %}dot-down{% endif %}"></span>
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
{{ c.name }}
</span>
<a href="/plugins/docker/container/{{ c.host_id }}/{{ c.name }}" style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:inherit;text-decoration:none;">
{{ c.name }}{% if c.health == 'unhealthy' %}<span title="unhealthy" style="color:var(--red);"></span>{% elif c.health == 'healthy' %}<span title="healthy" style="color:var(--green);"></span>{% endif %}{% if c.restart_count %}<span title="restarts" style="color:var(--orange);font-size:0.7rem;"> ⟳{{ c.restart_count }}</span>{% endif %}
</a>
{% if c.status != 'running' and c.exit_code is not none and c.exit_code != 0 %}
<span title="last exit code" style="font-size:0.7rem;color:var(--red);flex-shrink:0;">exit {{ c.exit_code }}</span>
{% endif %}
{% if c.cpu_pct is not none %}
<span style="font-size:0.72rem;color:var(--text-muted);font-family:ui-monospace,monospace;flex-shrink:0;">
{{ "%.1f" | format(c.cpu_pct) }}%
@@ -1,36 +1,28 @@
{# docker/widget_resources.html — dashboard widget: CPU + memory bars, busiest containers #}
{# docker/widget_resources.html — dashboard widget: the busiest running containers
by CPU, each with labeled CPU + memory utilisation bars. #}
{% if not rows %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">No running containers.</div>
{% else %}
<div style="display:grid;gap:0.5rem;">
{% macro bar(label, pct, warn, crit) %}
<div style="display:flex;align-items:center;gap:0.45rem;margin-bottom:2px;">
<span style="font-size:0.62rem;color:var(--text-dim);width:1.9rem;flex-shrink:0;letter-spacing:0.03em;">{{ label }}</span>
<div style="flex:1;height:6px;background:var(--bg-elevated);border-radius:3px;overflow:hidden;">
<div style="height:100%;border-radius:3px;width:{{ [pct, 100] | min }}%;
background:{% if pct > crit %}var(--red){% elif pct > warn %}var(--orange){% else %}var(--accent){% endif %};
transition:width 0.4s;"></div>
</div>
<span style="font-size:0.7rem;color:var(--text-muted);font-family:ui-monospace,monospace;width:3rem;text-align:right;flex-shrink:0;">{{ "%.1f" | format(pct) }}%</span>
</div>
{% endmacro %}
<div style="display:grid;gap:0.6rem;">
{% for r in rows %}
{% set c = r.c %}
<div>
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:0.15rem;">
<span style="font-size:0.8rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:55%;">
{{ c.name }}{% if multi_host %}<span style="color:var(--text-dim);font-weight:normal;"> · {{ r.host_name }}</span>{% endif %}
</span>
<span style="font-size:0.72rem;color:var(--text-muted);font-family:ui-monospace,monospace;flex-shrink:0;margin-left:0.5rem;">
{% if c.cpu_pct is not none %}CPU {{ "%.1f" | format(c.cpu_pct) }}%{% endif %}
{% if c.mem_pct is not none %} · Mem {{ "%.1f" | format(c.mem_pct) }}%{% endif %}
</span>
<div style="font-size:0.8rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:0.25rem;">
{{ c.name }}{% if multi_host %}<span style="color:var(--text-dim);font-weight:normal;"> · {{ r.host_name }}</span>{% endif %}
</div>
{% if c.cpu_pct is not none %}
<div style="height:3px;background:var(--bg-elevated);border-radius:2px;margin-bottom:2px;">
<div style="height:100%;border-radius:2px;width:{{ [c.cpu_pct, 100] | min }}%;
background:{% if c.cpu_pct > 80 %}var(--red){% elif c.cpu_pct > 50 %}var(--orange){% else %}var(--accent){% endif %};
transition:width 0.4s;">
</div>
</div>
{% endif %}
{% if c.mem_pct is not none %}
<div style="height:3px;background:var(--bg-elevated);border-radius:2px;">
<div style="height:100%;border-radius:2px;width:{{ [c.mem_pct, 100] | min }}%;
background:{% if c.mem_pct > 90 %}var(--red){% elif c.mem_pct > 70 %}var(--orange){% else %}var(--green){% endif %};
transition:width 0.4s;">
</div>
</div>
{% endif %}
{% if c.cpu_pct is not none %}{{ bar("CPU", c.cpu_pct, 50, 80) }}{% endif %}
{% if c.mem_pct is not none %}{{ bar("MEM", c.mem_pct, 70, 90) }}{% endif %}
</div>
{% endfor %}
</div>
+112
View File
@@ -0,0 +1,112 @@
"""Host-metric read helpers (latest snapshot + bucketed history).
Kept separate from routes.py so it imports only the core PluginMetric model — not
the host_agent ORM models — which lets integration tests import these helpers
without tripping the plugin-loader's double-registration ("Table already
defined") guard.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, or_, select
from steward.core.time_range import bucket_seconds
from steward.models.metrics import PluginMetric, PluginMetricHourly
SOURCE_MODULE = "host_agent"
# Host-level metrics charted on the detail page (sub-resources are shown as
# current-value lists, not time series, to keep the page readable).
HISTORY_METRICS = (
"cpu_pct", "mem_used_pct", "disk_used_pct_worst", "load_1m",
"net_rx_bps", "net_tx_bps", "disk_read_bps", "disk_write_bps",
"temp_c_max", "psi_mem_some_avg10",
)
async def _latest_metrics_for_host(session, host_name: str) -> dict[str, dict[str, float]]:
"""{resource_name: {metric: value}} — latest sample per (resource, metric) for
a host + its sub-resources.
DISTINCT ON picks the newest row per group in one index-ordered pass over
ix_plugin_metrics_module_resource_metric_recorded, instead of a GROUP-BY-max
subquery self-joined back to the table (two passes over the whole history).
"""
rows = (await session.execute(
select(PluginMetric)
.where(
PluginMetric.source_module == SOURCE_MODULE,
or_(
PluginMetric.resource_name == host_name,
PluginMetric.resource_name.like(host_name + ":%"),
),
)
.distinct(PluginMetric.resource_name, PluginMetric.metric_name)
.order_by(
PluginMetric.resource_name,
PluginMetric.metric_name,
PluginMetric.recorded_at.desc(),
)
)).scalars().all()
out: dict[str, dict[str, float]] = {}
for r in rows:
out.setdefault(r.resource_name, {})[r.metric_name] = r.value
return out
async def _history_for_host(session, host_name: str, since, *, raw_days: int = 7) -> dict[str, list]:
"""{metric: [[epoch_ms, avg_value], …]} host-level series since `since`.
Retention rolls raw plugin_metrics older than `raw_days` into hourly averages
(plugin_metrics_hourly) and deletes the raw rows. So we read the rollup for the
part of the range older than that boundary and raw (bucket-averaged in SQL,
capped to ≤1h to match the rollup) for the recent part — never shipping raw
samples to Python. The two windows don't overlap, so appending hourly-then-raw
keeps each metric's series time-ordered. Epoch-ms x feeds a linear chart axis.
"""
now = datetime.now(timezone.utc)
raw_cutoff = (now - timedelta(days=raw_days)).replace(minute=0, second=0, microsecond=0)
series: dict[str, list] = {m: [] for m in HISTORY_METRICS}
# ── Older than the raw window: the hourly rollup ──
if since < raw_cutoff:
hourly = (await session.execute(
select(PluginMetricHourly.metric_name, PluginMetricHourly.bucket,
PluginMetricHourly.value_avg)
.where(
PluginMetricHourly.source_module == SOURCE_MODULE,
PluginMetricHourly.resource_name == host_name,
PluginMetricHourly.metric_name.in_(HISTORY_METRICS),
PluginMetricHourly.bucket >= since,
PluginMetricHourly.bucket < raw_cutoff,
)
.order_by(PluginMetricHourly.bucket)
)).all()
for metric_name, bucket, avg in hourly:
series[metric_name].append([int(bucket.timestamp() * 1000), round(float(avg), 2)])
# ── Recent part: raw, bucket-averaged in SQL ──
raw_since = since if since >= raw_cutoff else raw_cutoff
width_s = bucket_seconds(raw_since, 120)
if since < raw_cutoff:
width_s = min(width_s, 3600) # ≤ 1h so it matches the hourly portion
rbucket = func.date_bin(
func.make_interval(0, 0, 0, 0, 0, 0, width_s),
PluginMetric.recorded_at,
func.to_timestamp(0), # epoch origin
).label("bucket")
raw = (await session.execute(
select(PluginMetric.metric_name, rbucket, func.avg(PluginMetric.value))
.where(
PluginMetric.source_module == SOURCE_MODULE,
PluginMetric.resource_name == host_name,
PluginMetric.metric_name.in_(HISTORY_METRICS),
PluginMetric.recorded_at >= raw_since,
)
.group_by(PluginMetric.metric_name, rbucket)
.order_by(rbucket)
)).all()
for metric_name, b, avg in raw:
series[metric_name].append([int(b.timestamp() * 1000), round(float(avg), 2)])
return series
+111 -95
View File
@@ -14,16 +14,21 @@ from steward.models.users import UserRole
from sqlalchemy import select, func, or_, and_
from datetime import timedelta
from steward.core.settings import public_base_url
from steward.core.settings import get_setting, public_base_url
from steward.core.time_range import parse_range, RANGE_OPTIONS
from steward.models.hosts import Host
from steward.models.metrics import PluginMetric
from .models import HostAgentRegistration
# Query helpers live in a model-free module so integration tests can import them
# without the plugin-loader double-registration guard tripping (see metrics_query).
from .metrics_query import (
SOURCE_MODULE,
_history_for_host,
_latest_metrics_for_host,
)
host_agent_bp = Blueprint("host_agent", __name__, template_folder="templates")
SOURCE_MODULE = "host_agent"
def _hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
@@ -540,89 +545,25 @@ def _downsample(series: list[list], target: int = 120) -> list[list]:
return out
# Host-level metrics charted on the detail page (sub-resources are shown as
# current-value lists, not time series, to keep the page readable).
HISTORY_METRICS = (
"cpu_pct", "mem_used_pct", "disk_used_pct_worst", "load_1m",
"net_rx_bps", "net_tx_bps", "disk_read_bps", "disk_write_bps",
"temp_c_max", "psi_mem_some_avg10",
)
# Full-metrics refresh cadences. The agent pushes every ~30s by default, so
# polling the current snapshot at 15s keeps it ≤ one cadence stale; the history
# charts move slowly over a multi-hour/day window, so they refresh less often.
_DETAIL_POLL_SECONDS = 15
_CHART_POLL_SECONDS = 60
async def _latest_metrics_for_host(session, host_name: str) -> dict[str, dict[str, float]]:
"""{resource_name: {metric: value}} of the latest sample for a host + sub-resources."""
subq = (
select(
PluginMetric.resource_name,
PluginMetric.metric_name,
func.max(PluginMetric.recorded_at).label("max_ts"),
)
.where(PluginMetric.source_module == SOURCE_MODULE)
.where(or_(
PluginMetric.resource_name == host_name,
PluginMetric.resource_name.like(host_name + ":%"),
))
.group_by(PluginMetric.resource_name, PluginMetric.metric_name)
).subquery()
rows = (await session.execute(
select(PluginMetric).join(
subq,
(PluginMetric.resource_name == subq.c.resource_name) &
(PluginMetric.metric_name == subq.c.metric_name) &
(PluginMetric.recorded_at == subq.c.max_ts),
).where(PluginMetric.source_module == SOURCE_MODULE)
)).scalars().all()
out: dict[str, dict[str, float]] = {}
for r in rows:
out.setdefault(r.resource_name, {})[r.metric_name] = r.value
return out
async def _history_for_host(session, host_name: str, since) -> dict[str, list]:
"""{metric: [[epoch_ms, value], …]} host-level series since `since`.
Epoch-ms x values let the charts use a linear axis (no Chart.js date adapter).
"""
rows = (await session.execute(
select(PluginMetric).where(
PluginMetric.source_module == SOURCE_MODULE,
PluginMetric.resource_name == host_name,
PluginMetric.metric_name.in_(HISTORY_METRICS),
PluginMetric.recorded_at >= since,
).order_by(PluginMetric.recorded_at)
)).scalars().all()
series: dict[str, list] = {m: [] for m in HISTORY_METRICS}
for p in rows:
series[p.metric_name].append([int(p.recorded_at.timestamp() * 1000), round(p.value, 2)])
# Downsample to a readable point count (see _downsample) — raw agent cadence
# is too dense to read over a multi-hour window.
return {m: _downsample(v) for m, v in series.items()}
@host_agent_bp.get("/<host_id>/")
@require_role(UserRole.viewer)
async def host_detail(host_id: str):
"""Netdata-style per-host detail: current gauges + history charts."""
since, range_key = parse_range(request.args.get("range"))
async with current_app.db_sessionmaker() as session:
host = (await session.execute(
select(Host).where(Host.id == host_id))).scalar_one_or_none()
if host is None:
return _error(404, "not_found")
reg = (await session.execute(select(HostAgentRegistration).where(
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
latest = await _latest_metrics_for_host(session, host.name)
series = await _history_for_host(session, host.name, since)
hostlvl = latest.get(host.name, {})
def _split_host_metrics(host_name: str, latest: dict) -> tuple:
"""Split a host's latest snapshot into
(hostlvl, cores, nets, disks_io, temps, mounts) for the current-state fragment."""
hostlvl = latest.get(host_name, {})
cores: list = []
nets: dict = {}
disks_io: dict = {}
temps: dict = {}
mounts: dict = {}
plen = len(host.name) + 1
plen = len(host_name) + 1
for res, metrics in latest.items():
if res == host.name:
if res == host_name:
continue
suffix = res[plen:]
if suffix.startswith("core"):
@@ -640,35 +581,80 @@ async def host_detail(host_id: str):
else:
mounts[suffix] = metrics
cores.sort(key=lambda c: c[0])
return hostlvl, cores, nets, disks_io, temps, mounts
ls = reg.last_seen_at if reg else None
stale = ls is None or (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds()
@host_agent_bp.get("/<host_id>/")
@require_role(UserRole.viewer)
async def host_detail(host_id: str):
"""Full-metrics shell: header + range toggle only. The current-state and
history sections stream in via HTMX (host_detail_metrics / host_detail_charts)
so the heavy history query never blocks first paint and the data live-updates."""
_, range_key = parse_range(request.args.get("range"))
async with current_app.db_sessionmaker() as session:
host = (await session.execute(
select(Host).where(Host.id == host_id))).scalar_one_or_none()
if host is None:
return _error(404, "not_found")
return await render_template(
"host_detail.html",
host=host, current_range=range_key, range_options=RANGE_OPTIONS,
poll_seconds=_DETAIL_POLL_SECONDS, chart_poll_seconds=_CHART_POLL_SECONDS,
)
@host_agent_bp.get("/<host_id>/metrics")
@require_role(UserRole.viewer)
async def host_detail_metrics(host_id: str):
"""Current-state fragment (gauges/cores/filesystems/IO/temps) — polled live so
the page shows the latest snapshot the server holds without a full reload."""
async with current_app.db_sessionmaker() as session:
host = (await session.execute(
select(Host).where(Host.id == host_id))).scalar_one_or_none()
if host is None:
return "", 404
reg = (await session.execute(select(HostAgentRegistration).where(
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
latest = await _latest_metrics_for_host(session, host.name)
hostlvl, cores, nets, disks_io, temps, mounts = _split_host_metrics(host.name, latest)
ls = reg.last_seen_at if reg else None
stale = ls is None or (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds()
return await render_template(
"_host_metrics.html",
host=host, reg=reg, stale=stale, hostlvl=hostlvl,
cores=cores, nets=nets, disks_io=disks_io, temps=temps, mounts=mounts,
series=series, range_key=range_key, range_options=RANGE_OPTIONS,
)
@host_agent_bp.get("/<host_id>/charts")
@require_role(UserRole.viewer)
async def host_detail_charts(host_id: str):
"""History-charts fragment — lazy-loaded so the date_bin query never blocks
the page shell; refreshed on a slow cadence and on range change."""
since, range_key = parse_range(request.args.get("range"))
async with current_app.db_sessionmaker() as session:
host = (await session.execute(
select(Host).where(Host.id == host_id))).scalar_one_or_none()
if host is None:
return "", 404
raw_days = int(await get_setting(session, "metrics.retention.raw_days") or 7)
series = await _history_for_host(session, host.name, since, raw_days=raw_days)
return await render_template("_host_charts.html", series=series, range_key=range_key)
def _new_token_pair() -> tuple[str, str]:
raw = secrets.token_urlsafe(32)
return raw, _hash_token(raw)
@host_agent_bp.get("/panel/<host_id>")
@host_agent_bp.get("/vitals/<host_id>")
@require_role(UserRole.viewer)
async def host_panel(host_id: str):
"""Per-host agent panel embedded into the core Hosts hub via HTMX.
async def host_vitals(host_id: str):
"""Compact live vitals strip (CPU/MEM/DISK/LOAD + pressure) for the Hosts hub.
Shows live agent metrics if the host reports, otherwise the provisioning
actions — tied to the host's linked Ansible target (the SSH connection).
Self-contained fragment (no base.html) so it can be hx-swapped in.
Self-contained fragment, polled by hosts/detail.html. Renders nothing until
the agent reports — the Agent panel below then carries provisioning.
"""
from steward.core.capabilities import has_capability
from steward.models.ansible_inventory import AnsibleTarget
async with current_app.db_sessionmaker() as db:
host = (await db.execute(
select(Host).where(Host.id == host_id))).scalar_one_or_none()
@@ -677,8 +663,6 @@ async def host_panel(host_id: str):
reg = (await db.execute(select(HostAgentRegistration).where(
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
latest = await _latest_metrics_for_host(db, host.name) if reg else {}
target = (await db.execute(select(AnsibleTarget).where(
AnsibleTarget.host_id == host_id))).scalar_one_or_none()
# Recent series for the at-a-glance sparklines (cpu/mem/load host-level,
# disk from the root mount sub-resource).
@@ -721,6 +705,40 @@ async def host_panel(host_id: str):
"mem": hostlvl.get("psi_mem_some_avg10"),
"io": hostlvl.get("psi_io_some_avg10"),
}
ls = reg.last_seen_at if reg else None
reporting = bool(reg) and ls is not None
stale = reporting and (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds()
return await render_template(
"_host_vitals.html",
reg=reg, cpu=hostlvl.get("cpu_pct"), mem=hostlvl.get("mem_used_pct"),
disk_root=disk_root, load_per_core=load_per_core, sparks=sparks, psi=psi,
reporting=reporting, stale=stale,
)
@host_agent_bp.get("/panel/<host_id>")
@require_role(UserRole.viewer)
async def host_panel(host_id: str):
"""Per-host agent management panel embedded into the Hosts hub via HTMX.
Lifecycle actions (update / rotate / remove / re-provision) when the host
reports, otherwise provisioning — tied to the host's linked Ansible target.
The live vitals are a separate strip (host_vitals); this panel is management
only. Self-contained fragment (no base.html) so it can be hx-swapped in.
"""
from steward.core.capabilities import has_capability
from steward.models.ansible_inventory import AnsibleTarget
async with current_app.db_sessionmaker() as db:
host = (await db.execute(
select(Host).where(Host.id == host_id))).scalar_one_or_none()
if host is None:
return "", 404
reg = (await db.execute(select(HostAgentRegistration).where(
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
target = (await db.execute(select(AnsibleTarget).where(
AnsibleTarget.host_id == host_id))).scalar_one_or_none()
ls = reg.last_seen_at if reg else None
# "Deployed" is gated on the agent actually checking in (last_seen_at), NOT on
# the registration row — that row is minted before the playbook runs, so a
@@ -731,9 +749,7 @@ async def host_panel(host_id: str):
ansible_cfg = current_app.config.get("ANSIBLE", {})
return await render_template(
"panel.html",
host=host, reg=reg, hostlvl=hostlvl, disk_root=disk_root,
sparks=sparks, cores=cores, load1=load1, load_per_core=load_per_core, psi=psi,
reporting=reporting, stale=stale, target=target,
host=host, reg=reg, reporting=reporting, stale=stale, target=target,
ansible_available=has_capability("ansible.run_playbook"),
managed_key_set=bool((ansible_cfg.get("ssh_public_key") or "").strip()),
)
@@ -0,0 +1,6 @@
{# History data-only fragment. Swapped into the hidden #hm-charts-data poller;
the script runs and feeds the persistent charts created by host_detail.html,
updating them in place (no canvas swap → no flicker / no re-animation). #}
<script>
if (window.applyHostSeries) window.applyHostSeries({{ series|tojson }}, {{ range_key|tojson }});
</script>
@@ -0,0 +1,182 @@
{# Current-state fragment for the full-metrics page — polled live via HTMX.
Self-contained (defines its own format macros) so each poll re-renders the
latest snapshot the server has. #}
{% macro fmt_bps(v) %}
{%- if v is none -%}—
{%- elif v >= 1048576 -%}{{ "%.1f"|format(v / 1048576) }} MB/s
{%- elif v >= 1024 -%}{{ "%.1f"|format(v / 1024) }} KB/s
{%- else -%}{{ "%.0f"|format(v) }} B/s
{%- endif -%}
{% endmacro %}
{% macro fmt_bytes(v) %}
{%- if v is none -%}—
{%- elif v >= 1125899906842624 -%}{{ "%.1f"|format(v / 1125899906842624) }} PB
{%- elif v >= 1099511627776 -%}{{ "%.1f"|format(v / 1099511627776) }} TB
{%- elif v >= 1073741824 -%}{{ "%.1f"|format(v / 1073741824) }} GB
{%- elif v >= 1048576 -%}{{ "%.0f"|format(v / 1048576) }} MB
{%- else -%}{{ "%.0f"|format(v / 1024) }} KB
{%- endif -%}
{% endmacro %}
{% macro bar(pct) %}
{%- set p = pct if pct is not none else 0 -%}
<div style="height:8px;background:var(--bg-elevated);border-radius:4px;overflow:hidden;border:1px solid var(--border);">
<div style="height:100%;width:{{ p|round(1) }}%;background:{% if pct is none %}var(--text-dim){% elif pct < 70 %}var(--green){% elif pct < 90 %}var(--yellow){% else %}var(--red){% endif %};"></div>
</div>
{% endmacro %}
{# ── Identity / metadata ─────────────────────────────────────────────────── #}
<div class="card" style="display:flex;flex-wrap:wrap;gap:0.5rem 1.25rem;align-items:center;font-size:0.82rem;color:var(--text-muted);">
<span>{% if stale %}<span class="badge badge-red">stale</span>{% else %}<span class="badge badge-green">live</span>{% endif %}</span>
<span><span style="color:var(--text-dim);">Address</span> {{ host.address or "—" }}</span>
{% if reg %}
{% if reg.distro %}<span><span style="color:var(--text-dim);">OS</span> {{ reg.distro }}</span>{% endif %}
{% if reg.kernel %}<span><span style="color:var(--text-dim);">Kernel</span> {{ reg.kernel }}</span>{% endif %}
{% if reg.arch %}<span><span style="color:var(--text-dim);">Arch</span> {{ reg.arch }}</span>{% endif %}
{% if reg.agent_version %}<span><span style="color:var(--text-dim);">Agent</span> v{{ reg.agent_version }}</span>{% endif %}
{% set up = hostlvl.get('uptime_secs') %}
{% if up %}<span><span style="color:var(--text-dim);">Uptime</span> {{ (up // 86400)|int }}d {{ ((up % 86400) // 3600)|int }}h</span>{% endif %}
<span><span style="color:var(--text-dim);">Last seen</span> {{ reg.last_seen_at.strftime("%Y-%m-%d %H:%M:%S") ~ " UTC" if reg.last_seen_at else "never" }}</span>
{% else %}
<span>No agent registration found for this host.</span>
{% endif %}
</div>
{% if not hostlvl %}
<div class="card empty">No metrics reported yet. Once the agent checks in, CPU, memory, network, disk I/O, temperatures, and pressure will appear here.</div>
{% else %}
{# ── Current headline gauges ─────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.75rem;margin-bottom:1rem;">
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;">CPU</div>
{% set c = hostlvl.get('cpu_pct') %}
<span class="stat-val" style="font-size:1.6rem;color:{% if c is none %}var(--text-dim){% elif c < 70 %}var(--green){% elif c < 90 %}var(--yellow){% else %}var(--red){% endif %};">{% if c is not none %}{{ "%.0f"|format(c) }}%{% else %}—{% endif %}</span>
<div style="margin-top:0.5rem;">{{ bar(c) }}</div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;">Memory</div>
{% set mp = hostlvl.get('mem_used_pct') %}
<span class="stat-val" style="font-size:1.6rem;color:{% if mp is none %}var(--text-dim){% elif mp < 70 %}var(--green){% elif mp < 90 %}var(--yellow){% else %}var(--red){% endif %};">{% if mp is not none %}{{ "%.0f"|format(mp) }}%{% else %}—{% endif %}</span>
<div style="margin-top:0.5rem;">{{ bar(mp) }}</div>
<div style="margin-top:0.45rem;font-size:0.72rem;color:var(--text-dim);">
avail {{ fmt_bytes(hostlvl.get('mem_available_bytes')) }} · cache {{ fmt_bytes(hostlvl.get('mem_cached_bytes')) }} · swap {{ fmt_bytes(hostlvl.get('swap_used_bytes')) }}
</div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;">Load</div>
<span class="stat-val" style="font-size:1.6rem;">{% if hostlvl.get('load_1m') is not none %}{{ "%.2f"|format(hostlvl.get('load_1m')) }}{% else %}—{% endif %}</span>
<div style="margin-top:0.45rem;font-size:0.72rem;color:var(--text-dim);">
5m {% if hostlvl.get('load_5m') is not none %}{{ "%.2f"|format(hostlvl.get('load_5m')) }}{% else %}—{% endif %} ·
15m {% if hostlvl.get('load_15m') is not none %}{{ "%.2f"|format(hostlvl.get('load_15m')) }}{% else %}—{% endif %}
</div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;">Network</div>
<div style="font-size:0.95rem;font-variant-numeric:tabular-nums;">
<div><span style="color:var(--green);"></span> {{ fmt_bps(hostlvl.get('net_rx_bps')) }}</div>
<div><span style="color:var(--red);"></span> {{ fmt_bps(hostlvl.get('net_tx_bps')) }}</div>
</div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;">Disk I/O</div>
<div style="font-size:0.95rem;font-variant-numeric:tabular-nums;">
<div><span style="color:var(--text-dim);">rd</span> {{ fmt_bps(hostlvl.get('disk_read_bps')) }}</div>
<div><span style="color:var(--text-dim);">wr</span> {{ fmt_bps(hostlvl.get('disk_write_bps')) }}</div>
</div>
</div>
{% if hostlvl.get('temp_c_max') is not none %}
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;">Temp (max)</div>
{% set t = hostlvl.get('temp_c_max') %}
<span class="stat-val" style="font-size:1.6rem;color:{% if t < 70 %}var(--green){% elif t < 85 %}var(--yellow){% else %}var(--red){% endif %};">{{ "%.0f"|format(t) }}°C</span>
</div>
{% endif %}
{% if hostlvl.get('psi_mem_some_avg10') is not none %}
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;" title="Pressure stall — % of time stalled, 10s avg">Pressure (10s)</div>
<div style="font-size:0.78rem;font-variant-numeric:tabular-nums;color:var(--text-muted);">
<div>mem {{ "%.1f"|format(hostlvl.get('psi_mem_some_avg10')) }}%</div>
{% if hostlvl.get('psi_cpu_some_avg10') is not none %}<div>cpu {{ "%.1f"|format(hostlvl.get('psi_cpu_some_avg10')) }}%</div>{% endif %}
{% if hostlvl.get('psi_io_some_avg10') is not none %}<div>io {{ "%.1f"|format(hostlvl.get('psi_io_some_avg10')) }}%</div>{% endif %}
</div>
</div>
{% endif %}
</div>
{# ── Per-core CPU ─────────────────────────────────────────────────────────── #}
{% if cores %}
<div class="card">
<div class="section-title" style="margin-bottom:0.6rem;">Per-core CPU</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:0.5rem 0.9rem;">
{% for idx, pct in cores %}
<div>
<div style="display:flex;justify-content:space-between;font-size:0.72rem;color:var(--text-dim);margin-bottom:0.2rem;">
<span>core {{ idx }}</span><span>{% if pct is not none %}{{ "%.0f"|format(pct) }}%{% else %}—{% endif %}</span>
</div>
{{ bar(pct) }}
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# ── Filesystems ──────────────────────────────────────────────────────────── #}
{% if mounts %}
<div class="card">
<div class="section-title" style="margin-bottom:0.6rem;">Filesystems</div>
{% for mount, m in mounts.items() %}
<div style="margin-bottom:0.6rem;">
<div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:0.2rem;">
<span style="font-family:ui-monospace,monospace;">{{ mount }}</span>
<span style="color:var(--text-muted);">{{ fmt_bytes(m.get('disk_used_bytes')) }} / {{ fmt_bytes(m.get('disk_total_bytes')) }}{% if m.get('disk_used_pct') is not none %} · {{ "%.0f"|format(m.get('disk_used_pct')) }}%{% endif %}</span>
</div>
{{ bar(m.get('disk_used_pct')) }}
</div>
{% endfor %}
</div>
{% endif %}
{# ── Interfaces / disks (short panels, side by side at natural height) ─────── #}
{% if nets or disks_io %}
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:1rem;align-items:start;">
{% if nets %}
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Interfaces</div>
{% for iface, m in nets.items() %}
<div style="display:flex;justify-content:space-between;gap:0.75rem;font-size:0.8rem;padding:0.15rem 0;">
<span style="font-family:ui-monospace,monospace;">{{ iface }}</span>
<span style="color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap;">↓ {{ fmt_bps(m.get('net_rx_bps')) }} · ↑ {{ fmt_bps(m.get('net_tx_bps')) }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if disks_io %}
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Disks</div>
{% for dev, m in disks_io.items() %}
<div style="display:flex;justify-content:space-between;gap:0.75rem;font-size:0.8rem;padding:0.15rem 0;">
<span style="font-family:ui-monospace,monospace;">{{ dev }}</span>
<span style="color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap;">rd {{ fmt_bps(m.get('disk_read_bps')) }} · wr {{ fmt_bps(m.get('disk_write_bps')) }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{# ── Temperatures (full width; cores flow into a compact multi-column grid) ── #}
{% if temps %}
<div class="card" style="margin-top:1rem;margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Temperatures</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:0.1rem 1.25rem;">
{% for label, c in temps.items() %}
<div style="display:flex;justify-content:space-between;gap:0.5rem;font-size:0.8rem;padding:0.15rem 0;">
<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ label }}</span>
<span style="color:{% if c is none %}var(--text-dim){% elif c < 70 %}var(--green){% elif c < 85 %}var(--yellow){% else %}var(--red){% endif %};font-variant-numeric:tabular-nums;">{% if c is not none %}{{ "%.0f"|format(c) }}°C{% else %}—{% endif %}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endif %}
@@ -0,0 +1,35 @@
{# Host vitals strip — compact CPU/MEM/DISK/LOAD + pressure, live-polled into the
host hub. Renders nothing until the agent reports (the Agent panel below then
shows provisioning). #}
{% if reporting %}
{% set lbl = "font-size:0.68rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;" %}
{% macro vital(label, value, text, kind, spark) %}
<div style="min-width:88px;">
<div style="{{ lbl }}">{{ label }}</div>
<div style="font-size:1.5rem;font-weight:700;line-height:1.1;{{ threshold_style(value, kind) }}">{{ text }}</div>
<div style="margin-top:0.2rem;">{{ spark | safe }}</div>
</div>
{% endmacro %}
<div class="card" style="display:flex;flex-wrap:wrap;align-items:flex-start;gap:1rem 2rem;margin-bottom:1rem;">
{{ vital("CPU", cpu, ('%.0f%%'|format(cpu) if cpu is not none else '—'), 'cpu', sparks.cpu) }}
{{ vital("Memory", mem, ('%.0f%%'|format(mem) if mem is not none else '—'), 'mem', sparks.mem) }}
{{ vital("Disk /", disk_root, ('%.0f%%'|format(disk_root) if disk_root is not none else '—'), 'disk', sparks.disk) }}
{{ vital("Load /core", load_per_core, ('%d%%'|format(load_per_core) if load_per_core is not none else '—'), 'load', sparks.load) }}
<div style="margin-left:auto;display:flex;flex-direction:column;align-items:flex-end;gap:0.3rem;font-size:0.75rem;color:var(--text-muted);text-align:right;">
<span>
{% if stale %}<span class="badge badge-red">stale</span>{% else %}<span class="badge badge-green">live</span>{% endif %}
{% if reg and reg.agent_version %}<span style="margin-left:0.3rem;">v{{ reg.agent_version }}</span>{% endif %}
</span>
{% if psi.cpu is not none or psi.mem is not none or psi.io is not none %}
<span title="Pressure stall — % of the last 10s tasks waited for the resource">
<span style="color:var(--text-dim);text-transform:uppercase;font-size:0.66rem;letter-spacing:0.04em;">Pressure 10s</span>
cpu {{ '%.0f%%'|format(psi.cpu) if psi.cpu is not none else '—' }}
· mem {{ '%.0f%%'|format(psi.mem) if psi.mem is not none else '—' }}
· io {{ '%.0f%%'|format(psi.io) if psi.io is not none else '—' }}
</span>
{% endif %}
{% if reg and reg.last_seen_at %}<span>seen {{ reg.last_seen_at.strftime("%H:%M:%S") }} UTC</span>{% endif %}
</div>
</div>
{% endif %}
+68 -216
View File
@@ -1,215 +1,56 @@
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% block title %}{{ host.name }} — Host Agent — Steward{% endblock %}
{% block title %}{{ host.name }} — Metrics — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), (host.name, "/hosts/" ~ host.id), ("Metrics", "")]) }}{% endblock %}
{% macro fmt_bps(v) %}
{%- if v is none -%}—
{%- elif v >= 1048576 -%}{{ "%.1f"|format(v / 1048576) }} MB/s
{%- elif v >= 1024 -%}{{ "%.1f"|format(v / 1024) }} KB/s
{%- else -%}{{ "%.0f"|format(v) }} B/s
{%- endif -%}
{% endmacro %}
{% macro fmt_bytes(v) %}
{%- if v is none -%}—
{%- elif v >= 1073741824 -%}{{ "%.1f"|format(v / 1073741824) }} GB
{%- elif v >= 1048576 -%}{{ "%.0f"|format(v / 1048576) }} MB
{%- else -%}{{ "%.0f"|format(v / 1024) }} KB
{%- endif -%}
{% endmacro %}
{% macro bar(pct) %}
{%- set p = pct if pct is not none else 0 -%}
<div style="height:8px;background:var(--bg-elevated);border-radius:4px;overflow:hidden;border:1px solid var(--border);">
<div style="height:100%;width:{{ p|round(1) }}%;background:{% if pct is none %}var(--text-dim){% elif pct < 70 %}var(--green){% elif pct < 90 %}var(--yellow){% else %}var(--red){% endif %};"></div>
</div>
{% endmacro %}
{% block content %}
{# Shell only: current-state + history data stream in via HTMX so the heavy
history query never blocks first paint and the data refreshes live (≈ the
agent's push cadence). The chart CANVASES live here permanently — only their
data is polled and applied in place, so refreshes never re-create/flicker. #}
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:0.75rem;">
<div style="display:flex;align-items:center;gap:0.6rem;">
<a href="/hosts/{{ host.id }}" class="btn btn-ghost btn-sm">← Host</a>
<h1 class="page-title" style="margin-bottom:0;">{{ host.name }}</h1>
{% if stale %}<span class="badge badge-red">stale</span>{% else %}<span class="badge badge-green">live</span>{% endif %}
</div>
<div style="display:flex;gap:0.3rem;">
{% for r in range_options %}
<a class="range-btn {% if r == range_key %}active{% endif %}" href="?range={{ r }}">{{ r }}</a>
{% endfor %}
</div>
<h1 class="page-title" style="margin-bottom:0;">{{ host.name }}</h1>
{% include "_time_range.html" %}
</div>
{# ── Identity / metadata ─────────────────────────────────────────────────── #}
<div class="card" style="display:flex;flex-wrap:wrap;gap:0.5rem 1.25rem;align-items:center;font-size:0.82rem;color:var(--text-muted);">
<span><span style="color:var(--text-dim);">Address</span> {{ host.address or "—" }}</span>
{% if reg %}
{% if reg.distro %}<span><span style="color:var(--text-dim);">OS</span> {{ reg.distro }}</span>{% endif %}
{% if reg.kernel %}<span><span style="color:var(--text-dim);">Kernel</span> {{ reg.kernel }}</span>{% endif %}
{% if reg.arch %}<span><span style="color:var(--text-dim);">Arch</span> {{ reg.arch }}</span>{% endif %}
{% if reg.agent_version %}<span><span style="color:var(--text-dim);">Agent</span> v{{ reg.agent_version }}</span>{% endif %}
{% set up = hostlvl.get('uptime_secs') %}
{% if up %}<span><span style="color:var(--text-dim);">Uptime</span> {{ (up // 86400)|int }}d {{ ((up % 86400) // 3600)|int }}h</span>{% endif %}
<span><span style="color:var(--text-dim);">Last seen</span> {{ reg.last_seen_at.strftime("%Y-%m-%d %H:%M:%S") ~ " UTC" if reg.last_seen_at else "never" }}</span>
{% else %}
<span>No agent registration found for this host.</span>
{% endif %}
{# Current state — lazy + polled live. Atomic innerHTML swap of fixed-height
cards, so values update without a layout jump. #}
<div id="hm-current"
hx-get="/plugins/host_agent/{{ host.id }}/metrics"
hx-trigger="load, every {{ poll_seconds }}s"
hx-swap="innerHTML">
<div class="card empty">Loading metrics…</div>
</div>
{% if not hostlvl %}
<div class="card empty">No metrics reported yet. Once the agent checks in, CPU, memory, network, disk I/O, temperatures, and pressure will appear here.</div>
{% else %}
{# ── Current headline gauges ─────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.75rem;margin-bottom:1rem;">
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;">CPU</div>
{% set c = hostlvl.get('cpu_pct') %}
<span class="stat-val" style="font-size:1.6rem;color:{% if c is none %}var(--text-dim){% elif c < 70 %}var(--green){% elif c < 90 %}var(--yellow){% else %}var(--red){% endif %};">{% if c is not none %}{{ "%.0f"|format(c) }}%{% else %}—{% endif %}</span>
<div style="margin-top:0.5rem;">{{ bar(c) }}</div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;">Memory</div>
{% set mp = hostlvl.get('mem_used_pct') %}
<span class="stat-val" style="font-size:1.6rem;color:{% if mp is none %}var(--text-dim){% elif mp < 70 %}var(--green){% elif mp < 90 %}var(--yellow){% else %}var(--red){% endif %};">{% if mp is not none %}{{ "%.0f"|format(mp) }}%{% else %}—{% endif %}</span>
<div style="margin-top:0.5rem;">{{ bar(mp) }}</div>
<div style="margin-top:0.45rem;font-size:0.72rem;color:var(--text-dim);">
avail {{ fmt_bytes(hostlvl.get('mem_available_bytes')) }} · cache {{ fmt_bytes(hostlvl.get('mem_cached_bytes')) }} · swap {{ fmt_bytes(hostlvl.get('swap_used_bytes')) }}
</div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;">Load</div>
<span class="stat-val" style="font-size:1.6rem;">{% if hostlvl.get('load_1m') is not none %}{{ "%.2f"|format(hostlvl.get('load_1m')) }}{% else %}—{% endif %}</span>
<div style="margin-top:0.45rem;font-size:0.72rem;color:var(--text-dim);">
5m {% if hostlvl.get('load_5m') is not none %}{{ "%.2f"|format(hostlvl.get('load_5m')) }}{% else %}—{% endif %} ·
15m {% if hostlvl.get('load_15m') is not none %}{{ "%.2f"|format(hostlvl.get('load_15m')) }}{% else %}—{% endif %}
</div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;">Network</div>
<div style="font-size:0.95rem;font-variant-numeric:tabular-nums;">
<div><span style="color:var(--green);"></span> {{ fmt_bps(hostlvl.get('net_rx_bps')) }}</div>
<div><span style="color:var(--red);"></span> {{ fmt_bps(hostlvl.get('net_tx_bps')) }}</div>
</div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;">Disk I/O</div>
<div style="font-size:0.95rem;font-variant-numeric:tabular-nums;">
<div><span style="color:var(--text-dim);">rd</span> {{ fmt_bps(hostlvl.get('disk_read_bps')) }}</div>
<div><span style="color:var(--text-dim);">wr</span> {{ fmt_bps(hostlvl.get('disk_write_bps')) }}</div>
</div>
</div>
{% if hostlvl.get('temp_c_max') is not none %}
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;">Temp (max)</div>
{% set t = hostlvl.get('temp_c_max') %}
<span class="stat-val" style="font-size:1.6rem;color:{% if t < 70 %}var(--green){% elif t < 85 %}var(--yellow){% else %}var(--red){% endif %};">{{ "%.0f"|format(t) }}°C</span>
</div>
{% endif %}
{% if hostlvl.get('psi_mem_some_avg10') is not none %}
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;" title="Pressure stall — % of time stalled, 10s avg">Pressure (10s)</div>
<div style="font-size:0.78rem;font-variant-numeric:tabular-nums;color:var(--text-muted);">
<div>mem {{ "%.1f"|format(hostlvl.get('psi_mem_some_avg10')) }}%</div>
{% if hostlvl.get('psi_cpu_some_avg10') is not none %}<div>cpu {{ "%.1f"|format(hostlvl.get('psi_cpu_some_avg10')) }}%</div>{% endif %}
{% if hostlvl.get('psi_io_some_avg10') is not none %}<div>io {{ "%.1f"|format(hostlvl.get('psi_io_some_avg10')) }}%</div>{% endif %}
</div>
</div>
{% endif %}
</div>
{# ── Per-core CPU ─────────────────────────────────────────────────────────── #}
{% if cores %}
<div class="card">
<div class="section-title" style="margin-bottom:0.6rem;">Per-core CPU</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:0.5rem 0.9rem;">
{% for idx, pct in cores %}
<div>
<div style="display:flex;justify-content:space-between;font-size:0.72rem;color:var(--text-dim);margin-bottom:0.2rem;">
<span>core {{ idx }}</span><span>{% if pct is not none %}{{ "%.0f"|format(pct) }}%{% else %}—{% endif %}</span>
</div>
{{ bar(pct) }}
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# ── Filesystems ──────────────────────────────────────────────────────────── #}
{% if mounts %}
<div class="card">
<div class="section-title" style="margin-bottom:0.6rem;">Filesystems</div>
{% for mount, m in mounts.items() %}
<div style="margin-bottom:0.6rem;">
<div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:0.2rem;">
<span style="font-family:ui-monospace,monospace;">{{ mount }}</span>
<span style="color:var(--text-muted);">{{ fmt_bytes(m.get('disk_used_bytes')) }} / {{ fmt_bytes(m.get('disk_total_bytes')) }}{% if m.get('disk_used_pct') is not none %} · {{ "%.0f"|format(m.get('disk_used_pct')) }}%{% endif %}</span>
</div>
{{ bar(m.get('disk_used_pct')) }}
</div>
{% endfor %}
</div>
{% endif %}
{# ── Interfaces / disks / sensors current detail ──────────────────────────── #}
{% if nets or disks_io or temps %}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1rem;">
{% if nets %}
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Interfaces</div>
{% for iface, m in nets.items() %}
<div style="display:flex;justify-content:space-between;font-size:0.8rem;padding:0.15rem 0;">
<span style="font-family:ui-monospace,monospace;">{{ iface }}</span>
<span style="color:var(--text-muted);font-variant-numeric:tabular-nums;">↓ {{ fmt_bps(m.get('net_rx_bps')) }} · ↑ {{ fmt_bps(m.get('net_tx_bps')) }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if disks_io %}
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Disks</div>
{% for dev, m in disks_io.items() %}
<div style="display:flex;justify-content:space-between;font-size:0.8rem;padding:0.15rem 0;">
<span style="font-family:ui-monospace,monospace;">{{ dev }}</span>
<span style="color:var(--text-muted);font-variant-numeric:tabular-nums;">rd {{ fmt_bps(m.get('disk_read_bps')) }} · wr {{ fmt_bps(m.get('disk_write_bps')) }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if temps %}
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Temperatures</div>
{% for label, c in temps.items() %}
<div style="display:flex;justify-content:space-between;font-size:0.8rem;padding:0.15rem 0;">
<span>{{ label }}</span>
<span style="color:{% if c is none %}var(--text-dim){% elif c < 70 %}var(--green){% elif c < 85 %}var(--yellow){% else %}var(--red){% endif %};font-variant-numeric:tabular-nums;">{% if c is not none %}{{ "%.0f"|format(c) }}°C{% else %}—{% endif %}</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{# ── History charts ───────────────────────────────────────────────────────── #}
{# History charts — persistent canvases (created once below); only data is polled. #}
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:1rem;margin-top:1rem;">
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Utilization % — last {{ range_key }}</div>
<div class="section-title" style="margin-bottom:0.5rem;">Utilization % — last <span class="hm-chart-range">{{ current_range }}</span></div>
<div style="height:220px;"><canvas id="chart-util"></canvas></div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Throughput (B/s) — last {{ range_key }}</div>
<div class="section-title" style="margin-bottom:0.5rem;">Throughput (B/s) — last <span class="hm-chart-range">{{ current_range }}</span></div>
<div style="height:220px;"><canvas id="chart-net"></canvas></div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Load &amp; pressure — last {{ range_key }}</div>
<div class="section-title" style="margin-bottom:0.5rem;">Load &amp; pressure — last <span class="hm-chart-range">{{ current_range }}</span></div>
<div style="height:220px;"><canvas id="chart-pressure"></canvas></div>
</div>
</div>
{# Hidden poller: fetches the history series and applies it to the charts above
(no visible swap). Lazy on load → never blocks paint; refreshed slowly + on
range change. #}
<div id="hm-charts-data" style="display:none;"
hx-get="/plugins/host_agent/{{ host.id }}/charts"
hx-trigger="load, every {{ chart_poll_seconds }}s, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML"></div>
<script>
(function () {
const series = {{ series|tojson }};
const fmtTime = (v) => { const d = new Date(v); return ("0" + d.getHours()).slice(-2) + ":" + ("0" + d.getMinutes()).slice(-2); };
const baseOpts = (ymax) => ({
responsive: true, maintainAspectRatio: false,
animation: false, // no grow-in / re-animate on refresh
interaction: { mode: "index", intersect: false },
elements: { point: { radius: 0 } },
scales: {
@@ -218,39 +59,50 @@
},
plugins: {
legend: { labels: { color: "#b8b8b0", boxWidth: 10, font: { size: 11 } } },
tooltip: { callbacks: { title: function (items) { return items.length ? fmtTime(items[0].parsed.x) : ""; } } },
tooltip: { callbacks: { title: (items) => items.length ? fmtTime(items[0].parsed.x) : "" } },
},
});
const mk = (id, datasets, ymax) => {
const el = document.getElementById(id);
if (!el) return;
new Chart(el.getContext("2d"), {
type: "line",
data: { datasets: datasets.map((d) => ({
label: d.label,
data: (series[d.key] || []).map((p) => ({ x: p[0], y: p[1] })),
borderColor: d.color, backgroundColor: d.color, borderWidth: 1.5, tension: 0.25,
})) },
options: baseOpts(ymax),
});
const defs = {
"chart-util": { ymax: 100, ds: [
{ key: "cpu_pct", label: "CPU %", color: "#c8a840" },
{ key: "mem_used_pct", label: "Mem %", color: "#4aa86a" },
{ key: "disk_used_pct_worst", label: "Disk % (worst)", color: "#c87840" },
] },
"chart-net": { ymax: undefined, ds: [
{ key: "net_rx_bps", label: "Net RX", color: "#4aa86a" },
{ key: "net_tx_bps", label: "Net TX", color: "#c84048" },
{ key: "disk_read_bps", label: "Disk read", color: "#6aa0d0" },
{ key: "disk_write_bps", label: "Disk write", color: "#c8a840" },
] },
"chart-pressure": { ymax: undefined, ds: [
{ key: "load_1m", label: "Load 1m", color: "#c8a840" },
{ key: "psi_mem_some_avg10", label: "Mem PSI %", color: "#c84048" },
{ key: "temp_c_max", label: "Temp °C", color: "#c87840" },
] },
};
const charts = {};
for (const id of Object.keys(defs)) {
const el = document.getElementById(id);
if (!el) continue;
charts[id] = new Chart(el.getContext("2d"), {
type: "line",
data: { datasets: defs[id].ds.map((d) => ({ label: d.label, data: [], borderColor: d.color, backgroundColor: d.color, borderWidth: 1.5, tension: 0.25 })) },
options: baseOpts(defs[id].ymax),
});
}
// Called by the polled /charts data fragment. Updates data in place (no
// re-create, no animation), so refreshes never flicker or shift layout.
window.applyHostSeries = function (series, rangeKey) {
for (const id of Object.keys(defs)) {
const ch = charts[id];
if (!ch) continue;
defs[id].ds.forEach((d, i) => {
ch.data.datasets[i].data = (series[d.key] || []).map((p) => ({ x: p[0], y: p[1] }));
});
ch.update("none");
}
if (rangeKey) document.querySelectorAll(".hm-chart-range").forEach((s) => { s.textContent = rangeKey; });
};
mk("chart-util", [
{ key: "cpu_pct", label: "CPU %", color: "#c8a840" },
{ key: "mem_used_pct", label: "Mem %", color: "#4aa86a" },
{ key: "disk_used_pct_worst", label: "Disk % (worst)", color: "#c87840" },
], 100);
mk("chart-net", [
{ key: "net_rx_bps", label: "Net RX", color: "#4aa86a" },
{ key: "net_tx_bps", label: "Net TX", color: "#c84048" },
{ key: "disk_read_bps", label: "Disk read", color: "#6aa0d0" },
{ key: "disk_write_bps", label: "Disk write", color: "#c8a840" },
], undefined);
mk("chart-pressure", [
{ key: "load_1m", label: "Load 1m", color: "#c8a840" },
{ key: "psi_mem_some_avg10", label: "Mem PSI %", color: "#c84048" },
{ key: "temp_c_max", label: "Temp °C", color: "#c87840" },
], undefined);
})();
</script>
{% endif %}
{% endblock %}
+2 -33
View File
@@ -14,39 +14,8 @@
</div>
{% if reporting %}
{# ── Reporting: at-a-glance metrics (+ sparkline trend) + lifecycle ── #}
{% set cpu = hostlvl.get('cpu_pct') %}
{% set mem = hostlvl.get('mem_used_pct') %}
{% set lbl = "font-size:0.72rem;color:var(--text-muted);text-transform:uppercase;" %}
<div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:0.6rem;">
<div title="Average CPU utilization across all cores">
<div style="{{ lbl }}">CPU</div>
<div style="font-weight:600;{{ threshold_style(cpu, 'cpu') }}">{{ '%.0f%%'|format(cpu) if cpu is not none else '—' }}</div>
{{ sparks.cpu | safe }}</div>
<div title="RAM in use (total minus available; cache/buffers count as free)">
<div style="{{ lbl }}">Memory</div>
<div style="font-weight:600;{{ threshold_style(mem, 'mem') }}">{{ '%.0f%%'|format(mem) if mem is not none else '—' }}</div>
{{ sparks.mem | safe }}</div>
<div title="Root filesystem (/) usage — see Full metrics for every mount">
<div style="{{ lbl }}">Disk /</div>
<div style="font-weight:600;{{ threshold_style(disk_root, 'disk') }}">{{ '%.0f%%'|format(disk_root) if disk_root is not none else '—' }}</div>
{{ sparks.disk | safe }}</div>
{# Load /core: 100% = run queue matches CPU capacity, so warn 80 / crit 100. #}
<div title="1-minute load average ÷ {{ cores or '?' }} CPU cores (100% = run queue matches capacity). Raw 1m load: {{ '%.2f'|format(load1) if load1 is not none else '—' }}.">
<div style="{{ lbl }}">Load /core</div>
<div style="font-weight:600;{{ threshold_style(load_per_core, 'load') }}">{{ '%d%%'|format(load_per_core) if load_per_core is not none else ('%.2f'|format(load1) if load1 is not none else '—') }}</div>
{{ sparks.load | safe }}</div>
</div>
{% if psi.cpu is not none or psi.mem is not none or psi.io is not none %}
<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:0.9rem;"
title="Pressure Stall Information — % of the last 10s that tasks were stalled waiting for the resource. Hardware-independent, comparable across hosts.">
<span style="text-transform:uppercase;font-size:0.7rem;letter-spacing:0.04em;color:var(--text-dim);">Pressure 10s</span>
&nbsp;CPU {{ '%.0f%%'|format(psi.cpu) if psi.cpu is not none else '—' }}
· Mem {{ '%.0f%%'|format(psi.mem) if psi.mem is not none else '—' }}
· IO {{ '%.0f%%'|format(psi.io) if psi.io is not none else '—' }}
</div>
{% endif %}
{# Reporting: live vitals are shown by the vitals strip above; this panel is
lifecycle/management only. #}
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;">
<a href="/plugins/host_agent/{{ host.id }}/" class="btn btn-sm btn-ghost">Full metrics →</a>
{% if session.user_role == 'admin' %}
@@ -5,7 +5,6 @@
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;gap:1rem;flex-wrap:wrap;">
<h1 class="page-title" style="margin-bottom:0;">Agent fleet</h1>
<a href="/hosts/" class="btn btn-ghost btn-sm">← Hosts</a>
</div>
<p style="color:var(--text-muted);font-size:0.84rem;margin:-0.4rem 0 1.25rem;">
Bulk agent operations across your inventory. For a single host, use its
+5
View File
@@ -21,3 +21,8 @@ def get_scheduled_tasks() -> list:
def get_blueprint():
from .routes import snmp_bp
return snmp_bp
def get_nav() -> list[dict]:
# Surfaces in the sidebar's Infrastructure group (SNMP device readings).
return [{"label": "SNMP", "href": "/plugins/snmp/"}]
+10
View File
@@ -15,6 +15,16 @@ tags:
config:
poll_interval_seconds: 60
# Each device is polled independently. OIDs must resolve to numeric values.
#
# `host` is the SNMP poll target (IP/hostname we query). A device also shows
# up on a Steward Host's detail page when it maps to that host. By default
# that mapping is implicit: `host` is matched against the Host's address or
# name. To bind explicitly instead — e.g. when you poll by IP but the Host is
# recorded by DNS name, or for an SNMP-only switch/PDU/UPS — add ONE of:
# host_id: "<steward-host-uuid>" # exact Host.id match
# steward_host: "switch01" # Host name OR address (case-insensitive)
# An explicit binding is exclusive: the device then maps ONLY to that host,
# never implicitly to another by a coincidental address string.
devices:
- name: "core-switch"
host: "192.168.1.1"
+123 -46
View File
@@ -1,11 +1,13 @@
# plugins/snmp/poller.py
"""
Synchronous SNMP GET helper, run via executor.
Asynchronous SNMP GET helper.
Requires pysnmp-lextudio (maintained pysnmp fork):
Requires pysnmp-lextudio (the maintained pysnmp fork), bundled into the Docker
image via the `snmp` extra (`pip install .[snmp]`):
pip install 'steward[snmp]'
If pysnmp is not installed, poll_device() returns an empty dict and logs a warning.
If pysnmp is not installed, poll_device() returns an empty dict and logs a
warning — SNMP polling is then simply disabled, nothing else breaks.
"""
from __future__ import annotations
import logging
@@ -22,73 +24,148 @@ def _pysnmp_available() -> bool:
def _mp_model(version: str) -> int:
"""Map version string to pysnmp mpModel integer."""
"""Map an SNMP version string to pysnmp's mpModel int (0 = v1, 1 = v2c)."""
return 0 if version == "1" else 1
def poll_device_sync(
def _close_engine(engine) -> None:
"""Release the engine's UDP transport socket.
pysnmp opens a UDP socket per ``SnmpEngine`` and never closes it on its own.
Since we build a fresh engine for every poll, an unclosed engine leaks one
file descriptor each scheduler tick; over hours of polling that exhausts the
process fd limit (``OSError: [Errno 24] Too many open files``), which also
takes down the app's listening socket. So close it explicitly here.
The close method/attribute names differ across pysnmp majors (6.2.x lextudio
is camelCase, canonical 7.x is snake_case), so probe for whatever exists.
All paths are best-effort — a failed close must never break the poll loop.
"""
# 7.x exposes a convenience close directly on the engine.
for meth in ("close_dispatcher", "closeDispatcher"):
fn = getattr(engine, meth, None)
if callable(fn):
try:
fn()
except Exception:
pass
return
# 6.2.x: go through the transport dispatcher.
for attr in ("transport_dispatcher", "transportDispatcher"):
dispatcher = getattr(engine, attr, None)
if dispatcher is None:
continue
for meth in ("close_dispatcher", "closeDispatcher"):
fn = getattr(dispatcher, meth, None)
if callable(fn):
try:
fn()
except Exception:
pass
return
return
async def poll_device(
host: str,
port: int,
community: str,
version: str,
oids: list[dict],
) -> dict[str, float]:
"""
Perform SNMP GET for each OID and return {label: float_value}.
Non-numeric OIDs (strings, etc.) are skipped.
Returns empty dict on any error.
"""Perform an SNMP GET for each OID and return ``{label: float_value}``.
Non-numeric OIDs (strings, etc.) are skipped. Returns an empty dict on any
error (unreachable host, wrong community, …) so a flaky device never breaks
the poll loop.
pysnmp's HLAPI is asyncio-only as of v6. The import path moved between major
versions, so we support both rather than let a dependency bump silently
re-break polling:
• pysnmp-lextudio 6.2.x → ``pysnmp.hlapi.asyncio`` (``getCmd`` + a directly
constructed ``UdpTransportTarget``).
• canonical pysnmp 7.x → ``pysnmp.hlapi.v3arch.asyncio`` (``get_cmd`` + the
async ``UdpTransportTarget.create``).
"""
if not _pysnmp_available():
logger.warning("pysnmp not installed — SNMP polling disabled. "
"Install with: pip install 'steward[snmp]'")
return {}
from pysnmp.hlapi import (
CommunityData,
ContextData,
ObjectIdentity,
ObjectType,
SnmpEngine,
UdpTransportTarget,
getCmd,
)
try:
# canonical pysnmp 7.x
from pysnmp.hlapi.v3arch.asyncio import (
CommunityData,
ContextData,
ObjectIdentity,
ObjectType,
SnmpEngine,
UdpTransportTarget,
get_cmd as _get_cmd,
)
_transport_is_async = True
except ImportError:
# pysnmp-lextudio 6.2.x
from pysnmp.hlapi.asyncio import (
CommunityData,
ContextData,
ObjectIdentity,
ObjectType,
SnmpEngine,
UdpTransportTarget,
getCmd as _get_cmd,
)
_transport_is_async = False
results: dict[str, float] = {}
engine = SnmpEngine()
for oid_cfg in oids:
oid = oid_cfg["oid"]
label = oid_cfg.get("label") or oid
scale = float(oid_cfg.get("scale", 1.0))
# Always release the engine's UDP socket — see _close_engine for why.
try:
# Same host/port for every OID on this device, so build the transport once.
try:
error_indication, error_status, error_index, var_binds = next(
getCmd(
if _transport_is_async:
transport = await UdpTransportTarget.create((host, port), timeout=5, retries=1)
else:
transport = UdpTransportTarget((host, port), timeout=5, retries=1)
except Exception as exc:
logger.debug("SNMP transport setup failed for %s:%s: %s", host, port, exc)
return {}
results: dict[str, float] = {}
for oid_cfg in oids:
oid = oid_cfg["oid"]
label = oid_cfg.get("label") or oid
scale = float(oid_cfg.get("scale", 1.0))
try:
error_indication, error_status, error_index, var_binds = await _get_cmd(
engine,
CommunityData(community, mpModel=_mp_model(version)),
UdpTransportTarget((host, port), timeout=5, retries=1),
transport,
ContextData(),
ObjectType(ObjectIdentity(oid)),
)
)
except Exception as exc:
logger.debug("SNMP GET %s@%s OID %s failed: %s", host, port, oid, exc)
continue
except Exception as exc:
logger.debug("SNMP GET %s@%s OID %s failed: %s", host, port, oid, exc)
continue
if error_indication:
logger.debug("SNMP error %s@%s OID %s: %s", host, port, oid, error_indication)
continue
if error_status:
logger.debug("SNMP status %s@%s OID %s: %s at %s",
host, port, oid, error_status.prettyPrint(),
error_index and var_binds[int(error_index) - 1][0] or "?")
continue
if error_indication:
logger.debug("SNMP error %s@%s OID %s: %s", host, port, oid, error_indication)
continue
if error_status:
logger.debug("SNMP status %s@%s OID %s: %s at %s",
host, port, oid, error_status.prettyPrint(),
error_index and var_binds[int(error_index) - 1][0] or "?")
continue
for _, val in var_binds:
try:
results[label] = float(val) * scale
except (TypeError, ValueError):
# Non-numeric type (e.g. OctetString description) — skip
logger.debug("SNMP non-numeric value for %s label=%s: %r", oid, label, val)
for _, val in var_binds:
try:
results[label] = float(val) * scale
except (TypeError, ValueError):
# Non-numeric type (e.g. OctetString description) — skip.
logger.debug("SNMP non-numeric value for %s label=%s: %r", oid, label, val)
return results
return results
finally:
_close_engine(engine)
+69
View File
@@ -93,6 +93,75 @@ async def index():
poll_interval=poll_interval)
def _device_binds_to_host(d: dict, keys: set[str], host_id: str) -> bool:
"""Does config device `d` belong on the host identified by `keys`
(lowercased {address, name}) / `host_id`?
A device may declare an **explicit** binding that decouples "which Steward
host this belongs to" from "where to poll" (the `host` field):
• `host_id` — the Steward Host UUID (exact match), the explicit link.
• `steward_host` — a friendly bind by Host name or address (case-insensitive).
An explicit binding is **exclusive**: a device bound to host A must not also
match host B by a coincidental poll-target string. Only when no explicit
binding is present do we fall back to the implicit `host`-string match
(backward compatible with configs that only set `host`).
"""
explicit_id = str(d.get("host_id", "")).strip()
explicit_name = str(d.get("steward_host", "")).strip().lower()
if explicit_id or explicit_name:
if explicit_id and host_id and explicit_id == host_id:
return True
return bool(explicit_name and explicit_name in keys)
return str(d.get("host", "")).strip().lower() in keys
def _devices_for_host(devices_cfg: list, address: str | None, name: str | None,
host_id: str | None = None) -> list[dict]:
"""SNMP devices that map onto a Steward host's page. Prefers an explicit
per-device binding (`host_id` / `steward_host`); otherwise falls back to
matching the device's `host` (poll target) against the host's address or
name (case-insensitive). See `_device_binds_to_host` for the precedence."""
keys = {(address or "").strip().lower(), (name or "").strip().lower()}
keys.discard("")
hid = (host_id or "").strip()
return [
d for d in devices_cfg
if isinstance(d, dict) and _device_binds_to_host(d, keys, hid)
]
@snmp_bp.get("/host/<host_id>")
@require_role(UserRole.viewer)
async def host_panel(host_id: str):
"""Per-host SNMP fragment for the Hosts hub. Surfaces any configured SNMP
device that maps to this host (by address or name) with its latest readings.
Renders nothing when no device maps, so hosts without SNMP carry no empty card.
"""
from steward.models.hosts import Host
devices_cfg: list[dict] = current_app.config["PLUGINS"]["snmp"].get("devices", [])
async with current_app.db_sessionmaker() as db:
host = (await db.execute(
select(Host).where(Host.id == host_id))).scalar_one_or_none()
if host is None:
return ""
matched = _devices_for_host(devices_cfg, host.address, host.name, host.id)
if not matched:
return ""
names = [d.get("name") or d.get("host", "?") for d in matched]
latest = await _latest_readings(db, names)
devices = [{
"name": d.get("name") or d.get("host", "?"),
"host": d.get("host", ""),
"oids": d.get("oids", []),
"readings": latest.get(d.get("name") or d.get("host", "?"), {}),
"bound": bool(str(d.get("host_id", "")).strip()
or str(d.get("steward_host", "")).strip()),
} for d in matched]
return await render_template("snmp/host_panel.html", devices=devices)
@snmp_bp.get("/widget")
@require_role(UserRole.viewer)
async def widget():
+2 -9
View File
@@ -1,6 +1,5 @@
# plugins/snmp/scheduler.py
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING
@@ -27,15 +26,13 @@ def make_poll_task(app: "Quart") -> ScheduledTask:
async def _do_poll(app: "Quart") -> None:
from .poller import poll_device_sync
from .poller import poll_device
from steward.core.alerts import record_metric
devices: list[dict] = app.config["PLUGINS"]["snmp"].get("devices", [])
if not devices:
return
loop = asyncio.get_event_loop()
async with app.db_sessionmaker() as session:
async with session.begin():
for device in devices:
@@ -52,11 +49,7 @@ async def _do_poll(app: "Quart") -> None:
continue
try:
readings = await loop.run_in_executor(
None,
poll_device_sync,
host, port, community, version, oids,
)
readings = await poll_device(host, port, community, version, oids)
except Exception:
logger.exception("SNMP poll failed for device %s (%s)", name, host)
continue
+2 -1
View File
@@ -1,9 +1,10 @@
{# plugins/snmp/templates/snmp/device.html #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% block title %}SNMP — {{ device_name }} — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("SNMP", "/plugins/snmp/"), (device_name, "")]) }}{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap;">
<a href="/plugins/snmp/" style="color:var(--text-muted);font-size:0.85rem;">&#8592; SNMP</a>
<h1 class="page-title" style="margin:0;">{{ device_name }}</h1>
<span style="font-size:0.8rem;color:var(--text-dim);">{{ device.host }}</span>
<div style="margin-left:auto;display:flex;gap:0.5rem;">
@@ -0,0 +1,51 @@
{# Per-host SNMP fragment — embedded on the Hosts hub via HTMX. Self-contained.
Shows the SNMP device(s) whose address maps to this host + latest readings. #}
<div class="card">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.6rem;gap:1rem;flex-wrap:wrap;">
<h3 class="section-title" style="margin-bottom:0;">SNMP</h3>
<a href="/plugins/snmp/" style="font-size:0.8rem;color:var(--text-muted);">All devices →</a>
</div>
{% for device in devices %}
<div style="{% if not loop.last %}margin-bottom:1rem;{% endif %}">
<div style="display:flex;align-items:center;gap:0.6rem;margin-bottom:0.5rem;flex-wrap:wrap;">
<span style="font-weight:600;font-size:0.92rem;">{{ device.name }}</span>
<span style="font-size:0.78rem;color:var(--text-dim);font-family:ui-monospace,monospace;">{{ device.host }}</span>
{% if device.bound %}
<span title="Explicitly bound to this host (host_id / steward_host)"
style="font-size:0.68rem;color:var(--text-muted);background:var(--bg-elevated);border:1px solid var(--border);border-radius:3px;padding:0.05rem 0.4rem;">bound</span>
{% endif %}
{% if device.readings %}
<span style="font-size:0.74rem;color:var(--green);">&#9679; reachable</span>
{% else %}
<span style="font-size:0.74rem;color:var(--text-dim);">&#9675; no data yet</span>
{% endif %}
<a href="/plugins/snmp/device/{{ device.name }}" class="btn btn-ghost btn-sm"
style="margin-left:auto;font-size:0.76rem;padding:0.15rem 0.55rem;">History</a>
</div>
{% if device.readings %}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.5rem;">
{% for oid_cfg in device.oids %}
{% set label = oid_cfg.label if oid_cfg.label else oid_cfg.oid %}
{% set val = device.readings.get(label) %}
<div style="background:var(--bg-elevated);border-radius:5px;padding:0.5rem 0.7rem;border:1px solid var(--border);">
<div style="font-size:0.68rem;color:var(--text-dim);margin-bottom:0.2rem;font-family:ui-monospace,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ label }}</div>
{% if val is not none %}
<div style="font-size:1rem;font-weight:600;font-variant-numeric:tabular-nums;">
{% if val >= 1000000 %}{{ "%.2f"|format(val / 1000000) }}<span style="font-size:0.72rem;color:var(--text-muted);">M</span>
{% elif val >= 1000 %}{{ "%.1f"|format(val / 1000) }}<span style="font-size:0.72rem;color:var(--text-muted);">K</span>
{% else %}{{ "%.4g"|format(val) }}{% endif %}
</div>
{% else %}
<div style="font-size:0.85rem;color:var(--text-dim);"></div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p style="font-size:0.8rem;color:var(--text-dim);margin:0;">No readings yet — waiting for the next poll.</p>
{% endif %}
</div>
{% endfor %}
</div>
+5
View File
@@ -28,3 +28,8 @@ def get_scheduled_tasks() -> list:
def get_blueprint():
from .routes import traefik_bp
return traefik_bp
def get_nav() -> list[dict]:
# Surfaces in the sidebar's Infrastructure group (Traefik services view).
return [{"label": "Traefik", "href": "/plugins/traefik/"}]
+5
View File
@@ -26,3 +26,8 @@ def get_scheduled_tasks() -> list:
def get_blueprint():
from .routes import unifi_bp
return unifi_bp
def get_nav() -> list[dict]:
# Surfaces in the sidebar's Infrastructure group (UniFi network overview).
return [{"label": "UniFi", "href": "/plugins/unifi/"}]
+21 -2
View File
@@ -15,6 +15,25 @@ logger = logging.getLogger(__name__)
_client = None # UnifiClient instance, initialised on first scrape
async def _drop_client() -> None:
"""Close and discard the cached client.
The client holds a long-lived ``httpx.AsyncClient`` (a connection pool over
real sockets). Nulling the reference without ``aclose()`` orphans that pool
until GC, so every failure tick that re-auths would leak file descriptors —
the same class of bug that took the app down via SNMP (Errno 24, "Too many
open files"). Close first, then drop. Best-effort: a failed close must not
break the poll loop.
"""
global _client
if _client is not None:
try:
await _client.close()
except Exception:
pass
_client = None
def make_poll_task(app: "Quart") -> ScheduledTask:
cfg = app.config["PLUGINS"]["unifi"]
interval = int(cfg.get("poll_interval_seconds", 60))
@@ -51,7 +70,7 @@ async def _do_poll(app: "Quart") -> None:
await _client.login()
except Exception as exc:
logger.warning("UniFi initial login failed: %s", exc)
_client = None
await _drop_client()
return
try:
@@ -60,7 +79,7 @@ async def _do_poll(app: "Quart") -> None:
devices = await _client.get_devices()
except Exception as exc:
logger.warning("UniFi poll failed — will retry next tick: %s", exc)
_client = None # force re-auth on next tick
await _drop_client() # close + force re-auth on next tick
return
scraped_at = datetime.now(timezone.utc)
+25 -3
View File
@@ -172,6 +172,8 @@ async def playbook_vars():
(vars/vars_prompt), loaded when a playbook is chosen from a dropdown."""
source_name = (request.args.get("source_name", "") or "").strip()
playbook_path = (request.args.get("playbook_path", "") or "").strip()
# Schedule form: schedules can't prompt, so secret vars render disabled.
schedule = (request.args.get("schedule", "") or "").strip() == "1"
source = next((s for s in _get_sources() if s["name"] == source_name), None)
variables: list = []
meta: dict = {"description": "", "category": "", "confirm": False}
@@ -182,7 +184,8 @@ async def playbook_vars():
meta = src_module.discover_playbook_meta(contents)
return await render_template(
"ansible/_playbook_vars.html", variables=variables,
description=meta["description"], category=meta["category"], confirm=meta["confirm"])
description=meta["description"], category=meta["category"],
confirm=meta["confirm"], schedule=schedule)
@ansible_bp.get("/run-form/<source_name>/<path:playbook_path>")
@@ -370,7 +373,6 @@ async def schedules():
{"name": s["name"], "playbooks": src_module.discover_playbooks(s["path"])}
for s in _get_sources()
]
all_playbooks = sorted({p for sd in source_data for p in sd["playbooks"]})
rows = []
editing = None
@@ -383,11 +385,31 @@ async def schedules():
if editing_id and s.id == editing_id:
editing = s
# Default the source dropdown to the first source (or the edited schedule's
# source), and pre-render that source's playbooks so the playbook dropdown
# starts on a real item rather than a placeholder.
sel_source = editing.source_name if editing else (source_data[0]["name"] if source_data else "")
sel_playbooks = next((sd["playbooks"] for sd in source_data if sd["name"] == sel_source), [])
# Edit mode: pre-render the selected playbook's declared variables so their
# saved values show (fresh loads fetch these via HTMX from /playbook-vars).
edit_variables: list = []
edit_meta: dict = {"description": "", "category": "", "confirm": False}
if editing:
src = next((s for s in _get_sources() if s["name"] == editing.source_name), None)
if src:
contents = src_module.read_playbook(src["path"], editing.playbook_path)
if contents is not None:
edit_variables = src_module.discover_playbook_variables(contents)
edit_meta = src_module.discover_playbook_meta(contents)
return await render_template(
"ansible/schedules.html",
rows=rows, groups=groups, targets=targets,
source_data=source_data, all_playbooks=all_playbooks,
source_data=source_data, sel_source=sel_source, sel_playbooks=sel_playbooks,
editing=editing, interval_presets=INTERVAL_PRESETS,
edit_variables=edit_variables, edit_description=edit_meta["description"],
edit_category=edit_meta["category"], edit_confirm=edit_meta["confirm"],
)
+34 -2
View File
@@ -1,11 +1,14 @@
# steward/app.py
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from quart import Quart, render_template
from .config import load_bootstrap
from .database import init_db
logger = logging.getLogger(__name__)
def create_app(
config_path: Path | str | None = None,
@@ -50,9 +53,17 @@ def create_app(
# ── 3b. Encryption-at-rest: bind the encryptor, then encrypt legacy secrets ─
if not testing:
from .core.crypto import init_crypto
from .core.settings import migrate_plaintext_secrets
from .core.settings import migrate_plaintext_secrets, scan_undecryptable_secrets
init_crypto(app.config["SECRET_KEY"])
migrate_plaintext_secrets(app.config["DATABASE_URL"])
# Flag any secrets sealed under a now-lost key (see the admin banner).
undecryptable = scan_undecryptable_secrets(app.config["DATABASE_URL"])
if undecryptable:
logger.warning(
"%d stored secret(s) cannot be decrypted with the current app key "
"(re-enter them in Settings): %s",
len(undecryptable), ", ".join(undecryptable),
)
# ── 4. Load all settings from DB → populate app.config ────────────────────
if not testing:
@@ -166,7 +177,8 @@ def create_app(
# ── 10. Template context: inject plugin_failures into every response ───────
@app.context_processor
def _inject_plugin_failures():
from .core.plugin_manager import get_plugin_failures
from .core.plugin_manager import get_plugin_failures, get_plugin_nav
from .core.settings import get_undecryptable_secrets
# enabled_plugins lets templates gate cross-plugin embeds (e.g. the
# Hosts hub only fetches the docker fragment when docker is enabled),
# avoiding a 404 to a route whose blueprint isn't registered.
@@ -174,9 +186,16 @@ def create_app(
name for name, cfg in (app.config.get("PLUGINS") or {}).items()
if isinstance(cfg, dict) and cfg.get("enabled")
}
# Sidebar entries for enabled plugins that expose a UI (get_nav). Filter
# by enabled so a hot-disabled plugin's link can't linger before restart.
plugin_nav = [e for e in get_plugin_nav() if e["plugin"] in enabled_plugins]
return {
"plugin_failures": get_plugin_failures(),
"enabled_plugins": enabled_plugins,
"plugin_nav": plugin_nav,
# Read from an in-memory cache (no DB hit per render); kept current by
# the startup scan + set_setting's discard-on-write.
"undecryptable_secrets": get_undecryptable_secrets(),
}
# ── 11. Share-token middleware ─────────────────────────────────────────────
@@ -249,6 +268,10 @@ def _register_core_tasks(app: Quart) -> None:
from .core.reports import check_and_send
await check_and_send(app)
async def run_self_monitor():
from .core.self_monitor import record_self_metrics
await record_self_metrics(app)
app._task_registry.extend([
ScheduledTask(
name="weekly_report_check",
@@ -256,6 +279,15 @@ def _register_core_tasks(app: Quart) -> None:
interval_seconds=3600,
run_on_startup=False,
),
# Watch our own fd usage so a descriptor leak surfaces as a metric/alert
# instead of a silent Errno 24 lockup. Cheap; plugin_metrics roll up
# hourly so the 60s cadence is storage-bounded.
ScheduledTask(
name="self_monitor",
coro_factory=run_self_monitor,
interval_seconds=60,
run_on_startup=True,
),
ScheduledTask(
name="monitor_check",
coro_factory=run_monitor_checks,
+27 -8
View File
@@ -75,13 +75,27 @@ def load_bootstrap(config_path: Path | str | None = None) -> dict[str, Any]:
def _resolve_secret_key(raw: dict) -> str:
"""Resolve secret_key: env var → file → auto-generate."""
"""Resolve secret_key: env var → file → auto-generate.
Refuses to start if a new key must be generated but cannot be persisted: an
ephemeral key changes on every restart, which silently renders every
encrypted secret (managed SSH key, SMTP/OIDC/LDAP credentials) unrecoverable.
Failing loudly with a fix beats limping along and losing data on the next
boot — exactly the footgun that bit the vdnt-docker02 deployment.
"""
from_env = _env("SECRET_KEY") or raw.get("secret_key")
if from_env:
return from_env
if _SECRET_KEY_FILE.exists():
key = _SECRET_KEY_FILE.read_text().strip()
try:
key = _SECRET_KEY_FILE.read_text().strip()
except OSError as exc:
raise RuntimeError(
f"App secret key file {_SECRET_KEY_FILE} exists but cannot be read "
f"({exc}). Make it readable by the container user (uid 1000), or set "
f"STEWARD_SECRET_KEY."
) from exc
if key:
return key
@@ -89,11 +103,16 @@ def _resolve_secret_key(raw: dict) -> str:
try:
_SECRET_KEY_FILE.parent.mkdir(parents=True, exist_ok=True)
_SECRET_KEY_FILE.write_text(key)
logger.info("Generated new secret key and saved to %s", _SECRET_KEY_FILE)
except OSError as exc:
logger.warning(
"Could not write secret key to %s (%s). "
"Key will not persist across restarts.",
_SECRET_KEY_FILE, exc,
)
raise RuntimeError(
f"Generated a new app secret key but could not persist it to "
f"{_SECRET_KEY_FILE} ({exc}). An ephemeral key changes on every restart, "
f"which makes all encrypted secrets (managed SSH key, SMTP/OIDC/LDAP "
f"credentials) unrecoverable. Fix one of:\n"
f" • set STEWARD_SECRET_KEY to a stable value "
f"(recommended for Swarm / multi-node), or\n"
f" • make {_SECRET_KEY_FILE.parent} writable by the container user "
f"(uid 1000)."
) from exc
logger.info("Generated new secret key and saved to %s", _SECRET_KEY_FILE)
return key
+20 -2
View File
@@ -6,7 +6,6 @@ from typing import TYPE_CHECKING
from sqlalchemy import delete
from steward.models.monitors import MonitorResult
from steward.models.metrics import PluginMetric
from steward.models.ansible import AnsibleRun
if TYPE_CHECKING:
@@ -26,7 +25,6 @@ async def run_cleanup(app: "Quart") -> None:
async with session.begin():
for model, ts_col in [
(MonitorResult, MonitorResult.checked_at),
(PluginMetric, PluginMetric.recorded_at),
(AnsibleRun, AnsibleRun.started_at),
]:
result = await session.execute(
@@ -35,9 +33,29 @@ async def run_cleanup(app: "Quart") -> None:
if result.rowcount:
logger.info(f"Pruned {result.rowcount} rows from {model.__tablename__}")
# plugin_metrics is NOT blanket-deleted here — it's rolled up to hourly
# then pruned, so multi-week host history stays cheap.
await _run_metrics_retention(session, now)
await _run_docker_retention(session, now)
async def _run_metrics_retention(session, now: datetime) -> None:
"""Roll up + prune plugin_metrics (raw → hourly → gone). Windows read fresh
from settings each run (rule 25 — UI change takes effect next cleanup, no
restart). get_setting's SELECT autobegins, so read inside the begin block."""
from steward.core.metrics_retention import rollup_plugin_metrics
from steward.core.settings import get_setting
async with session.begin():
raw_days = int(await get_setting(session, "metrics.retention.raw_days") or 7)
rollup_days = int(await get_setting(session, "metrics.retention.rollup_days") or 90)
counts = await rollup_plugin_metrics(
session, raw_days=raw_days, rollup_days=rollup_days, now=now,
)
if counts and any(counts.values()):
logger.info("Metrics retention: %s", counts)
async def _run_docker_retention(session, now: datetime) -> None:
"""Drive the docker plugin's rollup + prune via its capability, if loaded.
+112
View File
@@ -0,0 +1,112 @@
"""Bound plugin_metrics growth: roll old raw samples up to hourly, prune the rest.
plugin_metrics grows by (sources × resources × sample cadence) — host agents push
host-level + per-core/mount/iface sub-resources every ~30s, so a fleet accrues
millions of rows. We keep raw samples for a short window, aggregate everything
older into hourly averages (plugin_metrics_hourly) and delete the raw rows, then
prune hourly beyond a longer window. Charts read raw for the recent part of a
range and hourly for the older part.
Driven by the core cleanup task (steward.core.cleanup). Runs inside the caller's
open transaction; never opens or commits its own.
"""
from __future__ import annotations
from datetime import datetime, timedelta
def _hour_floor(dt: datetime) -> datetime:
"""Truncate a datetime down to the start of its hour (drops min/sec/µs)."""
return dt.replace(minute=0, second=0, microsecond=0)
def _rollup_cutoff(now: datetime, raw_days: int) -> datetime:
"""Hour-aligned boundary below which raw metrics get rolled up + deleted.
Aligning to the hour means we only roll up *whole* elapsed hours — a bucket
is never split across the keep/roll boundary, so a re-run can't produce a
partial-then-complete duplicate for the same hour.
"""
return _hour_floor(now - timedelta(days=raw_days))
async def rollup_plugin_metrics(
session,
*,
raw_days: int,
rollup_days: int,
now: datetime | None = None,
) -> dict:
"""Roll up + prune plugin_metrics. Returns a counts dict for logging.
1. Aggregate plugin_metrics older than the (hour-aligned) raw window into
plugin_metrics_hourly (avg/max per source/resource/metric/hour), upserting
so a re-run is idempotent, then delete those raw rows.
2. Prune rolled-up rows older than the rollup window.
"""
from datetime import timezone
from sqlalchemy import delete, func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from steward.models.metrics import PluginMetric, PluginMetricHourly
if now is None:
now = datetime.now(timezone.utc)
rolled = rolled_rows = rollup_pruned = 0
# ── 1. Roll up raw metrics older than the raw window into hourly buckets ──
raw_cutoff = _rollup_cutoff(now, raw_days)
hour = func.date_trunc("hour", PluginMetric.recorded_at)
agg = (
select(
PluginMetric.source_module,
PluginMetric.resource_name,
PluginMetric.metric_name,
hour.label("bucket"),
func.avg(PluginMetric.value).label("value_avg"),
func.max(PluginMetric.value).label("value_max"),
func.count().label("sample_count"),
)
.where(PluginMetric.recorded_at < raw_cutoff)
.group_by(
PluginMetric.source_module, PluginMetric.resource_name,
PluginMetric.metric_name, hour,
)
)
for r in (await session.execute(agg)).all():
avg_v = float(r.value_avg or 0.0)
max_v = float(r.value_max or 0.0)
cnt = int(r.sample_count or 0)
await session.execute(
pg_insert(PluginMetricHourly)
.values(
source_module=r.source_module, resource_name=r.resource_name,
metric_name=r.metric_name, bucket=r.bucket,
value_avg=avg_v, value_max=max_v, sample_count=cnt,
)
.on_conflict_do_update(
constraint="uq_plugin_metrics_hourly_bucket",
set_={"value_avg": avg_v, "value_max": max_v, "sample_count": cnt},
)
)
rolled += 1
rolled_rows += cnt
if rolled:
await session.execute(
delete(PluginMetric).where(PluginMetric.recorded_at < raw_cutoff)
)
# ── 2. Prune rolled-up rows beyond the rollup window ──
rollup_cutoff = now - timedelta(days=rollup_days)
res = await session.execute(
delete(PluginMetricHourly).where(PluginMetricHourly.bucket < rollup_cutoff)
)
rollup_pruned = res.rowcount or 0
return {
"buckets_rolled": rolled,
"raw_rows_rolled": rolled_rows,
"rollup_pruned": rollup_pruned,
}
+42
View File
@@ -24,12 +24,52 @@ _LOADED_PLUGINS: set[str] = set()
# Track plugins that failed to load: name → human-readable reason.
_FAILED_PLUGINS: dict[str, str] = {}
# Nav entries contributed by plugins via the optional get_nav() export, so a
# plugin's UI gets a home in the sidebar. Each entry:
# {"plugin": <name>, "label": str, "href": str, "section": str}
_PLUGIN_NAV: list[dict] = []
def get_plugin_failures() -> dict[str, str]:
"""Return a copy of the failed-plugin registry (name → error message)."""
return dict(_FAILED_PLUGINS)
def get_plugin_nav() -> list[dict]:
"""Plugin-contributed sidebar entries, sorted by section then label."""
return sorted(_PLUGIN_NAV, key=lambda e: (e.get("section", ""), e.get("label", "")))
def _collect_plugin_nav(name: str, module) -> None:
"""Pull a plugin's optional get_nav() entries into the nav registry.
Tolerant by design: a missing hook is fine, and a raising or malformed hook
is logged and skipped — a bad nav contribution must never break loading.
Idempotent per plugin (drops prior entries first) so hot-reload re-runs cleanly.
"""
global _PLUGIN_NAV
_PLUGIN_NAV = [e for e in _PLUGIN_NAV if e.get("plugin") != name]
if not hasattr(module, "get_nav"):
return
try:
items = module.get_nav() or []
except Exception:
logger.exception("Plugin %r: get_nav() raised, skipping its nav entries", name)
return
for item in items:
try:
label, href = str(item["label"]), str(item["href"])
except (TypeError, KeyError):
logger.warning("Plugin %r: malformed nav item %r, skipping", name, item)
continue
_PLUGIN_NAV.append({
"plugin": name,
"label": label,
"href": href,
"section": str(item.get("section", "Infrastructure")),
})
def resolve_plugin_path(plugin_dirs: list[Path], name: str) -> Path | None:
"""Return the first plugin root that contains `name`, else None.
@@ -222,6 +262,7 @@ def load_plugins(app: "Quart") -> None:
_FAILED_PLUGINS.pop(name, None) # clear any stale failure from a previous reload attempt
_LOADED_PLUGINS.add(name)
_collect_plugin_nav(name, module)
logger.info("Plugin %r loaded (v%s)", name, meta.get("version", "?"))
@@ -440,6 +481,7 @@ def hot_reload_plugin(app: "Quart", name: str) -> tuple[bool, str]:
return False, f"get_scheduled_tasks() raised: {exc}"
_LOADED_PLUGINS.add(name)
_collect_plugin_nav(name, module)
logger.info("Plugin %r hot-reloaded (v%s)", name, meta.get("version", "?"))
return True, f"Plugin activated (v{meta.get('version', '?')})"
+47 -10
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Callable, Coroutine
logger = logging.getLogger(__name__)
@@ -15,28 +15,65 @@ class ScheduledTask:
run_on_startup: bool = False
@dataclass
class _DueTracker:
"""Decides which scheduled tasks are due, with a self-overlap guard.
Pure (no asyncio, no clock of its own — `now` is passed in) so the
scheduling policy is unit-testable without timing races. A task whose prior
run is still in flight is NOT re-fired: overlapping poll runs stack up open
connections/subprocesses and amplify any per-poll resource use — the same
failure mode behind the fd-leak lockups. The skipped task is retried on the
next tick once it completes (its last_run isn't advanced while skipped).
"""
last_run: dict[str, float] = field(default_factory=dict)
in_flight: set[str] = field(default_factory=set)
def due(self, tasks: list[ScheduledTask], now: float) -> list[ScheduledTask]:
ready: list[ScheduledTask] = []
for task in tasks:
if now - self.last_run.get(task.name, 0) < task.interval_seconds:
continue
if task.name in self.in_flight:
logger.warning(
"Scheduled task %r still running — skipping this tick",
task.name)
continue
ready.append(task)
return ready
def mark_started(self, task: ScheduledTask, now: float) -> None:
self.in_flight.add(task.name)
self.last_run[task.name] = now
def mark_done(self, name: str) -> None:
self.in_flight.discard(name)
async def start_scheduler(tasks: list[ScheduledTask]) -> None:
"""Run scheduled tasks in a loop. Call with asyncio.create_task()."""
last_run: dict[str, float] = {}
tracker = _DueTracker()
def _spawn(task: ScheduledTask, now: float) -> None:
tracker.mark_started(task, now)
asyncio.create_task(_run_task(task, tracker))
for task in tasks:
if task.run_on_startup:
logger.info(f"Startup task: {task.name}")
asyncio.create_task(_run_task(task))
last_run[task.name] = asyncio.get_event_loop().time()
_spawn(task, asyncio.get_event_loop().time())
while True:
now = asyncio.get_event_loop().time()
for task in tasks:
last = last_run.get(task.name, 0)
if now - last >= task.interval_seconds:
asyncio.create_task(_run_task(task))
last_run[task.name] = now
for task in tracker.due(tasks, now):
_spawn(task, now)
await asyncio.sleep(1)
async def _run_task(task: ScheduledTask) -> None:
async def _run_task(task: ScheduledTask, tracker: _DueTracker) -> None:
try:
await task.coro_factory()
except Exception:
logger.exception(f"Scheduled task {task.name!r} raised an exception")
finally:
tracker.mark_done(task.name)
+116
View File
@@ -0,0 +1,116 @@
"""Self-monitoring: record Steward's own open file-descriptor usage.
A leaked socket/file handle in a poll loop (see the SNMP and UniFi fd-leak
issues) used to fail silently until the process hit its fd ceiling and
`socket.accept()` started raising `OSError: [Errno 24] Too many open files` —
taking the whole app down with no early warning.
This turns that failure mode into an observable signal. Steward monitors other
things; it should monitor itself. We record two metrics each tick under
`source_module="steward"`:
• ``open_fds`` — raw count of open descriptors
• ``open_fds_pct`` — that count as a percentage of the soft RLIMIT_NOFILE
Both flow through the normal alert pipeline, so the operator can attach an alert
rule to either via the existing alert-rules UI. As a zero-config floor we also
log a WARNING once usage crosses ``FD_WARN_PCT`` — a leak becomes visible even
before any rule is set up.
"""
from __future__ import annotations
import logging
import os
logger = logging.getLogger(__name__)
# Stdlib-only fd counting via /proc keeps this dependency-free. resource is
# POSIX-only but always present on the Linux runtime image; guard anyway so
# imports never explode on a dev machine.
try:
import resource
except ImportError: # pragma: no cover - non-POSIX
resource = None # type: ignore[assignment]
# Warn (without needing a configured alert rule) once we're using this fraction
# of the soft fd limit. 80% leaves headroom to act before accepts start failing.
FD_WARN_PCT = 80.0
_warned = False # de-dupe the WARNING so a sustained leak doesn't spam the log
def count_open_fds() -> int | None:
"""Number of open file descriptors for this process, or None if unknown.
Reads ``/proc/self/fd`` (Linux). Returns None where /proc isn't available
(e.g. a macOS dev box) so callers degrade to a no-op rather than guessing.
"""
try:
return len(os.listdir("/proc/self/fd"))
except OSError:
return None
def fd_soft_limit() -> int | None:
"""Soft RLIMIT_NOFILE for this process, or None if it can't be read.
None when the limit is unknown or 'unlimited' (RLIM_INFINITY) — a percentage
against an unbounded ceiling is meaningless, so we skip the pct metric then.
"""
if resource is None:
return None
try:
soft, _hard = resource.getrlimit(resource.RLIMIT_NOFILE)
except (ValueError, OSError):
return None
if soft <= 0 or soft == resource.RLIM_INFINITY:
return None
return soft
async def record_self_metrics(app) -> None:
"""Record open-fd usage as Steward's own metrics; warn past the floor.
No-op (logged at debug) when fd accounting isn't available on this platform,
so it's safe to schedule unconditionally.
"""
global _warned
fds = count_open_fds()
if fds is None:
logger.debug("self_monitor: /proc/self/fd unavailable — skipping fd metrics")
return
from .alerts import record_metric
soft = fd_soft_limit()
pct = (fds / soft * 100.0) if soft else None
async with app.db_sessionmaker() as session:
async with session.begin():
await record_metric(
session=session,
source_module="steward",
resource_name="process",
metric_name="open_fds",
value=float(fds),
)
if pct is not None:
await record_metric(
session=session,
source_module="steward",
resource_name="process",
metric_name="open_fds_pct",
value=pct,
)
if pct is not None and pct >= FD_WARN_PCT:
if not _warned:
logger.warning(
"Open file descriptors at %.0f%% of the soft limit (%d/%d) — "
"possible descriptor leak; the app will stop accepting "
"connections if this reaches 100%%.", pct, fds, soft)
_warned = True
else:
_warned = False # recovered — re-arm the warning for the next breach
logger.debug("self_monitor: open_fds=%d soft_limit=%s pct=%s", fds, soft, pct)
+69
View File
@@ -84,6 +84,10 @@ DEFAULTS: dict[str, Any] = {
"docker.retention.metrics_raw_days": 7,
"docker.retention.metrics_rollup_days": 90,
"docker.retention.events_days": 30,
# Host/plugin metrics retention (plugin_metrics): keep a short raw window at
# the agent's ~30s cadence, then roll up to hourly averages kept much longer.
"metrics.retention.raw_days": 7,
"metrics.retention.rollup_days": 90,
"plugins.index_url": "https://git.fabledsword.com/bvandeusen/Steward-plugins/raw/branch/main/index.yaml",
# Default-enabled plugins. These are the generic, non-vendor-specific
# bundled plugins (protocols/standards, not a single product) — useful on
@@ -150,6 +154,34 @@ def _decode(value: Any, key: str = "") -> Any:
return decrypt_secret(value, context=key) if is_encrypted(value) else value
# Secret settings that are stored as ciphertext but won't decrypt with the
# current app key (key rotated or lost). Surfaced as an admin banner so the
# operator knows precisely which secrets to re-enter — instead of finding out
# only when something that uses one fails. Refreshed at startup
# (scan_undecryptable_secrets) and kept live: re-entering a secret clears it
# without a restart (set_setting discards it on a fresh write).
_undecryptable_secrets: set[str] = set()
def get_undecryptable_secrets() -> list[str]:
"""Sorted keys whose stored ciphertext won't decrypt (for the UI banner)."""
return sorted(_undecryptable_secrets)
def _is_undecryptable(stored: Any, key: str = "") -> bool:
"""True iff `stored` is an encrypted token that fails to decrypt.
A failed decrypt returns the ciphertext unchanged (still enc-prefixed), so a
value that is still encrypted after a decrypt attempt is undecryptable.
"""
from steward.core.crypto import decrypt_secret, is_encrypted
return (
isinstance(stored, str)
and is_encrypted(stored)
and is_encrypted(decrypt_secret(stored, context=key))
)
# ─────────────────────────────────────────────────────────────────────────────
# Async helpers (use inside request handlers / scheduled tasks)
# ─────────────────────────────────────────────────────────────────────────────
@@ -182,6 +214,12 @@ async def set_setting(session: AsyncSession, key: str, value: Any) -> None:
row.value_json = json.dumps(to_store)
row.updated_at = now
# A fresh write of a secret is encrypted with the current key (or cleared to
# plaintext), so it's decryptable now — drop any stale "undecryptable" flag
# so the banner clears without a restart.
if key in SECRET_KEYS:
_undecryptable_secrets.discard(key)
async def get_all_settings(session: AsyncSession) -> dict[str, Any]:
"""Return flat key→value dict with defaults filled in for missing keys."""
@@ -359,6 +397,37 @@ def migrate_plaintext_secrets(db_url: str) -> int:
return asyncio.run(_run())
def scan_undecryptable_secrets(db_url: str) -> list[str]:
"""Populate the undecryptable-secrets cache from the DB; return the keys found.
Run once at startup, AFTER migrate_plaintext_secrets and init_crypto: any row
that's encrypted but won't decrypt with the current key was sealed under a
different (lost/rotated) key and must be re-entered. Surfaced via the admin
banner (get_undecryptable_secrets).
"""
async def _run() -> list[str]:
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
engine = create_async_engine(db_url, echo=False)
factory = async_sessionmaker(engine, expire_on_commit=False)
bad: list[str] = []
try:
async with factory() as session:
result = await session.execute(
select(AppSetting).where(AppSetting.key.in_(SECRET_KEYS))
)
for row in result.scalars():
if _is_undecryptable(json.loads(row.value_json), row.key):
bad.append(row.key)
finally:
await engine.dispose()
return bad
global _undecryptable_secrets
found = asyncio.run(_run())
_undecryptable_secrets = set(found)
return sorted(found)
# ─────────────────────────────────────────────────────────────────────────────
# External URL helper
# ─────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,39 @@
"""Index plugin_metrics for host/dashboard reads
plugin_metrics had only a PK on id, so every host-detail / full-metrics /
dashboard-widget query (all filter by source_module + resource_name over a
recorded_at range) sequentially scanned the whole time-series table — slower as
samples accumulate. Add the two composite indexes that match those query shapes.
The non-concurrent CREATE INDEX takes a brief exclusive lock; it runs once at
startup migration time, acceptable for this table.
Revision ID: 0023_plugin_metrics_indexes
Revises: 0022_unify_monitors
Create Date: 2026-06-20
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0023_plugin_metrics_indexes"
down_revision: Union[str, None] = "0022_unify_monitors"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
"ix_plugin_metrics_module_resource_recorded",
"plugin_metrics",
["source_module", "resource_name", "recorded_at"],
)
op.create_index(
"ix_plugin_metrics_module_resource_metric_recorded",
"plugin_metrics",
["source_module", "resource_name", "metric_name", "recorded_at"],
)
def downgrade() -> None:
op.drop_index("ix_plugin_metrics_module_resource_metric_recorded", "plugin_metrics")
op.drop_index("ix_plugin_metrics_module_resource_recorded", "plugin_metrics")
@@ -0,0 +1,43 @@
"""Hourly rollup table for plugin_metrics
Adds plugin_metrics_hourly — the coarse series that retention rolls raw
plugin_metrics into before pruning them, so multi-day/week host history stays
cheap to store. One row per (source_module, resource_name, metric_name, hour);
the unique constraint is the conflict target for the idempotent rollup upsert.
Revision ID: 0024_plugin_metrics_hourly
Revises: 0023_plugin_metrics_indexes
Create Date: 2026-06-20
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0024_plugin_metrics_hourly"
down_revision: Union[str, None] = "0023_plugin_metrics_indexes"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"plugin_metrics_hourly",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("source_module", sa.String(length=64), nullable=False),
sa.Column("resource_name", sa.String(length=255), nullable=False),
sa.Column("metric_name", sa.String(length=128), nullable=False),
sa.Column("bucket", sa.DateTime(timezone=True), nullable=False),
sa.Column("value_avg", sa.Float(), nullable=False, server_default="0"),
sa.Column("value_max", sa.Float(), nullable=False, server_default="0"),
sa.Column("sample_count", sa.Integer(), nullable=False, server_default="0"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("source_module", "resource_name", "metric_name", "bucket",
name="uq_plugin_metrics_hourly_bucket"),
)
op.create_index("ix_plugin_metrics_hourly_lookup", "plugin_metrics_hourly",
["source_module", "resource_name", "metric_name", "bucket"])
def downgrade() -> None:
op.drop_index("ix_plugin_metrics_hourly_lookup", table_name="plugin_metrics_hourly")
op.drop_table("plugin_metrics_hourly")
+39 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, String
from sqlalchemy import DateTime, Float, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -17,3 +17,41 @@ class PluginMetric(Base):
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
# This time-series table grows by (sources × resources × sample cadence); every
# host-detail / full-metrics / dashboard-widget read filters by
# (source_module, resource_name) over a recorded_at range. Without these it's a
# full sequential scan on every load.
__table_args__ = (
Index("ix_plugin_metrics_module_resource_recorded",
"source_module", "resource_name", "recorded_at"),
Index("ix_plugin_metrics_module_resource_metric_recorded",
"source_module", "resource_name", "metric_name", "recorded_at"),
)
class PluginMetricHourly(Base):
"""Hourly rollup of plugin_metrics — the coarse series that retention rolls
raw samples into before pruning them, so multi-day/week history stays cheap.
One row per (source_module, resource_name, metric_name, hour bucket); the
unique constraint is the conflict target for the idempotent rollup upsert.
Charts read this for the part of a range older than the raw-retention window.
"""
__tablename__ = "plugin_metrics_hourly"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
source_module: Mapped[str] = mapped_column(String(64), nullable=False)
resource_name: Mapped[str] = mapped_column(String(255), nullable=False)
metric_name: Mapped[str] = mapped_column(String(128), nullable=False)
bucket: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
value_avg: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
value_max: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
sample_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
__table_args__ = (
UniqueConstraint("source_module", "resource_name", "metric_name", "bucket",
name="uq_plugin_metrics_hourly_bucket"),
Index("ix_plugin_metrics_hourly_lookup",
"source_module", "resource_name", "metric_name", "bucket"),
)
+2
View File
@@ -133,6 +133,8 @@ _RETENTION_FIELDS = [
("docker_metrics_raw_days", "docker.retention.metrics_raw_days"),
("docker_metrics_rollup_days", "docker.retention.metrics_rollup_days"),
("docker_events_days", "docker.retention.events_days"),
("metrics_raw_days", "metrics.retention.raw_days"),
("metrics_rollup_days", "metrics.retention.rollup_days"),
]
@@ -6,7 +6,6 @@
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Maintenance Windows</h1>
<div style="display:flex;gap:0.5rem;">
<a class="btn btn-ghost" href="/alerts/">← Alerts</a>
<a class="btn" href="/alerts/maintenance/new">New Window</a>
</div>
</div>
@@ -1,6 +1,7 @@
{# <option> list for a playbook <select>, populated when a source is chosen.
Returned by /ansible/playbook-options. `selected` pre-selects one (edit). #}
<option value="">— choose playbook —</option>
Returned by /ansible/playbook-options. `selected` pre-selects one; with none
given the browser selects the first, so the dropdown never sits on a
placeholder. #}
{% for pb in playbooks %}
<option value="{{ pb }}" {% if pb == selected %}selected{% endif %}>{{ pb }}</option>
{% endfor %}
+16 -2
View File
@@ -1,6 +1,11 @@
{# Description + discovered variable fields for a selected playbook. Shared by
the browse run form, host run form, and schedule form. Expects `variables`
and `description`. Rendered standalone by /ansible/playbook-vars. #}
and `description`. Rendered standalone by /ansible/playbook-vars.
Optional: `values` (dict var-name→saved value, prefills fields for edit
forms) and `schedule` (True in the schedule form — schedules can't prompt,
so secret vars are shown disabled and the confirm gate isn't `required`). #}
{% set _values = values|default({}) %}
{% set _schedule = schedule|default(false) %}
{% if description or category %}
<div style="background:var(--bg);border-left:3px solid var(--accent);border-radius:3px;
padding:0.5rem 0.7rem;margin:0.25rem 0 0.6rem;font-size:0.82rem;color:var(--text-muted);">
@@ -13,7 +18,7 @@
<label style="display:flex;align-items:flex-start;gap:0.5rem;background:color-mix(in srgb,var(--red) 10%,var(--bg-elevated));
border:1px solid color-mix(in srgb,var(--red) 35%,var(--border));border-radius:6px;
padding:0.6rem 0.8rem;margin:0 0 0.6rem;font-size:0.82rem;font-weight:normal;cursor:pointer;">
<input type="checkbox" name="confirmed" required style="margin-top:0.15rem;">
<input type="checkbox" name="confirmed" {% if not _schedule %}required{% endif %} style="margin-top:0.15rem;">
<span><strong style="color:var(--red);">Confirm:</strong> this playbook is marked as making
significant or destructive changes. Tick to enable running it.</span>
</label>
@@ -33,13 +38,22 @@
({{ v.name }}{% if v.required %}, required{% endif %}{% if v.secret %}, secret{% endif %})
</span>
</label>
{% if v.secret and _schedule %}
{# Scheduled runs can't prompt, so secret vars are dropped downstream — show
the field disabled rather than inviting silent data loss. #}
<input type="text" disabled
placeholder="secrets can't be scheduled — set via inventory / global creds"
style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;opacity:0.55;">
{% else %}
{% if v.secret %}<input type="hidden" name="secret__{{ v.name }}" value="1">{% endif %}
<input type="{{ 'password' if v.secret else 'text' }}"
name="var__{{ v.name }}"
{% if not v.secret %}value="{{ _values.get(v.name, '') }}"{% endif %}
{% if v.required %}required{% endif %}
{% if v.secret %}autocomplete="new-password"{% endif %}
placeholder="{% if v.secret %}(hidden){% elif v.default not in (None, '') %}default: {{ v.default }}{% else %}no default{% endif %}"
style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;">
{% endif %}
</div>
{% endfor %}
{% else %}
-2
View File
@@ -17,7 +17,6 @@
{% endif %}
<a href="/ansible/inventory/targets" class="btn btn-ghost btn-sm">Inventory</a>
<a href="/ansible/schedules" class="btn btn-ghost btn-sm">Schedules</a>
<a href="/ansible/" class="btn btn-ghost btn-sm">← Run History</a>
</div>
</div>
@@ -35,7 +34,6 @@
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
{% endif %}
<a href="/ansible/browse" class="btn btn-ghost btn-sm">← Back to browse</a>
</div>
</div>
<pre style="background:var(--bg);padding:1rem;border-radius:4px;overflow-x:auto;font-size:0.8rem;color:var(--text-muted);max-height:600px;overflow-y:auto;">{{ view_contents }}</pre>
@@ -5,7 +5,6 @@
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Group: {{ group.name }}</h1>
<a href="/ansible/inventory/groups" class="btn btn-ghost btn-sm">← Groups</a>
</div>
<form method="post" action="/ansible/inventory/groups/{{ group.id }}">
@@ -7,7 +7,6 @@
<h1 class="page-title" style="margin-bottom:0;">Inventory Groups</h1>
<div style="display:flex;gap:0.5rem;">
<a href="/ansible/inventory/targets" class="btn btn-ghost btn-sm">Targets</a>
<a href="/ansible/" class="btn btn-ghost btn-sm">← Ansible</a>
</div>
</div>
@@ -5,7 +5,6 @@
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Target: {{ target.name }}</h1>
<a href="/ansible/inventory/targets" class="btn btn-ghost btn-sm">← Targets</a>
</div>
<form method="post" action="/ansible/inventory/targets/{{ target.id }}">
@@ -7,7 +7,6 @@
<h1 class="page-title" style="margin-bottom:0;">Inventory Targets</h1>
<div style="display:flex;gap:0.5rem;">
<a href="/ansible/inventory/groups" class="btn btn-ghost btn-sm">Groups</a>
<a href="/ansible/" class="btn btn-ghost btn-sm">← Ansible</a>
</div>
</div>
@@ -5,7 +5,6 @@
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.25rem;gap:1rem;flex-wrap:wrap;">
<h1 class="page-title" style="margin-bottom:0;">{{ "Edit playbook" if editing else "New playbook" }}</h1>
<a href="/ansible/browse" class="btn btn-ghost btn-sm">← Browse</a>
</div>
{% if error %}<div class="alert alert-error">{{ error }}</div>{% endif %}
@@ -4,7 +4,6 @@
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Run " ~ run.id[:8], "")]) }}{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">
<a href="/ansible/" class="btn btn-ghost btn-sm">← Runs</a>
<h1 class="page-title" style="margin-bottom:0;">Run Detail</h1>
<div style="margin-left:auto;display:flex;gap:0.5rem;">
{% if run.status.value in ("running", "queued") and session.user_role in ("operator", "admin") %}
+33 -11
View File
@@ -14,7 +14,6 @@
<div style="display:flex;gap:0.5rem;">
<a href="/ansible/inventory/targets" class="btn btn-ghost">Inventory</a>
<a href="/ansible/browse" class="btn btn-ghost">Browse</a>
<a href="/ansible/" class="btn btn-ghost">← Runs</a>
</div>
</div>
@@ -76,6 +75,8 @@
{# ── Create / edit form ───────────────────────────────────────────────────── #}
{% set p = (editing.params or {}) if editing else {} %}
{% set saved_vars = p.get('extra_vars_map') or {} %}
{% set declared_names = (edit_variables or []) | map(attribute='name') | list %}
<div class="card" id="form">
<h2 class="section-title" style="margin-bottom:1rem;">{{ "Edit schedule" if editing else "New schedule" }}</h2>
<form method="post" action="{{ '/ansible/schedules/' ~ editing.id if editing else '/ansible/schedules' }}">
@@ -88,18 +89,21 @@
<label>Source</label>
<select name="source_name" required
hx-get="/ansible/playbook-options" hx-trigger="change"
hx-target="#sch-playbook" hx-swap="innerHTML" hx-include="this">
<option value="">— choose source —</option>
hx-target="#sch-playbook" hx-swap="innerHTML" hx-include="this"
hx-on::after-settle="htmx.trigger('#sch-playbook','change')">
{% for sd in source_data %}
<option value="{{ sd.name }}" {% if editing and editing.source_name == sd.name %}selected{% endif %}>{{ sd.name }}</option>
<option value="{{ sd.name }}" {% if sd.name == sel_source %}selected{% endif %}>{{ sd.name }}</option>
{% endfor %}
{% if not source_data %}<option value="" disabled>No Ansible sources — add one in Settings</option>{% endif %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Playbook</label>
<select name="playbook_path" id="sch-playbook" required>
<option value="">— choose playbook —</option>
{% for pb in all_playbooks %}
<select name="playbook_path" id="sch-playbook" required
hx-get="/ansible/playbook-vars?schedule=1"
hx-trigger="{% if not editing %}load, {% endif %}change"
hx-target="#sch-playbook-vars" hx-swap="innerHTML" hx-include="closest form">
{% for pb in sel_playbooks %}
<option value="{{ pb }}" {% if editing and editing.playbook_path == pb %}selected{% endif %}>{{ pb }}</option>
{% endfor %}
</select>
@@ -133,11 +137,29 @@
<input type="text" name="tags" value="{{ p.get('tags', '') }}" placeholder="tag1,tag2">
</div>
</div>
<div class="form-group" style="margin-top:1rem;">
<label>Extra vars (one key=value per line)</label>
<textarea name="extra_vars" rows="3" placeholder="prune_volumes=false">{% for k, v in (p.get('extra_vars_map') or {}).items() %}{{ k }}={{ v }}
{% endfor %}</textarea>
{# Declared playbook variables (vars:/vars_prompt:) — loaded via HTMX on
playbook change; pre-rendered here in edit mode so saved values show. #}
<div id="sch-playbook-vars" style="margin-top:1rem;">
{% if editing %}
{% set variables = edit_variables %}
{% set description = edit_description %}
{% set category = edit_category %}
{% set confirm = edit_confirm %}
{% set values = saved_vars %}
{% set schedule = true %}
{% include "ansible/_playbook_vars.html" %}
{% endif %}
</div>
<details style="margin-top:0.75rem;">
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);">Extra vars</summary>
<div class="form-group" style="margin-top:0.6rem;">
<label>Extra vars <span style="color:var(--text-muted);font-weight:normal;">(one key=value per line — for vars not listed above)</span></label>
<textarea name="extra_vars" rows="3" placeholder="prune_volumes=false">{% for k, v in saved_vars.items() if k not in declared_names %}{{ k }}={{ v }}
{% endfor %}</textarea>
</div>
</details>
<div class="form-group" style="display:flex;align-items:center;gap:0.5rem;">
<input type="checkbox" name="check" id="check" style="width:auto;" {% if p.get('check') %}checked{% endif %}>
<label for="check" style="margin-bottom:0;">Dry run (--check --diff)</label>
+168 -40
View File
@@ -51,37 +51,90 @@ code { font-family: ui-monospace, monospace; font-size: 0.875em; background: var
radial-gradient(ellipse 50% 40% at 20% 80%, rgba(200, 168, 64, 0.025) 0%, transparent 65%);
}
/* Nav */
nav {
background: var(--bg-card);
padding: 0 1.5rem;
display: flex;
align-items: center;
gap: 0;
border-bottom: 1px solid var(--border-mid);
height: 48px;
position: relative;
z-index: 10;
/* ── App shell: persistent left sidebar + content column ───────────────────── */
.app-shell { display: flex; min-height: 100vh; position: relative; z-index: 1; }
.sidebar {
width: 220px; flex-shrink: 0; background: var(--bg-card);
border-right: 1px solid var(--border-mid);
display: flex; flex-direction: column;
position: sticky; top: 0; height: 100vh; z-index: 20;
}
nav .brand {
font-family: var(--font-serif);
font-weight: 700;
font-size: 1.1rem;
margin-right: 2rem;
letter-spacing: 0.01em;
display: flex;
align-items: center;
gap: 0.45rem;
color: var(--gold);
text-decoration: none;
.sidebar .brand {
font-family: var(--font-serif); font-weight: 700; font-size: 1.15rem;
letter-spacing: 0.01em; color: var(--gold); text-decoration: none;
display: flex; align-items: center; gap: 0.5rem;
padding: 0.85rem 1.25rem; border-bottom: 1px solid var(--border);
}
nav .brand svg { flex-shrink: 0; }
nav a { color: var(--text-muted); font-size: 0.875rem; padding: 0 0.875rem; height: 48px; display: flex; align-items: center; border-bottom: 2px solid transparent; transition: color .15s, border-color .15s; }
nav a:hover { color: var(--text); border-bottom-color: var(--border-mid); }
nav a.nav-end { margin-left: auto; }
.sidebar .brand svg { flex-shrink: 0; }
/* Only the nav list scrolls when long — the brand and user/logout stay pinned. */
.side-nav { flex: 1; min-height: 0; overflow-y: auto; padding: 0.75rem 0; }
.nav-group { margin-bottom: 0.9rem; }
.nav-group-label {
font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.09em;
color: var(--text-dim); font-weight: 600; padding: 0 1.25rem; margin-bottom: 0.3rem;
}
.side-nav a {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.4rem 1.25rem; font-size: 0.9rem; color: var(--text-muted);
border-left: 2px solid transparent; text-decoration: none;
transition: color .12s, background .12s, border-color .12s;
}
.side-nav a:hover { color: var(--text); background: var(--bg-elevated); }
.side-nav a.active {
color: var(--gold); border-left-color: var(--gold);
background: color-mix(in srgb, var(--gold) 8%, transparent);
}
.side-user { border-top: 1px solid var(--border); padding: 0.7rem 1.25rem; }
.side-user a {
color: var(--text-muted); font-size: 0.84rem; text-decoration: none;
display: flex; align-items: center; gap: 0.4rem;
}
.side-user a:hover { color: var(--text); }
/* Keyboard focus: a clear inset ring on every interactive sidebar element. */
.sidebar .brand:focus-visible,
.side-nav a:focus-visible,
.side-user a:focus-visible {
outline: 2px solid var(--accent); outline-offset: -2px; color: var(--text);
}
.app-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar { display: none; } /* slim mobile bar; shown only under 900px */
.nav-scrim { display: none; }
/* Layout */
main { padding: 1.5rem 2rem; max-width: 1600px; margin: 0 auto; width: 100%; box-sizing: border-box; position: relative; z-index: 1; }
main { padding: 1.5rem 2rem; max-width: 1400px; margin: 0 auto; width: 100%; box-sizing: border-box; position: relative; z-index: 1; }
/* Responsive: sidebar slides off-canvas under 900px, toggled by ☰ */
@media (max-width: 900px) {
.sidebar {
position: fixed; left: 0; top: 0; bottom: 0; height: 100%;
transform: translateX(-100%); transition: transform .2s ease;
box-shadow: 0 0 30px rgba(0,0,0,0.5);
}
body.nav-open .sidebar { transform: translateX(0); }
.topbar {
display: flex; align-items: center; gap: 0.75rem; height: 48px; padding: 0 1rem;
background: var(--bg-card); border-bottom: 1px solid var(--border-mid);
position: sticky; top: 0; z-index: 15;
}
.sidebar-toggle {
background: none; border: none; color: var(--text-muted); font-size: 1.3rem;
cursor: pointer; line-height: 1; padding: 0.25rem; border-radius: 4px;
}
.sidebar-toggle:hover { color: var(--text); }
.sidebar-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.topbar .brand-sm {
font-family: var(--font-serif); color: var(--gold); font-weight: 700;
font-size: 1.05rem; text-decoration: none;
}
body.nav-open .nav-scrim {
display: block; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 18;
}
}
@media (prefers-reduced-motion: reduce) {
.sidebar { transition: none; }
.side-nav a { transition: none; }
}
/* Alerts */
.alert { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
@@ -224,8 +277,10 @@ body.dash-editing .widget-drawer { transform:translateY(0); }
<div id="candle-glow"></div>
{% set _p = request.path %}
<div class="app-shell">
{% if session.user_id is defined %}
<nav>
<aside class="sidebar" id="sidebar">
<a href="/" class="brand">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="12" cy="12" r="10.5" fill="none" stroke="#c8a840" stroke-width="1.5"/>
@@ -237,18 +292,48 @@ body.dash-editing .widget-drawer { transform:translateY(0); }
</svg>
Steward
</a>
<a href="/">Dashboard</a>
<a href="/status">Status</a>
<a href="/hosts/">Hosts</a>
<a href="/monitors/">Monitors</a>
<a href="/alerts/">Alerts</a>
<a href="/ansible/">Ansible</a>
{% if session.user_role == 'admin' %}
<a href="/settings/">Settings</a>
<a href="/audit/">Audit</a>
{% endif %}
<a href="/logout" class="nav-end">{{ session.username }}</a>
</nav>
<nav class="side-nav" aria-label="Primary">
<div class="nav-group">
<div class="nav-group-label">Overview</div>
<a href="/" {% if _p == '/' %}class="active" aria-current="page"{% endif %}>Dashboard</a>
<a href="/status" {% if _p.startswith('/status') %}class="active" aria-current="page"{% endif %}>Status</a>
</div>
<div class="nav-group">
<div class="nav-group-label">Infrastructure</div>
<a href="/hosts/" {% if _p.startswith('/hosts') %}class="active" aria-current="page"{% endif %}>Hosts</a>
{% for item in plugin_nav %}
<a href="{{ item.href }}" {% if _p.startswith(item.href.rstrip('/')) %}class="active" aria-current="page"{% endif %}>{{ item.label }}</a>
{% endfor %}
</div>
<div class="nav-group">
<div class="nav-group-label">Monitoring</div>
<a href="/monitors/" {% if _p.startswith('/monitors') %}class="active" aria-current="page"{% endif %}>Monitors</a>
<a href="/alerts/" {% if _p.startswith('/alerts') %}class="active" aria-current="page"{% endif %}>Alerts</a>
</div>
<div class="nav-group">
<div class="nav-group-label">Automation</div>
<a href="/ansible/" {% if _p.startswith('/ansible') %}class="active" aria-current="page"{% endif %}>Ansible</a>
</div>
{% if session.user_role == 'admin' %}
<div class="nav-group">
<div class="nav-group-label">Admin</div>
<a href="/settings/" {% if _p.startswith('/settings') %}class="active" aria-current="page"{% endif %}>Settings</a>
<a href="/audit/" {% if _p.startswith('/audit') %}class="active" aria-current="page"{% endif %}>Audit</a>
</div>
{% endif %}
</nav>
<div class="side-user">
<a href="/logout" title="Log out">⏻ {{ session.username }}</a>
</div>
</aside>
{% endif %}
<div class="app-main">
{% if session.user_id is defined %}
<div class="topbar">
<button class="sidebar-toggle" type="button" aria-label="Toggle navigation"
aria-controls="sidebar" aria-expanded="false" onclick="toggleNav(this)"></button>
<a href="/" class="brand-sm">Steward</a>
</div>
{% endif %}
<script>
@@ -263,6 +348,16 @@ function setTimeRange(val) {
history.replaceState({}, '', url);
document.body.dispatchEvent(new CustomEvent('rangeChange'));
}
// Mobile sidebar (off-canvas). Keep aria-expanded in sync; Escape closes it.
function setNavOpen(open) {
document.body.classList.toggle('nav-open', open);
var btn = document.querySelector('.sidebar-toggle');
if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function toggleNav(btn) { setNavOpen(!document.body.classList.contains('nav-open')); }
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && document.body.classList.contains('nav-open')) setNavOpen(false);
});
</script>
<main>
@@ -280,12 +375,45 @@ function setTimeRange(val) {
</span>
</div>
{% endif %}
{% if undecryptable_secrets and session.user_role == 'admin' %}
{% set _sec_tab = {
'smtp.password': '/settings/notifications/',
'oidc.client_secret': '/settings/auth/',
'ldap.bind_password': '/settings/auth/',
'ansible.ssh_private_key': '/settings/ansible/',
'ansible.become_password': '/settings/ansible/',
'ansible.vault_password': '/settings/ansible/',
} %}
<div style="background:color-mix(in srgb,var(--red) 12%,var(--bg-elevated));
border:1px solid color-mix(in srgb,var(--red) 35%,var(--border));
border-radius:6px;padding:0.6rem 1rem;margin-bottom:1rem;
font-size:0.84rem;display:flex;align-items:flex-start;gap:0.6rem;">
<span style="color:var(--red);flex-shrink:0;"></span>
<span>
<strong style="color:var(--text);">
{{ undecryptable_secrets | length }} stored secret{{ 's' if undecryptable_secrets | length != 1 }}
cant be decrypted
</strong>
— the app secret key changed, so {{ 'they' if undecryptable_secrets | length != 1 else 'it' }}
must be re-entered:
{% for k in undecryptable_secrets -%}
{%- if _sec_tab.get(k) %}<a href="{{ _sec_tab[k] }}" style="color:var(--accent);">{{ k }}</a>
{%- else %}<code>{{ k }}</code>{% endif %}{% if not loop.last %}, {% endif %}
{%- endfor %}.
</span>
</div>
{% endif %}
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% block breadcrumb %}{% endblock %}
{% block content %}{% endblock %}
</main>
</div>{# .app-main #}
</div>{# .app-shell #}
{% if session.user_id is defined %}
<div class="nav-scrim" onclick="setNavOpen(false)"></div>
{% endif %}
{% block extra_scripts %}{% endblock %}
+31 -18
View File
@@ -26,8 +26,17 @@
{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %}
{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %}
{# ── Live vitals strip (host_agent fragment) — full width on top, polled ────── #}
<div id="hv-strip"
hx-get="/plugins/host_agent/vitals/{{ host.id }}"
hx-trigger="load, every 15s"
hx-swap="innerHTML"></div>
{# ── Monitors + Agent, side by side on wide screens, stacked when narrow ────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:1rem;align-items:start;margin-bottom:1rem;">
{# ── Monitors ─────────────────────────────────────────────────────────────── #}
<div class="card">
<div class="card" style="margin-bottom:0;">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.5rem;">
<h3 class="section-title" style="margin-bottom:0;">Monitors</h3>
{% if uptime %}
@@ -114,20 +123,15 @@
</form>
</details>
{% endif %}
</div>{# Monitors card #}
{# ── Agent (host_agent management panel) ──────────────────────────────────── #}
<div hx-get="/plugins/host_agent/panel/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML">
<div class="card" style="margin-bottom:0;"><span style="color:var(--text-muted);font-size:0.85rem;">Loading agent…</span></div>
</div>
</div>{# Monitors + Agent grid #}
{# ── Agent (host_agent plugin fragment, embedded across the plugin boundary) ── #}
<div hx-get="/plugins/host_agent/panel/{{ host.id }}" hx-trigger="load"
hx-swap="innerHTML">
<div class="card"><span style="color:var(--text-muted);font-size:0.85rem;">Loading agent…</span></div>
</div>
{# ── Docker (docker plugin fragment; renders nothing if the host has none) ──── #}
{% if "docker" in enabled_plugins %}
<div hx-get="/plugins/docker/host/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML"></div>
{% endif %}
{# ── Ansible ──────────────────────────────────────────────────────────────── #}
{# ── Ansible (full width) ─────────────────────────────────────────────────── #}
<div class="card">
<h3 class="section-title">Ansible</h3>
{% if linked_target %}
@@ -157,9 +161,9 @@
<div class="form-group">
<label>Source</label>
<select name="source_name" required
hx-get="/ansible/playbook-options" hx-trigger="change"
hx-target="#hp-playbook" hx-swap="innerHTML" hx-include="this">
<option value="">— choose source —</option>
hx-get="/ansible/playbook-options" hx-trigger="load, change"
hx-target="#hp-playbook" hx-swap="innerHTML" hx-include="this"
hx-on::after-settle="htmx.trigger('#hp-playbook','change')">
{% for s in ansible_sources %}<option value="{{ s }}">{{ s }}</option>{% endfor %}
</select>
</div>
@@ -168,7 +172,6 @@
<select name="playbook_path" id="hp-playbook" required
hx-get="/ansible/playbook-vars" hx-trigger="change"
hx-target="#hp-vars" hx-swap="innerHTML" hx-include="closest form">
<option value="">— choose a source first —</option>
</select>
</div>
<div id="hp-vars"></div>
@@ -213,7 +216,17 @@
</form>
</div>
{% endif %}
</div>
</div>{# Ansible card #}
{# ── Docker (docker plugin fragment; renders nothing if the host has none) ──── #}
{% if "docker" in enabled_plugins %}
<div hx-get="/plugins/docker/host/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML"></div>
{% endif %}
{# ── SNMP (renders nothing unless a configured SNMP device maps to this host) ── #}
{% if "snmp" in enabled_plugins %}
<div hx-get="/plugins/snmp/host/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML"></div>
{% endif %}
{{ toggle_script() }}
<script>var _mt=document.getElementById('mtype'); if(_mt) mtoggle(_mt.value);</script>
+2 -1
View File
@@ -1,9 +1,10 @@
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% block title %}Uptime / SLA — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), ("Uptime / SLA", "")]) }}{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Uptime / SLA</h1>
<a class="btn btn-ghost" href="/hosts/">← Hosts</a>
</div>
{# ── Summary pills ──────────────────────────────────────────────────────────── #}
+3 -2
View File
@@ -1,10 +1,11 @@
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% from "monitors/_fields.html" import type_fields, toggle_script %}
{% block title %}Edit Monitor — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Monitors", "/monitors/"), ("Edit monitor", "")]) }}{% endblock %}
{% block content %}
<div style="margin-bottom:1.25rem;">
<a href="/monitors/" style="color:var(--text-muted);font-size:0.85rem;"> Monitors</a>
<h1 class="page-title" style="margin:0.4rem 0 0;">Edit Monitor</h1>
<h1 class="page-title" style="margin-bottom:0;">Edit Monitor</h1>
</div>
{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %}
+3 -2
View File
@@ -1,5 +1,6 @@
{# settings/_tabs.html — include at top of each settings section #}
<h1 class="page-title">Settings</h1>
{# settings/_tabs.html — include at top of each settings section.
No section <h1>: the breadcrumb kicker ("Settings …") names the section and
the tab strip is the visual header, so we don't stack a redundant title. #}
<div style="display:flex;gap:0;border-bottom:1px solid var(--border-mid);margin-bottom:1.5rem;">
{% set tabs = [
("general", "General", "/settings/general/"),
@@ -8,7 +8,6 @@
{% include "settings/_tabs.html" %}
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">
<a href="/settings/plugins/" class="btn btn-ghost btn-sm">← Plugins</a>
<h1 class="page-title" style="margin-bottom:0;">{{ plugin.get('name', plugin._dir) }}</h1>
<span style="color:var(--text-muted);font-size:0.82rem;">v{{ plugin.get('version', '?') }}</span>
{% if fail_reason %}
@@ -70,6 +70,21 @@
"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;">Host metrics retention</h2>
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.25rem;">
Bounds how much host-agent metric history is stored (CPU, memory, disk, network,
temps, …). Raw per-sample points are kept for the raw window, then rolled up
into hourly averages kept for the rollup window. Host 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 host metrics this long, then roll up to hourly averages.") }}
{{ 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>
+46
View File
@@ -0,0 +1,46 @@
"""Real-fd regression canary.
Drives a real socket code path (the TCP reachability probe) in a tight loop and
asserts the process's open-fd count doesn't grow. If someone reintroduces a
socket leak in the probe path — the class of bug behind the Errno 24 lockups —
this fails in CI instead of in production. Uses real descriptors, not fakes, so
the assertion has teeth.
"""
import asyncio
import os
import pytest
from steward.monitors.ping import tcp_check
_ITERATIONS = 100
def _fd_count() -> int | None:
try:
return len(os.listdir("/proc/self/fd"))
except OSError:
return None
async def _hammer_tcp_check() -> None:
# 127.0.0.1:1 has no listener → connection refused immediately. Each call must
# fully release its socket; a leak would add ~one fd per iteration.
for _ in range(_ITERATIONS):
await tcp_check("127.0.0.1", 1)
def test_tcp_check_does_not_leak_fds():
if _fd_count() is None:
pytest.skip("/proc/self/fd unavailable on this platform")
asyncio.run(_hammer_tcp_check()) # warm up (lazy imports, caches)
before = _fd_count()
asyncio.run(_hammer_tcp_check())
after = _fd_count()
# Small slack for interpreter-internal fds; a genuine leak over 100 iterations
# would be far larger than this.
assert after - before <= 5, (
f"open fds grew {before}->{after} over {_ITERATIONS} tcp_check calls "
"— possible socket leak")
+17
View File
@@ -0,0 +1,17 @@
"""Unit tests for the plugin_metrics rollup cutoff helpers (no DB)."""
from datetime import datetime, timezone
from steward.core.metrics_retention import _hour_floor, _rollup_cutoff
def test_hour_floor_drops_sub_hour():
dt = datetime(2026, 6, 20, 15, 42, 9, 123456, tzinfo=timezone.utc)
assert _hour_floor(dt) == datetime(2026, 6, 20, 15, 0, 0, 0, tzinfo=timezone.utc)
def test_rollup_cutoff_is_hour_aligned_and_offset():
now = datetime(2026, 6, 20, 15, 42, 9, tzinfo=timezone.utc)
cutoff = _rollup_cutoff(now, 7)
assert (cutoff.minute, cutoff.second, cutoff.microsecond) == (0, 0, 0)
# 7 whole days back, then floored to the hour.
assert cutoff == datetime(2026, 6, 13, 15, 0, 0, 0, tzinfo=timezone.utc)
+57
View File
@@ -0,0 +1,57 @@
"""Unit tests for plugin-contributed sidebar nav collection."""
import types
from steward.core import plugin_manager as pm
def _mod(nav):
"""A stand-in plugin module; omit get_nav by passing nav=None."""
m = types.SimpleNamespace()
if nav is not None:
m.get_nav = lambda: nav
return m
def test_collect_and_get(monkeypatch):
monkeypatch.setattr(pm, "_PLUGIN_NAV", [])
pm._collect_plugin_nav("docker", _mod([{"label": "Docker", "href": "/plugins/docker/"}]))
assert pm.get_plugin_nav() == [
{"plugin": "docker", "label": "Docker", "href": "/plugins/docker/", "section": "Infrastructure"}
]
def test_missing_hook_is_noop(monkeypatch):
monkeypatch.setattr(pm, "_PLUGIN_NAV", [])
pm._collect_plugin_nav("x", _mod(None))
assert pm.get_plugin_nav() == []
def test_reload_replaces_same_plugin_entries(monkeypatch):
monkeypatch.setattr(pm, "_PLUGIN_NAV", [])
pm._collect_plugin_nav("docker", _mod([{"label": "Docker", "href": "/old"}]))
pm._collect_plugin_nav("docker", _mod([{"label": "Docker", "href": "/new"}]))
assert [e["href"] for e in pm.get_plugin_nav() if e["plugin"] == "docker"] == ["/new"]
def test_malformed_item_skipped(monkeypatch):
monkeypatch.setattr(pm, "_PLUGIN_NAV", [])
pm._collect_plugin_nav("x", _mod([{"label": "NoHref"}, {"label": "Ok", "href": "/ok"}]))
assert [e["href"] for e in pm.get_plugin_nav()] == ["/ok"]
def test_raising_hook_skipped(monkeypatch):
monkeypatch.setattr(pm, "_PLUGIN_NAV", [])
def boom():
raise RuntimeError("nope")
pm._collect_plugin_nav("x", types.SimpleNamespace(get_nav=boom))
assert pm.get_plugin_nav() == []
def test_get_plugin_nav_sorted_by_section_then_label(monkeypatch):
monkeypatch.setattr(pm, "_PLUGIN_NAV", [])
pm._collect_plugin_nav("traefik", _mod([{"label": "Traefik", "href": "/t"}]))
pm._collect_plugin_nav("docker", _mod([{"label": "Docker", "href": "/d"}]))
# Same default section → sorted by label.
assert [e["label"] for e in pm.get_plugin_nav()] == ["Docker", "Traefik"]
+58
View File
@@ -0,0 +1,58 @@
"""Unit tests for the scheduler's poll-overlap guard (_DueTracker).
The tracker is pure (clock passed in) so we can assert the overlap policy
deterministically: a task whose prior run is still in flight is never re-fired,
and a skipped task isn't penalised — it runs on the next tick once it finishes.
This is the rail that stops a hung poll from stacking overlapping runs (which
would amplify any per-poll resource/fd use).
"""
from steward.core.scheduler import ScheduledTask, _DueTracker
def _task(name: str, interval: int) -> ScheduledTask:
return ScheduledTask(name=name, coro_factory=lambda: None, interval_seconds=interval)
def test_due_only_after_interval_elapses():
t = _task("a", 60)
tr = _DueTracker()
assert tr.due([t], now=59) == [] # 59 < 60
assert tr.due([t], now=60) == [t] # interval elapsed
def test_in_flight_task_is_skipped_even_when_due():
t = _task("a", 0) # due every tick
tr = _DueTracker()
tr.mark_started(t, now=0)
assert tr.due([t], now=100) == [] # still running → skipped
tr.mark_done("a")
assert tr.due([t], now=100) == [t] # finished → eligible again
def test_skip_does_not_advance_last_run_so_it_retries():
t = _task("a", 10)
tr = _DueTracker()
tr.mark_started(t, now=0)
assert tr.due([t], now=100) == [] # due but in flight
assert tr.last_run["a"] == 0 # not advanced by the skip
tr.mark_done("a")
assert tr.due([t], now=100) == [t] # retried promptly after completion
def test_mark_started_advances_last_run_and_marks_in_flight():
t = _task("a", 10)
tr = _DueTracker()
tr.mark_started(t, now=50)
assert tr.last_run["a"] == 50
assert tr.in_flight == {"a"}
assert tr.due([t], now=55) == [] # 5 < 10 (not yet due)
assert tr.due([t], now=61) == [] # due by interval, but still in flight
tr.mark_done("a")
assert tr.due([t], now=61) == [t]
def test_independent_tasks_do_not_block_each_other():
a, b = _task("a", 0), _task("b", 0)
tr = _DueTracker()
tr.mark_started(a, now=0) # a hangs
assert tr.due([a, b], now=10) == [b] # b still fires
+115
View File
@@ -0,0 +1,115 @@
"""Unit tests for the self-fd watchdog (no DB, no network).
Exercises the fd accounting, the percentage/warning floor, and that the
recorder degrades to a no-op where fd accounting isn't available. The DB session
and the alert pipeline are faked — we only assert that the right metrics get
handed to record_metric.
"""
import asyncio
import logging
import steward.core.alerts as alerts
import steward.core.self_monitor as sm
# ── fd accounting ────────────────────────────────────────────────────────────
def test_count_open_fds_sane_or_none():
n = sm.count_open_fds()
# On the Linux CI image /proc exists; stdin/stdout/stderr are always open.
assert n is None or n >= 3
def test_fd_soft_limit_positive_or_none():
soft = sm.fd_soft_limit()
assert soft is None or soft > 0
# ── fake session / app plumbing ──────────────────────────────────────────────
class _Ctx:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
class _FakeSession(_Ctx):
def begin(self):
return _Ctx()
class _FakeApp:
def db_sessionmaker(self):
return _FakeSession()
def _patch_recorder(monkeypatch):
recorded: list[tuple] = []
async def fake_record_metric(session, source_module, resource_name, metric_name, value):
recorded.append((source_module, resource_name, metric_name, value))
monkeypatch.setattr(alerts, "record_metric", fake_record_metric)
return recorded
# ── record_self_metrics ──────────────────────────────────────────────────────
def test_records_both_fd_metrics(monkeypatch):
recorded = _patch_recorder(monkeypatch)
monkeypatch.setattr(sm, "count_open_fds", lambda: 50)
monkeypatch.setattr(sm, "fd_soft_limit", lambda: 1000)
asyncio.run(sm.record_self_metrics(_FakeApp()))
assert ("steward", "process", "open_fds", 50.0) in recorded
assert ("steward", "process", "open_fds_pct", 5.0) in recorded
def test_no_pct_metric_when_limit_unknown(monkeypatch):
recorded = _patch_recorder(monkeypatch)
monkeypatch.setattr(sm, "count_open_fds", lambda: 50)
monkeypatch.setattr(sm, "fd_soft_limit", lambda: None)
asyncio.run(sm.record_self_metrics(_FakeApp()))
metric_names = [m for (_s, _r, m, _v) in recorded]
assert "open_fds" in metric_names
assert "open_fds_pct" not in metric_names # meaningless without a ceiling
def test_warns_past_floor(monkeypatch, caplog):
_patch_recorder(monkeypatch)
monkeypatch.setattr(sm, "count_open_fds", lambda: 900)
monkeypatch.setattr(sm, "fd_soft_limit", lambda: 1000) # 90% ≥ FD_WARN_PCT
monkeypatch.setattr(sm, "_warned", False)
with caplog.at_level(logging.WARNING, logger=sm.logger.name):
asyncio.run(sm.record_self_metrics(_FakeApp()))
assert any("soft limit" in r.message for r in caplog.records)
def test_no_warning_below_floor(monkeypatch, caplog):
_patch_recorder(monkeypatch)
monkeypatch.setattr(sm, "count_open_fds", lambda: 100)
monkeypatch.setattr(sm, "fd_soft_limit", lambda: 1000) # 10%
monkeypatch.setattr(sm, "_warned", False)
with caplog.at_level(logging.WARNING, logger=sm.logger.name):
asyncio.run(sm.record_self_metrics(_FakeApp()))
assert not any("soft limit" in r.message for r in caplog.records)
def test_noop_when_fd_count_unavailable(monkeypatch):
# /proc absent → must return before touching the DB.
monkeypatch.setattr(sm, "count_open_fds", lambda: None)
class _Boom:
def db_sessionmaker(self):
raise AssertionError("must not open a session when fds are unknown")
asyncio.run(sm.record_self_metrics(_Boom())) # no raise
+29
View File
@@ -0,0 +1,29 @@
"""Unit tests for undecryptable-secret detection (the admin-banner source)."""
from steward.core.crypto import encrypt_secret, init_crypto
from steward.core.settings import _is_undecryptable, get_undecryptable_secrets
def test_is_undecryptable_false_for_current_key():
init_crypto("key-A")
tok = encrypt_secret("hunter2")
assert _is_undecryptable(tok, "smtp.password") is False
def test_is_undecryptable_true_after_key_rotation():
init_crypto("key-A")
tok = encrypt_secret("hunter2") # sealed under key-A
init_crypto("key-B") # key changed/lost
assert _is_undecryptable(tok, "smtp.password") is True
def test_is_undecryptable_ignores_plaintext_and_empty():
init_crypto("key-A")
assert _is_undecryptable("", "smtp.password") is False
assert _is_undecryptable("plain-value", "smtp.password") is False
assert _is_undecryptable(None, "smtp.password") is False
def test_get_undecryptable_secrets_returns_sorted_list():
# Default (nothing scanned) is an empty list; the accessor is always safe to
# call from the template context processor.
assert isinstance(get_undecryptable_secrets(), list)
+100
View File
@@ -151,6 +151,46 @@ def test_persist_scopes_containers_by_host(app):
@_NEEDS_DB
def test_large_memory_values_persist_as_bigint(app):
"""A container using >2^31 bytes of RAM must persist. Regression: mem_usage_bytes
/ mem_limit_bytes were int4 and overflowed (asyncpg 'value out of int32 range'),
failing the whole ingest batch for any host with a >2.1 GB container."""
from sqlalchemy import text
from steward.models.hosts import Host
persist = _persist_fn(app)
now = datetime.now(timezone.utc)
big_usage = 8_185_077_760 # ~8.18 GB — well past int4 max (2_147_483_647)
big_limit = 17_179_869_184 # 16 GB
snapshot = [(now, [{
"name": "bigmem", "container_id": "deadbeef", "image": "postgres",
"status": "running", "cpu_pct": 1.0, "mem_pct": 50.0,
"mem_usage_bytes": big_usage, "mem_limit_bytes": big_limit,
"ports": [], "started_at": None,
}])]
async def _go():
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(text("DELETE FROM docker_metrics"))
await s.execute(text("DELETE FROM docker_containers"))
h = Host(id=str(uuid.uuid4()), name="bigmemhost", address="10.0.0.9")
s.add(h)
await s.flush()
await persist(s, h, snapshot)
row = (await s.execute(text(
"SELECT mem_usage_bytes, mem_limit_bytes FROM docker_containers "
"WHERE name = 'bigmem'"))).first()
metric = (await s.execute(text(
"SELECT mem_usage_bytes FROM docker_metrics "
"WHERE container_name = 'bigmem'"))).scalar()
return row, metric
row, metric = asyncio.run(_go())
assert row == (big_usage, big_limit) # current-state row round-trips
assert metric == big_usage # time-series row round-trips
def test_lifecycle_events_derived_across_snapshots(app):
from sqlalchemy import text
from steward.models.hosts import Host
@@ -191,6 +231,44 @@ def test_lifecycle_events_derived_across_snapshots(app):
assert not any(e[0] == "start" for e in events)
@_NEEDS_DB
def test_vanished_container_is_reaped(app):
"""A container absent from the latest snapshot is deleted, not left behind as
a permanent stopped row. Regression: removed one-shot containers (CI jobs,
buildkit builders) accumulated forever and inflated the dashboard counts."""
from sqlalchemy import text
from steward.models.hosts import Host
persist = _persist_fn(app)
t1 = datetime(2026, 6, 19, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 6, 19, 12, 0, 30, tzinfo=timezone.utc)
base = {"status": "running", "cpu_pct": 1.0, "mem_pct": 1.0,
"restart_count": 0, "exit_code": None, "oom_killed": False, "health": None}
keep = {**base, "name": "keep"}
gone = {**base, "name": "ci-job-123"}
async def _go():
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(text("DELETE FROM docker_containers"))
h = Host(id=str(uuid.uuid4()), name="reap", address="10.9.9.8")
s.add(h)
await s.flush()
await persist(s, h, [(t1, [keep, gone])]) # both present
before = [r[0] for r in (await s.execute(text(
"SELECT name FROM docker_containers WHERE host_id = :h ORDER BY name"),
{"h": h.id})).all()]
await persist(s, h, [(t2, [keep])]) # ci-job-123 vanished
after = [r[0] for r in (await s.execute(text(
"SELECT name FROM docker_containers WHERE host_id = :h ORDER BY name"),
{"h": h.id})).all()]
return before, after
before, after = asyncio.run(_go())
assert before == ["ci-job-123", "keep"]
assert after == ["keep"] # the vanished container was reaped
@_NEEDS_DB
def test_swarm_topology_persisted(app):
from sqlalchemy import text
@@ -267,6 +345,8 @@ def test_disk_usage_persisted(app):
"FROM docker_disk_usage WHERE host_id=:h"), {"h": hid})).first()
img_count = (await s.execute(text(
"SELECT COUNT(*) FROM docker_images WHERE host_id=:h"), {"h": hid})).scalar()
# End the autobegun read transaction before opening the next write one.
await s.rollback()
# Re-report with one image dropped — the stale row must be pruned.
async with s.begin():
await persist(s, await s.get(Host, hid), [], None, disk2)
@@ -369,3 +449,23 @@ def test_retention_rollup_and_prune(app):
assert events_left == 1 # only the in-window event survives
assert counts["buckets_rolled"] == 1 and counts["raw_rows_rolled"] == 3
assert counts["events_pruned"] == 1 and counts["rollup_pruned"] == 1
def test_widget_dedup_collapses_cross_manager_duplicates():
"""The same swarm task is reported by every manager (identical container_id);
the dashboard widget must count it once. Older agents send no container_id,
so those rows can't be matched and are all kept."""
from types import SimpleNamespace
from plugins.docker.dedup import dedup_by_container_id
def c(cid, name, status="running"):
return SimpleNamespace(container_id=cid, name=name, status=status)
rows = [c("abc", "web@m1"), c("abc", "web@m2"), # same task, two managers
c("def", "db"), c("", "noid-1"), c("", "noid-2")]
out = dedup_by_container_id(rows)
assert len(out) == 4 # one "abc" dropped
abc = [r for r in out if r.container_id == "abc"]
assert len(abc) == 1 and abc[0].name == "web@m1" # first occurrence wins
assert sum(1 for r in out if r.container_id == "") == 2 # id-less rows all kept
+161
View File
@@ -0,0 +1,161 @@
"""Integration: host-metrics read paths (DISTINCT ON latest + SQL date_bin history).
Validates the two host_agent query helpers that back the slow host views, against
a live Postgres: the latest-per-(resource,metric) lookup and the bucket-averaged
history (which now aggregates in SQL instead of shipping raw rows to Python).
Requires STEWARD_DATABASE_URL.
"""
from __future__ import annotations
import asyncio
import os
import uuid
from datetime import datetime, timedelta, timezone
import pytest
pytestmark = pytest.mark.integration
_NEEDS_DB = pytest.mark.skipif(
not os.environ.get("STEWARD_DATABASE_URL"),
reason="integration test needs a live Postgres (STEWARD_DATABASE_URL)",
)
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
@_NEEDS_DB
def test_latest_distinct_on_and_sql_bucketed_history(app):
from sqlalchemy import text
from steward.models.metrics import PluginMetric
from plugins.host_agent.metrics_query import (
SOURCE_MODULE, _history_for_host, _latest_metrics_for_host,
)
now = datetime.now(timezone.utc)
hostname = "metrics-host-" + uuid.uuid4().hex[:8]
async def _go():
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(
text("DELETE FROM plugin_metrics WHERE resource_name LIKE :p"),
{"p": hostname + "%"},
)
rows = []
# Host-level CPU samples, oldest → newest (10, 20, 30).
for i, val in enumerate([10.0, 20.0, 30.0]):
rows.append(PluginMetric(
source_module=SOURCE_MODULE, resource_name=hostname,
metric_name="cpu_pct", value=val,
recorded_at=now - timedelta(minutes=30 - i * 10),
))
# A sub-resource (root mount) to exercise the host:% match.
rows.append(PluginMetric(
source_module=SOURCE_MODULE, resource_name=hostname + ":/",
metric_name="disk_used_pct", value=80.0,
recorded_at=now - timedelta(minutes=5),
))
s.add_all(rows)
latest = await _latest_metrics_for_host(s, hostname)
hist = await _history_for_host(s, hostname, now - timedelta(hours=1))
return latest, hist
latest, hist = asyncio.run(_go())
# DISTINCT ON returns the newest sample per (resource, metric).
assert latest[hostname]["cpu_pct"] == 30.0
assert latest[hostname + ":/"]["disk_used_pct"] == 80.0
# SQL date_bin aggregation returns cpu_pct buckets; averages stay in range.
cpu = hist["cpu_pct"]
assert cpu, "expected bucketed cpu_pct history"
assert all(10.0 <= v <= 30.0 for _, v in cpu)
@_NEEDS_DB
def test_rollup_aggregates_old_raw_and_prunes(app):
from sqlalchemy import text
from steward.core.metrics_retention import rollup_plugin_metrics
from steward.models.metrics import PluginMetric
now = datetime.now(timezone.utc)
hostname = "rollup-host-" + uuid.uuid4().hex[:8]
async def _go():
async with app.db_sessionmaker() as s:
async with s.begin():
for tbl in ("plugin_metrics", "plugin_metrics_hourly"):
await s.execute(
text(f"DELETE FROM {tbl} WHERE resource_name LIKE :p"),
{"p": hostname + "%"},
)
old = (now - timedelta(days=10)).replace(minute=5, second=0, microsecond=0)
s.add_all([
PluginMetric(source_module="host_agent", resource_name=hostname,
metric_name="cpu_pct", value=10.0, recorded_at=old),
PluginMetric(source_module="host_agent", resource_name=hostname,
metric_name="cpu_pct", value=20.0,
recorded_at=old.replace(minute=35)), # same hour bucket
PluginMetric(source_module="host_agent", resource_name=hostname,
metric_name="cpu_pct", value=99.0,
recorded_at=now - timedelta(hours=1)), # recent → kept raw
])
async with s.begin():
counts = await rollup_plugin_metrics(s, raw_days=7, rollup_days=90, now=now)
hrly = (await s.execute(text(
"SELECT value_avg, sample_count FROM plugin_metrics_hourly "
"WHERE resource_name = :h AND metric_name = 'cpu_pct'"), {"h": hostname})).all()
raw_left = (await s.execute(text(
"SELECT count(*) FROM plugin_metrics WHERE resource_name = :h"), {"h": hostname})).scalar()
return counts, hrly, raw_left
counts, hrly, raw_left = asyncio.run(_go())
assert counts["buckets_rolled"] == 1
assert counts["raw_rows_rolled"] == 2
assert len(hrly) == 1
assert abs(float(hrly[0][0]) - 15.0) < 0.001 # avg(10, 20)
assert hrly[0][1] == 2
assert raw_left == 1 # only the recent sample remains
@_NEEDS_DB
def test_history_merges_hourly_and_raw(app):
from sqlalchemy import text
from steward.models.metrics import PluginMetric, PluginMetricHourly
from plugins.host_agent.metrics_query import _history_for_host
now = datetime.now(timezone.utc)
hostname = "merge-host-" + uuid.uuid4().hex[:8]
async def _go():
async with app.db_sessionmaker() as s:
async with s.begin():
for tbl in ("plugin_metrics", "plugin_metrics_hourly"):
await s.execute(
text(f"DELETE FROM {tbl} WHERE resource_name LIKE :p"),
{"p": hostname + "%"},
)
old_bucket = (now - timedelta(days=10)).replace(minute=0, second=0, microsecond=0)
s.add(PluginMetricHourly(
source_module="host_agent", resource_name=hostname, metric_name="cpu_pct",
bucket=old_bucket, value_avg=42.0, value_max=50.0, sample_count=120))
s.add(PluginMetric(
source_module="host_agent", resource_name=hostname, metric_name="cpu_pct",
value=77.0, recorded_at=now - timedelta(hours=1)))
# 30d range with a 7d raw window → spans the rollup boundary.
return await _history_for_host(s, hostname, now - timedelta(days=30), raw_days=7)
hist = asyncio.run(_go())
cpu = hist["cpu_pct"]
vals = [v for _, v in cpu]
assert 42.0 in vals, "expected the rolled-up hourly point"
assert 77.0 in vals, "expected the recent raw point"
assert cpu == sorted(cpu, key=lambda p: p[0]), "series must be time-ordered"
+50
View File
@@ -0,0 +1,50 @@
"""Unit tests for the docker plugin's presentation layer (no DB).
CI never renders these templates through the running app (the unit-lane app is
created with testing=True, which skips plugin loading), so a Jinja syntax error
would otherwise ship green. These tests parse every docker template and smoke
the routes module so a broken tag or import is caught in the unit lane.
"""
import pathlib
import jinja2
from plugins.docker import routes as r
_TEMPLATES = pathlib.Path(r.__file__).parent / "templates" / "docker"
def test_all_docker_templates_parse():
env = jinja2.Environment()
files = sorted(_TEMPLATES.glob("*.html"))
assert files, "no docker templates found"
for f in files:
# Raises TemplateSyntaxError on an unbalanced/typo'd tag.
env.parse(f.read_text())
def test_routes_module_exposes_new_views():
# Import-smoke + confirms the #942 view functions are defined.
for name in ("container_detail", "container_history", "swarm", "disk", "index", "rows"):
assert callable(getattr(r, name)), name
assert r.docker_bp.name == "docker"
def test_human_bytes_formats_binary_units():
assert r._human_bytes(None) == ""
assert r._human_bytes(0) == "0 B"
assert r._human_bytes(512) == "512 B"
assert r._human_bytes(1024) == "1.0 KiB"
assert r._human_bytes(1536) == "1.5 KiB"
assert r._human_bytes(1024 ** 2) == "1.0 MiB"
assert r._human_bytes(int(1.5 * 1024 ** 3)) == "1.5 GiB"
assert r._human_bytes(1024 ** 4) == "1.0 TiB"
def test_human_uptime_compact():
from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc)
assert r._human_uptime(None) is None
assert r._human_uptime(now - timedelta(minutes=8)).endswith("m")
assert "h" in r._human_uptime(now - timedelta(hours=5, minutes=12))
assert "d" in r._human_uptime(now - timedelta(days=3, hours=4))
+79
View File
@@ -0,0 +1,79 @@
"""Unit tests for the swarm-aware container view builder (pure, no DB/ORM)."""
from types import SimpleNamespace
from plugins.docker.swarm_view import build_swarm_services
NODE_HOSTNAME = {"nA": "docker01", "nB": "docker02", "nC": "docker03"}
HOST_NAME = {"h1": "docker01", "h2": "docker02"} # agents run on docker01/02 only
def _c(host_id, name, *, service=None, node=None, status="running", cpu=1.0):
return SimpleNamespace(host_id=host_id, name=name, service_name=service,
node_id=node, status=status, cpu_pct=cpu, mem_pct=2.0,
health=None, restart_count=0, container_id=name)
def _svc(name, running, desired, placement, mode="replicated"):
import json
return SimpleNamespace(service_name=name, running=running, desired=desired,
mode=mode, placement_json=json.dumps(placement))
def test_real_replicas_plus_agentless_ghost():
containers = [
_c("h1", "web.1.aaa", service="web", node="nA"),
_c("h2", "web.2.bbb", service="web", node="nB"),
_c("h1", "db", node=None), # standalone, non-swarm
]
services = [_svc("web", running=3, desired=3,
placement=[{"node_id": "nA", "running": 1},
{"node_id": "nB", "running": 1},
{"node_id": "nC", "running": 1}])] # nC has no agent
view = build_swarm_services(containers, services, NODE_HOSTNAME, HOST_NAME)
assert len(view["services"]) == 1
svc = view["services"][0]
assert svc["name"] == "web" and svc["state"] == "healthy"
reals = [r for r in svc["replicas"] if not r["ghost"]]
ghosts = [r for r in svc["replicas"] if r["ghost"]]
assert sorted(r["host"] for r in reals) == ["docker01", "docker02"]
assert len(ghosts) == 1 and ghosts[0]["host"] == "docker03" and ghosts[0]["count"] == 1
# The non-swarm container is grouped by host, untouched.
assert len(view["standalone"]) == 1
g = view["standalone"][0]
assert g["host"] == "docker01" and [c.name for c in g["containers"]] == ["db"]
def test_placement_fully_covered_has_no_ghosts():
containers = [_c("h1", "api.1.x", service="api", node="nA"),
_c("h2", "api.2.y", service="api", node="nB")]
services = [_svc("api", 2, 2, [{"node_id": "nA", "running": 1},
{"node_id": "nB", "running": 1}])]
view = build_swarm_services(containers, services, NODE_HOSTNAME, HOST_NAME)
assert all(not r["ghost"] for r in view["services"][0]["replicas"])
def test_multiple_tasks_same_node_partially_agentless():
# Placement says 2 running on nA, but we only have 1 local row → 1 ghost on nA.
containers = [_c("h1", "cache.1.x", service="cache", node="nA")]
services = [_svc("cache", 2, 2, [{"node_id": "nA", "running": 2}])]
view = build_swarm_services(containers, services, NODE_HOSTNAME, HOST_NAME)
ghosts = [r for r in view["services"][0]["replicas"] if r["ghost"]]
assert len(ghosts) == 1 and ghosts[0]["count"] == 1 and ghosts[0]["host"] == "docker01"
def test_service_without_a_service_row_is_synthesised():
# Container labeled with a service, but no manager reported the service row.
containers = [_c("h1", "orphan.1.x", service="orphan", node="nA")]
view = build_swarm_services(containers, [], NODE_HOSTNAME, HOST_NAME)
svc = view["services"][0]
assert svc["name"] == "orphan" and svc["running"] == 1 and svc["desired"] == 1
assert all(not r["ghost"] for r in svc["replicas"])
def test_down_service_state():
services = [_svc("idle", 0, 2, [])]
view = build_swarm_services([], services, NODE_HOSTNAME, HOST_NAME)
assert view["services"][0]["state"] == "down"
View File
+69
View File
@@ -0,0 +1,69 @@
"""Unit tests for mapping config-defined SNMP devices onto a Steward host."""
from plugins.snmp.routes import _devices_for_host
_CFG = [
{"name": "core-switch", "host": "192.168.1.1"},
{"name": "ups", "host": "192.168.1.10"},
{"name": "by-name", "host": "edge-router"},
"not-a-dict", # tolerated, skipped
{"name": "no-host"}, # no host → never matches
]
def test_matches_by_address():
out = _devices_for_host(_CFG, "192.168.1.1", "somehost")
assert [d["name"] for d in out] == ["core-switch"]
def test_matches_by_name_case_insensitive():
out = _devices_for_host(_CFG, "10.0.0.9", "Edge-Router")
assert [d["name"] for d in out] == ["by-name"]
def test_no_match_returns_empty():
assert _devices_for_host(_CFG, "10.0.0.1", "nope") == []
def test_blank_address_and_name_match_nothing():
# A host with no address/name must not match a device with a blank host.
assert _devices_for_host(_CFG, "", None) == []
def test_implicit_match_unaffected_when_host_id_passed():
# Passing the new host_id arg must not break the legacy implicit match.
out = _devices_for_host(_CFG, "192.168.1.1", "somehost", host_id="h-1")
assert [d["name"] for d in out] == ["core-switch"]
# ── Explicit binding ─────────────────────────────────────────────────────────
def test_explicit_host_id_matches_exact_uuid():
cfg = [{"name": "sw", "host": "10.0.0.5", "host_id": "uuid-abc"}]
out = _devices_for_host(cfg, "10.0.0.9", "other", host_id="uuid-abc")
assert [d["name"] for d in out] == ["sw"]
def test_explicit_steward_host_matches_by_name_or_address():
cfg = [{"name": "pdu", "host": "10.0.0.5", "steward_host": "Switch01"}]
# Bind hits the Host's name despite a different poll target / address.
by_name = _devices_for_host(cfg, "10.0.0.9", "switch01", host_id="h9")
by_addr = _devices_for_host(cfg, "switch01", "whatever", host_id="h9")
assert [d["name"] for d in by_name] == ["pdu"]
assert [d["name"] for d in by_addr] == ["pdu"]
def test_explicit_binding_is_exclusive():
# Device polls 192.168.1.1 but is explicitly bound to host A by name.
cfg = [{"name": "sw", "host": "192.168.1.1", "steward_host": "hostA"}]
# Host B *would* match implicitly by the poll target — but must NOT,
# because the explicit binding takes over and points only at host A.
hostB = _devices_for_host(cfg, "192.168.1.1", "hostB", host_id="b")
hostA = _devices_for_host(cfg, "10.0.0.2", "hostA", host_id="a")
assert hostB == []
assert [d["name"] for d in hostA] == ["sw"]
def test_explicit_host_id_wrong_uuid_does_not_match():
cfg = [{"name": "sw", "host": "10.0.0.5", "host_id": "uuid-abc"}]
out = _devices_for_host(cfg, "10.0.0.5", "sw", host_id="uuid-zzz")
assert out == []
+76
View File
@@ -0,0 +1,76 @@
"""Unit tests for the SNMP poller (no pysnmp / no network).
The unit lane doesn't install the `snmp` extra, so these exercise the parts that
don't need pysnmp: the version→mpModel mapping, that the poller is async, and the
graceful "pysnmp missing → empty result" path. Live polling against a real device
is verified out-of-band (CI has no SNMP target). Uses asyncio.run() directly so it
doesn't depend on the pytest-asyncio mode.
"""
import asyncio
import inspect
from plugins.snmp import poller
def test_poll_device_is_coroutine():
# The scheduler awaits it directly (no executor) — it must be async.
assert inspect.iscoroutinefunction(poller.poll_device)
def test_mp_model_maps_version_to_int():
assert poller._mp_model("1") == 0 # SNMP v1
assert poller._mp_model("2c") == 1 # SNMP v2c
assert poller._mp_model("2") == 1 # anything non-"1" → v2c model
def test_poll_device_without_pysnmp_returns_empty(monkeypatch):
# When the optional dep is absent, polling is disabled gracefully (no raise).
monkeypatch.setattr(poller, "_pysnmp_available", lambda: False)
out = asyncio.run(
poller.poll_device("192.0.2.1", 161, "public", "2c", [{"oid": "1.3.6.1.2.1.1.3.0"}])
)
assert out == {}
# --- _close_engine: the fd-leak guard ---------------------------------------
# A fresh SnmpEngine per poll leaks its UDP socket unless closed; _close_engine
# must release it across both pysnmp API shapes. These fakes stand in for the
# engine since the unit lane has no pysnmp.
def test_close_engine_uses_engine_level_close_7x():
# canonical pysnmp 7.x: close method lives directly on the engine.
calls = []
class Engine:
def close_dispatcher(self):
calls.append("engine")
poller._close_engine(Engine())
assert calls == ["engine"]
def test_close_engine_falls_back_to_dispatcher_6x():
# pysnmp-lextudio 6.2.x: no engine-level close; go through the dispatcher.
calls = []
class Dispatcher:
def closeDispatcher(self):
calls.append("dispatcher")
class Engine:
transportDispatcher = Dispatcher()
poller._close_engine(Engine())
assert calls == ["dispatcher"]
def test_close_engine_never_raises():
# A failing close must not break the poll loop.
class Engine:
def close_dispatcher(self):
raise OSError("boom")
poller._close_engine(Engine()) # no exception
# No close path at all (defensive) is also fine.
poller._close_engine(object())
View File
+92
View File
@@ -0,0 +1,92 @@
"""Unit tests for the UniFi poll scheduler's client lifecycle (no network/DB).
The leak guard: the module-cached UnifiClient holds a long-lived httpx pool, so
whenever the scheduler discards it (login failure, or a mid-poll error that
forces re-auth) it MUST close it first — otherwise every failure tick orphans a
connection pool and leaks file descriptors. These tests drive both discard paths
and assert the client was closed and the cache cleared. Uses asyncio.run()
directly so it doesn't depend on the pytest-asyncio mode.
"""
import asyncio
import types
import plugins.unifi.client as client_mod
import plugins.unifi.scheduler as scheduler
def _make_app():
app = types.SimpleNamespace()
app.config = {"PLUGINS": {"unifi": {"host": "h", "username": "u", "password": "p"}}}
return app
class _FakeClient:
"""Stand-in for UnifiClient; records close() and can fail on demand."""
created: list["_FakeClient"] = []
login_fails = False
health_fails = False
def __init__(self, **_kwargs):
self.closed = False
_FakeClient.created.append(self)
async def login(self):
if _FakeClient.login_fails:
raise RuntimeError("login boom")
async def get_health(self):
if _FakeClient.health_fails:
raise RuntimeError("health boom")
return []
async def close(self):
self.closed = True
def _install(monkeypatch):
monkeypatch.setattr(client_mod, "UnifiClient", _FakeClient)
monkeypatch.setattr(scheduler, "_client", None)
_FakeClient.created = []
_FakeClient.login_fails = False
_FakeClient.health_fails = False
def test_login_failure_closes_and_clears_client(monkeypatch):
_install(monkeypatch)
_FakeClient.login_fails = True
asyncio.run(scheduler._do_poll(_make_app()))
assert len(_FakeClient.created) == 1
assert _FakeClient.created[0].closed is True # closed, not just dropped
assert scheduler._client is None # cache cleared → re-auth next tick
def test_poll_failure_closes_and_clears_client(monkeypatch):
_install(monkeypatch)
_FakeClient.health_fails = True # login OK, first API call blows up
asyncio.run(scheduler._do_poll(_make_app()))
assert len(_FakeClient.created) == 1
assert _FakeClient.created[0].closed is True
assert scheduler._client is None
def test_drop_client_is_noop_when_unset(monkeypatch):
_install(monkeypatch)
# No client cached — dropping must not raise.
asyncio.run(scheduler._drop_client())
assert scheduler._client is None
def test_drop_client_swallows_close_errors(monkeypatch):
_install(monkeypatch)
class _Boom:
async def close(self):
raise OSError("close boom")
monkeypatch.setattr(scheduler, "_client", _Boom())
asyncio.run(scheduler._drop_client()) # must not propagate
assert scheduler._client is None
+30 -1
View File
@@ -1,6 +1,6 @@
import textwrap
import pytest
from steward.config import load_bootstrap
from steward.config import load_bootstrap, _resolve_secret_key
def test_load_bootstrap_from_yaml(tmp_path):
@@ -36,3 +36,32 @@ def test_missing_database_url_raises(tmp_path):
cfg_file.write_text("secret_key: s\n")
with pytest.raises(ValueError, match="Database URL is required"):
load_bootstrap(cfg_file)
# ── secret key resolution ──────────────────────────────────────────────────────
def test_secret_key_existing_file_is_reused(tmp_path, monkeypatch):
key_file = tmp_path / "secret.key"
key_file.write_text("persisted-key\n")
monkeypatch.setattr("steward.config._SECRET_KEY_FILE", key_file)
monkeypatch.delenv("STEWARD_SECRET_KEY", raising=False)
assert _resolve_secret_key({}) == "persisted-key"
def test_secret_key_generated_and_persisted_when_writable(tmp_path, monkeypatch):
key_file = tmp_path / "sub" / "secret.key" # parent doesn't exist yet
monkeypatch.setattr("steward.config._SECRET_KEY_FILE", key_file)
monkeypatch.delenv("STEWARD_SECRET_KEY", raising=False)
key = _resolve_secret_key({})
assert key and key_file.read_text().strip() == key
def test_secret_key_unpersistable_raises_instead_of_ephemeral(tmp_path, monkeypatch):
# A file sits where a directory is needed, so mkdir/write fails → must raise
# (an ephemeral key would silently orphan every encrypted secret).
blocker = tmp_path / "blocker"
blocker.write_text("not a dir")
monkeypatch.setattr("steward.config._SECRET_KEY_FILE", blocker / "secret.key")
monkeypatch.delenv("STEWARD_SECRET_KEY", raising=False)
with pytest.raises(RuntimeError, match="could not persist"):
_resolve_secret_key({})
+34
View File
@@ -0,0 +1,34 @@
"""Syntax-parse every first-party template so a broken tag fails the unit lane.
The app only renders a handful of templates in unit tests (e.g. the login page),
so a Jinja syntax error elsewhere would otherwise ship green. env.parse() checks
syntax without resolving extends/includes/imports — enough to catch an unbalanced
or mistyped tag across the whole template tree (e.g. the base.html layout).
"""
import pathlib
import jinja2
import steward
_REPO = pathlib.Path(steward.__file__).parent.parent
_CORE_TEMPLATES = _REPO / "steward" / "templates"
_PLUGINS = _REPO / "plugins"
def test_all_steward_templates_parse():
env = jinja2.Environment()
files = sorted(_CORE_TEMPLATES.rglob("*.html"))
assert files, "no steward templates found"
for f in files:
env.parse(f.read_text()) # raises TemplateSyntaxError on a bad tag
def test_all_plugin_templates_parse():
# First-party plugins ship templates too (host_agent fragments, docker, …);
# they aren't rendered in the unit lane, so parse them here to catch a bad tag.
env = jinja2.Environment()
files = sorted(_PLUGINS.rglob("templates/**/*.html"))
assert files, "no plugin templates found"
for f in files:
env.parse(f.read_text())