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:
505
frontend-react/src/pages/config/EmbeddingsPage.tsx
Normal file
505
frontend-react/src/pages/config/EmbeddingsPage.tsx
Normal file
@@ -0,0 +1,505 @@
|
||||
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 : 'Erro ao carregar embeddings' });
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteEmb = async (docId: string) => {
|
||||
if (!confirm('Excluir este embedding?')) return;
|
||||
try {
|
||||
await embeddingsApi.remove(selVid, docId, selTable || undefined);
|
||||
loadEmbs();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Erro ao excluir');
|
||||
}
|
||||
};
|
||||
|
||||
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)' }}>Total: {total} documentos</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user