Files
A-Team-Security-Infra-Agent…/frontend-react/src/pages/config/EmbeddingsPage.tsx
nogueiraguh fbcb1966d5 feat: comprehensive security, performance, UX and deployment audit
Security:
- CORS restricted to explicit methods/headers, configurable via CORS_ORIGINS env
- Auth added to /reports/{rid}/html and /compliance-report endpoints
- Ownership check on report downloads
- Rate limiting on login (10 attempts/5min per IP with threading.Lock)
- Non-root container user (agent via gosu entrypoint)
- Nginx security headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy)
- .gitignore and .dockerignore for secret leak prevention
- .env.example with documentation

Performance:
- 16 SQLite indexes on foreign keys and frequently queried columns
- Pagination on chat messages (100), reports (50), audit log (100)
- subprocess.run wrapped in run_in_executor (3 async handlers)
- asyncio.wait_for timeouts on GenAI calls (300s) and Chromium PDF (120s)
- Thread pool reduced to 10 workers
- Code splitting with React.lazy (bundle: 1.3MB -> ~550KB initial)
- React.memo on TreeItem (recursive compartment tree)

Error handling:
- 13 bare except clauses replaced with logged Exception handlers
- Graceful shutdown handler (terminates subprocesses + executor)
- File upload validation (50MB max, extension whitelist per endpoint)
- Health check expanded (version, uptime, db_ok)
- Cache-Control on /api/genai/models (1h)
- Auto-cleanup audit_log > 30 days

Dead code removed:
- DownloadsPage.tsx, StubPage.tsx, MfaPage.tsx (moved to UsersPage)
- Legacy frontend/ directory
- 19 unused i18n keys, dist/ removed from git tracking

UX & i18n:
- 8 alert() calls replaced with styled error states (TerraformPage)
- 50+ hardcoded Portuguese strings localized to i18n (pt/en) across 11 files
- aria-label on all icon-only buttons (Chat, Explorer, Sidebar)
- focus-visible CSS for keyboard navigation
- Responsive grid fix (360px -> 280px for mobile)
- aria-hidden on decorative SVGs

Deployment:
- Docker resource limits (backend 4G, frontend 512M)
- Log rotation (json-file, 10MB x 3)
- Terraform version parameterized via ARG
2026-03-20 13:48:30 -03:00

506 lines
21 KiB
TypeScript

