Major product upgrade: landing page, scanning UI, admin panel, working reports
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>
This commit is contained in:
239
frontend/src/app/scans/page.tsx
Normal file
239
frontend/src/app/scans/page.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user