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
This commit is contained in:
453
frontend-react/src/pages/DownloadsPage.tsx
Normal file
453
frontend-react/src/pages/DownloadsPage.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user