Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions app/api/dashboard.py
Original file line number Diff line number Diff line change
@@ -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),
}
2 changes: 2 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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/*
Expand Down
2 changes: 2 additions & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ export const api = {
request<{ enabled: boolean }>('/system/log-forward/reload', { method: 'POST' }),

getSnmpDashboard: () => request<SnmpDashboard>('/snmp/dashboard'),
getDashboardCharts: (hours: number) => request<DashboardCharts>(`/dashboard?hours=${hours}`),
getDeviceTree: () => request<EnvironmentNode[]>('/snmp/devices/tree'),

exportDevices: async (): Promise<void> => {
Expand Down Expand Up @@ -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 }>
Expand Down
183 changes: 183 additions & 0 deletions frontend/src/components/FleetCharts.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className="grid place-items-center text-center f-lbl px-4" style={{ height }}>{msg}</div>
}

function Panel({ title, chip, children }: { title: string; chip?: ReactNode; children: ReactNode }) {
return (
<section className="min-w-0">
<InstrumentHead right={chip ? <span className="f-lbl">{chip}</span> : undefined}>{title}</InstrumentHead>
<div className="f-panel p-3">{children}</div>
</section>
)
}

/** 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 <Empty msg={empty} />
const max = Math.max(1, ...rows.map(r => r.value))
return (
<ul className="space-y-2.5">
{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 = (
<>
<div className="flex items-baseline justify-between gap-3 mb-1">
<span className="text-xs text-white truncate group-hover:underline">{r.label}</span>
<span className="font-mono text-xs text-white shrink-0">
{pct ? `${Math.round(r.value)}%` : r.value.toLocaleString()}
</span>
</div>
<div className="h-1.5 bg-gray-800">
<div className={`h-full ${tone}`} style={{ width: `${Math.min(100, Math.max(0, width))}%` }} />
</div>
</>
)
return (
<li key={r.key}>
{onPick
? <button type="button" onClick={() => onPick(r.key)} className="block w-full text-left group">{body}</button>
: <div className="group">{body}</div>}
</li>
)
})}
</ul>
)
}

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 <Empty msg="No alerts fired in this window" height={CHART_H} />
return (
<div>
<InstrumentFrame height={CHART_H}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={rows} margin={{ top: 10, right: 10, bottom: 4, left: 0 }}>
<CartesianGrid {...gridProps} />
<XAxis dataKey="t" type="number" scale="time" domain={['dataMin', 'dataMax']}
tickFormatter={timeTick(spanMs)} minTickGap={48} {...axisProps} />
<YAxis width={32} allowDecimals={false} {...axisProps} />
<Tooltip
contentStyle={tooltipProps.contentStyle}
labelStyle={tooltipProps.labelStyle}
cursor={tooltipProps.cursor}
labelFormatter={(v: number) => new Date(v).toLocaleString()}
formatter={(v: number, key: string) => [v, SEVERITY.find(s => s.key === key)?.name ?? key]}
/>
{SEVERITY.map(s => (
<Bar key={s.key} dataKey={s.key} stackId="a" fill={s.color} isAnimationActive={false} />
))}
</BarChart>
</ResponsiveContainer>
</InstrumentFrame>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2">
{totals.map(s => (
<span key={s.key} className="flex items-center gap-1.5 font-mono text-[10px] text-gray-400">
<span className="w-2 h-2" style={{ background: s.color }} />
{s.name} <span className="text-white">{s.total}</span>
</span>
))}
</div>
</div>
)
}

export default function FleetCharts({ onPickDevice }: { onPickDevice: (id: number) => void }) {
const [hours, setHours] = useState(24)
const [data, setData] = useState<DashboardCharts | null>(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 (
<div className="space-y-5">
<div className="flex items-center justify-between gap-3 flex-wrap">
<p className="f-lbl f-lbl-gold">Fleet history</p>
<div className="flex gap-1">
{WINDOWS.map(w => (
<button key={w.hours} type="button" onClick={() => setHours(w.hours)}
className={`px-2.5 py-1 text-xs font-mono border transition-colors ${
hours === w.hours ? 'border-blue-500 text-white bg-gray-800' : 'border-gray-800 text-gray-400 hover:text-white'}`}>
{w.label}
</button>
))}
</div>
</div>

{failed && !data && <Empty msg="Fleet history could not be loaded" />}

{data && (
<>
<Panel title="Alerts raised" chip={`by severity · ${label}`}>
<AlertTrend rows={data.alert_trend} spanMs={hours * 3600 * 1000} />
</Panel>

<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<Panel title="Noisiest rules" chip={label}>
<BarList empty="No alerts fired in this window"
rows={data.top_rules.map(r => ({ key: String(r.id), label: r.name, value: r.count }))} />
</Panel>
<Panel title="Noisiest devices" chip={label}>
<BarList empty="No device alerts in this window"
onPick={k => onPickDevice(Number(k))}
rows={data.top_devices.map(r => ({ key: String(r.id), label: r.name, value: r.count }))} />
</Panel>
<Panel title="Top CPU" chip="now">
<BarList pct empty="No device is reporting CPU load right now"
onPick={k => onPickDevice(Number(k))}
rows={data.top_cpu.map(r => ({ key: String(r.id), label: r.name, value: r.value }))} />
</Panel>
<Panel title="Devices by type" chip="now">
<BarList empty="No devices registered"
rows={data.by_type.map(t => ({ key: t.type, label: t.type, value: t.count }))} />
</Panel>
</div>
</>
)}
</div>
)
}
Loading
Loading