perf(dashboard): cut the load to a fifth of its queries

The dashboard fires fourteen requests and renders nothing until the slowest
returns. Measured against a seeded fleet (300 assets, 80k findings), the burst
took 0.76s — and the server runs them strictly one after another: every
vulnerabilities endpoint is `async def` holding a blocking Session, so six
parallel requests take exactly as long as six sequential ones (measured
speedup 0.90x). Wall clock is therefore the SUM of the endpoints, which makes
every wasted query on the page a direct hit on time-to-render.

Three sources of waste, all removed:

- /reports/dashboard ran eighteen COUNTs over the same scan — four severities,
  exploitable, avg, distinct assets, oldest row, seven one-day history counts,
  two totals, two asset counts. Now three statements: one aggregate with FILTER
  clauses, one grouped history query, one asset count. Response is byte-identical.
- The list endpoint lazily loaded asset, asset.groups and packages per row, so
  a twelve-row widget spent 36 round trips after its one real query. Eager-loaded:
  39 statements to 5 for that call, and the dashboard fires seven such widgets.
- With distinct_cve the unused `total` re-ran the whole window-function pass
  (80ms of a 160ms call) for a number no widget renders. New `with_total=false`,
  which the dashboard's seven widget calls now pass; default stays on, so the
  Vulnerabilities page is untouched.

Dashboard burst 0.76s → 0.48s locally. The N+1 removal counts for more than
that on a real deployment, where every round trip crosses the network.

