- 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
1672 lines
70 KiB
TypeScript
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>
|
|
);
|
|
}
|