diff --git a/steward/core/status.py b/steward/core/status.py index 6bc88e2..2586fcf 100644 --- a/steward/core/status.py +++ b/steward/core/status.py @@ -35,7 +35,6 @@ class StatusEntry: heartbeat: list[dict] = field(default_factory=list) # oldest-first {state,title} latency_ms: float | None = None spark: list[float] = field(default_factory=list) # response series - spark_svg: str = "" # filled by the route via sparkline_svg() tls_days: float | None = None # days until TLS expiry (http only) detail_url: str | None = None # When the current up/down state began, as far as the recent checks show; diff --git a/steward/core/widgets.py b/steward/core/widgets.py index 5c15d22..6b0fbb4 100644 --- a/steward/core/widgets.py +++ b/steward/core/widgets.py @@ -65,7 +65,7 @@ WIDGET_REGISTRY: dict[str, dict] = { "label": "Overview", "description": "What needs attention across Steward: hosts, monitors and firing alerts, problems listed with since-times", "hx_url": "/status/widget", - "detail_url": "/status/", + "detail_url": "/monitors/", "plugin": None, "poll": True, "params": [], diff --git a/steward/monitors/routes.py b/steward/monitors/routes.py index 244c3bb..ab1544b 100644 --- a/steward/monitors/routes.py +++ b/steward/monitors/routes.py @@ -16,7 +16,6 @@ from steward.core.audit import audit_request from steward.models.users import UserRole from steward.models.monitors import Monitor, MonitorType, MONITOR_TYPE_VALUES from steward.core.status import _heartbeat_title, streak_start -from steward.core.time_range import DEFAULT_RANGE from steward.models.hosts import Host from steward.monitors import service as monitor_svc @@ -47,23 +46,15 @@ def _form_opts(form, mtype: str) -> dict: @require_role(UserRole.viewer) async def index(): poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60) - return await render_template( - "monitors/index.html", - poll_interval=poll_interval, - current_range=request.args.get("range", DEFAULT_RANGE), - ) + return await render_template("monitors/index.html", poll_interval=poll_interval) @monitors_bp.get("/rows") @require_role(UserRole.viewer) async def rows(): async with current_app.db_sessionmaker() as db: - data, counts, range_key = await monitor_svc.monitor_rows( - db, request.args.get("range", DEFAULT_RANGE), current_app.config.get("THRESHOLDS", {})) - return await render_template( - "monitors/rows.html", - monitor_data=data, counts=counts, range_key=range_key, - ) + data, counts = await monitor_svc.monitor_rows(db, current_app.config.get("THRESHOLDS", {})) + return await render_template("monitors/rows.html", monitor_data=data, counts=counts) @monitors_bp.post("/add") @@ -219,8 +210,7 @@ async def widget(): widget_id = request.args.get("wid", "0") async with current_app.db_sessionmaker() as db: - data, counts, _ = await monitor_svc.monitor_rows( - db, DEFAULT_RANGE, current_app.config.get("THRESHOLDS", {})) + data, counts = await monitor_svc.monitor_rows(db, current_app.config.get("THRESHOLDS", {})) if type_filter in MONITOR_TYPE_VALUES: data = [d for d in data if d["monitor"].type == type_filter] # Counted after the type filter, so the verdict speaks for what is listed. diff --git a/steward/monitors/service.py b/steward/monitors/service.py index c081f3a..17626fc 100644 --- a/steward/monitors/service.py +++ b/steward/monitors/service.py @@ -14,7 +14,6 @@ from sqlalchemy import func, select from steward.core.formatting import status_class from steward.core.status import _heartbeat_title, _last_n_results, _uptime_by_monitor -from steward.core.time_range import parse_range from steward.models.monitors import MONITOR_TYPE_VALUES, Monitor, MonitorResult, MonitorType HTTP_METHODS = ("GET", "HEAD", "POST") @@ -171,40 +170,37 @@ def problems_first(rows: list[dict]) -> list[dict]: return sorted(rows, key=lambda d: (STATE_ORDER[d["state"]], d["monitor"].name.lower())) -async def monitor_rows(db, range_key: str, thresholds: dict): - """Per-monitor state: latest result, heartbeat strip, uptime and TLS days, - problems first. +async def monitor_rows(db, thresholds: dict): + """Per-monitor state: latest result, heartbeat strip, latency trend, + uptime over 24h / 7d / 30d and TLS days, problems first. - Returns (rows, counts, range_key). `counts` has up (degraded included), - degraded, down, pending and paused. `range_key` picks the uptime window - surfaced (24h/7d/30d; anything else falls back to 24h) and comes back - normalised. + Returns (rows, counts). `counts` has up (degraded included), degraded, + down, pending and paused. A monitor is degraded on its 24h uptime. """ - _since, range_key = parse_range(range_key) + from steward.core.status import sparkline_svg monitors = list((await db.execute(select(Monitor).order_by(Monitor.name))).scalars()) ids = [m.id for m in monitors] recent = await _last_n_results(db, ids) uptime = await _uptime_by_monitor(db, ids) - # Map the selected range to the uptime bucket we surface in the list. - bucket = {"24h": "24h", "7d": "7d", "30d": "30d"}.get(range_key, "24h") now = datetime.now(timezone.utc) data = [] for m in monitors: rows = recent.get(m.id, []) latest = rows[-1] if rows else None - uptime_pct = uptime.get(m.id, {}).get(bucket) + windows = uptime.get(m.id, {}) tls_days = None if latest and latest.tls_expires_at: tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0 + times = [r.response_ms for r in rows if r.response_ms is not None] data.append({ "monitor": m, "latest": latest, - "state": row_state(m, latest, uptime_pct, thresholds), + "state": row_state(m, latest, windows.get("24h"), thresholds), "heartbeat": [{"state": "up" if r.is_up else "down", "title": _heartbeat_title(m.type, r)} for r in rows], - "uptime_pct": uptime_pct, - "uptime": uptime.get(m.id, {}), + "spark": sparkline_svg(times, width=56, height=16, stroke="currentColor") if len(times) > 1 else "", + "uptime": windows, "tls_days": tls_days, "down_since": None, "down_reason": down_reason(latest) if latest is not None and not latest.is_up else None, @@ -215,7 +211,7 @@ async def monitor_rows(db, range_key: str, thresholds: dict): d["down_since"] = await down_since(db, d["monitor"].id) counts = {k: sum(d["state"] == k for d in data) for k in STATE_ORDER} counts["up"] += counts["degraded"] - return problems_first(data), counts, range_key + return problems_first(data), counts async def down_since(db, monitor_id: str): diff --git a/steward/static/steward.css b/steward/static/steward.css index ab4077d..df3e360 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -324,6 +324,15 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat .ping-row-paused { opacity:0.7; } .row-actions { display:flex; gap:0.35rem; flex-shrink:0; } .ping-meta { min-width:120px; max-width:180px; flex-shrink:1; overflow:hidden; } +/* Monitors list columns (#5270): fixed widths under one header row. */ +.mon-head { font-size:var(--fs-size-tiny); color:var(--text-dim); text-transform:uppercase; letter-spacing:var(--fs-tracking-tiny); padding-top:0; } +.mon-head .ping-pills { display:block; } +.ping-meta.mon-col-name { width:14rem; min-width:0; max-width:none; flex-shrink:0; } +.mon-latest { width:7.5rem; flex-shrink:0; text-align:right; font-size:0.8rem; font-variant-numeric:tabular-nums; white-space:nowrap; } +.mon-spark { color:var(--text-dim); margin-right:0.35rem; } +.mon-up { width:4.2rem; flex-shrink:0; text-align:right; font-size:0.8rem; font-variant-numeric:tabular-nums; } +.mon-tls { width:3.5rem; flex-shrink:0; text-align:right; font-size:0.78rem; font-variant-numeric:tabular-nums; } +.mon-act { width:6.6rem; justify-content:flex-end; } .ping-name { font-size:0.875rem; font-weight:500; display:block; overflow-wrap:anywhere; } a.ping-name:hover { text-decoration:underline; } /* Dashboard widget grid — Gridstack-rendered. Static (positioned but inert) in diff --git a/steward/status/__init__.py b/steward/status/__init__.py index d0efc1c..42f0bbc 100644 --- a/steward/status/__init__.py +++ b/steward/status/__init__.py @@ -1 +1,2 @@ -# steward/status/ — unified monitor-status surface (page + dashboard widget) +# steward/status/ — the dashboard Overview widget (/status/widget). The Status +# page it once sat beside was folded into Monitors (#5270). diff --git a/steward/status/routes.py b/steward/status/routes.py index 961d813..1b7363a 100644 --- a/steward/status/routes.py +++ b/steward/status/routes.py @@ -4,7 +4,7 @@ from sqlalchemy import func, select from steward.alerts.service import active_alerts, alert_label from steward.auth.middleware import require_role -from steward.core.status import collect_status, sparkline_svg +from steward.core.status import collect_status from steward.models.hosts import Host from steward.models.users import UserRole @@ -18,31 +18,6 @@ def _summarize(entries) -> tuple[int, int, int]: return up, down, pending -@status_bp.get("/") -@require_role(UserRole.viewer) -async def index(): - poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60) - return await render_template("status/index.html", poll_interval=poll_interval) - - -@status_bp.get("/rows") -@require_role(UserRole.viewer) -async def rows(): - """HTMX fragment — what is wrong (down monitors, active alerts), then the - full status list, refreshed on poll.""" - async with current_app.db_sessionmaker() as db: - entries = await collect_status(db) - alerts = await active_alerts(db) - for e in entries: - if e.spark: - e.spark_svg = sparkline_svg(e.spark) - up, down, pending = _summarize(entries) - return await render_template( - "status/rows.html", entries=entries, up=up, down=down, pending=pending, - alerts=alerts, problems=[e for e in entries if e.status == "down"], - ) - - @status_bp.get("/widget") @require_role(UserRole.viewer) async def widget(): diff --git a/steward/templates/base.html b/steward/templates/base.html index 448e6a7..80bdca5 100644 --- a/steward/templates/base.html +++ b/steward/templates/base.html @@ -46,7 +46,6 @@