Files
vulncheck/frontend/app/page.tsx
T
vulncheck 7851b31af7 fix(dashboard): Recent Critical widget starved to 2 rows — distinct_cve + deeper feed
Published-date-desc feeder returned the newest 300 per-asset rows, mostly
low/medium; after the CVSS≥8/KEV/EUVD filter only 2 criticals survived.
Same starving Newly Published had — same cure: distinct_cve=true collapses
per-asset duplicates server-side and the deeper window (300) reliably
fills the 10 slots.
2026-07-24 15:35:31 +02:00

855 lines
43 KiB
TypeScript

"use client";
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
ArrowUpRightIcon,
ArrowDownRightIcon,
SparklesIcon,
ClockIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import api from '../lib/api';
import { DashboardStats, Vulnerability, AIPriorityResponse } from '../types';
import AIRecommendations from '@/components/AIRecommendations';
// Compact CVE table widget used twice on the dashboard (Recent Critical
// + Newly Published). Same shape, different feeder list so the operator
// can compare 'highest current risk' vs 'just published' at a glance.
function renderVulnWidget(opts: {
title: string;
subtitle: string;
vulns: Vulnerability[];
onRowClick: (cveId: string) => void;
viewAllHref?: string;
// EOL widget: the pseudo-CVE id ("EOL-CHROME-148") is noise — show the
// product/package name in the first column instead, with a "PRODUCT"
// header. Falls back to cve_id when no package name.
labelField?: 'cve_id' | 'package_name';
firstColHeader?: string;
// EOL widget: replace the (often empty) CPR column with the affected
// asset's hostname, linked to that asset's EOL findings.
assetColumn?: boolean;
}) {
const { title, subtitle, vulns, onRowClick, viewAllHref } = opts;
const labelField = opts.labelField || 'cve_id';
const firstColHeader = opts.firstColHeader || 'CVE';
const assetColumn = opts.assetColumn || false;
const firstColLabel = (v: Vulnerability) =>
labelField === 'package_name' ? (v.package_name || v.cve_id) : v.cve_id;
const prioCls = (p: number | null | undefined) =>
p == null ? 'text-gray-400'
: p >= 80 ? 'text-red-700 font-bold'
: p >= 50 ? 'text-orange-700 font-bold'
: p >= 20 ? 'text-yellow-700' : 'text-gray-600';
const cprCls = (c: number | null | undefined) =>
c == null ? 'text-gray-400'
: c >= 50 ? 'text-red-700 font-bold'
: c >= 20 ? 'text-orange-700' : 'text-gray-600';
return (
<div className="bg-white border border-gray-200 shadow-sm rounded-sm overflow-hidden flex flex-col">
<div className="p-4 border-b border-gray-200 flex justify-between items-baseline bg-gray-50/50">
<div>
<h3 className="text-base font-bold text-gray-900 font-mono">{title}</h3>
<p className="text-[11px] text-gray-500 font-mono mt-0.5">{subtitle}</p>
</div>
<Link href={viewAllHref || "/vulnerabilities"} prefetch className="text-truevuln-blue text-[10px] font-bold uppercase tracking-wider font-mono hover:text-blue-700">View All &gt;</Link>
</div>
<div className="overflow-x-auto flex-1">
<table className="min-w-full divide-y divide-gray-200 table-fixed">
<colgroup>
<col className="w-[34%]" />
<col className="w-[12%]" />
<col className="w-[10%]" />
<col className="w-[12%]" />
<col className="w-[12%]" />
<col className="w-[20%]" />
</colgroup>
<thead className="bg-gray-50">
<tr>
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">{firstColHeader}</th>
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">Sev</th>
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="CVSS Base Score">CVSS</th>
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="Priority Score 0-100">PRIO</th>
{assetColumn
? <th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="Affected asset — click for all its EOL findings">Asset</th>
: <th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="CPR 0-100 (JacquesKruger)">CPR</th>}
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">Flags</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200 font-mono text-xs">
{vulns.map((vuln) => (
<tr key={vuln.id} onClick={() => onRowClick(vuln.cve_id)} className="cursor-pointer hover:bg-gray-50 transition-colors">
<td className="px-2 py-2 font-bold text-indigo-600 hover:text-indigo-800">
<div className="truncate" title={`${firstColLabel(vuln)} · ${vuln.cve_id}`}>{firstColLabel(vuln)}</div>
</td>
<td className="px-2 py-2">
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold ${vuln.severity === 'critical' ? 'bg-red-100 text-red-700'
: vuln.severity === 'high' ? 'bg-orange-100 text-orange-700'
: vuln.severity === 'medium' ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'
}`}>
{vuln.severity.toUpperCase().slice(0, 4)}
</span>
</td>
<td className="px-2 py-2 font-bold">{vuln.cvss_score ?? '—'}</td>
<td className={`px-2 py-2 ${prioCls(vuln.priority_score ?? null)}`}>
{vuln.priority_score == null ? '—' : vuln.priority_score.toFixed(0)}
</td>
{assetColumn ? (
<td className="px-2 py-2">
{vuln.asset_id ? (
<Link
href={`/vulnerabilities?asset_id=${vuln.asset_id}&eol=1`}
onClick={(e) => e.stopPropagation()}
className="text-indigo-600 hover:text-indigo-800 hover:underline truncate block"
title={`All EOL findings on ${vuln.asset_hostname || `asset #${vuln.asset_id}`}`}
>
{vuln.asset_hostname || `#${vuln.asset_id}`}
</Link>
) : '—'}
</td>
) : (
<td className={`px-2 py-2 ${cprCls(vuln.cpr_score ?? null)}`}>
{vuln.cpr_score == null ? '—' : vuln.cpr_score.toFixed(1)}
</td>
)}
<td className="px-2 py-2">
<div className="flex gap-1 flex-wrap">
{vuln.kev_listed && <span className="inline-flex items-center rounded bg-red-100 px-1 py-0.5 text-[9px] font-bold text-red-700">KEV</span>}
{vuln.euvd_listed && <span className="inline-flex items-center rounded bg-blue-100 px-1 py-0.5 text-[9px] font-bold text-blue-700">EUVD</span>}
{vuln.exploitation_status && vuln.exploitation_status !== 'none' && (
<span className={`inline-flex items-center rounded px-1 py-0.5 text-[9px] font-bold uppercase ${vuln.exploitation_status === 'widespread' ? 'bg-red-600 text-white'
: vuln.exploitation_status === 'active' ? 'bg-orange-100 text-orange-700'
: 'bg-yellow-100 text-yellow-700'
}`}>{vuln.exploitation_status.slice(0, 4)}</span>
)}
</div>
</td>
</tr>
))}
{vulns.length === 0 && (
<tr><td colSpan={6} className="text-center py-4 text-gray-500">No data.</td></tr>
)}
</tbody>
</table>
</div>
</div>
);
}
export default function Dashboard() {
const router = useRouter();
const [stats, setStats] = useState<DashboardStats | null>(null);
const [recentVulns, setRecentVulns] = useState<Vulnerability[]>([]);
const [aiPriorities, setAiPriorities] = useState<AIPriorityResponse | null>(null);
const [loading, setLoading] = 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.
const [userRole, setUserRole] = useState('');
useEffect(() => {
api.get('/auth/me').then(r => setUserRole(r.data?.role || '')).catch(() => { });
}, []);
const canEdit = userRole === 'admin' || userRole === 'editor';
const [aiSeverity, setAiSeverity] = useState<string>('all');
const [aiAbortController, setAiAbortController] = useState<AbortController | null>(null);
const [showHistory, setShowHistory] = useState(false);
const [historyReports, setHistoryReports] = useState<any[]>([]);
const fetchAIRecommendations = async (severityFilter?: string) => {
setAiLoading(true);
setAiError(null);
const controller = new AbortController();
setAiAbortController(controller);
try {
console.log(`Triggering AI Audit (Severity: ${severityFilter || aiSeverity})...`);
const params = (severityFilter || aiSeverity) !== 'all' ? { severity: severityFilter || aiSeverity } : {};
const res = await api.get('/api/v1/vulnerabilities/ai-prioritization', { params, signal: controller.signal });
console.log("AI Response received:", res.data);
setAiPriorities(res.data);
if (res.data.error) {
setAiError(res.data.error);
}
} catch (error: any) {
if (error.name === 'CanceledError' || error.code === 'ERR_CANCELED') {
console.log("AI Audit cancelled by user.");
setAiError(null);
} else {
console.error("Failed to fetch AI recommendations:", error);
setAiError(error.response?.data?.detail || "The AI session timed out. Please try again.");
}
} finally {
setAiLoading(false);
setAiAbortController(null);
}
};
const cancelAIAudit = () => {
if (aiAbortController) {
aiAbortController.abort();
}
};
const loadHistory = async () => {
try {
const res = await api.get('/api/v1/vulnerabilities/ai-history');
setHistoryReports(res.data);
setShowHistory(true);
} catch (e) {
console.error("Failed to load history", e);
}
};
const loadReport = (report: any) => {
setAiPriorities({
global_strategy: report.global_strategy,
recommendations: report.recommendations
});
setShowHistory(false);
};
// Number of enabled scan schedules — drives the "Sync active" badge
// in the stat cards. Hardcoded label before this commit always said
// "Sync active" even when no schedules existed, which tester flagged
// as misleading.
const [activeScheduleCount, setActiveScheduleCount] = useState<number | null>(null);
// Compliance widget feed
type ComplianceSummary = {
assets_with_compliance: number;
total_policies: number;
avg_score_all: number | null;
pass_total: number;
fail_total: number;
not_applicable_total: number;
worst_offenders: Array<{
asset_id: number;
hostname: string;
avg_score: number | null;
worst_policy: string | null;
}>;
};
const [compliance, setCompliance] = useState<ComplianceSummary | null>(null);
type URSRow = {
asset_id: number; hostname: string | null;
avs: number | null; ass: number | null; urs: number | null;
severity: 'CRITICAL'|'HIGH'|'MEDIUM'|'LOW'|'NONE';
criticality: string | null;
};
const [ursTop, setUrsTop] = useState<URSRow[]>([]);
// Two split widgets — Recent Critical (high impact, recently changed)
// and Newly Published (chronological first-seen) — plus a third for
// endoflife.date EOL pseudo-CVEs so unsupported software has its own
// dashboard surface (tester request).
const [criticalVulns, setCriticalVulns] = useState<Vulnerability[]>([]);
const [eolVulns, setEolVulns] = useState<Vulnerability[]>([]);
const [mobileVulns, setMobileVulns] = useState<Vulnerability[]>([]);
const [kevAdvisories, setKevAdvisories] = useState<any[]>([]);
useEffect(() => {
const fetchData = async () => {
try {
const [statsRes, vulnsRes, criticalRes, eolRes, mobileRes, kevRes, schedRes, compRes, ursRes] = await Promise.all([
api.get('/api/v1/vulnerabilities/reports/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'),
// 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
// forever — tester). Client tie-breaks same-day CVEs by CPR desc.
// distinct_cve + deep limit: the newest rows are mostly low/medium,
// so a shallow per-asset window left only 2 criticals (same starving
// Newly Published had before distinct_cve).
api.get('/api/v1/vulnerabilities?limit=300&status=open&sort_by=published_date&sort_order=desc&distinct_cve=true')
.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')
.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')
.catch(() => ({ data: { items: [] } })),
// Advisory feed: recently-added CISA KEV (actively exploited in the
// wild), independent of whether we have an affected asset yet.
api.get('/api/v1/advisories/kev-recent?limit=12')
.catch(() => ({ data: { items: [] } })),
api.get('/api/v1/scans/schedules').catch(() => ({ data: [] })),
api.get('/api/v1/compliance/summary').catch(() => ({ data: null })),
api.get('/api/v1/compliance/urs?limit=200').catch(() => ({ data: [] })),
]);
setStats(statsRes.data);
// EOL pseudo-CVEs have their own widget — skip them here so
// they don't take seats in Recent Critical / Newly Published.
const isRealCve = (v: Vulnerability) =>
!!v.cve_id && !v.cve_id.startsWith('EOL-') && !v.cve_id.startsWith('NESSUS-PLUGIN-');
// Newly Published — dedup by CVE-ID, keep first occurrence
// (already sorted by published_date desc).
const rawVulns: Vulnerability[] = vulnsRes.data.items || vulnsRes.data;
const seenPublished = new Set<string>();
const recentPublished: Vulnerability[] = [];
for (const v of rawVulns) {
if (!isRealCve(v)) continue;
if (v.cve_id && !seenPublished.has(v.cve_id)) {
seenPublished.add(v.cve_id);
recentPublished.push(v);
if (recentPublished.length >= 10) break;
}
}
setRecentVulns(recentPublished);
// Recent Critical — dedup + filter (CVSS ≥ 8 OR KEV OR EUVD). Feeder
// is published_date desc; tie-break same-day CVEs by CPR desc so the
// most exploitable of the newest sit on top.
const rawCritical: Vulnerability[] =
(criticalRes.data && (criticalRes.data.items || criticalRes.data)) || [];
const seenCritical = new Set<string>();
const recentCritical: Vulnerability[] = [];
for (const v of rawCritical) {
if (!isRealCve(v)) continue;
const isCritical =
(v.cvss_score ?? 0) >= 8.0 || v.kev_listed === true || v.euvd_listed === true;
if (!isCritical) continue;
if (v.cve_id && !seenCritical.has(v.cve_id)) {
seenCritical.add(v.cve_id);
recentCritical.push(v);
if (recentCritical.length >= 10) break;
}
}
recentCritical.sort((a: any, b: any) => {
const da = (a.published_date || '').slice(0, 10);
const db_ = (b.published_date || '').slice(0, 10);
if (da !== db_) return da < db_ ? 1 : -1; // newer day first
return (b.cpr_score ?? 0) - (a.cpr_score ?? 0);
});
setCriticalVulns(recentCritical);
// Mobile findings have their own widget — keep them out of the
// generic (desktop-software) EOL widget so the two stay cleanly apart.
const isMobileFinding = (v: Vulnerability) => !!v.cve_id && (
v.cve_id.startsWith('ANDROID-PATCH-') ||
v.cve_id.startsWith('EOL-IPHONE-') || v.cve_id.startsWith('EOL-IPAD-') ||
v.cve_id.startsWith('EOL-SAMSUNG-MOBILE-') || v.cve_id.startsWith('EOL-SAMSUNG-GALAXY-TAB-'));
// EOL widget — dedup by cve_id (one row per EOL stream is plenty).
const rawEol: Vulnerability[] = (eolRes.data && (eolRes.data.items || eolRes.data)) || [];
const seenEol = new Set<string>();
const recentEol: Vulnerability[] = [];
for (const v of rawEol) {
if (isMobileFinding(v)) continue;
if (v.cve_id && !seenEol.has(v.cve_id)) {
seenEol.add(v.cve_id);
recentEol.push(v);
if (recentEol.length >= 10) break;
}
}
setEolVulns(recentEol);
// Mobile Security widget — dedup by cve_id + asset (the SAME EOL
// stream on N different devices is N distinct findings).
const rawMobile: Vulnerability[] = (mobileRes.data && (mobileRes.data.items || mobileRes.data)) || [];
const seenMobile = new Set<string>();
const recentMobile: Vulnerability[] = [];
for (const v of rawMobile) {
const key = `${v.cve_id}|${v.asset_id}`;
if (!seenMobile.has(key)) {
seenMobile.add(key);
recentMobile.push(v);
if (recentMobile.length >= 10) break;
}
}
setMobileVulns(recentMobile);
setKevAdvisories((kevRes.data && kevRes.data.items) || []);
const schedules = Array.isArray(schedRes.data) ? schedRes.data : [];
setActiveScheduleCount(schedules.filter((s: any) => s.enabled).length);
setCompliance(compRes.data);
setUrsTop(Array.isArray(ursRes.data) ? ursRes.data : []);
} catch (error) {
console.error("Failed to fetch dashboard data:", error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
if (loading) {
return <div className="p-8 text-center">Loading Dashboard...</div>;
}
const statItems = [
{ name: 'CRITICAL', value: stats?.critical_count, change: '+0', changeType: 'increase', color: 'text-securis-danger' },
{ name: 'HIGH', value: stats?.high_count, change: '+0', changeType: 'decrease', color: 'text-truevuln-blue' },
{ name: 'MEDIUM', value: stats?.medium_count, change: '+0', changeType: 'increase', color: 'text-blue-600' },
{ name: 'LOW', value: stats?.low_count, change: '+0', changeType: 'decrease', color: 'text-green-600' },
];
return (
// Full available width (minus AppShell padding) — tester wanted the
// empty left/right gutters used on wide monitors. No max-width cap.
<div className="w-full">
{/* Header Section */}
<div className="md:flex md:items-center md:justify-between mb-8">
<div className="min-w-0 flex-1">
<h2 className="text-3xl font-bold leading-7 text-gray-900 sm:truncate sm:tracking-tight font-mono">
Vulnerability Dashboard
</h2>
<p className="mt-1 text-sm text-gray-500">Monitor and manage security vulnerabilities across your infrastructure</p>
</div>
<div className="mt-4 flex flex-wrap md:ml-4 md:mt-0 items-center gap-2 justify-end">
{canEdit && (<>
<div className="flex items-center ring-1 ring-inset ring-gray-300 rounded-md bg-white px-2">
<span className="text-xs font-mono text-gray-500 mr-1 uppercase font-bold text-indigo-600">Audit filter:</span>
<select
value={aiSeverity}
onChange={(e) => setAiSeverity(e.target.value)}
disabled={aiLoading}
className="bg-transparent text-sm font-semibold text-gray-900 py-2 focus:outline-none border-none cursor-pointer font-mono"
>
<option value="all">Any Severity</option>
<option value="critical">Critical Only</option>
<option value="high">High Only</option>
<option value="medium">Medium Only</option>
<option value="low">Low Only</option>
</select>
</div>
{aiLoading ? (
<button
onClick={cancelAIAudit}
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-semibold shadow-sm ring-1 ring-inset ring-red-300 bg-red-50 text-red-700 hover:bg-red-100 transition-all"
>
<svg className="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
Cancel AI Audit
</button>
) : (
<button
onClick={() => fetchAIRecommendations()}
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-semibold shadow-sm ring-1 ring-inset ring-gray-300 bg-white text-gray-900 hover:bg-indigo-50 hover:ring-indigo-300 transition-all"
>
<SparklesIcon className="h-4 w-4 mr-2 text-indigo-600" />
AI Audit
</button>
)}
</>)}
{canEdit && (
<button
onClick={loadHistory}
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-semibold shadow-sm ring-1 ring-inset ring-gray-300 bg-white text-gray-900 hover:bg-gray-50 transition-all ml-2"
title="View Scan History"
>
<ClockIcon className="h-4 w-4 text-gray-500" />
</button>
)}
{/* Was a dead button (no onClick) for every role — point it at the
reports page, which is where the exports actually live. */}
<Link
href="/reports"
className="inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
>
Export Report
</Link>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
{statItems.map((item) => (
<div
key={item.name}
onClick={() => router.push(`/vulnerabilities?severity=${item.name.toLowerCase()}`)}
className="relative overflow-hidden rounded-sm bg-white border border-gray-200 p-6 shadow-sm cursor-pointer hover:border-gray-400 hover:shadow-md transition-all"
>
<dt>
<p className="truncate text-xs font-bold text-gray-500 uppercase tracking-wider font-mono">{item.name}</p>
</dt>
<dd className="flex items-baseline pb-1 sm:pb-2">
<p className={`text-4xl font-bold ${item.color || 'text-gray-900'} font-mono`}>{item.value ?? '-'}</p>
</dd>
<div className="flex items-center text-xs text-gray-500 gap-1.5">
{activeScheduleCount === null ? (
<span className="font-mono text-gray-400">checking</span>
) : activeScheduleCount > 0 ? (
<>
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span className="font-mono" title="Scheduled syncs enabled in Settings → Scan Schedules">
{activeScheduleCount} scheduled sync{activeScheduleCount === 1 ? '' : 's'}
</span>
</>
) : (
<>
<span className="inline-block h-1.5 w-1.5 rounded-full bg-gray-300" />
<span className="font-mono text-gray-400" title="No enabled scan schedules — data only updates on manual sync">
No scheduled syncs
</span>
</>
)}
</div>
</div>
))}
</div>
{/* URS widget — Unified Risk Score (Plan E) — hidden until any asset has URS */}
{ursTop.some(r => r.urs !== null) && (() => {
const scored = ursTop.filter(r => r.urs !== null);
const avgUrs = scored.length ? scored.reduce((s, r) => s + (r.urs || 0), 0) / scored.length : null;
const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, NONE: 0 } as Record<string, number>;
scored.forEach(r => { counts[r.severity] = (counts[r.severity] || 0) + 1; });
const top5 = [...scored].sort((a, b) => (b.urs || 0) - (a.urs || 0)).slice(0, 5);
const sevClass = (s: string) =>
s === 'CRITICAL' ? 'bg-red-700 text-white' :
s === 'HIGH' ? 'bg-red-100 text-red-700' :
s === 'MEDIUM' ? 'bg-orange-100 text-orange-700' :
s === 'LOW' ? 'bg-emerald-100 text-emerald-700' :
'bg-gray-100 text-gray-500';
const avgClass =
avgUrs === null ? 'text-gray-400' :
avgUrs >= 90 ? 'text-red-700' :
avgUrs >= 70 ? 'text-red-600' :
avgUrs >= 40 ? 'text-orange-700' :
'text-emerald-700';
return (
<div className="bg-white border border-gray-200 rounded-sm shadow-sm p-6 mb-8">
<div className="flex items-baseline justify-between mb-4">
<h3 className="text-lg font-bold text-gray-900 font-mono">
Unified Risk Score (URS)
</h3>
<Link href="/compliance" prefetch className="text-truevuln-blue text-xs font-bold uppercase tracking-wider font-mono hover:text-blue-700">
Open &gt;
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<p className="text-xs uppercase tracking-wider text-gray-500 font-mono">Avg URS</p>
<p className={`text-4xl font-mono mt-1 ${avgClass}`}>
{avgUrs !== null ? avgUrs.toFixed(1) : '—'}
</p>
<p className="text-xs text-gray-400 font-mono mt-2">{scored.length} assets scored</p>
</div>
<div>
<p className="text-xs uppercase tracking-wider text-gray-500 font-mono mb-2">Severity spread</p>
<div className="flex flex-wrap gap-1.5">
{(['CRITICAL','HIGH','MEDIUM','LOW','NONE'] as const).map(sev => (
counts[sev] > 0 && (
<span key={sev} className={`inline-flex items-center rounded px-2 py-0.5 text-[10px] font-bold ${sevClass(sev)}`}>
{sev} {counts[sev]}
</span>
)
))}
</div>
</div>
<div>
<p className="text-xs uppercase tracking-wider text-gray-500 font-mono mb-2">Highest-risk assets</p>
<ul className="space-y-1 text-sm font-mono">
{top5.map(r => (
<li key={r.asset_id} className="flex justify-between gap-2">
<span className="truncate">{r.hostname || `#${r.asset_id}`}</span>
<span className="flex items-center gap-1.5">
<span className="text-gray-700 font-bold">
{r.urs !== null ? r.urs.toFixed(0) : '—'}
</span>
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[9px] font-bold ${sevClass(r.severity)}`}>
{r.severity}
</span>
</span>
</li>
))}
</ul>
</div>
</div>
</div>
);
})()}
{/* Compliance widget — hidden if no SCA data yet */}
{compliance && compliance.assets_with_compliance > 0 && (() => {
const score = compliance.avg_score_all;
const scoreCls = score === null ? 'text-gray-400'
: score >= 90 ? 'text-emerald-700'
: score >= 70 ? 'text-yellow-700'
: score >= 40 ? 'text-orange-700' : 'text-red-700';
const barCls = score === null ? 'bg-gray-200'
: score >= 90 ? 'bg-emerald-500'
: score >= 70 ? 'bg-yellow-500'
: score >= 40 ? 'bg-orange-500' : 'bg-red-500';
return (
<div className="bg-white border border-gray-200 rounded-sm shadow-sm p-6 mb-8">
<div className="flex items-baseline justify-between mb-4">
<h3 className="text-lg font-bold text-gray-900 font-mono">
Compliance (Wazuh SCA)
</h3>
<Link href="/compliance" prefetch className="text-truevuln-blue text-xs font-bold uppercase tracking-wider font-mono hover:text-blue-700">
Open &gt;
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<p className="text-xs uppercase tracking-wider text-gray-500 font-mono">Avg score</p>
<p className={`text-4xl font-mono mt-1 ${scoreCls}`}>
{score !== null ? `${score.toFixed(1)}%` : '—'}
</p>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-100 mt-2">
<div className={`h-full ${barCls}`} style={{ width: `${score ?? 0}%` }} />
</div>
<p className="text-xs text-gray-400 font-mono mt-2">
{compliance.assets_with_compliance} assets · {compliance.total_policies} policy runs
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wider text-gray-500 font-mono">Check totals</p>
<div className="grid grid-cols-3 gap-2 mt-2 text-center">
<div>
<p className="text-xl font-mono font-bold text-emerald-700">{compliance.pass_total.toLocaleString()}</p>
<p className="text-[10px] uppercase text-gray-500 font-mono">Pass</p>
</div>
<div>
<p className="text-xl font-mono font-bold text-red-700">{compliance.fail_total.toLocaleString()}</p>
<p className="text-[10px] uppercase text-gray-500 font-mono">Fail</p>
</div>
<div>
<p className="text-xl font-mono font-bold text-gray-500">{compliance.not_applicable_total.toLocaleString()}</p>
<p className="text-[10px] uppercase text-gray-500 font-mono">N/A</p>
</div>
</div>
</div>
<div>
<p className="text-xs uppercase tracking-wider text-gray-500 font-mono mb-2">Worst offenders</p>
<ul className="space-y-1 text-sm font-mono">
{compliance.worst_offenders.slice(0, 3).map(o => {
const c = o.avg_score === null ? 'text-gray-400'
: o.avg_score >= 70 ? 'text-yellow-700'
: o.avg_score >= 40 ? 'text-orange-700' : 'text-red-700';
return (
<li key={o.asset_id} className="flex justify-between gap-2">
<Link href={`/compliance`} className="text-truevuln-blue hover:underline truncate" title={o.worst_policy || ''}>
{o.hostname}
</Link>
<span className={`${c} font-bold`}>
{o.avg_score !== null ? `${o.avg_score.toFixed(0)}%` : '—'}
</span>
</li>
);
})}
{compliance.worst_offenders.length === 0 && (
<li className="text-gray-400 text-xs">All assets above threshold.</li>
)}
</ul>
</div>
</div>
</div>
);
})()}
{/* AI Smart Priorities — editor+ only (the audit that fills it is gated) */}
{canEdit && (
<AIRecommendations
recommendations={aiPriorities?.recommendations || []}
strategy={aiPriorities?.global_strategy}
loading={aiLoading}
error={aiError}
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
{/* Chart Section Placeholder */}
<div className="lg:col-span-2 bg-white border border-gray-200 p-6 rounded-sm shadow-sm">
<div className="mb-6">
<h3 className="text-lg font-bold text-gray-900 font-mono">Severity Breakdown Last 7 Days</h3>
</div>
<div className="h-48 flex items-end justify-between gap-4 px-4 pt-4 border-b border-gray-100 pb-2">
{stats?.severity_history.map((h, idx) => {
const maxCount = Math.max(...(stats?.severity_history.map(s => s.count) || [1]));
const height = stats?.severity_history ? (h.count / maxCount * 100) : 0;
const colors = ['bg-amber-900', 'bg-amber-700', 'bg-truevuln-blue', 'bg-blue-900', 'bg-red-700', 'bg-green-900', 'bg-blue-500'];
return (
<div
key={idx}
className={`w-full ${colors[idx % colors.length]} rounded-t-sm transition-all duration-500 ease-out`}
style={{ height: `${Math.max(height, 5)}%`, opacity: h.count === 0 ? 0.3 : 1 }}
title={`${h.day}: ${h.count} new vulns`}
></div>
);
})}
</div>
<div className="flex justify-between px-4 text-xs text-gray-400 font-mono mt-2">
{stats?.severity_history.map((h, idx) => (
<span key={idx} className="w-full text-center">{h.day}</span>
))}
</div>
</div>
<div className="bg-white border border-gray-200 p-6 rounded-sm shadow-sm">
<div className="mb-6">
<h3 className="text-lg font-bold text-gray-900 font-mono">Scan Coverage</h3>
</div>
<div className="space-y-6">
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-500 font-mono">Assets Scanned</span>
<span className="font-bold flex font-mono">{stats?.scanned_assets.toLocaleString() || 0} <span className="text-gray-400 font-normal ml-1">/ {stats?.total_assets.toLocaleString() || 0}</span></span>
</div>
<div className="w-full bg-gray-100 rounded-full h-2.5">
<div
className="bg-truevuln-blue h-2.5 rounded-full"
style={{ width: `${stats?.total_assets ? (stats.scanned_assets / stats.total_assets * 100) : 0}%` }}
></div>
</div>
<div className="mt-2 text-right">
<span className="text-xs font-bold text-green-600 font-mono">
{stats?.total_assets ? (stats.scanned_assets / stats.total_assets * 100).toFixed(1) : '0.0'}% Coverage
</span>
</div>
</div>
<div className="pt-4 border-t border-gray-100">
<div className="flex justify-between text-sm py-1">
<span className="text-gray-500 font-mono">Last Full Scan</span>
<span className="font-mono text-gray-900">Continuous</span>
</div>
<div className="flex justify-between text-sm py-1">
<span className="text-gray-500 font-mono">Scan Frequency</span>
<span className="font-mono text-gray-900">Automated</span>
</div>
<div className="flex justify-between text-sm py-1">
<span className="text-gray-500 font-mono">Coverage Rate</span>
<span className={`font-mono font-bold ${stats?.total_assets && (stats.scanned_assets / stats.total_assets) === 1 ? 'text-green-600' : 'text-blue-600'}`}>
{stats?.total_assets ? (stats.scanned_assets / stats.total_assets * 100).toFixed(1) : '0.0'}%
</span>
</div>
</div>
</div>
</div>
</div>
{
showHistory && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-500 bg-opacity-75">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[80vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold font-mono">AI Audit History</h3>
<button onClick={() => setShowHistory(false)} className="text-gray-400 hover:text-gray-600">
<span className="sr-only">Close</span>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="space-y-2">
{historyReports.map((report) => (
<div key={report.id} onClick={() => loadReport(report)} className="p-4 border border-gray-200 rounded hover:bg-indigo-50 cursor-pointer flex justify-between items-center transition-colors">
<div>
<p className="font-bold text-sm text-gray-900">{new Date(report.created_at).toLocaleString()}</p>
<p className="text-xs text-gray-500 truncate max-w-md">{report.global_strategy.substring(0, 100)}...</p>
</div>
<span className="text-xs font-mono bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full">
{report.recommendations_count} Findings
</span>
</div>
))}
{historyReports.length === 0 && <p className="text-gray-500 text-center py-4">No history available yet.</p>}
</div>
</div>
</div>
)
}
{/* Recent Vulnerabilities — three split widgets:
left = Recent Critical CVEs (CVSS ≥ 8 OR KEV OR EUVD,
sorted by our DB updated_at desc — catches
Vulnrichment corrections + KEV/EUVD just-landed)
middle = Newly Published CVEs (sort published_date desc)
right = Newly EOL / EOS (endoflife.date pseudo-CVEs) */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
{renderVulnWidget({
title: 'Recent Critical CVEs',
subtitle: 'CVSS ≥ 8 or KEV or EUVD · newest first, ties by CPR',
vulns: criticalVulns,
onRowClick: (cveId) => router.push(`/vulnerabilities?cve_id=${cveId}`),
viewAllHref: '/vulnerabilities?sort_by=published_date&sort_order=desc',
})}
{renderVulnWidget({
title: 'Newly Published CVEs',
subtitle: 'All severities · sorted by CVE published date',
vulns: recentVulns,
onRowClick: (cveId) => router.push(`/vulnerabilities?cve_id=${cveId}`),
viewAllHref: '/vulnerabilities?sort_by=published_date&sort_order=desc',
})}
{renderVulnWidget({
title: 'Newly EOL / EOS',
subtitle: 'End-of-life software · sorted by detection',
vulns: eolVulns,
labelField: 'package_name',
firstColHeader: 'Product',
assetColumn: true,
onRowClick: (cveId) => router.push(`/vulnerabilities?cve_id=${cveId}`),
// eol=1 drives the eolOnly toggle on the list page.
viewAllHref: '/vulnerabilities?eol=1&sort_by=detected_at&sort_order=desc',
})}
{renderVulnWidget({
title: 'Mobile Security · EOL & Patch Level',
subtitle: 'Phones & tablets · vendor EOL/EOS + Android patch staleness',
vulns: mobileVulns,
labelField: 'package_name',
firstColHeader: 'Device / Item',
assetColumn: true,
onRowClick: (cveId) => router.push(`/vulnerabilities?cve_id=${cveId}`),
viewAllHref: '/vulnerabilities?finding_type=mobile&sort_by=cvss&sort_order=desc',
})}
{/* Advisory feed — CISA KEV (actively exploited in the wild), independent
of asset findings. "In inventory" badge when we already track it. */}
<div className="bg-white border border-gray-200 shadow-sm rounded-sm overflow-hidden flex flex-col">
<div className="p-4 border-b border-gray-200 flex justify-between items-baseline bg-gray-50/50">
<div>
<h3 className="text-base font-bold text-gray-900 font-mono">Actively Exploited · CISA KEV</h3>
<p className="text-[11px] text-gray-500 font-mono mt-0.5">Newly added known-exploited CVEs · 🔒 = ransomware use</p>
</div>
<a href="https://www.cisa.gov/known-exploited-vulnerabilities-catalog" target="_blank" rel="noreferrer"
className="text-truevuln-blue text-[10px] font-bold uppercase tracking-wider font-mono hover:text-blue-700">View All &gt;</a>
</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>
) : (
<table className="min-w-full text-sm">
<tbody className="divide-y divide-gray-100">
{kevAdvisories.map((k: any) => (
<tr key={k.cve_id} className="hover:bg-gray-50 cursor-pointer"
onClick={() => router.push(`/vulnerabilities?cve_id=${k.cve_id}`)}>
<td className="px-3 py-2 whitespace-nowrap">
<span className="font-mono text-truevuln-blue text-xs font-bold">{k.cve_id}</span>
{k.ransomware && <span title="Known ransomware campaign use" className="ml-1">🔒</span>}
</td>
<td className="px-3 py-2 text-xs text-gray-600 truncate max-w-[180px]" title={`${k.vendor || ''} ${k.product || ''}`}>
{[k.vendor, k.product].filter(Boolean).join(' · ')}
</td>
<td className="px-3 py-2 whitespace-nowrap text-[11px] text-gray-400 font-mono">{k.date_added}</td>
<td className="px-3 py-2 whitespace-nowrap text-right">
{k.in_inventory ? (
<span className="inline-flex items-center rounded-sm border border-red-200 bg-red-50 px-1.5 py-0.5 text-[10px] font-bold uppercase text-red-700"
title={`Present on ${k.asset_count} asset(s)`}>In inventory · {k.asset_count}</span>
) : (
<span className="inline-flex items-center rounded-sm border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-[10px] font-mono uppercase text-gray-400">not seen</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
</div >
);
}