View mode splits each saved row into seven, so a widget no longer carries up
to 69px of empty space below its content (Overview, SNMP). Edit mode and the
saved layout keep 70px rows. The Busiest Containers host column aligns left
like its header, its note fits one line, and the Hosts column reads Disk.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each widget is as tall as its content, never taller than its saved height
(where it scrolls). The dashboard is capped at 2400px instead of 1400px.
Past 12 columns' worth of width it adds columns (one per 110px, up to 24)
and places widgets again in reading order at their saved widths, so more sit
side by side instead of stretching. Edit mode shows and saves the
12-column layout, so a layout doesn't depend on the screen it was edited on.
The share view uses the same layout code (static/dash-layout.js).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dashboard's host widgets took 18-60 s. Every "latest reading" query
(hosts overview, host-agent fleet, host page, vitals, SNMP, MCP get_host)
searched plugin_metrics for the newest row per metric. Postgres has no skip
scan, so each one read every raw sample in the 7-day window.
- plugin_metric_latest: one row per (source, resource, metric).
record_metrics upserts it in the same transaction, moving forward only and
in sorted key order. Migration 0030 seeds it by stepping the existing index.
- Retention drops a latest row once its sample leaves the raw window, so a
resource that stopped reporting disappears when its history does, as before.
Renames carry it along.
- A quiet host keeps its last values, marked stale. A time-bounded query
would have shown it as missing.
- Vitals sparklines span the 6 h they claim, averaged in SQL to ~120 points,
instead of fetching 6 h raw and drawing the last 120 samples.
- Dashboard and share-view cells: "Loading…" until the first answer; a 20 s
deadline per fetch; "Couldn't load (why)" with Retry now and an automatic
retry for cells that don't poll; "as of HH:MM" in the header, and
"couldn't refresh · as of" when a refresh fails over shown data.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An audit of 1,980 text/surface pairs (every steward.css rule and every inline
template style, tints composited over page, card and hovered row) found:
- Red, yellow and orange status text on its own 20% tint over a hovered row
at 3.97-4.06:1 (badges, count badge, alert-error, btn-warn). --red and
--yellow are lifted 15% toward the primary text, --orange 18%, as --blue
already was: worst case now 4.54:1.
- Inputs, selects, ghost and range buttons outlined in --border-mid at
1.4-1.9:1, on surfaces 1.1:1 apart, so the outline is what marks them.
New --border-control (45% toward secondary text): 3.13:1 worst. The
dashboard switcher and widget-drawer fields drop their inline styles for
the shared .input class.
- The breadcrumb separator painted in a border colour (1.4-1.9:1): now
--text-dim (4.64:1) and aria-hidden, since it is decoration.
tests/test_contrast.py resolves the tokens (color-mix and tints included)
and holds every drawn pair to 4.5:1, status text on its tint to 4.5:1 and
control outlines to 3:1, with a test that the guard catches the old pairs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5091 "Local assets". base.html, the dashboard and the share view loaded
htmx and Chart.js from unpkg/jsdelivr, Gridstack from jsdelivr and their
fonts from Google. On an instance with no outbound network (rule 164) every
page lost htmx, and so every button and poll; and each page load told those
hosts about the visitor (rule 155).
- steward/static/vendor: htmx 2.0.4, Chart.js 4.4.4, Gridstack 12.6.0 (the
versions the pages already used), with their licences (0BSD, MIT, MIT).
- steward/static/fonts: Inter, Fraunces and JetBrains Mono, the design
system's faces (SIL OFL, licences beside them), every unicode-range
subset, with fonts.css. The serif face is now Fraunces in place of
EB Garamond (base) and Libertinus Serif (share view); applying Inter and
the type scale is the tokens step.
- Templates use url_for('static'); /static already passes every gate.
- Guard (tests/test_no_remote_assets.py): no template or stylesheet loads a
script, stylesheet, font or image from another origin (comments stripped,
example URLs in help text allowed), with a self-test that it can fail;
every url_for('static') file and every font the sheet names exists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5031 "Forged-request protection: SameSite cookie, per-session CSRF token
on every non-GET, HTMX header". A page on another site could make a
logged-in browser POST to Steward with the session cookie attached.
- steward/auth/csrf.py: a per-session token, checked in one before_request
for every POST/PUT/PATCH/DELETE (header X-CSRF-Token or form field
csrf_token, constant-time compare). The bearer-authenticated host-agent
ingest is exempt; /mcp never reaches Quart.
- Every POST form renders {{ csrf_field() }}; base.html carries the token
in a meta tag and adds it to every HTMX request; the dashboard's fetch()
calls send it.
- The session cookie is SameSite=Lax; login issues a fresh token.
- PUBLIC_BASE_URL is now loaded from Settings at startup (it was only set
when Settings → General was saved).
- tests/test_csrf.py: refusal, acceptance, rotation, the ingest exemption,
and a guard that every POST form in the templates carries the field.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
Renames the Python package directory, CLI command, env var prefix,
docker-compose service/container/image, Postgres role/db, and all
visible branding. Marketing form is "Fabled Steward".
Clean break from the previous rebrand: drops the fabledscryer→roundtable
import shim in __init__.py and the FABLEDSCRYER_* env var fallback in
config.py and migrations/env.py. Env vars are now STEWARD_* only.
Heads-up for existing deployments:
- Postgres user/db renamed fabledscryer → steward in docker-compose.yml.
Existing volumes need the role/db renamed inside Postgres, or override
POSTGRES_USER/POSTGRES_DB to keep the old names.
- Host-agent systemd unit is now steward-agent.service. Existing agents
keep running under the old name; reinstall to switch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>