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:
+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 %}
|
||||
|
||||
Reference in New Issue
Block a user