Frontend: - New public marketing landing page at / (hero, features, stats, how-it-works, CTA) - New /scans page surfacing the scanning engine: asset health grid, one-click full scan, live scanner findings feed, 24h stats - New /admin panel (was a 404 from the sidebar): audit report generation and instant PDF snapshot download - Reports page: now visible to all roles, working PDF downloads, posture snapshot export for IT/admin - Findings page: full-text search, sorting (severity/newest/title), severity count chips, CSV export - Dashboard: scan activity strip, Run Scan + Export PDF quick actions - Footprint page: summary stat cards - api.ts: scanning, reports, and PDF download endpoints + types; fixed missing resolution_note on Finding type - Removed unsupported eslint key from next.config.ts Backend: - Audit reports: list/get/PDF now open to executives and IT admins with strict tenant isolation (was trustos_admin-only, leaving tenants unable to see their own reports); PDF snapshot open to IT admins; generation stays admin-only Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
240 lines
10 KiB
TypeScript
240 lines
10 KiB
TypeScript
"use client";
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { api, type ScanStatus, type AssetHealth, type ScanFinding } from "@/lib/api";
|
|
import Sidebar from "@/components/Sidebar";
|
|
import Link from "next/link";
|
|
import {
|
|
Radar, Play, Server, Globe, Cloud, AlertCircle, RefreshCw, CheckCircle2, Clock
|
|
} from "lucide-react";
|
|
|
|
const ASSET_ICONS: Record<string, typeof Server> = {
|
|
domain: Globe,
|
|
server: Server,
|
|
cloud: Cloud,
|
|
};
|
|
|
|
function healthColor(score: number) {
|
|
if (score >= 80) return "text-emerald-400";
|
|
if (score >= 60) return "text-amber-400";
|
|
if (score >= 30) return "text-orange-400";
|
|
return "text-red-400";
|
|
}
|
|
|
|
function healthBar(score: number) {
|
|
if (score >= 80) return "bg-vault-emerald";
|
|
if (score >= 60) return "bg-vault-amber";
|
|
return "bg-vault-crimson";
|
|
}
|
|
|
|
export default function ScansPage() {
|
|
const { tenantId, role, ready } = useAuth();
|
|
const [status, setStatus] = useState<ScanStatus | null>(null);
|
|
const [assets, setAssets] = useState<AssetHealth[]>([]);
|
|
const [recent, setRecent] = useState<ScanFinding[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [scanning, setScanning] = useState(false);
|
|
const [message, setMessage] = useState("");
|
|
|
|
const load = useCallback(() => {
|
|
if (!tenantId) return;
|
|
Promise.all([
|
|
api.scanStatus(tenantId).then(setStatus).catch(() => {}),
|
|
api.assetHealth(tenantId).then(setAssets).catch(() => {}),
|
|
api.recentScanFindings(tenantId).then(setRecent).catch(() => {}),
|
|
]).finally(() => setLoading(false));
|
|
}, [tenantId]);
|
|
|
|
useEffect(() => {
|
|
if (!ready || !tenantId) return;
|
|
load();
|
|
}, [ready, tenantId, load]);
|
|
|
|
async function handleStartScan() {
|
|
if (!tenantId) return;
|
|
setScanning(true);
|
|
setMessage("");
|
|
try {
|
|
const res = await api.startScan(tenantId);
|
|
setMessage(`Scan started on ${res.assets_scanned} asset${res.assets_scanned === 1 ? "" : "s"}. Results will appear below as they complete.`);
|
|
// Refresh after a delay to pick up new findings
|
|
setTimeout(load, 8000);
|
|
} catch (e: any) {
|
|
setMessage(e.message || "Failed to start scan");
|
|
} finally {
|
|
setScanning(false);
|
|
}
|
|
}
|
|
|
|
const canScan = role === "it_admin" || role === "trustos_admin";
|
|
|
|
return (
|
|
<div className="flex min-h-screen bg-vault-black">
|
|
<Sidebar />
|
|
<main className="ml-64 flex-1 p-8">
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-vault-text flex items-center gap-2">
|
|
<Radar className="w-6 h-6 text-vault-sapphire" />
|
|
Continuous Scanning
|
|
</h1>
|
|
<p className="text-vault-muted text-sm mt-1">Asset health, automated scans, and freshly discovered exposures</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button onClick={load} className="btn-ghost border border-vault-border rounded-lg" title="Refresh">
|
|
<RefreshCw className="w-4 h-4" /> Refresh
|
|
</button>
|
|
{canScan && (
|
|
<button onClick={handleStartScan} disabled={scanning} className="btn-primary">
|
|
{scanning ? (
|
|
<><RefreshCw className="w-4 h-4 animate-spin" /> Starting…</>
|
|
) : (
|
|
<><Play className="w-4 h-4" /> Run Full Scan</>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{message && (
|
|
<div className="vault-card border-vault-sapphire/40 bg-vault-sapphireDim/30 text-vault-sapphireLight text-sm mb-6 py-3">
|
|
{message}
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div className="flex items-center justify-center h-40">
|
|
<div className="animate-spin w-6 h-6 border-2 border-vault-sapphire border-t-transparent rounded-full" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Scan status summary */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
|
{[
|
|
{
|
|
label: "Last 24h Findings",
|
|
value: status?.findings_found ?? 0,
|
|
icon: AlertCircle,
|
|
color: "text-vault-text",
|
|
},
|
|
{
|
|
label: "Critical",
|
|
value: status?.critical_count ?? 0,
|
|
icon: AlertCircle,
|
|
color: (status?.critical_count ?? 0) > 0 ? "text-red-400" : "text-emerald-400",
|
|
},
|
|
{
|
|
label: "High",
|
|
value: status?.high_count ?? 0,
|
|
icon: AlertCircle,
|
|
color: (status?.high_count ?? 0) > 0 ? "text-orange-400" : "text-emerald-400",
|
|
},
|
|
{
|
|
label: "Last Scan",
|
|
value: status?.last_scan ? new Date(status.last_scan).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "—",
|
|
icon: Clock,
|
|
color: "text-vault-subtle",
|
|
},
|
|
].map(({ label, value, icon: Icon, color }) => (
|
|
<div key={label} className="vault-card">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<p className="text-vault-muted text-xs font-medium">{label}</p>
|
|
<Icon className={`w-4 h-4 ${color}`} />
|
|
</div>
|
|
<p className={`text-2xl font-bold ${color}`}>{value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Asset health */}
|
|
<div className="mb-8">
|
|
<h2 className="text-vault-text font-semibold mb-4 flex items-center gap-2">
|
|
<Server className="w-4 h-4 text-vault-sapphire" /> Asset Health
|
|
</h2>
|
|
{assets.length === 0 ? (
|
|
<div className="vault-card text-center py-10">
|
|
<p className="text-vault-muted text-sm">No assets enrolled yet. Assets are added during Vault onboarding.</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
{assets.map(a => {
|
|
const Icon = ASSET_ICONS[a.asset_type] ?? Server;
|
|
return (
|
|
<div key={a.asset_id} className="vault-card">
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="w-9 h-9 rounded-lg bg-vault-sapphire/15 border border-vault-sapphire/30 flex items-center justify-center flex-shrink-0">
|
|
<Icon className="w-4 h-4 text-vault-sapphire" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-vault-text text-sm font-medium truncate">{a.asset_name}</p>
|
|
<p className="text-vault-muted text-xs truncate">{a.asset_value}</p>
|
|
</div>
|
|
</div>
|
|
<span className={`text-lg font-bold flex-shrink-0 ${healthColor(a.health_score)}`}>
|
|
{a.health_score}
|
|
</span>
|
|
</div>
|
|
<div className="h-1.5 rounded-full bg-vault-titanium overflow-hidden mb-3">
|
|
<div
|
|
className={`h-full rounded-full ${healthBar(a.health_score)}`}
|
|
style={{ width: `${a.health_score}%` }}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between text-xs text-vault-muted">
|
|
<span>{a.findings_count} finding{a.findings_count === 1 ? "" : "s"}</span>
|
|
{a.critical_count > 0 ? (
|
|
<span className="text-red-400 font-semibold">{a.critical_count} critical</span>
|
|
) : (
|
|
<span className="flex items-center gap-1 text-emerald-400">
|
|
<CheckCircle2 className="w-3 h-3" /> no criticals
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Recent scanner findings */}
|
|
<div>
|
|
<h2 className="text-vault-text font-semibold mb-4 flex items-center gap-2">
|
|
<Radar className="w-4 h-4 text-vault-sapphire" /> Recent Scanner Findings
|
|
</h2>
|
|
{recent.length === 0 ? (
|
|
<div className="vault-card text-center py-10">
|
|
<p className="text-vault-muted text-sm">No automated scanner findings yet.</p>
|
|
{canScan && <p className="text-vault-muted text-xs mt-1">Run a full scan to populate this feed.</p>}
|
|
</div>
|
|
) : (
|
|
<div className="vault-card p-0 overflow-hidden">
|
|
{recent.map(f => (
|
|
<Link
|
|
key={f.id}
|
|
href={`/findings/${f.id}`}
|
|
className="flex items-center gap-4 px-5 py-3.5 border-b border-vault-border/50 last:border-0 hover:bg-vault-titanium/30 transition-colors"
|
|
>
|
|
<span className={`vault-badge-${f.severity} flex-shrink-0`}>{f.severity.toUpperCase()}</span>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-vault-text text-sm font-medium truncate">{f.title}</p>
|
|
{f.affected_component && (
|
|
<p className="text-vault-muted text-xs truncate">{f.affected_component}</p>
|
|
)}
|
|
</div>
|
|
<span className="text-vault-muted text-xs flex-shrink-0">
|
|
{new Date(f.found_at).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}
|
|
</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|