diff --git a/plugins/host_agent/routes.py b/plugins/host_agent/routes.py index 57c92bc..8134ca9 100644 --- a/plugins/host_agent/routes.py +++ b/plugins/host_agent/routes.py @@ -387,34 +387,44 @@ async def widget_table(): @host_agent_bp.get("/widget/history") async def widget_history(): host_id = request.args.get("host_id", "") - hours = int(request.args.get("hours", "6")) + try: + hours = max(1, min(168, int(request.args.get("hours", "6")))) + except ValueError: + hours = 6 + # wid (the dashboard widget row id) keeps the id unique when several + # history widgets share a page; fall back to the host id. + wid = request.args.get("wid", "") or host_id cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) - async with current_app.db_sessionmaker() as session: - host = (await session.execute( - select(Host).where(Host.id == host_id))).scalar_one_or_none() - if host is None: - return _error(404, "not_found") - points = (await session.execute( - select(PluginMetric).where( - PluginMetric.source_module == SOURCE_MODULE, - PluginMetric.recorded_at >= cutoff, - or_( - and_(PluginMetric.resource_name == host.name, - PluginMetric.metric_name.in_(("cpu_pct", "mem_used_pct"))), - and_(PluginMetric.resource_name == host.name + ":/", - PluginMetric.metric_name == "disk_used_pct"), - ), - ).order_by(PluginMetric.recorded_at) - )).scalars().all() + series: dict[str, list[list]] = {"cpu_pct": [], "mem_used_pct": [], "disk_root": []} + host = None + if host_id: + async with current_app.db_sessionmaker() as session: + host = (await session.execute( + select(Host).where(Host.id == host_id))).scalar_one_or_none() + if host is not None: + points = (await session.execute( + select(PluginMetric).where( + PluginMetric.source_module == SOURCE_MODULE, + PluginMetric.recorded_at >= cutoff, + or_( + and_(PluginMetric.resource_name == host.name, + PluginMetric.metric_name.in_(("cpu_pct", "mem_used_pct"))), + and_(PluginMetric.resource_name == host.name + ":/", + PluginMetric.metric_name == "disk_used_pct"), + ), + ).order_by(PluginMetric.recorded_at) + )).scalars().all() + # Disk = root (/), consistent with the host panel — not "worst". + # Epoch-ms x values let the chart use a plain linear axis (no + # Chart.js date adapter needed), matching the host-detail charts. + for p in points: + key = "disk_root" if p.resource_name != host.name else p.metric_name + series[key].append([int(p.recorded_at.timestamp() * 1000), round(p.value, 2)]) - # Disk = root (/), consistent with the host panel — not the opaque "worst". - series: dict[str, list[dict]] = {"cpu_pct": [], "mem_used_pct": [], "disk_root": []} - for p in points: - key = "disk_root" if p.resource_name != host.name else p.metric_name - series[key].append({"t": p.recorded_at.isoformat(), "v": p.value}) - - return await render_template("widget_history.html", host=host, series=series, hours=hours) + return await render_template( + "widget_history.html", host=host, series=series, hours=hours, wid=wid, + ) # Host-level metrics charted on the detail page (sub-resources are shown as diff --git a/plugins/host_agent/templates/widget_history.html b/plugins/host_agent/templates/widget_history.html index 0d5b567..cf9f492 100644 --- a/plugins/host_agent/templates/widget_history.html +++ b/plugins/host_agent/templates/widget_history.html @@ -1,24 +1,53 @@ -
-

{{ host.name }} — last {{ hours }}h

