Files
A-Team-Security-Infra-Agent…/frontend-react/src/pages/ReportsPage.tsx
nogueiraguh 73c60212f7 feat: auto-mapped CIS report embedding, section embed, ADB RAW(16) fix
- Auto-embed report: maps each CSV to its ADB table (summaryreportcsvvector, identityandaccess, networking, etc.)
- Section embed: new endpoint POST /api/embeddings/report/{rid}/section with per-section button in UI
- Table validation: checks registered ADB tables before embedding, reports missing tables
- Auto-detect embedding dimension: reads table dim and selects correct model (3072→large, 1536→small)
- ADB RAW(16) ID fix: all vector inserts use HEXTORAW() for UUID (fixes ORA-01465)
- Float32 vectors: all inserts use array.array('f') for FLOAT32 compatibility
- Embedding status: real-time progress polling (Embedding X/Y — table: Z)
- Loading per section: spinner only on the section being embedded, not all
- Removed individual file embed buttons (only section + full report)
- Summary CSV chunking: groups by CIS section with tenancy + extract_date metadata
- Findings CSV chunking: each row becomes a document with structured content
- README: documented all 11 required ADB vector tables with descriptions
2026-03-21 00:10:50 -03:00

1672 lines
70 KiB
TypeScript

import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useAppStore } from '@/stores/app';
import { useI18n } from '@/i18n';
import { usePolling } from '@/hooks/usePolling';
import {
reportsApi,
type Report,
type ReportProgress,
type ReportSummary,
type ReportFile,
} from '@/api/endpoints/reports';
import {
BarChart3, RefreshCw, Play, XCircle, ChevronDown, ChevronRight,
Search, X, Loader2, AlertCircle, CheckCircle2, Clock, FileText,
Download, ShieldCheck, ShieldAlert, Activity, Eye, EyeOff, Trash2,
Database, Lock, Globe, Server, Package, Dna, Check,
} from 'lucide-react';
import {
PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
} from 'recharts';
/* ── Fallback Regions ── */
const FALLBACK_REGIONS: Record<string, string[]> = {
'Americas': [
'us-ashburn-1', 'us-phoenix-1', 'us-sanjose-1', 'us-chicago-1',
'ca-toronto-1', 'ca-montreal-1', 'sa-saopaulo-1', 'sa-vinhedo-1',
'sa-santiago-1', 'sa-bogota-1', 'mx-queretaro-1', 'mx-monterrey-1',
],
'Europe': [
'eu-frankfurt-1', 'eu-amsterdam-1', 'eu-zurich-1', 'eu-madrid-1',
'eu-marseille-1', 'eu-milan-1', 'eu-stockholm-1', 'eu-paris-1',
'uk-london-1', 'uk-cardiff-1', 'eu-jovanovac-1',
],
'Asia Pacific': [
'ap-tokyo-1', 'ap-osaka-1', 'ap-seoul-1', 'ap-sydney-1',
'ap-melbourne-1', 'ap-mumbai-1', 'ap-hyderabad-1', 'ap-singapore-1',
],
'Middle East & Africa': [
'me-jeddah-1', 'me-dubai-1', 'me-abudhabi-1',
'af-johannesburg-1', 'il-jerusalem-1',
],
};
/* ── Progress estimation ── */
function estimateProgress(lines: string[]): number {
if (!lines.length) return 0;
const last = lines[lines.length - 1].toLowerCase();
// Walk backwards to find the most advanced keyword
for (let i = lines.length - 1; i >= 0; i--) {
const l = lines[i].toLowerCase();
if (l.includes('finished')) return 100;
if (l.includes('best practices')) return 85;
if (l.includes('writing cis reports') || l.includes('writing reports')) return 75;
if (l.includes('cis summary report') || l.includes('summary report')) return 60;
if (l.includes('processing regional')) return 35;
if (l.includes('processing home region')) return 20;
if (l.includes('identity domains') || l.includes('identity')) return 10;
}
// If there are lines but no keyword matched, at least show minimal progress
if (last) return 5;
return 0;
}
function scoreColor(score: number): string {
if (score >= 80) return 'var(--gn)';
if (score >= 60) return '#f59e0b';
return 'var(--rd)';
}
function statusBadge(status: string) {
const t = useI18n.getState().t;
const map: Record<string, { bg: string; fg: string; label: string }> = {
running: { bg: 'color-mix(in srgb, var(--bl) 12%, transparent)', fg: 'var(--bl)', label: t('rpt.inExecution') },
completed: { bg: 'var(--gnl)', fg: 'var(--gn)', label: t('rpt.completed') },
failed: { bg: 'var(--rdl)', fg: 'var(--rd)', label: t('rpt.failed') },
cancelled: { bg: 'color-mix(in srgb, var(--t4) 12%, transparent)', fg: 'var(--t4)', label: t('rpt.cancelled') },
};
const s = map[status] || { bg: 'var(--bg2)', fg: 'var(--t4)', label: status };
return (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.62rem] font-semibold"
style={{ background: s.bg, color: s.fg }}
>
{status === 'running' && <Loader2 size={10} className="animate-spin" />}
{status === 'completed' && <CheckCircle2 size={10} />}
{status === 'failed' && <AlertCircle size={10} />}
{status === 'cancelled' && <XCircle size={10} />}
{s.label}
</span>
);
}
/* ── File Grouping Helpers ── */
function extractSection(name: string): string {
const m = name.match(/^(?:cis|obp)_([A-Za-z_]+?)_\d/);
if (m) return m[1].replace(/_/g, ' ');
if (name.includes('summary')) return 'Summary';
if (name.includes('error')) return 'Error';
return 'Other';
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function fileExt(name: string): string {
return (name.split('.').pop() || '').toLowerCase();
}
const extColors: Record<string, string> = {
html: '#e44d26', csv: '#217346', json: '#f5a623',
xlsx: '#217346', txt: '#6b7280', pdf: '#dc2626',
};
const sectionIcons: Record<string, typeof Lock> = {
'Identity and Access Management': Lock,
'Networking': Globe,
'Compute': Server,
'Logging and Monitoring': Activity,
'Storage Object Storage': Database,
'Storage Block Volumes': Database,
'Storage File Storage Service': Database,
'Asset Management': Package,
'Summary': BarChart3,
'Error': AlertCircle,
'Other': FileText,
};
function sectionSort(a: string, b: string): number {
if (a === 'Summary') return -1;
if (b === 'Summary') return 1;
if (a === 'Other') return 1;
if (b === 'Other') return -1;
return a.localeCompare(b);
}
function groupBySection(files: ReportFile[]): Record<string, ReportFile[]> {
const map: Record<string, ReportFile[]> = {};
for (const f of files) {
const sec = extractSection(f.file_name);
(map[sec] ||= []).push(f);
}
return map;
}
/* ── SVG Gauge ── */
function ComplianceGauge({ score }: { score: number }) {
const radius = 54;
const stroke = 10;
const circumference = 2 * Math.PI * radius;
const pct = Math.min(Math.max(score, 0), 100);
const offset = circumference - (pct / 100) * circumference;
const color = scoreColor(pct);
return (
<svg width="130" height="130" viewBox="0 0 130 130">
<circle
cx="65" cy="65" r={radius}
fill="none"
stroke="color-mix(in srgb, var(--t4) 15%, transparent)"
strokeWidth={stroke}
/>
<circle
cx="65" cy="65" r={radius}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
transform="rotate(-90 65 65)"
style={{ transition: 'stroke-dashoffset 0.8s ease' }}
/>
<text
x="65" y="58"
textAnchor="middle"
style={{ fill: color, fontSize: '1.6rem', fontWeight: 700 }}
>
{pct.toFixed(1)}%
</text>
<text
x="65" y="78"
textAnchor="middle"
style={{ fill: 'var(--t4)', fontSize: '.6rem', fontWeight: 500 }}
>
Compliance
</text>
</svg>
);
}
/* ── Region Multi-Select ── */
function RegionMultiSelect({
selected,
onChange,
regionsMap,
}: {
selected: string[];
onChange: (regions: string[]) => void;
regionsMap: Record<string, string[]>;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState('');
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const f = filter.toLowerCase();
const toggle = (r: string) => {
if (selected.includes(r)) {
onChange(selected.filter((s) => s !== r));
} else {
onChange([...selected, r]);
}
};
const removeTag = (r: string) => onChange(selected.filter((s) => s !== r));
return (
<div ref={ref} className="relative">
<div
onClick={() => setOpen(!open)}
className="flex items-center flex-wrap gap-1.5 cursor-pointer px-3 py-2 rounded-lg min-h-[36px]"
style={{ background: 'var(--bg2)', border: '1.5px solid var(--bd)' }}
>
{selected.length === 0 ? (
<span className="text-[.78rem]" style={{ color: 'var(--t4)' }}>
{t('rpt.allRegionsDefault')}
</span>
) : (
selected.map((r) => (
<span
key={r}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.64rem] font-medium"
style={{ background: 'color-mix(in srgb, var(--ac) 12%, transparent)', color: 'var(--ac)' }}
>
{r}
<X
size={10}
className="cursor-pointer opacity-60 hover:opacity-100"
onClick={(e) => { e.stopPropagation(); removeTag(r); }}
/>
</span>
))
)}
<ChevronDown size={12} className="ml-auto flex-shrink-0" style={{ color: 'var(--t4)' }} />
</div>
{open && (
<div
className="absolute top-full left-0 right-0 mt-1 rounded-lg overflow-hidden z-50"
style={{ background: 'var(--bg)', border: '1px solid var(--bd)', boxShadow: '0 4px 16px rgba(0,0,0,.25)', maxHeight: 300 }}
>
<div className="p-1.5" style={{ borderBottom: '1px solid var(--bd)' }}>
<div className="flex items-center gap-1.5 px-2" style={{ background: 'var(--bg2)', borderRadius: 6 }}>
<Search size={12} style={{ color: 'var(--t4)' }} />
<input
type="text"
placeholder={t('rpt.searchRegion')}
value={filter}
onChange={(e) => setFilter(e.target.value)}
onClick={(e) => e.stopPropagation()}
className="w-full border-none outline-none text-xs py-1.5"
style={{ background: 'transparent', color: 'var(--t1)' }}
autoFocus
/>
</div>
</div>
<div className="overflow-y-auto" style={{ maxHeight: 250 }}>
{Object.entries(regionsMap).map(([grp, regs]) => {
const filtered = regs.filter((r) => !f || r.includes(f));
if (!filtered.length) return null;
return (
<div key={grp}>
<div
className="px-3 py-1 text-[.62rem] font-semibold uppercase tracking-wider"
style={{ color: 'var(--t4)', background: 'var(--bg1)' }}
>
{grp}
</div>
{filtered.map((r) => {
const isSelected = selected.includes(r);
return (
<div
key={r}
onClick={() => toggle(r)}
className="flex items-center gap-2 px-3 py-1.5 text-xs cursor-pointer transition-colors"
style={{
color: isSelected ? 'var(--ac)' : 'var(--t2)',
background: isSelected ? 'color-mix(in srgb, var(--ac) 8%, transparent)' : undefined,
}}
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget.style.background = 'var(--bg2)'); }}
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget.style.background = ''); }}
>
<div
className="w-3.5 h-3.5 rounded flex items-center justify-center flex-shrink-0"
style={{
border: isSelected ? '1.5px solid var(--ac)' : '1.5px solid var(--bd)',
background: isSelected ? 'var(--ac)' : 'transparent',
}}
>
{isSelected && <CheckCircle2 size={8} style={{ color: '#fff' }} />}
</div>
{r}
</div>
);
})}
</div>
);
})}
</div>
</div>
)}
</div>
);
}
/* ── OCI Config Dropdown ── */
function ConfigDropdown({
configs,
value,
onChange,
}: {
configs: { id: string; tenancy_name: string; region: string }[];
value: string;
onChange: (id: string) => void;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState('');
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const f = filter.toLowerCase();
const selected = configs.find((c) => c.id === value);
const filtered = configs.filter(
(c) => !f || c.tenancy_name.toLowerCase().includes(f) || c.region.toLowerCase().includes(f),
);
return (
<div ref={ref} className="relative">
<div
onClick={() => setOpen(!open)}
className="flex items-center justify-between cursor-pointer px-3 py-2 rounded-lg text-[.78rem]"
style={{ background: 'var(--bg2)', border: '1.5px solid var(--bd)', minHeight: 36 }}
>
<span style={{ color: selected ? 'var(--t1)' : 'var(--t4)' }}>
{selected ? `${selected.tenancy_name} (${selected.region})` : t('rpt.selectOciCred')}
</span>
<ChevronDown size={12} style={{ color: 'var(--t4)' }} />
</div>
{open && (
<div
className="absolute top-full left-0 right-0 mt-1 rounded-lg overflow-hidden z-50"
style={{ background: 'var(--bg)', border: '1px solid var(--bd)', boxShadow: '0 4px 16px rgba(0,0,0,.25)', maxHeight: 260 }}
>
<div className="p-1.5" style={{ borderBottom: '1px solid var(--bd)' }}>
<div className="flex items-center gap-1.5 px-2" style={{ background: 'var(--bg2)', borderRadius: 6 }}>
<Search size={12} style={{ color: 'var(--t4)' }} />
<input
type="text"
placeholder={t('rpt.searchTenancy')}
value={filter}
onChange={(e) => setFilter(e.target.value)}
onClick={(e) => e.stopPropagation()}
className="w-full border-none outline-none text-xs py-1.5"
style={{ background: 'transparent', color: 'var(--t1)' }}
autoFocus
/>
</div>
</div>
<div className="overflow-y-auto" style={{ maxHeight: 210 }}>
{filtered.length === 0 ? (
<div className="p-3 text-xs" style={{ color: 'var(--t4)' }}>{t('rpt.noCredentials')}</div>
) : (
filtered.map((c) => (
<div
key={c.id}
onClick={() => { onChange(c.id); setOpen(false); setFilter(''); }}
className="px-3 py-2 text-xs cursor-pointer transition-colors"
style={{
color: value === c.id ? 'var(--ac)' : 'var(--t2)',
background: value === c.id ? 'color-mix(in srgb, var(--ac) 8%, transparent)' : undefined,
}}
onMouseEnter={(e) => { if (value !== c.id) (e.currentTarget.style.background = 'var(--bg2)'); }}
onMouseLeave={(e) => { if (value !== c.id) (e.currentTarget.style.background = ''); }}
>
<div className="font-semibold">{c.tenancy_name}</div>
<div className="text-[.62rem] mt-0.5" style={{ color: 'var(--t4)' }}>{c.region}</div>
</div>
))
)}
</div>
</div>
)}
</div>
);
}
/* ── Report Selector ── */
function ReportSelector({
reports,
value,
onChange,
}: {
reports: Report[];
value: string;
onChange: (id: string) => void;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState('');
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const f = filter.toLowerCase();
const completed = reports.filter((r) => r.status === 'completed');
const selected = completed.find((r) => r.id === value);
const filtered = completed.filter(
(c) => !f || c.tenancy_name.toLowerCase().includes(f) || c.created_at.toLowerCase().includes(f),
);
return (
<div ref={ref} className="relative">
<div
onClick={() => setOpen(!open)}
className="flex items-center justify-between cursor-pointer px-3 py-2 rounded-lg text-[.78rem]"
style={{ background: 'var(--bg2)', border: '1.5px solid var(--bd)', minHeight: 36 }}
>
<span style={{ color: selected ? 'var(--t1)' : 'var(--t4)' }}>
{selected
? `${selected.tenancy_name} — Level ${selected.level || 2}${selected.created_at}`
: t('rpt.selectCompletedReport')}
</span>
<ChevronDown size={12} style={{ color: 'var(--t4)' }} />
</div>
{open && (
<div
className="absolute top-full left-0 right-0 mt-1 rounded-lg overflow-hidden z-50"
style={{ background: 'var(--bg)', border: '1px solid var(--bd)', boxShadow: '0 4px 16px rgba(0,0,0,.25)', maxHeight: 300 }}
>
<div className="p-1.5" style={{ borderBottom: '1px solid var(--bd)' }}>
<div className="flex items-center gap-1.5 px-2" style={{ background: 'var(--bg2)', borderRadius: 6 }}>
<Search size={12} style={{ color: 'var(--t4)' }} />
<input
type="text"
placeholder={t('rpt.searchByTenancy')}
value={filter}
onChange={(e) => setFilter(e.target.value)}
onClick={(e) => e.stopPropagation()}
className="w-full border-none outline-none text-xs py-1.5"
style={{ background: 'transparent', color: 'var(--t1)' }}
autoFocus
/>
</div>
</div>
<div className="overflow-y-auto" style={{ maxHeight: 250 }}>
{filtered.length === 0 ? (
<div className="p-3 text-xs" style={{ color: 'var(--t4)' }}>{t('rpt.noReports')}</div>
) : (
filtered.map((r) => (
<div
key={r.id}
onClick={() => { onChange(r.id); setOpen(false); setFilter(''); }}
className="px-3 py-2 text-xs cursor-pointer transition-colors"
style={{
color: value === r.id ? 'var(--ac)' : 'var(--t2)',
background: value === r.id ? 'color-mix(in srgb, var(--ac) 8%, transparent)' : undefined,
}}
onMouseEnter={(e) => { if (value !== r.id) (e.currentTarget.style.background = 'var(--bg2)'); }}
onMouseLeave={(e) => { if (value !== r.id) (e.currentTarget.style.background = ''); }}
>
<div className="font-semibold">{r.tenancy_name}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[.62rem]" style={{ color: 'var(--t4)' }}>Level {r.level || 2}</span>
<span className="text-[.5rem]" style={{ color: 'var(--t4)' }}>|</span>
<span className="text-[.62rem]" style={{ color: 'var(--t4)' }}>{r.created_at}</span>
</div>
</div>
))
)}
</div>
</div>
)}
</div>
);
}
/* ── Recharts Custom Tooltip ── */
function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: { value: number; name: string; fill?: string }[]; label?: string }) {
if (!active || !payload?.length) return null;
return (
<div
className="px-3 py-2 rounded-lg text-xs"
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)', boxShadow: 'var(--sh1)' }}
>
{label && <div className="font-semibold mb-1" style={{ color: 'var(--t1)' }}>{label}</div>}
{payload.map((p, i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ background: p.fill || 'var(--ac)' }} />
<span style={{ color: 'var(--t2)' }}>{p.name}: {p.value}</span>
</div>
))}
</div>
);
}
/* ── Main Component ── */
export default function ReportsPage() {
const { t } = useI18n();
const store = useAppStore();
const ociCfg = store.ociCfg;
const ociRegions = store.ociRegions;
const adbCfg = store.adbCfg;
/* ── Reports list ── */
const [reports, setReports] = useState<Report[]>([]);
const [reportsLoading, setReportsLoading] = useState(true);
/* ── Form state (from store) ── */
const formOpen = store.rptFormOpen;
const setFormOpen = store.setRptFormOpen;
const ociVal = store.rptOciVal;
const setOciVal = store.setRptOciVal;
const level = store.rptLevel;
const setLevel = store.setRptLevel;
const selectedRegions = store.rptSelectedRegions;
const setSelectedRegions = store.setRptSelectedRegions;
const obp = store.rptObp;
const setObp = store.setRptObp;
const raw = store.rptRaw;
const setRaw = store.setRptRaw;
const redact = store.rptRedact;
const setRedact = store.setRptRedact;
/* ── Tracking / progress (from store) ── */
const trackingId = store.rptTrackingId;
const setTrackingId = store.setRptTrackingId;
const [progress, setProgress] = useState<ReportProgress | null>(null);
const [logExpanded, setLogExpanded] = useState(false);
/* ── Selected report + KPI (from store) ── */
const selectedRid = store.rptSelectedRid;
const setSelectedRid = store.setRptSelectedRid;
const [kpi, setKpi] = useState<ReportSummary | null>(null);
const [kpiLoading, setKpiLoading] = useState(false);
/* ── Report files ── */
const [files, setFiles] = useState<ReportFile[]>([]);
/* ── Embedding state (from store) ── */
const embedAdb = store.rptEmbedAdb;
const setEmbedAdb = store.setRptEmbedAdb;
const embedTable = store.rptEmbedTable;
const setEmbedTable = store.setRptEmbedTable;
const [embedLoading, setEmbedLoading] = useState(''); // '' = idle, 'all' = full embed, section name = section embed
const [embedMsg, setEmbedMsg] = useState<{ type: 's' | 'e'; text: string } | null>(null);
/* ── HTML iframe (from store) ── */
const showIframe = store.rptShowIframe;
const setShowIframe = store.setRptShowIframe;
const showCompliance = store.rptShowCompliance;
const setShowCompliance = store.setRptShowCompliance;
const [complianceReady, setComplianceReady] = useState(false);
const [complianceGenerating, setComplianceGenerating] = useState(false);
/* ── Error ── */
const [error, setError] = useState('');
/* ── Regions map ── */
const regionsMap = useMemo<Record<string, string[]>>(() => {
if (ociRegions && typeof ociRegions === 'object') {
const entries = Object.entries(ociRegions);
if (entries.length > 0) {
const first = entries[0][1];
if (Array.isArray(first)) return ociRegions as unknown as Record<string, string[]>;
const flat = entries.map(([, v]) => v as unknown as string);
if (flat.length > 0) return { [t('rpt.regions')]: flat };
}
}
return FALLBACK_REGIONS;
}, [ociRegions]);
/* ── Init embed ADB ── */
useEffect(() => {
if (!embedAdb && adbCfg.length) {
const active = adbCfg.find((c) => c.is_active);
const cfg = active || adbCfg[0];
setEmbedAdb(cfg.id);
const tables = (cfg.tables || []).filter((t) => t.is_active);
setEmbedTable(tables[0]?.table_name || '');
}
}, [adbCfg, embedAdb]);
/* ── Update table when ADB changes ── */
const embedAdbTables = useMemo(() => {
const cfg = adbCfg.find((c) => c.id === embedAdb);
return (cfg?.tables || []).filter((t) => t.is_active);
}, [adbCfg, embedAdb]);
/* ── Load reports ── */
const loadReports = useCallback(async () => {
setReportsLoading(true);
setError('');
try {
const data = await reportsApi.list();
setReports(data);
// Auto-track if there's a running report
const running = data.find((r) => r.status === 'running');
if (running && !trackingId) {
setTrackingId(running.id);
}
// Auto-select most recent completed report if none selected
if (!selectedRid && data.length > 0) {
const completed = data.find((r) => r.status === 'completed');
if (completed) setSelectedRid(completed.id);
}
} catch (err) {
setError(err instanceof Error ? err.message : t('rpt.errorLoadReports'));
} finally {
setReportsLoading(false);
}
}, [trackingId, selectedRid]);
useEffect(() => { loadReports(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
/* ── Poll progress ── */
usePolling(
async () => {
if (!trackingId) return true;
try {
const p = await reportsApi.progress(trackingId);
setProgress(p);
if (p.status === 'completed' || p.status === 'failed' || p.status === 'cancelled') {
// Refresh reports list
await loadReports();
if (p.status === 'completed') {
setSelectedRid(trackingId);
setShowIframe(true);
}
setTrackingId(null);
return true; // stop polling
}
} catch {
// Continue polling on transient errors
}
return false;
},
3000,
!!trackingId,
);
/* ── Load KPI when selected report changes ── */
useEffect(() => {
if (!selectedRid) {
setKpi(null);
setFiles([]);
return;
}
let cancelled = false;
setKpiLoading(true);
Promise.all([
reportsApi.summary(selectedRid),
reportsApi.files(selectedRid),
reportsApi.complianceReportStatus(selectedRid).catch(() => ({ ready: false, generating: false } as { ready: boolean; generating: boolean })),
])
.then(([summaryData, filesData, compStatus]) => {
if (!cancelled) {
setKpi(summaryData);
setFiles(filesData);
setComplianceReady(!!compStatus.ready);
setComplianceGenerating(!!compStatus.generating);
}
})
.catch(() => {
if (!cancelled) {
setKpi(null);
setFiles([]);
setComplianceReady(false);
}
})
.finally(() => {
if (!cancelled) setKpiLoading(false);
});
return () => { cancelled = true; };
}, [selectedRid]);
/* ── Compliance report polling (uses same usePolling as CIS report) ── */
usePolling(
async () => {
if (!complianceGenerating || !selectedRid) return false;
try {
const s = await reportsApi.complianceReportStatus(selectedRid);
if (s.ready) {
setComplianceReady(true);
setComplianceGenerating(false);
setShowCompliance(true);
return true; // stop polling
}
} catch { /* keep polling */ }
return false;
},
3000,
complianceGenerating && !!selectedRid,
);
const startComplianceGeneration = useCallback(async () => {
if (!selectedRid || complianceGenerating) return;
setComplianceReady(false);
setComplianceGenerating(true);
try {
await reportsApi.generateComplianceReport(selectedRid);
} catch { /* ignore */ }
// polling is already running via usePolling above
}, [selectedRid, complianceGenerating]);
/* ── Handlers ── */
const handleRun = async () => {
if (!ociVal) {
setError(t('rpt.selectOciCredError'));
return;
}
if (trackingId) {
setError(t('rpt.alreadyRunning'));
return;
}
setError('');
setProgress(null);
setLogExpanded(false);
try {
const result = await reportsApi.run({
config_id: ociVal,
regions: selectedRegions.length > 0 ? selectedRegions : undefined,
level,
obp,
raw,
redact_output: redact,
});
setTrackingId(result.report_id);
setFormOpen(false);
await loadReports();
} catch (err) {
setError(err instanceof Error ? err.message : t('rpt.errorStartScan'));
}
};
const handleCancel = async () => {
if (!trackingId) return;
try {
await reportsApi.cancel(trackingId);
} catch {
// ignore
}
};
const pollEmbeddingStatus = useCallback(async (taskId: string) => {
setEmbedMsg({ type: 's', text: 'Embedding iniciado...' });
const poll = setInterval(async () => {
try {
const s = await reportsApi.embeddingStatus(taskId);
if (s.status === 'running') {
setEmbedMsg({ type: 's', text: s.message });
} else if (s.status === 'done') {
clearInterval(poll);
setEmbedMsg({ type: 's', text: s.message });
setEmbedLoading('');
} else if (s.status === 'error') {
clearInterval(poll);
setEmbedMsg({ type: 'e', text: s.message });
setEmbedLoading('');
}
} catch { /* keep polling */ }
}, 2000);
setTimeout(() => { clearInterval(poll); setEmbedLoading(''); }, 300000);
}, []);
const handleEmbed = async (rid: string) => {
const adbId = embedAdb || (adbCfg.length > 0 ? adbCfg[0].id : '');
if (!adbId) { setEmbedMsg({ type: 'e', text: t('rpt.selectAdb') }); return; }
setEmbedLoading('all');
setEmbedMsg(null);
try {
const r = await reportsApi.embedReport(rid, adbId);
if (r.task_id) {
pollEmbeddingStatus(r.task_id);
} else {
setEmbedMsg({ type: 's', text: r.message });
setEmbedLoading('');
}
} catch (err) {
setEmbedMsg({ type: 'e', text: err instanceof Error ? err.message : t('rpt.errorEmbedding') });
setEmbedLoading('');
}
};
const handleEmbedFile = async (rid: string, fid: string) => {
if (!embedAdb) { setEmbedMsg({ type: 'e', text: t('rpt.selectAdb') }); return; }
if (!embedTable) { setEmbedMsg({ type: 'e', text: t('rpt.selectTable') }); return; }
setEmbedLoading('all');
setEmbedMsg(null);
try {
const r = await reportsApi.embedFile(rid, fid, embedAdb, embedTable);
if (r.task_id) {
pollEmbeddingStatus(r.task_id);
} else {
setEmbedMsg({ type: 's', text: r.message });
setEmbedLoading('');
}
} catch (err) {
setEmbedMsg({ type: 'e', text: err instanceof Error ? err.message : t('rpt.errorEmbedding') });
setEmbedLoading('');
}
};
const handleEmbedSection = async (secName: string, sectionFiles: ReportFile[]) => {
const csvFiles = sectionFiles.filter((f) => f.file_name.endsWith('.csv'));
if (!csvFiles.length) return;
const adbId = embedAdb || (adbCfg.length > 0 ? adbCfg[0].id : '');
if (!adbId) { setEmbedMsg({ type: 'e', text: t('rpt.selectAdb') }); return; }
setEmbedLoading(secName);
setEmbedMsg(null);
try {
const r = await reportsApi.embedSection(selectedRid, csvFiles.map((f) => f.file_name), adbId);
if (r.task_id) {
pollEmbeddingStatus(r.task_id);
} else {
setEmbedMsg({ type: 's', text: r.message });
setEmbedLoading('');
}
} catch (err) {
setEmbedMsg({ type: 'e', text: err instanceof Error ? err.message : t('rpt.errorEmbedding') });
setEmbedLoading('');
}
};
const handleRefresh = () => {
loadReports();
};
/* ── Derived data ── */
const isRunning = !!trackingId;
const pct = progress ? estimateProgress(progress.progress) : 0;
const lastLine = progress?.progress?.length ? progress.progress[progress.progress.length - 1] : '';
/* ── KPI chart data ── */
const pieData = kpi
? [
{ name: t('rpt.compliant'), value: kpi.passed, fill: 'var(--gn)' },
{ name: t('rpt.nonCompliant'), value: kpi.failed, fill: 'var(--rd)' },
]
: [];
const barData = kpi
? Object.entries(kpi.sections)
.map(([name, s]) => ({ name: name.length > 25 ? name.slice(0, 25) + '...' : name, fullName: name, failed: s.failed, passed: s.passed }))
.sort((a, b) => b.failed - a.failed)
: [];
/* ── Style helpers ── */
const cardStyle = { background: 'var(--bg1)', border: '1px solid var(--bd)' };
const labelCls = "block text-[.72rem] font-semibold mb-1";
const labelStyle = { color: 'var(--t3)' };
return (
<div className="h-full overflow-y-auto animate-[fadeIn_.4s_ease]" style={{ background: 'var(--bg)' }}>
<div className="max-w-6xl mx-auto p-6 flex flex-col gap-4">
{/* ═══ Header ═══ */}
<div
className="flex items-center justify-between px-5 py-4 rounded-xl"
style={cardStyle}
>
<div className="flex items-center gap-3">
<div
className="w-9 h-9 rounded-lg flex items-center justify-center"
style={{ background: 'color-mix(in srgb, var(--ac) 12%, transparent)' }}
>
<BarChart3 size={18} style={{ color: 'var(--ac)' }} />
</div>
<div>
<h1 className="text-sm font-bold" style={{ color: 'var(--t1)' }}>{t('rpt.pageTitle')}</h1>
<p className="text-[.66rem]" style={{ color: 'var(--t4)' }}>
{t('rpt.pageSubtitle')}
</p>
</div>
</div>
<button
onClick={handleRefresh}
disabled={reportsLoading}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t2)' }}
>
<RefreshCw size={13} className={reportsLoading ? 'animate-spin' : ''} />
{t('rpt.refresh')}
</button>
</div>
{/* ═══ Error ═══ */}
{error && (
<div
className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg text-xs font-medium"
style={{ background: 'var(--rdl)', color: 'var(--rd)' }}
>
<AlertCircle size={14} />
{error}
<X
size={12}
className="ml-auto cursor-pointer opacity-60 hover:opacity-100"
onClick={() => setError('')}
/>
</div>
)}
{/* ═══ Run Report Form (collapsible) ═══ */}
<div className="rounded-xl" style={cardStyle}>
<div
className="flex items-center gap-2.5 px-5 py-3.5 cursor-pointer select-none"
onClick={() => setFormOpen(!formOpen)}
>
<ChevronRight
size={14}
style={{ color: 'var(--t3)', transition: 'transform .2s', transform: formOpen ? 'rotate(90deg)' : 'rotate(0)' }}
/>
<Play size={14} style={{ color: 'var(--ac)' }} />
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
{t('rpt.runScanCis')}
</span>
{isRunning && (
<span
className="ml-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.62rem] font-semibold"
style={{ background: 'color-mix(in srgb, var(--bl) 12%, transparent)', color: 'var(--bl)' }}
>
<Loader2 size={10} className="animate-spin" />
{t('rpt.inExecution')}
</span>
)}
</div>
{formOpen && (
<div className="px-5 pb-5 flex flex-col gap-4" style={{ borderTop: '1px solid var(--bd)' }}>
<p className="text-[.7rem] pt-3" style={{ color: 'var(--t4)' }}>
{t('rpt.configParams')}
</p>
{/* Row 1: Config + Level */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className={labelCls} style={labelStyle}>{t('rpt.ociCredential')}</label>
<ConfigDropdown configs={ociCfg} value={ociVal} onChange={setOciVal} />
</div>
<div>
<label className={labelCls} style={labelStyle}>{t('rpt.cisLevel')}</label>
<div className="flex gap-2">
{([1, 2] as const).map((l) => (
<button
key={l}
onClick={() => setLevel(l)}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-[.78rem] font-semibold transition-all"
style={{
background: level === l ? 'color-mix(in srgb, var(--ac) 15%, transparent)' : 'var(--bg2)',
color: level === l ? 'var(--ac)' : 'var(--t4)',
border: `1.5px solid ${level === l ? 'var(--ac)' : 'var(--bd)'}`,
}}
>
Level {l}
</button>
))}
</div>
<p className="text-[.62rem] mt-1" style={{ color: 'var(--t4)' }}>
{level === 1 ? t('rpt.level1Desc') : t('rpt.level2Desc')}
</p>
</div>
</div>
{/* Row 2: Regions */}
<div>
<label className={labelCls} style={labelStyle}>{t('rpt.regions')}</label>
<RegionMultiSelect
selected={selectedRegions}
onChange={setSelectedRegions}
regionsMap={regionsMap}
/>
<p className="text-[.62rem] mt-1" style={{ color: 'var(--t4)' }}>
{t('rpt.leaveEmpty')}
</p>
</div>
{/* Row 3: Checkboxes */}
<div className="flex flex-wrap gap-5">
{[
{ id: 'obp', label: 'OCI Best Practices', checked: obp, set: setObp },
{ id: 'raw', label: 'Raw Data', checked: raw, set: setRaw },
{ id: 'redact', label: 'Redact OCIDs', checked: redact, set: setRedact },
].map(({ id, label, checked, set }) => (
<label key={id} className="flex items-center gap-2 cursor-pointer select-none">
<div
className="w-4 h-4 rounded flex items-center justify-center transition-colors"
style={{
border: checked ? '1.5px solid var(--ac)' : '1.5px solid var(--bd)',
background: checked ? 'var(--ac)' : 'transparent',
}}
onClick={() => set(!checked)}
>
{checked && <CheckCircle2 size={10} style={{ color: '#fff' }} />}
</div>
<span className="text-[.76rem] font-medium" style={{ color: 'var(--t2)' }}>{label}</span>
</label>
))}
</div>
{/* Execute button */}
<div>
<button
onClick={handleRun}
disabled={isRunning || !ociVal}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-semibold text-white transition-all disabled:opacity-50"
style={{ background: 'var(--ac)' }}
>
{isRunning ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Play size={16} />
)}
{t('rpt.executeCompliance')}
</button>
</div>
</div>
)}
</div>
{/* ═══ Progress Card ═══ */}
{(isRunning || (progress && progress.status !== 'completed')) && progress && (
<div className="rounded-xl overflow-hidden" style={cardStyle}>
<div className="px-5 py-3 flex items-center gap-2" style={{ borderBottom: '1px solid var(--bd)' }}>
<Activity size={14} style={{ color: 'var(--bl)' }} />
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
{t('rpt.scanProgress')}
</span>
<span className="ml-auto text-[.68rem] font-bold" style={{ color: 'var(--bl)' }}>
{pct}%
</span>
</div>
<div className="px-5 py-4 flex flex-col gap-3">
{/* Progress bar */}
<div className="w-full h-2 rounded-full overflow-hidden" style={{ background: 'var(--bg2)' }}>
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${pct}%`,
background: pct === 100 ? 'var(--gn)' : 'var(--bl)',
}}
/>
</div>
{/* Last status line */}
{lastLine && (
<div className="flex items-center gap-2">
<Loader2 size={12} className="animate-spin flex-shrink-0" style={{ color: 'var(--bl)' }} />
<span className="text-[.72rem] truncate" style={{ color: 'var(--t2)', fontFamily: 'var(--fm)' }}>
{lastLine}
</span>
</div>
)}
{/* Collapsible log */}
<div>
<button
onClick={() => setLogExpanded(!logExpanded)}
className="flex items-center gap-1 text-[.68rem] font-medium"
style={{ color: 'var(--t4)' }}
>
<ChevronRight
size={12}
style={{ transition: 'transform .2s', transform: logExpanded ? 'rotate(90deg)' : 'rotate(0)' }}
/>
{logExpanded ? t('rpt.hideLog') : t('rpt.showLog')} ({progress.progress.length} {t('rpt.lines')})
</button>
{logExpanded && (
<div
className="mt-2 p-3 rounded-lg overflow-auto text-[.66rem] leading-relaxed"
style={{
background: 'var(--bg)',
border: '1px solid var(--bd)',
maxHeight: 240,
fontFamily: 'var(--fm)',
color: 'var(--t3)',
}}
>
{progress.progress.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
)}
</div>
{/* Cancel button */}
{isRunning && (
<button
onClick={handleCancel}
className="flex items-center gap-1.5 self-start px-3 py-1.5 rounded-lg text-[.72rem] font-medium transition-all"
style={{ background: 'color-mix(in srgb, var(--rd) 10%, transparent)', color: 'var(--rd)', border: '1px solid color-mix(in srgb, var(--rd) 25%, transparent)' }}
>
<XCircle size={13} />
{t('rpt.cancelScan')}
</button>
)}
{/* Error message for failed */}
{progress.status === 'failed' && progress.error_msg && (
<div
className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs"
style={{ background: 'var(--rdl)', color: 'var(--rd)' }}
>
<AlertCircle size={14} />
{progress.error_msg}
</div>
)}
</div>
</div>
)}
{/* ═══ Report Selector ═══ */}
<div className="rounded-xl" style={cardStyle}>
<div className="px-5 py-3 flex items-center gap-2" style={{ borderBottom: '1px solid var(--bd)' }}>
<FileText size={14} style={{ color: 'var(--ac)' }} />
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
{t('rpt.selectReport')}
</span>
</div>
<div className="px-5 py-4">
<ReportSelector reports={reports} value={selectedRid} onChange={setSelectedRid} />
</div>
</div>
{/* ═══ KPI Dashboard ═══ */}
{kpiLoading && (
<div className="flex items-center justify-center gap-2 py-12" style={{ color: 'var(--t4)' }}>
<Loader2 size={18} className="animate-spin" />
<span className="text-sm">{t('rpt.loadingDashboard')}</span>
</div>
)}
{kpi && !kpiLoading && (
<>
{/* KPI Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{/* Score Gauge */}
<div
className="rounded-xl p-4 flex flex-col items-center justify-center"
style={cardStyle}
>
<ComplianceGauge score={kpi.score} />
<span className="text-[.66rem] mt-1 font-semibold" style={{ color: 'var(--t3)' }}>
{t('rpt.complianceScore')}
</span>
</div>
{/* Compliant */}
<div className="rounded-xl p-4 flex flex-col justify-center" style={cardStyle}>
<div className="flex items-center gap-2 mb-2">
<ShieldCheck size={18} style={{ color: 'var(--gn)' }} />
<span className="text-[.68rem] font-semibold" style={{ color: 'var(--t3)' }}>{t('rpt.compliant')}</span>
</div>
<div className="text-2xl font-bold" style={{ color: 'var(--gn)' }}>
{kpi.passed}
</div>
<div className="text-[.64rem] mt-1" style={{ color: 'var(--t4)' }}>
{kpi.total > 0 ? ((kpi.passed / kpi.total) * 100).toFixed(1) : 0}% {t('rpt.ofTotal')}
</div>
<div className="text-[.62rem] mt-0.5" style={{ color: 'var(--t4)' }}>
{kpi.total_compliant} {t('rpt.compliantItems')}
</div>
</div>
{/* Non-Compliant */}
<div className="rounded-xl p-4 flex flex-col justify-center" style={cardStyle}>
<div className="flex items-center gap-2 mb-2">
<ShieldAlert size={18} style={{ color: 'var(--rd)' }} />
<span className="text-[.68rem] font-semibold" style={{ color: 'var(--t3)' }}>{t('rpt.nonCompliant')}</span>
</div>
<div className="text-2xl font-bold" style={{ color: 'var(--rd)' }}>
{kpi.failed}
</div>
<div className="text-[.64rem] mt-1" style={{ color: 'var(--t4)' }}>
{kpi.total_findings} {t('rpt.totalFindings')}
</div>
</div>
{/* Total Evaluated */}
<div className="rounded-xl p-4 flex flex-col justify-center" style={cardStyle}>
<div className="flex items-center gap-2 mb-2">
<Activity size={18} style={{ color: 'var(--bl)' }} />
<span className="text-[.68rem] font-semibold" style={{ color: 'var(--t3)' }}>{t('rpt.evaluated')}</span>
</div>
<div className="text-2xl font-bold" style={{ color: 'var(--t1)' }}>
{kpi.total}
</div>
<div className="text-[.64rem] mt-1" style={{ color: 'var(--t4)' }}>
{t('rpt.controlsVerified')}
</div>
<div className="text-[.62rem] mt-0.5" style={{ color: 'var(--t4)' }}>
Level {kpi.level} | {kpi.regions.length} {t('rpt.regionsCount')}
</div>
</div>
</div>
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Donut Chart */}
<div className="rounded-xl p-4" style={cardStyle}>
<h3 className="text-[.76rem] font-semibold mb-3" style={{ color: 'var(--t2)' }}>
{t('rpt.complianceDist')}
</h3>
<div className="flex items-center justify-center" style={{ height: 220 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={85}
paddingAngle={3}
dataKey="value"
stroke="none"
>
{pieData.map((entry, i) => (
<Cell key={i} fill={entry.fill} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex items-center justify-center gap-5 mt-2">
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full" style={{ background: 'var(--gn)' }} />
<span className="text-[.66rem]" style={{ color: 'var(--t3)' }}>{t('rpt.compliant')} ({kpi.passed})</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full" style={{ background: 'var(--rd)' }} />
<span className="text-[.66rem]" style={{ color: 'var(--t3)' }}>{t('rpt.nonCompliant')} ({kpi.failed})</span>
</div>
</div>
</div>
{/* Horizontal Bar Chart */}
<div className="rounded-xl p-4" style={cardStyle}>
<h3 className="text-[.76rem] font-semibold mb-3" style={{ color: 'var(--t2)' }}>
{t('rpt.findingsBySection')}
</h3>
<div style={{ height: Math.max(220, barData.length * 32) }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={barData}
layout="vertical"
margin={{ top: 0, right: 20, bottom: 0, left: 10 }}
>
<XAxis type="number" tick={{ fill: 'var(--t4)', fontSize: 10 }} axisLine={false} tickLine={false} />
<YAxis
dataKey="name"
type="category"
tick={{ fill: 'var(--t3)', fontSize: 10 }}
width={140}
axisLine={false}
tickLine={false}
/>
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="failed" name={t('rpt.nonCompliant')} fill="var(--rd)" radius={[0, 4, 4, 0]} barSize={14} />
<Bar dataKey="passed" name={t('rpt.compliant')} fill="var(--gn)" radius={[0, 4, 4, 0]} barSize={14} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Summary info */}
<div
className="rounded-xl px-5 py-3 flex flex-wrap items-center gap-4 text-[.68rem]"
style={{ ...cardStyle, color: 'var(--t4)' }}
>
<span>{t('rpt.tenancy')}: <strong style={{ color: 'var(--t2)' }}>{kpi.tenancy}</strong></span>
<span>{t('rpt.level')}: <strong style={{ color: 'var(--t2)' }}>{kpi.level}</strong></span>
<span>{t('rpt.regions')}: <strong style={{ color: 'var(--t2)' }}>{kpi.regions.join(', ')}</strong></span>
<span>{t('rpt.generatedAt')}: <strong style={{ color: 'var(--t2)' }}>{kpi.generated_at}</strong></span>
</div>
</>
)}
{/* ═══ HTML Report Iframe ═══ */}
{selectedRid && (
<div className="rounded-xl overflow-hidden" style={cardStyle}>
<div
className="flex items-center justify-between px-5 py-3 cursor-pointer"
style={{ borderBottom: '1px solid var(--bd)' }}
onClick={() => setShowIframe(!showIframe)}
>
<div className="flex items-center gap-2">
{showIframe ? <Eye size={14} style={{ color: 'var(--ac)' }} /> : <EyeOff size={14} style={{ color: 'var(--t4)' }} />}
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
{t('rpt.htmlReport')}
</span>
</div>
<ChevronRight
size={14}
style={{ color: 'var(--t3)', transition: 'transform .2s', transform: showIframe ? 'rotate(90deg)' : 'rotate(0)' }}
/>
</div>
{showIframe && (
<iframe
src={reportsApi.htmlUrl(selectedRid)}
className="w-full border-0"
style={{ height: 600, background: '#fff' }}
title="CIS Report HTML"
/>
)}
</div>
)}
{/* ═══ LAD A-Team CIS Compliance Report ═══ */}
{selectedRid && (
<div className="rounded-xl overflow-hidden" style={cardStyle}>
<div
className="flex items-center justify-between px-5 py-3 cursor-pointer"
style={{ borderBottom: '1px solid var(--bd)' }}
onClick={() => setShowCompliance(!showCompliance)}
>
<div className="flex items-center gap-2">
{showCompliance ? <Eye size={14} style={{ color: 'var(--gn)' }} /> : <EyeOff size={14} style={{ color: 'var(--t4)' }} />}
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
{t('rpt.complianceTitle')}
</span>
{complianceReady && (
<span className="text-[.6rem] px-2 py-0.5 rounded-full font-semibold" style={{ background: 'var(--gnl)', color: 'var(--gn)' }}>
{t('rpt.complianceRemediation')}
</span>
)}
{complianceGenerating && (
<span className="text-[.6rem] px-2 py-0.5 rounded-full font-semibold flex items-center gap-1" style={{ background: 'var(--bll)', color: 'var(--bl)' }}>
<Loader2 size={10} className="animate-spin" /> {t('rpt.complianceGeneratingShort')}
</span>
)}
</div>
<ChevronRight
size={14}
style={{ color: 'var(--t3)', transition: 'transform .2s', transform: showCompliance ? 'rotate(90deg)' : 'rotate(0)' }}
/>
</div>
{showCompliance && (
complianceReady && !complianceGenerating ? (
<div>
<iframe
key={selectedRid + '-' + complianceReady}
src={reportsApi.complianceReportUrl(selectedRid)}
className="w-full border-0"
style={{ height: 700, background: '#fff' }}
title="LAD A-Team CIS Compliance Report"
/>
<div className="px-5 py-2 flex justify-end gap-2" style={{ borderTop: '1px solid var(--bd)' }}>
<a
href={reportsApi.complianceReportZipUrl(selectedRid)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer transition-colors no-underline"
style={{ background: 'var(--ac)', color: '#fff' }}
>
<Download size={12} /> {t('rpt.complianceDownloadZip')}
</a>
<button
onClick={startComplianceGeneration}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer transition-colors"
style={{ background: 'var(--bg3)', color: 'var(--t2)', border: '1px solid var(--bd)' }}
>
<RefreshCw size={12} /> {t('rpt.complianceRegenerate')}
</button>
</div>
</div>
) : complianceGenerating ? (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<Loader2 size={32} className="animate-spin" style={{ color: 'var(--gn)' }} />
<p className="text-[.82rem] font-medium" style={{ color: 'var(--t2)' }}>{t('rpt.complianceGenerating')}</p>
<p className="text-[.7rem]" style={{ color: 'var(--t4)' }}>{t('rpt.complianceFetchingKB')}</p>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 gap-3">
<ShieldCheck size={32} style={{ color: 'var(--t4)', opacity: 0.5 }} />
<p className="text-[.78rem]" style={{ color: 'var(--t3)' }}>{t('rpt.complianceNotGenerated')}</p>
<button
onClick={startComplianceGeneration}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-[.76rem] font-semibold cursor-pointer"
style={{ background: 'var(--gn)', color: '#fff' }}
>
<ShieldCheck size={14} /> {t('rpt.complianceGenerate')}
</button>
</div>
)
)}
</div>
)}
{/* ═══ Report Files (grouped by section) ═══ */}
{selectedRid && files.length > 0 && (() => {
const grouped = groupBySection(files);
const sections = Object.keys(grouped).sort(sectionSort);
const token = localStorage.getItem('t') || '';
return (
<div className="rounded-xl" style={cardStyle}>
{/* Header with embedding controls */}
<div className="px-5 py-3 flex items-center justify-between flex-wrap gap-2" style={{ borderBottom: '1px solid var(--bd)' }}>
<div className="flex items-center gap-2">
<Download size={14} style={{ color: 'var(--ac)' }} />
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
{t('rpt.files')} ({files.length})
</span>
<span className="text-[.62rem]" style={{ color: 'var(--t4)' }}>
{sections.length} {t('rpt.inSections')}
</span>
</div>
{/* Embedding controls */}
{adbCfg.length > 0 && (
<button
onClick={() => handleEmbed(selectedRid)}
disabled={!!embedLoading}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.68rem] font-semibold transition-colors"
style={{ background: 'color-mix(in srgb, var(--yl) 12%, transparent)', color: 'var(--yl)', border: '1px solid color-mix(in srgb, var(--yl) 25%, transparent)' }}
>
{embedLoading === 'all' ? <Loader2 size={12} className="animate-spin" /> : <Dna size={12} />}
{t('rpt.fullEmbedding')}
</button>
)}
</div>
{/* Embed message */}
{embedMsg && (
<div
className="mx-5 mt-3 flex items-center gap-2 px-3 py-2 rounded-lg text-xs"
style={{
background: embedMsg.type === 's' ? 'var(--gnl)' : 'var(--rdl)',
color: embedMsg.type === 's' ? 'var(--gn)' : 'var(--rd)',
}}
>
{embedMsg.type === 's' ? <Check size={14} /> : <AlertCircle size={14} />}
<span>{embedMsg.text}</span>
<button onClick={() => setEmbedMsg(null)} className="ml-auto opacity-60 hover:opacity-100">
<X size={12} />
</button>
</div>
)}
{/* File sections */}
<div className="px-5 py-4">
{sections.map((sec) => {
const secFiles = grouped[sec];
const Icon = sectionIcons[sec] || FileText;
return (
<div key={sec} className="mb-4 last:mb-0">
{/* Section header */}
<div
className="flex items-center gap-2 mb-2 pb-1.5"
style={{ borderBottom: '1px solid var(--bd)' }}
>
<Icon size={13} style={{ color: 'var(--t3)', opacity: 0.7 }} />
<span className="text-[.72rem] font-bold" style={{ color: 'var(--t1)' }}>{sec}</span>
<span className="text-[.6rem]" style={{ color: 'var(--t4)' }}>({secFiles.length})</span>
{adbCfg.length > 0 && secFiles.some((f) => f.file_name.endsWith('.csv')) && (
<button
onClick={() => handleEmbedSection(sec, secFiles)}
disabled={!!embedLoading}
className="ml-auto flex items-center gap-1 px-2 py-0.5 rounded-md text-[.6rem] font-semibold transition-colors cursor-pointer"
style={{ background: 'color-mix(in srgb, var(--yl) 10%, transparent)', color: 'var(--yl)', border: '1px solid color-mix(in srgb, var(--yl) 20%, transparent)' }}
title={`Embed ${sec} CSVs`}
>
{embedLoading === sec ? <Loader2 size={10} className="animate-spin" /> : <Dna size={10} />}
Embed
</button>
)}
</div>
{/* File grid */}
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))' }}>
{secFiles.map((f) => {
const ext = fileExt(f.file_name);
const color = extColors[ext] || 'var(--ac)';
const canEmbed = ['csv', 'json', 'txt', 'md', 'pdf'].includes(ext);
return (
<div
key={f.id}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg transition-all group"
style={{ background: 'var(--bg)', border: '1px solid var(--bd)' }}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = color;
e.currentTarget.style.boxShadow = `0 2px 8px ${color}18`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--bd)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{ background: `${color}14` }}
>
<span className="text-[.58rem] font-bold uppercase tracking-wide" style={{ color }}>
{ext}
</span>
</div>
<div className="flex-1 min-w-0 overflow-hidden">
<div className="text-[.7rem] font-medium truncate" style={{ color: 'var(--t1)' }} title={f.file_name}>
{f.file_name}
</div>
<div className="text-[.58rem] mt-0.5" style={{ color: 'var(--t4)' }}>
{formatFileSize(f.file_size)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<a
href={`/api/reports/${selectedRid}/files/${f.id}/download?token=${encodeURIComponent(token)}`}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 rounded-md opacity-30 group-hover:opacity-70 transition-opacity no-underline"
style={{ color: 'var(--t3)' }}
title="Download"
>
<Download size={12} />
</a>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
})()}
{/* ═══ Report History ═══ */}
<div className="rounded-xl overflow-hidden" style={cardStyle}>
<div className="px-5 py-3 flex items-center gap-2" style={{ borderBottom: '1px solid var(--bd)' }}>
<Clock size={14} style={{ color: 'var(--ac)' }} />
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
{t('rpt.executionHistory')}
</span>
<span className="ml-auto text-[.64rem] px-2 py-0.5 rounded-md font-semibold" style={{ background: 'var(--bg2)', color: 'var(--t4)' }}>
{reports.length} {t('rpt.reports')}
</span>
</div>
{reportsLoading && !reports.length ? (
<div className="flex items-center justify-center gap-2 py-12" style={{ color: 'var(--t4)' }}>
<Loader2 size={16} className="animate-spin" />
<span className="text-xs">{t('rpt.loading')}</span>
</div>
) : reports.length === 0 ? (
<div className="py-10 text-center">
<div
className="w-14 h-14 mx-auto mb-3 rounded-full flex items-center justify-center"
style={{ background: 'color-mix(in srgb, var(--t4) 8%, transparent)' }}
>
<BarChart3 size={24} style={{ color: 'var(--t4)' }} />
</div>
<p className="text-xs font-medium" style={{ color: 'var(--t2)' }}>{t('rpt.noReports')}.</p>
<p className="text-[.66rem] mt-1" style={{ color: 'var(--t4)' }}>
{t('rpt.firstScanHint')}
</p>
</div>
) : (
<div className="divide-y" style={{ borderColor: 'var(--bd)' }}>
{reports.map((r) => (
<div
key={r.id}
className="flex items-center justify-between px-5 py-3 transition-colors cursor-pointer"
style={{
background: selectedRid === r.id ? 'color-mix(in srgb, var(--ac) 5%, var(--bg1))' : undefined,
}}
onClick={() => {
if (r.status === 'completed') setSelectedRid(r.id);
}}
onMouseEnter={(e) => { if (selectedRid !== r.id) e.currentTarget.style.background = 'var(--bg2)'; }}
onMouseLeave={(e) => { if (selectedRid !== r.id) e.currentTarget.style.background = ''; }}
>
<div className="flex items-center gap-3">
<div>
<div className="text-[.78rem] font-semibold" style={{ color: 'var(--t1)' }}>
{r.tenancy_name}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[.62rem]" style={{ color: 'var(--t4)' }}>
Level {r.level || 2}
</span>
<span className="text-[.5rem]" style={{ color: 'var(--t4)' }}>|</span>
<span className="text-[.62rem]" style={{ color: 'var(--t4)' }}>
{r.created_at}
</span>
{r.completed_at && (
<>
<span className="text-[.5rem]" style={{ color: 'var(--t4)' }}>|</span>
<span className="text-[.62rem]" style={{ color: 'var(--t4)' }}>
{t('rpt.completedAt')} {r.completed_at}
</span>
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{statusBadge(r.status)}
{r.status === 'completed' && selectedRid === r.id && (
<span className="text-[.6rem] font-semibold" style={{ color: 'var(--ac)' }}>{t('rpt.selected')}</span>
)}
{(r.status === 'failed' || r.status === 'cancelled') && (
<button
className="flex items-center gap-1 px-2 py-1 rounded text-[.62rem] font-medium transition-colors"
style={{ background: 'var(--rdl)', color: 'var(--rd)' }}
title={t('rpt.delete')}
onClick={async (e) => {
e.stopPropagation();
if (!confirm(t('rpt.deleteConfirm'))) return;
try {
await reportsApi.delete(r.id);
setReports((prev) => prev.filter((x) => x.id !== r.id));
} catch (err) {
alert(err instanceof Error ? err.message : t('rpt.deleteFailed'));
}
}}
>
<Trash2 size={10} /> {t('rpt.delete')}
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}