Remaining top cost is /compliance/urs (185ms, ~40% of what is left): it pulls
every open finding into Python to compute CPR per row. The materialised
cpr_score column could aggregate that in SQL, but that changes URS numbers
wherever the column is stale, so it stays a separate decision.
This commit is contained in:
2026-08-29 09:23:35 +02:00
parent b14e83be92
commit 9e38554787
3 changed files with 255 additions and 98 deletions
+78 -91
View File
@@ -6,8 +6,8 @@ Endpoints for CVE management, prioritization, and AI analysis.
from typing import Optional, List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Query, BackgroundTasks
from sqlalchemy.orm import Session
from sqlalchemy import desc, asc, func
from sqlalchemy.orm import Session, selectinload
from sqlalchemy import desc, asc, func, and_
from sqlalchemy.exc import IntegrityError
from pydantic import BaseModel, field_validator
import json
@@ -561,6 +561,7 @@ async def list_vulnerabilities(
search: Optional[str] = Query(None, description="Suche in CVE-ID, Package, Title"),
include_inactive_assets: bool = Query(False, description="Include CVEs on INACTIVE/DECOMMISSIONED assets (off by default)"),
distinct_cve: bool = Query(False, description="Collapse per-asset duplicates to one row per CVE-ID (dashboard 'newest N distinct CVEs' widgets)"),
with_total: bool = Query(True, description="Compute the total row count. The dashboard widgets only render items, and with distinct_cve the count is a second full window-function pass over the table — pass false to skip it (`total` comes back null)."),
sort_by: str = Query("priority", description="Sortierung: priority, cvss, detected_at"),
sort_order: str = Query("desc", description="Reihenfolge: asc, desc"),
limit: int = Query(100, le=1000),
@@ -725,8 +726,22 @@ async def list_vulnerabilities(
_keep_ids = db.query(_sub.c.vid).filter(_sub.c.rn == 1)
query = query.filter(Vulnerability.id.in_(_keep_ids))
# Get total count before pagination
total_count = query.count()
# Get total count before pagination. Callers that only render the page
# itself (dashboard widgets) skip it — with distinct_cve the count re-runs
# the whole window-function pass, doubling the query for a number nobody
# displays.
total_count = query.count() if with_total else None
# Eager-load what _build_vuln_response touches on every row. Lazily these
# were three extra SELECTs per item — a 12-row dashboard widget spent 36
# round trips after its one real query, and the dashboard fires seven such
# widgets. Applied after the distinct_cve subquery is built, so the window
# pass above stays a plain id projection.
query = query.options(
selectinload(Vulnerability.asset).selectinload(Asset.groups),
selectinload(Vulnerability.group),
selectinload(Vulnerability.packages),
)
# Sortierung
# Supported sort_by values map to SQL columns.
@@ -1050,105 +1065,77 @@ async def get_dashboard_statistics(
- Betroffene Assets
- Älteste offene Vulnerability
"""
# Base Query: Nur offene Vulnerabilities, scoped to ACTIVE assets so
# that INACTIVE / DECOMMISSIONED assets do not inflate dashboard counts.
# Orphan vulnerabilities (asset_id NULL) are kept.
open_vulns = (
db.query(Vulnerability)
.outerjoin(Asset, Vulnerability.asset_id == Asset.id)
.filter(
Vulnerability.status == VulnerabilityStatus.open,
(Asset.id.is_(None)) | (Asset.status == AssetStatus.ACTIVE),
# One aggregate pass instead of eighteen COUNTs. Every number below is the
# same scan of vulnerabilities-joined-to-active-assets, so it is computed
# with FILTER clauses in a single round trip; the seven-day history is a
# second grouped query instead of one COUNT per day. Orphan vulnerabilities
# (asset_id NULL) are kept, INACTIVE / DECOMMISSIONED assets excluded.
from datetime import timedelta, date as _date
_active_scope = (Asset.id.is_(None)) | (Asset.status == AssetStatus.ACTIVE)
_open = Vulnerability.status == VulnerabilityStatus.open
def _n(expr):
return func.count().filter(expr)
agg = (
db.query(
func.count().label("total"),
_n(_open).label("open"),
_n(and_(_open, Vulnerability.severity == VulnerabilitySeverity.critical)).label("critical"),
_n(and_(_open, Vulnerability.severity == VulnerabilitySeverity.high)).label("high"),
_n(and_(_open, Vulnerability.severity == VulnerabilitySeverity.medium)).label("medium"),
_n(and_(_open, Vulnerability.severity == VulnerabilitySeverity.low)).label("low"),
_n(and_(_open, Vulnerability.exploit_available == True)).label("exploitable"),
func.avg(Vulnerability.cvss_score).filter(_open).label("avg_cvss"),
func.count(func.distinct(Vulnerability.asset_id)).filter(_open).label("affected_assets"),
func.min(Vulnerability.detected_at).filter(_open).label("oldest"),
)
.outerjoin(Asset, Vulnerability.asset_id == Asset.id)
.filter(_active_scope)
.one()
)
# Counts pro Severity
critical_count = open_vulns.filter(
Vulnerability.severity == VulnerabilitySeverity.critical
).count()
oldest_days = (datetime.now() - agg.oldest).days if agg.oldest else 0
high_count = open_vulns.filter(
Vulnerability.severity == VulnerabilitySeverity.high
).count()
medium_count = open_vulns.filter(
Vulnerability.severity == VulnerabilitySeverity.medium
).count()
low_count = open_vulns.filter(
Vulnerability.severity == VulnerabilitySeverity.low
).count()
# Exploitable Count
exploitable_count = open_vulns.filter(
Vulnerability.exploit_available == True
).count()
# Durchschnittlicher CVSS-Score
avg_cvss = (
db.query(func.avg(Vulnerability.cvss_score))
.outerjoin(Asset, Vulnerability.asset_id == Asset.id)
.filter(
Vulnerability.status == VulnerabilityStatus.open,
(Asset.id.is_(None)) | (Asset.status == AssetStatus.ACTIVE),
)
.scalar()
) or 0.0
# Betroffene Assets
affected_assets = (
db.query(func.count(func.distinct(Vulnerability.asset_id)))
.outerjoin(Asset, Vulnerability.asset_id == Asset.id)
.filter(
Vulnerability.status == VulnerabilityStatus.open,
(Asset.id.is_(None)) | (Asset.status == AssetStatus.ACTIVE),
)
.scalar()
) or 0
# Älteste Vulnerability
oldest_vuln = open_vulns.order_by(asc(Vulnerability.detected_at)).first()
oldest_days = 0
if oldest_vuln:
oldest_days = (datetime.now() - oldest_vuln.detected_at).days
# Verlauf der letzten 7 Tage (Erkennungsdatum)
severity_history = []
from datetime import timedelta, time, datetime as dt_class
# Verlauf der letzten 7 Tage (Erkennungsdatum) — one grouped query.
now = datetime.now()
window_start = datetime.combine((now - timedelta(days=6)).date(), datetime.min.time())
day_col = func.date(Vulnerability.detected_at)
# SQLite hands func.date() back as a 'YYYY-MM-DD' string, Postgres as a
# date — normalise so the lookup below matches on both.
counts_by_day = {
(_date.fromisoformat(d) if isinstance(d, str) else d): c
for d, c in db.query(day_col, func.count())
.outerjoin(Asset, Vulnerability.asset_id == Asset.id)
.filter(_active_scope, _open, Vulnerability.detected_at >= window_start)
.group_by(day_col)
.all()
}
severity_history = []
for i in range(6, -1, -1):
day_date = (now - timedelta(days=i)).date()
start_of_day = dt_class.combine(day_date, time.min)
end_of_day = dt_class.combine(day_date, time.max)
count = (
db.query(Vulnerability)
.outerjoin(Asset, Vulnerability.asset_id == Asset.id)
.filter(
Vulnerability.detected_at >= start_of_day,
Vulnerability.detected_at <= end_of_day,
Vulnerability.status == VulnerabilityStatus.open,
(Asset.id.is_(None)) | (Asset.status == AssetStatus.ACTIVE),
)
.count()
)
severity_history.append({
"day": day_date.strftime("%a"),
"count": count
"count": counts_by_day.get(day_date, 0),
})
# Gesamt-Zahlen
total_vulns = (
db.query(Vulnerability)
.outerjoin(Asset, Vulnerability.asset_id == Asset.id)
.filter((Asset.id.is_(None)) | (Asset.status == AssetStatus.ACTIVE))
.count()
)
open_vulns_count = open_vulns.count()
asset_counts = db.query(
func.count().label("total"),
func.count().filter(Asset.last_scan.isnot(None)).label("scanned"),
).one()
total_assets = db.query(Asset).count()
# Assets, die mindestens einmal gescannt wurden (last_scan is not null)
scanned_assets = db.query(Asset).filter(Asset.last_scan != None).count()
total_vulns = agg.total
open_vulns_count = agg.open
critical_count = agg.critical
high_count = agg.high
medium_count = agg.medium
low_count = agg.low
exploitable_count = agg.exploitable
avg_cvss = agg.avg_cvss or 0.0
affected_assets = agg.affected_assets or 0
total_assets = asset_counts.total
scanned_assets = asset_counts.scanned
return {
"total_vulnerabilities": total_vulns,
+7 -7
View File
@@ -253,7 +253,7 @@ export default function Dashboard() {
// Newly Published: sort by published_date desc. distinct_cve=true
// collapses per-asset duplicates server-side so we reliably get 10
// distinct CVEs (client dedup alone starved when a CVE hit N assets).
api.get('/api/v1/vulnerabilities?limit=12&status=open&sort_by=published_date&sort_order=desc&distinct_cve=true'),
api.get('/api/v1/vulnerabilities?limit=12&status=open&sort_by=published_date&sort_order=desc&distinct_cve=true&with_total=false'),
// Recent Critical: CVSS ≥ 8 OR KEV OR EUVD. Feeder sorts by CVE
// published date desc so the widget actually shows RECENT criticals
// (priority-sorted it pinned the same old 2021 KEV heavyweights
@@ -270,22 +270,22 @@ export default function Dashboard() {
// 8.0-8.9 band that is not "critical"), plus the two exploitation
// catalogues, which qualify regardless of score.
Promise.all([
api.get('/api/v1/vulnerabilities?limit=15&status=open&severity=critical&sort_by=published_date&sort_order=desc&distinct_cve=true'),
api.get('/api/v1/vulnerabilities?limit=15&status=open&severity=high&sort_by=published_date&sort_order=desc&distinct_cve=true'),
api.get('/api/v1/vulnerabilities?limit=15&status=open&kev_only=true&sort_by=published_date&sort_order=desc&distinct_cve=true'),
api.get('/api/v1/vulnerabilities?limit=15&status=open&euvd_only=true&sort_by=published_date&sort_order=desc&distinct_cve=true'),
api.get('/api/v1/vulnerabilities?limit=15&status=open&severity=critical&sort_by=published_date&sort_order=desc&distinct_cve=true&with_total=false'),
api.get('/api/v1/vulnerabilities?limit=15&status=open&severity=high&sort_by=published_date&sort_order=desc&distinct_cve=true&with_total=false'),
api.get('/api/v1/vulnerabilities?limit=15&status=open&kev_only=true&sort_by=published_date&sort_order=desc&distinct_cve=true&with_total=false'),
api.get('/api/v1/vulnerabilities?limit=15&status=open&euvd_only=true&sort_by=published_date&sort_order=desc&distinct_cve=true&with_total=false'),
]).then(rs => ({ data: { items: rs.flatMap(r => r.data?.items || r.data || []) } }))
.catch(() => ({ data: { items: [] } })),
// EOL / EOS: pseudo-CVEs from endoflife.date check (cve_id
// starts with "EOL-"). Sort by detected_at desc so the freshest EOL
// findings surface first; distinct_cve collapses the same EOL stream
// across many assets to one row (else the widget starved to ~4).
api.get('/api/v1/vulnerabilities?limit=12&status=active&search=EOL-&sort_by=detected_at&sort_order=desc&distinct_cve=true')
api.get('/api/v1/vulnerabilities?limit=12&status=active&search=EOL-&sort_by=detected_at&sort_order=desc&distinct_cve=true&with_total=false')
.catch(() => ({ data: { items: [] } })),
// Mobile Security: vendor EOL/EOS + Android patch-level staleness on
// phones/tablets. cvss desc surfaces the worst first (EOL 9 → patch
// ≥1y 8 → EOL-soon 5.5 → end-of-active-support 3).
api.get('/api/v1/vulnerabilities?limit=15&status=active&finding_type=mobile&sort_by=cvss&sort_order=desc')
api.get('/api/v1/vulnerabilities?limit=15&status=active&finding_type=mobile&sort_by=cvss&sort_order=desc&with_total=false')
.catch(() => ({ data: { items: [] } })),
// Advisory feed: actively exploited in the wild (CISA KEV + ENISA
// EUVD), independent of whether we have an affected asset yet.
+170
View File
@@ -0,0 +1,170 @@
"""Dashboard stats are one aggregate pass — run: python tests/test_dashboard_stats_queries.py
/vulnerabilities/reports/dashboard issued eighteen separate COUNTs over the
same vulnerabilities-joined-to-active-assets scan (four severities, exploitable,
avg, distinct assets, oldest row, seven one-day history counts, two totals, two
asset counts). Every one of them re-scanned the table, and because the endpoint
is `async def` with a blocking Session they run strictly back to back while the
rest of the dashboard's requests wait behind them.
Pinned here: a flat, small statement count and the exact numbers the eighteen
counts produced. Plus the list endpoint's `with_total=false`, which drops the
second full window-function pass the dashboard widgets never render.
"""
import asyncio
import os
import sys
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("JWT_SECRET_KEY", "test-key-test-key-test-key-test-key")
from sqlalchemy import create_engine, event # noqa: E402
from sqlalchemy.orm import sessionmaker # noqa: E402
import app.models # noqa: F401,E402 (registers every mapper)
from app.models.base import Base # noqa: E402
from app.models.asset import Asset, AssetSource, AssetStatus # noqa: E402
from app.models.vulnerability import ( # noqa: E402
Vulnerability, VulnerabilitySeverity, VulnerabilityStatus,
)
from app.routers.vulnerabilities import ( # noqa: E402
get_dashboard_statistics, list_vulnerabilities,
)
SEVERITIES = [VulnerabilitySeverity.critical, VulnerabilitySeverity.high,
VulnerabilitySeverity.medium, VulnerabilitySeverity.low]
def _db(hosts=10, per_host=8):
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
db = sessionmaker(bind=engine)()
now = datetime.now()
for i in range(hosts):
asset = Asset(hostname=f"host-{i}", ip_address="10.0.0.1",
source=AssetSource.MANUAL, operating_system="Ubuntu",
# One host is decommissioned — its findings must not count.
status=AssetStatus.DECOMMISSIONED if i == 0 else AssetStatus.ACTIVE,
last_scan=now if i % 2 == 0 else None)
db.add(asset)
db.flush()
for j in range(per_host):
n = i * per_host + j
db.add(Vulnerability(
cve_id=f"CVE-2024-{n:05d}", asset_id=asset.id,
severity=SEVERITIES[j % 4],
status=VulnerabilityStatus.patched if j == 0 else VulnerabilityStatus.open,
cvss_score=float(j),
exploit_available=(j % 3 == 0),
published_date=now - timedelta(days=j),
detected_at=now - timedelta(days=j % 5),
sources='["wazuh"]',
))
db.commit()
return engine, db
class _User:
pass
def _stats(db):
return asyncio.run(get_dashboard_statistics(db=db, current_user=_User()))
def test_statement_count_is_flat_and_small():
counts = {}
for hosts in (10, 100):
engine, db = _db(hosts=hosts)
stmts = []
event.listen(engine, "before_cursor_execute",
lambda conn, cur, stmt, *a: stmts.append(stmt))
_stats(db)
counts[hosts] = len(stmts)
assert counts[10] == counts[100], f"statements grew with the fleet: {counts}"
assert counts[100] <= 4, f"expected the aggregate pass, got {counts[100]} statements"
print(f"{counts[100]} statements for 100 hosts, same as for 10 (was 18)")
def test_numbers_match_the_per_count_semantics():
"""Same values the eighteen separate COUNTs produced."""
engine, db = _db(hosts=10, per_host=8)
s = _stats(db)
active = db.query(Asset).filter(Asset.status == AssetStatus.ACTIVE).all()
active_ids = {a.id for a in active}
rows = [v for v in db.query(Vulnerability).all() if v.asset_id in active_ids]
open_rows = [v for v in rows if v.status == VulnerabilityStatus.open]
assert s["total_vulnerabilities"] == len(rows)
assert s["open_vulnerabilities"] == len(open_rows)
for key, sev in (("critical_count", VulnerabilitySeverity.critical),
("high_count", VulnerabilitySeverity.high),
("medium_count", VulnerabilitySeverity.medium),
("low_count", VulnerabilitySeverity.low)):
assert s[key] == len([v for v in open_rows if v.severity == sev]), key
assert s["exploitable_count"] == len([v for v in open_rows if v.exploit_available])
assert s["avg_cvss_score"] == round(
sum(v.cvss_score for v in open_rows) / len(open_rows), 2)
assert s["affected_assets"] == len({v.asset_id for v in open_rows})
assert s["oldest_vulnerability_days"] == \
(datetime.now() - min(v.detected_at for v in open_rows)).days
assert s["total_assets"] == db.query(Asset).count()
assert s["scanned_assets"] == db.query(Asset).filter(Asset.last_scan.isnot(None)).count()
print("✅ every aggregate matches the per-count result, decommissioned host excluded")
def test_seven_day_history_still_has_seven_days():
engine, db = _db()
s = _stats(db)
hist = s["severity_history"]
assert len(hist) == 7
now = datetime.now()
assert [h["day"] for h in hist] == \
[(now - timedelta(days=i)).strftime("%a") for i in range(6, -1, -1)]
# Seeded detected_at spans the last five days, all inside the window.
open_rows = [v for v in db.query(Vulnerability).all()
if v.status == VulnerabilityStatus.open and v.asset_id != 1]
assert sum(h["count"] for h in hist) == len(open_rows)
print("✅ history is seven labelled days and totals the open findings")
def test_with_total_false_skips_the_count_only():
"""Same items, `total` null, one statement fewer."""
engine, db = _db(hosts=20)
# Called directly, so every Query() default has to be spelled out.
kw = dict(severity=None, status="open", exploitable=None, kev_only=None,
euvd_only=None, eu_critical=None, in_any_catalog=None,
in_both_catalogs=None, epss_min=None, source=None,
finding_type=None, cross_confirmed=None, asset_id=None, search=None,
include_inactive_assets=False, distinct_cve=True,
# detected_at, not published_date: that branch sorts with
# split_part/regex, which is Postgres-only and never reaches the
# count this test is about.
sort_by="detected_at", sort_order="desc", limit=12, offset=0,
db=db, current_user=_User())
stmts = []
event.listen(engine, "before_cursor_execute",
lambda conn, cur, stmt, *a: stmts.append(stmt))
with_total = asyncio.run(list_vulnerabilities(**kw))
n_with = len(stmts)
stmts.clear()
without = asyncio.run(list_vulnerabilities(with_total=False, **kw))
n_without = len(stmts)
def _ids(r):
return [(v["cve_id"] if isinstance(v, dict) else v.cve_id) for v in r["items"]]
assert _ids(with_total) == _ids(without)
assert with_total["total"] > 0 and without["total"] is None
assert n_without < n_with, f"count not skipped: {n_without} vs {n_with} statements"
print(f"✅ same items, total skipped, {n_with}{n_without} statements")
if __name__ == "__main__":
test_statement_count_is_flat_and_small()
test_numbers_match_the_per_count_semantics()
test_seven_day_history_still_has_seven_days()
test_with_total_false_skips_the_count_only()
print("\nAll dashboard stats query tests passed.")