diff --git a/docs/plugins/host-agent-design.md b/docs/plugins/host-agent-design.md index e81e5bd..39199c6 100644 --- a/docs/plugins/host-agent-design.md +++ b/docs/plugins/host-agent-design.md @@ -157,7 +157,6 @@ plugins/host_agent/ │ └── host_agent_001_initial.py # creates host_agent_registrations table ├── templates/ │ ├── detail.html # per-host detail page -│ ├── widget_table.html # dashboard fleet table widget │ ├── widget_history.html # dashboard history chart widget │ ├── settings_list.html # plugin settings: list of registered hosts │ └── install.sh.j2 # install script template (Jinja) @@ -438,7 +437,7 @@ echo "Logs: journalctl -u steward-agent -f" ### Dashboard widgets (`steward/core/widgets.py`) -- **`host_resources`** — table widget. One row per monitored host: name, CPU %, mem %, disk % (worst mount), load 1m, "last seen Xs ago" with red/yellow/green coloring. Fleet glance. +- The fleet glance (CPU, mem, disk /, load 1m, stale mark) is the core **Hosts — Overview** widget (`hosts_overview`); the old `host_resources` table was folded into it (#5213). - **`host_resource_history`** — chart widget for one host. CPU / mem / disk over a selectable time range (1h, 6h, 24h, 7d). Same pattern as every other history widget — Chart.js. ### Per-host detail page diff --git a/plugins/host_agent/routes.py b/plugins/host_agent/routes.py index 20a90a7..4448dcc 100644 --- a/plugins/host_agent/routes.py +++ b/plugins/host_agent/routes.py @@ -17,7 +17,7 @@ from sqlalchemy import select, or_, and_ from datetime import timedelta from steward.core.formatting import worst_reading -from steward.core.metric_series import latest_by_resource, points as metric_points +from steward.core.metric_series import points as metric_points from steward.core.settings import get_setting, public_base_url from steward.core.time_range import parse_range, RANGE_OPTIONS from steward.models.hosts import Host @@ -477,108 +477,6 @@ def _stale_after_seconds() -> int: ) -async def _fleet_rows(session) -> list[dict]: - """One row per registered host with its latest host-level metrics + stale flag. - - Only bare host-level resources are used here (sub-resources carry a ':' and - are skipped) so the fleet glance stays one line per host. - """ - regs = (await session.execute(select(HostAgentRegistration))).scalars().all() - host_ids = [r.host_id for r in regs] - hosts = { - h.id: h for h in (await session.execute( - select(Host).where(Host.id.in_(host_ids)) - )).scalars().all() - } if host_ids else {} - - host_names = [h.name for h in hosts.values()] - latest_by = await latest_by_resource( - session, SOURCE_MODULE, host_names + [n + ":/" for n in host_names]) - latest: dict[str, dict[str, float]] = {} - root_disk: dict[str, float] = {} # host_name → root (/) disk % - for resource, metrics in latest_by.items(): - if resource.endswith(":/"): - if "disk_used_pct" in metrics: - root_disk[resource[:-2]] = metrics["disk_used_pct"].value - continue - latest[resource] = {m: r.value for m, r in metrics.items()} - - # Per-host recent series for the inline sparklines (cpu/mem/load host-level, - # disk from the root mount), so each metric shows its trend beside the value — - # the host-panel at-a-glance, on the fleet widget. 1h window keeps it cheap. - spark_series: dict[str, dict[str, list]] = { - n: {"cpu": [], "mem": [], "disk": [], "load": []} for n in host_names - } - if host_names: - since = datetime.now(timezone.utc) - timedelta(hours=1) - sp_rows = (await session.execute( - select(PluginMetric).where( - PluginMetric.source_module == SOURCE_MODULE, - PluginMetric.recorded_at >= since, - or_( - and_(PluginMetric.resource_name.in_(host_names), - PluginMetric.metric_name.in_(("cpu_pct", "mem_used_pct", "load_1m"))), - and_(PluginMetric.resource_name.in_([n + ":/" for n in host_names]), - PluginMetric.metric_name == "disk_used_pct"), - ), - ).order_by(PluginMetric.recorded_at) - )).scalars().all() - for r in sp_rows: - if r.resource_name.endswith(":/"): - name = r.resource_name[:-2] - if name in spark_series: - spark_series[name]["disk"].append(r.value) - else: - key = {"cpu_pct": "cpu", "mem_used_pct": "mem", "load_1m": "load"}.get(r.metric_name) - if key and r.resource_name in spark_series: - spark_series[r.resource_name][key].append(r.value) - - from steward.core.status import sparkline_svg - - stale_after = _stale_after_seconds() - now = datetime.now(timezone.utc) - rows = [] - for reg in regs: - host = hosts.get(reg.host_id) - if host is None: - continue - m = latest.get(host.name, {}) - ls = reg.last_seen_at - stale = ls is None or (now - ls).total_seconds() > stale_after - ser = spark_series.get(host.name, {}) - sparks = { - k: (sparkline_svg(v[-60:], width=72, height=16) if len(v) >= 2 else "") - for k, v in ser.items() - } - rows.append({ - "host": host, - "reg": reg, - "stale": stale, - "cpu_pct": m.get("cpu_pct"), - "mem_used_pct": m.get("mem_used_pct"), - "disk_worst": m.get("disk_used_pct_worst"), # health dot only - "disk_root": root_disk.get(host.name), # displayed at-a-glance - "load_1m": m.get("load_1m"), - "temp_max": m.get("temp_c_max"), - "net_rx_bps": m.get("net_rx_bps"), - "net_tx_bps": m.get("net_tx_bps"), - "disk_read_bps": m.get("disk_read_bps"), - "disk_write_bps": m.get("disk_write_bps"), - "sparks": sparks, - }) - rows.sort(key=lambda r: r["host"].name.lower()) - return rows - - -@host_agent_bp.get("/widget") -@require_role(UserRole.viewer) -async def widget_table(): - """Fleet-glance dashboard widget: one row per monitored host, latest metrics.""" - async with current_app.db_sessionmaker() as session: - rows = await _fleet_rows(session) - return await render_template("widget_table.html", rows=rows) - - @host_agent_bp.get("/widget/history") @require_role(UserRole.viewer) async def widget_history(): diff --git a/plugins/host_agent/templates/widget_table.html b/plugins/host_agent/templates/widget_table.html deleted file mode 100644 index 31c892e..0000000 --- a/plugins/host_agent/templates/widget_table.html +++ /dev/null @@ -1,69 +0,0 @@ -{# host_agent widget — fleet glance. One horizontal row per host, zebra-striped - so adjacent hosts read as distinct groupings: health dot + host name on the - left, metric cells (cpu/mem/disk/load) each with a trend sparkline laid out - horizontally on the right. The name wraps (never hard-truncated) but the row - stays horizontal. #} -{% if rows %} -{% macro metric_cell(label, value, fmt, svg, style="", title="") %} -
-
{{ label }}
-
- {% if value is not none %}{{ fmt|format(value) }}{% else %}—{% endif %} -
-
{{ svg | safe }}
-
-{% endmacro %} -{# Two-line down/up (or read/write) throughput cell. #} -{% macro io_cell(label, down_sym, down_val, up_sym, up_val, title="") %} -
-
{{ label }}
-
-
{{ down_sym }} {{ down_val|rate }}
-
{{ up_sym }} {{ up_val|rate }}
-
-
-{% endmacro %} -
- {% for r in rows %} - {% set stale = r.stale %} -
- {# Left — health dot + full host name (wraps, never truncated) + last seen #} -
- - - {% if session.user_id %} - {{ r.host.name }} - {% else %} - {{ r.host.name }} - {% endif %} - - {% if r.reg.last_seen_at %}seen {{ r.reg.last_seen_at|ago }}{% else %}never seen{% endif %} - - -
- {# Right — metric cells with value + trend, laid out horizontally #} -
- {{ metric_cell("cpu", r.cpu_pct, "%.0f%%", r.sparks.cpu, threshold_style(r.cpu_pct, 'cpu'), "Average CPU utilization") }} - {{ metric_cell("mem", r.mem_used_pct, "%.0f%%", r.sparks.mem, threshold_style(r.mem_used_pct, 'mem'), "Memory in use") }} - {{ metric_cell("disk /", r.disk_root, "%.0f%%", r.sparks.disk, threshold_style(r.disk_root, 'disk'), "Root filesystem (/) usage") }} - {{ metric_cell("load", r.load_1m, "%.2f", r.sparks.load, "", "Load average (1m)") }} - {# Extra agent metrics — shown only when the host reports them (VMs/containers - often have no temp sensor, etc.) so absent data doesn't clutter the row. #} - {% if r.net_rx_bps is not none or r.net_tx_bps is not none %} - {{ io_cell("net", "↓", r.net_rx_bps, "↑", r.net_tx_bps, "Network throughput (down / up)") }} - {% endif %} - {% if r.disk_read_bps is not none or r.disk_write_bps is not none %} - {{ io_cell("disk i/o", "rd", r.disk_read_bps, "wr", r.disk_write_bps, "Disk I/O (read / write)") }} - {% endif %} - {% if r.temp_max is not none %} - {{ metric_cell("temp", r.temp_max, "%.0f°C", "", threshold_style(r.temp_max, 'temp'), "Hottest sensor") }} - {% endif %} -
-
- {% endfor %} -
-{% else %} -

No hosts with agent data yet.

-{% endif %} diff --git a/plugins/unifi/routes.py b/plugins/unifi/routes.py index e5e00a6..55b48ea 100644 --- a/plugins/unifi/routes.py +++ b/plugins/unifi/routes.py @@ -324,33 +324,3 @@ async def widget_clients(): snapshot=snapshot, widget_id=widget_id, ) - - -@unifi_bp.get("/widget/devices") -@require_role(UserRole.viewer) -async def widget_devices(): - """HTMX dashboard widget: infrastructure devices with optional type filter.""" - type_filter = request.args.get("type_filter", "all") - widget_id = request.args.get("wid", "0") - - async with current_app.db_sessionmaker() as db: - result = await db.execute( - select(UnifiDevice).order_by(UnifiDevice.state.desc(), UnifiDevice.name) - ) - all_devices = result.scalars().all() - - if type_filter == "wireless": - devices = [d for d in all_devices if d.device_type == "uap"] - elif type_filter == "wired": - devices = [d for d in all_devices if d.device_type != "uap"] - elif type_filter == "offline": - devices = [d for d in all_devices if d.state != 1] - else: - devices = list(all_devices) - - return await render_template( - "unifi/widget_devices.html", - devices=devices, - type_filter=type_filter, - widget_id=widget_id, - ) diff --git a/plugins/unifi/templates/unifi/widget_devices.html b/plugins/unifi/templates/unifi/widget_devices.html deleted file mode 100644 index 3c04ae7..0000000 --- a/plugins/unifi/templates/unifi/widget_devices.html +++ /dev/null @@ -1,36 +0,0 @@ -{# unifi/widget_devices.html — dashboard widget: infrastructure devices #} -{% if not devices %} -
- {% if type_filter != "all" %}No {{ type_filter }} devices found.{% else %}No devices found.{% endif %} -
-{% else %} - -{% set online = devices | selectattr("state", "equalto", 1) | list %} -{% set offline = devices | rejectattr("state", "equalto", 1) | list %} - -
-
- {{ online | length }} - online -
- {% if offline %} -
- {{ offline | length }} - offline -
- {% endif %} -
- -
- {% for d in devices %} -
- - - {{ d.name or d.mac }} - - {{ d.model or d.device_type or "" }} -
- {% endfor %} -
- -{% endif %} diff --git a/steward/core/widgets.py b/steward/core/widgets.py index ae6d769..6fa6b09 100644 --- a/steward/core/widgets.py +++ b/steward/core/widgets.py @@ -9,6 +9,9 @@ # type - "select" (only type currently supported) # default - default value (int or str determines how it's parsed from the form) # options - list of {value, label} dicts +# +# "poll" refreshes the cell every monitor poll interval; "poll_seconds" +# overrides that cadence for a widget whose data moves slowly. from __future__ import annotations WIDGET_REGISTRY: dict[str, dict] = { @@ -60,7 +63,7 @@ WIDGET_REGISTRY: dict[str, dict] = { "status_overview": { "key": "status_overview", "label": "Overview", - "description": "At-a-glance summary — host count, monitor up/down/pending, and alerts", + "description": "What needs attention across Steward: hosts, monitors and firing alerts, problems listed with since-times", "hx_url": "/status/widget", "detail_url": "/status/", "plugin": None, @@ -70,7 +73,7 @@ WIDGET_REGISTRY: dict[str, dict] = { "hosts_overview": { "key": "hosts_overview", "label": "Hosts — Overview", - "description": "One row per host: monitor status + uptime and agent CPU/memory/disk, linking to the host hub", + "description": "One row per host: monitor status, uptime, and agent CPU, memory, disk and load, linking to the host page", "hx_url": "/hosts/overview/widget", "detail_url": "/hosts/", "plugin": None, @@ -84,7 +87,9 @@ WIDGET_REGISTRY: dict[str, dict] = { "hx_url": "/hosts/uptime/widget", "detail_url": "/hosts/uptime", "plugin": None, - "poll": False, + "poll": True, + # Uptime moves slowly and reads 30 days of checks: every 5 minutes, not every poll. + "poll_seconds": 300, "params": [], }, "traefik_routers": { @@ -184,29 +189,6 @@ WIDGET_REGISTRY: dict[str, dict] = { }, ], }, - "unifi_devices": { - "key": "unifi_devices", - "label": "UniFi — Devices", - "description": "Network infrastructure devices with online/offline state", - "hx_url": "/plugins/unifi/widget/devices", - "detail_url": "/plugins/unifi/", - "plugin": "unifi", - "poll": True, - "params": [ - { - "key": "type_filter", - "label": "Device type", - "type": "select", - "default": "all", - "options": [ - {"value": "all", "label": "All devices"}, - {"value": "wireless", "label": "Wireless APs"}, - {"value": "wired", "label": "Wired only"}, - {"value": "offline", "label": "Offline devices"}, - ], - }, - ], - }, "docker_containers": { "key": "docker_containers", "label": "Docker — Services & failures", @@ -260,16 +242,6 @@ WIDGET_REGISTRY: dict[str, dict] = { "poll": True, "params": [], }, - "host_resources": { - "key": "host_resources", - "label": "Host Agent — Resources", - "description": "Fleet view: CPU, memory, disk, and load per host — each with a trend sparkline", - "hx_url": "/plugins/host_agent/widget", - "detail_url": "/hosts/", - "plugin": "host_agent", - "poll": True, - "params": [], - }, "host_resource_history": { "key": "host_resource_history", "label": "Host Agent — History Graph", @@ -301,6 +273,47 @@ WIDGET_REGISTRY: dict[str, dict] = { }, } +# Widgets that were merged into another (#5213): migration 0032 moved every +# placement of the old key onto the new one, so nothing here renders them. +RETIRED = { + "host_resources": "hosts_overview", # Hosts — Overview carries cpu/mem/disk/load per host + "unifi_devices": "unifi_overview", # UniFi — Overview lists the offline devices +} + +# The recommended dashboard (#5213): what needs attention first, then the +# fleet, then each integration. (key, width, height, config) on the 12-column +# grid, one cell 70px tall; a widget whose plugin is off is skipped and the +# rest close up. Seeded into the first dashboard and offered by +# "Reset to the recommended layout" in edit mode. +RECOMMENDED: list[tuple[str, int, int, dict]] = [ + ("status_overview", 12, 3, {}), + ("hosts_overview", 6, 7, {}), + ("monitors", 6, 7, {"show_down_only": "no", "limit": 10}), + ("docker_containers", 6, 6, {}), + ("docker_resources", 6, 6, {"limit": 10}), + ("uptime_summary", 6, 5, {}), + ("traefik_routers", 6, 5, {}), + ("unifi_overview", 6, 4, {}), + ("snmp_devices", 6, 4, {}), + ("traefik_request_chart", 12, 4, {}), +] + + +def recommended_layout(plugins_cfg: dict) -> list[dict]: + """RECOMMENDED placed on the grid for what is enabled: left to right, + a new row when the next one does not fit, each row as tall as its + tallest widget. [{key, x, y, w, h, config}].""" + available = {w["key"] for w in get_available_widgets(plugins_cfg)} + out, x, y, row_h = [], 0, 0, 0 + for key, w, h, config in RECOMMENDED: + if key not in available: + continue + if x + w > 12: + x, y, row_h = 0, y + row_h, 0 + out.append({"key": key, "x": x, "y": y, "w": w, "h": h, "config": config}) + x, row_h = x + w, max(row_h, h) + return out + # Group each widget for the picker (Core monitors / Monitoring capabilities / # Integrations), mirroring the Settings → Plugins taxonomy. Capability plugins diff --git a/steward/dashboard/routes.py b/steward/dashboard/routes.py index a4d4788..34fb4b9 100644 --- a/steward/dashboard/routes.py +++ b/steward/dashboard/routes.py @@ -11,7 +11,7 @@ from steward.models.users import UserRole from steward.models.hosts import Host from steward.models.dashboard import Dashboard, DashboardWidget, DashboardShareToken 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, recommended_layout, WIDGET_REGISTRY dashboard_bp = Blueprint("dashboard", __name__) @@ -92,16 +92,20 @@ async def _get_or_create_system_default(db) -> Dashboard: dash = Dashboard(name="Default", owner_id=None, is_default=True, is_shared=True) db.add(dash) await db.flush() - plugins_cfg = current_app.config.get("PLUGINS", {}) - # Seed a 3-up grid (each widget 4 cols wide x 4 cells tall on the 12-col grid). - for pos, w in enumerate(get_available_widgets(plugins_cfg)): - db.add(DashboardWidget( - dashboard_id=dash.id, widget_key=w["key"], - grid_x=(pos % 3) * 4, grid_y=(pos // 3) * 4, grid_w=4, grid_h=4, - )) + _seed_recommended(db, dash.id) return dash +def _seed_recommended(db, dash_id: int) -> None: + """Place the recommended layout (widgets.RECOMMENDED) on a dashboard.""" + for item in recommended_layout(current_app.config.get("PLUGINS", {})): + db.add(DashboardWidget( + dashboard_id=dash_id, widget_key=item["key"], + grid_x=item["x"], grid_y=item["y"], grid_w=item["w"], grid_h=item["h"], + config_json=json.dumps(item["config"]) if item["config"] else None, + )) + + def _resolve_widgets(widgets: list[DashboardWidget]) -> list[dict]: plugins_cfg = current_app.config.get("PLUGINS", {}) out = [] @@ -384,6 +388,25 @@ async def remove_widget(dash_id: int, widget_id: int): return ("", 204) +@dashboard_bp.post("/d//edit/reset") +@require_role(UserRole.viewer) +async def reset_layout(dash_id: int): + """Replace a dashboard's widgets with the recommended layout (#5213). The + edit drawer asks first: widgets added by hand and their titles go.""" + user_id = current_user_id() + user_role = session.get("user_role", "viewer") + async with current_app.db_sessionmaker() as db: + async with db.begin(): + dash = (await db.execute(select(Dashboard).where(Dashboard.id == dash_id))).scalar_one_or_none() + if dash is None or not _can_edit(dash, user_id, user_role): + abort(403) + for w in await _get_widgets(db, dash_id): + await db.delete(w) + await db.flush() + _seed_recommended(db, dash_id) + return redirect(f"/d/{dash_id}") + + @dashboard_bp.post("/d//edit/layout") @require_role(UserRole.viewer) async def save_layout(dash_id: int): diff --git a/steward/hosts/service.py b/steward/hosts/service.py index 16dca69..d7ff4a0 100644 --- a/steward/hosts/service.py +++ b/steward/hosts/service.py @@ -16,7 +16,7 @@ from steward.models.monitors import Monitor, MonitorResult # Host-level agent metrics read for the at-a-glance views (plus the root # mount's disk_used_pct, read from the ":/" sub-resource). -AGENT_GLANCE_METRICS = ("cpu_pct", "mem_used_pct", "disk_used_pct", "disk_used_pct_worst") +AGENT_GLANCE_METRICS = ("cpu_pct", "mem_used_pct", "disk_used_pct", "disk_used_pct_worst", "load_1m") async def check_name_free(db, name: str, exclude_id: str | None = None) -> None: @@ -172,7 +172,7 @@ async def agent_overview_by_host( ) -> dict[str, dict]: """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, + {host_name: {cpu_pct, mem_used_pct, disk_used_pct_worst, disk_root, load_1m, last_at, fresh}} for each host with any agent samples. Core-safe (no host_agent import). Freshness compares the latest host-level diff --git a/steward/migrations/versions/0032_retire_duplicate_widgets.py b/steward/migrations/versions/0032_retire_duplicate_widgets.py new file mode 100644 index 0000000..8bc1897 --- /dev/null +++ b/steward/migrations/versions/0032_retire_duplicate_widgets.py @@ -0,0 +1,52 @@ +"""Merge duplicate dashboard widgets into the one that covers both (#5213) + +Revision ID: 0032_retire_duplicate_widgets +Revises: 0031_ansible_run_origin +Create Date: 2026-10-07 + +Host Agent — Resources (host_resources) folds into Hosts — Overview +(hosts_overview), and UniFi — Devices (unifi_devices) into UniFi — Overview +(unifi_overview); widgets.RETIRED names the pairs. A dashboard that already +shows the surviving widget loses the retired placement; any other keeps its +cell, now showing the survivor. The old widget's own options don't apply to +the new one, so they are cleared. +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "0032_retire_duplicate_widgets" +down_revision: Union[str, None] = "0031_ansible_run_origin" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# Frozen here rather than imported: a migration keeps meaning what it meant. +_PAIRS = (("host_resources", "hosts_overview"), ("unifi_devices", "unifi_overview")) + + +def upgrade() -> None: + for old, new in _PAIRS: + op.execute(sa.text(""" + DELETE FROM dashboard_widgets d + WHERE d.widget_key = :old + AND EXISTS (SELECT 1 FROM dashboard_widgets k + WHERE k.dashboard_id = d.dashboard_id AND k.widget_key = :new) + """).bindparams(old=old, new=new)) + # Two placements of the old widget on one dashboard: keep the first. + op.execute(sa.text(""" + DELETE FROM dashboard_widgets d + WHERE d.widget_key = :old + AND EXISTS (SELECT 1 FROM dashboard_widgets k + WHERE k.dashboard_id = d.dashboard_id AND k.widget_key = :old + AND k.id < d.id) + """).bindparams(old=old)) + op.execute(sa.text(""" + UPDATE dashboard_widgets SET widget_key = :new, config_json = NULL + WHERE widget_key = :old + """).bindparams(old=old, new=new)) + + +def downgrade() -> None: + # The merge is one-way: which survivor cells were once the retired widget + # isn't recorded, and the retired widgets no longer exist to render. + pass diff --git a/steward/status/routes.py b/steward/status/routes.py index 072d6e1..961d813 100644 --- a/steward/status/routes.py +++ b/steward/status/routes.py @@ -2,7 +2,7 @@ from __future__ import annotations from quart import Blueprint, current_app, render_template, request from sqlalchemy import func, select -from steward.alerts.service import active_alerts +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.models.hosts import Host @@ -53,10 +53,23 @@ async def widget(): entries = await collect_status(db) total_hosts = (await db.execute( select(func.count()).select_from(Host))).scalar() + alerts = await active_alerts(db) up, down, pending = _summarize(entries) return await render_template( "status/widget.html", entries=entries, up=up, down=down, pending=pending, - total_hosts=total_hosts, + total_hosts=total_hosts, alerts=overview_alerts(alerts), widget_id=request.args.get("wid", "0"), ) + + +def overview_alerts(alerts) -> list[dict]: + """The Overview's alert lines: breaching or silent first, then those + someone has taken; each {rule, label, since}.""" + order = {"firing": 0, "no_data": 1, "pending": 2, "acknowledged": 3} + rows = [] + for state, rule in alerts: + label = alert_label(state) + since = state.no_data_since if label == "no_data" else state.last_transition_at + rows.append({"rule": rule, "label": label, "since": since}) + return sorted(rows, key=lambda r: order.get(r["label"], 9)) diff --git a/steward/templates/dashboard/_grid_item.html b/steward/templates/dashboard/_grid_item.html index 430e019..de50355 100644 --- a/steward/templates/dashboard/_grid_item.html +++ b/steward/templates/dashboard/_grid_item.html @@ -20,7 +20,7 @@
Loading…
diff --git a/steward/templates/dashboard/_widget_drawer.html b/steward/templates/dashboard/_widget_drawer.html index 8af9711..61a9476 100644 --- a/steward/templates/dashboard/_widget_drawer.html +++ b/steward/templates/dashboard/_widget_drawer.html @@ -5,7 +5,14 @@
Add a widget
- + +
{{ csrf_field() }} + +
+ +
{% if addable %} {% set _groups = [("core", "Core monitors"), ("capability", "Monitoring capabilities"), ("integration", "Integrations")] %} diff --git a/steward/templates/dashboard/share_view.html b/steward/templates/dashboard/share_view.html index e12f7e3..cb4c3d9 100644 --- a/steward/templates/dashboard/share_view.html +++ b/steward/templates/dashboard/share_view.html @@ -51,7 +51,7 @@ {# Pass share token as &s= param so require_role allows through #}
Loading…
diff --git a/steward/templates/hosts/overview_widget.html b/steward/templates/hosts/overview_widget.html index 99066b8..53ee2f6 100644 --- a/steward/templates/hosts/overview_widget.html +++ b/steward/templates/hosts/overview_widget.html @@ -34,6 +34,7 @@ {% if a.get('cpu_pct') is not none %}cpu {{ "%.0f"|format(a.cpu_pct) }}%{% if spark %}{{ spark | safe }}{% endif %}{% endif %} {% if a.get('mem_used_pct') is not none %}mem {{ "%.0f"|format(a.mem_used_pct) }}%{% endif %} {% if a.get('disk_root') is not none %}disk / {{ "%.0f"|format(a.disk_root) }}%{% endif %} + {% if a.get('load_1m') is not none %}load {{ "%.2f"|format(a.load_1m) }}{% endif %} {% if not a.fresh %}stale{% endif %} {% else %} no agent diff --git a/steward/templates/status/widget.html b/steward/templates/status/widget.html index 09a8373..dc60b95 100644 --- a/steward/templates/status/widget.html +++ b/steward/templates/status/widget.html @@ -1,28 +1,48 @@ -{# status/widget.html — the unified Overview: host count + monitor health - (up/down/pending) + alerts link + problem list. Replaced the old fixed - top summary strip. #} -
+{# status/widget.html — the dashboard Overview (#5213): everything that needs + attention, across hosts, monitors and alerts, with since-times. The Monitors + widget is the full list of checks; this is the summary. #} +{% from "_macros.html" import status_badge %} +{% set firing = alerts | selectattr("label", "in", ["firing", "no_data"]) | list %} +
{% if session.user_id %}{% endif %} {{ total_hosts }}host{{ '' if total_hosts == 1 else 's' }} {% if session.user_id %}{% endif %} - {{ up }}up - {{ down }}down - {{ pending }}pending - {% if session.user_id %}Alerts →{% endif %} + + Monitors + {{ up }}up + {{ down }}down + {% if pending %}{{ pending }}pending{% endif %} + + + {% if session.user_id %}{% endif %} + Alerts + {{ firing|length }}firing + {% if session.user_id %}{% endif %} +
{% set problems = entries | rejectattr("status", "equalto", "up") | list %} -{% if not entries %} -

No monitors configured.

-{% elif not problems %} -
✓ All {{ up }} monitors are up.
+{% if not problems and not alerts %} +
+ {% if entries %}✓ All {{ up }} monitors are up and no alert is firing.{% else %}No monitors configured, and no alert is firing.{% endif %} +
{% else %}
+ {% for a in alerts[:6] %} +
+ {{ status_badge(a.label) }} + {% set nm_style = "flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" %} + {% if session.user_id %} + {{ a.rule.name }} + {% else %}{{ a.rule.name }}{% endif %} + {{ a.since|ago }} +
+ {% endfor %} {% for e in problems[:8] %}
- + {{ status_badge(e.status) }} {% set nm_style = "flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" %} {# Link to the monitor's own detail view for logged-in users; plain text on the public share view. #} {% if session.user_id and e.detail_url %} @@ -30,12 +50,16 @@ {% else %} {{ e.name }} {% endif %} - {{ e.kind }} - {{ e.status }} + {{ e.kind|monitor_type }} + {% if e.since %}{% if e.since_at_least %}over {% endif %}{{ e.since|ago }}{% endif %}
{% endfor %} - {% if problems|length > 8 %} -
+{{ problems|length - 8 }} more…
+ {% if alerts|length > 6 or problems|length > 8 %} +
+ {% if alerts|length > 6 %}+{{ alerts|length - 6 }} more alerts{% endif %} + {% if problems|length > 8 %}{% if alerts|length > 6 %} · {% endif %}+{{ problems|length - 8 }} more monitors{% endif %} + {% if session.user_id %} — Status{% endif %} +
{% endif %}
{% endif %} diff --git a/tests/core/test_dashboard_defaults.py b/tests/core/test_dashboard_defaults.py new file mode 100644 index 0000000..2ee3087 --- /dev/null +++ b/tests/core/test_dashboard_defaults.py @@ -0,0 +1,108 @@ +"""The dashboard starts from a curated layout, the duplicate widgets are gone, +and the Overview says what each number counts (#5213).""" +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +from quart import render_template, session + +from steward.core.status import StatusEntry +from steward.core.widgets import RECOMMENDED, RETIRED, WIDGET_REGISTRY, recommended_layout +from steward.status.routes import overview_alerts +from tests.test_style_tokens import hex_colours + +ALL_ON = {p: {"enabled": True} for p in ("docker", "host_agent", "traefik", "unifi", "snmp")} + + +def _overlap(a, b): + return (a["x"] < b["x"] + b["w"] and b["x"] < a["x"] + a["w"] + and a["y"] < b["y"] + b["h"] and b["y"] < a["y"] + a["h"]) + + +def test_retired_widgets_point_at_the_one_that_replaced_them(): + for old, new in RETIRED.items(): + assert old not in WIDGET_REGISTRY and new in WIDGET_REGISTRY + assert all(key in WIDGET_REGISTRY for key, *_ in RECOMMENDED) + + +def test_the_recommended_layout_fills_rows_without_overlap(): + items = recommended_layout(ALL_ON) + assert [i["key"] for i in items] == [key for key, *_ in RECOMMENDED] + assert items[0] == {"key": "status_overview", "x": 0, "y": 0, "w": 12, "h": 3, "config": {}} + assert (items[1]["x"], items[1]["y"], items[2]["x"], items[2]["y"]) == (0, 3, 6, 3) + for i, a in enumerate(items): + assert a["x"] + a["w"] <= 12 + assert not any(_overlap(a, b) for b in items[i + 1:]), a + + +def test_the_recommended_layout_skips_what_is_not_enabled(): + keys = [i["key"] for i in recommended_layout({})] + assert keys == ["status_overview", "hosts_overview", "monitors", "uptime_summary"] + # uptime no longer fits beside the monitors, so it starts the next row + items = recommended_layout({}) + assert (items[3]["x"], items[3]["y"]) == (0, 10) + + +def test_overview_alerts_put_the_breaching_first(): + from steward.models.alerts import AlertStateEnum + ack = SimpleNamespace(state=AlertStateEnum.acknowledged, last_transition_at=None, no_data_since=None) + silent = SimpleNamespace(state=AlertStateEnum.inactive, last_transition_at=None, + no_data_since=datetime(2026, 10, 7, tzinfo=timezone.utc)) + firing = SimpleNamespace(state=AlertStateEnum.firing, last_transition_at=datetime(2026, 10, 6, tzinfo=timezone.utc), + no_data_since=None) + rows = overview_alerts([(ack, "r-ack"), (silent, "r-silent"), (firing, "r-firing")]) + assert [(r["rule"], r["label"]) for r in rows] == [ + ("r-firing", "firing"), ("r-silent", "no_data"), ("r-ack", "acknowledged")] + assert rows[1]["since"] == silent.no_data_since # silent since the series stopped + assert rows[0]["since"] == firing.last_transition_at + + +async def test_the_overview_labels_its_counts_and_lists_what_is_wrong(app): + now = datetime.now(timezone.utc) + rule = SimpleNamespace(id="r1", name="nas disk full") + alerts = [{"rule": rule, "label": "firing", "since": now - timedelta(minutes=12)}] + entries = [ + StatusEntry(kind="http", key="1", name="web", status="up", detail_url="/monitors/1"), + StatusEntry(kind="tcp", key="2", name="ssh-nas", status="down", detail_url="/monitors/2", + since=now - timedelta(hours=2)), + ] + async with app.test_request_context("/status/widget"): + session["user_id"] = "u1" + page = await render_template("status/widget.html", entries=entries, up=1, down=1, pending=0, + total_hosts=3, alerts=alerts, widget_id="1") + assert ">Monitors<" in page and ">Alerts<" in page and ">firing<" in page and ">hosts<" in page + assert 'Firing' in page + assert 'href="/alerts/history?rule=r1"' in page and "12m ago" in page + assert 'Down' in page and "2h ago" in page + assert page.index("nas disk full") < page.index("ssh-nas") # alerts, then monitors + assert ">pending<" not in page # a zero pending count is not shown + assert not hex_colours(page), hex_colours(page) + + +async def test_a_quiet_overview_says_so(app): + async with app.test_request_context("/status/widget"): + page = await render_template("status/widget.html", entries=[], up=0, down=0, pending=0, + total_hosts=1, alerts=[], widget_id="1") + assert "No monitors configured, and no alert is firing." in page + assert ">host<" in page and 'href="/alerts/"' not in page # the share view has no links + + +async def test_the_hosts_widget_shows_load(app): + host = SimpleNamespace(id="h1", name="web-1") + agent = {"web-1": {"cpu_pct": 12.0, "mem_used_pct": 40.0, "load_1m": 0.734, "fresh": True}} + async with app.test_request_context("/hosts/overview/widget"): + page = await render_template("hosts/overview_widget.html", hosts=[host], host_status={}, + uptime={}, agent=agent, cpu_sparks={}) + assert ">load 0.73" in page + + +async def test_a_widget_polls_at_its_own_pace(app): + defn = {**WIDGET_REGISTRY["uptime_summary"]} + item = SimpleNamespace(db=SimpleNamespace(id=7, grid_x=0, grid_y=0, grid_w=6, grid_h=5), + title="Uptime", defn=defn, hx_url="/uptime/widget") + async with app.test_request_context("/"): + page = await render_template("dashboard/_grid_item.html", item=item, can_edit=False, poll_interval=30) + assert "every 300s" in page + defn.pop("poll_seconds") + async with app.test_request_context("/"): + page = await render_template("dashboard/_grid_item.html", item=item, can_edit=False, poll_interval=30) + assert "every 30s" in page diff --git a/tests/integration/test_dashboard_reset.py b/tests/integration/test_dashboard_reset.py new file mode 100644 index 0000000..3cabc88 --- /dev/null +++ b/tests/integration/test_dashboard_reset.py @@ -0,0 +1,69 @@ +"""Integration: "Reset to the recommended layout" replaces a dashboard's +widgets with widgets.RECOMMENDED, and only for someone who may edit it +(#5213), against real Postgres.""" +from __future__ import annotations + +import asyncio +import os +import time +import uuid + +import pytest + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def app(): + if not os.environ.get("STEWARD_DATABASE_URL"): + pytest.skip("needs Postgres") + from steward.app import create_app + return create_app(testing=False) + + +async def _client(app, role): + from steward.models.users import User, UserRole + uid, tag = str(uuid.uuid4()), uuid.uuid4().hex[:8] + async with app.db_sessionmaker() as s: + async with s.begin(): + s.add(User(id=uid, username=f"reset-{tag}", email=f"reset-{tag}@test", + password_hash="x", role=UserRole(role), is_active=True)) + client = app.test_client() + async with client.session_transaction() as sess: + sess.update(user_id=uid, user_role=role, username=f"reset-{tag}", + _csrf="test-token", login_at=time.time()) + return client, uid + + +CSRF = {"X-CSRF-Token": "test-token"} + + +def test_reset_puts_back_the_recommended_layout_for_its_owner_only(app): + from sqlalchemy import select + from steward.core.widgets import recommended_layout + from steward.models.dashboard import Dashboard, DashboardWidget + + async def _go(): + owner, owner_id = await _client(app, "operator") + other, _ = await _client(app, "operator") + async with app.db_sessionmaker() as s: + async with s.begin(): + dash = Dashboard(name="mine", owner_id=owner_id) + s.add(dash) + await s.flush() + s.add(DashboardWidget(dashboard_id=dash.id, widget_key="monitors", + grid_x=3, grid_y=9, grid_w=2, grid_h=2)) + dash_id = dash.id + + assert (await other.post(f"/d/{dash_id}/edit/reset", headers=CSRF)).status_code == 403 + resp = await owner.post(f"/d/{dash_id}/edit/reset", headers=CSRF) + assert resp.status_code == 302 and resp.headers["Location"].endswith(f"/d/{dash_id}") + + async with app.db_sessionmaker() as s: + rows = (await s.execute(select(DashboardWidget).where(DashboardWidget.dashboard_id == dash_id) + .order_by(DashboardWidget.grid_y, DashboardWidget.grid_x))).scalars().all() + want = recommended_layout(app.config.get("PLUGINS", {})) + assert [(w.widget_key, w.grid_x, w.grid_y, w.grid_w, w.grid_h) for w in rows] == \ + [(i["key"], i["x"], i["y"], i["w"], i["h"]) for i in want] + + asyncio.run(_go()) diff --git a/tests/test_route_access.py b/tests/test_route_access.py index 38068dd..45a4acc 100644 --- a/tests/test_route_access.py +++ b/tests/test_route_access.py @@ -52,7 +52,7 @@ def test_every_route_says_who_may_reach_it(): def test_the_scan_sees_routes(): names = {n.name for _, text in _sources() for n, _ in route_views(text)} - assert {"login", "health", "ingest", "widget_table", "public_share"} <= names + assert {"login", "health", "ingest", "widget_history", "public_share"} <= names @pytest.mark.asyncio