Regression from 5703dfa. A Server 2016 host (10.0.14393.9234) was flagged with
CVE-2021-26432 'fixed in 10.0.17763.2114' — a Server 2019 build. My claim that
cvelistV5 ranges are release-bounded came from checking ONE modern record, and
older ones are not:
CVE-2021-26432 version=10.0.0 lessThan=10.0.17763.2114 (generic floor)
CVE-2026-47291 version=10.0.14393.0 lessThan=10.0.14393.9234 (release-bounded)
A '10.0.0' floor makes the range swallow every lower build, so a 14393 host fell
inside a 17763 fix. The OS scan now requires the floor and the fix to sit on the
same build line and skips the rest — such an entry carries no release info at
all, and the OS string alone can't supply it. Costs nothing in practice: Windows
servicing is cumulative, so a host behind on an old CVE is already flagged by
that line's newer ones. Existing false positives self-heal on the next scan via
the app-scan auto-resolve.
Also:
- scheduler job renamed to '(Windows OS)' — it covers client too, not just Server
- dashboard: AI Audit History hidden from read-only (AI endpoints are editor+)
- dashboard: 'Export Report' had no onClick at all (dead for every role) — now
links to the reports page
- compliance: the Impact-CSV upload card is admin-only (the import endpoint is
RequireAdmin), so it no longer 403s silently for other roles
525 lines
27 KiB
TypeScript
525 lines
27 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from 'react';
|
||
import api from '../../lib/api';
|
||
import { ArrowPathIcon, CheckBadgeIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
|
||
|
||
type AssetSummary = {
|
||
asset_id: number;
|
||
hostname: string;
|
||
operating_system: string | null;
|
||
policy_count: number;
|
||
avg_score: number | null;
|
||
worst_score: number | null;
|
||
worst_policy: string | null;
|
||
last_synced: string | null;
|
||
};
|
||
|
||
type GlobalSummary = {
|
||
assets_with_compliance: number;
|
||
total_policies: number;
|
||
avg_score_all: number | null;
|
||
pass_total: number;
|
||
fail_total: number;
|
||
not_applicable_total: number;
|
||
worst_offenders: AssetSummary[];
|
||
};
|
||
|
||
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;
|
||
criticality_factor: number | null;
|
||
policy_count: number | null;
|
||
};
|
||
|
||
/**
|
||
* Normalises a thrown axios/fetch error into a renderable string.
|
||
* FastAPI returns 422 errors as { detail: [{type,loc,msg,input}, ...] }
|
||
* — rendering that array directly crashes React with #31. Flatten to text.
|
||
*/
|
||
function formatApiError(e: any, fallback: string): string {
|
||
const detail = e?.response?.data?.detail;
|
||
if (!detail) return e?.message || fallback;
|
||
if (typeof detail === 'string') return detail;
|
||
if (Array.isArray(detail)) {
|
||
return detail
|
||
.map((d: any) => {
|
||
if (typeof d === 'string') return d;
|
||
const loc = Array.isArray(d?.loc) ? d.loc.join('.') : '';
|
||
return `${loc ? loc + ': ' : ''}${d?.msg || JSON.stringify(d)}`;
|
||
})
|
||
.join('; ');
|
||
}
|
||
try { return JSON.stringify(detail); } catch { return fallback; }
|
||
}
|
||
|
||
function ursColor(severity: string): string {
|
||
switch (severity) {
|
||
case 'CRITICAL': return 'bg-red-700 text-white';
|
||
case 'HIGH': return 'bg-red-100 text-red-700';
|
||
case 'MEDIUM': return 'bg-orange-100 text-orange-700';
|
||
case 'LOW': return 'bg-emerald-100 text-emerald-700';
|
||
default: return 'bg-gray-100 text-gray-500';
|
||
}
|
||
}
|
||
|
||
type PolicyResult = {
|
||
id: number;
|
||
asset_id: number;
|
||
policy_id: string;
|
||
policy_name: string | null;
|
||
policy_description: string | null;
|
||
total_checks: number;
|
||
pass_count: number;
|
||
fail_count: number;
|
||
not_applicable_count: number;
|
||
score: number | null;
|
||
end_scan: string | null;
|
||
last_synced: string;
|
||
};
|
||
|
||
function scoreColor(score: number | null): string {
|
||
if (score === null) return 'text-gray-400';
|
||
if (score >= 90) return 'text-emerald-700 font-bold';
|
||
if (score >= 70) return 'text-yellow-700 font-bold';
|
||
if (score >= 40) return 'text-orange-700 font-bold';
|
||
return 'text-red-700 font-bold';
|
||
}
|
||
|
||
function scoreBg(score: number | null): string {
|
||
if (score === null) return 'bg-gray-200';
|
||
if (score >= 90) return 'bg-emerald-500';
|
||
if (score >= 70) return 'bg-yellow-500';
|
||
if (score >= 40) return 'bg-orange-500';
|
||
return 'bg-red-500';
|
||
}
|
||
|
||
export default function CompliancePage() {
|
||
const [summary, setSummary] = useState<GlobalSummary | null>(null);
|
||
const [assets, setAssets] = useState<AssetSummary[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
const [selectedAsset, setSelectedAsset] = useState<number | null>(null);
|
||
const [assetPolicies, setAssetPolicies] = useState<PolicyResult[]>([]);
|
||
const [policyLoading, setPolicyLoading] = useState(false);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
const [ursRows, setUrsRows] = useState<URSRow[]>([]);
|
||
const [avsMode, setAvsMode] = useState<'hybrid' | 'avg' | 'max'>('hybrid');
|
||
const [impactStats, setImpactStats] = useState<Array<{benchmark: string; count: number; avg_impact: number | null}>>([]);
|
||
|
||
const fetchAll = async () => {
|
||
setLoading(true);
|
||
setErr(null);
|
||
try {
|
||
const [s, a, u, imp] = await Promise.all([
|
||
api.get('/api/v1/compliance/summary'),
|
||
api.get('/api/v1/compliance/assets?limit=200'),
|
||
api.get(`/api/v1/compliance/urs?avs_mode=${avsMode}&limit=200`),
|
||
api.get('/api/v1/compliance/impacts/stats').catch(() => ({ data: [] })),
|
||
]);
|
||
setSummary(s.data);
|
||
setAssets(Array.isArray(a.data) ? a.data : []);
|
||
// Defensive: backend could 500 with non-array body — never let
|
||
// .map crash the page.
|
||
setUrsRows(Array.isArray(u.data) ? u.data : []);
|
||
setImpactStats(Array.isArray(imp.data) ? imp.data : []);
|
||
} catch (e: any) {
|
||
setErr(formatApiError(e, 'Failed to load compliance data.'));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => { fetchAll(); }, [avsMode]);
|
||
|
||
// CSV upload state. The import endpoint is admin-only, so non-admins were
|
||
// shown an upload control that silently 403'd.
|
||
const [uploading, setUploading] = useState(false);
|
||
const [isAdmin, setIsAdmin] = useState(false);
|
||
useEffect(() => {
|
||
api.get('/auth/me').then(r => setIsAdmin((r.data?.role || '') === 'admin')).catch(() => { });
|
||
}, []);
|
||
const uploadCsvs = async (filelist: FileList | null) => {
|
||
if (!filelist || filelist.length === 0) return;
|
||
setUploading(true);
|
||
setErr(null);
|
||
try {
|
||
const fd = new FormData();
|
||
Array.from(filelist).forEach(f => fd.append('files', f));
|
||
const r = await api.post('/api/v1/compliance/impacts/import', fd, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
});
|
||
const d = r.data || {};
|
||
const perFile = d.per_file || {};
|
||
const firstFile: any = Object.values(perFile)[0] || {};
|
||
let msg = `Impacts imported: ${d.imported ?? 0} rows from ${d.files ?? 0} files. `;
|
||
msg += `Skipped: no-id=${d.skipped_no_id ?? 0}, not-cis=${d.skipped_not_cis ?? 0}.`;
|
||
if ((d.imported ?? 0) === 0) {
|
||
msg += `\n\nDIAGNOSE (first file):\n`;
|
||
msg += `Headers: ${(firstFile.detected_headers || []).join(', ')}\n`;
|
||
msg += `ID column used: ${firstFile.id_column_used || '(none recognised — check CSV header names)'}\n`;
|
||
msg += `Impact column used: ${firstFile.impact_column_used || '(none — defaulting to 50)'}\n`;
|
||
if (Array.isArray(firstFile.preview) && firstFile.preview.length > 0) {
|
||
msg += `Sample skipped rows:\n`;
|
||
firstFile.preview.forEach((p: any) => {
|
||
msg += ` row ${p.row}: ${p.reason}` +
|
||
(p.value ? ` — value="${p.value}"` : '') +
|
||
(p.sample ? ` — sample=${JSON.stringify(p.sample)}` : '') + '\n';
|
||
});
|
||
}
|
||
}
|
||
alert(msg);
|
||
await fetchAll();
|
||
} catch (e: any) {
|
||
setErr(formatApiError(e, 'Impact CSV upload failed.'));
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
};
|
||
|
||
const recomputeURS = async () => {
|
||
try {
|
||
await api.post(`/api/v1/compliance/urs/recompute-all?avs_mode=${avsMode}`);
|
||
await fetchAll();
|
||
} catch (e: any) {
|
||
setErr(formatApiError(e, 'URS recompute failed.'));
|
||
}
|
||
};
|
||
|
||
const refreshAll = async () => {
|
||
if (!confirm('Refresh SCA results for every Wazuh-linked asset? This pulls /sca/{agent_id} per agent.')) return;
|
||
setRefreshing(true);
|
||
setErr(null);
|
||
try {
|
||
const r = await api.post('/api/v1/compliance/refresh');
|
||
alert(`Compliance refreshed: ${r.data.assets_synced} assets, ${r.data.policies_synced} policy results.${(r.data.errors || []).length ? ' Some errors — see backend logs.' : ''}`);
|
||
await fetchAll();
|
||
} catch (e: any) {
|
||
setErr(formatApiError(e, 'Refresh failed.'));
|
||
} finally {
|
||
setRefreshing(false);
|
||
}
|
||
};
|
||
|
||
const openAsset = async (assetId: number) => {
|
||
setSelectedAsset(assetId);
|
||
setPolicyLoading(true);
|
||
setAssetPolicies([]);
|
||
try {
|
||
const r = await api.get(`/api/v1/compliance/${assetId}`);
|
||
setAssetPolicies(r.data);
|
||
} catch (e) {
|
||
setAssetPolicies([]);
|
||
} finally {
|
||
setPolicyLoading(false);
|
||
}
|
||
};
|
||
|
||
const refreshAsset = async (assetId: number) => {
|
||
try {
|
||
await api.post(`/api/v1/compliance/${assetId}/refresh`);
|
||
await openAsset(assetId);
|
||
await fetchAll();
|
||
} catch {
|
||
// ignore — keep modal open
|
||
}
|
||
};
|
||
|
||
if (loading) return <div className="p-8 text-center font-mono text-gray-500">Loading compliance data…</div>;
|
||
|
||
return (
|
||
<div className="w-full p-4 sm:p-6 lg:p-8">
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900 font-mono flex items-center gap-2">
|
||
<CheckBadgeIcon className="h-7 w-7 text-emerald-600" />
|
||
Compliance
|
||
</h1>
|
||
<p className="text-sm text-gray-500 font-mono mt-1">
|
||
Wazuh SCA results — pass / fail per check, aggregated per policy and asset.
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={refreshAll}
|
||
disabled={refreshing}
|
||
className="inline-flex items-center gap-2 rounded-md bg-truevuln-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-50 font-mono"
|
||
>
|
||
<ArrowPathIcon className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||
{refreshing ? 'Refreshing…' : 'Refresh All'}
|
||
</button>
|
||
</div>
|
||
|
||
{err && (
|
||
<div className="mb-4 rounded-md bg-red-50 border border-red-200 p-3 text-sm text-red-700 font-mono">
|
||
{err}
|
||
</div>
|
||
)}
|
||
|
||
{/* Global summary cards */}
|
||
{summary && (
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-4">
|
||
<p className="text-xs uppercase tracking-wider text-gray-500 font-mono">Avg score</p>
|
||
<p className={`text-3xl font-mono mt-1 ${scoreColor(summary.avg_score_all)}`}>
|
||
{summary.avg_score_all !== null ? `${summary.avg_score_all.toFixed(1)}%` : '—'}
|
||
</p>
|
||
</div>
|
||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-4">
|
||
<p className="text-xs uppercase tracking-wider text-gray-500 font-mono">Assets covered</p>
|
||
<p className="text-3xl font-mono text-gray-900 mt-1">{summary.assets_with_compliance}</p>
|
||
<p className="text-xs text-gray-400 font-mono">{summary.total_policies} policy runs</p>
|
||
</div>
|
||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-4">
|
||
<p className="text-xs uppercase tracking-wider text-gray-500 font-mono">Pass total</p>
|
||
<p className="text-3xl font-mono text-emerald-700 mt-1">{summary.pass_total.toLocaleString()}</p>
|
||
</div>
|
||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-4">
|
||
<p className="text-xs uppercase tracking-wider text-gray-500 font-mono">Fail total</p>
|
||
<p className="text-3xl font-mono text-red-700 mt-1">{summary.fail_total.toLocaleString()}</p>
|
||
<p className="text-xs text-gray-400 font-mono">{summary.not_applicable_total.toLocaleString()} N/A</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Worst offenders */}
|
||
{summary && Array.isArray(summary.worst_offenders) && summary.worst_offenders.length > 0 && (
|
||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-4 mb-8">
|
||
<h2 className="text-sm font-bold text-gray-900 font-mono mb-3 flex items-center gap-2">
|
||
<ExclamationTriangleIcon className="h-4 w-4 text-orange-600" />
|
||
Worst-Performing Assets
|
||
</h2>
|
||
<ul className="divide-y divide-gray-100">
|
||
{summary.worst_offenders.map(a => (
|
||
<li key={a.asset_id} className="py-2 flex items-center justify-between text-sm font-mono">
|
||
<button onClick={() => openAsset(a.asset_id)} className="text-truevuln-blue hover:underline text-left">
|
||
{a.hostname}
|
||
</button>
|
||
<span className="text-gray-500 text-xs truncate ml-2 max-w-xs" title={a.worst_policy || ''}>
|
||
worst: {a.worst_policy || '—'}
|
||
</span>
|
||
<span className={`ml-3 ${scoreColor(a.avg_score)}`}>
|
||
{a.avg_score !== null ? `${a.avg_score.toFixed(1)}%` : '—'}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{/* URS table */}
|
||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden mb-8">
|
||
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50 flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-sm font-bold text-gray-900 font-mono">
|
||
Unified Risk Score (URS)
|
||
</h2>
|
||
<p className="text-[11px] text-gray-500 font-mono">
|
||
URS = ((AVS + ASS) / 2) × criticality. 90+ CRITICAL · 70+ HIGH · 40+ MEDIUM · 1+ LOW
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<select
|
||
value={avsMode}
|
||
onChange={(e) => setAvsMode(e.target.value as any)}
|
||
className="text-xs border border-gray-300 rounded px-2 py-1 font-mono"
|
||
title="AVS calculation mode"
|
||
>
|
||
<option value="hybrid">AVS: hybrid (0.7×avg + 0.3×max)</option>
|
||
<option value="avg">AVS: avg</option>
|
||
<option value="max">AVS: max</option>
|
||
</select>
|
||
<button
|
||
onClick={recomputeURS}
|
||
className="text-xs px-2 py-1 border border-gray-300 rounded hover:bg-gray-50 font-mono"
|
||
>
|
||
↻ recompute
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full divide-y divide-gray-200 text-sm font-mono">
|
||
<thead className="bg-gray-50">
|
||
<tr>
|
||
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Hostname</th>
|
||
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Criticality</th>
|
||
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">AVS</th>
|
||
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">ASS</th>
|
||
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">URS</th>
|
||
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Severity</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100 bg-white">
|
||
{ursRows.length === 0 && (
|
||
<tr><td colSpan={6} className="px-4 py-6 text-center text-gray-500">No URS data — recompute first.</td></tr>
|
||
)}
|
||
{ursRows.map(r => (
|
||
<tr key={r.asset_id} className="hover:bg-gray-50">
|
||
<td className="px-4 py-2 text-truevuln-blue font-bold">{r.hostname || `#${r.asset_id}`}</td>
|
||
<td className="px-4 py-2 text-gray-700 uppercase text-xs">{r.criticality || 'normal'}<span className="text-gray-400 ml-1">×{r.criticality_factor ?? 1.0}</span></td>
|
||
<td className="px-4 py-2 text-gray-700">{r.avs !== null ? r.avs.toFixed(1) : '—'}</td>
|
||
<td className="px-4 py-2 text-gray-700">{r.ass !== null ? r.ass.toFixed(1) : '—'}</td>
|
||
<td className="px-4 py-2 font-bold text-gray-900">{r.urs !== null ? r.urs.toFixed(1) : '—'}</td>
|
||
<td className="px-4 py-2">
|
||
<span className={`inline-flex items-center rounded px-2 py-0.5 text-[10px] font-bold ${ursColor(r.severity)}`}>
|
||
{r.severity}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Impact CSV upload — admin only (POST /compliance/impacts/import) */}
|
||
{isAdmin && (
|
||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-4 mb-8">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div>
|
||
<h2 className="text-sm font-bold text-gray-900 font-mono">Compliance Impact CSVs</h2>
|
||
<p className="text-[11px] text-gray-500 font-mono">
|
||
CIS-Benchmark exports (cis_id, title, impact). Multi-upload supported.
|
||
Filename → benchmark label.
|
||
</p>
|
||
</div>
|
||
<label className="text-xs px-3 py-2 border border-gray-300 rounded hover:bg-gray-50 font-mono cursor-pointer">
|
||
{uploading ? 'Uploading…' : '+ Upload CSVs'}
|
||
<input
|
||
type="file"
|
||
multiple
|
||
accept=".csv,text/csv"
|
||
disabled={uploading}
|
||
onChange={(e) => uploadCsvs(e.target.files)}
|
||
className="hidden"
|
||
/>
|
||
</label>
|
||
</div>
|
||
{impactStats.length === 0 ? (
|
||
<p className="text-sm text-gray-400 font-mono">
|
||
No impact data loaded yet. Without impacts, ASS falls back to plain pass%.
|
||
</p>
|
||
) : (
|
||
<ul className="text-xs font-mono divide-y divide-gray-100">
|
||
{impactStats.map(s => (
|
||
<li key={s.benchmark} className="flex justify-between py-1">
|
||
<span className="truncate" title={s.benchmark}>{s.benchmark}</span>
|
||
<span className="text-gray-500">
|
||
{s.count} checks · avg impact {s.avg_impact ?? '—'}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* All assets table */}
|
||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
|
||
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50">
|
||
<h2 className="text-sm font-bold text-gray-900 font-mono">All Assets</h2>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full divide-y divide-gray-200 text-sm font-mono">
|
||
<thead className="bg-gray-50">
|
||
<tr>
|
||
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Hostname</th>
|
||
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">OS</th>
|
||
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Policies</th>
|
||
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Avg</th>
|
||
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Worst</th>
|
||
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Last sync</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100 bg-white">
|
||
{assets.length === 0 && (
|
||
<tr><td colSpan={6} className="px-4 py-6 text-center text-gray-500">
|
||
No compliance data yet. Click <b>Refresh All</b> to pull SCA results from Wazuh.
|
||
</td></tr>
|
||
)}
|
||
{assets.map(a => (
|
||
<tr key={a.asset_id} className="hover:bg-gray-50 cursor-pointer" onClick={() => openAsset(a.asset_id)}>
|
||
<td className="px-4 py-2 text-truevuln-blue font-bold">{a.hostname}</td>
|
||
<td className="px-4 py-2 text-gray-600 truncate max-w-xs">{a.operating_system || '—'}</td>
|
||
<td className="px-4 py-2 text-gray-700">{a.policy_count}</td>
|
||
<td className={`px-4 py-2 ${scoreColor(a.avg_score)}`}>
|
||
{a.avg_score !== null ? `${a.avg_score.toFixed(1)}%` : '—'}
|
||
</td>
|
||
<td className={`px-4 py-2 ${scoreColor(a.worst_score)}`}>
|
||
{a.worst_score !== null ? `${a.worst_score.toFixed(1)}%` : '—'}
|
||
</td>
|
||
<td className="px-4 py-2 text-gray-500 text-xs">
|
||
{a.last_synced ? new Date(a.last_synced).toLocaleString('de-CH') : '—'}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Asset detail modal */}
|
||
{selectedAsset !== null && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-700/70">
|
||
<div className="bg-white rounded-lg shadow-xl max-w-3xl w-full max-h-[85vh] overflow-hidden flex flex-col">
|
||
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
|
||
<h3 className="text-base font-bold font-mono text-gray-900">
|
||
Compliance — asset #{selectedAsset}
|
||
</h3>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => refreshAsset(selectedAsset)}
|
||
className="text-xs px-2 py-1 border border-gray-300 rounded hover:bg-gray-50 font-mono"
|
||
title="Re-fetch from Wazuh"
|
||
>
|
||
↻ refresh
|
||
</button>
|
||
<button
|
||
onClick={() => setSelectedAsset(null)}
|
||
className="text-gray-400 hover:text-gray-600 text-xl leading-none"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="overflow-y-auto p-4 space-y-2">
|
||
{policyLoading && <p className="text-sm font-mono text-gray-500">Loading…</p>}
|
||
{!policyLoading && assetPolicies.length === 0 && (
|
||
<p className="text-sm font-mono text-gray-500">No policies yet — try refresh.</p>
|
||
)}
|
||
{assetPolicies.map(p => (
|
||
<div key={p.id} className="border border-gray-200 rounded-md p-3">
|
||
<div className="flex items-baseline justify-between gap-3 mb-1">
|
||
<p className="text-sm font-bold text-gray-900 font-mono truncate" title={p.policy_name || p.policy_id}>
|
||
{p.policy_name || p.policy_id}
|
||
</p>
|
||
<span className={`text-base font-mono ${scoreColor(p.score)}`}>
|
||
{p.score !== null ? `${p.score.toFixed(1)}%` : '—'}
|
||
</span>
|
||
</div>
|
||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-100 mb-2">
|
||
<div className={`h-full ${scoreBg(p.score)}`} style={{ width: `${p.score ?? 0}%` }} />
|
||
</div>
|
||
<div className="flex justify-between text-xs text-gray-500 font-mono">
|
||
<span title="passed checks" className="text-emerald-700">✓ {p.pass_count}</span>
|
||
<span title="failed checks" className="text-red-700">✗ {p.fail_count}</span>
|
||
<span title="not applicable" className="text-gray-500">○ {p.not_applicable_count}</span>
|
||
<span title="total checks" className="text-gray-400">Σ {p.total_checks}</span>
|
||
<span className="text-gray-400 truncate">
|
||
scan: {p.end_scan ? new Date(p.end_scan).toLocaleDateString('de-CH') : '—'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|