perf(dashboard): erst die Zahlen, dann der Rest — und eine Antwortzeile, die etwas sagt
Gemeldet als "das Dashboard laedt seit den Sync-Status-Sachen langsamer". Nachmessen ging bisher nicht, und das war der erste Fehler. Die Antwortzeile im Log lautete "⬅️ Response: 200" — ohne Pfad, ohne Dauer. Das Dashboard feuert dreizehn Requests gleichzeitig ab, deren Antworten sich also beliebig verschraenken; welcher davon gebremst hat, war aus dem Log schlicht nicht ablesbar. Jetzt stehen Methode, Pfad und Dauer in der Zeile, und alles ab einer Sekunde zusaetzlich als "🐢 SLOW". Das ist das Messinstrument, das der naechsten Meldung eine Zahl statt einer Vermutung gibt. Dazu die eine Ursache, die ohne Messung schon feststand: die Seite hing hinter Promise.all ueber allen dreizehn Requests und zeigte bis zum LANGSAMSTEN nur "Loading Dashboard...". Die Kennzahlen brauchen davon genau einen. Der hebt jetzt die Sperre, die Widgets fuellen sich nach. Wichtig dabei, und der Grund fuer das zusaetzliche widgetsLoading: ein Widget ohne Daten zeigte bisher "No data.". Wer die Seite frueher sieht, saehe damit "nichts gefunden", wo "noch nicht da" gemeint ist — die eine Verwechslung, die dieses Dashboard sich nicht leisten darf. Solange die Charge laeuft, steht dort ein Spinner. Der Ladebildschirm selbst ist ebenfalls einer, statt einer nackten Textzeile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+17
-2
@@ -191,16 +191,31 @@ def _safe_query_string(query: str) -> str:
|
||||
return "?" + "&".join(parts)
|
||||
|
||||
|
||||
# Anything slower than this is worth finding in a log by eye. The dashboard
|
||||
# fires thirteen requests at once, so "the page is slow" is only actionable
|
||||
# once a single line says WHICH of them was slow.
|
||||
SLOW_REQUEST_MS = 1000
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
safe_target = f"{request.url.path}{_safe_query_string(request.url.query)}"
|
||||
logger.info(f"➡️ Incoming Request: {request.method} {safe_target}")
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
response = await call_next(request)
|
||||
logger.info(f"⬅️ Response: {response.status_code}")
|
||||
# The path and the duration belong on the RESPONSE line, not just the
|
||||
# request one. Concurrent requests interleave — the dashboard alone
|
||||
# issues thirteen — so a bare "⬅️ Response: 200" cannot be tied back to
|
||||
# the request it answers, and a report of "the page loads slowly" had
|
||||
# no line to point at.
|
||||
ms = (time.perf_counter() - started) * 1000
|
||||
line = f"⬅️ Response: {response.status_code} {request.method} {safe_target} in {ms:.0f}ms"
|
||||
logger.warning(f"🐢 SLOW {line}") if ms >= SLOW_REQUEST_MS else logger.info(line)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Request Failed: {e}")
|
||||
ms = (time.perf_counter() - started) * 1000
|
||||
logger.error(f"❌ Request Failed after {ms:.0f}ms: {request.method} {safe_target}: {e}")
|
||||
raise
|
||||
|
||||
@app.middleware("http")
|
||||
|
||||
+39
-4
@@ -32,6 +32,11 @@ function renderVulnWidget(opts: {
|
||||
// EOL widget: replace the (often empty) CPR column with the affected
|
||||
// asset's hostname, linked to that asset's EOL findings.
|
||||
assetColumn?: boolean;
|
||||
// The page renders as soon as the stat cards have their numbers, so a
|
||||
// widget can be on screen before its own request has answered. Empty then
|
||||
// means "not back yet", and it must NOT read as "No data." — that is the
|
||||
// one confusion this dashboard cannot afford.
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const { title, subtitle, vulns, onRowClick, viewAllHref } = opts;
|
||||
const labelField = opts.labelField || 'cve_id';
|
||||
@@ -130,7 +135,14 @@ function renderVulnWidget(opts: {
|
||||
</tr>
|
||||
))}
|
||||
{vulns.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center py-4 text-gray-500">No data.</td></tr>
|
||||
<tr><td colSpan={6} className="text-center py-4 text-gray-500">
|
||||
{opts.loading
|
||||
? <span className="inline-flex items-center gap-2 text-gray-400">
|
||||
<span className="h-3 w-3 rounded-full border-2 border-gray-300 border-t-truevuln-blue animate-spin" />
|
||||
Loading…
|
||||
</span>
|
||||
: 'No data.'}
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -145,6 +157,9 @@ export default function Dashboard() {
|
||||
const [recentVulns, setRecentVulns] = useState<Vulnerability[]>([]);
|
||||
const [aiPriorities, setAiPriorities] = useState<AIPriorityResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Separate from `loading`: the page unblocks on the stat cards, and the
|
||||
// widgets keep their own spinner until the rest of the batch is in.
|
||||
const [widgetsLoading, setWidgetsLoading] = useState(true);
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
// AI audit hits an editor-gated endpoint → hide the controls from read-only.
|
||||
@@ -254,8 +269,16 @@ export default function Dashboard() {
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// The stat cards need this one and nothing else, so it lifts the
|
||||
// page's own gate as soon as it lands. Before, the whole dashboard sat
|
||||
// behind `Promise.all` of thirteen requests and showed a bare
|
||||
// "Loading Dashboard..." until the SLOWEST of them answered — the
|
||||
// numbers were ready long before the page admitted it.
|
||||
const statsP = api.get('/api/v1/vulnerabilities/reports/dashboard');
|
||||
statsP.then((r) => { setStats(r.data); setLoading(false); }).catch(() => { });
|
||||
|
||||
const [statsRes, vulnsRes, criticalRes, eolRes, mobileRes, kevRes, kevMineRes, schedRes, compRes, ursRes] = await Promise.all([
|
||||
api.get('/api/v1/vulnerabilities/reports/dashboard'),
|
||||
statsP,
|
||||
// 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).
|
||||
@@ -418,6 +441,7 @@ export default function Dashboard() {
|
||||
console.error("Failed to fetch dashboard data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setWidgetsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -425,7 +449,13 @@ export default function Dashboard() {
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-center">Loading Dashboard...</div>;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-4" role="status" aria-live="polite">
|
||||
<span className="h-10 w-10 rounded-full border-[3px] border-gray-200 border-t-truevuln-blue animate-spin" />
|
||||
<p className="text-sm font-mono text-gray-500 tracking-wide">Loading dashboard…</p>
|
||||
<span className="sr-only">Loading dashboard</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statItems = [
|
||||
@@ -824,6 +854,7 @@ export default function Dashboard() {
|
||||
title: 'Recent Critical CVEs',
|
||||
subtitle: 'CVSS ≥ 8 or Critical or KEV or EUVD · newest first, ties by CPR',
|
||||
vulns: criticalVulns,
|
||||
loading: widgetsLoading,
|
||||
onRowClick: (cveId) => router.push(`/vulnerabilities?cve_id=${cveId}`),
|
||||
viewAllHref: '/vulnerabilities?sort_by=published_date&sort_order=desc',
|
||||
})}
|
||||
@@ -831,6 +862,7 @@ export default function Dashboard() {
|
||||
title: 'Newly Published CVEs',
|
||||
subtitle: 'All severities · sorted by CVE published date',
|
||||
vulns: recentVulns,
|
||||
loading: widgetsLoading,
|
||||
onRowClick: (cveId) => router.push(`/vulnerabilities?cve_id=${cveId}`),
|
||||
viewAllHref: '/vulnerabilities?sort_by=published_date&sort_order=desc',
|
||||
})}
|
||||
@@ -838,6 +870,7 @@ export default function Dashboard() {
|
||||
title: 'Newly EOL / EOS',
|
||||
subtitle: 'End-of-life software · sorted by detection',
|
||||
vulns: eolVulns,
|
||||
loading: widgetsLoading,
|
||||
labelField: 'package_name',
|
||||
firstColHeader: 'Product',
|
||||
assetColumn: true,
|
||||
@@ -849,6 +882,7 @@ export default function Dashboard() {
|
||||
title: 'Mobile Security · EOL & Patch Level',
|
||||
subtitle: 'Phones & tablets · vendor EOL/EOS + Android patch staleness',
|
||||
vulns: mobileVulns,
|
||||
loading: widgetsLoading,
|
||||
labelField: 'package_name',
|
||||
firstColHeader: 'Device / Item',
|
||||
assetColumn: true,
|
||||
@@ -875,7 +909,8 @@ export default function Dashboard() {
|
||||
</div>
|
||||
<div className="overflow-x-auto flex-1">
|
||||
{kevAdvisories.length === 0 ? (
|
||||
<p className="p-4 text-xs text-gray-400 font-mono">No KEV data.</p>
|
||||
<p className="p-4 text-xs text-gray-400 font-mono">
|
||||
{widgetsLoading ? 'Loading…' : 'No KEV data.'}</p>
|
||||
) : (
|
||||
<table className="min-w-full text-sm">
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
|
||||
Reference in New Issue
Block a user