import { useState, useRef, useCallback, useEffect } from 'react';
import { useAppStore, type AdbConfig, type AdbTable } from '@/stores/app';
import { embeddingsApi, type EmbeddingDoc } from '@/api/endpoints/embeddings';
import { useI18n } from '@/i18n';
import {
Dna, Shield, ScrollText, Upload, Loader2, Check, X,
Trash2, RefreshCw, Link, FileUp, Database, Search, AlertTriangle,
} from 'lucide-react';
/* ── Helpers ── */
function Msg({ type, text }: { type: 's' | 'e' | 'i'; text: string }) {
const bg = type === 's' ? 'var(--gnl)' : type === 'e' ? 'var(--rdl)' : 'var(--bg2)';
const fg = type === 's' ? 'var(--gn)' : type === 'e' ? 'var(--rd)' : 'var(--t2)';
return (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs" style={{ background: bg, color: fg }}>
{type === 's' && <Check size={14} />}
{type === 'e' && <X size={14} />}
{type === 'i' && <Loader2 size={14} className="animate-spin" />}
<span>{text}</span>
</div>
);
}
type MsgState = { type: 's' | 'e' | 'i'; text: string } | null;
function getActiveTables(cfg: AdbConfig): AdbTable[] {
return (cfg.tables || []).filter((t) => t.is_active);
}
function getAllTables(cfg: AdbConfig): AdbTable[] {
return cfg.tables || [];
}
/* ── Main ── */
export default function EmbeddingsPage() {
const { t } = useI18n();
const { adbCfg, ociCfg, genaiCfg } = useAppStore();
// ── Existing Embeddings section ──
const [selVid, setSelVid] = useState(() => adbCfg.find((c) => c.is_active)?.id || adbCfg[0]?.id || '');
const [selTable, setSelTable] = useState('');
const [docs, setDocs] = useState<EmbeddingDoc[]>([]);
const [total, setTotal] = useState(0);
const [listLoading, setListLoading] = useState(false);
const [listMsg, setListMsg] = useState<MsgState>(null);
// ── CIS Recommendations section ──
const [cisVid, setCisVid] = useState(() => adbCfg.find((c) => c.is_active)?.id || adbCfg[0]?.id || '');
const cisPdfRef = useRef<HTMLInputElement>(null);
const [cisUploading, setCisUploading] = useState(false);
const [cisMsg, setCisMsg] = useState<MsgState>(null);
// ── Knowledge Base section ──
const [kbVid, setKbVid] = useState(() => adbCfg.find((c) => c.is_active)?.id || adbCfg[0]?.id || '');
const kbFileRef = useRef<HTMLInputElement>(null);
const [kbUrl, setKbUrl] = useState('');
const [kbFileUploading, setKbFileUploading] = useState(false);
const [kbUrlUploading, setKbUrlUploading] = useState(false);
const [kbMsg, setKbMsg] = useState<MsgState>(null);
// ── Purge section ──
const [purgeVid, setPurgeVid] = useState(() => adbCfg.find((c) => c.is_active)?.id || adbCfg[0]?.id || '');
const [purgeTable, setPurgeTable] = useState('');
const [purgeTenancy, setPurgeTenancy] = useState('');
const [purging, setPurging] = useState(false);
const [purgeMsg, setPurgeMsg] = useState<MsgState>(null);
const [purgeConfirm, setPurgeConfirm] = useState(false);
// Update selTable when selVid changes
useEffect(() => {
const cfg = adbCfg.find((c) => c.id === selVid);
if (cfg) {
const tables = getActiveTables(cfg);
setSelTable(tables[0]?.table_name || '');
}
}, [selVid, adbCfg]);
// Preconditions
if (!adbCfg.length) {
return (
<div className="page">
<div className="empty-state">
<Database size={36} />
<h3>{t('emb.noAdb')}</h3>
<p>{t('emb.noAdbHint')}</p>
</div>
</div>
);
}
if (!genaiCfg.length && !ociCfg.length) {
return (
<div className="page">
<div className="empty-state">
<Dna size={36} />
<h3>{t('emb.noOci')}</h3>
<p>{t('emb.noOciHint')}</p>
</div>
</div>
);
}
/* ── Handlers ── */
const loadEmbs = async () => {
if (!selVid) return;
setListLoading(true);
setListMsg(null);
setDocs([]);
try {
const d = await embeddingsApi.list(selVid, selTable || undefined);
setDocs(d.documents);
setTotal(d.total);
if (!d.documents.length) setListMsg({ type: 'i', text: t('emb.noEmbeddings') });
} catch (err) {
setListMsg({ type: 'e', text: err instanceof Error ? err.message : t('emb.errorLoadEmb') });
} finally {
setListLoading(false);
}
};
const deleteEmb = async (docId: string) => {
if (!confirm(t('emb.confirmDeleteEmb'))) return;
try {
await embeddingsApi.remove(selVid, docId, selTable || undefined);
loadEmbs();
} catch (err) {
alert(err instanceof Error ? err.message : t('common.errorDelete'));
}
};
const uploadCis = async () => {
const f = cisPdfRef.current?.files?.[0];
if (!cisVid || !f) { setCisMsg({ type: 'e', text: t('emb.selectAdbAndFile') }); return; }
setCisUploading(true);
setCisMsg({ type: 'i', text: t('emb.processing') });
try {
const d = await embeddingsApi.uploadFile(cisVid, 'cisrecom', f);
setCisMsg({ type: 's', text: `${d.message} -> tabela CISRECOM` });
if (cisPdfRef.current) cisPdfRef.current.value = '';
} catch (err) {
setCisMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
} finally {
setCisUploading(false);
}
};
const uploadKbFile = async () => {
const f = kbFileRef.current?.files?.[0];
if (!kbVid || !f) { setKbMsg({ type: 'e', text: t('emb.selectAdbAndFile') }); return; }
setKbFileUploading(true);
setKbMsg({ type: 'i', text: t('emb.uploading') });
try {
const d = await embeddingsApi.uploadFile(kbVid, 'engineerknowledgebase', f);
setKbMsg({ type: 's', text: d.message });
if (kbFileRef.current) kbFileRef.current.value = '';
} catch (err) {
setKbMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
} finally {
setKbFileUploading(false);
}
};
const uploadKbUrl = async () => {
if (!kbVid || !kbUrl.trim()) { setKbMsg({ type: 'e', text: t('emb.selectAdbAndUrl') }); return; }
setKbUrlUploading(true);
setKbMsg({ type: 'i', text: t('emb.fetchingUrl') });
try {
const d = await embeddingsApi.uploadUrl(kbVid, 'engineerknowledgebase', kbUrl.trim());
setKbMsg({ type: 's', text: d.message });
setKbUrl('');
} catch (err) {
setKbMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
} finally {
setKbUrlUploading(false);
}
};
const executePurge = async () => {
if (!purgeVid || !purgeTable) { setPurgeMsg({ type: 'e', text: t('emb.selectTable') }); return; }
setPurging(true);
setPurgeMsg({ type: 'i', text: t('emb.purging') });
try {
const d = await embeddingsApi.purge(purgeVid, purgeTable, purgeTenancy || undefined);
setPurgeMsg({ type: 's', text: `${d.deleted} embeddings removidos da tabela ${d.table} (tenancy: ${d.tenancy})` });
setPurgeConfirm(false);
} catch (err) {
setPurgeMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
} finally {
setPurging(false);
}
};
/* ── Shared styles ── */
const inputCls = 'w-full px-3 py-2 rounded-lg text-[.78rem] outline-none transition-colors focus:ring-1';
const inputStyle = { background: 'var(--bg2)', border: '1.5px solid var(--bd)', color: 'var(--t1)' };
const labelCls = 'block text-[.72rem] font-semibold mb-1';
const labelStyle = { color: 'var(--t3)' };
const cardCls = 'card';
const cardStyle = { padding: 0 };
const cardHeaderCls = 'card-header';
const cardHeaderStyle = { margin: 0, padding: '14px 20px' };
const adbOptions = adbCfg.map((c) => (
<option key={c.id} value={c.id}>{c.config_name}</option>
));
const tableOptions = (vid: string) => {
const cfg = adbCfg.find((c) => c.id === vid);
if (!cfg) return null;
return getActiveTables(cfg).map((t) => (
<option key={t.table_name} value={t.table_name}>{t.table_name}</option>
));
};
const allTableOptions = (vid: string) => {
const cfg = adbCfg.find((c) => c.id === vid);
if (!cfg) return null;
return getAllTables(cfg).map((t) => (
<option key={t.table_name} value={t.table_name}>{t.table_name}</option>
));
};
return (
<div className="page" style={{ overflow: 'auto', height: '100%', maxWidth: 1100 }}>
{/* Header */}
<div className="page-header">
<div className="card-header icon" style={{ background: 'color-mix(in srgb, var(--yl) 12%, transparent)' }}>
<Dna size={18} style={{ color: 'var(--yl)' }} />
</div>
<div>
<h1>{t('emb.title')}</h1>
<div className="subtitle">
{t('emb.subtitle')}
</div>
</div>
</div>
{/* ────── Existing Embeddings ────── */}
<div className={cardCls} style={cardStyle}>
<div className={cardHeaderCls} style={cardHeaderStyle}>
<Search size={14} style={{ color: 'var(--bl)' }} />
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>{t('emb.existing')}</span>
</div>
<div className="px-5 py-4 flex flex-col gap-3">
<p className="text-[.7rem]" style={{ color: 'var(--t4)' }}>
{t('emb.existingDesc')}
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className={labelCls} style={labelStyle}>{t('emb.adbConnection')}</label>
<select value={selVid} onChange={(e) => setSelVid(e.target.value)} className={inputCls} style={inputStyle}>
{adbOptions}
</select>
</div>
<div>
<label className={labelCls} style={labelStyle}>{t('emb.table')}</label>
<select value={selTable} onChange={(e) => setSelTable(e.target.value)} className={inputCls} style={inputStyle}>
{tableOptions(selVid)}
</select>
</div>
<div className="flex items-end">
<button
onClick={loadEmbs}
disabled={listLoading}
className="btn btn-secondary btn-sm w-full justify-center"
>
{listLoading ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
{t('emb.load')}
</button>
</div>
</div>
{listMsg && <Msg type={listMsg.type} text={listMsg.text} />}
{docs.length > 0 && (
<>
<div className="overflow-x-auto" style={{ borderRadius: 'var(--r)', border: '1px solid var(--bd)' }}>
<table className="data-table" style={{ tableLayout: 'fixed' }}>
<thead>
<tr>
<th style={{ width: 100 }}>ID</th>
<th>Metadata</th>
<th style={{ width: 60 }}></th>
</tr>
</thead>
<tbody>
{docs.map((doc) => {
let meta = doc.metadata || '\u2014';
if (typeof meta === 'object') meta = JSON.stringify(meta);
if (meta.length > 200) meta = meta.substring(0, 200) + '\u2026';
return (
<tr key={doc.id}>
<td>
<code className="text-[.68rem]" style={{ fontFamily: 'var(--fm)', color: 'var(--t4)', wordBreak: 'break-all' }}>
{doc.id.substring(0, 12)}
</code>
</td>
<td>
<span className="text-[.72rem]" style={{ color: 'var(--t3)', wordBreak: 'break-word', whiteSpace: 'pre-wrap' }}>
{meta}
</span>
</td>
<td>
<button onClick={() => deleteEmb(doc.id)} className="btn btn-sm" style={{ color: 'var(--rd)', background: 'none', border: 'none', padding: 4 }}>
<Trash2 size={12} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="text-[.68rem]" style={{ color: 'var(--t4)' }}>{t('emb.totalDocs').replace('{0}', String(total))}</p>
</>
)}
</div>
</div>
{/* ────── CIS Recommendations ────── */}
<div className={cardCls} style={cardStyle}>
<div className={cardHeaderCls} style={cardHeaderStyle}>
<Shield size={14} style={{ color: 'var(--gn)' }} />
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>{t('emb.cisTitle')}</span>
</div>
<div className="px-5 py-4 flex flex-col gap-3">
<p className="text-[.7rem]" style={{ color: 'var(--t4)' }}>
{t('emb.cisDesc')}
</p>
{cisMsg && <Msg type={cisMsg.type} text={cisMsg.text} />}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className={labelCls} style={labelStyle}>{t('emb.adbConnection')}</label>
<select value={cisVid} onChange={(e) => setCisVid(e.target.value)} className={inputCls} style={inputStyle}>
{adbOptions}
</select>
</div>
<div>
<label className={labelCls} style={labelStyle}>{t('emb.pdfFile')}</label>
<input
ref={cisPdfRef}
type="file"
accept=".pdf,.txt"
className="w-full text-[.72rem] file:mr-2 file:px-3 file:py-1.5 file:rounded-lg file:border-0 file:text-[.68rem] file:font-semibold file:cursor-pointer"
style={{ color: 'var(--t3)' }}
/>
</div>
<div className="flex items-end">
<button
onClick={uploadCis}
disabled={cisUploading}
className="btn btn-success btn-sm w-full justify-center"
>
{cisUploading ? <Loader2 size={14} className="animate-spin" /> : <Upload size={14} />}
{t('emb.uploadCis')}
</button>
</div>
</div>
</div>
</div>
{/* ────── Knowledge Base ────── */}
<div className={cardCls} style={cardStyle}>
<div className={cardHeaderCls} style={cardHeaderStyle}>
<ScrollText size={14} style={{ color: 'var(--yl)' }} />
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>{t('emb.kbTitle')}</span>
</div>
<div className="px-5 py-4 flex flex-col gap-3">
<p className="text-[.7rem]" style={{ color: 'var(--t4)' }}>
{t('emb.kbDesc')}
</p>
{kbMsg && <Msg type={kbMsg.type} text={kbMsg.text} />}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className={labelCls} style={labelStyle}>{t('emb.adbConnection')}</label>
<select value={kbVid} onChange={(e) => setKbVid(e.target.value)} className={inputCls} style={inputStyle}>
{adbOptions}
</select>
</div>
</div>
<div className="flex gap-4 flex-wrap mt-1">
{/* File upload */}
<div className="flex-1 min-w-[240px] flex flex-col gap-2">
<label className={labelCls} style={labelStyle}>{t('emb.fileLabel')}</label>
<input
ref={kbFileRef}
type="file"
accept=".txt,.pdf,.csv,.json,.md"
className="w-full text-[.72rem] file:mr-2 file:px-3 file:py-1.5 file:rounded-lg file:border-0 file:text-[.68rem] file:font-semibold file:cursor-pointer"
style={{ color: 'var(--t3)' }}
/>
<button
onClick={uploadKbFile}
disabled={kbFileUploading}
className="btn btn-sm w-full justify-center"
style={{ background: 'var(--bl)', color: '#fff', borderColor: 'var(--bl)' }}
>
{kbFileUploading ? <Loader2 size={14} className="animate-spin" /> : <FileUp size={14} />}
{t('emb.uploadFile')}
</button>
</div>
{/* Divider */}
<div className="w-px self-stretch my-1" style={{ background: 'var(--bd)' }} />
{/* URL import */}
<div className="flex-1 min-w-[240px] flex flex-col gap-2">
<label className={labelCls} style={labelStyle}>{t('emb.urlLabel')}</label>
<input
type="text"
value={kbUrl}
onChange={(e) => setKbUrl(e.target.value)}
placeholder="https://docs.oracle.com/..."
className={inputCls}
style={inputStyle}
/>
<button
onClick={uploadKbUrl}
disabled={kbUrlUploading}
className="btn btn-sm w-full justify-center"
style={{ background: 'var(--bl)', color: '#fff', borderColor: 'var(--bl)' }}
>
{kbUrlUploading ? <Loader2 size={14} className="animate-spin" /> : <Link size={14} />}
{t('emb.importUrl')}
</button>
</div>
</div>
</div>
</div>
{/* ────── Purge ────── */}
<div className={cardCls} style={{ ...cardStyle, borderColor: 'color-mix(in srgb, var(--rd) 25%, var(--bd))' }}>
<div className={cardHeaderCls} style={cardHeaderStyle}>
<AlertTriangle size={14} style={{ color: 'var(--rd)' }} />
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>{t('emb.purgeTitle')}</span>
</div>
<div className="px-5 py-4 flex flex-col gap-3">
<p className="text-[.7rem]" style={{ color: 'var(--t4)' }}>
{t('emb.purgeDesc')}
</p>
{purgeMsg && <Msg type={purgeMsg.type} text={purgeMsg.text} />}
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className={labelCls} style={labelStyle}>{t('emb.adbConnection')}</label>
<select value={purgeVid} onChange={(e) => setPurgeVid(e.target.value)} className={inputCls} style={inputStyle}>
{adbOptions}
</select>
</div>
<div>
<label className={labelCls} style={labelStyle}>{t('emb.table')}</label>
<select value={purgeTable} onChange={(e) => setPurgeTable(e.target.value)} className={inputCls} style={inputStyle}>
<option value="">{t('common.select')}</option>
{allTableOptions(purgeVid)}
</select>
</div>
<div>
<label className={labelCls} style={labelStyle}>{t('emb.tenancyOptional')}</label>
<input
type="text"
value={purgeTenancy}
onChange={(e) => setPurgeTenancy(e.target.value)}
placeholder="Filtrar por tenancy"
className={inputCls}
style={inputStyle}
/>
</div>
<div className="flex items-end">
{!purgeConfirm ? (
<button
onClick={() => { if (!purgeTable) { setPurgeMsg({ type: 'e', text: t('emb.selectTable') }); return; } setPurgeConfirm(true); }}
className="btn btn-danger btn-sm w-full justify-center"
>
<Trash2 size={14} />
{t('emb.purge')}
</button>
) : (
<div className="flex gap-2 w-full">
<button
onClick={executePurge}
disabled={purging}
className="flex items-center gap-1 px-3 py-2 rounded-lg text-[.72rem] font-semibold text-white flex-1 justify-center disabled:opacity-50"
style={{ background: 'var(--rd)' }}
>
{purging ? <Loader2 size={12} className="animate-spin" /> : <Check size={12} />}
{t('emb.confirm')}
</button>
<button
onClick={() => setPurgeConfirm(false)}
className="px-3 py-2 rounded-lg text-[.72rem] font-medium"
style={{ background: 'var(--bg2)', color: 'var(--t3)', border: '1px solid var(--bd)' }}
>
{t('common.no')}
</button>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}