From d5b0b0c98056706c0cb0224dba673ba1fa8c2369 Mon Sep 17 00:00:00 2001 From: Robert Barnett Date: Sat, 3 Oct 2026 23:05:05 -0400 Subject: [PATCH] Add fleet history charts to the Dashboard, and move Fabric Integrity into the top row The Dashboard gains a window picker (1h to 7d) with alerts raised by severity, the noisiest rules and devices, top CPU and devices by type. One read, /api/dashboard, feeds them all, through the control database and the storage interface so it behaves the same on every backend. Fabric Integrity moves from the side column into the readout row. --- app/api/dashboard.py | 126 ++++++++++++++++ app/main.py | 2 + docs/USER_GUIDE.md | 2 + frontend/src/api/client.ts | 11 ++ frontend/src/components/FleetCharts.tsx | 183 ++++++++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 29 +++- 6 files changed, 347 insertions(+), 6 deletions(-) create mode 100644 app/api/dashboard.py create mode 100644 frontend/src/components/FleetCharts.tsx diff --git a/app/api/dashboard.py b/app/api/dashboard.py new file mode 100644 index 0000000..223b704 --- /dev/null +++ b/app/api/dashboard.py @@ -0,0 +1,126 @@ +""" +/api/dashboard — everything the Dashboard page's history charts draw, in one read. + +One request instead of one per panel, so the trend, the leaderboards and the +breakdowns all describe the same moment. Device counts and the trap panels stay +on /api/snmp/dashboard — this is the half that follows the window picker. + +Everything here reads the control database or goes through the storage +interface (get_all_devices_latest), never a backend's connection, so it behaves +the same on SQLite, DuckDB and ClickHouse. +""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import aiosqlite +from fastapi import APIRouter, Depends, Query + +from app.database import get_db +from app.dependencies import CurrentUser + +router = APIRouter() + +# Window (hours) -> bucket width (seconds); each step keeps the point count at 48-170. +_BUCKET_SECONDS = {1: 60, 6: 300, 24: 900, 168: 3600} +_TOP_N = 8 +# A device that stopped answering keeps its last reading forever; a leaderboard +# of machines that have since gone dark would report load that no longer exists. +_FRESH_MINUTES = 30 + + +def _bucket_for(hours: int) -> int: + for limit in sorted(_BUCKET_SECONDS): + if hours <= limit: + return _BUCKET_SECONDS[limit] + return 3600 + + +def _utc(dt: datetime) -> str: + return dt.strftime("%Y-%m-%d %H:%M:%S") + + +async def _alert_trend(db: aiosqlite.Connection, since: str, bucket: int) -> list[dict]: + async with db.execute( + """SELECT CAST(strftime('%s', fired_at) AS INTEGER) / ? AS b, severity, COUNT(*) AS n + FROM alert_events WHERE fired_at >= ? + GROUP BY b, severity ORDER BY b""", + (bucket, since), + ) as cur: + rows = await cur.fetchall() + by_bucket: dict[int, dict] = {} + for r in rows: + point = by_bucket.setdefault( + r["b"], {"t": r["b"] * bucket * 1000, "critical": 0, "warning": 0, "info": 0} + ) + point[r["severity"]] = r["n"] + return [by_bucket[b] for b in sorted(by_bucket)] + + +async def _top_cpu(db: aiosqlite.Connection) -> list[dict]: + from app.storage.factory import get_storage + + async with db.execute("SELECT id, name FROM devices WHERE enabled = 1") as cur: + names = {r["id"]: r["name"] for r in await cur.fetchall()} + if not names: + return [] + + fresh = _utc(datetime.now(timezone.utc) - timedelta(minutes=_FRESH_MINUTES)) + latest = await get_storage().get_all_devices_latest(list(names)) + out = [] + for did, rows in latest.items(): + # hrProcessorLoad is one row per core; the device's figure is their mean. + # polled_at is T-format on SQLite and space-format on DuckDB, and the + # space form sorts below the T form, so normalise before comparing. + vals = [ + r["value_numeric"] for r in rows + if r["oid_label"] == "hrProcessorLoad" and r["value_numeric"] is not None + and str(r["polled_at"] or "").replace("T", " ")[:19] >= fresh + ] + if vals: + out.append({"id": did, "name": names[did], "value": sum(vals) / len(vals)}) + out.sort(key=lambda r: r["value"], reverse=True) + return out[:_TOP_N] + + +@router.get("") +async def get_dashboard_charts( + _: CurrentUser, + db: aiosqlite.Connection = Depends(get_db), + hours: int = Query(24, ge=1, le=168), +) -> dict: + db.row_factory = aiosqlite.Row + since = _utc(datetime.now(timezone.utc) - timedelta(hours=hours)) + bucket = _bucket_for(hours) + + async with db.execute( + """SELECT r.id, r.name, COUNT(*) AS n + FROM alert_events e JOIN alert_rules r ON r.id = e.rule_id + WHERE e.fired_at >= ? GROUP BY r.id ORDER BY n DESC LIMIT ?""", + (since, _TOP_N), + ) as cur: + top_rules = [{"id": r["id"], "name": r["name"], "count": r["n"]} for r in await cur.fetchall()] + + async with db.execute( + """SELECT d.id, d.name, COUNT(*) AS n + FROM alert_events e JOIN devices d ON d.id = e.device_id + WHERE e.fired_at >= ? GROUP BY d.id ORDER BY n DESC LIMIT ?""", + (since, _TOP_N), + ) as cur: + top_devices = [{"id": r["id"], "name": r["name"], "count": r["n"]} for r in await cur.fetchall()] + + async with db.execute( + """SELECT COALESCE(NULLIF(device_type, ''), 'unclassified') AS t, COUNT(*) AS n + FROM devices GROUP BY t ORDER BY n DESC""" + ) as cur: + by_type = [{"type": r["t"], "count": r["n"]} for r in await cur.fetchall()] + + return { + "hours": hours, + "bucket_seconds": bucket, + "alert_trend": await _alert_trend(db, since, bucket), + "top_rules": top_rules, + "top_devices": top_devices, + "by_type": by_type, + "top_cpu": await _top_cpu(db), + } diff --git a/app/main.py b/app/main.py index c319739..aff0cd6 100644 --- a/app/main.py +++ b/app/main.py @@ -33,6 +33,7 @@ from app.api import integrations as integrations_router from app.api import widgets as widgets_router from app.api import nav as nav_router +from app.api import dashboard as dashboard_router from app.api import docs as docs_router settings = get_settings() @@ -300,6 +301,7 @@ async def _direct_access_lock(request: Request, call_next): app.include_router(integrations_router.router, prefix="/api/integrations", tags=["integrations"]) app.include_router(widgets_router.router, prefix="/api/widgets", tags=["widgets"]) app.include_router(nav_router.router, prefix="/api/nav", tags=["nav"]) +app.include_router(dashboard_router.router, prefix="/api/dashboard", tags=["dashboard"]) app.include_router(docs_router.router, prefix="/api/docs-content", tags=["docs"]) app.include_router(resonance_router.router, prefix="/api/resonance", tags=["resonance"]) # The assistant's data surface. Carries its own absolute paths — /api/resonance/data/* diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 6dbde8d..4d55157 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -27,6 +27,8 @@ The top-level pages are **Dashboard**, **Devices**, **Metrics**, **Alerts**, and The landing page gives an at-a-glance view of your SNMP-monitored environment: device status counts, recent alerts, and top interfaces by traffic/error rate. Use it to spot problems quickly before diving into a specific device. +**Fleet history** sits below the status row, with a window picker (1h, 6h, 24h, 7d). Alerts raised are charted by severity, with the noisiest rules and devices for the same window beside them. Top CPU (the average load across a device's processors, for devices polled in the last 30 minutes) and Devices by type show the fleet as it is now. Click a device in a list to open its metrics panel. + ## Devices Lists every monitored device, organized under the **Org → Group → Site → Location** hierarchy set up by your admin. Click a device to see its detail view — status, interfaces, and recent metric history. Devices that are unreachable or reporting errors are visually flagged. diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index b777c8e..37cf268 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -280,6 +280,7 @@ export const api = { request<{ enabled: boolean }>('/system/log-forward/reload', { method: 'POST' }), getSnmpDashboard: () => request('/snmp/dashboard'), + getDashboardCharts: (hours: number) => request(`/dashboard?hours=${hours}`), getDeviceTree: () => request('/snmp/devices/tree'), exportDevices: async (): Promise => { @@ -697,6 +698,16 @@ export interface HierarchyOrg { groups: HierarchyGroup[] } +export interface DashboardCharts { + hours: number + bucket_seconds: number + alert_trend: Array<{ t: number; critical: number; warning: number; info: number }> + top_rules: Array<{ id: number; name: string; count: number }> + top_devices: Array<{ id: number; name: string; count: number }> + by_type: Array<{ type: string; count: number }> + top_cpu: Array<{ id: number; name: string; value: number }> +} + export interface SnmpDashboard { trap_timeline: Array<{ hour: string; count: number }> top_sources: Array<{ source_ip: string; count: number }> diff --git a/frontend/src/components/FleetCharts.tsx b/frontend/src/components/FleetCharts.tsx new file mode 100644 index 0000000..9fbf555 --- /dev/null +++ b/frontend/src/components/FleetCharts.tsx @@ -0,0 +1,183 @@ +import { useEffect, useState, type ReactNode } from 'react' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' +import { api, DashboardCharts } from '../api/client' +import { useAutoRefresh } from '../store/autoRefresh' +import { axisProps, tooltipProps, gridProps, INSTRUMENT, InstrumentFrame, InstrumentHead } from './instrument' + +const WINDOWS = [ + { hours: 1, label: '1h' }, + { hours: 6, label: '6h' }, + { hours: 24, label: '24h' }, + { hours: 168, label: '7d' }, +] +const CHART_H = 180 + +// Alarm hues are the suite's only high-chroma colours, so severity reads the +// same here as on the Alerts page. +const SEVERITY: Array<{ key: 'critical' | 'warning' | 'info'; name: string; color: string }> = [ + { key: 'critical', name: 'Critical', color: '#ff6b5e' }, + { key: 'warning', name: 'Warning', color: '#f3c265' }, + { key: 'info', name: 'Info', color: INSTRUMENT.ice }, +] + +function timeTick(spanMs: number) { + const withDate = spanMs > 24 * 3600 * 1000 + return (ms: number) => new Date(ms).toLocaleString([], withDate + ? { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' } + : { hour: '2-digit', minute: '2-digit' }) +} + +function Empty({ msg, height = 90 }: { msg: string; height?: number }) { + return
{msg}
+} + +function Panel({ title, chip, children }: { title: string; chip?: ReactNode; children: ReactNode }) { + return ( +
+ {chip} : undefined}>{title} +
{children}
+
+ ) +} + +/** Horizontal bars: warm past a threshold so the entry worth a look stands out + * without the whole list shouting. `pct` marks a 0–100 value; otherwise bars + * scale to the largest row. */ +function BarList({ rows, empty, pct, onPick }: { + rows: Array<{ key: string; label: string; value: number }> + empty: string + pct?: boolean + onPick?: (key: string) => void +}) { + if (!rows.length) return + const max = Math.max(1, ...rows.map(r => r.value)) + return ( +
    + {rows.map(r => { + const width = pct ? r.value : (r.value / max) * 100 + const tone = pct && r.value >= 90 ? 'bg-red-500' : pct && r.value >= 75 ? 'bg-yellow-400' : 'bg-cyan-400' + const body = ( + <> +
    + {r.label} + + {pct ? `${Math.round(r.value)}%` : r.value.toLocaleString()} + +
    +
    +
    +
    + + ) + return ( +
  • + {onPick + ? + :
    {body}
    } +
  • + ) + })} +
+ ) +} + +function AlertTrend({ rows, spanMs }: { rows: DashboardCharts['alert_trend']; spanMs: number }) { + const totals = SEVERITY.map(s => ({ ...s, total: rows.reduce((n, r) => n + r[s.key], 0) })) + if (totals.every(s => s.total === 0)) return + return ( +
+ + + + + + + new Date(v).toLocaleString()} + formatter={(v: number, key: string) => [v, SEVERITY.find(s => s.key === key)?.name ?? key]} + /> + {SEVERITY.map(s => ( + + ))} + + + +
+ {totals.map(s => ( + + + {s.name} {s.total} + + ))} +
+
+ ) +} + +export default function FleetCharts({ onPickDevice }: { onPickDevice: (id: number) => void }) { + const [hours, setHours] = useState(24) + const [data, setData] = useState(null) + const [failed, setFailed] = useState(false) + + const load = async () => { + try { setData(await api.getDashboardCharts(hours)); setFailed(false) } + catch { setFailed(true) } + } + const { tick } = useAutoRefresh() + useEffect(() => { load() }, [hours]) + useEffect(() => { if (tick > 0) load() }, [tick]) + + const label = WINDOWS.find(w => w.hours === hours)?.label + + return ( +
+
+

Fleet history

+
+ {WINDOWS.map(w => ( + + ))} +
+
+ + {failed && !data && } + + {data && ( + <> + + + + +
+ + ({ key: String(r.id), label: r.name, value: r.count }))} /> + + + onPickDevice(Number(k))} + rows={data.top_devices.map(r => ({ key: String(r.id), label: r.name, value: r.count }))} /> + + + onPickDevice(Number(k))} + rows={data.top_cpu.map(r => ({ key: String(r.id), label: r.name, value: r.value }))} /> + + + ({ key: t.type, label: t.type, value: t.count }))} /> + +
+ + )} +
+ ) +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 1a7eda1..9eb66fa 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -7,6 +7,7 @@ import { import { useAutoRefresh } from '../store/autoRefresh' import DeviceMetricsPanel from '../components/DeviceMetricsPanel' import HelpButton from '../components/HelpButton' +import FleetCharts from '../components/FleetCharts' import { RadialGauge, InstrumentFrame, InstrumentHead, glow, INSTRUMENT } from '../components/instrument' // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -642,6 +643,19 @@ function EventStream({ traps }: { traps: SnmpDashboard['recent_traps'] }) { ) } +/** Walks the hierarchy for one device by id, whatever the level names are. */ +function findDevice(nodes: unknown, id: number): DeviceTreeNode | null { + if (!Array.isArray(nodes)) return null + for (const n of nodes as any[]) { + if (n?.type === 'device' && n.id === id) return n as DeviceTreeNode + for (const v of Object.values(n ?? {})) { + const hit = Array.isArray(v) ? findDevice(v, id) : null + if (hit) return hit + } + } + return null +} + // ── Main page ───────────────────────────────────────────────────────────────── const EMPTY_DASH: SnmpDashboard = { @@ -714,7 +728,7 @@ export default function Dashboard() { {/* ── main column ── */}
0 ? `${devCounts.up} of ${devCounts.total} responding` : undefined} /> +
+
Fabric Integrity
+
+ +
+
+ setSelectedDevice(findDevice(tree, id))} /> +
{/* ── telemetry column ── */}