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
506 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|