- - +{# host_agent history-graph widget — the host-view utilization chart, embedded + on a dashboard. Fills the (resizable) panel; epoch-ms linear axis so no + Chart.js date adapter is needed. #} +{% set has_data = host and (series.cpu_pct or series.mem_used_pct or series.disk_root) %} +{% if not host %} +
+ No host selected. Edit this dashboard and pick a host for this graph.
+{% elif not has_data %} +
+ No agent metrics for {{ host.name }} in the last {{ hours }}h yet. +
+{% else %} +
+ +
+ +{% endif %} diff --git a/steward/core/widgets.py b/steward/core/widgets.py index 7ea8671..a4f0349 100644 --- a/steward/core/widgets.py +++ b/steward/core/widgets.py @@ -279,13 +279,20 @@ WIDGET_REGISTRY: dict[str, dict] = { }, "host_resource_history": { "key": "host_resource_history", - "label": "Host Agent — History", - "description": "CPU/memory/disk history for one host", + "label": "Host Agent — History Graph", + "description": "CPU / memory / disk time-series graph for one host (the host-view chart, on your dashboard)", "hx_url": "/plugins/host_agent/widget/history", - "detail_url": "/plugins/host_agent/fleet/", + "detail_url": "/hosts/", "plugin": "host_agent", "poll": True, "params": [ + # type "host" → the edit form renders a live dropdown of hosts. + { + "key": "host_id", + "label": "Host", + "type": "host", + "default": "", + }, { "key": "hours", "label": "Time range", diff --git a/steward/dashboard/routes.py b/steward/dashboard/routes.py index 8802b04..57a82d4 100644 --- a/steward/dashboard/routes.py +++ b/steward/dashboard/routes.py @@ -173,7 +173,7 @@ async def _get_panels_data(db, dashboard_id: int) -> tuple: result = await db.execute(select(Dashboard).where(Dashboard.id == dashboard_id)) dash = result.scalar_one_or_none() if dash is None: - return None, [], [] + return None, [], [], [] widgets = await _get_widgets(db, dashboard_id) plugins_cfg = current_app.config.get("PLUGINS", {}) available = get_available_widgets(plugins_cfg) @@ -182,15 +182,17 @@ async def _get_panels_data(db, dashboard_id: int) -> tuple: {"db": w, "defn": WIDGET_REGISTRY[w.widget_key], "title": w.title or WIDGET_REGISTRY[w.widget_key]["label"]} for w in widgets if w.widget_key in WIDGET_REGISTRY ] - return dash, resolved, available + # Hosts feed the "host" param dropdown (e.g. the history-graph widget). + hosts = list((await db.execute(select(Host).order_by(Host.name))).scalars()) + return dash, resolved, available, hosts async def _panels_response(dashboard_id: int): async with current_app.db_sessionmaker() as db: - dash, resolved, addable = await _get_panels_data(db, dashboard_id) + dash, resolved, addable, hosts = await _get_panels_data(db, dashboard_id) return await render_template( "dashboard/_edit_panels.html", - dashboard=dash, widgets=resolved, addable=addable, + dashboard=dash, widgets=resolved, addable=addable, hosts=hosts, ) @@ -369,12 +371,12 @@ async def edit(dash_id: int): user_id = current_user_id() user_role = session.get("user_role", "viewer") async with current_app.db_sessionmaker() as db: - dash, resolved, addable = await _get_panels_data(db, dash_id) + dash, resolved, addable, hosts = await _get_panels_data(db, dash_id) if dash is None or not _can_edit(dash, user_id, user_role): abort(403) return await render_template( "dashboard/edit.html", - dashboard=dash, widgets=resolved, addable=addable, + dashboard=dash, widgets=resolved, addable=addable, hosts=hosts, ) diff --git a/steward/hosts/routes.py b/steward/hosts/routes.py index f0c3a2d..6917ccb 100644 --- a/steward/hosts/routes.py +++ b/steward/hosts/routes.py @@ -153,6 +153,36 @@ async def _agent_overview_by_host(db, host_names: list[str]) -> dict[str, dict]: return out +async def _agent_cpu_sparks_by_host(db, host_names: list[str], hours: int = 1) -> dict[str, str]: + """{host_name: inline-SVG cpu sparkline} over the last `hours` (core-safe). + + A tiny at-a-glance trend in each row — the host-view sparkline, on the widget. + """ + from steward.models.metrics import PluginMetric + from steward.core.status import sparkline_svg + if not host_names: + return {} + cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) + rows = (await db.execute( + select(PluginMetric.resource_name, PluginMetric.value, PluginMetric.recorded_at) + .where( + PluginMetric.source_module == "host_agent", + PluginMetric.metric_name == "cpu_pct", + PluginMetric.resource_name.in_(host_names), + PluginMetric.recorded_at >= cutoff, + ) + .order_by(PluginMetric.recorded_at) + )).all() + by_host: dict[str, list[float]] = {} + for r in rows: + by_host.setdefault(r.resource_name, []).append(r.value) + # Amber line to read as "CPU"; only draw when there are a couple of points. + return { + name: sparkline_svg(vals[-40:], width=64, height=18, stroke="#c8a840") + for name, vals in by_host.items() if len(vals) >= 2 + } + + @hosts_bp.get("/overview/widget") @require_role(UserRole.viewer) async def overview_widget(): @@ -180,12 +210,14 @@ async def overview_widget(): & (DnsResult.resolved_at == latest_dns_subq.c.max_at), ))).scalars()} uptime = await _compute_uptime(db) - agent = await _agent_overview_by_host(db, [h.name for h in hosts]) + host_names = [h.name for h in hosts] + agent = await _agent_overview_by_host(db, host_names) + cpu_sparks = await _agent_cpu_sparks_by_host(db, host_names) return await render_template( "hosts/overview_widget.html", hosts=hosts, latest_pings=latest_pings, latest_dns=latest_dns, - uptime=uptime, agent=agent, + uptime=uptime, agent=agent, cpu_sparks=cpu_sparks, ) diff --git a/steward/templates/dashboard/_edit_panels.html b/steward/templates/dashboard/_edit_panels.html index fe12d49..2a410e6 100644 --- a/steward/templates/dashboard/_edit_panels.html +++ b/steward/templates/dashboard/_edit_panels.html @@ -92,6 +92,16 @@ {% endfor %} + {% elif p.type == "host" %} + {% endif %}
{% endfor %} diff --git a/steward/templates/hosts/overview_widget.html b/steward/templates/hosts/overview_widget.html index 6e4ebf8..8c2052b 100644 --- a/steward/templates/hosts/overview_widget.html +++ b/steward/templates/hosts/overview_widget.html @@ -17,6 +17,8 @@ {% else %} {{ host.name }} {% endif %} + {% set spark = cpu_sparks.get(host.name) %} + {% if spark %}{{ spark | safe }}{% endif %} {# Monitors #} {% if host.ping_enabled and ping and ping.response_time_ms is not none %}