perf(metrics): a latest-value table for "what is it now"; dashboard cells say loading, failed and as-of (#5208)
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 1m14s
CI / unit (push) Successful in 1m33s
CI / publish (push) Skipped

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>
This commit is contained in:
2026-10-06 23:06:34 -04:00
co-authored by Claude Opus 5.5
parent ba140ab109
commit b8f73270e7
20 changed files with 487 additions and 202 deletions
+30 -1
View File
@@ -18,7 +18,7 @@ from steward.models.alerts import (
MaintenanceWindow,
)
from steward.core.formatting import human_duration
from steward.models.metrics import PluginMetric
from steward.models.metrics import PluginMetric, PluginMetricLatest
if TYPE_CHECKING:
from quart import Quart
@@ -155,6 +155,34 @@ def _after_commit(session: AsyncSession, fn, *args) -> None:
info[_AFTER_COMMIT].append((fn, args))
LATEST_CHUNK = 1000
async def _keep_latest(session: AsyncSession, rows: list[PluginMetric]) -> None:
"""Move each key's row in plugin_metric_latest to the newest sample in
`rows`. A row only moves forward, so a backlog arriving after newer samples
leaves it alone. Keys go in sorted order, so two batches sharing keys lock
them in the same order and cannot deadlock."""
from sqlalchemy.dialects.postgresql import insert as pg_insert
newest: dict[tuple[str, str, str], PluginMetric] = {}
for row in rows:
key = (row.source_module, row.resource_name, row.metric_name)
if key not in newest or row.recorded_at > newest[key].recorded_at:
newest[key] = row
values = [{"source_module": k[0], "resource_name": k[1], "metric_name": k[2],
"value": newest[k].value, "recorded_at": newest[k].recorded_at}
for k in sorted(newest)]
# Five parameters a row; chunks keep a statement well under asyncpg's 32767.
for i in range(0, len(values), LATEST_CHUNK):
stmt = pg_insert(PluginMetricLatest).values(values[i:i + LATEST_CHUNK])
await session.execute(stmt.on_conflict_do_update(
index_elements=["source_module", "resource_name", "metric_name"],
set_={"value": stmt.excluded.value, "recorded_at": stmt.excluded.recorded_at},
where=PluginMetricLatest.recorded_at <= stmt.excluded.recorded_at,
))
async def record_metrics(session: AsyncSession, rows: list[PluginMetric], phases=None) -> None:
"""Write metric rows and evaluate the alert rules that watch them.
@@ -182,6 +210,7 @@ async def record_metrics(session: AsyncSession, rows: list[PluginMetric], phases
row.recorded_at = now
session.add_all(rows)
await session.flush()
await _keep_latest(session, rows)
mark("flush")
by_key: dict[tuple[str, str, str], list[PluginMetric]] = {}
+35 -12
View File
@@ -11,7 +11,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import func, or_, select, text
from steward.core.time_range import bucket_seconds
from steward.models.metrics import PluginMetric, PluginMetricHourly
from steward.models.metrics import PluginMetric, PluginMetricHourly, PluginMetricLatest
# Loose index scans: step through the distinct leading values of the
# (source_module, resource_name, …) index one probe at a time instead of
@@ -48,26 +48,49 @@ async def resources(db, source_module: str) -> list[str]:
return [v for (v,) in await db.execute(_RESOURCES_SQL, {"src": source_module})]
def _resource_match(resource: str, include_children: bool):
match = PluginMetricLatest.resource_name == resource
if include_children:
like = resource.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + ":%"
match = or_(match, PluginMetricLatest.resource_name.like(like, escape="\\"))
return match
async def latest_values(db, source_module: str, resource: str, *,
include_children: bool = False) -> list[PluginMetric]:
include_children: bool = False) -> list[PluginMetricLatest]:
"""The newest sample of each metric for a resource, sorted by (resource, metric).
include_children also returns the resource's sub-resources, named
"<resource>:<part>" (a host's mounts, interfaces, sensors).
"<resource>:<part>" (a host's mounts, interfaces, sensors). Read from
plugin_metric_latest, so the cost is the resource's metric count, not its
history.
"""
match = PluginMetric.resource_name == resource
if include_children:
like = resource.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + ":%"
match = or_(match, PluginMetric.resource_name.like(like, escape="\\"))
return list((await db.execute(
select(PluginMetric)
.where(PluginMetric.source_module == source_module, match)
.distinct(PluginMetric.resource_name, PluginMetric.metric_name)
.order_by(PluginMetric.resource_name, PluginMetric.metric_name,
PluginMetric.recorded_at.desc())
select(PluginMetricLatest)
.where(PluginMetricLatest.source_module == source_module,
_resource_match(resource, include_children))
.order_by(PluginMetricLatest.resource_name, PluginMetricLatest.metric_name)
)).scalars())
async def latest_by_resource(db, source_module: str, resources, metrics=None,
) -> dict[str, dict[str, PluginMetricLatest]]:
"""{resource: {metric: newest sample}} for several resources of a source,
optionally only the named metrics. A resource with no samples is absent."""
resources = list(resources)
if not resources:
return {}
q = select(PluginMetricLatest).where(
PluginMetricLatest.source_module == source_module,
PluginMetricLatest.resource_name.in_(resources))
if metrics is not None:
q = q.where(PluginMetricLatest.metric_name.in_(list(metrics)))
out: dict[str, dict[str, PluginMetricLatest]] = {}
for r in (await db.execute(q)).scalars():
out.setdefault(r.resource_name, {})[r.metric_name] = r
return out
async def points(db, source_module: str, resource: str, metric: str, since: datetime,
*, target: int = 120) -> list[tuple[datetime, float]]:
"""(time, average) points for one metric since `since`, oldest first.
+7 -1
View File
@@ -49,7 +49,7 @@ async def rollup_plugin_metrics(
from sqlalchemy import delete, func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from steward.models.metrics import PluginMetric, PluginMetricHourly
from steward.models.metrics import PluginMetric, PluginMetricHourly, PluginMetricLatest
if now is None:
now = datetime.now(timezone.utc)
@@ -97,6 +97,11 @@ async def rollup_plugin_metrics(
await session.execute(
delete(PluginMetric).where(PluginMetric.recorded_at < raw_cutoff)
)
# A latest value goes with its raw history: a resource that stopped
# reporting a raw window ago is no longer "now" anywhere.
latest_pruned = (await session.execute(
delete(PluginMetricLatest).where(PluginMetricLatest.recorded_at < raw_cutoff)
)).rowcount or 0
# ── 2. Prune rolled-up rows beyond the rollup window ──
rollup_cutoff = now - timedelta(days=rollup_days)
@@ -109,4 +114,5 @@ async def rollup_plugin_metrics(
"buckets_rolled": rolled,
"raw_rows_rolled": rolled_rows,
"rollup_pruned": rollup_pruned,
"latest_pruned": latest_pruned,
}
+4 -2
View File
@@ -11,7 +11,7 @@ from sqlalchemy import and_, delete, func, or_, update
from sqlalchemy.ext.asyncio import AsyncSession
from steward.models.alerts import AlertRule, MaintenanceWindow
from steward.models.metrics import PluginMetric, PluginMetricHourly
from steward.models.metrics import PluginMetric, PluginMetricHourly, PluginMetricLatest
# What a host's name keys, and how each source names a part of the host:
# host_agent "web01:/var", "web01:core3"; docker "web01/nginx".
@@ -47,7 +47,7 @@ async def carry_rename(session: AsyncSession, sources, old: str, new: str) -> di
if not old or old == new:
return {}
sources = tuple(sources)
for model in (PluginMetric, PluginMetricHourly):
for model in (PluginMetric, PluginMetricHourly, PluginMetricLatest):
await session.execute(delete(model).where(
_matches(model.source_module, model.resource_name, sources, new)))
moved = {}
@@ -58,6 +58,8 @@ async def carry_rename(session: AsyncSession, sources, old: str, new: str) -> di
("metric_rows", PluginMetric, PluginMetric.source_module, PluginMetric.resource_name),
("hourly_rows", PluginMetricHourly, PluginMetricHourly.source_module,
PluginMetricHourly.resource_name),
("latest_rows", PluginMetricLatest, PluginMetricLatest.source_module,
PluginMetricLatest.resource_name),
):
result = await session.execute(
update(model).where(_matches(src, res, sources, old))
+22 -64
View File
@@ -144,92 +144,50 @@ async def host_monitors(db, host_id: str) -> list[dict]:
async def agent_metrics_by_host(db, host_names: list[str]) -> dict[str, dict]:
"""Latest agent cpu/mem per host name from the generic PluginMetric table.
"""Latest agent cpu/mem per host name, from plugin_metric_latest.
Read directly (PluginMetric is a core model) so the Hosts list can show an
Core-safe (no host_agent import), so the Hosts list can show an
at-a-glance agent column without importing the host_agent plugin.
"""
from steward.models.metrics import PluginMetric
if not host_names:
return {}
wanted = ("cpu_pct", "mem_used_pct")
subq = (
select(
PluginMetric.resource_name, PluginMetric.metric_name,
func.max(PluginMetric.recorded_at).label("m"),
)
.where(
PluginMetric.source_module == "host_agent",
PluginMetric.metric_name.in_(wanted),
PluginMetric.resource_name.in_(host_names),
)
.group_by(PluginMetric.resource_name, PluginMetric.metric_name)
).subquery()
rows = (await db.execute(
select(PluginMetric).join(
subq,
(PluginMetric.resource_name == subq.c.resource_name)
& (PluginMetric.metric_name == subq.c.metric_name)
& (PluginMetric.recorded_at == subq.c.m),
)
)).scalars().all()
out: dict[str, dict] = {}
for r in rows:
out.setdefault(r.resource_name, {})[r.metric_name] = r.value
return out
from steward.core.metric_series import latest_by_resource
latest = await latest_by_resource(db, "host_agent", host_names, ("cpu_pct", "mem_used_pct"))
return {name: {m: r.value for m, r in metrics.items()} for name, metrics in latest.items()}
async def agent_overview_by_host(
db, host_names: list[str], stale_after_seconds: int = 180,
) -> dict[str, dict]:
"""Per-host agent glance (cpu/mem/disk + freshness) from PluginMetric.
"""Per-host agent glance (cpu/mem/disk + freshness) from plugin_metric_latest.
{host_name: {cpu_pct, mem_used_pct, disk_used_pct_worst, disk_root,
last_at, fresh}} for each host with any agent samples.
Core-safe (no host_agent import). Freshness compares the latest host-level
sample against the host_agent plugin's stale window (stale_after_seconds;
callers pass agent_stale_after_seconds(app.config)).
callers pass agent_stale_after_seconds(app.config)). A host whose agent has
gone quiet keeps its last values with fresh=False (#5208).
"""
from steward.models.metrics import PluginMetric
from steward.core.metric_series import latest_by_resource
if not host_names:
return {}
now = datetime.now(timezone.utc)
stale_after = int(stale_after_seconds)
resources = list(host_names) + [h + ":/" for h in host_names]
subq = (
select(
PluginMetric.resource_name, PluginMetric.metric_name,
func.max(PluginMetric.recorded_at).label("m"),
)
.where(
PluginMetric.source_module == "host_agent",
PluginMetric.resource_name.in_(resources),
PluginMetric.metric_name.in_(AGENT_GLANCE_METRICS),
)
.group_by(PluginMetric.resource_name, PluginMetric.metric_name)
).subquery()
rows = (await db.execute(
select(PluginMetric).join(
subq,
(PluginMetric.resource_name == subq.c.resource_name)
& (PluginMetric.metric_name == subq.c.metric_name)
& (PluginMetric.recorded_at == subq.c.m),
).where(PluginMetric.source_module == "host_agent")
)).scalars().all()
latest = await latest_by_resource(
db, "host_agent", list(host_names) + [h + ":/" for h in host_names],
set(AGENT_GLANCE_METRICS) | {"disk_used_pct"})
out: dict[str, dict] = {}
for r in rows:
if r.resource_name.endswith(":/") and r.metric_name == "disk_used_pct":
out.setdefault(r.resource_name[:-2], {})["disk_root"] = r.value
elif r.resource_name in host_names:
d = out.setdefault(r.resource_name, {})
d[r.metric_name] = r.value
if d.get("_ts") is None or r.recorded_at > d["_ts"]:
d["_ts"] = r.recorded_at
for d in out.values():
ts = d.pop("_ts", None)
for name in host_names:
own = {m: r for m, r in latest.get(name, {}).items() if m in AGENT_GLANCE_METRICS}
root = latest.get(name + ":/", {}).get("disk_used_pct")
if not own and root is None:
continue
d = {m: r.value for m, r in own.items()}
if root is not None:
d["disk_root"] = root.value
ts = max((r.recorded_at for r in own.values()), default=None)
d["last_at"] = ts
d["fresh"] = bool(ts and (now - ts).total_seconds() <= stale_after)
out[name] = d
return out
@@ -0,0 +1,55 @@
"""The newest sample of each metric, kept beside plugin_metrics (#5208)
Revision ID: 0030_plugin_metric_latest
Revises: 0029_alert_no_data
Create Date: 2026-10-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0030_plugin_metric_latest"
down_revision: Union[str, None] = "0029_alert_no_data"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"plugin_metric_latest",
sa.Column("source_module", sa.String(64), primary_key=True),
sa.Column("resource_name", sa.String(255), primary_key=True),
sa.Column("metric_name", sa.String(128), primary_key=True),
sa.Column("value", sa.Float, nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
)
# Seed from history without a full scan: step through the distinct keys of
# the (source, resource, metric, recorded_at) index one probe at a time,
# then take each key's newest row with a backward probe of the same index.
op.execute("""
WITH RECURSIVE k AS (
(SELECT source_module, resource_name, metric_name FROM plugin_metrics
ORDER BY source_module, resource_name, metric_name LIMIT 1)
UNION ALL
SELECT n.source_module, n.resource_name, n.metric_name
FROM k, LATERAL (
SELECT p.source_module, p.resource_name, p.metric_name FROM plugin_metrics p
WHERE (p.source_module, p.resource_name, p.metric_name)
> (k.source_module, k.resource_name, k.metric_name)
ORDER BY p.source_module, p.resource_name, p.metric_name LIMIT 1
) n
)
INSERT INTO plugin_metric_latest
(source_module, resource_name, metric_name, value, recorded_at)
SELECT k.source_module, k.resource_name, k.metric_name, l.value, l.recorded_at
FROM k, LATERAL (
SELECT p.value, p.recorded_at FROM plugin_metrics p
WHERE p.source_module = k.source_module AND p.resource_name = k.resource_name
AND p.metric_name = k.metric_name
ORDER BY p.recorded_at DESC LIMIT 1
) l
""")
def downgrade() -> None:
op.drop_table("plugin_metric_latest")
+19
View File
@@ -30,6 +30,25 @@ class PluginMetric(Base):
)
class PluginMetricLatest(Base):
"""The newest sample of each (source, resource, metric), kept beside
plugin_metrics by record_metrics so "what is it now" never scans history.
Postgres has no skip scan, so a newest-per-key query over plugin_metrics
reads every raw sample in the window: on a seven-day window that took a
dashboard widget a minute (#5208). This table is one row per key. Retention
drops a row once its sample has aged out of the raw window, so a resource
that stopped reporting disappears when its raw history does.
"""
__tablename__ = "plugin_metric_latest"
source_module: Mapped[str] = mapped_column(String(64), primary_key=True)
resource_name: Mapped[str] = mapped_column(String(255), primary_key=True)
metric_name: Mapped[str] = mapped_column(String(128), primary_key=True)
value: Mapped[float] = mapped_column(Float, nullable=False)
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
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.
+90
View File
@@ -0,0 +1,90 @@
/* Dashboard widget cells: say when a cell is loading, when it could not load,
and how fresh what it shows is (#5208).
Each .dash-cell-body is an htmx target with a request deadline
(hx-request timeout), so every fetch ends in a swap or one of the error
events below. A cell that polls keeps polling through htmx's own `every`
trigger; a cell that loads once is retried here, re-armed from the failure
handler of each attempt, so a failed retry arms the next one. */
(function () {
var RETRY_SECONDS = 30;
function hhmm(d) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function asofOf(cell) {
var box = cell.closest('.grid-stack-item-content, .dash-cell');
return box ? box.querySelector('.widget-asof') : null;
}
function isCell(el) {
return el && el.classList && el.classList.contains('dash-cell-body');
}
function scheduleRetry(cell) {
clearTimeout(cell._retry);
if (cell.dataset.poll === '1') return; // its own poll is the retry
cell._retry = setTimeout(function () {
htmx.trigger(cell, 'retry');
}, RETRY_SECONDS * 1000);
}
function failed(cell, why) {
var asof = asofOf(cell);
var when = cell.dataset.loadedAt ? new Date(+cell.dataset.loadedAt) : null;
if (when) {
// Keep what it showed; say it is no longer current.
if (asof) {
asof.textContent = "couldn't refresh · as of " + hhmm(when);
asof.classList.add('widget-asof-failed');
asof.title = why;
}
} else {
var retryLine = cell.dataset.poll === '1'
? 'Trying again with the next refresh.'
: 'Trying again in ' + RETRY_SECONDS + 's.';
cell.innerHTML = '';
var box = document.createElement('div');
box.className = 'cell-state cell-state-failed';
var line = document.createElement('div');
line.textContent = "Couldn't load this widget (" + why + '). ' + retryLine;
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-sm';
btn.textContent = 'Retry now';
btn.addEventListener('click', function () {
clearTimeout(cell._retry);
htmx.trigger(cell, 'retry');
});
box.appendChild(line);
box.appendChild(btn);
cell.appendChild(box);
}
scheduleRetry(cell);
}
document.body.addEventListener('htmx:afterSwap', function (e) {
var cell = e.detail.target;
if (!isCell(cell)) return;
clearTimeout(cell._retry);
var now = new Date();
cell.dataset.loadedAt = String(now.getTime());
var asof = asofOf(cell);
if (asof) {
asof.textContent = 'as of ' + hhmm(now);
asof.classList.remove('widget-asof-failed');
asof.title = 'Loaded ' + now.toLocaleString();
}
});
document.body.addEventListener('htmx:responseError', function (e) {
if (isCell(e.detail.target)) failed(e.detail.target, 'HTTP ' + e.detail.xhr.status);
});
document.body.addEventListener('htmx:sendError', function (e) {
if (isCell(e.detail.target)) failed(e.detail.target, 'no connection');
});
document.body.addEventListener('htmx:timeout', function (e) {
if (isCell(e.detail.target)) failed(e.detail.target, 'timed out');
});
})();
+6
View File
@@ -371,6 +371,12 @@ body.dash-editing .widget-drawer { transform:translateY(0); }
.widget-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem; }
.widget-title { font-family:var(--fs-font-body); font-size:var(--fs-size-tiny); font-weight:500; color:var(--text-muted); text-transform:uppercase; letter-spacing:var(--fs-tracking-tiny); }
.widget-link { font-size:0.8rem; color:var(--text-muted); }
/* A widget cell's own state (#5208): loading, couldn't load, and how fresh
the shown data is. Set by static/dash-cells.js. */
.widget-asof { font-size:var(--fs-size-tiny); color:var(--text-dim); white-space:nowrap; }
.widget-asof-failed { color:var(--yellow); }
.cell-state { font-size:0.85rem; color:var(--text-muted); padding:0.25rem 0; }
.cell-state-failed { display:flex; flex-direction:column; align-items:flex-start; gap:0.5rem; color:var(--yellow); }
.widget-link:hover { color:var(--text); }
/* Time range selector */
+5 -2
View File
@@ -11,6 +11,7 @@
<span class="widget-title" title="{{ item.title }}">{{ item.title }}</span>
</span>
<span style="display:flex;align-items:center;gap:0.5rem;flex-shrink:0;">
<span class="widget-asof" aria-live="polite"></span>
<a href="{{ item.defn.detail_url }}" class="widget-link">Details →</a>
{% if can_edit %}<button type="button" class="panel-chrome btn btn-danger btn-sm"
data-remove="{{ item.db.id }}" title="Remove widget"
@@ -19,7 +20,9 @@
</div>
<div class="dash-cell-body" id="widget-{{ item.db.id }}"
hx-get="{{ item.hx_url }}"
hx-trigger="load{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
hx-swap="innerHTML"></div>
hx-trigger="load, retry{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
hx-request='{"timeout": 20000}'
data-poll="{{ '1' if item.defn.poll else '0' }}"
hx-swap="innerHTML"><div class="cell-state">Loading…</div></div>
</div>
</div>
+1
View File
@@ -55,6 +55,7 @@
{% block extra_scripts %}
<script src="{{ url_for('static', filename='vendor/gridstack-12.6.0-all.min.js') }}"></script>
<script src="{{ url_for('static', filename='dash-cells.js') }}"></script>
<script>
(function () {
var gridEl = document.querySelector('.grid-stack');
+6 -3
View File
@@ -11,6 +11,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='steward.css') }}">
<script src="{{ url_for('static', filename='chart-colors.js') }}"></script>
<script defer src="{{ url_for('static', filename='dash-cells.js') }}"></script>
{# The palette, type and widget classes come from steward.css like every
other page; only the read-only page's own frame and its grid live here. #}
<style>
@@ -45,13 +46,15 @@
style="grid-column:{{ gw.grid_x + 1 }} / span {{ gw.grid_w }};grid-row:{{ gw.grid_y + 1 }} / span {{ gw.grid_h }};">
<div class="widget-header">
<span class="widget-title" title="{{ item.title }}">{{ item.title }}</span>
<span class="widget-asof" aria-live="polite"></span>
</div>
{# Pass share token as &s= param so require_role allows through #}
<div class="dash-cell-body" id="widget-{{ gw.id }}"
hx-get="{{ item.hx_url }}&s={{ raw_token }}"
hx-trigger="load{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
hx-swap="innerHTML">
</div>
hx-trigger="load, retry{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
hx-request='{"timeout": 20000}'
data-poll="{{ '1' if item.defn.poll else '0' }}"
hx-swap="innerHTML"><div class="cell-state">Loading…</div></div>
</div>
{% endfor %}
</div>