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
|
│ └── host_agent_001_initial.py # creates host_agent_registrations table
|
||||||
├── templates/
|
├── templates/
|
||||||
│ ├── detail.html # per-host detail page
|
│ ├── detail.html # per-host detail page
|
||||||
│ ├── widget_table.html # dashboard fleet table widget
|
|
||||||
│ ├── widget_history.html # dashboard history chart widget
|
│ ├── widget_history.html # dashboard history chart widget
|
||||||
│ ├── settings_list.html # plugin settings: list of registered hosts
|
│ ├── settings_list.html # plugin settings: list of registered hosts
|
||||||
│ └── install.sh.j2 # install script template (Jinja)
|
│ └── install.sh.j2 # install script template (Jinja)
|
||||||
@@ -438,7 +437,7 @@ echo "Logs: journalctl -u steward-agent -f"
|
|||||||
|
|
||||||
### Dashboard widgets (`steward/core/widgets.py`)
|
### 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.
|
- **`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
|
### Per-host detail page
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from sqlalchemy import select, or_, and_
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from steward.core.formatting import worst_reading
|
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.settings import get_setting, public_base_url
|
||||||
from steward.core.time_range import parse_range, RANGE_OPTIONS
|
from steward.core.time_range import parse_range, RANGE_OPTIONS
|
||||||
from steward.models.hosts import Host
|
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")
|
@host_agent_bp.get("/widget/history")
|
||||||
@require_role(UserRole.viewer)
|
@require_role(UserRole.viewer)
|
||||||
async def widget_history():
|
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,
|
snapshot=snapshot,
|
||||||
widget_id=widget_id,
|
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)
|
# type - "select" (only type currently supported)
|
||||||
# default - default value (int or str determines how it's parsed from the form)
|
# default - default value (int or str determines how it's parsed from the form)
|
||||||
# options - list of {value, label} dicts
|
# 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
|
from __future__ import annotations
|
||||||
|
|
||||||
WIDGET_REGISTRY: dict[str, dict] = {
|
WIDGET_REGISTRY: dict[str, dict] = {
|
||||||
@@ -60,7 +63,7 @@ WIDGET_REGISTRY: dict[str, dict] = {
|
|||||||
"status_overview": {
|
"status_overview": {
|
||||||
"key": "status_overview",
|
"key": "status_overview",
|
||||||
"label": "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",
|
"hx_url": "/status/widget",
|
||||||
"detail_url": "/status/",
|
"detail_url": "/status/",
|
||||||
"plugin": None,
|
"plugin": None,
|
||||||
@@ -70,7 +73,7 @@ WIDGET_REGISTRY: dict[str, dict] = {
|
|||||||
"hosts_overview": {
|
"hosts_overview": {
|
||||||
"key": "hosts_overview",
|
"key": "hosts_overview",
|
||||||
"label": "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",
|
"hx_url": "/hosts/overview/widget",
|
||||||
"detail_url": "/hosts/",
|
"detail_url": "/hosts/",
|
||||||
"plugin": None,
|
"plugin": None,
|
||||||
@@ -84,7 +87,9 @@ WIDGET_REGISTRY: dict[str, dict] = {
|
|||||||
"hx_url": "/hosts/uptime/widget",
|
"hx_url": "/hosts/uptime/widget",
|
||||||
"detail_url": "/hosts/uptime",
|
"detail_url": "/hosts/uptime",
|
||||||
"plugin": None,
|
"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": [],
|
"params": [],
|
||||||
},
|
},
|
||||||
"traefik_routers": {
|
"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": {
|
"docker_containers": {
|
||||||
"key": "docker_containers",
|
"key": "docker_containers",
|
||||||
"label": "Docker — Services & failures",
|
"label": "Docker — Services & failures",
|
||||||
@@ -260,16 +242,6 @@ WIDGET_REGISTRY: dict[str, dict] = {
|
|||||||
"poll": True,
|
"poll": True,
|
||||||
"params": [],
|
"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": {
|
"host_resource_history": {
|
||||||
"key": "host_resource_history",
|
"key": "host_resource_history",
|
||||||
"label": "Host Agent — History Graph",
|
"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 /
|
# Group each widget for the picker (Core monitors / Monitoring capabilities /
|
||||||
# Integrations), mirroring the Settings → Plugins taxonomy. Capability plugins
|
# 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.hosts import Host
|
||||||
from steward.models.dashboard import Dashboard, DashboardWidget, DashboardShareToken
|
from steward.models.dashboard import Dashboard, DashboardWidget, DashboardShareToken
|
||||||
from steward.core.settings import public_base_url
|
from steward.core.settings import public_base_url
|
||||||
from steward.core.widgets import get_available_widgets, WIDGET_REGISTRY
|
from steward.core.widgets import get_available_widgets, recommended_layout, WIDGET_REGISTRY
|
||||||
|
|
||||||
dashboard_bp = Blueprint("dashboard", __name__)
|
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)
|
dash = Dashboard(name="Default", owner_id=None, is_default=True, is_shared=True)
|
||||||
db.add(dash)
|
db.add(dash)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
plugins_cfg = current_app.config.get("PLUGINS", {})
|
_seed_recommended(db, dash.id)
|
||||||
# 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,
|
|
||||||
))
|
|
||||||
return dash
|
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]:
|
def _resolve_widgets(widgets: list[DashboardWidget]) -> list[dict]:
|
||||||
plugins_cfg = current_app.config.get("PLUGINS", {})
|
plugins_cfg = current_app.config.get("PLUGINS", {})
|
||||||
out = []
|
out = []
|
||||||
@@ -384,6 +388,25 @@ async def remove_widget(dash_id: int, widget_id: int):
|
|||||||
return ("", 204)
|
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")
|
@dashboard_bp.post("/d/<int:dash_id>/edit/layout")
|
||||||
@require_role(UserRole.viewer)
|
@require_role(UserRole.viewer)
|
||||||
async def save_layout(dash_id: int):
|
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
|
# 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).
|
# 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:
|
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]:
|
) -> dict[str, dict]:
|
||||||
"""Per-host agent glance (cpu/mem/disk + freshness) from plugin_metric_latest.
|
"""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.
|
last_at, fresh}} for each host with any agent samples.
|
||||||
|
|
||||||
Core-safe (no host_agent import). Freshness compares the latest host-level
|
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 quart import Blueprint, current_app, render_template, request
|
||||||
from sqlalchemy import func, select
|
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.auth.middleware import require_role
|
||||||
from steward.core.status import collect_status, sparkline_svg
|
from steward.core.status import collect_status, sparkline_svg
|
||||||
from steward.models.hosts import Host
|
from steward.models.hosts import Host
|
||||||
@@ -53,10 +53,23 @@ async def widget():
|
|||||||
entries = await collect_status(db)
|
entries = await collect_status(db)
|
||||||
total_hosts = (await db.execute(
|
total_hosts = (await db.execute(
|
||||||
select(func.count()).select_from(Host))).scalar()
|
select(func.count()).select_from(Host))).scalar()
|
||||||
|
alerts = await active_alerts(db)
|
||||||
up, down, pending = _summarize(entries)
|
up, down, pending = _summarize(entries)
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"status/widget.html",
|
"status/widget.html",
|
||||||
entries=entries, up=up, down=down, pending=pending,
|
entries=entries, up=up, down=down, pending=pending,
|
||||||
total_hosts=total_hosts,
|
total_hosts=total_hosts, alerts=overview_alerts(alerts),
|
||||||
widget_id=request.args.get("wid", "0"),
|
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>
|
||||||
<div class="dash-cell-body" id="widget-{{ item.db.id }}"
|
<div class="dash-cell-body" id="widget-{{ item.db.id }}"
|
||||||
hx-get="{{ item.hx_url }}"
|
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}'
|
hx-request='{"timeout": 20000}'
|
||||||
data-poll="{{ '1' if item.defn.poll else '0' }}"
|
data-poll="{{ '1' if item.defn.poll else '0' }}"
|
||||||
hx-swap="innerHTML"><div class="cell-state">Loading…</div></div>
|
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;
|
<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;">
|
position:sticky;top:0;background:var(--bg-card);padding-bottom:0.5rem;">
|
||||||
<div class="section-title" style="margin-bottom:0;">Add a widget</div>
|
<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>
|
</div>
|
||||||
{% if addable %}
|
{% if addable %}
|
||||||
{% set _groups = [("core", "Core monitors"), ("capability", "Monitoring capabilities"), ("integration", "Integrations")] %}
|
{% 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 #}
|
{# Pass share token as &s= param so require_role allows through #}
|
||||||
<div class="dash-cell-body" id="widget-{{ gw.id }}"
|
<div class="dash-cell-body" id="widget-{{ gw.id }}"
|
||||||
hx-get="{{ item.hx_url }}&s={{ raw_token }}"
|
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}'
|
hx-request='{"timeout": 20000}'
|
||||||
data-poll="{{ '1' if item.defn.poll else '0' }}"
|
data-poll="{{ '1' if item.defn.poll else '0' }}"
|
||||||
hx-swap="innerHTML"><div class="cell-state">Loading…</div></div>
|
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('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('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('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 %}
|
{% if not a.fresh %}<span title="Agent data is stale" style="color:var(--yellow);">stale</span>{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span style="color:var(--text-dim);" title="No agent reporting">no agent</span>
|
<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
|
{# status/widget.html — the dashboard Overview (#5213): everything that needs
|
||||||
(up/down/pending) + alerts link + problem list. Replaced the old fixed
|
attention, across hosts, monitors and alerts, with since-times. The Monitors
|
||||||
top summary strip. #}
|
widget is the full list of checks; this is the summary. #}
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:1.25rem;align-items:baseline;margin-bottom:0.6rem;">
|
{% 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>
|
<span>
|
||||||
{% if session.user_id %}<a href="/hosts/" style="color:inherit;text-decoration:none;">{% endif %}
|
{% 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>
|
<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 %}
|
{% if session.user_id %}</a>{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<span><span class="stat-val" style="font-size:1.5rem;color:var(--green);">{{ up }}</span><span class="stat-label">up</span></span>
|
<span title="Uptime monitors by their latest check">
|
||||||
<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 class="stat-label" style="margin-right:0.35rem;">Monitors</span>
|
||||||
<span><span class="stat-val" style="font-size:1.5rem;color:{% if pending %}var(--yellow){% else %}var(--text-dim){% endif %};">{{ pending }}</span><span class="stat-label">pending</span></span>
|
<span class="stat-val tone-ok" style="font-size:1.5rem;">{{ up }}</span><span class="stat-label">up</span>
|
||||||
{% if session.user_id %}<a href="/alerts/" style="margin-left:auto;align-self:center;font-size:0.8rem;">Alerts →</a>{% endif %}
|
<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>
|
</div>
|
||||||
|
|
||||||
{% set problems = entries | rejectattr("status", "equalto", "up") | list %}
|
{% set problems = entries | rejectattr("status", "equalto", "up") | list %}
|
||||||
{% if not entries %}
|
{% if not problems and not alerts %}
|
||||||
<p class="empty" style="padding:0.3rem 0;font-size:0.85rem;">No monitors configured.</p>
|
<div class="tone-ok" style="font-size:0.85rem;padding:0.3rem 0;">
|
||||||
{% elif not problems %}
|
{% if entries %}✓ All {{ up }} monitors are up and no alert is firing.{% else %}No monitors configured, and no alert is firing.{% endif %}
|
||||||
<div style="color:var(--green);font-size:0.85rem;padding:0.3rem 0;">✓ All {{ up }} monitors are up.</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div style="display:grid;gap:1px;">
|
<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] %}
|
{% for e in problems[:8] %}
|
||||||
<div style="display:flex;align-items:center;gap:0.5rem;padding:0.2rem 0;font-size:0.82rem;">
|
<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;" %}
|
{% 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. #}
|
{# 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 %}
|
{% if session.user_id and e.detail_url %}
|
||||||
@@ -30,12 +50,16 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span title="{{ e.name }}" style="{{ nm_style }}">{{ e.name }}</span>
|
<span title="{{ e.name }}" style="{{ nm_style }}">{{ e.name }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span style="color:var(--text-dim);font-size:0.7rem;text-transform:uppercase;">{{ e.kind }}</span>
|
<span style="color:var(--text-dim);font-size:0.7rem;">{{ e.kind|monitor_type }}</span>
|
||||||
<span style="color:{% if e.status == 'down' %}var(--red){% else %}var(--yellow){% endif %};font-size:0.75rem;font-weight:500;">{{ e.status }}</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>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if problems|length > 8 %}
|
{% if alerts|length > 6 or problems|length > 8 %}
|
||||||
<div style="color:var(--text-dim);font-size:0.75rem;padding-top:0.2rem;">+{{ problems|length - 8 }} more…</div>
|
<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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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():
|
def test_the_scan_sees_routes():
|
||||||
names = {n.name for _, text in _sources() for n, _ in route_views(text)}
|
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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user