06e89308a86a899c120a9db2de4dbda7c45dd42b
96
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
06e89308a8 |
feat(ansible): a run's scope reads as the target or group it names
#5014 "Ansible run scope is shown as a raw id" - inventory_gen.describe_scope / scope_labels turn "steward:target:<uuid>" into "target web-1", "steward:group:<uuid>" into "group Docker Swarm", and a deleted one into "target abcdef01 (deleted)". - MCP run and schedule rows carry scope_label beside the stored scope; the ansible.run.started audit detail records it too. - The web run page and schedules table show the name, with the stored scope as the tooltip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f72ed97034 |
feat(mcp): get_host groups a host's parts by kind and says what it cut
#5012 "get_host cuts `parts` without saying so" - The agent's sub-resources come back as mounts (fullest first), interfaces and disk_io (busiest first) and temperatures (hottest first), each bounded on its own with total, truncated and a `more` that names parts_limit and query_metrics. - Per-core CPU is a summary (count, min/avg/max, three busiest), so 38 cores no longer push every interface out of the reply. - New argument parts_limit (default 20, max 200). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3474cdff46 |
feat(mcp): connect card, MCP docs, and an end-to-end roles/audit test over HTTP
MCP-8 #4972 "Connect card, docs, and end-to-end integration test". - AI access page: a "Connect a client" card with the /mcp URL (from Settings → General → Public base URL, else the request host) and recipes for Claude Code, Claude Desktop (mcp-remote) and other Streamable HTTP clients, plus the reverse-proxy note. The token-created page shows the same recipes with the new token filled in, ready to paste. - docs/core/mcp.md: connecting, the reverse-proxy requirements, the security model (token ceiling, default deny, check-mode default, secrets, audit), tools by role, response conventions, adding tools. docs/architecture.md describes the /mcp dispatch outside the blueprint pipeline; docs/reference/code-map.md gains an MCP section. - tests/integration/test_mcp.py: over HTTP, a viewer token lists no write tools and is refused one by name; an operator token reads get_overview and its maintenance-window create/end land in the audit log as "<user> via MCP" with the token id. (Revoked → 401 was already covered.) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
9abe0beecc |
feat(mcp): monitoring-config write tools — monitors, alert rules, acks, maintenance
MCP-7 #4971 "Monitoring-config write tools: monitors, alert rules, acks, maintenance". The validation the forms did inline (or not at all) moves to the services, and both the routes and the tools call it, so bad input is refused the same way with the same reason: - monitors/service: monitor_fields, build_config, normalise_target. Port 1-65535, status 100-599, timeout 1-60, method GET/HEAD/POST, headers an object of strings, a key the type does not take is named. The forms now answer 400 with the reason instead of redirecting silently or a 500. - alerts/service: METRIC_CATALOG and SOURCE_MODULES, resources_for, metrics_for, rule_fields (source, resource and metric from the same choices the rule form offers), add_rule, acknowledge, window_fields. Tools: create_monitor, update_monitor (config merged over the stored one; a rename warns about rules still watching the old name), set_monitor_enabled, create_alert_rule, update_alert_rule, acknowledge_alert, create_maintenance_window (duration or end), end_maintenance_window (ends one in progress, cancels an upcoming one) — operator. delete_monitor, delete_alert_rule — admin. get_alert_rule_options — viewer. Every write is audited as "<user> via MCP". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
680a6bf3b6 |
feat(mcp): automation tools — playbooks, runs, schedules, alert actions, docker prune
MCP-6 #4970 "Automation tools: playbooks, runs, schedules, alert actions". Viewer: list_playbooks, get_playbook (secret defaults masked), list_inventory (variable names only), list_ansible_runs, get_ansible_run (log tail + grep), list_schedules, wait_for_run (polls, 120s cap). Operator: run_playbook (check mode unless check=false; a steward:confirm playbook applies only with confirmed=true; secret variables reach the run but are never stored, audited or returned), cancel_ansible_run, and create/update/delete_schedule (secrets refused, check by default). Admin: set_alert_action, and the docker plugin's prune_docker, which previews from the disk report unless check=false (Ansible's check mode skips the prune commands, so a check run would show nothing). Every write is audited as "<user> via MCP" with the token id; the audit tool's user filter includes those rows. prune_extra_vars moves to the docker service so the page and the tool share it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
21338dbcd9 |
feat(mcp): docker and host-agent tools; TLS and crash-loop cutoffs in Settings
MCP-5 of milestone 461 "MCP server: Steward as an LLM-inspectable, LLM-operable hub". Contributed through each plugin's register_mcp_tools hook, so they exist only while the plugin is enabled. docker (viewer, read-only): - list_containers: filter by host, status, compose project, or problem (restarting, crash_looping, unhealthy, oom_killed, exited_nonzero); problems sort first and each row names what is wrong. - get_container: state, CPU/memory history across the raw/hourly seam, lifecycle events. A unique part of a swarm task name is enough. - list_swarm_services: replicas per node (agent-less placements as ghosts) and the swarm's nodes. - search_container_logs: text (% and _ literal), stream, range, context lines, paging by a full-precision `before` cursor; over-long lines keep both ends and say how much of the middle was cut. - get_docker_disk_usage: bytes per host, reclaimable first. - mcp_overview: failing containers and under-replicated swarm services join get_overview. host_agent: list_agents (reporting / stale / pending, version, OS), and an overview finding for agents registered over 30 minutes ago that never checked in. Settings > Thresholds gains the TLS-expiry warn/crit days (were 14/3 in code) and the crash-loop deaths-per-hour count (3), which get_overview and the docker tools now read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
41c7509366 |
feat(mcp): diagnostic tools — overview, hosts, monitors, alerts, metrics, audit
MCP-4 of milestone 461 "MCP server: Steward as an LLM-inspectable, LLM-operable hub". Twelve read-only viewer tools: - get_overview: what is wrong now, ranked critical-then-oldest. Failing monitors with their error and when the run of failures began, firing, pending and still-breaching acknowledged alerts, TLS certificates expiring within 14 days, agents that stopped reporting, hosts over the configured CPU/memory/disk thresholds (Settings > Thresholds), failed Ansible runs in the last 24h, and findings from enabled plugins through a new optional `mcp_overview(db)` plugin hook. Each finding names the call that shows its evidence. - list_hosts, get_host, get_host_metrics - list_monitors, get_monitor (HTTP header values and URL passwords are redacted) - list_alerts, get_alert_history, list_alert_rules, list_maintenance_windows - query_metrics: the whole metric store, narrowed one argument at a time (sources > resources > latest values > a series summary) - list_audit_events Shared shaping in steward/mcp/tools/_shape.py: UTC ISO times with age_s, bounded lists that say when and how they were cut, series as min/avg/max/p95/last plus a ≤60-point series, lookup by id or case-insensitive name with a did-you-mean, and a range argument that refuses an unknown key instead of reading it as 24h. New core reads in steward/core/metric_series.py (sources and resources via loose index scans, latest values via DISTINCT ON, series across the raw/hourly-rollup seam). monitors/service.py gains monitor_states, down_since and recent_failures; agent_overview_by_host now carries the fullest disk and the last sample time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
312ea95bcf |
refactor: move host, docker, alert and monitor queries into service modules
MCP-3 of milestone 461 "MCP server: Steward as an LLM-inspectable, LLM-operable hub". The /mcp tools run outside Quart's request pipeline, so the queries they share with the pages now live in plain functions that take an open session: - steward/hosts/service.py: uptime, status, monitors, agent metrics, agent overview (stale threshold passed in, not read from current_app), CPU sparks. - plugins/docker/service.py: bucket, container_history, host_map, list_containers, container_events, query_logs. - steward/alerts/service.py: active_alerts (also used by core/reports), list_rules, list_maintenance_windows. - steward/monitors/service.py: monitor_rows (was routes._rows_data). Routes are repointed and render the same data. One behavior fix rides along: the container-log search now escapes % and _ so they match literally instead of acting as LIKE wildcards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3984f639e8 |
feat(mcp): /mcp endpoint with bearer auth and role-gated tools
Second step of milestone 461. An MCP client (Claude Code, Claude Desktop via mcp-remote, Open WebUI) connects to /mcp with a personal API token. - mcp>=2.2 (SDK v2 MCPServer). Mounted by wrapping app.asgi_app, so /mcp never enters Quart's pipeline; stateless streamable HTTP so a redeploy never strands a client on a session id the new process didn't issue. - Auth: Authorization: Bearer stw_… resolves to a Caller (user + effective role) in a ContextVar; 401 with WWW-Authenticate otherwise. The web pipeline's database gate runs first, so a DB restart answers 503 instead of failing inside a tool. - Registry, default-deny: register_tool(fn, role=, read_only=) is the only door; role and read_only are required. Tools above the caller's role, or from a disabled plugin, are neither listed nor callable. Undeclared arguments are refused (not silently dropped); a tool's ValueError reaches the agent as its reason; a DB failure mid-tool says so. - Plugins contribute tools via an optional register_mcp_tools(register) export, all-or-nothing, replaced on hot-reload. Documented in docs/plugins/writing-a-plugin.md. - First tool: whoami. - Tests drive app.asgi_app directly (Quart's test client never reaches /mcp); the integration test mints a real token against Postgres and runs initialize → tools/list → whoami → revoke → 401. Task #4966 "MCP-2 — /mcp transport". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
1c60fb6b4d |
feat(api): personal API tokens and the AI access page
First step of milestone 461 (MCP server). A token is the credential an MCP client presents as `Authorization: Bearer stw_…` to act as its user. - api_tokens table (0026): sha256 hash only, display prefix, role ceiling, expiry, revocation, throttled last_used_at. - steward/core/api_tokens.py: mint, list, revoke, resolve. Resolution re-reads the user's current role and acts with the lower of that and the token's ceiling, so demoting or disabling a user weakens or kills their tokens with nothing to revoke by hand. - /ai-access/ (sidebar → Automation → AI access): every user manages their own tokens, can't grant above their own role, sees the token once. Session-only: a share-link viewer is refused. Create and revoke are audited. - Integration tests drive the routes against real Postgres so their transactions run for real (#884 escaped CI behind a mocked session). Task #4965 "MCP-1 — Personal API tokens". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
0da88c7076 |
fix(plugins): show load-failure reasons on the page the banner links to
The admin banner counts every plugin load failure, but settings/plugins.html only rendered failure reasons inside the plugin card macro -- which iterates discovered plugins. A plugin that failed BECAUSE it has no directory was therefore counted in the banner and shown nowhere on the page the banner links to. That dead end is what the operator hit: "1 plugin failed to load" on the dashboard, nothing wrong on the details page, and no way to clean it up. Folds the reason into the "Configured but not installed" rows rather than adding a competing section, and drives that list from the UNION of stored plugin.* settings and recorded load failures instead of stored settings alone. A failure with no stored row -- a plugin enabled by a DEFAULTS entry, which is exactly what plugin.http was -- would otherwise still be counted and still be invisible. Rows carry `removable`: only a plugin with a real stored row can be cleaned up, so a default-declared one shows the reason and an explanation instead of a Remove button that would delete nothing while the default reasserts it. Rows with a reason read as an error (red, "Failed to load"); rows that merely have leftover config stay a warning. A discovered plugin that failed is excluded -- its own card already shows the reason. Replaces find_orphaned_plugin_names outright rather than adding a second overlapping helper; it had one caller. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8c50ff242c |
fix(db): ride out a brief database outage mid-request; explain it if it persists
The operator's 500 was NOT the startup gap I attributed it to. They found it by logging in and getting Steward's own error page -- which means create_app had completed and the app was serving, so the gaierror came from a request handler acquiring a connection, not from boot. Neither prior fix covers that: the startup retry never runs, and pool_pre_ping only helps once the database is back, since its replacement connect fails too while the container is gone. Adds a before_request gate that acquires a pooled connection with a short bounded retry (~1.75s over 4 tries) so a database restart is ridden out invisibly, and renders a distinct 503 "database unavailable" page when the budget is exhausted. The request budget is deliberately far shorter than the startup one: nobody watches a container boot, but somebody is watching this page load, and a page that hangs is worse than one that says what is wrong. /health and static are exempt -- a liveness probe must stay answerable while the database is down, or a dependency outage triggers a restart loop. Credential and missing-database errors still propagate rather than being reported as "unavailable", which would send the operator chasing the wrong problem entirely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
59fece855d |
fix(plugins): remove the phantom http plugin; surface orphaned plugin settings
Operator saw the `http` plugin -- deleted when ping/dns/http were unified into the Monitor entity -- still reported as enabled, with no way to clear it. It was never an orphaned row. `plugin.http` was hardcoded in core DEFAULTS, so deleting the app_settings row did nothing: the default reasserted it on the next settings load. That is precisely why no cleanup UI could have fixed it. Rule 22 says the removed subsystem should have taken its setting with it. Purged the surviving references -- the DEFAULTS key, "http" in CAPABILITY_PLUGINS, the stale plugin_manager docstring example, and the capabilities blurb still advertising HTTP/uptime as a bundled capability -- plus a migration dropping any stored plugin.http row. Untouched: `http` as a MONITOR TYPE (icmp/tcp/dns/http) everywhere it appears, and http_001_initial, which is kept deliberately so existing DBs resolve the revision graph. For the general case, cleanup splits by provenance rather than being uniformly automatic or uniformly manual: Bundled plugins ship in the image and version atomically with core, so they cannot be transiently missing -- a plugin.* default with no bundled directory is unambiguously a bug. Guarded by a unit test that fails CI, which is the only part safe to automate. External plugins live in operator-mounted /data/plugins, where absence is ambiguous: unmounted volume, failed install, mid-upgrade. Auto-deleting their config would silently destroy unrecoverable credentials on a transient condition, so Settings now lists them under "Configured but not installed" with an explicit, confirmed, audit-logged Remove. The section hides entirely when empty, and the remove route refuses if the plugin is actually installed. Orphan detection reads STORED rows, never the DEFAULTS-merged view -- offering to remove a default-only key would be a lie -- and returns names only, since plugin config can hold credentials and listing orphans never needs their values. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6c9b89390a |
fix(db): survive a database restart and a not-yet-ready database
Two connection-recovery gaps, both surfaced by a Postgres restart that left the app throwing tracebacks while the DB itself was healthy. pool_pre_ping + pool_recycle on the app engine [#2626]: when the database restarts, every connection already in the pool is dead at the socket level. SQLAlchemy only discovered that by failing a real query, so the first operation after a restart errored out on whatever triggered it. pre_ping checks liveness on checkout and swaps the dead connection transparently; pool_recycle caps connection age so a socket stranded by a NAT/conntrack timeout or a Docker network rebuild is retired on a timer instead. wait_for_database() gate at startup [#2627]: create_app touches the DB synchronously (migrations, secret re-encryption, settings load) and assumed it was both resolvable and accepting connections on the first try. Neither holds after a host reboot (Docker DNS not yet serving `db` -> gaierror -2) or an unclean shutdown (Postgres still replaying WAL -> "not yet accepting connections"). Both are transient, so retry with capped backoff behind one gate ahead of the first DB touch. Credential and missing-database errors are classified by SQLSTATE and still fail immediately -- waiting cannot fix those. Budget is bootstrap-configurable (STEWARD_DB_CONNECT_TIMEOUT / database.connect_timeout, default 60s) since it governs reaching the DB and so cannot live in the DB-backed settings. Tests drive the retry loop off a fake clock, so backoff and timeout behaviour are deterministic rather than wall-clock dependent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8f1c8c5cf7 |
feat(docker): container log viewer + Settings controls [M79 step 5]
The user-facing half (rule 27). Per-container log viewer + admin controls, so container logs are something the operator can actually touch. Viewer (plugins/docker): - /container/<host>/<name>/logs full page + /logs/lines HTMX fragment polled every 5s (near-live follow); stream filter (all/stdout/stderr) + case-insensitive text search; newest-first so the latest lines survive the poll's scroll reset; empty/loading states; a "View logs" link from the container detail page. Jinja auto-escapes log content (no markup injection). Settings (Thresholds & Retention tab): - global on/off toggle, comma-separated exclude list, retention days + max MB per container — DB-backed, no restart. The toggle + exclude are enforced authoritatively at ingest (_persist_logs drops disabled/excluded lines), since the push model has no channel to tell an agent to stop. Tests: route-defined smoke; template-parse covers the new templates; integration test for the ingest-time toggle + exclude enforcement. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAGR73DUowdVFVvYzLXC5C |
||
|
|
07a841d91e |
feat(docker): per-container size+age log ring rotation [M79 step 4]
Bound docker_logs growth in the periodic cleanup task (same architecture as the metrics/events retention). run_docker_retention gains logs_retention_days + logs_max_bytes_per_container: it prunes lines past the age window, then keeps only the newest ~cap bytes per (host, container) via a window-function ring (exclusive-prefix sum, so the newest line always survives even if it alone exceeds the cap). Containers rotate independently. - settings DEFAULTS: docker.logs.enabled/exclude/retention_days(3)/ max_bytes_per_container(5MB) — operator preference: ~3 days / ~5 MB - cleanup.py reads the two windows fresh each run (rule 25, no restart) - integration rotate test: age prune + per-container byte cap + isolation Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAGR73DUowdVFVvYzLXC5C |
||
|
|
e8ac99174a |
feat(ansible): parameterize docker_prune with prune_target (containers/images/system)
M78 step 1. The bundled maintenance/docker_prune.yml was a single
`docker system prune -f`; the upcoming per-host prune buttons need a
granular split. Add a `prune_target` extra-var:
- containers → docker container prune -f
- images → docker image prune -f (+ -a when prune_all_images)
- system → docker system prune -f (+ -a / --volumes) — the DEFAULT,
so existing manual/scheduled callers (which set no var)
keep today's behavior.
Reuses the existing bundled playbook rather than adding parallel files;
keeps the description/category/confirm meta so it still self-describes as
a confirm-gated maintenance run. Adds unit coverage: the playbook stays
valid YAML, its meta still discovers, and all three targets are guarded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CAGR73DUowdVFVvYzLXC5C
|
||
|
|
524fd1f509 |
feat(ansible): default source/playbook dropdowns to the first item
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> |
||
|
|
efc11c1837 |
feat(ansible): structured playbook-variable fields on the schedule form
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>
|
||
|
|
f40063a74d |
feat(reliability): fd-leak rails — self-watchdog, poll-overlap guard, fd tests
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> |
||
|
|
51682f130a |
feat(snmp): surface SNMP device readings on the matching host page
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 |
||
|
|
8af297670e |
feat(metrics): roll plugin_metrics up to hourly to bound storage
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
|
||
|
|
b0d3e83bdd |
feat(hosts): true live vitals strip; agent panel becomes management-only
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 |
||
|
|
a35c369dd4 |
polish(ui): sidebar nav a11y + scroll/focus refinements (nav slice 3)
- 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 |
||
|
|
6d08db0d89 |
feat(hosts): rework host summary layout to use horizontal space
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 |
||
|
|
3a54d6d71d |
perf(metrics): index plugin_metrics for host/dashboard reads
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 |
||
|
|
c8b6719b37 |
feat(ui): plugin get_nav() hook → sidebar Infrastructure links (slice 2)
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 |
||
|
|
95ebdf7045 |
feat(ui): replace flat top nav with a grouped left sidebar (slice 1)
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 |
||
|
|
0940dc6972 |
feat(crypto): fail loudly on unpersistable secret key; flag undecryptable secrets
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 |
||
|
|
88091936c5 |
fix(ui): unify header/breadcrumb treatment, drop redundant back buttons
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
|
||
|
|
faecac3ec6 |
feat(docker): retention + hourly rollup for metrics/events with Settings windows
Bounds Docker time-series growth (the main scaling concern). New docker_metrics_hourly table + docker_006 migration; a plugin retention module (docker.run_retention capability) rolls raw docker_metrics older than the raw window into hourly averages (idempotent upsert), deletes the rolled raw rows, then prunes stale rollups + lifecycle events. Core cleanup.py drives it each hourly run via the capability (no plugin-model import), reading the three retention windows fresh from settings so changes apply without restart (rule 25). Settings → "Thresholds & Retention" gains a Docker retention card (raw / rolled-up / events windows, working defaults 7/90/30 days). Unit tests cover the hour-aligned cutoff/bucketing helpers; integration test exercises the real rollup-average + prune across both windows. Milestone 77 task #941. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC |
||
|
|
578cc33cc0 |
feat(docker): ingest swarm topology + lifecycle events + health/restart alerts
Wires the agent's enriched + swarm payloads through the docker.persist_host_
samples capability:
* Swarm topology — persist sample["swarm"] into docker_swarm_services /
docker_swarm_nodes (upsert + prune stale, host-scoped so two managers don't
clobber). Migration docker_005 adds services.placement_json for the
task→node placement the agent now reports.
* Lifecycle events — _derive_events (pure, unit-tested) diffs the newest
snapshot against stored per-container state: start / stop / die (non-zero
exit) / oom / health_change → docker_events rows. Skipped on a host's first
snapshot so the baseline doesn't emit a start per existing container.
* Alerts — record restart_count (always) and is_healthy (1.0/0.0, only when a
HEALTHCHECK exists) alongside cpu/mem, under host-scoped resource names;
METRIC_CATALOG[docker] gains restart_count + is_healthy so they're alertable.
host_agent ingest captures the newest sample's swarm object and threads it to
the capability (now persist_host_docker(session, host, snapshots, swarm=None));
invoked when containers OR swarm are present, under the same SAVEPOINT. Unit
tests cover the event-diff matrix; integration tests cover event derivation
across two snapshots and swarm topology round-trip (incl. placement).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
|
||
|
|
7b80552a7d |
feat(docker): per-host collection via the host agent; drop central scrape
Docker collection moves off the central single-socket scrape onto the host
agent, giving Docker a real per-host dimension. The Steward host now reports
its own containers like any other host, and same-named containers on different
hosts no longer collide.
- agent: stdlib UDS Docker client (AF_UNIX HTTP/1.1, Connection: close,
chunked-aware), collect_docker() ports the cpu%/mem math; sample["docker"]
added best-effort (silent-skip on absent/unreadable socket). AGENT_VERSION
1.2.0 → 1.3.0; optional docker_socket config key.
- ingest: host_agent ingest hands per-host container snapshots to the docker
plugin via a new "docker.persist_host_samples" capability (no hard import,
no-op when docker disabled), inside a SAVEPOINT so a docker failure never
sinks the host metrics. Resource names are host-scoped ("<host>/<name>").
- schema: docker_containers re-keyed (host_id, name); docker_metrics gains
host_id; docker_002 migration DROP+recreates (dev-only, rule 122).
- ui: Docker page + widgets grouped by host with host links; new per-host
Docker panel embedded on the Hosts hub (gated on docker enabled via a new
enabled_plugins template context). Replaces the SQLite-only strftime
bucketing with DB-agnostic Python bucketing.
- provisioning: install/provision playbooks add steward-agent to the docker
group (best-effort) so the agent can read the socket.
- removed central scrape: docker scheduler.py + scraper.py deleted; plugin.yaml
socket_path/scrape_interval_seconds/include_stopped dropped (plugin 2.0.0).
- tests: agent docker collector units (math, chunked decode, silent-skip,
sample shape, config) + integration (host-scoped schema + persistence).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
|
||
|
|
35f658b573 |
feat(monitors): unify ping/dns/http into one Monitor entity + custom targets
Collapse the three former check types into a single core `Monitor` entity
with one management surface (/monitors), one result table (monitor_results),
and a single scheduled task. Every type can now watch a free-standing custom
destination (optional host_id) — not just a registered Host.
- models: Monitor + MonitorResult replace PingResult/DnsResult; Host loses its
ping/dns facet columns (now Monitor rows linked by host_id).
- checks: monitors/{ping,dns,http}.py pure probes + runner.run_monitor
dispatcher; one monitor_check scheduler with a per-monitor due-filter.
- status: single monitor_status_source replaces the three sources.
- UI: /monitors blueprint (type-aware add/edit/list/widget); host hub shows a
host's linked monitors + "add monitor for this host"; nav + widget registry
+ alert metric catalog rewired. http plugin folded into core and removed.
- migration 0022 merges the http branch, data-migrates host facets +
http_monitors + all three result histories, drops the old tables/columns.
Resolves the per-host ping/dns auto-attach issue (#275): monitors are now
explicit, never auto-added to every host.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
|
||
|
|
591706bd39 |
feat(settings): configurable monitoring thresholds
Move the hardcoded warn/crit cutoffs into Settings -> Thresholds (DB-backed, live, no restart). New thresholds.* keys + to_thresholds_cfg() + a threshold_style(value, kind) jinja global that reads them; latency reuses the existing ping good/warn keys, uptime is direction-aware (floors). Replace the _macros metric_style/uptime_style macros (now removed) with the global across Hosts-Overview, host_agent fleet + panel, Uptime/SLA widget, and the ping page uptime column — all now honor the configured cutoffs. Uptime keeps its green 'good' look when not degraded. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e446b7099e |
feat(dashboard): threshold colors on Hosts-Overview ping + 24h uptime
Add an inverted uptime_style macro (low-is-bad mirror of metric_style) and color the inline ping latency (warn 100ms / crit 250ms) and 24h uptime values in the Hosts-Overview widget, which were the remaining uncolored metrics. (Uptime/SLA + Ping widgets already colored degraded values.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ae03f09234 |
feat(dashboard): merge top summary strip + Status widget into one Overview
Operator chose a single canonical summary. Repurpose the status_overview widget into 'Overview' (host count + monitor up/down/pending + Alerts link; key kept so existing dashboards upgrade in place) and remove the redundant fixed top strip from the dashboard view. Drops _get_summary_stats and its now-unused PingResult/DnsResult imports (rule 22). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
10808c1c5d |
fix(dashboard): inline cpu sparkline, history graph host label + stable y-axis
- Hosts-Overview: move the CPU sparkline inline next to the cpu % value it represents (was floated far right on the name line, reading as unrelated). - Host Agent history widget: caption the chart with the host it represents (the panel title is generic) — links to the host hub. - History widget: snap the y-axis to a stable 0..next-10%-band ceiling instead of auto-scaling to the exact peak, so the 'zoom' no longer jumps each poll. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e58c86cf01 |
feat(dashboard): widget readability — name-per-line, fill graphs, container breakpoints
Address graphical issues raised from the dashboard screenshot: - No more truncated host names. Hosts-Overview and Host-Agent-Resources put the host name on its own line (full, wraps if needed) with its data grouped beneath it; ping/dns (.ping-name) and uptime widget names wrap instead of ellipsis. Prefer vertical overflow over cramming/truncating a row. - History graph fills the panel: drop the fixed 0–100 y-axis ceiling (beginAtZero + 8% grace) so the lines use the vertical space instead of hugging the bottom; axis labels still show real %. - Container-query breakpoints: the widget body is now a query container, so fragments restyle to their OWN panel width. Host-list widgets flow into 2 cols ≥520px and 3 cols ≥900px (.host-blocks) — use the width, remove vertical deadspace — and collapse to one column when narrow. Mirrored into the share view. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
cb47b5e977 |
feat(dashboard): per-metric sparklines in the Host Agent — Resources widget
Operator ask: show the graphs next to their fields in the fleet widget, like the host page's AGENT panel. Each row now renders cpu/mem/disk/load as a value with a trend sparkline beneath it (1h window), instead of bare numbers. - _fleet_rows fetches a per-host recent series (cpu/mem/load host-level, disk from the root mount) in one 1h query and attaches a sparkline per metric to each row. - widget_table.html lays out a metric cell (label + threshold-coloured value + sparkline) per field, mirroring panel.html. Threshold colour is computed in the loop and passed into the cell macro (keeps Jinja macro scope clean). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
88dca32d3c |
feat(dashboard): edit in place over the live dashboard with a bottom widget drawer
Milestone 72 phase D — the dashboard view IS the edit surface (operator ask): - /d/<id> renders the grid via Gridstack in STATIC mode — positioned exactly like before, live HTMX widget bodies keep polling. An "Edit" button flips the same grid interactive (grid.setStatic(false)) in place, so you drag/resize over real data. "Done" flips it back. Layout autosaves on change. - The widget picker is now a bottom drawer (position:fixed overlay) revealed by body.dash-editing — so the dashboard width is identical entering/leaving edit. - Add: POST returns a single grid item; JS inserts it + grid.makeWidget + htmx.process so it loads live data. Remove: POST 204 + grid.removeWidget. Per-panel drag handle + remove ✕ are in the DOM for editors, shown only while editing. - Gridstack loads for everyone (viewers get the static grid); edit wiring is gated on can_edit. Mobile collapses to one column. - Removed the separate /d/<id>/edit route + edit.html + _edit_panels.html (rule 22). Dashboard-list Edit and new-dashboard create now deep-link /d/<id>?edit=1 which opens edit mode on load. Browser-only behaviour — CI can't exercise it; needs an operator visual check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
988c13d51f |
feat(dashboard): phase C richer panels — host time-series graph + cpu sparklines
Milestone 72 phase C — bring the host-view graphs onto the dashboard:
- host_resource_history widget reworked into a real host-view chart: epoch-ms
linear axis (no Chart.js date adapter), themed like the host-detail charts,
maintainAspectRatio:false so it fills the resized panel, unique canvas per
widget instance (wid), and empty states ("pick a host" / "no metrics yet").
Was previously unusable — it had a broken time axis and no way to choose a host.
- Add a "host" param type: the edit form renders a live dropdown of hosts
(dashboard routes now pass the host list to the editor); the chosen host_id is
stored in config and fed to the widget.
- Hosts-overview widget gains a per-row CPU sparkline (last hour) via the shared
sparkline_svg helper — the host-view at-a-glance trend, on the main widget.
Charts/sparklines render only in the browser, so CI can't exercise them — needs
an operator visual check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
525f6eedbd |
feat(dashboard): phase B drag-resize grid (Gridstack) replacing masonry
Milestone 72 phase B — Grafana-style drag-resize grid for dashboard widgets: - DashboardWidget: replace `position` with a 12-col grid placement (grid_x/grid_y/grid_w/grid_h). Migration 0021 backfills the old position order into a 3-up grid (4 cols x 4 cells each) and drops position. - View + share render a static CSS grid from x/y/w/h: fixed cell height (h * 70px) with the body scrolling, so the arranged layout is what's shown; collapses to a single column under 820px. - Edit view: Gridstack.js 12.6.0 (vanilla, CDN, pinned) — drag the title bar to move, drag a corner/edge to resize; every change autosaves to a new /d/<id>/edit/layout endpoint. Replaces the SortableJS position reorder. - add_widget appends at the bottom-left; remove no longer renumbers. _get_widgets now orders by grid position (drives DOM + mobile fallback order). Note: Gridstack drag-resize is browser-only, so CI can't exercise it — needs an operator visual check of the edit experience. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5f92340c0c |
feat(dashboard): phase A widget clarity — threshold colours, host links, tooltips
Milestone 72 phase A (clarity wins, no schema change): - Add shared metric_style() macro in _macros.html: colours a numeric metric amber (>=warn) / red (>=crit) on the value ITSELF, not just the status dot. Defaults 80/90 for percentage gauges; load /core uses warn 80 / crit 100. Applied to CPU/mem/disk across host_agent panel + fleet widget + the unified hosts-overview widget. - Link every host reference to the host hub (/hosts/<id>) in ping, dns, hosts-overview, host_agent fleet, and uptime widgets; status widget entries link to their own detail_url. All guarded on session.user_id so the public share view degrades to plain text (the bare href carries no share token). - Fix truncation: title= tooltips on all names that ellipsis, plus a hover underline affordance on the now-clickable ping-name links. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2ea8c2f9af |
feat(ansible): bundled system_update maintenance playbook
New maintenance/system_update.yml: cross-distro OS package upgrade (apt +
dnf/yum) with two run-form flags:
- restart_services: restart every service that needs it after the upgrade
(Debian via needrestart -r a, installed if missing; RHEL via
needs-restarting -s → systemctl try-restart).
- reboot_if_required: reboot the host only when a reboot is actually pending
(Debian /var/run/reboot-required; RHEL needs-restarting -r). Never reboots
otherwise.
Tagged steward:category=maintenance and steward:confirm=true (significant /
reboot-capable), so it shows in the run UI with the confirm gate and both flags
as fill-in fields. No code changes — auto-discovered from the builtin source.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
88afad9de4 |
feat(hosts): re-provision action on reporting hosts; Ansible off the edit page
- Host agent panel (reporting state) gains a "Re-provision" collapsible (admin + linked target + managed key): bootstrap user/password → provision.yml, which reinstalls the steward account + managed key + agent. This is the missing path after regenerating the managed key — Update alone can't fix a broken key. - Remove the Ansible sections (run-playbook + target link) from the host EDIT page — they were the stale free-text version and the wrong place. They live on the host detail hub (dropdown + discovered variables). Edit page now links to the host page; edit_host route simplified (no ansible fetch). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
71715c38d8 |
feat(auth): return to the original view after an auth-expiry redirect
When a session has expired, require_role now bounces to /login?next=<path> and sends the user back there after re-login. - middleware: safe_next_url() (same-site relative path/query only; rejects off-site, protocol-relative, javascript:, and the auth pages — no open redirect). _login_redirect() builds the next param; for HTMX requests it uses HX-Current-URL (the page, not the fragment) and HX-Redirect so the whole browser navigates instead of swapping the login page into a fragment. - login GET/POST carry `next` (hidden field), validated, used on success for local + LDAP; OIDC stashes it in session across the IdP round-trip. - login.html: hidden next field + next on the SSO link. - tests for safe_next_url. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2594ca517d |
fix(ansible): clear error when the managed SSH key can't be decrypted
When ansible.ssh_private_key can't be decrypted (app secret key changed), decrypt_secret returns the ciphertext unchanged; the executor was writing that enc:v1: blob as the SSH key file → cryptic "Load key ...: error in libcrypto" → Permission denied. Now: - build_credentials skips a still-encrypted key value (never writes ciphertext as a key file). - start_run broadcasts a plain-language run error: "The managed SSH key could not be decrypted (the app secret key changed). Regenerate it in Settings → Ansible and re-provision the host(s)." The run still fails (no usable key), but the reason is now obvious instead of a libcrypto error. Operator remedy: regenerate the managed key + re-provision. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e0253fba48 |
fix(ansible): surface run failure reason; widget audit cleanup
Run UX: when start_run throws before/around launch (e.g. ENOSPC creating the temp dir — the box is out of disk), the run was marked "failed" with empty output. Now the exception is broadcast + written to the run output/results so the run view shows e.g. "[run error] OSError: [Errno 28] No space left on device" instead of a blank failure. Widget audit follow-ups (no broken links were found; these are consistency): - host_resource_history widget now charts root (/) disk, consistent with the host panel (was the opaque "disk worst"). - host_resources widget: tooltip on the health dot explaining it warns on the worst mount while the number shows root. - status_overview widget detail_url /status → /status/ (avoid redirect). - Normalize ad-hoc widget empty-states to the shared .empty style (wording, which distinguishes "configured" vs "data yet", preserved). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
609bd78af2 |
feat(dashboard): unified Hosts widget + grouped widget picker
Bring the dashboard in line with the unified host IA + improved displays. - New core "Hosts — Overview" widget (/hosts/overview/widget): one row per host combining monitor status (ping dot + latency, uptime 24h) with the agent glance (CPU / memory / disk root + stale flag), each row linking to the host hub. Reads agent data from the generic PluginMetric table via a core-safe _agent_overview_by_host helper (no host_agent import); freshness vs the plugin's stale window. The granular Ping/DNS/Uptime/Agent widgets stay. - Group the add-widget picker into Core monitors / Monitoring capabilities / Integrations (a `group` field on every WIDGET_REGISTRY entry + section headings in _edit_panels.html), matching the Settings → Plugins taxonomy. - Fix the agent fleet widget rows to link to the host hub (/hosts/<id>) instead of the old /plugins/host_agent/<id>/ page. Scribe #903. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |