feat(dashboard): a curated default layout, merged duplicate widgets, a labelled Overview (#5213)
- New installs start from widgets.RECOMMENDED, packed row by row for the enabled plugins; the edit drawer can reset a dashboard to it. - Host Agent — Resources folds into Hosts — Overview (which gains load 1m), UniFi — Devices into UniFi — Overview; migration 0032 moves existing dashboards onto the survivors. - The Overview labels what it counts (hosts, monitors up/down/pending, alerts firing) and lists firing alerts and down monitors with since-times. - Uptime polls every 5 minutes (poll_seconds) instead of every cycle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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="") %}
|
||||
<div style="min-width:62px;flex:0 0 auto;" title="{{ title }}">
|
||||
<div style="font-size:0.62rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.03em;">{{ label }}</div>
|
||||
<div style="font-weight:500;font-size:0.8rem;font-variant-numeric:tabular-nums;{{ style }}">
|
||||
{% if value is not none %}{{ fmt|format(value) }}{% else %}—{% endif %}
|
||||
</div>
|
||||
<div style="line-height:0;height:16px;">{{ svg | safe }}</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
{# Two-line down/up (or read/write) throughput cell. #}
|
||||
{% macro io_cell(label, down_sym, down_val, up_sym, up_val, title="") %}
|
||||
<div style="min-width:80px;flex:0 0 auto;" title="{{ title }}">
|
||||
<div style="font-size:0.62rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.03em;">{{ label }}</div>
|
||||
<div style="font-size:0.72rem;font-variant-numeric:tabular-nums;line-height:1.3;">
|
||||
<div>{{ down_sym }} {{ down_val|rate }}</div>
|
||||
<div>{{ up_sym }} {{ up_val|rate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
<div class="host-fleet">
|
||||
{% for r in rows %}
|
||||
{% set stale = r.stale %}
|
||||
<div style="display:flex;align-items:center;gap:0.6rem 1rem;flex-wrap:wrap;padding:0.5rem 0.6rem;border-radius:4px;{% if loop.index0 % 2 %}background:var(--bg-elevated);{% endif %}">
|
||||
{# Left — health dot + full host name (wraps, never truncated) + last seen #}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;flex:1 1 150px;min-width:120px;">
|
||||
<span class="dot {% if stale %}dot-dim{% elif (r.cpu_pct or 0) >= 90 or (r.mem_used_pct or 0) >= 90 or (r.disk_worst or 0) >= 90 %}dot-warn{% else %}dot-up{% endif %}"
|
||||
style="flex-shrink:0;"
|
||||
title="{% if stale %}stale / no recent data{% else %}warns at CPU/memory ≥90% or any disk mount ≥90% (the 'disk /' figure shows root only){% endif %}"></span>
|
||||
<span style="min-width:0;">
|
||||
{% if session.user_id %}
|
||||
<a href="/hosts/{{ r.host.id }}" style="font-weight:500;font-size:0.85rem;overflow-wrap:anywhere;">{{ r.host.name }}</a>
|
||||
{% else %}
|
||||
<span style="font-weight:500;font-size:0.85rem;overflow-wrap:anywhere;">{{ r.host.name }}</span>
|
||||
{% endif %}
|
||||
<span style="display:block;font-size:0.7rem;color:var(--text-dim);">
|
||||
{% if r.reg.last_seen_at %}seen {{ r.reg.last_seen_at|ago }}{% else %}never seen{% endif %}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{# Right — metric cells with value + trend, laid out horizontally #}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.4rem 1rem;justify-content:flex-end;">
|
||||
{{ 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 %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty" style="padding:0.5rem 0;font-size:0.85rem;">No hosts with agent data yet.</p>
|
||||
{% endif %}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
{# unifi/widget_devices.html — dashboard widget: infrastructure devices #}
|
||||
{% if not devices %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
|
||||
{% if type_filter != "all" %}No {{ type_filter }} devices found.{% else %}No devices found.{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
{% set online = devices | selectattr("state", "equalto", 1) | list %}
|
||||
{% set offline = devices | rejectattr("state", "equalto", 1) | list %}
|
||||
|
||||
<div style="display:flex;gap:1rem;margin-bottom:0.75rem;">
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--green);">{{ online | length }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">online</span>
|
||||
</div>
|
||||
{% if offline %}
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--red);">{{ offline | length }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">offline</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="display:grid;gap:3px;">
|
||||
{% for d in devices %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;padding:0.15rem 0;">
|
||||
<span class="dot {% if d.state == 1 %}dot-up{% else %}dot-down{% endif %}"></span>
|
||||
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
||||
{{ d.name or d.mac }}
|
||||
</span>
|
||||
<span style="font-size:0.72rem;color:var(--text-dim);flex-shrink:0;">{{ d.model or d.device_type or "" }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
+49
-36
@@ -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
|
||||
|
||||
@@ -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/<int:dash_id>/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/<int:dash_id>/edit/layout")
|
||||
@require_role(UserRole.viewer)
|
||||
async def save_layout(dash_id: int):
|
||||
|
||||
@@ -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 "<host>:/" 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
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
<div class="dash-cell-body" id="widget-{{ item.db.id }}"
|
||||
hx-get="{{ item.hx_url }}"
|
||||
hx-trigger="load, retry{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
|
||||
hx-trigger="load, retry{% if item.defn.poll %}, every {{ item.defn.get('poll_seconds') or 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>
|
||||
|
||||
@@ -5,7 +5,14 @@
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;
|
||||
position:sticky;top:0;background:var(--bg-card);padding-bottom:0.5rem;">
|
||||
<div class="section-title" style="margin-bottom:0;">Add a widget</div>
|
||||
<button type="button" class="btn btn-sm" id="dash-edit-done">Done</button>
|
||||
<span style="display:flex;gap:0.5rem;align-items:center;">
|
||||
<form method="post" action="/d/{{ dashboard.id }}/edit/reset" style="margin:0;"
|
||||
data-confirm="Replace every widget on this dashboard with the recommended layout? Widgets you added or renamed are removed.">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-ghost btn-sm"
|
||||
title="Overview first, then hosts and monitors, then each integration">Reset to the recommended layout</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-sm" id="dash-edit-done">Done</button>
|
||||
</span>
|
||||
</div>
|
||||
{% if addable %}
|
||||
{% set _groups = [("core", "Core monitors"), ("capability", "Monitoring capabilities"), ("integration", "Integrations")] %}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
{# 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, retry{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
|
||||
hx-trigger="load, retry{% if item.defn.poll %}, every {{ item.defn.get('poll_seconds') or 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>
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
{% if a.get('cpu_pct') is not none %}<span title="CPU — value + last-hour trend" style="display:inline-flex;align-items:center;gap:0.3rem;{{ threshold_style(a.cpu_pct, 'cpu') }}"><span style="color:var(--text-dim);">cpu</span> {{ "%.0f"|format(a.cpu_pct) }}%{% if spark %}<span style="line-height:0;opacity:0.85;">{{ spark | safe }}</span>{% endif %}</span>{% endif %}
|
||||
{% if a.get('mem_used_pct') is not none %}<span title="Memory" style="{{ threshold_style(a.mem_used_pct, 'mem') }}"><span style="color:var(--text-dim);">mem</span> {{ "%.0f"|format(a.mem_used_pct) }}%</span>{% endif %}
|
||||
{% if a.get('disk_root') is not none %}<span title="Root filesystem (/)" style="{{ threshold_style(a.disk_root, 'disk') }}"><span style="color:var(--text-dim);">disk /</span> {{ "%.0f"|format(a.disk_root) }}%</span>{% endif %}
|
||||
{% if a.get('load_1m') is not none %}<span title="Load average over 1 minute: processes running or waiting to run"><span style="color:var(--text-dim);">load</span> {{ "%.2f"|format(a.load_1m) }}</span>{% endif %}
|
||||
{% if not a.fresh %}<span title="Agent data is stale" style="color:var(--yellow);">stale</span>{% endif %}
|
||||
{% else %}
|
||||
<span style="color:var(--text-dim);" title="No agent reporting">no agent</span>
|
||||
|
||||
@@ -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. #}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:1.25rem;align-items:baseline;margin-bottom:0.6rem;">
|
||||
{# 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 %}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.4rem 1.5rem;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:{% 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>
|
||||
{% if session.user_id %}<a href="/alerts/" style="margin-left:auto;align-self:center;font-size:0.8rem;">Alerts →</a>{% endif %}
|
||||
<span title="Uptime monitors by their latest check">
|
||||
<span class="stat-label" style="margin-right:0.35rem;">Monitors</span>
|
||||
<span class="stat-val tone-ok" style="font-size:1.5rem;">{{ up }}</span><span class="stat-label">up</span>
|
||||
<span class="stat-val {{ 'tone-crit' if down else 'tone-dim' }}" style="font-size:1.5rem;margin-left:0.6rem;">{{ down }}</span><span class="stat-label">down</span>
|
||||
{% if pending %}<span class="stat-val tone-warn" style="font-size:1.5rem;margin-left:0.6rem;">{{ pending }}</span><span class="stat-label">pending</span>{% endif %}
|
||||
</span>
|
||||
<span title="Alert rules firing or gone silent">
|
||||
{% if session.user_id %}<a href="/alerts/" style="color:inherit;text-decoration:none;">{% endif %}
|
||||
<span class="stat-label" style="margin-right:0.35rem;">Alerts</span>
|
||||
<span class="stat-val {{ 'tone-crit' if firing else 'tone-dim' }}" style="font-size:1.5rem;">{{ firing|length }}</span><span class="stat-label">firing</span>
|
||||
{% if session.user_id %}</a>{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% set problems = entries | rejectattr("status", "equalto", "up") | list %}
|
||||
{% if not entries %}
|
||||
<p class="empty" style="padding:0.3rem 0;font-size:0.85rem;">No monitors configured.</p>
|
||||
{% elif not problems %}
|
||||
<div style="color:var(--green);font-size:0.85rem;padding:0.3rem 0;">✓ All {{ up }} monitors are up.</div>
|
||||
{% if not problems and not alerts %}
|
||||
<div class="tone-ok" style="font-size:0.85rem;padding:0.3rem 0;">
|
||||
{% if entries %}✓ All {{ up }} monitors are up and no alert is firing.{% else %}No monitors configured, and no alert is firing.{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="display:grid;gap:1px;">
|
||||
{% for a in alerts[:6] %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;padding:0.2rem 0;font-size:0.82rem;">
|
||||
{{ 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 href="/alerts/history?rule={{ a.rule.id }}" title="{{ a.rule.name }}" style="{{ nm_style }}color:inherit;text-decoration:none;">{{ a.rule.name }}</a>
|
||||
{% else %}<span title="{{ a.rule.name }}" style="{{ nm_style }}">{{ a.rule.name }}</span>{% endif %}
|
||||
<span style="color:var(--text-dim);font-size:0.75rem;white-space:nowrap;">{{ a.since|ago }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for e in problems[:8] %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;padding:0.2rem 0;font-size:0.82rem;">
|
||||
<span class="dot {% if e.status == 'down' %}dot-down{% else %}dot-dim{% endif %}"></span>
|
||||
{{ 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 %}
|
||||
<span title="{{ e.name }}" style="{{ nm_style }}">{{ e.name }}</span>
|
||||
{% endif %}
|
||||
<span style="color:var(--text-dim);font-size:0.7rem;text-transform:uppercase;">{{ e.kind }}</span>
|
||||
<span style="color:{% if e.status == 'down' %}var(--red){% else %}var(--yellow){% endif %};font-size:0.75rem;font-weight:500;">{{ e.status }}</span>
|
||||
<span style="color:var(--text-dim);font-size:0.7rem;">{{ e.kind|monitor_type }}</span>
|
||||
{% if e.since %}<span style="color:var(--text-dim);font-size:0.75rem;white-space:nowrap;">{% if e.since_at_least %}over {% endif %}{{ e.since|ago }}</span>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if problems|length > 8 %}
|
||||
<div style="color:var(--text-dim);font-size:0.75rem;padding-top:0.2rem;">+{{ problems|length - 8 }} more…</div>
|
||||
{% if alerts|length > 6 or problems|length > 8 %}
|
||||
<div style="color:var(--text-dim);font-size:0.75rem;padding-top:0.2rem;">
|
||||
{% 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 %} — <a href="/status/">Status</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -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 '<span class="badge badge-crit">Firing</span>' in page
|
||||
assert 'href="/alerts/history?rule=r1"' in page and "12m ago" in page
|
||||
assert '<span class="badge badge-crit">Down</span>' 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</span> 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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user