feat(dashboard): merge top summary strip + Status widget into one Overview
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 2m15s
CI / publish (push) Successful in 51s

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>
This commit is contained in:
2026-06-17 20:40:26 -04:00
parent 10808c1c5d
commit ae03f09234
5 changed files with 22 additions and 75 deletions
+2 -2
View File
@@ -24,8 +24,8 @@ WIDGET_REGISTRY: dict[str, dict] = {
}, },
"status_overview": { "status_overview": {
"key": "status_overview", "key": "status_overview",
"label": "Status — Overview", "label": "Overview",
"description": "Unified up/down/pending summary across ping, DNS, and HTTP monitors", "description": "At-a-glance summary — host count, monitor up/down/pending, and alerts",
"hx_url": "/status/widget", "hx_url": "/status/widget",
"detail_url": "/status/", "detail_url": "/status/",
"plugin": None, "plugin": None,
-39
View File
@@ -9,7 +9,6 @@ from sqlalchemy import select, func, update
from steward.auth.middleware import require_role, current_user_id from steward.auth.middleware import require_role, current_user_id
from steward.models.users import UserRole from steward.models.users import UserRole
from steward.models.hosts import Host from steward.models.hosts import Host
from steward.models.monitors import PingResult, DnsResult
from steward.models.dashboard import Dashboard, DashboardWidget, DashboardShareToken from steward.models.dashboard import Dashboard, DashboardWidget, DashboardShareToken
from steward.core.settings import public_base_url from steward.core.settings import public_base_url
from steward.core.widgets import get_available_widgets, WIDGET_REGISTRY from steward.core.widgets import get_available_widgets, WIDGET_REGISTRY
@@ -103,42 +102,6 @@ async def _get_or_create_system_default(db) -> Dashboard:
return dash return dash
async def _get_summary_stats(db) -> dict:
latest_ping_subq = (
select(PingResult.host_id, func.max(PingResult.probed_at).label("max_at"))
.group_by(PingResult.host_id).subquery()
)
pr = await db.execute(
select(PingResult).join(
latest_ping_subq,
(PingResult.host_id == latest_ping_subq.c.host_id)
& (PingResult.probed_at == latest_ping_subq.c.max_at),
)
)
pings = list(pr.scalars())
ping_up = sum(1 for p in pings if p.status.value == "up")
ping_down = sum(1 for p in pings if p.status.value == "down")
latest_dns_subq = (
select(DnsResult.host_id, func.max(DnsResult.resolved_at).label("max_at"))
.group_by(DnsResult.host_id).subquery()
)
dr = await db.execute(
select(DnsResult).join(
latest_dns_subq,
(DnsResult.host_id == latest_dns_subq.c.host_id)
& (DnsResult.resolved_at == latest_dns_subq.c.max_at),
)
)
dns = list(dr.scalars())
dns_ok = sum(1 for d in dns if d.status.value == "resolved")
dns_fail = sum(1 for d in dns if d.status.value != "resolved")
total = (await db.execute(select(func.count()).select_from(Host))).scalar()
return dict(ping_up=ping_up, ping_down=ping_down,
dns_ok=dns_ok, dns_fail=dns_fail, total_hosts=total)
def _resolve_widgets(widgets: list[DashboardWidget]) -> list[dict]: def _resolve_widgets(widgets: list[DashboardWidget]) -> list[dict]:
plugins_cfg = current_app.config.get("PLUGINS", {}) plugins_cfg = current_app.config.get("PLUGINS", {})
out = [] out = []
@@ -199,7 +162,6 @@ async def view(dash_id: int):
if dash is None or not _can_view(dash, user_id, user_role): if dash is None or not _can_view(dash, user_id, user_role):
abort(404) abort(404)
stats = await _get_summary_stats(db)
widgets = await _get_widgets(db, dash_id) widgets = await _get_widgets(db, dash_id)
accessible = await _accessible_dashboards(db, user_id, user_role) accessible = await _accessible_dashboards(db, user_id, user_role)
can_edit = _can_edit(dash, user_id, user_role) can_edit = _can_edit(dash, user_id, user_role)
@@ -224,7 +186,6 @@ async def view(dash_id: int):
can_edit=can_edit, can_edit=can_edit,
addable=addable, addable=addable,
hosts=hosts, hosts=hosts,
**stats,
) )
+8 -1
View File
@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, current_app, render_template, request from quart import Blueprint, current_app, render_template, request
from sqlalchemy import func, select
from steward.auth.middleware import require_role from steward.auth.middleware import require_role
from steward.core.status import collect_status, sparkline_svg from steward.core.status import collect_status, sparkline_svg
from steward.models.hosts import Host
from steward.models.users import UserRole from steward.models.users import UserRole
status_bp = Blueprint("status", __name__, url_prefix="/status") status_bp = Blueprint("status", __name__, url_prefix="/status")
@@ -40,12 +42,17 @@ async def rows():
@status_bp.get("/widget") @status_bp.get("/widget")
@require_role(UserRole.viewer) @require_role(UserRole.viewer)
async def widget(): async def widget():
"""HTMX dashboard widget — compact up/down/pending summary + problem list.""" """HTMX dashboard 'Overview' widget — the single at-a-glance summary that
replaced the old fixed top strip: host count + monitor up/down/pending +
problem list + an alerts link."""
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
entries = await collect_status(db) entries = await collect_status(db)
total_hosts = (await db.execute(
select(func.count()).select_from(Host))).scalar()
up, down, pending = _summarize(entries) up, down, pending = _summarize(entries)
return await render_template( return await render_template(
"status/widget.html", "status/widget.html",
entries=entries, up=up, down=down, pending=pending, entries=entries, up=up, down=down, pending=pending,
total_hosts=total_hosts,
widget_id=request.args.get("wid", "0"), widget_id=request.args.get("wid", "0"),
) )
+2 -31
View File
@@ -33,37 +33,8 @@
</div> </div>
</div> </div>
{# ── Summary strip ─────────────────────────────────────────────────────────── #} {# The old fixed summary strip (Hosts/Ping/DNS/Alerts) was merged into the single
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:0.75rem;margin-bottom:1.5rem;"> 'Overview' widget (status_overview) so there's one canonical summary, not two. #}
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Hosts</div>
<span class="stat-val">{{ total_hosts }}</span>
<span class="stat-label">total</span>
<div style="margin-top:0.5rem;"><a href="/hosts/" style="font-size:0.8rem;">Manage →</a></div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Ping</div>
{% if ping_up + ping_down == 0 %}
<span style="color:var(--text-muted);font-size:0.85rem;">No hosts</span>
{% else %}
<span class="stat-val" style="color:var(--green);">{{ ping_up }}</span><span class="stat-label">up</span>
{% if ping_down %}&nbsp;<span class="stat-val" style="color:var(--red);">{{ ping_down }}</span><span class="stat-label">down</span>{% endif %}
{% endif %}
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">DNS</div>
{% if dns_ok + dns_fail == 0 %}
<span style="color:var(--text-muted);font-size:0.85rem;">No hosts</span>
{% else %}
<span class="stat-val" style="color:var(--green);">{{ dns_ok }}</span><span class="stat-label">ok</span>
{% if dns_fail %}&nbsp;<span class="stat-val" style="color:var(--red);">{{ dns_fail }}</span><span class="stat-label">failed</span>{% endif %}
{% endif %}
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Alerts</div>
<a href="/alerts/" style="font-size:0.85rem;">View rules →</a>
</div>
</div>
{# ── Widget grid (Gridstack; static until Edit is toggled) ──────────────────── #} {# ── Widget grid (Gridstack; static until Edit is toggled) ──────────────────── #}
{% if not widgets %} {% if not widgets %}
+10 -2
View File
@@ -1,8 +1,16 @@
{# status/widget.html — compact dashboard summary + problem list #} {# status/widget.html — the unified Overview: host count + monitor health
<div style="display:flex;gap:1.25rem;align-items:baseline;margin-bottom:0.6rem;"> (up/down/pending) + alerts link + problem list. Replaced the old fixed
top summary strip. #}
<div style="display:flex;flex-wrap:wrap;gap:1.25rem;align-items:baseline;margin-bottom:0.6rem;">
<span>
{% if session.user_id %}<a href="/hosts/" style="color:inherit;text-decoration:none;">{% endif %}
<span class="stat-val" style="font-size:1.5rem;">{{ total_hosts }}</span><span class="stat-label">host{{ '' if total_hosts == 1 else 's' }}</span>
{% if session.user_id %}</a>{% endif %}
</span>
<span><span class="stat-val" style="font-size:1.5rem;color:var(--green);">{{ up }}</span><span class="stat-label">up</span></span> <span><span class="stat-val" style="font-size:1.5rem;color:var(--green);">{{ up }}</span><span class="stat-label">up</span></span>
<span><span class="stat-val" style="font-size:1.5rem;color:{% if down %}var(--red){% else %}var(--text-dim){% endif %};">{{ down }}</span><span class="stat-label">down</span></span> <span><span class="stat-val" style="font-size:1.5rem;color:{% if down %}var(--red){% else %}var(--text-dim){% endif %};">{{ down }}</span><span class="stat-label">down</span></span>
<span><span class="stat-val" style="font-size:1.5rem;color:{% if pending %}var(--yellow){% else %}var(--text-dim){% endif %};">{{ pending }}</span><span class="stat-label">pending</span></span> <span><span class="stat-val" style="font-size:1.5rem;color:{% if pending %}var(--yellow){% else %}var(--text-dim){% endif %};">{{ pending }}</span><span class="stat-label">pending</span></span>
{% if session.user_id %}<a href="/alerts/" style="margin-left:auto;align-self:center;font-size:0.8rem;">Alerts →</a>{% endif %}
</div> </div>
{% set problems = entries | rejectattr("status", "equalto", "up") | list %} {% set problems = entries | rejectattr("status", "equalto", "up") | list %}