Files
A-Team-Security-Infra-Agent…/frontend-react/src/pages/DownloadsPage.tsx
nogueiraguh 442e710b9f feat: LAD A-Team CIS Compliance Report, React SPA, i18n, report management, explorer UX fixes
- Professional Oracle-format compliance report with RAG remediation from ADB vector store
- React 19 SPA at /app/ (16 pages, TypeScript, Vite, Zustand, i18n pt/en 625 keys)
- Report delete endpoint, embed individual report files into vector store
- Terraform ZIP download (client-side, pure JS)
- Explorer silent polling during start/stop (no flickering)
- HTML report and compliance report sections start minimized
- Token auth fix for compliance report CSV download links
2026-03-19 07:34:06 -03:00

454 lines
16 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import {
FolderDown, ChevronRight, RefreshCw, Download, FileText, Lock, Globe,
Server, Activity, Database, Package, BarChart3, AlertCircle, Loader2, Filter,
} from 'lucide-react';
import client from '@/api/client';
import { useI18n } from '@/i18n';
/* ── Types ── */
interface Report {
id: string;
tenancy_name: string;
status: string;
level: number | null;
created_at: string;
completed_at: string | null;
}
interface ReportFile {
id: string;
file_name: string;
file_type: string;
file_category: string;
file_size: number;
}
/* ── 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 formatSize(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;
}
/* ── Component ── */
export default function DownloadsPage() {
const { t } = useI18n();
const [reports, setReports] = useState<Report[]>([]);
const [expandedRid, setExpandedRid] = useState<string | null>(null);
const [filesCache, setFilesCache] = useState<Record<string, ReportFile[]>>({});
const [loadingFiles, setLoadingFiles] = useState<string | null>(null);
const [tenancyFilter, setTenancyFilter] = useState('');
const [sectionFilter, setSectionFilter] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const token = localStorage.getItem('t') || '';
const loadReports = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await client.get('/reports') as unknown as Report[];
setReports(data.filter((r) => r.status === 'completed'));
} catch (err) {
setError(err instanceof Error ? err.message : t('dl.loadError'));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadReports(); }, [loadReports]);
const handleRefresh = useCallback(() => {
setFilesCache({});
setExpandedRid(null);
setSectionFilter('');
loadReports();
}, [loadReports]);
const toggleExpand = useCallback(async (rid: string) => {
if (expandedRid === rid) {
setExpandedRid(null);
return;
}
setExpandedRid(rid);
if (!filesCache[rid]) {
setLoadingFiles(rid);
try {
const files = await client.get(`/reports/${rid}/files`) as unknown as ReportFile[];
setFilesCache((prev) => ({ ...prev, [rid]: files }));
} catch {
setFilesCache((prev) => ({ ...prev, [rid]: [] }));
} finally {
setLoadingFiles(null);
}
}
}, [expandedRid, filesCache]);
const handleDownload = useCallback((rid: string, fid: string) => {
window.open(`/api/reports/${rid}/files/${fid}/download?token=${token}`, '_blank');
}, [token]);
/* Derived data */
const tenancies = [...new Set(reports.map((r) => r.tenancy_name))].sort();
const filtered = tenancyFilter ? reports.filter((r) => r.tenancy_name === tenancyFilter) : reports;
/* Sections available in expanded report (for filter) */
const expandedFiles = expandedRid ? filesCache[expandedRid] : null;
const expandedSections = expandedFiles
? [...new Set(expandedFiles.map((f) => extractSection(f.file_name)))].sort(sectionSort)
: [];
return (
<div className="page">
{/* Header */}
<div className="page-header">
<div className="icon" style={{ background: 'var(--bg3)' }}>
<FolderDown size={24} style={{ color: 'var(--t3)' }} />
</div>
<div>
<h1>{t('dl.title')}</h1>
<div className="subtitle">{t('dl.subtitle')}</div>
</div>
<div className="spacer" />
<span className="count">{filtered.length} {t('dl.reports')}</span>
</div>
{/* Toolbar */}
<div className="card" style={{ padding: '12px 20px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div className="flex items-center gap-3 flex-wrap">
{/* Tenancy filter */}
<div className="flex items-center gap-1.5">
<Filter size={13} style={{ color: 'var(--t4)' }} />
<select
value={tenancyFilter}
onChange={(e) => { setTenancyFilter(e.target.value); setExpandedRid(null); setSectionFilter(''); }}
className="text-xs py-1.5 px-2.5 rounded-lg outline-none cursor-pointer"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 180 }}
>
<option value="">{t('dl.allTenancies')} ({reports.length})</option>
{tenancies.map((tn) => (
<option key={tn} value={tn}>
{tn} ({reports.filter((r) => r.tenancy_name === tn).length})
</option>
))}
</select>
</div>
{/* Section filter (only when expanded) */}
{expandedRid && expandedSections.length > 1 && (
<select
value={sectionFilter}
onChange={(e) => setSectionFilter(e.target.value)}
className="text-xs py-1.5 px-2.5 rounded-lg outline-none cursor-pointer"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 160 }}
>
<option value="">{t('dl.allSections')}</option>
{expandedSections.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
)}
<span className="text-[.7rem]" style={{ color: 'var(--t4)' }}>
{filtered.length} {t('dl.reports')}
</span>
</div>
<button
onClick={handleRefresh}
disabled={loading}
className="btn btn-secondary btn-sm"
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
{t('dl.refresh')}
</button>
</div>
{/* Error */}
{error && (
<div
className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg mb-4 text-xs font-medium"
style={{ background: 'var(--rdl)', color: 'var(--rd)' }}
>
<AlertCircle size={14} />
{error}
</div>
)}
{/* Loading */}
{loading && !reports.length && (
<div className="flex items-center justify-center gap-2 py-16" style={{ color: 'var(--t4)' }}>
<Loader2 size={18} className="animate-spin" />
<span className="text-sm">{t('dl.loading')}</span>
</div>
)}
{/* Empty state */}
{!loading && !filtered.length && (
<div className="card">
<div className="empty-state">
<FolderDown size={40} />
<h3>{t('dl.noReports')}</h3>
<p>{t('dl.noReportsHint')}</p>
</div>
</div>
)}
{/* Report list */}
<div className="flex flex-col gap-2.5">
{filtered.map((report) => {
const isExpanded = expandedRid === report.id;
const files = filesCache[report.id];
const isLoading = loadingFiles === report.id;
const fileCount = files ? files.length : null;
return (
<div
key={report.id}
className="card"
style={{
padding: 0,
overflow: 'hidden',
borderColor: isExpanded ? 'color-mix(in srgb, var(--ac) 30%, var(--bd))' : undefined,
boxShadow: isExpanded ? 'var(--sh2)' : undefined,
}}
>
{/* Report header */}
<button
onClick={() => toggleExpand(report.id)}
className="w-full flex items-center justify-between px-4 py-3 text-left transition-colors hover:bg-[var(--bg2)]"
>
<div className="flex items-center gap-3">
<ChevronRight
size={14}
className="transition-transform duration-200 flex-shrink-0"
style={{
color: 'var(--ac)',
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
}}
/>
<div>
<span className="text-[.82rem] font-semibold" style={{ color: 'var(--t1)' }}>
{report.tenancy_name}
</span>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>
Level {report.level || 2}
</span>
<span className="text-[.5rem]" style={{ color: 'var(--t4)' }}>|</span>
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>
{report.created_at}
</span>
{fileCount != null && (
<>
<span className="text-[.5rem]" style={{ color: 'var(--t4)' }}>|</span>
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>
{fileCount} {t('dl.files')}
</span>
</>
)}
</div>
</div>
</div>
<span className="text-[.68rem] flex-shrink-0" style={{ color: 'var(--t4)' }}>
{isExpanded ? t('dl.hide') : t('dl.viewFiles')}
</span>
</button>
{/* Expanded file list */}
{isExpanded && (
<div
className="px-4 py-4"
style={{ borderTop: '1px solid var(--bd)', background: 'var(--bg2)' }}
>
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-6" style={{ color: 'var(--t4)' }}>
<Loader2 size={16} className="animate-spin" />
<span className="text-xs">{t('dl.loadingFiles')}</span>
</div>
) : !files || !files.length ? (
<p className="text-xs text-center py-6" style={{ color: 'var(--t4)' }}>
{t('dl.noFiles')}
</p>
) : (
<FileListGrouped
files={files}
reportId={report.id}
sectionFilter={sectionFilter}
onDownload={handleDownload}
/>
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
/* ── FileListGrouped sub-component ── */
function FileListGrouped({
files,
reportId,
sectionFilter,
onDownload,
}: {
files: ReportFile[];
reportId: string;
sectionFilter: string;
onDownload: (rid: string, fid: string) => void;
}) {
const { t } = useI18n();
const grouped = groupBySection(files);
let sections = Object.keys(grouped).sort(sectionSort);
if (sectionFilter) sections = sections.filter((s) => s === sectionFilter);
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<span className="text-[.72rem]" style={{ color: 'var(--t3)' }}>
{files.length} {t('dl.filesInSections').replace('{0}', String(Object.keys(grouped).length))}
</span>
</div>
{sections.map((sec) => {
const secFiles = grouped[sec];
const Icon = sectionIcons[sec] || FileText;
return (
<div key={sec} className="mb-3">
{/* 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>
</div>
{/* File grid */}
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(230px, 1fr))' }}>
{secFiles.map((f) => {
const ext = fileExt(f.file_name);
const color = extColors[ext] || 'var(--ac)';
return (
<button
key={f.id}
onClick={() => onDownload(reportId, f.id)}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-left 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';
}}
>
{/* File type badge */}
<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>
{/* File info */}
<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)' }}>
{formatSize(f.file_size)}
</div>
</div>
{/* Download icon */}
<Download
size={12}
className="flex-shrink-0 opacity-30 group-hover:opacity-70 transition-opacity"
style={{ color: 'var(--t3)' }}
/>
</button>
);
})}
</div>
</div>
);
})}
</div>
);
}