refactor: reorganize project structure — frontend/, infra/, clean paths
This commit is contained in:
1144
frontend/src/pages/ChatPage.tsx
Normal file
1144
frontend/src/pages/ChatPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1614
frontend/src/pages/ExplorerPage.tsx
Normal file
1614
frontend/src/pages/ExplorerPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
157
frontend/src/pages/LoginPage.tsx
Normal file
157
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useState, useEffect, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const { login, mfaRequired, mfaSetup, totpUri, user } = useAuthStore();
|
||||
const { loadData } = useAppStore();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [totp, setTotp] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [authMode, setAuthMode] = useState('local');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/oidc/config').then(r => r.json()).then(d => setAuthMode(d.auth_mode || 'local')).catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (user) {
|
||||
navigate('/chat', { replace: true });
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await login(username, password, totp || undefined);
|
||||
if (!res.mfa_required && res.token) {
|
||||
await loadData();
|
||||
navigate('/chat', { replace: true });
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('login.error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen" style={{ background: 'var(--bg)' }}>
|
||||
<div className="w-[380px] p-8 rounded-2xl" style={{ background: 'var(--bg1)', boxShadow: 'var(--sh3)', border: '1px solid var(--bd)' }}>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width={52} height={52}>
|
||||
<rect x="2" y="5" width="32" height="26" rx="13" ry="13" fill="rgba(199,70,52,0.08)" stroke="#C74634" strokeWidth="1.8" />
|
||||
<rect x="11" y="11" width="14" height="14" rx="4" fill="#fff" stroke="#C74634" strokeWidth="0.4" />
|
||||
<circle cx="15" cy="17" r="2" fill="#C74634" />
|
||||
<circle cx="21" cy="17" r="2" fill="#C74634" />
|
||||
<circle cx="15" cy="16.7" r="0.7" fill="#fff" />
|
||||
<circle cx="21" cy="16.7" r="0.7" fill="#fff" />
|
||||
<rect x="14" y="21" width="8" height="1.5" rx="0.75" fill="#C74634" opacity="0.6" />
|
||||
</svg>
|
||||
<h1 className="text-lg font-bold mt-3" style={{ color: 'var(--t1)' }}>AI Agent</h1>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--t3)' }}>{t('login.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{authMode !== 'oidc' && (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
{!mfaRequired ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('login.userPlaceholder')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t('login.passPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{mfaSetup && totpUri && (
|
||||
<div className="text-center mb-2">
|
||||
<p className="text-xs mb-2" style={{ color: 'var(--t3)' }}>
|
||||
{t('login.mfaScanQr')}
|
||||
</p>
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpUri)}`}
|
||||
alt="QR Code"
|
||||
className="mx-auto rounded-lg"
|
||||
width={180}
|
||||
height={180}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('login.totpPlaceholder')}
|
||||
value={totp}
|
||||
onChange={(e) => setTotp(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none text-center tracking-[.3em]"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', fontFamily: 'var(--fm)' }}
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-xs px-3 py-2 rounded-lg" style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 rounded-lg text-sm font-semibold text-white transition-colors disabled:opacity-60"
|
||||
style={{ background: 'var(--ac)' }}
|
||||
>
|
||||
{loading ? t('login.loading') : mfaRequired ? t('login.verify') : t('login.submit')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{(authMode === 'oidc' || authMode === 'both') && (
|
||||
<>
|
||||
{authMode === 'both' && (
|
||||
<div className="flex items-center gap-2 my-3">
|
||||
<div className="flex-1 h-px" style={{ background: 'var(--bd)' }} />
|
||||
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>{t('login.or')}</span>
|
||||
<div className="flex-1 h-px" style={{ background: 'var(--bd)' }} />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { window.location.href = '/api/auth/oidc/login'; }}
|
||||
className="w-full py-2.5 rounded-lg text-sm font-semibold transition-colors flex items-center justify-center gap-2"
|
||||
style={{ background: 'var(--rdl)', color: 'var(--rd)', border: '1px solid color-mix(in srgb, var(--rd) 30%, transparent)' }}
|
||||
>
|
||||
<Shield size={16} />
|
||||
{t('login.oidcButton')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
454
frontend/src/pages/OciServicesPage.tsx
Normal file
454
frontend/src/pages/OciServicesPage.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useI18n } from '@/i18n';
|
||||
import {
|
||||
reportsApi,
|
||||
type OciServiceStatus,
|
||||
type OciHealthRegion,
|
||||
} from '@/api/endpoints/reports';
|
||||
import {
|
||||
Shield, RefreshCw, Check, Loader2, ChevronDown, Server,
|
||||
Activity, Search, X, Globe, CheckCircle2, AlertTriangle, XCircle, ChevronRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Status helpers ── */
|
||||
const STATUS_MAP: Record<string, { label: string; color: string; bg: string; dot: string }> = {
|
||||
NormalPerformance: { label: 'Operational', color: '#16a34a', bg: '#dcfce7', dot: '#22c55e' },
|
||||
DegradedPerformance: { label: 'Degraded', color: '#d97706', bg: '#fef3c7', dot: '#f59e0b' },
|
||||
ServiceDisruption: { label: 'Disruption', color: '#dc2626', bg: '#fef2f2', dot: '#ef4444' },
|
||||
MaintenanceInProgress: { label: 'Maintenance', color: '#2563eb', bg: '#eff6ff', dot: '#3b82f6' },
|
||||
};
|
||||
const getStatus = (s: string) => STATUS_MAP[s] || STATUS_MAP.NormalPerformance;
|
||||
|
||||
const GEO_ORDER = ['North America', 'LAD', 'EMEA', 'APAC', 'EU Sovereign'];
|
||||
const GEO_LABELS: Record<string, string> = {
|
||||
'North America': 'Americas',
|
||||
LAD: 'Latin America',
|
||||
EMEA: 'Europe, Middle East & Africa',
|
||||
APAC: 'Asia Pacific',
|
||||
'EU Sovereign': 'EU Sovereign',
|
||||
};
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════════
|
||||
SERVICE STATUS TAB (existing functionality)
|
||||
══════════════════════════════════════════════════════════════════════════════ */
|
||||
function ServiceStatusTab() {
|
||||
const { t } = useI18n();
|
||||
const ociCfg = useAppStore((s) => s.ociCfg);
|
||||
const [selectedConfig, setSelectedConfig] = useState('');
|
||||
const [services, setServices] = useState<OciServiceStatus[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<{ type: 's' | 'e'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedConfig && ociCfg.length > 0) setSelectedConfig(ociCfg[0].id);
|
||||
}, [ociCfg, selectedConfig]);
|
||||
|
||||
const loadServices = useCallback(async (detect = true) => {
|
||||
if (!selectedConfig) return;
|
||||
setLoading(true); setMsg(null);
|
||||
try { setServices(await reportsApi.getOciServices(selectedConfig, detect)); }
|
||||
catch { setMsg({ type: 'e', text: 'Erro ao verificar servicos' }); }
|
||||
finally { setLoading(false); }
|
||||
}, [selectedConfig]);
|
||||
|
||||
useEffect(() => { if (selectedConfig) loadServices(true); }, [selectedConfig]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const saveServices = useCallback(async () => {
|
||||
if (!selectedConfig) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await reportsApi.setOciServices(selectedConfig, services);
|
||||
setMsg({ type: 's', text: 'Status salvo com sucesso' });
|
||||
} catch { setMsg({ type: 'e', text: 'Erro ao salvar' }); }
|
||||
finally { setSaving(false); }
|
||||
}, [selectedConfig, services]);
|
||||
|
||||
const toggleStatus = (idx: number) => {
|
||||
setServices((p) => p.map((s, i) => i !== idx ? s : { ...s, status: s.status === 'OK' ? 'WARNING' : 'OK', overridden: true }));
|
||||
};
|
||||
const updateDescription = (idx: number, desc: string) => {
|
||||
setServices((p) => p.map((s, i) => i !== idx ? s : { ...s, description: desc, overridden: true }));
|
||||
};
|
||||
const resetToAuto = (idx: number) => {
|
||||
setServices((p) => p.map((s, i) => i !== idx ? s : { ...s, status: s.auto_status || s.status, description: s.auto_description || s.description, overridden: false }));
|
||||
};
|
||||
|
||||
const configName = ociCfg.find((c) => c.id === selectedConfig)?.tenancy_name || '';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Tenancy selector */}
|
||||
<div className="card" style={{ padding: '14px 20px' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Server size={16} style={{ color: 'var(--ac)' }} />
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--t1)' }}>Tenancy</span>
|
||||
<div className="relative flex-1" style={{ maxWidth: 350 }}>
|
||||
<select value={selectedConfig} onChange={(e) => setSelectedConfig(e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-xs outline-none cursor-pointer appearance-none pr-8"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }}>
|
||||
<option value="">Selecione...</option>
|
||||
{ociCfg.map((c) => <option key={c.id} value={c.id}>{c.tenancy_name}</option>)}
|
||||
</select>
|
||||
<ChevronDown size={14} className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none" style={{ color: 'var(--t3)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className="px-4 py-2 rounded-lg text-xs font-medium"
|
||||
style={{ background: msg.type === 's' ? 'var(--gnl)' : 'var(--rdl)', color: msg.type === 's' ? 'var(--gn)' : 'var(--rd)' }}>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedConfig && (
|
||||
<div className="card overflow-hidden" style={{ padding: 0 }}>
|
||||
<div className="px-5 py-3 flex items-center justify-between" style={{ borderBottom: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={16} style={{ color: 'var(--ac)' }} />
|
||||
<span className="text-[.78rem] font-semibold" style={{ color: 'var(--t1)' }}>{configName}</span>
|
||||
<span className="text-[.65rem] px-2 py-0.5 rounded-full font-bold" style={{ background: 'var(--gnl)', color: 'var(--gn)' }}>
|
||||
{services.filter((s) => s.status === 'OK').length}/{services.length || 6} OK
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => loadServices(true)} disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer"
|
||||
style={{ background: 'var(--bg3)', color: 'var(--t2)', border: '1px solid var(--bd)' }}>
|
||||
{loading ? <Loader2 size={12} className="animate-spin" /> : <RefreshCw size={12} />} Re-detect
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 gap-2">
|
||||
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--ac)' }} />
|
||||
<span className="text-[.76rem]" style={{ color: 'var(--t3)' }}>Verificando servicos OCI...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[.74rem]" style={{ borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--bg2)' }}>
|
||||
<th className="py-2.5 px-4 text-left font-semibold" style={{ color: 'var(--t3)', width: 90 }}>STATUS</th>
|
||||
<th className="py-2.5 px-4 text-left font-semibold" style={{ color: 'var(--t3)', width: 200 }}>SERVICE</th>
|
||||
<th className="py-2.5 px-4 text-left font-semibold" style={{ color: 'var(--t3)' }}>DESCRIPTION</th>
|
||||
<th className="py-2.5 px-4 text-center font-semibold" style={{ color: 'var(--t3)', width: 70 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{services.map((svc, i) => (
|
||||
<tr key={svc.service} style={{ borderBottom: '1px solid var(--bd)' }}>
|
||||
<td className="py-2.5 px-4">
|
||||
<button onClick={() => toggleStatus(i)} className="px-3 py-1 rounded text-[.68rem] font-bold cursor-pointer"
|
||||
style={{ background: svc.status === 'OK' ? '#dcfce7' : '#fef3c7', color: svc.status === 'OK' ? '#16a34a' : '#d97706', border: 'none', minWidth: 65 }}
|
||||
title="Clique para alternar OK/WARNING">{svc.status}</button>
|
||||
</td>
|
||||
<td className="py-2.5 px-4">
|
||||
<span className="font-semibold" style={{ color: 'var(--t1)' }}>{svc.service}</span>
|
||||
{svc.overridden && <span className="ml-2 text-[.58rem] px-1.5 py-0.5 rounded" style={{ background: 'var(--bll)', color: 'var(--bl)' }}>editado</span>}
|
||||
</td>
|
||||
<td className="py-2.5 px-4">
|
||||
<input type="text" value={svc.description} onChange={(e) => updateDescription(i, e.target.value)}
|
||||
className="w-full px-2.5 py-1.5 rounded-lg text-[.72rem] outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} />
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-center">
|
||||
{svc.overridden && <button onClick={() => resetToAuto(i)} className="text-[.6rem] px-2 py-0.5 rounded cursor-pointer"
|
||||
style={{ background: 'var(--bg3)', color: 'var(--t3)', border: '1px solid var(--bd)' }}>reset</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{!loading && services.length > 0 && (
|
||||
<div className="px-5 py-3 flex justify-end" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<button onClick={saveServices} disabled={saving}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-[.74rem] font-semibold cursor-pointer"
|
||||
style={{ background: 'var(--ac)', color: '#fff', border: 'none' }}>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} Salvar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════════
|
||||
OCI HEALTH TAB (Oracle public status)
|
||||
══════════════════════════════════════════════════════════════════════════════ */
|
||||
function OciHealthTab() {
|
||||
const [regions, setRegions] = useState<OciHealthRegion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [geoFilter, setGeoFilter] = useState('all');
|
||||
const [expandedRegion, setExpandedRegion] = useState<string | null>(null);
|
||||
|
||||
const loadHealth = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await reportsApi.getOciHealth();
|
||||
setRegions(data.regionHealthReports || []);
|
||||
setLastUpdate(new Date());
|
||||
} catch {}
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadHealth(); }, [loadHealth]);
|
||||
|
||||
// Stats
|
||||
const stats = useMemo(() => {
|
||||
let totalServices = 0, operational = 0, degraded = 0, disrupted = 0;
|
||||
regions.forEach((r) => r.serviceHealthReports.forEach((s) => {
|
||||
totalServices++;
|
||||
if (s.serviceStatus === 'NormalPerformance') operational++;
|
||||
else if (s.serviceStatus === 'DegradedPerformance') degraded++;
|
||||
else disrupted++;
|
||||
}));
|
||||
return { totalServices, operational, degraded, disrupted, regions: regions.length };
|
||||
}, [regions]);
|
||||
|
||||
// Filter
|
||||
const filteredRegions = useMemo(() => {
|
||||
let filtered = regions;
|
||||
if (geoFilter !== 'all') filtered = filtered.filter((r) => r.geographicAreaName === geoFilter);
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
filtered = filtered.map((r) => ({
|
||||
...r,
|
||||
serviceHealthReports: r.serviceHealthReports.filter((s) =>
|
||||
s.serviceName.toLowerCase().includes(q) || r.regionName.toLowerCase().includes(q)
|
||||
),
|
||||
})).filter((r) => r.serviceHealthReports.length > 0);
|
||||
}
|
||||
return filtered;
|
||||
}, [regions, geoFilter, search]);
|
||||
|
||||
// Group by geography
|
||||
const grouped = useMemo(() => {
|
||||
const map: Record<string, OciHealthRegion[]> = {};
|
||||
filteredRegions.forEach((r) => {
|
||||
const geo = r.geographicAreaName || 'Other';
|
||||
if (!map[geo]) map[geo] = [];
|
||||
map[geo].push(r);
|
||||
});
|
||||
return GEO_ORDER.filter((g) => map[g]).map((g) => ({ geo: g, label: GEO_LABELS[g] || g, regions: map[g] }));
|
||||
}, [filteredRegions]);
|
||||
|
||||
// Geos available
|
||||
const availableGeos = useMemo(() => {
|
||||
const s = new Set(regions.map((r) => r.geographicAreaName));
|
||||
return GEO_ORDER.filter((g) => s.has(g));
|
||||
}, [regions]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<Loader2 size={28} className="animate-spin" style={{ color: 'var(--ac)' }} />
|
||||
<span className="text-[.8rem]" style={{ color: 'var(--t3)' }}>Loading OCI Health Status...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ── Global Status Banner ── */}
|
||||
<div className="rounded-xl overflow-hidden" style={{ background: stats.disrupted > 0 ? 'linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%)' : stats.degraded > 0 ? 'linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%)' : 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)', border: '1px solid var(--bd)' }}>
|
||||
<div className="px-6 py-5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center"
|
||||
style={{ background: stats.disrupted > 0 ? '#fee2e2' : stats.degraded > 0 ? '#fef3c7' : '#dcfce7' }}>
|
||||
{stats.disrupted > 0 ? <XCircle size={24} style={{ color: '#dc2626' }} /> :
|
||||
stats.degraded > 0 ? <AlertTriangle size={24} style={{ color: '#d97706' }} /> :
|
||||
<CheckCircle2 size={24} style={{ color: '#16a34a' }} />}
|
||||
</div>
|
||||
{stats.disrupted === 0 && stats.degraded === 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 rounded-full animate-ping" style={{ background: '#22c55e', opacity: 0.5 }} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[1rem] font-bold" style={{ color: stats.disrupted > 0 ? '#dc2626' : stats.degraded > 0 ? '#d97706' : '#16a34a' }}>
|
||||
{stats.disrupted > 0 ? 'Service Disruption Detected' : stats.degraded > 0 ? 'Degraded Performance' : 'All Systems Operational'}
|
||||
</h2>
|
||||
<p className="text-[.72rem] mt-0.5" style={{ color: 'var(--t3)' }}>
|
||||
{stats.regions} regions · {stats.totalServices.toLocaleString()} services monitored
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{lastUpdate && (
|
||||
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>
|
||||
Updated {lastUpdate.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={loadHealth} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer"
|
||||
style={{ background: 'rgba(255,255,255,.7)', color: 'var(--t2)', border: '1px solid var(--bd)' }}>
|
||||
<RefreshCw size={12} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats pills */}
|
||||
<div className="px-6 pb-4 flex gap-3">
|
||||
{[
|
||||
{ label: 'Operational', count: stats.operational, color: '#16a34a', bg: '#f0fdf4' },
|
||||
{ label: 'Degraded', count: stats.degraded, color: '#d97706', bg: '#fffbeb' },
|
||||
{ label: 'Disrupted', count: stats.disrupted, color: '#dc2626', bg: '#fef2f2' },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-[.7rem] font-semibold"
|
||||
style={{ background: s.bg, color: s.color, border: `1px solid ${s.color}20` }}>
|
||||
<span className="w-2 h-2 rounded-full" style={{ background: s.color }} />
|
||||
{s.count.toLocaleString()} {s.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Search + Geo Filter ── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--t4)' }} />
|
||||
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search services or regions..."
|
||||
className="w-full pl-9 pr-8 py-2 rounded-lg text-[.76rem] outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} />
|
||||
{search && <button onClick={() => setSearch('')} className="absolute right-2.5 top-1/2 -translate-y-1/2 cursor-pointer" style={{ color: 'var(--t4)' }}><X size={14} /></button>}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => setGeoFilter('all')}
|
||||
className="px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer transition-colors"
|
||||
style={{ background: geoFilter === 'all' ? 'var(--ac)' : 'var(--bg2)', color: geoFilter === 'all' ? '#fff' : 'var(--t2)', border: '1px solid var(--bd)' }}>
|
||||
<Globe size={12} className="inline mr-1" style={{ verticalAlign: -2 }} />All
|
||||
</button>
|
||||
{availableGeos.map((g) => (
|
||||
<button key={g} onClick={() => setGeoFilter(geoFilter === g ? 'all' : g)}
|
||||
className="px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer transition-colors"
|
||||
style={{ background: geoFilter === g ? 'var(--ac)' : 'var(--bg2)', color: geoFilter === g ? '#fff' : 'var(--t2)', border: '1px solid var(--bd)' }}>
|
||||
{GEO_LABELS[g]?.split(',')[0]?.split('&')[0]?.trim() || g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Region Cards ── */}
|
||||
{grouped.map(({ geo, label, regions: geoRegions }) => (
|
||||
<div key={geo}>
|
||||
<h3 className="text-[.72rem] font-bold uppercase tracking-wider mb-2 px-1" style={{ color: 'var(--t4)' }}>
|
||||
{label}
|
||||
</h3>
|
||||
<div className="grid gap-2">
|
||||
{geoRegions.map((region) => {
|
||||
const svcs = region.serviceHealthReports;
|
||||
const ok = svcs.filter((s) => s.serviceStatus === 'NormalPerformance').length;
|
||||
const issues = svcs.length - ok;
|
||||
const isExpanded = expandedRegion === region.regionName;
|
||||
|
||||
return (
|
||||
<div key={region.regionName} className="rounded-xl overflow-hidden transition-all"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center justify-between px-4 py-3 cursor-pointer"
|
||||
onClick={() => setExpandedRegion(isExpanded ? null : region.regionName)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ background: issues > 0 ? '#f59e0b' : '#22c55e', boxShadow: issues > 0 ? '0 0 8px #f59e0b40' : '0 0 8px #22c55e40' }} />
|
||||
<span className="text-[.78rem] font-semibold" style={{ color: 'var(--t1)' }}>
|
||||
{region.regionName}
|
||||
</span>
|
||||
<span className="text-[.65rem] px-2 py-0.5 rounded-full" style={{ background: 'var(--bg3)', color: 'var(--t3)' }}>
|
||||
{region.regionCanonicalName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[.65rem] font-medium" style={{ color: issues > 0 ? '#d97706' : '#16a34a' }}>
|
||||
{issues > 0 ? `${issues} issue(s)` : `${ok} services OK`}
|
||||
</span>
|
||||
<ChevronRight size={14}
|
||||
style={{ color: 'var(--t4)', transition: 'transform .2s', transform: isExpanded ? 'rotate(90deg)' : 'rotate(0)' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-1.5">
|
||||
{svcs.map((svc) => {
|
||||
const st = getStatus(svc.serviceStatus);
|
||||
return (
|
||||
<div key={svc.serviceId} className="flex items-center gap-2 px-2.5 py-2 rounded-lg transition-colors"
|
||||
style={{ background: 'var(--bg2)' }}>
|
||||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: st.dot }} />
|
||||
<span className="text-[.65rem] truncate" style={{ color: 'var(--t2)' }} title={svc.serviceName}>
|
||||
{svc.serviceName.replace(/^Oracle Cloud Infrastructure /, '').replace(/^Oracle /, '')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════════
|
||||
MAIN PAGE WITH TABS
|
||||
══════════════════════════════════════════════════════════════════════════════ */
|
||||
export default function OciServicesPage() {
|
||||
const { t } = useI18n();
|
||||
const [activeTab, setActiveTab] = useState<'status' | 'health'>('status');
|
||||
|
||||
const tabs = [
|
||||
{ id: 'status' as const, label: 'Service Status', icon: <Shield size={14} /> },
|
||||
{ id: 'health' as const, label: 'OCI Health', icon: <Activity size={14} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-5">
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<Shield size={24} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('svc.title') || 'OCI Services'}</h1>
|
||||
<p className="text-[.78rem]" style={{ color: 'var(--t3)' }}>
|
||||
{activeTab === 'status'
|
||||
? (t('svc.subtitle') || 'Verifique e configure o status dos servicos de seguranca OCI por tenancy')
|
||||
: 'Real-time Oracle Cloud Infrastructure service health across all regions'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 p-1 rounded-xl" style={{ background: 'var(--bg2)', width: 'fit-content' }}>
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-[.76rem] font-semibold cursor-pointer transition-all"
|
||||
style={{
|
||||
background: activeTab === tab.id ? 'var(--bg1)' : 'transparent',
|
||||
color: activeTab === tab.id ? 'var(--ac)' : 'var(--t3)',
|
||||
boxShadow: activeTab === tab.id ? '0 1px 3px rgba(0,0,0,.08)' : 'none',
|
||||
border: 'none',
|
||||
}}>
|
||||
{tab.icon} {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'status' ? <ServiceStatusTab /> : <OciHealthTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
611
frontend/src/pages/PromptGeneratorPage.tsx
Normal file
611
frontend/src/pages/PromptGeneratorPage.tsx
Normal file
@@ -0,0 +1,611 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Sparkles, Send, RefreshCw, Copy, Check, PanelLeftOpen,
|
||||
PanelLeftClose, Plus, Trash2, ChevronDown, Loader2, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import { useAppStore, type ModelInfo } from '@/stores/app';
|
||||
import { terraformApi, type ChatSession } from '@/api/endpoints/terraform';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
/* ── Constants ── */
|
||||
|
||||
const TFP_EXAMPLES = [
|
||||
'VCN com subnets publica e privada, internet gateway, NAT gateway e 2 compute instances Ubuntu',
|
||||
'Ambiente multi-region MAD1 + MAD3 com DRG, RPC e VCNs espelhadas',
|
||||
'Cluster OKE com 3 node pools, load balancer e container registry',
|
||||
'Banco Autonomous Database com vault, encryption key e bastion para acesso',
|
||||
'Infraestrutura completa: VCN, compute, block storage, object storage, IAM policies e monitoring',
|
||||
];
|
||||
|
||||
const TFP_ALLOWED = [
|
||||
'openai.gpt-4.1', 'openai.o3', 'openai.o4-mini',
|
||||
'openai.gpt-5.1', 'openai.gpt-5.2',
|
||||
'google.gemini-2.5-pro', 'google.gemini-2.5-flash',
|
||||
];
|
||||
|
||||
const PROV_ORDER = ['openai', 'google'];
|
||||
|
||||
interface Msg {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
raw?: string;
|
||||
failed?: boolean;
|
||||
copied?: boolean;
|
||||
}
|
||||
|
||||
/* ── Component ── */
|
||||
|
||||
export default function PromptGeneratorPage() {
|
||||
const store = useAppStore();
|
||||
const { models, genaiCfg, ociCfg } = store;
|
||||
const { t } = useI18n();
|
||||
|
||||
/* state (from store) */
|
||||
const msgs = store.pgMessages as Msg[];
|
||||
const setMsgs = store.setPgMessages;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
const sid = store.pgSessionId;
|
||||
const setSid = store.setPgSessionId;
|
||||
|
||||
/* model selection (from store) */
|
||||
const selectedModel = store.pgSelectedModel;
|
||||
const setSelectedModel = store.setPgSelectedModel;
|
||||
const [ddOpen, setDdOpen] = useState(false);
|
||||
const [ddSearch, setDdSearch] = useState('');
|
||||
|
||||
/* OCI config (from store) */
|
||||
const ociId = store.pgOciId;
|
||||
const setOciId = store.setPgOciId;
|
||||
const region = store.pgRegion;
|
||||
const setRegion = store.setPgRegion;
|
||||
const compartment = store.pgCompartment;
|
||||
const setCompartment = store.setPgCompartment;
|
||||
|
||||
/* history sidebar (from store) */
|
||||
const [histOpen, setHistOpen] = useState(false);
|
||||
const history = store.pgHistory as ChatSession[];
|
||||
const setHistory = store.setPgHistory;
|
||||
|
||||
const chatRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const ddRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/* ── auto-scroll ── */
|
||||
const scrollBottom = useCallback(() => {
|
||||
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
|
||||
}, []);
|
||||
|
||||
useEffect(() => { scrollBottom(); }, [msgs, loading, scrollBottom]);
|
||||
|
||||
/* ── close dropdown on outside click ── */
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ddRef.current && !ddRef.current.contains(e.target as Node)) setDdOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
/* ── history ── */
|
||||
const loadHistory = useCallback(async () => {
|
||||
try {
|
||||
const h = await terraformApi.listSessions('tf-prompt');
|
||||
setHistory(h);
|
||||
} catch { setHistory([]); }
|
||||
}, []);
|
||||
|
||||
const loadSession = useCallback(async (sessionId: string) => {
|
||||
try {
|
||||
const d = await terraformApi.loadSession(sessionId);
|
||||
setSid(sessionId);
|
||||
setMsgs(
|
||||
d.messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
raw: m.role === 'assistant' ? m.content : undefined,
|
||||
})),
|
||||
);
|
||||
} catch { /* noop */ }
|
||||
}, []);
|
||||
|
||||
const deleteSession = useCallback(async (sessionId: string) => {
|
||||
try { await terraformApi.deleteSession(sessionId); } catch { /* noop */ }
|
||||
if (sid === sessionId) { setSid(null); setMsgs([]); }
|
||||
loadHistory();
|
||||
}, [sid, loadHistory]);
|
||||
|
||||
const newConversation = () => { setSid(null); setMsgs([]); };
|
||||
|
||||
/* ── resolve genai config ── */
|
||||
const resolveGenai = (): Record<string, string | undefined> => {
|
||||
if (selectedModel.startsWith('cfg:')) return { genai_config_id: selectedModel.slice(4) };
|
||||
if (selectedModel && ociId) return { oci_config_id: ociId, model_id: selectedModel, genai_region: region, compartment_id: compartment };
|
||||
const def = genaiCfg.find((g) => g.is_default) || genaiCfg[0];
|
||||
if (def) return { genai_config_id: def.id };
|
||||
if (!ociCfg.length) return {};
|
||||
const oc = ociCfg[0];
|
||||
return { oci_config_id: oc.id, model_id: 'openai.gpt-4.1', genai_region: oc.region, compartment_id: oc.compartment_id };
|
||||
};
|
||||
|
||||
/* ── pick model ── */
|
||||
const pickModel = (v: string) => {
|
||||
setSelectedModel(v);
|
||||
setDdOpen(false);
|
||||
if (v && !v.startsWith('cfg:') && !ociId && ociCfg.length) {
|
||||
setOciId(ociCfg[0].id);
|
||||
setRegion(ociCfg[0].region);
|
||||
setCompartment(ociCfg[0].compartment_id || '');
|
||||
} else if (v.startsWith('cfg:')) {
|
||||
const g = genaiCfg.find((x) => x.id === v.slice(4));
|
||||
if (g?.oci_config_id && !ociId) {
|
||||
setOciId(g.oci_config_id);
|
||||
const c = ociCfg.find((x) => x.id === g.oci_config_id);
|
||||
if (c) { setRegion(c.region); setCompartment(c.compartment_id || ''); }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* ── send ── */
|
||||
const send = async (overrideMsg?: string) => {
|
||||
const m = overrideMsg || input.trim();
|
||||
if (!m || loading) return;
|
||||
if (!overrideMsg) setInput('');
|
||||
|
||||
const newMsgs: Msg[] = [...msgs, { role: 'user', content: m }];
|
||||
setMsgs(newMsgs);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const gc = resolveGenai();
|
||||
const hist = newMsgs.slice(0, -1)
|
||||
.filter((x) => x.role === 'user' || x.role === 'assistant')
|
||||
.map((x) => ({ role: x.role === 'user' ? 'USER' : 'CHATBOT', content: x.raw || x.content }));
|
||||
|
||||
const body = {
|
||||
message: m,
|
||||
genai_config: gc,
|
||||
history: hist.length ? hist : null,
|
||||
...(sid ? { session_id: sid } : {}),
|
||||
};
|
||||
|
||||
const d = await terraformApi.generatePrompt(body);
|
||||
if (d.session_id) setSid(d.session_id);
|
||||
|
||||
if (d.status === 'processing' && d.message_id) {
|
||||
await pollResult(d.message_id, newMsgs);
|
||||
} else if (d.prompt) {
|
||||
setMsgs([...newMsgs, { role: 'assistant', content: d.prompt, raw: d.prompt }]);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setMsgs([...newMsgs, { role: 'assistant', content: t('common.errorPrefix') + (e as Error).message, failed: true }]);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pollResult = async (mid: string, currentMsgs: Msg[]) => {
|
||||
for (let i = 0; i < 600; i++) {
|
||||
await new Promise((r) => setTimeout(r, i < 10 ? 1000 : 3000));
|
||||
try {
|
||||
const r = await terraformApi.pollMessageStatus(mid);
|
||||
if (r.status === 'done') {
|
||||
setMsgs([...currentMsgs, { role: 'assistant', content: r.content, raw: r.content }]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (r.status === 'failed') {
|
||||
setMsgs([...currentMsgs, { role: 'assistant', content: r.content, failed: true }]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch { /* keep polling */ }
|
||||
}
|
||||
setMsgs([...currentMsgs, { role: 'assistant', content: t('pg.timeout'), failed: true }]);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
/* ── retry ── */
|
||||
const retry = (idx: number) => {
|
||||
let userMsg = '';
|
||||
for (let i = idx - 1; i >= 0; i--) { if (msgs[i].role === 'user') { userMsg = msgs[i].content; break; } }
|
||||
if (!userMsg) return;
|
||||
const next = [...msgs];
|
||||
next.splice(idx, 1);
|
||||
setMsgs(next);
|
||||
send(userMsg);
|
||||
};
|
||||
|
||||
/* ── copy ── */
|
||||
const copyPrompt = async (idx: number) => {
|
||||
const m = msgs[idx];
|
||||
if (!m?.raw) return;
|
||||
await navigator.clipboard.writeText(m.raw);
|
||||
setMsgs((prev) => prev.map((msg, i) => (i === idx ? { ...msg, copied: true } : msg)));
|
||||
setTimeout(() => {
|
||||
setMsgs((prev) => prev.map((msg, i) => (i === idx ? { ...msg, copied: false } : msg)));
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
/* ── model dropdown data ── */
|
||||
const provGroups: Record<string, { id: string; name: string }[]> = {};
|
||||
TFP_ALLOWED.forEach((mid) => {
|
||||
const mi: ModelInfo | undefined = models[mid];
|
||||
if (mi) {
|
||||
const p = mi.provider || 'other';
|
||||
if (!provGroups[p]) provGroups[p] = [];
|
||||
provGroups[p].push({ id: mid, name: mi.name });
|
||||
}
|
||||
});
|
||||
|
||||
const cfgFiltered = genaiCfg.filter((g) => TFP_ALLOWED.includes(g.model_id));
|
||||
|
||||
let curLabel = t('pg.selectModel');
|
||||
if (selectedModel.startsWith('cfg:')) {
|
||||
const g = genaiCfg.find((x) => x.id === selectedModel.slice(4));
|
||||
curLabel = g ? (g.name || g.model_id) : 'Config...';
|
||||
} else if (selectedModel) {
|
||||
const mi = models[selectedModel];
|
||||
curLabel = mi ? mi.name : selectedModel;
|
||||
}
|
||||
|
||||
const isDirect = selectedModel && !selectedModel.startsWith('cfg:');
|
||||
|
||||
/* ── auto-grow textarea ── */
|
||||
const autoGrow = (el: HTMLTextAreaElement) => {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
|
||||
};
|
||||
|
||||
/* ── history date grouping ── */
|
||||
const histDateGroup = (ts: string | undefined) => {
|
||||
const tFn = useI18n.getState().t;
|
||||
if (!ts) return '';
|
||||
const dt = ts.slice(0, 10);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
||||
if (dt === today) return tFn('chat.today');
|
||||
if (dt === yesterday) return tFn('chat.yesterday');
|
||||
const d = new Date(dt + 'T00:00:00');
|
||||
const diff = Math.floor((Date.now() - d.getTime()) / 86400000);
|
||||
if (diff < 7) return d.toLocaleDateString('pt-BR', { weekday: 'long' });
|
||||
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
};
|
||||
|
||||
/* ── render ── */
|
||||
return (
|
||||
<div className="page-full flex">
|
||||
{/* ── History sidebar ── */}
|
||||
{histOpen && (
|
||||
<div className="flex flex-col w-64 border-r shrink-0"
|
||||
style={{ background: 'var(--bg1)', borderColor: 'var(--bd)' }}>
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b"
|
||||
style={{ borderColor: 'var(--bd)' }}>
|
||||
<span className="text-xs font-semibold" style={{ color: 'var(--t2)' }}>{t('pg.history')}</span>
|
||||
<button onClick={newConversation}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[.66rem] cursor-pointer"
|
||||
style={{ background: 'var(--ppl)', color: 'var(--pp)', border: 'none' }}>
|
||||
<Plus size={12} /> {t('pg.new')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-1.5">
|
||||
{history.length === 0 && (
|
||||
<p className="text-center text-[.7rem] p-4" style={{ color: 'var(--t4)' }}>
|
||||
{t('pg.noConversations')}
|
||||
</p>
|
||||
)}
|
||||
{(() => {
|
||||
let lastGroup = '';
|
||||
return history.map((s) => {
|
||||
const g = histDateGroup(s.updated_at || s.created_at);
|
||||
const showGroup = g !== lastGroup;
|
||||
lastGroup = g;
|
||||
return (
|
||||
<div key={s.id}>
|
||||
{showGroup && (
|
||||
<div className="text-[.62rem] font-semibold uppercase tracking-wider px-2 pt-3 pb-1"
|
||||
style={{ color: 'var(--t4)' }}>{g}</div>
|
||||
)}
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1.5 rounded-md cursor-pointer group transition-colors"
|
||||
style={{
|
||||
background: s.id === sid ? 'var(--ppl)' : 'transparent',
|
||||
color: s.id === sid ? 'var(--pp)' : 'var(--t2)',
|
||||
}}
|
||||
onClick={() => loadSession(s.id)}>
|
||||
<span className="flex-1 truncate text-[.72rem]">{s.title || t('tf.newConversation')}</span>
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 rounded cursor-pointer"
|
||||
style={{ color: 'var(--rd)', background: 'none', border: 'none' }}
|
||||
onClick={(e) => { e.stopPropagation(); deleteSession(s.id); }}
|
||||
title={t('common.delete')}>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Main chat area ── */}
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
{/* toolbar */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b"
|
||||
style={{ background: 'var(--bg1)', borderColor: 'var(--bd)' }}>
|
||||
<button onClick={() => { setHistOpen(!histOpen); if (!histOpen) loadHistory(); }}
|
||||
className="p-1.5 rounded-md cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: histOpen ? 'var(--ppl)' : 'transparent',
|
||||
color: histOpen ? 'var(--pp)' : 'var(--t3)',
|
||||
border: 'none',
|
||||
}}
|
||||
title={t('pg.history')}>
|
||||
{histOpen ? <PanelLeftClose size={16} /> : <PanelLeftOpen size={16} />}
|
||||
</button>
|
||||
|
||||
<Sparkles size={16} style={{ color: 'var(--pp)' }} />
|
||||
|
||||
{/* Model dropdown */}
|
||||
<div className="relative" ref={ddRef}>
|
||||
<button onClick={() => setDdOpen(!ddOpen)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[.72rem] font-medium cursor-pointer"
|
||||
style={{ background: 'var(--bg2)', color: 'var(--t2)', border: '1px solid var(--bd)' }}>
|
||||
{curLabel}
|
||||
<ChevronDown size={13} className={`transition-transform ${ddOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{ddOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 w-64 rounded-xl overflow-hidden z-50 animate-[fadeIn_.15s_ease]"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)', boxShadow: 'var(--sh3)' }}>
|
||||
<div className="p-2">
|
||||
<input type="text" placeholder={t('pg.search')}
|
||||
value={ddSearch} onChange={(e) => setDdSearch(e.target.value)}
|
||||
className="w-full px-2.5 py-1.5 rounded-md text-[.72rem]"
|
||||
style={{ background: 'var(--bg2)', color: 'var(--t1)', border: '1px solid var(--bd)' }}
|
||||
autoFocus />
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto px-1 pb-1">
|
||||
{cfgFiltered.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[.62rem] font-semibold uppercase tracking-wider"
|
||||
style={{ color: 'var(--t4)' }}>{t('chat.savedConfigs')}</div>
|
||||
{cfgFiltered
|
||||
.filter((g) => !ddSearch || (g.name || g.model_id).toLowerCase().includes(ddSearch.toLowerCase()))
|
||||
.map((g) => {
|
||||
const mi = models[g.model_id];
|
||||
return (
|
||||
<DdItem key={'cfg:' + g.id} label={g.name || mi?.name || g.model_id}
|
||||
selected={selectedModel === 'cfg:' + g.id}
|
||||
onClick={() => pickModel('cfg:' + g.id)} />
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{PROV_ORDER.filter((p) => provGroups[p]).map((p) => (
|
||||
<div key={p}>
|
||||
<div className="px-2 py-1 text-[.62rem] font-semibold uppercase tracking-wider"
|
||||
style={{ color: 'var(--t4)' }}>{p[0].toUpperCase() + p.slice(1)}</div>
|
||||
{provGroups[p]
|
||||
.filter((m) => !ddSearch || m.name.toLowerCase().includes(ddSearch.toLowerCase()))
|
||||
.map((m) => (
|
||||
<DdItem key={m.id} label={m.name}
|
||||
selected={selectedModel === m.id}
|
||||
onClick={() => pickModel(m.id)} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OCI config selector (direct models only) */}
|
||||
{isDirect && (
|
||||
<select
|
||||
value={ociId}
|
||||
onChange={(e) => {
|
||||
setOciId(e.target.value);
|
||||
const c = ociCfg.find((x) => x.id === e.target.value);
|
||||
if (c) { setRegion(c.region); setCompartment(c.compartment_id || ''); }
|
||||
}}
|
||||
className="px-2 py-1.5 rounded-lg text-[.72rem] max-w-[180px]"
|
||||
style={{ background: 'var(--bg2)', color: 'var(--t2)', border: '1px solid var(--bd)' }}>
|
||||
<option value="">{t('tf.ociConfig')}</option>
|
||||
{ociCfg.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.tenancy_name} ({c.region})</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
{/* messages area */}
|
||||
<div ref={chatRef} className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
||||
{msgs.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<Sparkles size={32} className="mb-3 opacity-30" style={{ color: 'var(--pp)' }} />
|
||||
<p className="text-sm font-medium mb-1" style={{ color: 'var(--t2)' }}>
|
||||
{t('pg.emptyTitle')}
|
||||
</p>
|
||||
<p className="text-xs mb-5 opacity-60" style={{ color: 'var(--t3)' }}>
|
||||
{t('pg.emptySubtitle')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center max-w-2xl">
|
||||
{TFP_EXAMPLES.map((ex, i) => (
|
||||
<button key={i} onClick={() => { setInput(ex); inputRef.current?.focus(); }}
|
||||
className="px-3 py-2 rounded-lg text-[.68rem] text-left cursor-pointer transition-colors leading-snug"
|
||||
style={{
|
||||
background: 'var(--bg2)', color: 'var(--t3)',
|
||||
border: '1px solid var(--bd)', maxWidth: 280,
|
||||
}}>
|
||||
{ex}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msgs.map((m, i) => (
|
||||
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className="max-w-[80%] animate-[fadeIn_.25s_ease]">
|
||||
<div className="rounded-xl px-4 py-3 text-[.8rem] leading-relaxed"
|
||||
style={{
|
||||
background: m.role === 'user' ? 'var(--pp)' : 'var(--bg2)',
|
||||
color: m.role === 'user' ? '#fff' : 'var(--t1)',
|
||||
border: m.role === 'user' ? 'none' : '1px solid var(--bd)',
|
||||
}}>
|
||||
{m.role === 'assistant' ? (
|
||||
<div className="prose-sm prose-invert max-w-none [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:text-[.72rem] [&_pre]:overflow-auto [&_code]:text-[.72rem] [&_pre]:relative [&_pre]:group"
|
||||
style={{
|
||||
['--tw-prose-body' as string]: 'var(--t1)',
|
||||
['--tw-prose-headings' as string]: 'var(--t1)',
|
||||
['--tw-prose-code' as string]: 'var(--pp)',
|
||||
}}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}
|
||||
components={{
|
||||
pre: ({ children }) => (
|
||||
<div className="relative group">
|
||||
<pre className="rounded-lg p-3 text-[.72rem] overflow-auto"
|
||||
style={{ background: 'var(--bg3)', fontFamily: 'var(--fm)', color: 'var(--t2)' }}>
|
||||
{children}
|
||||
</pre>
|
||||
<CopyCodeBtn>{children}</CopyCodeBtn>
|
||||
</div>
|
||||
),
|
||||
code: ({ children, className }) => {
|
||||
if (className?.includes('language-')) return <code className={className}>{children}</code>;
|
||||
return (
|
||||
<code className="px-1 py-0.5 rounded text-[.72rem]"
|
||||
style={{ background: 'var(--bg3)', color: 'var(--pp)', fontFamily: 'var(--fm)' }}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
}}>
|
||||
{m.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ whiteSpace: 'pre-wrap' }}>{m.content}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* assistant actions */}
|
||||
{m.role === 'assistant' && m.raw && !m.failed && (
|
||||
<div className="flex gap-1.5 mt-1.5">
|
||||
<button onClick={() => copyPrompt(i)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[.64rem] cursor-pointer transition-colors"
|
||||
style={{ background: 'var(--bg3)', color: m.copied ? 'var(--gn)' : 'var(--t3)', border: '1px solid var(--bd)' }}>
|
||||
{m.copied ? <Check size={11} /> : <Copy size={11} />}
|
||||
{m.copied ? t('pg.copied') : t('pg.copy')}
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
const url = `/terraform`;
|
||||
window.open(url, '_blank');
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[.64rem] cursor-pointer transition-colors"
|
||||
style={{ background: 'var(--ppl)', color: 'var(--pp)', border: '1px solid var(--pp)' }}>
|
||||
<ExternalLink size={11} /> Terraform
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* retry on failure */}
|
||||
{m.failed && (
|
||||
<button onClick={() => retry(i)}
|
||||
className="flex items-center gap-1 mt-1.5 px-2 py-1 rounded-md text-[.64rem] cursor-pointer"
|
||||
style={{ background: 'var(--rdl)', color: 'var(--rd)', border: '1px solid var(--rd)' }}>
|
||||
<RefreshCw size={11} /> {t('pg.retry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* loading indicator */}
|
||||
{loading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-xl px-4 py-3 text-[.8rem] flex items-center gap-2"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t3)' }}>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{t('pg.loading')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* input area */}
|
||||
<div className="px-4 py-3 border-t" style={{ borderColor: 'var(--bd)', background: 'var(--bg1)' }}>
|
||||
<div className="flex items-end gap-2 max-w-3xl mx-auto">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); autoGrow(e.target); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
|
||||
}}
|
||||
placeholder={t('pg.placeholder')}
|
||||
rows={1}
|
||||
className="flex-1 px-3 py-2.5 rounded-xl text-[.8rem] resize-none outline-none"
|
||||
style={{
|
||||
background: 'var(--bg2)', color: 'var(--t1)',
|
||||
border: '1px solid var(--bd)', maxHeight: 200, overflow: 'auto',
|
||||
}}
|
||||
/>
|
||||
<button onClick={() => send()} disabled={loading || !input.trim()}
|
||||
className="p-2.5 rounded-xl cursor-pointer transition-colors disabled:opacity-40"
|
||||
style={{ background: '#7b42bc', color: '#fff', border: 'none' }}>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Copy code block button ── */
|
||||
|
||||
function CopyCodeBtn({ children }: { children: React.ReactNode }) {
|
||||
const extractText = (node: React.ReactNode): string => {
|
||||
if (typeof node === 'string') return node;
|
||||
if (Array.isArray(node)) return node.map(extractText).join('');
|
||||
if (node && typeof node === 'object' && 'props' in node) {
|
||||
return extractText((node as React.ReactElement<{ children?: React.ReactNode }>).props.children);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className="absolute top-2 right-2 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
style={{ background: 'var(--bg4)', color: 'var(--t3)' }}
|
||||
onClick={() => navigator.clipboard.writeText(extractText(children))}
|
||||
title={useI18n.getState().t('common.copy')}>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Dropdown item ── */
|
||||
|
||||
function DdItem({ label, selected, onClick }: { label: string; selected: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button onClick={onClick}
|
||||
className="w-full text-left px-2.5 py-1.5 rounded-md text-[.72rem] cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: selected ? 'var(--ppl)' : 'transparent',
|
||||
color: selected ? 'var(--pp)' : 'var(--t2)',
|
||||
border: 'none',
|
||||
}}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
1804
frontend/src/pages/ReportsPage.tsx
Normal file
1804
frontend/src/pages/ReportsPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
329
frontend/src/pages/TerminalPage.tsx
Normal file
329
frontend/src/pages/TerminalPage.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Terminal, Loader2, ChevronDown, HelpCircle, X } from 'lucide-react';
|
||||
import client from '@/api/client';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { useTerminalStore } from '@/stores/terminal';
|
||||
|
||||
interface OciCfg { id: string; tenancy_name: string; }
|
||||
|
||||
const FONT = "'JetBrains Mono','Fira Code','Cascadia Code','SF Mono','Consolas','Liberation Mono',monospace";
|
||||
const BG = '#0c0c0c';
|
||||
const FG = '#cccccc';
|
||||
const GREEN = '#16c60c';
|
||||
const BLUE = '#3b78ff';
|
||||
const RED = '#e74856';
|
||||
const DIM = '#767676';
|
||||
const YELLOW = '#f9f1a5';
|
||||
|
||||
export default function TerminalPage() {
|
||||
const { t } = useI18n();
|
||||
const { lines, selectedCfg, cmdHistory, addLine, clearLines, setSelectedCfg, setCmdHistory, addToHistory } = useTerminalStore();
|
||||
const [configs, setConfigs] = useState<OciCfg[]>([]);
|
||||
const [command, setCommand] = useState('');
|
||||
const [running, setRunning] = useState(false);
|
||||
const [histIdx, setHistIdx] = useState(-1);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [tabCount, setTabCount] = useState(0);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
client.get('/oci/configs').then((data: any) => {
|
||||
setConfigs(data);
|
||||
if (data.length > 0 && !selectedCfg) setSelectedCfg(data[0].id);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (containerRef.current) containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(scrollToBottom, [lines, running, suggestions, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCfg && cmdHistory.length === 0) {
|
||||
client.get(`/terminal/history?oci_config_id=${selectedCfg}&limit=100`).then((data: any) => {
|
||||
setCmdHistory(data.map((h: any) => h.command).reverse());
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, [selectedCfg]);
|
||||
|
||||
|
||||
const cfgName = configs.find(c => c.id === selectedCfg)?.tenancy_name || 'oci';
|
||||
const PS1_user = `agent@${cfgName}`;
|
||||
|
||||
const execute = useCallback(async () => {
|
||||
const cmd = command.trim();
|
||||
if (!cmd) return;
|
||||
if (!selectedCfg) { addLine('error', t('term.selectConfig')); return; }
|
||||
if (cmd === 'clear') { clearLines(); setCommand(''); return; }
|
||||
if (cmd === 'history') {
|
||||
addLine('input', cmd);
|
||||
cmdHistory.forEach((h, i) => addLine('output', ` ${String(i + 1).padStart(4)} ${h}`));
|
||||
setCommand(''); return;
|
||||
}
|
||||
if (cmd === 'help') {
|
||||
addLine('input', cmd);
|
||||
addLine('info', 'Commands:');
|
||||
addLine('info', ' oci <service> <resource> <action> [opts] Run OCI CLI command');
|
||||
addLine('info', ' ocid1.instance.oc1.xxx... Auto-lookup resource by OCID');
|
||||
addLine('info', ' find <name> Search resource by display name');
|
||||
addLine('info', ' find %partial% Search with partial match');
|
||||
addLine('info', ' find 10.0.1.5 Search by private IP');
|
||||
addLine('info', ' clear Clear screen');
|
||||
addLine('info', ' history Command history');
|
||||
addLine('info', ' help This message');
|
||||
addLine('info', '');
|
||||
addLine('info', 'Shortcuts:');
|
||||
addLine('info', ' Tab Autocomplete');
|
||||
addLine('info', ' ↑ / ↓ Browse history');
|
||||
addLine('info', ' Ctrl+L Clear screen');
|
||||
setCommand(''); return;
|
||||
}
|
||||
|
||||
addLine('input', cmd);
|
||||
setCommand('');
|
||||
addToHistory(cmd);
|
||||
setHistIdx(-1);
|
||||
setSuggestions([]);
|
||||
setRunning(true);
|
||||
|
||||
try {
|
||||
const resp = await client.post('/terminal/execute', { oci_config_id: selectedCfg, command: cmd }) as any;
|
||||
if (resp.resolved_cmd) addLine('info', `→ ${resp.resolved_cmd}`);
|
||||
if (resp.output) addLine('output', resp.output);
|
||||
if (resp.error) addLine('error', resp.error);
|
||||
} catch (err: any) {
|
||||
addLine('error', err?.message || err?.detail || String(err));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [command, selectedCfg, addLine, cmdHistory, t]);
|
||||
|
||||
const handleTab = useCallback(async () => {
|
||||
if (!command.trim() || running) return;
|
||||
try {
|
||||
const resp = await client.get(`/terminal/completions?prefix=${encodeURIComponent(command)}`) as any;
|
||||
const sugs: string[] = resp.suggestions || [];
|
||||
if (sugs.length === 0) return;
|
||||
if (sugs.length === 1) {
|
||||
const parts = command.trimEnd().split(' ');
|
||||
if (command.endsWith(' ')) setCommand(command + sugs[0] + ' ');
|
||||
else { parts[parts.length - 1] = sugs[0]; setCommand(parts.join(' ') + ' '); }
|
||||
setSuggestions([]); setTabCount(0);
|
||||
} else {
|
||||
if (tabCount > 0) { addLine('input', command); addLine('info', sugs.join(' ')); }
|
||||
setSuggestions(sugs); setTabCount(prev => prev + 1);
|
||||
let common = sugs[0];
|
||||
for (const s of sugs) { while (!s.startsWith(common)) common = common.slice(0, -1); }
|
||||
if (common) {
|
||||
const parts = command.trimEnd().split(' ');
|
||||
if (command.endsWith(' ')) { if (common.length > 0) setCommand(command + common); }
|
||||
else if (common.length > (parts[parts.length - 1] || '').length) {
|
||||
parts[parts.length - 1] = common; setCommand(parts.join(' '));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [command, running, tabCount, addLine]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Tab') { e.preventDefault(); handleTab(); return; }
|
||||
setTabCount(0); setSuggestions([]);
|
||||
if (e.key === 'Enter' && !running) execute();
|
||||
else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (cmdHistory.length > 0) {
|
||||
const n = histIdx < cmdHistory.length - 1 ? histIdx + 1 : histIdx;
|
||||
setHistIdx(n); setCommand(cmdHistory[cmdHistory.length - 1 - n] || '');
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (histIdx > 0) { setHistIdx(histIdx - 1); setCommand(cmdHistory[cmdHistory.length - histIdx] || ''); }
|
||||
else { setHistIdx(-1); setCommand(''); }
|
||||
} else if (e.key === 'l' && e.ctrlKey) { e.preventDefault(); clearLines(); }
|
||||
};
|
||||
|
||||
const Prompt = () => (
|
||||
<span>
|
||||
<span style={{ color: GREEN, fontWeight: 700 }}>{PS1_user}</span>
|
||||
<span style={{ color: DIM }}>:</span>
|
||||
<span style={{ color: BLUE, fontWeight: 700 }}>~</span>
|
||||
<span style={{ color: FG }}>$ </span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 0 }}>
|
||||
{/* Top bar — config selector */}
|
||||
<div className="flex items-center gap-3 px-3 py-2" style={{ flexShrink: 0, background: 'var(--bg1)', borderBottom: '1px solid var(--bd)' }}>
|
||||
<Terminal size={15} style={{ color: 'var(--ac)' }} />
|
||||
<span className="text-xs font-bold" style={{ color: 'var(--t1)' }}>OCI CLI</span>
|
||||
<div className="relative">
|
||||
<select value={selectedCfg} onChange={e => { setSelectedCfg(e.target.value); clearLines(); setCmdHistory([]); }}
|
||||
className="pl-2.5 pr-7 py-1.5 rounded text-xs font-medium outline-none cursor-pointer appearance-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 180 }}>
|
||||
<option value="">-- {t('term.selectConfig')} --</option>
|
||||
{configs.map(c => <option key={c.id} value={c.id}>{c.tenancy_name}</option>)}
|
||||
</select>
|
||||
<ChevronDown size={12} className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none" style={{ color: 'var(--t3)' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded text-xs font-medium transition-all"
|
||||
style={{
|
||||
background: showHelp ? 'var(--acl)' : 'var(--bg2)',
|
||||
border: `1px solid ${showHelp ? 'var(--ac)' : 'var(--bd)'}`,
|
||||
color: showHelp ? 'var(--ac)' : 'var(--t3)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<HelpCircle size={13} />
|
||||
Help
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Help panel */}
|
||||
{showHelp && (
|
||||
<div style={{ flexShrink: 0, background: 'var(--bg2)', borderBottom: '1px solid var(--bd)', padding: '12px 16px', fontSize: '0.75rem' }}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-bold" style={{ color: 'var(--t1)' }}>Terminal Commands</span>
|
||||
<button onClick={() => setShowHelp(false)} style={{ color: 'var(--t4)', cursor: 'pointer', background: 'none', border: 'none' }}><X size={14} /></button>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 24px', color: 'var(--t2)' }}>
|
||||
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>oci <service> <resource> <action></code><span style={{ color: 'var(--t4)' }}>Execute OCI CLI command</span></div>
|
||||
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>oci</code><span style={{ color: 'var(--t4)' }}>Show OCI CLI help</span></div>
|
||||
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>ocid1.instance.oc1.xxx...</code><span style={{ color: 'var(--t4)' }}>Auto-lookup resource by OCID</span></div>
|
||||
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>find <name></code><span style={{ color: 'var(--t4)' }}>Search resource by display name</span></div>
|
||||
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>find %partial%</code><span style={{ color: 'var(--t4)' }}>Search with partial match (LIKE)</span></div>
|
||||
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>find 10.0.1.5</code><span style={{ color: 'var(--t4)' }}>Search by private IP address</span></div>
|
||||
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>clear</code><span style={{ color: 'var(--t4)' }}>Clear screen</span></div>
|
||||
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>history</code><span style={{ color: 'var(--t4)' }}>Show command history</span></div>
|
||||
</div>
|
||||
<div className="mt-2 pt-2" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<span className="text-xs font-bold" style={{ color: 'var(--t1)' }}>Shortcuts</span>
|
||||
<div className="flex gap-6 mt-1" style={{ color: 'var(--t4)' }}>
|
||||
<span><kbd style={{ background: 'var(--bg3)', padding: '1px 5px', borderRadius: 3, fontSize: '0.65rem', fontFamily: FONT }}>Tab</kbd> Autocomplete</span>
|
||||
<span><kbd style={{ background: 'var(--bg3)', padding: '1px 5px', borderRadius: 3, fontSize: '0.65rem', fontFamily: FONT }}>↑↓</kbd> History</span>
|
||||
<span><kbd style={{ background: 'var(--bg3)', padding: '1px 5px', borderRadius: 3, fontSize: '0.65rem', fontFamily: FONT }}>Ctrl+L</kbd> Clear</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terminal body — single continuous scroll */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
background: BG,
|
||||
padding: '8px 12px',
|
||||
fontFamily: FONT,
|
||||
fontSize: '13px',
|
||||
lineHeight: '20px',
|
||||
color: FG,
|
||||
cursor: 'text',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `#333 ${BG}`,
|
||||
}}
|
||||
>
|
||||
{/* MOTD */}
|
||||
{lines.length === 0 && !running && (
|
||||
<>
|
||||
<span style={{ color: GREEN }}>Oracle Cloud Infrastructure CLI</span>{'\n'}
|
||||
<span style={{ color: DIM }}>Type </span>
|
||||
<span style={{ color: YELLOW }}>help</span>
|
||||
<span style={{ color: DIM }}> for commands, </span>
|
||||
<span style={{ color: YELLOW }}>Tab</span>
|
||||
<span style={{ color: DIM }}> for autocomplete</span>{'\n\n'}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* All output lines — continuous flow */}
|
||||
{lines.map((line, i) => {
|
||||
if (line.type === 'input') {
|
||||
return <span key={i}><Prompt /><span style={{ color: FG }}>{line.text}</span>{'\n'}</span>;
|
||||
}
|
||||
if (line.type === 'error') {
|
||||
return <span key={i} style={{ color: RED }}>{line.text}{'\n'}</span>;
|
||||
}
|
||||
if (line.type === 'info') {
|
||||
return <span key={i} style={{ color: DIM }}>{line.text}{'\n'}</span>;
|
||||
}
|
||||
// output
|
||||
return <span key={i} style={{ color: FG }}>{line.text}{'\n'}</span>;
|
||||
})}
|
||||
|
||||
{/* Autocomplete suggestions */}
|
||||
{suggestions.length > 1 && !running && (
|
||||
<span style={{ color: DIM }}>{suggestions.join(' ')}{'\n'}</span>
|
||||
)}
|
||||
|
||||
{/* Running indicator */}
|
||||
{running && (
|
||||
<span style={{ color: BLUE }}>
|
||||
<Loader2 size={12} className="animate-spin" style={{ display: 'inline-block', verticalAlign: 'middle', marginRight: 6 }} />
|
||||
{t('term.executing')}{'\n'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Active input line — part of the scroll, not a separate bar */}
|
||||
{!running && (
|
||||
<span style={{ display: 'inline' }}>
|
||||
<Prompt />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={command}
|
||||
onChange={e => { setCommand(e.target.value); setTabCount(0); setSuggestions([]); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: FG,
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
width: `${Math.max(1, command.length + 1)}ch`,
|
||||
caretColor: FG,
|
||||
verticalAlign: 'baseline',
|
||||
}}
|
||||
/>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: '8px',
|
||||
height: '15px',
|
||||
background: FG,
|
||||
verticalAlign: 'text-bottom',
|
||||
animation: 'termBlink 1s step-end infinite',
|
||||
marginLeft: command ? 0 : -1,
|
||||
}} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes termBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1586
frontend/src/pages/TerraformPage.tsx
Normal file
1586
frontend/src/pages/TerraformPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
462
frontend/src/pages/WorkspacesPage.tsx
Normal file
462
frontend/src/pages/WorkspacesPage.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
FolderOpen, RefreshCw, ChevronRight, Cloud, Download,
|
||||
Play, Rocket, Flame, X, ExternalLink, Ban, Clock,
|
||||
} from 'lucide-react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { terraformApi, type Workspace, type WsStatus } from '@/api/endpoints/terraform';
|
||||
|
||||
/* ── helpers ── */
|
||||
|
||||
type FilterKey = 'all' | 'active' | 'draft' | 'failed' | 'destroyed';
|
||||
|
||||
const RUNNING: WsStatus[] = ['planning', 'applying', 'destroying'];
|
||||
const ACTIVE: WsStatus[] = ['applied', 'planned', 'planning', 'applying'];
|
||||
|
||||
function dateLabel(dt: string | undefined): string {
|
||||
const tFn = useI18n.getState().t;
|
||||
if (!dt) return tFn('ws.noDate');
|
||||
const slice = dt.slice(0, 10);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
||||
if (slice === today) return tFn('chat.today');
|
||||
if (slice === yesterday) return tFn('chat.yesterday');
|
||||
const d = new Date(slice + 'T00:00:00');
|
||||
const diff = Math.floor((Date.now() - d.getTime()) / 86400000);
|
||||
if (diff < 7) return d.toLocaleDateString('pt-BR', { weekday: 'long' });
|
||||
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
function timeAgo(ts: string | undefined): string {
|
||||
if (!ts) return '—';
|
||||
const d = new Date(ts + 'Z');
|
||||
const s = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
const tFn = useI18n.getState().t;
|
||||
if (s < 60) return tFn('chat.timeNow');
|
||||
if (s < 3600) return Math.floor(s / 60) + 'min';
|
||||
if (s < 86400) return Math.floor(s / 3600) + 'h';
|
||||
return Math.floor(s / 86400) + 'd';
|
||||
}
|
||||
|
||||
const BADGE: Record<string, { cls: string; label: string }> = {
|
||||
draft: { cls: 'bg-[var(--bg3)] text-[var(--t3)]', label: 'Draft' },
|
||||
planning: { cls: 'bg-[var(--ppl)] text-[var(--pp)]', label: 'Planning...' },
|
||||
planned: { cls: 'bg-[var(--gnl)] text-[var(--gn)]', label: 'Plan OK' },
|
||||
applying: { cls: 'bg-[var(--bll)] text-[var(--bl)]', label: 'Applying...' },
|
||||
applied: { cls: 'bg-[var(--gnl)] text-[var(--gn)]', label: 'Applied' },
|
||||
destroying: { cls: 'bg-[var(--rdl)] text-[var(--rd)]', label: 'Destroying...' },
|
||||
destroyed: { cls: 'bg-[var(--bg3)] text-[var(--t4)]', label: 'Destroyed' },
|
||||
failed: { cls: 'bg-[var(--rdl)] text-[var(--rd)]', label: 'Failed' },
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: WsStatus }) {
|
||||
const b = BADGE[status] || BADGE.draft;
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[.68rem] font-semibold whitespace-nowrap ${b.cls}`}>
|
||||
{(RUNNING.includes(status)) && (
|
||||
<span className="inline-block w-2 h-2 rounded-full mr-1.5 animate-pulse"
|
||||
style={{ background: 'currentColor' }} />
|
||||
)}
|
||||
{b.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── component ── */
|
||||
|
||||
export default function WorkspacesPage() {
|
||||
const ociCfg = useAppStore((s) => s.ociCfg);
|
||||
const { t } = useI18n();
|
||||
|
||||
const [list, setList] = useState<Workspace[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<FilterKey>('all');
|
||||
const [closedGroups, setClosedGroups] = useState<Set<string>>(new Set());
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
/* confirm modals */
|
||||
const [confirmDestroy, setConfirmDestroy] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [destroyInput, setDestroyInput] = useState('');
|
||||
|
||||
const pollRef = useRef<Set<string>>(new Set());
|
||||
|
||||
/* ── load ── */
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const ws = await terraformApi.listWorkspaces();
|
||||
setList(ws);
|
||||
} catch {
|
||||
setList([]);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
/* ── polling ── */
|
||||
const pollWs = useCallback(async (wid: string) => {
|
||||
if (pollRef.current.has(wid)) return;
|
||||
pollRef.current.add(wid);
|
||||
for (let i = 0; i < 300; i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
try {
|
||||
const r = await terraformApi.status(wid);
|
||||
setList((prev) =>
|
||||
prev.map((w) =>
|
||||
w.id === wid
|
||||
? {
|
||||
...w,
|
||||
status: r.status,
|
||||
plan_output: r.plan_output || w.plan_output,
|
||||
apply_output: r.apply_output || w.apply_output,
|
||||
destroy_output: r.destroy_output || w.destroy_output,
|
||||
error: r.error || '',
|
||||
}
|
||||
: w,
|
||||
),
|
||||
);
|
||||
if (!RUNNING.includes(r.status)) {
|
||||
pollRef.current.delete(wid);
|
||||
load();
|
||||
return;
|
||||
}
|
||||
} catch { /* keep polling */ }
|
||||
}
|
||||
pollRef.current.delete(wid);
|
||||
}, [load]);
|
||||
|
||||
/* ── actions ── */
|
||||
const doPlan = async (wid: string) => {
|
||||
try { await terraformApi.plan(wid); } catch (e: unknown) { alert(t('common.errorPrefix') + (e as Error).message); return; }
|
||||
load(); pollWs(wid);
|
||||
};
|
||||
const doApply = async (wid: string) => {
|
||||
try { await terraformApi.apply(wid); } catch (e: unknown) { alert(t('common.errorPrefix') + (e as Error).message); return; }
|
||||
load(); pollWs(wid);
|
||||
};
|
||||
const doDestroy = async () => {
|
||||
if (!confirmDestroy || destroyInput !== 'DESTROY') return;
|
||||
const wid = confirmDestroy;
|
||||
setConfirmDestroy(null); setDestroyInput('');
|
||||
try { await terraformApi.destroy(wid); } catch (e: unknown) { alert(t('common.errorPrefix') + (e as Error).message); load(); return; }
|
||||
load(); pollWs(wid);
|
||||
};
|
||||
const doDelete = async () => {
|
||||
if (!confirmDelete) return;
|
||||
const wid = confirmDelete;
|
||||
setConfirmDelete(null);
|
||||
try { await terraformApi.deleteWorkspace(wid); } catch (e: unknown) { alert(t('common.errorPrefix') + (e as Error).message); }
|
||||
load();
|
||||
};
|
||||
const doCancel = async (wid: string) => {
|
||||
try { await terraformApi.cancel(wid); } catch { /* noop */ }
|
||||
load();
|
||||
};
|
||||
|
||||
/* ── filter + group ── */
|
||||
let items = list;
|
||||
if (filter === 'active') items = list.filter((w) => ACTIVE.includes(w.status));
|
||||
else if (filter === 'destroyed') items = list.filter((w) => w.status === 'destroyed');
|
||||
else if (filter === 'failed') items = list.filter((w) => w.status === 'failed');
|
||||
else if (filter === 'draft') items = list.filter((w) => w.status === 'draft');
|
||||
|
||||
const counts = {
|
||||
all: list.length,
|
||||
active: list.filter((w) => ACTIVE.includes(w.status)).length,
|
||||
draft: list.filter((w) => w.status === 'draft').length,
|
||||
failed: list.filter((w) => w.status === 'failed').length,
|
||||
destroyed: list.filter((w) => w.status === 'destroyed').length,
|
||||
};
|
||||
|
||||
const groups: { label: string; date: string; items: Workspace[] }[] = [];
|
||||
const gMap: Record<string, number> = {};
|
||||
for (const w of items) {
|
||||
const dt = (w.updated_at || w.created_at || '').slice(0, 10);
|
||||
const label = dateLabel(dt);
|
||||
if (gMap[label] === undefined) { gMap[label] = groups.length; groups.push({ label, date: dt, items: [] }); }
|
||||
groups[gMap[label]].items.push(w);
|
||||
}
|
||||
|
||||
const toggleGroup = (label: string) =>
|
||||
setClosedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(label)) next.delete(label); else next.add(label);
|
||||
return next;
|
||||
});
|
||||
|
||||
const ociName = (id: string) => {
|
||||
const oc = ociCfg.find((c) => c.id === id);
|
||||
return oc ? (oc.tenancy_name || oc.region || id) : '—';
|
||||
};
|
||||
|
||||
/* ── render ── */
|
||||
return (
|
||||
<div className="page-full flex flex-col">
|
||||
{/* header */}
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b"
|
||||
style={{ borderColor: 'var(--bd)', background: 'var(--bg1)' }}>
|
||||
<FolderOpen size={20} style={{ color: 'var(--pp)' }} />
|
||||
<h2 className="text-base font-bold" style={{ color: 'var(--t1)' }}>{t('ws.title')}</h2>
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* filters */}
|
||||
<div className="flex gap-1">
|
||||
{([
|
||||
['all', t('ws.all')],
|
||||
['active', t('ws.active')],
|
||||
['draft', t('ws.draft')],
|
||||
['failed', t('ws.failed')],
|
||||
['destroyed', t('ws.destroyed')],
|
||||
] as [FilterKey, string][]).map(([k, l]) => (
|
||||
<button key={k} onClick={() => setFilter(k)}
|
||||
className="px-2.5 py-1 rounded-full text-[.68rem] font-medium transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: filter === k ? 'var(--pp)' : 'var(--bg3)',
|
||||
color: filter === k ? '#fff' : 'var(--t3)',
|
||||
border: 'none',
|
||||
}}>
|
||||
{l} ({counts[k]})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button onClick={load} disabled={loading}
|
||||
className="btn btn-secondary btn-sm">
|
||||
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
||||
{loading ? t('ws.loading') : t('ws.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* body */}
|
||||
<div className="flex-1 overflow-y-auto p-4" style={{ background: 'var(--bg)' }}>
|
||||
{groups.length === 0 && !loading && (
|
||||
<div className="empty-state">
|
||||
<FolderOpen size={40} />
|
||||
<h3>{t('ws.noWs')}</h3>
|
||||
<p>{t('ws.noWsHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.length === 0 && loading && (
|
||||
<div className="flex items-center justify-center py-16" style={{ color: 'var(--t4)' }}>
|
||||
<RefreshCw size={18} className="animate-spin mr-2" />
|
||||
<span className="text-sm">{t('ws.loadingWs')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map((g) => {
|
||||
const open = !closedGroups.has(g.label);
|
||||
return (
|
||||
<div key={g.label} className="mb-3">
|
||||
{/* group header */}
|
||||
<button onClick={() => toggleGroup(g.label)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg cursor-pointer transition-colors"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)' }}>
|
||||
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''}`}
|
||||
style={{ color: 'var(--t3)' }} />
|
||||
<span className="text-[.78rem] font-semibold" style={{ color: 'var(--t1)' }}>{g.label}</span>
|
||||
<span className="text-[.66rem]" style={{ color: 'var(--t4)' }}>
|
||||
{g.items.length} workspace{g.items.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* group body */}
|
||||
{open && (
|
||||
<div className="mt-1 space-y-1 pl-1">
|
||||
{g.items.map((w) => {
|
||||
const running = RUNNING.includes(w.status);
|
||||
const hasOutput = !!(w.plan_output || w.apply_output || w.destroy_output || w.error);
|
||||
const isExpanded = expanded === w.id;
|
||||
const title = w.session_title || w.name || 'workspace';
|
||||
|
||||
return (
|
||||
<div key={w.id}>
|
||||
{/* row */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors group"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}
|
||||
onClick={() => {
|
||||
if (hasOutput && !running) setExpanded(isExpanded ? null : w.id);
|
||||
}}>
|
||||
<StatusBadge status={w.status} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[.78rem] font-medium truncate" style={{ color: 'var(--t1)' }}
|
||||
title={title}>
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5 text-[.66rem]" style={{ color: 'var(--t4)' }}>
|
||||
<span className="flex items-center gap-1">
|
||||
<Cloud size={10} /> {ociName(w.oci_config_id)}
|
||||
</span>
|
||||
{w.compartment_id && (
|
||||
<span title={w.compartment_id}>...{w.compartment_id.slice(-8)}</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={10} /> {timeAgo(w.updated_at || w.created_at)}
|
||||
</span>
|
||||
{hasOutput && !running && (
|
||||
<span style={{ color: 'var(--pp)', cursor: 'pointer' }}>
|
||||
{isExpanded ? `▾ ${t('ws.hideOutput')}` : `▸ ${t('ws.viewOutput')}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* actions */}
|
||||
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
{running ? (
|
||||
<ActionBtn color="var(--rd)" icon={<Ban size={12} />}
|
||||
label={t('ws.cancel')} onClick={() => doCancel(w.id)} />
|
||||
) : (
|
||||
<>
|
||||
<ActionBtn color="var(--bg3)" textColor="var(--t2)"
|
||||
icon={<ExternalLink size={12} />} label={t('ws.open')}
|
||||
onClick={() => window.open(`/terraform?ws=${w.id}`, '_blank')} />
|
||||
|
||||
{['draft', 'failed', 'destroyed', 'planned', 'applied'].includes(w.status) && (
|
||||
<ActionBtn color="var(--pp)" icon={<Play size={12} />}
|
||||
label={t('ws.plan')} onClick={() => doPlan(w.id)} />
|
||||
)}
|
||||
|
||||
{w.status === 'planned' && (
|
||||
<ActionBtn color="var(--gn)" icon={<Rocket size={12} />}
|
||||
label={t('ws.apply')} onClick={() => doApply(w.id)} />
|
||||
)}
|
||||
|
||||
{(w.status === 'applied' || w.status === 'planned') && (
|
||||
<ActionBtn color="var(--rd)" icon={<Flame size={12} />}
|
||||
label={t('ws.destroy')} onClick={() => { setConfirmDestroy(w.id); setDestroyInput(''); }} />
|
||||
)}
|
||||
|
||||
<ActionBtn color="transparent" textColor="var(--rd)"
|
||||
borderColor="var(--rd)" icon={<X size={12} />}
|
||||
label="" title={t('ws.delete')}
|
||||
onClick={() => setConfirmDelete(w.id)} />
|
||||
|
||||
<a href={terraformApi.downloadUrl(w.id)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[.66rem] font-medium transition-colors cursor-pointer"
|
||||
style={{ background: 'var(--bg3)', color: 'var(--t3)', border: '1px solid var(--bd)' }}
|
||||
title="Download ZIP" download
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<Download size={12} />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* expanded output */}
|
||||
{isExpanded && hasOutput && (
|
||||
<div className="mt-1 ml-4 rounded-lg overflow-hidden"
|
||||
style={{ border: '1px solid var(--bd)', background: 'var(--bg1)' }}>
|
||||
<pre className="p-3 text-[.68rem] overflow-auto max-h-80 whitespace-pre-wrap"
|
||||
style={{ color: w.error ? 'var(--rd)' : 'var(--t2)', fontFamily: 'var(--fm)' }}>
|
||||
{w.error
|
||||
? 'ERROR: ' + w.error
|
||||
: (w.destroy_output || w.apply_output || w.plan_output || '')}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Destroy confirm modal */}
|
||||
{confirmDestroy && (
|
||||
<Modal onClose={() => setConfirmDestroy(null)}>
|
||||
<h3 className="text-sm font-bold mb-2" style={{ color: 'var(--t1)' }}>{t('ws.confirmDestroy')}</h3>
|
||||
<p className="text-[.76rem] mb-3" style={{ color: 'var(--t2)' }}>
|
||||
{t('ws.confirmDestroyMsg')}
|
||||
{' '}
|
||||
<span dangerouslySetInnerHTML={{ __html: t('ws.typeDestroyLabel') }} />
|
||||
</p>
|
||||
<input type="text" value={destroyInput}
|
||||
onChange={(e) => setDestroyInput(e.target.value)}
|
||||
placeholder="DESTROY"
|
||||
className="w-full px-3 py-2 rounded-lg text-sm text-center"
|
||||
style={{
|
||||
background: 'var(--bg2)', color: 'var(--t1)',
|
||||
border: `1px solid ${destroyInput === 'DESTROY' ? 'var(--gn)' : 'var(--rd)'}`,
|
||||
}}
|
||||
autoFocus
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') doDestroy(); }}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 mt-3">
|
||||
<button onClick={() => setConfirmDestroy(null)} className="btn btn-secondary btn-sm">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={doDestroy} disabled={destroyInput !== 'DESTROY'}
|
||||
className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff', borderColor: 'var(--rd)' }}>
|
||||
{t('ws.destroy')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Delete confirm modal */}
|
||||
{confirmDelete && (
|
||||
<Modal onClose={() => setConfirmDelete(null)}>
|
||||
<h3 className="text-sm font-bold mb-2" style={{ color: 'var(--t1)' }}>{t('ws.deleteWs')}</h3>
|
||||
<p className="text-[.76rem] mb-3" style={{ color: 'var(--t2)' }}>
|
||||
{t('ws.deleteWsMsg')}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 mt-3">
|
||||
<button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-sm">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={doDelete} className="btn btn-sm"
|
||||
style={{ background: 'var(--rd)', color: '#fff', borderColor: 'var(--rd)' }}>
|
||||
{t('ws.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Small shared components ── */
|
||||
|
||||
function ActionBtn({
|
||||
color, textColor, borderColor, icon, label, title, onClick,
|
||||
}: {
|
||||
color: string; textColor?: string; borderColor?: string;
|
||||
icon: React.ReactNode; label: string; title?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button onClick={onClick} title={title || label}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[.66rem] font-medium transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: color,
|
||||
color: textColor || '#fff',
|
||||
border: `1px solid ${borderColor || color}`,
|
||||
}}>
|
||||
{icon}
|
||||
{label && <span>{label}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ background: 'rgba(0,0,0,.55)' }} onClick={onClose}>
|
||||
<div className="w-full max-w-sm rounded-xl p-5 animate-[fadeIn_.2s_ease]"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)', boxShadow: 'var(--sh3)' }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
536
frontend/src/pages/admin/AuditPage.tsx
Normal file
536
frontend/src/pages/admin/AuditPage.tsx
Normal file
@@ -0,0 +1,536 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
ScrollText,
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
adminApi,
|
||||
type AuditEntry,
|
||||
type ConfigLogEntry,
|
||||
type ChatLogEntry,
|
||||
} from '@/api/endpoints/admin';
|
||||
import { usePolling } from '@/hooks/usePolling';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
/* ── Helpers ── */
|
||||
|
||||
function formatDate(iso: string) {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z');
|
||||
return d.toLocaleString('pt-BR', {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: string }) {
|
||||
const map: Record<string, { bg: string; fg: string }> = {
|
||||
error: { bg: 'var(--rdl)', fg: 'var(--rd)' },
|
||||
success: { bg: 'var(--gnl)', fg: 'var(--gn)' },
|
||||
info: { bg: 'var(--bll)', fg: 'var(--bl)' },
|
||||
warning: { bg: 'var(--yll)', fg: 'var(--yl)' },
|
||||
};
|
||||
const s = map[severity] ?? { bg: 'var(--bg3)', fg: 'var(--t3)' };
|
||||
return (
|
||||
<span
|
||||
className="inline-block px-2 py-0.5 rounded-full text-[0.68rem] font-semibold"
|
||||
style={{ background: s.bg, color: s.fg }}
|
||||
>
|
||||
{severity}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionTag({ action }: { action: string }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-block px-2 py-0.5 rounded-md text-[0.7rem] font-medium"
|
||||
style={{ background: 'var(--acl)', color: 'var(--ac)', border: '1px solid var(--acl2)' }}
|
||||
>
|
||||
{action}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center py-12"
|
||||
style={{ color: 'var(--t4)' }}
|
||||
>
|
||||
<FileText size={32} className="mb-3 opacity-40" />
|
||||
<p className="text-sm">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-3 rounded-xl text-sm"
|
||||
style={{ background: 'var(--rdl)', color: 'var(--rd)', border: '1px solid var(--rd)' }}
|
||||
>
|
||||
<AlertCircle size={16} />
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingRow({ cols, text }: { cols: number; text: string }) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={cols} className="text-center py-10">
|
||||
<Loader2 size={22} className="inline animate-spin" style={{ color: 'var(--ac)' }} />
|
||||
<span className="ml-2 text-sm" style={{ color: 'var(--t4)' }}>{text}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Shared table wrapper ── */
|
||||
|
||||
const tableContainerStyle: React.CSSProperties = {
|
||||
background: 'var(--bg1)',
|
||||
border: '1px solid var(--bd)',
|
||||
borderRadius: 'var(--r)',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
padding: '0.6rem 0.75rem',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
color: 'var(--t3)',
|
||||
background: 'var(--bg2)',
|
||||
borderBottom: '1px solid var(--bd)',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: '0.55rem 0.75rem',
|
||||
fontSize: '0.76rem',
|
||||
borderBottom: '1px solid var(--bg3)',
|
||||
color: 'var(--t2)',
|
||||
};
|
||||
|
||||
const tdDateStyle: React.CSSProperties = {
|
||||
...tdStyle,
|
||||
fontSize: '0.68rem',
|
||||
color: 'var(--t4)',
|
||||
whiteSpace: 'nowrap',
|
||||
fontFamily: 'var(--fm)',
|
||||
};
|
||||
|
||||
const tdMonoStyle: React.CSSProperties = {
|
||||
...tdStyle,
|
||||
fontSize: '0.68rem',
|
||||
color: 'var(--t4)',
|
||||
fontFamily: 'var(--fm)',
|
||||
};
|
||||
|
||||
/* ── Toolbar ── */
|
||||
|
||||
interface ToolbarProps {
|
||||
limit: number;
|
||||
onLimitChange: (n: number) => void;
|
||||
severity?: string;
|
||||
onSeverityChange?: (s: string) => void;
|
||||
onRefresh: () => void;
|
||||
loading: boolean;
|
||||
showSeverity?: boolean;
|
||||
}
|
||||
|
||||
function Toolbar({
|
||||
limit,
|
||||
onLimitChange,
|
||||
severity,
|
||||
onSeverityChange,
|
||||
onRefresh,
|
||||
loading,
|
||||
showSeverity = false,
|
||||
}: ToolbarProps) {
|
||||
const { t } = useI18n();
|
||||
const selectStyle: React.CSSProperties = {
|
||||
fontSize: '0.72rem',
|
||||
padding: '0.3rem 0.5rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--bd)',
|
||||
background: 'var(--bg2)',
|
||||
color: 'var(--t2)',
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{showSeverity && onSeverityChange && (
|
||||
<select
|
||||
value={severity ?? ''}
|
||||
onChange={(e) => onSeverityChange(e.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">{t('audit.all')}</option>
|
||||
<option value="error">{t('audit.errors')}</option>
|
||||
<option value="success">{t('audit.success')}</option>
|
||||
<option value="info">{t('audit.info')}</option>
|
||||
</select>
|
||||
)}
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(e) => onLimitChange(Number(e.target.value))}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value={50}>50 {t('audit.records')}</option>
|
||||
<option value={100}>100 {t('audit.records')}</option>
|
||||
<option value={200}>200 {t('audit.records')}</option>
|
||||
<option value={500}>500 {t('audit.records')}</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
||||
{t('audit.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Tab definitions ── */
|
||||
|
||||
type TabId = 'audit' | 'config' | 'chat';
|
||||
|
||||
/* ── Audit Log Table ── */
|
||||
|
||||
function AuditLogTable() {
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<AuditEntry[]>([]);
|
||||
const [limit, setLimit] = useState(100);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const rows = await adminApi.getAuditLog(limit);
|
||||
setData(rows);
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Erro ao carregar audit log');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [limit]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
usePolling(async () => { await load(); }, 30_000, true);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>
|
||||
{data.length > 0 && `${data.length} ${t('audit.records')}`}
|
||||
</p>
|
||||
<Toolbar limit={limit} onLimitChange={setLimit} onRefresh={load} loading={loading} />
|
||||
</div>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<div className="overflow-x-auto" style={{ borderRadius: 'var(--r)', border: '1px solid var(--bd)' }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('audit.date')}</th>
|
||||
<th>{t('audit.user')}</th>
|
||||
<th>{t('audit.action')}</th>
|
||||
<th>{t('audit.resource')}</th>
|
||||
<th>{t('audit.details')}</th>
|
||||
<th>{t('audit.ip')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && data.length === 0 ? (
|
||||
<LoadingRow cols={6} text={t('audit.loadingLogs')} />
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6}><EmptyState text={t('audit.noAudit')} /></td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td style={{ whiteSpace: 'nowrap', fontFamily: 'var(--fm)', fontSize: '.72rem', color: 'var(--t4)' }}>{formatDate(row.created_at)}</td>
|
||||
<td>{row.username || '—'}</td>
|
||||
<td><ActionTag action={row.action} /></td>
|
||||
<td style={{ fontFamily: 'var(--fm)', fontSize: '.72rem', color: 'var(--t4)' }} title={row.resource ?? ''}>
|
||||
{row.resource ? (row.resource.length > 20 ? row.resource.slice(0, 20) + '...' : row.resource) : '—'}
|
||||
</td>
|
||||
<td
|
||||
style={{ fontSize: '0.72rem', maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
title={row.details ?? ''}
|
||||
>
|
||||
{row.details?.slice(0, 120) || '—'}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.72rem' }}>{row.ip || '—'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Config Logs Table ── */
|
||||
|
||||
function ConfigLogsTable() {
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<ConfigLogEntry[]>([]);
|
||||
const [limit, setLimit] = useState(50);
|
||||
const [severity, setSeverity] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const rows = await adminApi.getConfigLogs({
|
||||
severity: severity || undefined,
|
||||
limit,
|
||||
});
|
||||
setData(rows);
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Erro ao carregar config logs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [limit, severity]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
usePolling(async () => { await load(); }, 30_000, true);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>
|
||||
{data.length > 0 && `${data.length} ${t('audit.records')}`}
|
||||
</p>
|
||||
<Toolbar
|
||||
limit={limit}
|
||||
onLimitChange={setLimit}
|
||||
severity={severity}
|
||||
onSeverityChange={setSeverity}
|
||||
onRefresh={load}
|
||||
loading={loading}
|
||||
showSeverity
|
||||
/>
|
||||
</div>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<div className="overflow-x-auto" style={{ borderRadius: 'var(--r)', border: '1px solid var(--bd)' }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('audit.date')}</th>
|
||||
<th>{t('audit.config')}</th>
|
||||
<th>{t('audit.action')}</th>
|
||||
<th>{t('audit.status')}</th>
|
||||
<th>{t('audit.message')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && data.length === 0 ? (
|
||||
<LoadingRow cols={5} text={t('audit.loadingLogs')} />
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5}><EmptyState text={t('audit.noConfig')} /></td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td style={{ whiteSpace: 'nowrap', fontFamily: 'var(--fm)', fontSize: '.72rem', color: 'var(--t4)' }}>{formatDate(row.created_at)}</td>
|
||||
<td style={{ fontSize: '0.72rem' }}>
|
||||
{row.config_name || row.config_id?.slice(0, 8) || '—'}
|
||||
</td>
|
||||
<td><ActionTag action={row.action} /></td>
|
||||
<td><SeverityBadge severity={row.severity} /></td>
|
||||
<td
|
||||
style={{ fontSize: '0.72rem', maxWidth: '400px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
title={row.message || ''}
|
||||
>
|
||||
{row.message?.slice(0, 150) || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Chat Logs Table ── */
|
||||
|
||||
function ChatLogsTable() {
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<ChatLogEntry[]>([]);
|
||||
const [limit, setLimit] = useState(50);
|
||||
const [severity, setSeverity] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const rows = await adminApi.getChatLogs({
|
||||
severity: severity || undefined,
|
||||
limit,
|
||||
});
|
||||
setData(rows);
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Erro ao carregar chat logs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [limit, severity]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
usePolling(async () => { await load(); }, 30_000, true);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>
|
||||
{data.length > 0 && `${data.length} ${t('audit.records')}`}
|
||||
</p>
|
||||
<Toolbar
|
||||
limit={limit}
|
||||
onLimitChange={setLimit}
|
||||
severity={severity}
|
||||
onSeverityChange={setSeverity}
|
||||
onRefresh={load}
|
||||
loading={loading}
|
||||
showSeverity
|
||||
/>
|
||||
</div>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<div className="overflow-x-auto" style={{ borderRadius: 'var(--r)', border: '1px solid var(--bd)' }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('audit.date')}</th>
|
||||
<th>{t('audit.session')}</th>
|
||||
<th>{t('audit.source')}</th>
|
||||
<th>{t('audit.action')}</th>
|
||||
<th>{t('audit.status')}</th>
|
||||
<th>{t('audit.message')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && data.length === 0 ? (
|
||||
<LoadingRow cols={6} text={t('audit.loadingLogs')} />
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6}><EmptyState text={t('audit.noChat')} /></td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td style={{ whiteSpace: 'nowrap', fontFamily: 'var(--fm)', fontSize: '.72rem', color: 'var(--t4)' }}>{formatDate(row.created_at)}</td>
|
||||
<td style={{ fontFamily: 'var(--fm)', fontSize: '.72rem', color: 'var(--t4)' }} title={row.session_id ?? ''}>
|
||||
{row.session_id ? row.session_id.slice(0, 8) + '...' : '—'}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.72rem' }}>{row.source || '—'}</td>
|
||||
<td><ActionTag action={row.action} /></td>
|
||||
<td><SeverityBadge severity={row.severity} /></td>
|
||||
<td
|
||||
style={{ fontSize: '0.72rem', maxWidth: '400px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
title={row.message || ''}
|
||||
>
|
||||
{row.message?.slice(0, 150) || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main Page ── */
|
||||
|
||||
export default function AuditPage() {
|
||||
const { t } = useI18n();
|
||||
const [activeTab, setActiveTab] = useState<TabId>('audit');
|
||||
|
||||
const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'audit', label: t('audit.tabAudit'), icon: <FileText size={15} /> },
|
||||
{ id: 'config', label: t('audit.tabConfig'), icon: <ScrollText size={15} /> },
|
||||
{ id: 'chat', label: t('audit.tabChat'), icon: <MessageSquare size={15} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="page" style={{ overflow: 'auto', height: '100%' }}>
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="card-header icon" style={{ background: 'var(--acl)' }}>
|
||||
<FileText size={20} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('audit.title')}</h1>
|
||||
<div className="subtitle">
|
||||
{t('audit.subtitle')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card with tabs + content */}
|
||||
<div className="card">
|
||||
{/* Tab bar */}
|
||||
<div
|
||||
className="flex gap-1 p-1 rounded-xl mb-4"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className="btn btn-sm"
|
||||
style={{
|
||||
background: activeTab === tab.id ? 'var(--bg1)' : 'transparent',
|
||||
color: activeTab === tab.id ? 'var(--t1)' : 'var(--t3)',
|
||||
border: activeTab === tab.id ? '1px solid var(--bd)' : '1px solid transparent',
|
||||
boxShadow: activeTab === tab.id ? 'var(--sh1)' : 'none',
|
||||
}}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div style={{ animation: 'fadeIn .3s ease-out' }}>
|
||||
{activeTab === 'audit' && <AuditLogTable />}
|
||||
{activeTab === 'config' && <ConfigLogsTable />}
|
||||
{activeTab === 'chat' && <ChatLogsTable />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
356
frontend/src/pages/admin/MySettingsPage.tsx
Normal file
356
frontend/src/pages/admin/MySettingsPage.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Settings, Clock, ShieldCheck, ShieldOff, KeyRound, Lock, Loader2,
|
||||
CheckCircle, AlertTriangle, Eye, EyeOff, Save, Languages,
|
||||
} from 'lucide-react';
|
||||
import client from '@/api/client';
|
||||
import { adminApi } from '@/api/endpoints/admin';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
export default function MySettingsPage() {
|
||||
const { t, lang, setLang } = useI18n();
|
||||
const { user: currentUser, checkAuth } = useAuthStore();
|
||||
const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
||||
|
||||
const isLocal = !currentUser?.auth_provider || currentUser.auth_provider === 'local';
|
||||
|
||||
// Timezone
|
||||
const [timezone, setTimezone] = useState('');
|
||||
const [tzOptions, setTzOptions] = useState<string[]>([]);
|
||||
const [tzSaving, setTzSaving] = useState(false);
|
||||
|
||||
// Password
|
||||
const [currentPw, setCurrentPw] = useState('');
|
||||
const [newPw, setNewPw] = useState('');
|
||||
const [confirmPw, setConfirmPw] = useState('');
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [pwSaving, setPwSaving] = useState(false);
|
||||
|
||||
// MFA
|
||||
const [mfaSecret, setMfaSecret] = useState<string | null>(null);
|
||||
const [mfaTotpUri, setMfaTotpUri] = useState<string | null>(null);
|
||||
const [mfaCode, setMfaCode] = useState('');
|
||||
const [mfaLoading, setMfaLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.getMyTimezone().then((d) => setTimezone(d.timezone)).catch(() => {});
|
||||
adminApi.getTimezoneOptions().then((d) => setTzOptions(d.timezones)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (msg) { const timer = setTimeout(() => setMsg(null), 4000); return () => clearTimeout(timer); }
|
||||
}, [msg]);
|
||||
|
||||
/* ── Timezone ── */
|
||||
const handleTzChange = async (tz: string) => {
|
||||
setTzSaving(true);
|
||||
try {
|
||||
await adminApi.setMyTimezone(tz);
|
||||
setTimezone(tz);
|
||||
setMsg({ text: t('settings.tzChanged') || `Timezone alterado para ${tz}`, type: 'success' });
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Erro ao alterar timezone', type: 'error' });
|
||||
} finally {
|
||||
setTzSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Password ── */
|
||||
const handleChangePw = async () => {
|
||||
if (!currentPw || !newPw) { setMsg({ text: t('settings.pwRequired') || 'Preencha todos os campos', type: 'error' }); return; }
|
||||
if (newPw !== confirmPw) { setMsg({ text: t('settings.pwMismatch') || 'As senhas não conferem', type: 'error' }); return; }
|
||||
if (newPw.length < 8) { setMsg({ text: t('settings.pwMinLength') || 'A nova senha deve ter pelo menos 8 caracteres', type: 'error' }); return; }
|
||||
setPwSaving(true);
|
||||
try {
|
||||
await client.post('/auth/change-password', { current_password: currentPw, new_password: newPw });
|
||||
setMsg({ text: t('settings.pwChanged') || 'Senha alterada com sucesso', type: 'success' });
|
||||
setCurrentPw(''); setNewPw(''); setConfirmPw('');
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Erro ao alterar senha', type: 'error' });
|
||||
} finally {
|
||||
setPwSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── MFA ── */
|
||||
const handleMfaSetup = async () => {
|
||||
setMfaLoading(true);
|
||||
try {
|
||||
const data = await client.post('/mfa/setup') as unknown as { secret: string; uri: string };
|
||||
setMfaSecret(data.secret);
|
||||
setMfaTotpUri(data.uri);
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Erro', type: 'error' });
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMfaVerify = async () => {
|
||||
if (mfaCode.length !== 6) { setMsg({ text: t('mfa.invalidCode'), type: 'error' }); return; }
|
||||
setMfaLoading(true);
|
||||
try {
|
||||
await client.post('/mfa/verify', { totp_code: mfaCode });
|
||||
setMsg({ text: t('mfa.activated'), type: 'success' });
|
||||
setMfaSecret(null); setMfaTotpUri(null); setMfaCode('');
|
||||
await checkAuth();
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Codigo invalido', type: 'error' });
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMfaDisable = async () => {
|
||||
if (!currentUser) return;
|
||||
if (!window.confirm(t('mfa.confirmDisable'))) return;
|
||||
setMfaLoading(true);
|
||||
try {
|
||||
await client.post(`/mfa/disable/${currentUser.id}`);
|
||||
setMsg({ text: t('mfa.deactivated'), type: 'success' });
|
||||
setMfaSecret(null); setMfaTotpUri(null); setMfaCode('');
|
||||
await checkAuth();
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Erro', type: 'error' });
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const mfaOn = !!currentUser?.mfa_enabled;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
{msg && (
|
||||
<div className="fixed top-4 right-4 z-50 flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium shadow-lg"
|
||||
style={{
|
||||
background: msg.type === 'success' ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: msg.type === 'success' ? 'var(--gn)' : 'var(--rd)',
|
||||
border: `1px solid color-mix(in srgb, ${msg.type === 'success' ? 'var(--gn)' : 'var(--rd)'} 30%, transparent)`,
|
||||
animation: 'fadeIn .3s ease',
|
||||
}}>
|
||||
{msg.type === 'success' ? <CheckCircle size={16} /> : <AlertTriangle size={16} />}
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<Settings size={24} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('settings.title') || 'Minhas Configurações'}</h1>
|
||||
<div className="subtitle">
|
||||
{currentUser?.first_name} {currentUser?.last_name} — {currentUser?.username}
|
||||
{!isLocal && (
|
||||
<span className="ml-2 px-1.5 py-0.5 rounded text-[.55rem] font-bold" style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>
|
||||
ORACLE IAM
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timezone */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<Clock size={16} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<h2>{t('settings.timezone') || 'Timezone'}</h2>
|
||||
</div>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--t3)' }}>
|
||||
{t('settings.tzDesc') || 'Define o fuso horário para exibição de datas e horários.'}
|
||||
</p>
|
||||
<select
|
||||
value={timezone}
|
||||
onChange={(e) => handleTzChange(e.target.value)}
|
||||
disabled={tzSaving}
|
||||
className="px-3 py-2 rounded-lg text-sm outline-none cursor-pointer"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 280 }}
|
||||
>
|
||||
{tzOptions.map((tz) => (
|
||||
<option key={tz} value={tz}>{tz}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Language */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<Languages size={16} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<h2>{t('settings.language') || 'Idioma'}</h2>
|
||||
</div>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--t3)' }}>
|
||||
{t('settings.langDesc') || 'Selecione o idioma da interface.'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{([['pt', 'Português', '🇧🇷'], ['en', 'English', '🇺🇸'], ['es', 'Español', '🇪🇸']] as const).map(([code, label, flag]) => (
|
||||
<button key={code} onClick={() => setLang(code as any)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-all"
|
||||
style={{
|
||||
background: lang === code ? 'var(--acl)' : 'var(--bg2)',
|
||||
border: `1.5px solid ${lang === code ? 'var(--ac)' : 'var(--bd)'}`,
|
||||
color: lang === code ? 'var(--ac)' : 'var(--t3)',
|
||||
}}>
|
||||
<span className="text-base">{flag}</span>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Change — only for local users */}
|
||||
{isLocal && (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--bll)' }}>
|
||||
<Lock size={16} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
<h2>{t('settings.changePassword') || 'Alterar Senha'}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3" style={{ maxWidth: 400 }}>
|
||||
<div className="form-group">
|
||||
<label>{t('settings.currentPw') || 'Senha Atual'}</label>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
type={showPw ? 'text' : 'password'}
|
||||
value={currentPw}
|
||||
onChange={e => setCurrentPw(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="flex-1"
|
||||
style={{ fontFamily: 'var(--fm)' }}
|
||||
/>
|
||||
<button onClick={() => setShowPw(!showPw)} className="btn btn-secondary btn-sm" style={{ padding: '0 8px' }}>
|
||||
{showPw ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('settings.newPw') || 'Nova Senha'}</label>
|
||||
<input
|
||||
type={showPw ? 'text' : 'password'}
|
||||
value={newPw}
|
||||
onChange={e => setNewPw(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
style={{ fontFamily: 'var(--fm)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('settings.confirmPw') || 'Confirmar Nova Senha'}</label>
|
||||
<input
|
||||
type={showPw ? 'text' : 'password'}
|
||||
value={confirmPw}
|
||||
onChange={e => setConfirmPw(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
style={{ fontFamily: 'var(--fm)' }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleChangePw(); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button onClick={handleChangePw} disabled={pwSaving || !currentPw || !newPw || !confirmPw}
|
||||
className="btn btn-primary self-start disabled:opacity-50">
|
||||
{pwSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
{pwSaving ? (t('settings.saving') || 'Salvando...') : (t('settings.savePw') || 'Alterar Senha')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MFA — only for local users */}
|
||||
{isLocal && (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: mfaOn ? 'var(--gnl)' : 'var(--bg3)' }}>
|
||||
{mfaOn ? <ShieldCheck size={16} style={{ color: 'var(--gn)' }} /> : <Lock size={16} style={{ color: 'var(--t4)' }} />}
|
||||
</div>
|
||||
<h2>{t('settings.mfa') || 'Autenticação Multi-Fator (MFA)'}</h2>
|
||||
<div className="spacer" />
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[.7rem] font-semibold"
|
||||
style={{ background: mfaOn ? 'var(--gnl)' : 'var(--bg3)', color: mfaOn ? 'var(--gn)' : 'var(--t4)' }}>
|
||||
{mfaOn ? t('usr.active') || 'Ativo' : t('usr.inactive') || 'Inativo'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{mfaOn ? (
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<div className="w-16 h-16 rounded-full flex items-center justify-center" style={{ background: 'color-mix(in srgb, var(--gn) 10%, transparent)' }}>
|
||||
<ShieldCheck size={32} style={{ color: 'var(--gn)' }} />
|
||||
</div>
|
||||
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>{t('mfa.enabledDesc')}</p>
|
||||
<button onClick={handleMfaDisable} disabled={mfaLoading}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer"
|
||||
style={{ background: 'var(--rdl)', color: 'var(--rd)', border: '1px solid color-mix(in srgb, var(--rd) 25%, transparent)' }}>
|
||||
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <ShieldOff size={14} />}
|
||||
{t('mfa.disableBtn')}
|
||||
</button>
|
||||
</div>
|
||||
) : mfaSecret && mfaTotpUri ? (
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div className="flex justify-center">
|
||||
<div className="p-3 rounded-xl" style={{ background: '#fff' }}>
|
||||
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(mfaTotpUri)}`}
|
||||
alt="QR Code" width={180} height={180} className="rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2 rounded-lg text-center" style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}>
|
||||
<span className="text-[.6rem] block mb-1" style={{ color: 'var(--t4)' }}>SECRET</span>
|
||||
<code className="text-xs font-bold tracking-wider select-all" style={{ color: 'var(--ac)', fontFamily: 'var(--fm)' }}>{mfaSecret}</code>
|
||||
</div>
|
||||
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>
|
||||
{t('mfa.scanQr') || 'Escaneie o QR code com seu app autenticador e insira o código de 6 dígitos.'}
|
||||
</p>
|
||||
<div className="flex gap-2 justify-center">
|
||||
<input type="text" value={mfaCode} onChange={e => setMfaCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000" maxLength={6} autoFocus
|
||||
className="px-3 py-2 rounded-lg text-sm text-center tracking-[.3em] font-semibold outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', fontFamily: 'var(--fm)', width: 160 }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleMfaVerify(); }} />
|
||||
<button onClick={handleMfaVerify} disabled={mfaLoading || mfaCode.length !== 6}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer disabled:opacity-50"
|
||||
style={{ background: 'var(--gn)', color: '#fff' }}>
|
||||
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <KeyRound size={14} />}
|
||||
{t('mfa.activateMfa')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<div className="w-16 h-16 rounded-full flex items-center justify-center" style={{ background: 'color-mix(in srgb, var(--yl) 10%, transparent)' }}>
|
||||
<KeyRound size={32} style={{ color: 'var(--yl)' }} />
|
||||
</div>
|
||||
<p className="text-xs text-center max-w-sm" style={{ color: 'var(--t3)' }}>
|
||||
{t('mfa.generateHint')}
|
||||
</p>
|
||||
<button onClick={handleMfaSetup} disabled={mfaLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer"
|
||||
style={{ background: 'var(--ac)', color: '#fff' }}>
|
||||
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <KeyRound size={14} />}
|
||||
{t('mfa.generateSecret')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info for federated users */}
|
||||
{!isLocal && (
|
||||
<div className="card" style={{ opacity: 0.7 }}>
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<ShieldCheck size={18} style={{ color: 'var(--t3)' }} />
|
||||
<p className="text-xs" style={{ color: 'var(--t3)' }}>
|
||||
{t('settings.federatedInfo') || 'Senha e MFA são gerenciados pelo Oracle IAM Identity Domain. Configure diretamente no portal OCI.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
266
frontend/src/pages/admin/OracleIamPage.tsx
Normal file
266
frontend/src/pages/admin/OracleIamPage.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Shield, Globe, Key, Users, FlaskConical, Loader2, CheckCircle, AlertTriangle, Save, Eye, EyeOff } from 'lucide-react';
|
||||
import client from '@/api/client';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
interface IamConfig {
|
||||
auth_mode: string;
|
||||
oidc_issuer: string;
|
||||
oidc_client_id: string;
|
||||
oidc_client_secret: string;
|
||||
oidc_redirect_uri: string;
|
||||
oidc_group_admin: string;
|
||||
oidc_group_user: string;
|
||||
oidc_group_viewer: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: IamConfig = {
|
||||
auth_mode: 'local',
|
||||
oidc_issuer: '',
|
||||
oidc_client_id: '',
|
||||
oidc_client_secret: '',
|
||||
oidc_redirect_uri: '',
|
||||
oidc_group_admin: 'CISAgent_Admins',
|
||||
oidc_group_user: 'CISAgent_Users',
|
||||
oidc_group_viewer: 'CISAgent_Viewers',
|
||||
};
|
||||
|
||||
const SETTINGS_KEYS = [
|
||||
'auth_mode', 'oidc_issuer', 'oidc_client_id', 'oidc_client_secret',
|
||||
'oidc_redirect_uri', 'oidc_group_admin', 'oidc_group_user', 'oidc_group_viewer',
|
||||
];
|
||||
|
||||
export default function OracleIamPage() {
|
||||
const { t } = useI18n();
|
||||
const [config, setConfig] = useState<IamConfig>(DEFAULT_CONFIG);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
SETTINGS_KEYS.map(k => client.get(`/settings/${k}`) as unknown as { key: string; value: string })
|
||||
);
|
||||
const loaded: any = { ...DEFAULT_CONFIG };
|
||||
results.forEach(r => { if (r.value) loaded[r.key] = r.value; });
|
||||
setConfig(loaded);
|
||||
} catch {
|
||||
// Settings not found — use defaults
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (msg) { const t = setTimeout(() => setMsg(null), 5000); return () => clearTimeout(t); }
|
||||
}, [msg]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await Promise.all(
|
||||
SETTINGS_KEYS.filter(k => !(k === 'oidc_client_secret' && (config as any)[k]?.includes('\u2022')))
|
||||
.map(k => client.put(`/settings/${k}`, { value: (config as any)[k] || '' }))
|
||||
);
|
||||
setMsg({ text: t('iam.saved') || 'Configuração salva com sucesso', type: 'success' });
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Erro ao salvar', type: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!config.oidc_issuer) {
|
||||
setMsg({ text: t('iam.issuerRequired') || 'Issuer URL é obrigatório', type: 'error' });
|
||||
return;
|
||||
}
|
||||
setTesting(true);
|
||||
try {
|
||||
const resp = await client.post('/settings/oidc/test') as unknown as {
|
||||
ok: boolean; issuer: string;
|
||||
authorization_endpoint: boolean; token_endpoint: boolean; jwks_uri: boolean;
|
||||
};
|
||||
if (resp.ok) {
|
||||
setMsg({ text: `${t('iam.testOk') || 'Conexão OK'} — ${resp.issuer}`, type: 'success' });
|
||||
} else {
|
||||
setMsg({ text: t('iam.testInvalid') || 'Resposta inválida do discovery endpoint', type: 'error' });
|
||||
}
|
||||
} catch (err) {
|
||||
setMsg({ text: `${t('iam.testFail') || 'Falha na conexão'}: ${err instanceof Error ? err.message : 'Erro'}`, type: 'error' });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const update = (key: keyof IamConfig, value: string) => setConfig(prev => ({ ...prev, [key]: value }));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 size={28} className="animate-spin" style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
{msg && (
|
||||
<div className="fixed top-4 right-4 z-50 flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium shadow-lg"
|
||||
style={{
|
||||
background: msg.type === 'success' ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: msg.type === 'success' ? 'var(--gn)' : 'var(--rd)',
|
||||
border: `1px solid color-mix(in srgb, ${msg.type === 'success' ? 'var(--gn)' : 'var(--rd)'} 30%, transparent)`,
|
||||
animation: 'fadeIn .3s ease',
|
||||
}}>
|
||||
{msg.type === 'success' ? <CheckCircle size={16} /> : <AlertTriangle size={16} />}
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--rdl)' }}>
|
||||
<Shield size={24} style={{ color: 'var(--rd)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('iam.title') || 'Oracle IAM Identity Domains'}</h1>
|
||||
<div className="subtitle">{t('iam.subtitle') || 'Integração OIDC para autenticação corporativa'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth Mode */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<Key size={16} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<h2>{t('iam.authMode') || 'Modo de Autenticação'}</h2>
|
||||
</div>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--t3)' }}>
|
||||
{t('iam.authModeDesc') || 'Define como os usuários se autenticam no sistema.'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{(['local', 'oidc', 'both'] as const).map(mode => {
|
||||
const selected = config.auth_mode === mode;
|
||||
const labels: Record<string, { label: string; desc: string }> = {
|
||||
local: { label: t('iam.modeLocal') || 'Local', desc: t('iam.modeLocalDesc') || 'Usuários locais com senha' },
|
||||
oidc: { label: t('iam.modeOidc') || 'Oracle IAM', desc: t('iam.modeOidcDesc') || 'Somente SSO corporativo' },
|
||||
both: { label: t('iam.modeBoth') || 'Ambos', desc: t('iam.modeBothDesc') || 'Local + Oracle IAM' },
|
||||
};
|
||||
return (
|
||||
<button key={mode} onClick={() => update('auth_mode', mode)}
|
||||
className="flex-1 flex flex-col items-center gap-1 py-3 rounded-lg text-xs font-semibold transition-all"
|
||||
style={{
|
||||
background: selected ? 'var(--acl)' : 'var(--bg2)',
|
||||
border: `1.5px solid ${selected ? 'var(--ac)' : 'var(--bd)'}`,
|
||||
color: selected ? 'var(--ac)' : 'var(--t3)',
|
||||
}}>
|
||||
<span className="text-sm">{labels[mode].label}</span>
|
||||
<span className="text-[.6rem] font-normal" style={{ color: selected ? 'var(--ac)' : 'var(--t4)' }}>{labels[mode].desc}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OIDC Configuration */}
|
||||
{config.auth_mode !== 'local' && (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--rdl)' }}>
|
||||
<Globe size={16} style={{ color: 'var(--rd)' }} />
|
||||
</div>
|
||||
<h2>{t('iam.oidcConfig') || 'Configuração OIDC'}</h2>
|
||||
<div className="spacer" />
|
||||
<button onClick={handleTest} disabled={testing} className="btn btn-secondary btn-sm">
|
||||
{testing ? <Loader2 size={12} className="animate-spin" /> : <FlaskConical size={12} />}
|
||||
{t('iam.testConnection') || 'Testar Conexão'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-row" style={{ marginBottom: 12 }}>
|
||||
<div className="form-group" style={{ flex: 2 }}>
|
||||
<label>{t('iam.issuerUrl') || 'Issuer URL (Identity Domain)'}</label>
|
||||
<input type="text" value={config.oidc_issuer} onChange={e => update('oidc_issuer', e.target.value)}
|
||||
placeholder="https://idcs-xxx.identity.oraclecloud.com" style={{ fontFamily: 'var(--fm)' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row" style={{ marginBottom: 12 }}>
|
||||
<div className="form-group">
|
||||
<label>{t('iam.clientId') || 'Client ID'}</label>
|
||||
<input type="text" value={config.oidc_client_id} onChange={e => update('oidc_client_id', e.target.value)}
|
||||
placeholder="ea58b04047fd4d8182a8bad4e3d0aff5" style={{ fontFamily: 'var(--fm)' }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('iam.clientSecret') || 'Client Secret'}</label>
|
||||
<div className="flex gap-1">
|
||||
<input type={showSecret ? 'text' : 'password'} value={config.oidc_client_secret}
|
||||
onChange={e => update('oidc_client_secret', e.target.value)}
|
||||
placeholder="••••••••" className="flex-1" style={{ fontFamily: 'var(--fm)' }} />
|
||||
<button onClick={() => setShowSecret(!showSecret)} className="btn btn-secondary btn-sm" style={{ padding: '0 8px' }}>
|
||||
{showSecret ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>{t('iam.redirectUri') || 'Redirect URI'}</label>
|
||||
<input type="text" value={config.oidc_redirect_uri} onChange={e => update('oidc_redirect_uri', e.target.value)}
|
||||
placeholder="https://yourapp.com/api/auth/oidc/callback" style={{ fontFamily: 'var(--fm)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group Mapping */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--bll)' }}>
|
||||
<Users size={16} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
<h2>{t('iam.groupMapping') || 'Mapeamento de Grupos'}</h2>
|
||||
</div>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--t3)' }}>
|
||||
{t('iam.groupMappingDesc') || 'Mapeie os grupos do OCI Identity Domain para as roles do sistema.'}
|
||||
</p>
|
||||
|
||||
<div className="form-row" style={{ marginBottom: 12 }}>
|
||||
<div className="form-group">
|
||||
<label style={{ color: 'var(--rd)' }}>Admin</label>
|
||||
<input type="text" value={config.oidc_group_admin} onChange={e => update('oidc_group_admin', e.target.value)}
|
||||
placeholder="CISAgent_Admins" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ color: 'var(--bl)' }}>User</label>
|
||||
<input type="text" value={config.oidc_group_user} onChange={e => update('oidc_group_user', e.target.value)}
|
||||
placeholder="CISAgent_Users" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ color: 'var(--t3)' }}>Viewer</label>
|
||||
<input type="text" value={config.oidc_group_viewer} onChange={e => update('oidc_group_viewer', e.target.value)}
|
||||
placeholder="CISAgent_Viewers" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Save */}
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<button onClick={handleSave} disabled={saving} className="btn btn-primary">
|
||||
{saving ? <Loader2 size={15} className="animate-spin" /> : <Save size={15} />}
|
||||
{saving ? (t('iam.saving') || 'Salvando...') : (t('iam.save') || 'Salvar Configuração')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
644
frontend/src/pages/admin/UsersPage.tsx
Normal file
644
frontend/src/pages/admin/UsersPage.tsx
Normal file
@@ -0,0 +1,644 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Users, Plus, Shield, Eye, User as UserIcon, Trash2, X, AlertTriangle, CheckCircle, Loader2, UserPlus, Lock, ShieldCheck, ShieldOff, KeyRound } from 'lucide-react';
|
||||
import client from '@/api/client';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
/* ── Types ── */
|
||||
interface AppUser {
|
||||
id: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
username: string;
|
||||
email: string | null;
|
||||
role: 'admin' | 'user' | 'viewer';
|
||||
mfa_enabled: boolean | number;
|
||||
is_active: boolean | number;
|
||||
created_at: string | null;
|
||||
last_login: string | null;
|
||||
auth_provider?: string;
|
||||
}
|
||||
|
||||
type Role = 'admin' | 'user' | 'viewer';
|
||||
|
||||
const ROLE_CONFIG: Record<Role, { label: string; color: string; bg: string; icon: typeof Shield }> = {
|
||||
admin: { label: 'Admin', color: 'var(--rd)', bg: 'var(--rdl)', icon: Shield },
|
||||
user: { label: 'User', color: 'var(--bl)', bg: 'var(--bll)', icon: UserIcon },
|
||||
viewer: { label: 'Viewer', color: 'var(--t3)', bg: 'var(--bg3)', icon: Eye },
|
||||
};
|
||||
|
||||
/* ── Component ── */
|
||||
export default function UsersPage() {
|
||||
const { t } = useI18n();
|
||||
const { user: currentUser, checkAuth } = useAuthStore();
|
||||
const [users, setUsers] = useState<AppUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// MFA modal state
|
||||
const [mfaUserId, setMfaUserId] = useState<string | null>(null);
|
||||
const [mfaSecret, setMfaSecret] = useState<string | null>(null);
|
||||
const [mfaTotpUri, setMfaTotpUri] = useState<string | null>(null);
|
||||
const [mfaCode, setMfaCode] = useState('');
|
||||
const [mfaLoading, setMfaLoading] = useState(false);
|
||||
const [mfaMsg, setMfaMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
||||
|
||||
// Form state
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState<Role>('viewer');
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const data = await client.get('/users') as unknown as AppUser[];
|
||||
setUsers(data);
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : t('usr.errorLoadUsers'), type: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchUsers(); }, [fetchUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (msg) {
|
||||
const timer = setTimeout(() => setMsg(null), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [msg]);
|
||||
|
||||
const resetForm = () => {
|
||||
setFirstName(''); setLastName(''); setUsername('');
|
||||
setEmail(''); setPassword(''); setRole('viewer');
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!firstName.trim() || !lastName.trim()) {
|
||||
setMsg({ text: t('usr.requiredName'), type: 'error' });
|
||||
return;
|
||||
}
|
||||
if (!username.trim()) {
|
||||
setMsg({ text: t('usr.requiredUsername'), type: 'error' });
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
setMsg({ text: t('usr.requiredPassword'), type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await client.post('/auth/register', {
|
||||
first_name: firstName.trim(),
|
||||
last_name: lastName.trim(),
|
||||
username: username.trim(),
|
||||
email: email.trim() || undefined,
|
||||
password,
|
||||
role,
|
||||
});
|
||||
setMsg({ text: t('usr.created'), type: 'success' });
|
||||
resetForm();
|
||||
setShowForm(false);
|
||||
await fetchUsers();
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : t('usr.errorCreateUser'), type: 'error' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = async (userId: string, newRole: Role) => {
|
||||
try {
|
||||
await client.put(`/users/${userId}`, { role: newRole });
|
||||
setUsers(prev => prev.map(u => u.id === userId ? { ...u, role: newRole } : u));
|
||||
setMsg({ text: t('usr.roleUpdated'), type: 'success' });
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : t('usr.errorUpdateRole'), type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async (userId: string) => {
|
||||
try {
|
||||
await client.delete(`/users/${userId}`);
|
||||
setConfirmDelete(null);
|
||||
setMsg({ text: t('usr.deactivated'), type: 'success' });
|
||||
await fetchUsers();
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : t('usr.errorDeactivate'), type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (d: string | null) => {
|
||||
if (!d) return '---';
|
||||
try {
|
||||
return new Intl.DateTimeFormat('pt-BR', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
}).format(new Date(d));
|
||||
} catch {
|
||||
return d;
|
||||
}
|
||||
};
|
||||
|
||||
// MFA handlers
|
||||
const openMfa = (userId: string) => {
|
||||
setMfaUserId(userId);
|
||||
setMfaSecret(null);
|
||||
setMfaTotpUri(null);
|
||||
setMfaCode('');
|
||||
setMfaMsg(null);
|
||||
};
|
||||
const closeMfa = () => { setMfaUserId(null); setMfaSecret(null); setMfaTotpUri(null); setMfaCode(''); setMfaMsg(null); };
|
||||
|
||||
const handleMfaSetup = async () => {
|
||||
setMfaLoading(true);
|
||||
setMfaMsg(null);
|
||||
try {
|
||||
const data = await client.post('/mfa/setup') as unknown as { secret: string; uri: string };
|
||||
setMfaSecret(data.secret);
|
||||
setMfaTotpUri(data.uri);
|
||||
} catch (err) {
|
||||
setMfaMsg({ text: err instanceof Error ? err.message : 'Erro', type: 'error' });
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMfaVerify = async () => {
|
||||
if (mfaCode.length !== 6) { setMfaMsg({ text: t('mfa.invalidCode'), type: 'error' }); return; }
|
||||
setMfaLoading(true);
|
||||
setMfaMsg(null);
|
||||
try {
|
||||
await client.post('/mfa/verify', { totp_code: mfaCode });
|
||||
setMfaMsg({ text: t('mfa.activated'), type: 'success' });
|
||||
setMfaSecret(null); setMfaTotpUri(null); setMfaCode('');
|
||||
await fetchUsers();
|
||||
await checkAuth();
|
||||
} catch (err) {
|
||||
setMfaMsg({ text: err instanceof Error ? err.message : 'Codigo invalido', type: 'error' });
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMfaDisable = async (userId: string) => {
|
||||
if (!window.confirm(t('mfa.confirmDisable'))) return;
|
||||
setMfaLoading(true);
|
||||
setMfaMsg(null);
|
||||
try {
|
||||
await client.post(`/mfa/disable/${userId}`);
|
||||
setMfaMsg({ text: t('mfa.deactivated'), type: 'success' });
|
||||
await fetchUsers();
|
||||
if (userId === currentUser?.id) await checkAuth();
|
||||
} catch (err) {
|
||||
setMfaMsg({ text: err instanceof Error ? err.message : 'Erro', type: 'error' });
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activeUsers = users.filter(u => u.is_active);
|
||||
const inactiveUsers = users.filter(u => !u.is_active);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 size={28} className="animate-spin" style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
{/* Toast */}
|
||||
{msg && (
|
||||
<div
|
||||
className="fixed top-4 right-4 z-50 flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium shadow-lg"
|
||||
style={{
|
||||
background: msg.type === 'success' ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: msg.type === 'success' ? 'var(--gn)' : 'var(--rd)',
|
||||
border: `1px solid ${msg.type === 'success' ? 'var(--gn)' : 'var(--rd)'}`,
|
||||
borderColor: `color-mix(in srgb, ${msg.type === 'success' ? 'var(--gn)' : 'var(--rd)'} 30%, transparent)`,
|
||||
animation: 'fadeIn .3s ease',
|
||||
}}
|
||||
>
|
||||
{msg.type === 'success' ? <CheckCircle size={16} /> : <AlertTriangle size={16} />}
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<Users size={24} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('usr.title')}</h1>
|
||||
<div className="subtitle">
|
||||
{activeUsers.length} {activeUsers.length !== 1 ? t('usr.activesCount') : t('usr.activeCount')}
|
||||
{inactiveUsers.length > 0 && ` · ${inactiveUsers.length} ${inactiveUsers.length !== 1 ? t('usr.inactivesCount') : t('usr.inactiveCount')}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<span className="count">{users.length} {t('usr.total')}</span>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{showForm ? <X size={16} /> : <Plus size={16} />}
|
||||
{showForm ? t('usr.cancel') : t('usr.newUser')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create User Form */}
|
||||
{showForm && (
|
||||
<div className="card" style={{ animation: 'fadeIn .3s ease' }}>
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<UserPlus size={16} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<h2>{t('usr.createUser')}</h2>
|
||||
</div>
|
||||
|
||||
<div className="form-row" style={{ marginBottom: 12 }}>
|
||||
<div className="form-group">
|
||||
<label>{t('usr.firstName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Joao"
|
||||
value={firstName}
|
||||
onChange={e => setFirstName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('usr.lastName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Silva"
|
||||
value={lastName}
|
||||
onChange={e => setLastName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('usr.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="joao.silva"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
style={{ fontFamily: 'var(--fm)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row" style={{ marginBottom: 16 }}>
|
||||
<div className="form-group">
|
||||
<label>{t('usr.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="joao@empresa.com"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('usr.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('usr.role')}</label>
|
||||
<div className="flex gap-2">
|
||||
{(['viewer', 'user', 'admin'] as Role[]).map(r => {
|
||||
const cfg = ROLE_CONFIG[r];
|
||||
const Icon = cfg.icon;
|
||||
const selected = role === r;
|
||||
return (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRole(r)}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-semibold transition-all duration-200"
|
||||
style={{
|
||||
background: selected ? cfg.bg : 'var(--bg2)',
|
||||
border: `1.5px solid ${selected ? cfg.color : 'var(--bd)'}`,
|
||||
color: selected ? cfg.color : 'var(--t3)',
|
||||
}}
|
||||
>
|
||||
<Icon size={13} />
|
||||
{cfg.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={submitting}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{submitting ? <Loader2 size={15} className="animate-spin" /> : <Plus size={15} />}
|
||||
{submitting ? t('usr.creating') : t('usr.create')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Table */}
|
||||
{users.length === 0 ? (
|
||||
<div className="card">
|
||||
<div className="empty-state">
|
||||
<Users size={40} style={{ color: 'var(--ac)' }} />
|
||||
<h3>{t('usr.noUsers')}</h3>
|
||||
<p>{t('usr.noUsersHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('usr.name')}</th>
|
||||
<th>{t('usr.username')}</th>
|
||||
<th>{t('usr.email')}</th>
|
||||
<th>{t('usr.role')}</th>
|
||||
<th style={{ textAlign: 'center' }}>{t('usr.mfa')}</th>
|
||||
<th style={{ textAlign: 'center' }}>{t('usr.status')}</th>
|
||||
<th>{t('usr.lastLogin')}</th>
|
||||
<th style={{ textAlign: 'right' }}>{t('usr.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u, idx) => {
|
||||
const cfg = ROLE_CONFIG[u.role];
|
||||
const isActive = !!u.is_active;
|
||||
const isAdmin = u.username === 'admin';
|
||||
const RoleIcon = cfg.icon;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={u.id}
|
||||
className="transition-colors duration-150"
|
||||
style={{
|
||||
borderBottom: idx < users.length - 1 ? '1px solid var(--bd)' : undefined,
|
||||
opacity: isActive ? 1 : 0.5,
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg2)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = ''; }}
|
||||
>
|
||||
{/* Name */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--t1)' }}>
|
||||
{u.first_name || ''} {u.last_name || ''}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Username */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs" style={{ color: 'var(--t2)', fontFamily: 'var(--fm)' }}>
|
||||
{u.username}
|
||||
</span>
|
||||
{u.auth_provider === 'oidc' && (
|
||||
<span className="ml-1.5 text-[.55rem] font-bold px-1.5 py-0.5 rounded"
|
||||
style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>OIDC</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Email */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs" style={{ color: 'var(--t3)' }}>
|
||||
{u.email || '---'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Role Badge */}
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[.7rem] font-semibold"
|
||||
style={{ background: cfg.bg, color: cfg.color }}
|
||||
>
|
||||
<RoleIcon size={12} />
|
||||
{cfg.label}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* MFA */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
{u.mfa_enabled ? (
|
||||
<button
|
||||
onClick={() => openMfa(u.id)}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.65rem] font-semibold cursor-pointer border-none"
|
||||
style={{ background: 'var(--gnl)', color: 'var(--gn)' }}
|
||||
title={t('mfa.configure')}
|
||||
>
|
||||
<ShieldCheck size={11} /> {t('usr.active')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => openMfa(u.id)}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.65rem] font-semibold cursor-pointer border-none"
|
||||
style={{ background: 'var(--bg3)', color: 'var(--t4)' }}
|
||||
title={t('mfa.configure')}
|
||||
>
|
||||
<Lock size={11} /> {t('mfa.configure')}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span
|
||||
className="inline-block px-2 py-0.5 rounded-md text-[.65rem] font-semibold"
|
||||
style={{
|
||||
background: isActive ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: isActive ? 'var(--gn)' : 'var(--rd)',
|
||||
}}
|
||||
>
|
||||
{isActive ? t('usr.active') : t('usr.inactive')}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Last login */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs" style={{ color: 'var(--t4)' }}>
|
||||
{u.last_login ? formatDate(u.last_login) : t('usr.never')}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{/* Role selector */}
|
||||
<select
|
||||
value={u.role}
|
||||
onChange={e => handleRoleChange(u.id, e.target.value as Role)}
|
||||
disabled={isAdmin}
|
||||
className="px-2 py-1 rounded-lg text-xs outline-none cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: 'var(--bg2)',
|
||||
border: '1px solid var(--bd)',
|
||||
color: 'var(--t2)',
|
||||
}}
|
||||
>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="user">User</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
|
||||
{/* Deactivate */}
|
||||
{!isAdmin && isActive && (
|
||||
<>
|
||||
{confirmDelete === u.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleDeactivate(u.id)}
|
||||
className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}
|
||||
>
|
||||
{t('usr.confirm')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
{t('common.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(u.id)}
|
||||
className="p-1.5 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--t4)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--rdl)'; e.currentTarget.style.color = 'var(--rd)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = ''; e.currentTarget.style.color = 'var(--t4)'; }}
|
||||
title={t('usr.deactivate')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MFA Modal */}
|
||||
{mfaUserId && (() => {
|
||||
const targetUser = users.find(u => u.id === mfaUserId);
|
||||
if (!targetUser) return null;
|
||||
const isSelf = mfaUserId === currentUser?.id;
|
||||
const mfaOn = !!targetUser.mfa_enabled;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,.5)' }} onClick={closeMfa}>
|
||||
<div className="rounded-xl overflow-hidden" style={{ background: 'var(--bg1)', border: '1px solid var(--bd)', width: 440, maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock size={16} style={{ color: 'var(--ac)' }} />
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--t1)' }}>
|
||||
MFA — {targetUser.first_name} {targetUser.last_name}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={closeMfa} className="p-1 rounded cursor-pointer" style={{ color: 'var(--t4)' }}><X size={16} /></button>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
{/* Message */}
|
||||
{mfaMsg && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg mb-4 text-xs font-medium"
|
||||
style={{ background: mfaMsg.type === 'success' ? 'var(--gnl)' : 'var(--rdl)', color: mfaMsg.type === 'success' ? 'var(--gn)' : 'var(--rd)' }}>
|
||||
{mfaMsg.type === 'success' ? <CheckCircle size={14} /> : <AlertTriangle size={14} />}
|
||||
{mfaMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mfaOn ? (
|
||||
/* MFA enabled — show disable */
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
<div className="w-14 h-14 rounded-full flex items-center justify-center" style={{ background: 'color-mix(in srgb, var(--gn) 10%, transparent)' }}>
|
||||
<ShieldCheck size={28} style={{ color: 'var(--gn)' }} />
|
||||
</div>
|
||||
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>{t('mfa.enabledDesc')}</p>
|
||||
<button onClick={() => handleMfaDisable(mfaUserId)} disabled={mfaLoading}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer"
|
||||
style={{ background: 'var(--rdl)', color: 'var(--rd)', border: '1px solid color-mix(in srgb, var(--rd) 25%, transparent)' }}>
|
||||
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <ShieldOff size={14} />}
|
||||
{t('mfa.disableBtn')}
|
||||
</button>
|
||||
</div>
|
||||
) : isSelf && mfaSecret && mfaTotpUri ? (
|
||||
/* Setup step 2: QR + verify (only for self) */
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-center">
|
||||
<div className="p-3 rounded-xl" style={{ background: '#fff' }}>
|
||||
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(mfaTotpUri)}`}
|
||||
alt="QR Code" width={160} height={160} className="rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2 rounded-lg text-center" style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}>
|
||||
<span className="text-[.6rem] block mb-1" style={{ color: 'var(--t4)' }}>SECRET</span>
|
||||
<code className="text-xs font-bold tracking-wider select-all" style={{ color: 'var(--ac)', fontFamily: 'var(--fm)' }}>{mfaSecret}</code>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={mfaCode} onChange={e => setMfaCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000" maxLength={6} autoFocus
|
||||
className="flex-1 px-3 py-2 rounded-lg text-sm text-center tracking-[.3em] font-semibold outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', fontFamily: 'var(--fm)' }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleMfaVerify(); }} />
|
||||
<button onClick={handleMfaVerify} disabled={mfaLoading || mfaCode.length !== 6}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer disabled:opacity-50"
|
||||
style={{ background: 'var(--gn)', color: '#fff' }}>
|
||||
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <KeyRound size={14} />}
|
||||
{t('mfa.activateMfa')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : isSelf ? (
|
||||
/* Setup step 1: generate (only for self) */
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
<div className="w-14 h-14 rounded-full flex items-center justify-center" style={{ background: 'color-mix(in srgb, var(--yl) 10%, transparent)' }}>
|
||||
<KeyRound size={28} style={{ color: 'var(--yl)' }} />
|
||||
</div>
|
||||
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>{t('mfa.generateHint')}</p>
|
||||
<button onClick={handleMfaSetup} disabled={mfaLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer"
|
||||
style={{ background: 'var(--ac)', color: '#fff' }}>
|
||||
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <KeyRound size={14} />}
|
||||
{t('mfa.generateSecret')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Not self — can only disable */
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>
|
||||
{t('usr.mfaSelfOnly')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
655
frontend/src/pages/config/AdbConfigPage.tsx
Normal file
655
frontend/src/pages/config/AdbConfigPage.tsx
Normal file
@@ -0,0 +1,655 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { adbApi, type AdbConfigFull } from '@/api/endpoints/adb';
|
||||
import {
|
||||
Database, Plus, Pencil, Trash2, FlaskConical, Check, X, Loader2,
|
||||
ChevronRight, Upload, Shield, Table2, ChevronDown, ChevronUp, Search,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Msg ── */
|
||||
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 dangerouslySetInnerHTML={{ __html: text }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
export default function AdbConfigPage() {
|
||||
const { genaiCfg, embModels } = useAppStore();
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === 'admin');
|
||||
const { t } = useI18n();
|
||||
|
||||
const [configs, setConfigs] = useState<AdbConfigFull[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Form
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<AdbConfigFull | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formMsg, setFormMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
const walletRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Form fields
|
||||
const [cfgName, setCfgName] = useState('');
|
||||
const [dsn, setDsn] = useState('');
|
||||
const [dsnOptions, setDsnOptions] = useState<string[]>([]);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [walletPassword, setWalletPassword] = useState('');
|
||||
const [genaiConfigId, setGenaiConfigId] = useState('');
|
||||
const [embeddingModelId, setEmbeddingModelId] = useState('cohere.embed-v4.0');
|
||||
const [parsingWallet, setParsingWallet] = useState(false);
|
||||
|
||||
// Table
|
||||
const [tableMsg, setTableMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
const [testResults, setTestResults] = useState<Record<string, { type: 's' | 'e'; text: string }>>({});
|
||||
const [testingMap, setTestingMap] = useState<Record<string, boolean>>({});
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [expandedTables, setExpandedTables] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Wallet upload section
|
||||
const walletUploadRef = useRef<HTMLInputElement>(null);
|
||||
const [walletUploadId, setWalletUploadId] = useState('');
|
||||
const [uploadingWallet, setUploadingWallet] = useState(false);
|
||||
const [walletMsg, setWalletMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
|
||||
// New table form
|
||||
const [newTableName, setNewTableName] = useState('');
|
||||
const [newTableDesc, setNewTableDesc] = useState('');
|
||||
|
||||
const fetchConfigs = useCallback(async () => {
|
||||
try {
|
||||
const data = await adbApi.list();
|
||||
setConfigs(data);
|
||||
} catch {} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchConfigs(); }, [fetchConfigs]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setFormOpen(false);
|
||||
setCfgName('');
|
||||
setDsn('');
|
||||
setDsnOptions([]);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setWalletPassword('');
|
||||
setGenaiConfigId('');
|
||||
setEmbeddingModelId('cohere.embed-v4.0');
|
||||
setFormMsg(null);
|
||||
setSaving(false);
|
||||
if (walletRef.current) walletRef.current.value = '';
|
||||
};
|
||||
|
||||
const startEdit = (cfg: AdbConfigFull) => {
|
||||
setEditing(cfg);
|
||||
setCfgName(cfg.config_name);
|
||||
setDsn(cfg.dsn);
|
||||
setDsnOptions([]);
|
||||
setUsername(cfg.username);
|
||||
setPassword('');
|
||||
setWalletPassword('');
|
||||
setGenaiConfigId(cfg.genai_config_id || '');
|
||||
setEmbeddingModelId(cfg.embedding_model_id || 'cohere.embed-v4.0');
|
||||
setFormOpen(true);
|
||||
setFormMsg(null);
|
||||
setTimeout(() => formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100);
|
||||
};
|
||||
|
||||
const parseWallet = async () => {
|
||||
const file = walletRef.current?.files?.[0];
|
||||
if (!file) { setFormMsg({ type: 'e', text: t('adb.selectWallet') }); return; }
|
||||
setParsingWallet(true);
|
||||
try {
|
||||
const result = await adbApi.parseWallet(file);
|
||||
setDsnOptions(result.dsn_names);
|
||||
if (result.dsn_names.length > 0) setDsn(result.dsn_names[0]);
|
||||
setFormMsg({ type: 's', text: t('adb.walletParsed').replace('{0}', String(result.dsn_names.length)).replace('{1}', result.dsn_names.join(', ')).replace('{2}', result.files.join(', ')) });
|
||||
} catch (err) {
|
||||
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorLoad') });
|
||||
} finally {
|
||||
setParsingWallet(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!cfgName.trim()) { setFormMsg({ type: 'e', text: t('adb.fillName') }); return; }
|
||||
if (!dsn.trim()) { setFormMsg({ type: 'e', text: t('adb.fillDsn') }); return; }
|
||||
if (!username.trim()) { setFormMsg({ type: 'e', text: t('adb.fillUsername') }); return; }
|
||||
if (!editing && !password) { setFormMsg({ type: 'e', text: t('adb.fillPassword') }); return; }
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('config_name', cfgName.trim());
|
||||
fd.append('dsn', dsn.trim());
|
||||
fd.append('username', username.trim());
|
||||
fd.append('password', password);
|
||||
fd.append('wallet_password', walletPassword);
|
||||
fd.append('use_mtls', 'true');
|
||||
fd.append('genai_config_id', genaiConfigId);
|
||||
fd.append('embedding_model_id', embeddingModelId);
|
||||
const walletFile = walletRef.current?.files?.[0];
|
||||
if (walletFile) fd.append('wallet', walletFile);
|
||||
|
||||
setSaving(true);
|
||||
setFormMsg({ type: 'i', text: t('adb.saving') });
|
||||
|
||||
try {
|
||||
if (editing) {
|
||||
await adbApi.update(editing.id, fd);
|
||||
} else {
|
||||
await adbApi.create(fd);
|
||||
}
|
||||
setFormMsg({ type: 's', text: (editing ? t('adb.updatedSuccess') : t('adb.savedSuccess')) + (walletFile ? t('adb.walletIncluded') : '') });
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTimeout(resetForm, 1200);
|
||||
} catch (err) {
|
||||
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorSave') });
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setConfirmDelete(null);
|
||||
setTableMsg({ type: 'i', text: t('adb.deleting') });
|
||||
try {
|
||||
await adbApi.remove(id);
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: t('adb.deletedSuccess') });
|
||||
if (editing?.id === id) resetForm();
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorDelete') });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
setTestingMap((p) => ({ ...p, [id]: true }));
|
||||
setTestResults((p) => { const n = { ...p }; delete n[id]; return n; });
|
||||
try {
|
||||
const res = await adbApi.test(id);
|
||||
setTestResults((p) => ({
|
||||
...p,
|
||||
[id]: { type: res.status === 'success' ? 's' : 'e', text: res.message },
|
||||
}));
|
||||
} catch (err) {
|
||||
setTestResults((p) => ({ ...p, [id]: { type: 'e', text: err instanceof Error ? err.message : 'Erro' } }));
|
||||
} finally {
|
||||
setTestingMap((p) => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleWalletUpload = async () => {
|
||||
const file = walletUploadRef.current?.files?.[0];
|
||||
if (!file) { setWalletMsg({ type: 'e', text: t('adb.selectWallet') }); return; }
|
||||
if (!walletUploadId) { setWalletMsg({ type: 'e', text: t('adb.selectConnection') }); return; }
|
||||
setUploadingWallet(true);
|
||||
setWalletMsg({ type: 'i', text: t('adb.walletSending') });
|
||||
try {
|
||||
const res = await adbApi.uploadWallet(walletUploadId, file);
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setWalletMsg({ type: 's', text: t('adb.walletSent').replace('{0}', res.files.join(', ')) });
|
||||
if (walletUploadRef.current) walletUploadRef.current.value = '';
|
||||
} catch (err) {
|
||||
setWalletMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorSave') });
|
||||
} finally {
|
||||
setUploadingWallet(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTable = async (vid: string) => {
|
||||
if (!newTableName.trim()) { setTableMsg({ type: 'e', text: t('adb.enterTableName') }); return; }
|
||||
try {
|
||||
await adbApi.addTable(vid, newTableName.trim(), newTableDesc.trim());
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: t('adb.tableRegistered').replace('{0}', newTableName.trim()) });
|
||||
setNewTableName('');
|
||||
setNewTableDesc('');
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTable = async (vid: string, tid: string, currentActive: boolean) => {
|
||||
try {
|
||||
await adbApi.updateTable(vid, tid, { is_active: !currentActive });
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTable = async (vid: string, tid: string) => {
|
||||
if (!confirm(t('adb.confirmRemoveTable'))) return;
|
||||
try {
|
||||
await adbApi.removeTable(vid, tid);
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: t('adb.tableRemoved') });
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
}
|
||||
};
|
||||
|
||||
const isFormVisible = formOpen || !!editing;
|
||||
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";
|
||||
const labelStyle = {};
|
||||
const hintCls = "text-[.64rem] mt-0.5";
|
||||
const hintStyle = { color: 'var(--t4)' };
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--bll)' }}>
|
||||
<Database size={24} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('adb.title')}</h1>
|
||||
<div className="subtitle">{t('adb.subtitle')}</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<span className="count">
|
||||
{configs.length} {configs.length === 1 ? t('adb.connection') : t('adb.connections')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--bll)' }}>
|
||||
<Database size={16} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
<h2>{t('adb.registered')}</h2>
|
||||
</div>
|
||||
|
||||
{tableMsg && <div className="px-5 py-2"><Msg type={tableMsg.type} text={tableMsg.text} /></div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--t4)' }} />
|
||||
</div>
|
||||
) : configs.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<Database size={28} style={{ color: 'var(--t4)', margin: '0 auto 8px' }} />
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>{t('adb.noConnections')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{[t('adb.thName'), t('adb.thDsn'), t('adb.thUser'), t('adb.thTables'), t('adb.thMtls'), t('adb.thWallet'), t('adb.thEmbed'), t('adb.thActions')].map((h) => (
|
||||
<th key={h}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{configs.map((c) => {
|
||||
const tables = c.tables || [];
|
||||
const emb = c.embedding_model_id ? embModels[c.embedding_model_id] : null;
|
||||
const isExpanded = expandedTables[c.id];
|
||||
return (
|
||||
<tr
|
||||
key={c.id}
|
||||
className="transition-colors align-top"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--bd)',
|
||||
background: editing?.id === c.id ? 'color-mix(in srgb, var(--bl) 5%, var(--bg1))' : undefined,
|
||||
}}
|
||||
onMouseEnter={(e) => { if (editing?.id !== c.id) e.currentTarget.style.background = 'var(--bg2)'; }}
|
||||
onMouseLeave={(e) => { if (editing?.id !== c.id) e.currentTarget.style.background = ''; }}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<strong className="text-xs" style={{ color: 'var(--t1)' }}>{c.config_name}</strong>
|
||||
{(c as any).is_global === 1 && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[.55rem] font-bold" style={{ background: 'var(--bll)', color: 'var(--bl)' }}>GLOBAL</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-[.68rem]" style={{ fontFamily: 'var(--fm)', color: 'var(--t4)' }}>{c.dsn}</code>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[.72rem]" style={{ color: 'var(--t2)' }}>{c.username}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{tables.length > 0 ? (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<button
|
||||
onClick={() => setExpandedTables((p) => ({ ...p, [c.id]: !p[c.id] }))}
|
||||
className="flex items-center gap-1 text-[.62rem] font-semibold px-1.5 py-0.5 rounded"
|
||||
style={{ background: 'var(--bg)', color: 'var(--t3)' }}
|
||||
>
|
||||
<Table2 size={10} /> {tables.length}
|
||||
{isExpanded ? <ChevronUp size={8} /> : <ChevronDown size={8} />}
|
||||
</button>
|
||||
{!isExpanded && tables.slice(0, 3).map((tb) => (
|
||||
<span key={tb.id} className="px-1.5 py-0.5 rounded text-[.58rem]" style={{ background: 'var(--bg)', color: 'var(--t4)' }}>{tb.table_name}</span>
|
||||
))}
|
||||
{!isExpanded && tables.length > 3 && (
|
||||
<span className="text-[.58rem]" style={{ color: 'var(--t4)' }}>+{tables.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[.72rem]" style={{ color: 'var(--t4)' }}>{'\u2014'}</span>
|
||||
)}
|
||||
{isExpanded && tables.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<table className="w-full text-[.68rem]" style={{ borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{[t('adb.thTable'), t('adb.thDescription'), t('adb.thActive'), t('adb.thTableActions')].map((h) => (
|
||||
<th key={h} className="text-left px-2 py-1 text-[.6rem] font-semibold uppercase" style={{ color: 'var(--t4)', borderBottom: '1px solid var(--bd)' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tables.map((tb) => (
|
||||
<tr key={tb.id} style={{ opacity: tb.is_active ? 1 : 0.55 }}>
|
||||
<td className="px-2 py-1.5">
|
||||
<span className="font-mono text-[.68rem]" style={{ color: 'var(--t1)' }}>{tb.table_name}</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<span className="text-[.66rem]" style={{ color: 'var(--t3)' }}>{tb.description || '—'}</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<button onClick={() => handleToggleTable(c.id, tb.id, tb.is_active)} className="text-[.6rem] px-1.5 py-0.5 rounded font-medium" style={{ background: tb.is_active ? 'var(--gnl)' : 'var(--bg)', color: tb.is_active ? 'var(--gn)' : 'var(--t4)' }}>
|
||||
{tb.is_active ? t('adb.yes') : t('adb.no')}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<button onClick={() => handleRemoveTable(c.id, tb.id)} className="text-[.58rem] px-1.5 py-0.5 rounded" style={{ color: 'var(--rd)', background: 'var(--rdl)' }}>
|
||||
{t('adb.delete')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* Add table inline */}
|
||||
{editing?.id === c.id && (
|
||||
<div className="flex gap-1 items-end mt-1">
|
||||
<input type="text" value={newTableName} onChange={(e) => setNewTableName(e.target.value)} placeholder="Nome" className="px-2 py-1 rounded text-[.64rem] w-28" style={{ background: 'var(--bg)', border: '1px solid var(--bd)', color: 'var(--t1)' }} />
|
||||
<input type="text" value={newTableDesc} onChange={(e) => setNewTableDesc(e.target.value)} placeholder="Descricao" className="px-2 py-1 rounded text-[.64rem] w-32" style={{ background: 'var(--bg)', border: '1px solid var(--bd)', color: 'var(--t1)' }} />
|
||||
<button onClick={() => handleAddTable(c.id)} className="px-2 py-1 rounded text-[.62rem] font-semibold" style={{ background: 'var(--bl)', color: '#fff' }}>
|
||||
<Plus size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{c.use_mtls ? (
|
||||
<Shield size={14} style={{ color: 'var(--gn)' }} />
|
||||
) : (
|
||||
<span style={{ color: 'var(--t4)' }}>{'\u2014'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{c.wallet_dir ? (
|
||||
<Check size={14} style={{ color: 'var(--gn)' }} />
|
||||
) : (
|
||||
<X size={14} style={{ color: 'var(--rd)' }} />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{c.genai_config_id ? (
|
||||
<span className="px-1.5 py-0.5 rounded text-[.6rem] font-semibold" style={{ background: 'var(--gnl)', color: 'var(--gn)' }}>
|
||||
{emb?.name || c.embedding_model_id}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--t4)' }}>{'\u2014'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{(isAdmin || !(c as any).is_global) && (
|
||||
<button onClick={() => startEdit(c)} className="btn btn-secondary btn-sm">
|
||||
<Pencil size={10} /> {t('adb.edit')}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => handleTest(c.id)} disabled={testingMap[c.id]} className="btn btn-secondary btn-sm">
|
||||
{testingMap[c.id] ? <Loader2 size={10} className="animate-spin" /> : <FlaskConical size={10} />} {t('adb.test')}
|
||||
</button>
|
||||
{(isAdmin || !(c as any).is_global) && (
|
||||
confirmDelete === c.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleDelete(c.id)} className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}>
|
||||
<Check size={10} /> {t('adb.yes')}
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-sm">
|
||||
{t('adb.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setConfirmDelete(c.id)} className="btn btn-danger btn-sm">
|
||||
<Trash2 size={10} /> {t('adb.delete')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{testResults[c.id] && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded text-[.62rem] font-medium"
|
||||
style={{
|
||||
background: testResults[c.id].type === 's' ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: testResults[c.id].type === 's' ? 'var(--gn)' : 'var(--rd)',
|
||||
}}>
|
||||
{testResults[c.id].type === 's' ? <Check size={10} /> : <X size={10} />}
|
||||
{testResults[c.id].text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div ref={formRef} className="card" style={{ overflow: 'hidden' }}>
|
||||
<div className="flex items-center gap-2.5 px-5 py-3.5 cursor-pointer select-none" onClick={() => { if (!editing) setFormOpen(!formOpen); }}>
|
||||
<ChevronRight size={14} style={{ color: 'var(--t3)', transition: 'transform .2s', transform: isFormVisible ? 'rotate(90deg)' : 'rotate(0)' }} />
|
||||
<div className="flex items-center gap-2">
|
||||
{editing ? <Pencil size={14} style={{ color: 'var(--bl)' }} /> : <Plus size={14} style={{ color: 'var(--bl)' }} />}
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
|
||||
{editing ? `${t('adb.editConnection')} — ${editing.config_name}` : t('adb.newConnection')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFormVisible && (
|
||||
<div className="px-5 pb-5 flex flex-col gap-4" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<p className="text-[.7rem] pt-3" style={{ color: 'var(--t4)' }}>
|
||||
{editing
|
||||
? <>{t('adb.editingLabel')} <strong style={{ color: 'var(--t2)' }}>{editing.config_name}</strong></>
|
||||
: <>{t('adb.connDesc')}</>}
|
||||
</p>
|
||||
|
||||
{formMsg && <Msg type={formMsg.type} text={formMsg.text} />}
|
||||
|
||||
{/* Row 1 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.connName')}</label>
|
||||
<input type="text" value={cfgName} onChange={(e) => setCfgName(e.target.value)} placeholder="Producao ADB" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.walletZip')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>
|
||||
{editing ? t('adb.walletEditHint') : t('adb.walletHint')}
|
||||
</p>
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<input
|
||||
ref={walletRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="flex-1 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={parseWallet}
|
||||
disabled={parsingWallet}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-[.68rem] font-semibold disabled:opacity-50"
|
||||
style={{ background: 'var(--bg2)', color: 'var(--t2)', border: '1px solid var(--bd)' }}
|
||||
>
|
||||
{parsingWallet ? <Loader2 size={12} className="animate-spin" /> : <Search size={12} />}
|
||||
{t('adb.analyze')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.dsn')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('adb.dsnHint')}</p>
|
||||
{dsnOptions.length > 0 ? (
|
||||
<select value={dsn} onChange={(e) => setDsn(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
{dsnOptions.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input type="text" value={dsn} onChange={(e) => setDsn(e.target.value)} placeholder="myatp_high" className={inputCls} style={inputStyle} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.username')}</label>
|
||||
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="ADMIN" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.password')}</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder={editing ? '(manter atual)' : ''} className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.walletPassword')}</label>
|
||||
<input type="password" value={walletPassword} onChange={(e) => setWalletPassword(e.target.value)} placeholder="(opcional)" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4 - Embedding config */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.genaiConfig')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('adb.genaiConfigHint')}</p>
|
||||
<select value={genaiConfigId} onChange={(e) => setGenaiConfigId(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
<option value="">{t('adb.noRag')}</option>
|
||||
{genaiCfg.map((g) => (
|
||||
<option key={g.id} value={g.id}>{g.name || g.model_id} {g.genai_region ? `- ${g.genai_region}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.embeddingModel')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('adb.embeddingModelHint')}</p>
|
||||
<select value={embeddingModelId} onChange={(e) => setEmbeddingModelId(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
{Object.entries(embModels).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.name} ({v.dims}d)</option>
|
||||
))}
|
||||
{Object.keys(embModels).length === 0 && (
|
||||
<option value="cohere.embed-v4.0">cohere.embed-v4.0</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button onClick={handleSave} disabled={saving} className="btn btn-primary" style={{ background: 'var(--bl)', borderColor: 'var(--bl)' }}>
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{editing ? t('adb.saveChanges') : t('adb.saveConnection')}
|
||||
</button>
|
||||
{editing && (
|
||||
<button onClick={resetForm} className="btn btn-secondary">
|
||||
{t('adb.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Wallet Upload card */}
|
||||
{configs.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--bll)' }}>
|
||||
<Upload size={16} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
<h2>{t('adb.updateWallet')}</h2>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex flex-col gap-3">
|
||||
{walletMsg && <Msg type={walletMsg.type} text={walletMsg.text} />}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.adbConnection')}</label>
|
||||
<select value={walletUploadId} onChange={(e) => setWalletUploadId(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
<option value="">{t('common.select')}</option>
|
||||
{configs.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.config_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.walletZip')}</label>
|
||||
<input
|
||||
ref={walletUploadRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="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>
|
||||
<div>
|
||||
<button
|
||||
onClick={handleWalletUpload}
|
||||
disabled={uploadingWallet}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
{uploadingWallet ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
|
||||
{t('adb.uploadWallet')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
320
frontend/src/pages/config/EmbConsultPage.tsx
Normal file
320
frontend/src/pages/config/EmbConsultPage.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useAppStore, type AdbConfig, type AdbTable } from '@/stores/app';
|
||||
import { embeddingsApi, type ConsultResult } from '@/api/endpoints/embeddings';
|
||||
import { useI18n } from '@/i18n';
|
||||
import {
|
||||
Search, Send, Loader2, Database, Trash2, User, Bot,
|
||||
FileText, Hash,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Types ── */
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
sources?: ConsultResult['documents'];
|
||||
totalSources?: number;
|
||||
}
|
||||
|
||||
/* ── Markdown-lite renderer ── */
|
||||
function renderMarkdown(text: string): string {
|
||||
let html = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// Bold
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
// Italic
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
// Inline code
|
||||
html = html.replace(/`(.+?)`/g, '<code style="background:var(--bg);padding:1px 4px;border-radius:3px;font-size:.72rem;font-family:var(--fm)">$1</code>');
|
||||
// Horizontal rule
|
||||
html = html.replace(/\n---\n/g, '<hr style="border:none;border-top:1px solid var(--bd);margin:8px 0">');
|
||||
// Line breaks
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
export default function EmbConsultPage() {
|
||||
const { t } = useI18n();
|
||||
const { adbCfg, ociCfg } = useAppStore();
|
||||
|
||||
// Config selectors
|
||||
const [selTable, setSelTable] = useState('');
|
||||
const [selOci, setSelOci] = useState(ociCfg.length > 0 ? ociCfg[0].id : '');
|
||||
const [topK, setTopK] = useState(10);
|
||||
|
||||
// Chat
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const msgsRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Auto scroll
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (msgsRef.current) {
|
||||
msgsRef.current.scrollTop = msgsRef.current.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, loading, scrollToBottom]);
|
||||
|
||||
// Focus input on mount
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Table options from all active ADB configs
|
||||
const allTables: { name: string; configName: string }[] = [];
|
||||
for (const cfg of adbCfg) {
|
||||
for (const t of (cfg.tables || []).filter((t) => t.is_active)) {
|
||||
allTables.push({ name: t.table_name, configName: cfg.config_name });
|
||||
}
|
||||
}
|
||||
|
||||
// Preconditions
|
||||
if (!adbCfg.length) {
|
||||
return (
|
||||
<div className="page-full flex items-center justify-center">
|
||||
<div className="empty-state">
|
||||
<Database size={36} />
|
||||
<h3>{t('emb.noAdb')}</h3>
|
||||
<p>{t('emb.noAdbHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Submit ── */
|
||||
const handleSubmit = async () => {
|
||||
const q = query.trim();
|
||||
if (!q || loading) return;
|
||||
|
||||
const userMsg: ChatMessage = { role: 'user', content: q };
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setQuery('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const d = await embeddingsApi.consult(q, selTable || undefined, topK, selOci || undefined);
|
||||
let answer = d.answer || t('ec.noAnswer');
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: answer,
|
||||
sources: d.documents,
|
||||
totalSources: d.total,
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
} catch (err) {
|
||||
const assistantMsg: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: `${t('ec.errorPrefix')}${err instanceof Error ? err.message : t('ec.errorUnknown')}`,
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const clearChat = () => {
|
||||
setMessages([]);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-full flex flex-col">
|
||||
<div className="max-w-5xl mx-auto w-full flex flex-col h-full p-6 gap-3">
|
||||
|
||||
{/* Header */}
|
||||
<div className="card flex items-center justify-between flex-shrink-0" style={{ marginBottom: 0, padding: '14px 20px' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="card-header icon" style={{ background: 'color-mix(in srgb, var(--bl) 12%, transparent)', margin: 0, padding: 0, border: 'none' }}>
|
||||
<Search size={18} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '1rem', fontWeight: 700, color: 'var(--t1)' }}>{t('ec.title')}</h1>
|
||||
<p className="subtitle" style={{ fontSize: '.72rem' }}>
|
||||
{t('ec.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{messages.length > 0 && (
|
||||
<button onClick={clearChat} className="btn btn-secondary btn-sm">
|
||||
<Trash2 size={12} />
|
||||
{t('ec.clear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config bar */}
|
||||
<div className="flex gap-3 flex-wrap flex-shrink-0">
|
||||
<div className="min-w-[160px]">
|
||||
<label className="block text-[.68rem] font-semibold mb-1" style={{ color: 'var(--t4)' }}>Tenancy</label>
|
||||
<select
|
||||
value={selOci}
|
||||
onChange={(e) => setSelOci(e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-[.76rem] outline-none"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)', color: 'var(--t1)' }}
|
||||
>
|
||||
<option value="">Todas</option>
|
||||
{ociCfg.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.tenancy_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[180px]">
|
||||
<label className="block text-[.68rem] font-semibold mb-1" style={{ color: 'var(--t4)' }}>{t('ec.tableOptional')}</label>
|
||||
<select
|
||||
value={selTable}
|
||||
onChange={(e) => setSelTable(e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-[.76rem] outline-none"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)', color: 'var(--t1)' }}
|
||||
>
|
||||
<option value="">{t('ec.allTables')}</option>
|
||||
{allTables.map((t) => (
|
||||
<option key={t.name} value={t.name}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-[80px]">
|
||||
<label className="block text-[.68rem] font-semibold mb-1" style={{ color: 'var(--t4)' }}>Top-K</label>
|
||||
<input
|
||||
type="number"
|
||||
value={topK}
|
||||
onChange={(e) => setTopK(Math.max(1, Math.min(30, parseInt(e.target.value) || 10)))}
|
||||
min={1}
|
||||
max={30}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-[.76rem] outline-none text-center"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)', color: 'var(--t1)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages area */}
|
||||
<div
|
||||
ref={msgsRef}
|
||||
className="flex-1 overflow-y-auto rounded-xl p-4 flex flex-col gap-3 min-h-0"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)' }}
|
||||
>
|
||||
{messages.length === 0 && !loading && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Search size={32} style={{ color: 'var(--t4)', margin: '0 auto 10px', opacity: 0.5 }} />
|
||||
<p className="text-[.82rem] font-medium" style={{ color: 'var(--t3)' }}>
|
||||
{t('ec.emptyTitle')}
|
||||
</p>
|
||||
<p className="text-[.7rem] mt-2" style={{ color: 'var(--t4)' }}>
|
||||
{t('ec.emptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex gap-2.5 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center mt-0.5" style={{ background: 'color-mix(in srgb, var(--bl) 12%, transparent)' }}>
|
||||
<Bot size={14} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="max-w-[80%] rounded-xl px-4 py-3"
|
||||
style={{
|
||||
background: msg.role === 'user' ? 'var(--ac)' : 'var(--bg2)',
|
||||
color: msg.role === 'user' ? '#fff' : 'var(--t1)',
|
||||
}}
|
||||
>
|
||||
{msg.role === 'user' ? (
|
||||
<p className="text-[.8rem] leading-relaxed">{msg.content}</p>
|
||||
) : (
|
||||
<div className="text-[.8rem] leading-relaxed" dangerouslySetInnerHTML={{ __html: renderMarkdown(msg.content) }} />
|
||||
)}
|
||||
|
||||
{/* Sources */}
|
||||
{msg.sources && msg.sources.length > 0 && (
|
||||
<div className="mt-3 pt-3" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<FileText size={12} style={{ color: 'var(--t4)' }} />
|
||||
<span className="text-[.66rem] font-semibold" style={{ color: 'var(--t4)' }}>
|
||||
{t('ec.sources')} ({msg.sources.length} documentos{msg.totalSources ? `, ${msg.totalSources} total` : ''})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{msg.sources.map((src, j) => (
|
||||
<div key={j} className="flex items-center gap-2 px-2 py-1 rounded" style={{ background: 'var(--bg)' }}>
|
||||
<Hash size={10} style={{ color: 'var(--t4)' }} />
|
||||
<span className="text-[.66rem] font-semibold" style={{ color: 'var(--bl)' }}>{src.source}</span>
|
||||
<span className="text-[.6rem]" style={{ color: 'var(--t4)' }}>
|
||||
dist: {src.distance.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{msg.role === 'user' && (
|
||||
<div className="flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center mt-0.5" style={{ background: 'color-mix(in srgb, var(--ac) 15%, transparent)' }}>
|
||||
<User size={14} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{loading && (
|
||||
<div className="flex gap-2.5 justify-start">
|
||||
<div className="flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center mt-0.5" style={{ background: 'color-mix(in srgb, var(--bl) 12%, transparent)' }}>
|
||||
<Bot size={14} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
<div className="rounded-xl px-4 py-3 flex items-center gap-2" style={{ background: 'var(--bg2)' }}>
|
||||
<Loader2 size={14} className="animate-spin" style={{ color: 'var(--bl)' }} />
|
||||
<span className="text-[.76rem]" style={{ color: 'var(--t3)' }}>{t('ec.searching')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
<div className="flex gap-2.5 flex-shrink-0">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('ec.placeholder')}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl text-[.82rem] outline-none transition-colors focus:ring-1 disabled:opacity-60"
|
||||
style={{ background: 'var(--bg1)', border: '1.5px solid var(--bd)', color: 'var(--t1)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !query.trim()}
|
||||
className="btn"
|
||||
style={{ background: 'var(--bl)', color: '#fff', borderColor: 'var(--bl)', borderRadius: '12px' }}
|
||||
>
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||
{t('ec.submit')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
516
frontend/src/pages/config/EmbeddingsPage.tsx
Normal file
516
frontend/src/pages/config/EmbeddingsPage.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
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);
|
||||
|
||||
// Auto-select ADB config when data loads (fixes empty dropdown after navigation)
|
||||
useEffect(() => {
|
||||
const defaultId = adbCfg.find((c) => c.is_active)?.id || adbCfg[0]?.id || '';
|
||||
if (defaultId) {
|
||||
if (!selVid) setSelVid(defaultId);
|
||||
if (!cisVid) setCisVid(defaultId);
|
||||
if (!kbVid) setKbVid(defaultId);
|
||||
if (!purgeVid) setPurgeVid(defaultId);
|
||||
}
|
||||
}, [adbCfg]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── 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>
|
||||
);
|
||||
}
|
||||
446
frontend/src/pages/config/GenAiConfigPage.tsx
Normal file
446
frontend/src/pages/config/GenAiConfigPage.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { genaiApi, type GenAiConfigFull, type GenAiConfigPayload } from '@/api/endpoints/genai';
|
||||
import {
|
||||
Brain, Plus, Pencil, Trash2, FlaskConical, Check, X, Loader2,
|
||||
ChevronRight, Star, Link as LinkIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Msg ── */
|
||||
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 dangerouslySetInnerHTML={{ __html: text }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
export default function GenAiConfigPage() {
|
||||
const { ociCfg, models, regions } = useAppStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const [configs, setConfigs] = useState<GenAiConfigFull[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Form
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<GenAiConfigFull | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formMsg, setFormMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Form fields
|
||||
const [name, setName] = useState('');
|
||||
const [ociConfigId, setOciConfigId] = useState('');
|
||||
const [modelId, setModelId] = useState('openai.gpt-4.1');
|
||||
const [modelOcid, setModelOcid] = useState('');
|
||||
const [compartmentId, setCompartmentId] = useState('');
|
||||
const [genaiRegion, setGenaiRegion] = useState('us-ashburn-1');
|
||||
const [isDefault, setIsDefault] = useState(false);
|
||||
|
||||
// Table
|
||||
const [tableMsg, setTableMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
const [testResults, setTestResults] = useState<Record<string, { type: 's' | 'e'; text: string }>>({});
|
||||
const [testing, setTesting] = useState<Record<string, boolean>>({});
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
|
||||
const fetchConfigs = useCallback(async () => {
|
||||
try {
|
||||
const data = await genaiApi.list();
|
||||
setConfigs(data);
|
||||
} catch {} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchConfigs(); }, [fetchConfigs]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setFormOpen(false);
|
||||
setName('');
|
||||
setOciConfigId('');
|
||||
setModelId('openai.gpt-4.1');
|
||||
setModelOcid('');
|
||||
setCompartmentId('');
|
||||
setGenaiRegion('us-ashburn-1');
|
||||
setIsDefault(false);
|
||||
setFormMsg(null);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const startEdit = (cfg: GenAiConfigFull) => {
|
||||
setEditing(cfg);
|
||||
setName(cfg.name || '');
|
||||
setOciConfigId(cfg.oci_config_id || '');
|
||||
setModelId(cfg.model_ocid ? '_custom' : (cfg.model_id || 'openai.gpt-4.1'));
|
||||
setModelOcid(cfg.model_ocid || '');
|
||||
setCompartmentId(cfg.compartment_id || '');
|
||||
setGenaiRegion(cfg.genai_region || 'us-ashburn-1');
|
||||
setIsDefault(!!cfg.is_default);
|
||||
setFormOpen(true);
|
||||
setFormMsg(null);
|
||||
setTimeout(() => formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100);
|
||||
};
|
||||
|
||||
const fillFromOci = (ociId: string) => {
|
||||
setOciConfigId(ociId);
|
||||
const cfg = ociCfg.find((c) => c.id === ociId);
|
||||
if (!cfg) return;
|
||||
setGenaiRegion(cfg.region);
|
||||
if (cfg.compartment_id) setCompartmentId(cfg.compartment_id);
|
||||
setFormMsg({ type: 'i', text: t('genai.regionFilled').replace('{0}', cfg.tenancy_name) });
|
||||
setTimeout(() => setFormMsg(null), 3000);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) { setFormMsg({ type: 'e', text: t('genai.fillName') }); return; }
|
||||
if (!ociConfigId) { setFormMsg({ type: 'e', text: t('genai.selectOci') }); return; }
|
||||
if (modelId === '_custom' && !modelOcid) { setFormMsg({ type: 'e', text: t('genai.fillOcid') }); return; }
|
||||
|
||||
const body: GenAiConfigPayload = {
|
||||
name: name.trim(),
|
||||
oci_config_id: ociConfigId,
|
||||
model_id: modelId === '_custom' ? 'custom' : modelId,
|
||||
model_ocid: modelOcid || null,
|
||||
compartment_id: compartmentId,
|
||||
genai_region: genaiRegion,
|
||||
endpoint: null,
|
||||
serving_type: 'ON_DEMAND',
|
||||
dedicated_endpoint_id: null,
|
||||
is_default: editing ? !!editing.is_default : configs.length === 0,
|
||||
};
|
||||
if (isDefault) body.is_default = true;
|
||||
|
||||
setSaving(true);
|
||||
setFormMsg({ type: 'i', text: t('genai.saving') });
|
||||
|
||||
try {
|
||||
if (editing) {
|
||||
await genaiApi.update(editing.id, body);
|
||||
} else {
|
||||
await genaiApi.create(body);
|
||||
}
|
||||
setFormMsg({ type: 's', text: editing ? t('genai.updatedSuccess') : t('genai.savedSuccess') });
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTimeout(resetForm, 1200);
|
||||
} catch (err) {
|
||||
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorSave') });
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setConfirmDelete(null);
|
||||
setTableMsg({ type: 'i', text: t('genai.deleting') });
|
||||
try {
|
||||
await genaiApi.remove(id);
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: t('genai.deletedSuccess') });
|
||||
if (editing?.id === id) resetForm();
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorDelete') });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
setTesting((p) => ({ ...p, [id]: true }));
|
||||
setTestResults((p) => { const n = { ...p }; delete n[id]; return n; });
|
||||
try {
|
||||
const res = await genaiApi.test(id);
|
||||
setTestResults((p) => ({
|
||||
...p,
|
||||
[id]: {
|
||||
type: res.status === 'success' ? 's' : 'e',
|
||||
text: res.message + (res.response ? ` — ${res.response}` : ''),
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
setTestResults((p) => ({ ...p, [id]: { type: 'e', text: err instanceof Error ? err.message : 'Erro' } }));
|
||||
} finally {
|
||||
setTesting((p) => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Group models by provider
|
||||
const groupedModels = Object.entries(models).reduce<Record<string, { key: string; name: string }[]>>((acc, [key, m]) => {
|
||||
const provider = m.provider || 'Other';
|
||||
if (!acc[provider]) acc[provider] = [];
|
||||
acc[provider].push({ key, name: m.name });
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const isFormVisible = formOpen || !!editing;
|
||||
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";
|
||||
const labelStyle = {};
|
||||
const hintCls = "text-[.64rem] mt-0.5";
|
||||
const hintStyle = { color: 'var(--t4)' };
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--ppl)' }}>
|
||||
<Brain size={24} style={{ color: 'var(--pp)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('genai.title')}</h1>
|
||||
<div className="subtitle">{t('genai.subtitle')}</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<span className="count">
|
||||
{configs.length} {configs.length === 1 ? t('genai.model') : t('genai.models')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--ppl)' }}>
|
||||
<Brain size={16} style={{ color: 'var(--pp)' }} />
|
||||
</div>
|
||||
<h2>{t('genai.configured')}</h2>
|
||||
</div>
|
||||
|
||||
{tableMsg && <div className="px-5 py-2"><Msg type={tableMsg.type} text={tableMsg.text} /></div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--t4)' }} />
|
||||
</div>
|
||||
) : configs.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<Brain size={28} style={{ color: 'var(--t4)', margin: '0 auto 8px' }} />
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>{t('genai.noModels')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{[t('genai.thConfig'), t('genai.thModel'), t('genai.thOciConfig'), t('genai.thRegion'), t('genai.thActions')].map((h) => (
|
||||
<th key={h}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{configs.map((c) => {
|
||||
const mi = models[c.model_id];
|
||||
const oci = ociCfg.find((o) => o.id === c.oci_config_id);
|
||||
return (
|
||||
<tr
|
||||
key={c.id}
|
||||
className="transition-colors"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--bd)',
|
||||
background: editing?.id === c.id ? 'color-mix(in srgb, var(--pp) 5%, var(--bg1))' : undefined,
|
||||
}}
|
||||
onMouseEnter={(e) => { if (editing?.id !== c.id) e.currentTarget.style.background = 'var(--bg2)'; }}
|
||||
onMouseLeave={(e) => { if (editing?.id !== c.id) e.currentTarget.style.background = ''; }}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold" style={{ color: 'var(--t1)' }}>{c.name || '\u2014'}</span>
|
||||
{c.is_default && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[.58rem] font-semibold"
|
||||
style={{ background: 'color-mix(in srgb, #f39c12 15%, transparent)', color: '#f39c12' }}>
|
||||
<Star size={8} /> {t('genai.default')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-xs" style={{ color: 'var(--t1)' }}>
|
||||
{mi?.name || (c.model_ocid ? 'OCID Personalizado' : c.model_id)}
|
||||
</div>
|
||||
<span className="inline-block mt-0.5 px-1.5 py-0.5 rounded text-[.58rem] font-semibold"
|
||||
style={{ background: 'var(--bg2)', color: 'var(--t4)' }}>
|
||||
{mi?.provider || (c.model_ocid ? 'custom' : '?')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[.7rem]" style={{ color: 'var(--t2)' }}>
|
||||
{oci?.tenancy_name || '\u2014'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-0.5 rounded text-[.64rem] font-medium" style={{ background: 'var(--bg2)', color: 'var(--t2)', fontFamily: 'var(--fm)' }}>
|
||||
{c.genai_region}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-1.5">
|
||||
<button onClick={() => startEdit(c)} className="btn btn-secondary btn-sm">
|
||||
<Pencil size={10} /> {t('oci.edit')}
|
||||
</button>
|
||||
<button onClick={() => handleTest(c.id)} disabled={testing[c.id]} className="btn btn-secondary btn-sm">
|
||||
{testing[c.id] ? <Loader2 size={10} className="animate-spin" /> : <FlaskConical size={10} />} {t('oci.test')}
|
||||
</button>
|
||||
{confirmDelete === c.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleDelete(c.id)} className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}>
|
||||
<Check size={10} /> {t('common.yes')}
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-sm">
|
||||
{t('common.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setConfirmDelete(c.id)} className="btn btn-danger btn-sm">
|
||||
<Trash2 size={10} /> {t('oci.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{testResults[c.id] && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded text-[.62rem] font-medium"
|
||||
style={{
|
||||
background: testResults[c.id].type === 's' ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: testResults[c.id].type === 's' ? 'var(--gn)' : 'var(--rd)',
|
||||
}}>
|
||||
{testResults[c.id].type === 's' ? <Check size={10} /> : <X size={10} />}
|
||||
<span className="truncate max-w-xs">{testResults[c.id].text}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div ref={formRef} className="card" style={{ overflow: 'hidden' }}>
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-5 py-3.5 cursor-pointer select-none"
|
||||
onClick={() => { if (!editing) setFormOpen(!formOpen); }}
|
||||
>
|
||||
<ChevronRight size={14} style={{ color: 'var(--t3)', transition: 'transform .2s', transform: isFormVisible ? 'rotate(90deg)' : 'rotate(0)' }} />
|
||||
<div className="flex items-center gap-2">
|
||||
{editing ? <Pencil size={14} style={{ color: 'var(--pp)' }} /> : <Plus size={14} style={{ color: 'var(--pp)' }} />}
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
|
||||
{editing ? `${t('genai.editModel')} — ${editing.name}` : t('genai.newModel')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFormVisible && (
|
||||
<div className="px-5 pb-5 flex flex-col gap-4" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<p className="text-[.7rem] pt-3" style={{ color: 'var(--t4)' }}>
|
||||
{editing
|
||||
? <>{t('genai.editingLabel')} <strong style={{ color: 'var(--t2)' }}>{editing.name}</strong></>
|
||||
: t('genai.addDesc')}
|
||||
</p>
|
||||
|
||||
{formMsg && <Msg type={formMsg.type} text={formMsg.text} />}
|
||||
|
||||
{/* Row 1 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('genai.configName')}</label>
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Meu Modelo Custom" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('genai.ociCredential')}</label>
|
||||
<select value={ociConfigId} onChange={(e) => fillFromOci(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
<option value="">{t('common.select')}</option>
|
||||
{ociCfg.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.tenancy_name} ({c.region})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('genai.modelSelect')}</label>
|
||||
<select value={modelId} onChange={(e) => setModelId(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
<option value="_custom"><LinkIcon size={10} className="inline" /> {t('genai.customOcid')}</option>
|
||||
{Object.entries(groupedModels).map(([provider, items]) => (
|
||||
<optgroup key={provider} label={provider}>
|
||||
{items.map((m) => (
|
||||
<option key={m.key} value={m.key}>{m.name} ({provider})</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('genai.genaiRegion')}</label>
|
||||
<select value={genaiRegion} onChange={(e) => setGenaiRegion(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
{regions.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('genai.compartment')}</label>
|
||||
<input type="text" value={compartmentId} onChange={(e) => setCompartmentId(e.target.value)} placeholder="ocid1.compartment.oc1.." className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('genai.modelOcid')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('genai.modelOcidHint')}</p>
|
||||
<input
|
||||
type="text"
|
||||
value={modelOcid}
|
||||
onChange={(e) => setModelOcid(e.target.value)}
|
||||
placeholder="ocid1.generativeaimodel.oc1.iad.amaaaaaa..."
|
||||
className={inputCls}
|
||||
style={{ ...inputStyle, borderColor: modelOcid ? 'var(--pp)' : undefined, fontFamily: 'var(--fm)', fontSize: '.72rem' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default toggle */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDefault(!isDefault)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-[.72rem] font-semibold transition-all"
|
||||
style={{
|
||||
background: isDefault ? 'color-mix(in srgb, #f39c12 15%, transparent)' : 'var(--bg2)',
|
||||
color: isDefault ? '#f39c12' : 'var(--t4)',
|
||||
border: `1.5px solid ${isDefault ? '#f39c12' : 'var(--bd)'}`,
|
||||
}}
|
||||
>
|
||||
<Star size={12} /> {isDefault ? t('genai.default') : t('genai.setDefault')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button onClick={handleSave} disabled={saving} className="btn btn-primary" style={{ background: 'var(--pp)', borderColor: 'var(--pp)' }}>
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{editing ? t('genai.saveChanges') : t('genai.saveModel')}
|
||||
</button>
|
||||
{editing && (
|
||||
<button onClick={resetForm} className="btn btn-secondary">
|
||||
{t('genai.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
532
frontend/src/pages/config/McpServersPage.tsx
Normal file
532
frontend/src/pages/config/McpServersPage.tsx
Normal file
@@ -0,0 +1,532 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { mcpApi, type McpServerFull, type McpServerPayload, type McpToolDef } from '@/api/endpoints/mcp';
|
||||
import {
|
||||
Plug, Plus, Pencil, Trash2, Check, X, Loader2, ChevronRight,
|
||||
Power, PowerOff, Search, Wrench, Terminal, Globe, FileCode2, ChevronDown, ChevronUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Msg ── */
|
||||
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 badge ── */
|
||||
function TypeBadge({ type }: { type: string }) {
|
||||
const cfg: Record<string, { icon: React.ReactNode; bg: string; fg: string }> = {
|
||||
stdio: { icon: <Terminal size={10} />, bg: 'color-mix(in srgb, var(--ac) 12%, transparent)', fg: 'var(--ac)' },
|
||||
sse: { icon: <Globe size={10} />, bg: 'color-mix(in srgb, var(--gn) 12%, transparent)', fg: 'var(--gn)' },
|
||||
module: { icon: <FileCode2 size={10} />, bg: 'color-mix(in srgb, #e67e22 12%, transparent)', fg: '#e67e22' },
|
||||
};
|
||||
const c = cfg[type] || cfg.stdio;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.62rem] font-semibold" style={{ background: c.bg, color: c.fg }}>
|
||||
{c.icon} {type}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
export default function McpServersPage() {
|
||||
const { adbCfg } = useAppStore();
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === 'admin');
|
||||
const { t } = useI18n();
|
||||
|
||||
const [servers, setServers] = useState<McpServerFull[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Form
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<McpServerFull | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formMsg, setFormMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Form fields
|
||||
const [sName, setSName] = useState('');
|
||||
const [sDesc, setSDesc] = useState('');
|
||||
const [sType, setSType] = useState<'stdio' | 'sse' | 'module'>('stdio');
|
||||
const [sCmd, setSCmd] = useState('');
|
||||
const [sArgs, setSArgs] = useState('');
|
||||
const [sUrl, setSUrl] = useState('');
|
||||
const [sAdb, setSAdb] = useState('');
|
||||
|
||||
// Table
|
||||
const [tableMsg, setTableMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [toggling, setToggling] = useState<Record<string, boolean>>({});
|
||||
const [discovering, setDiscovering] = useState<Record<string, boolean>>({});
|
||||
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>({});
|
||||
|
||||
const fetchServers = useCallback(async () => {
|
||||
try {
|
||||
const data = await mcpApi.list();
|
||||
setServers(data);
|
||||
} catch {} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchServers(); }, [fetchServers]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setFormOpen(false);
|
||||
setSName('');
|
||||
setSDesc('');
|
||||
setSType('stdio');
|
||||
setSCmd('');
|
||||
setSArgs('');
|
||||
setSUrl('');
|
||||
setSAdb('');
|
||||
setFormMsg(null);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const startEdit = (srv: McpServerFull) => {
|
||||
setEditing(srv);
|
||||
setSName(srv.name);
|
||||
setSDesc(srv.description || '');
|
||||
setSType((srv.server_type || 'stdio') as 'stdio' | 'sse' | 'module');
|
||||
setSCmd(srv.command || '');
|
||||
setSArgs(srv.args ? JSON.stringify(srv.args) : '');
|
||||
setSUrl(srv.url || '');
|
||||
setSAdb(srv.linked_adb_id || '');
|
||||
setFormOpen(true);
|
||||
setFormMsg(null);
|
||||
setTimeout(() => formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!sName.trim()) { setFormMsg({ type: 'e', text: t('mcp.fillName') }); return; }
|
||||
|
||||
let args: string[] | null = null;
|
||||
if (sArgs.trim()) {
|
||||
try { args = JSON.parse(sArgs); } catch { setFormMsg({ type: 'e', text: t('mcp.invalidArgs') }); return; }
|
||||
}
|
||||
|
||||
const body: McpServerPayload = {
|
||||
name: sName.trim(),
|
||||
description: sDesc.trim() || null,
|
||||
server_type: sType,
|
||||
command: sType !== 'sse' ? (sCmd.trim() || null) : null,
|
||||
url: sType === 'sse' ? (sUrl.trim() || null) : null,
|
||||
args,
|
||||
linked_adb_id: sAdb || null,
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
setFormMsg({ type: 'i', text: editing ? t('mcp.updating') : t('mcp.saving') });
|
||||
|
||||
try {
|
||||
if (editing) {
|
||||
await mcpApi.update(editing.id, body);
|
||||
} else {
|
||||
await mcpApi.create(body);
|
||||
}
|
||||
setFormMsg({ type: 's', text: editing ? t('mcp.updatedSuccess') : t('mcp.savedSuccess') });
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
setTimeout(resetForm, 1200);
|
||||
} catch (err) {
|
||||
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorSave') });
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setConfirmDelete(null);
|
||||
setTableMsg({ type: 'i', text: t('mcp.deletingServer') });
|
||||
try {
|
||||
await mcpApi.remove(id);
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: t('mcp.deletedSuccess') });
|
||||
if (editing?.id === id) resetForm();
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorDelete') });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (id: string) => {
|
||||
setToggling((p) => ({ ...p, [id]: true }));
|
||||
try {
|
||||
await mcpApi.toggle(id);
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
} finally {
|
||||
setToggling((p) => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscover = async (id: string) => {
|
||||
setDiscovering((p) => ({ ...p, [id]: true }));
|
||||
setTableMsg({ type: 'i', text: t('mcp.discovering') });
|
||||
try {
|
||||
const res = await mcpApi.discoverTools(id);
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: t('mcp.discoveredTools').replace('{0}', String(res.discovered)).replace('{1}', String(res.total)) });
|
||||
setExpandedTools((p) => ({ ...p, [id]: true }));
|
||||
setTimeout(() => setTableMsg(null), 4000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('mcp.errorDiscoverTools') });
|
||||
} finally {
|
||||
setDiscovering((p) => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTool = async (srvId: string, idx: number) => {
|
||||
const srv = servers.find((s) => s.id === srvId);
|
||||
if (!srv?.tools) return;
|
||||
const tool = srv.tools[idx];
|
||||
const toolName = typeof tool === 'string' ? tool : (tool as McpToolDef).name;
|
||||
if (!confirm(t('mcp.removeToolConfirm').replace('{0}', toolName))) return;
|
||||
const newTools = [...srv.tools];
|
||||
newTools.splice(idx, 1);
|
||||
try {
|
||||
await mcpApi.updateTools(srvId, newTools);
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
}
|
||||
};
|
||||
|
||||
const isFormVisible = formOpen || !!editing;
|
||||
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";
|
||||
const labelStyle = {};
|
||||
const hintCls = "text-[.64rem] mt-0.5";
|
||||
const hintStyle = { color: 'var(--t4)' };
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--gnl)' }}>
|
||||
<Plug size={24} style={{ color: 'var(--gn)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('mcp.title')}</h1>
|
||||
<div className="subtitle">{t('mcp.subtitle')}</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<span className="count">
|
||||
{servers.length} {servers.length === 1 ? 'server' : 'servers'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Server cards */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--gnl)' }}>
|
||||
<Plug size={16} style={{ color: 'var(--gn)' }} />
|
||||
</div>
|
||||
<h2>{t('mcp.registered')}</h2>
|
||||
</div>
|
||||
|
||||
{tableMsg && <div className="px-5 py-2"><Msg type={tableMsg.type} text={tableMsg.text} /></div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--t4)' }} />
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<Plug size={28} style={{ color: 'var(--t4)', margin: '0 auto 8px' }} />
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>{t('mcp.noServers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{servers.map((srv) => {
|
||||
const tools: McpToolDef[] = Array.isArray(srv.tools) ? srv.tools : [];
|
||||
const isExpanded = expandedTools[srv.id];
|
||||
const adb = srv.linked_adb_id ? adbCfg.find((a) => a.id === srv.linked_adb_id) : null;
|
||||
return (
|
||||
<div
|
||||
key={srv.id}
|
||||
className="px-5 py-4 transition-colors"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--bd)',
|
||||
background: editing?.id === srv.id ? 'color-mix(in srgb, var(--gn) 5%, var(--bg1))' : undefined,
|
||||
}}
|
||||
>
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<strong className="text-[.88rem]" style={{ color: 'var(--t1)' }}>{srv.name}</strong>
|
||||
<TypeBadge type={srv.server_type} />
|
||||
{(srv as any).is_global === 1 && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[.55rem] font-bold" style={{ background: 'var(--bll)', color: 'var(--bl)' }}>GLOBAL</span>
|
||||
)}
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.6rem] font-semibold"
|
||||
style={{
|
||||
background: srv.is_active ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: srv.is_active ? 'var(--gn)' : 'var(--rd)',
|
||||
}}
|
||||
>
|
||||
{srv.is_active ? <Power size={8} /> : <PowerOff size={8} />}
|
||||
{srv.is_active ? t('mcp.active') : t('mcp.inactive')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{(isAdmin || !(srv as any).is_global) && (
|
||||
<>
|
||||
<button onClick={() => startEdit(srv)} className="btn btn-secondary btn-sm">
|
||||
<Pencil size={10} /> {t('mcp.edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggle(srv.id)}
|
||||
disabled={toggling[srv.id]}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
{toggling[srv.id] ? <Loader2 size={10} className="animate-spin" /> : srv.is_active ? <PowerOff size={10} /> : <Power size={10} />}
|
||||
{srv.is_active ? t('mcp.deactivate') : t('mcp.activate')}
|
||||
</button>
|
||||
{confirmDelete === srv.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleDelete(srv.id)} className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}>
|
||||
<Check size={10} /> {t('common.yes')}
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-sm">
|
||||
{t('common.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setConfirmDelete(srv.id)} className="btn btn-danger btn-sm">
|
||||
<Trash2 size={10} /> {t('mcp.delete')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{srv.description && (
|
||||
<div className="text-[.72rem] mb-2" style={{ color: 'var(--t3)' }}>{srv.description}</div>
|
||||
)}
|
||||
|
||||
{/* Details row */}
|
||||
<div className="flex gap-4 flex-wrap text-[.72rem] mb-2" style={{ color: 'var(--t4)' }}>
|
||||
{srv.command && (
|
||||
<div>
|
||||
<span className="font-semibold" style={{ color: 'var(--t3)' }}>{t('mcp.command')}</span>{' '}
|
||||
<code className="text-[.66rem]" style={{ fontFamily: 'var(--fm)' }}>{srv.command}</code>
|
||||
</div>
|
||||
)}
|
||||
{srv.url && (
|
||||
<div>
|
||||
<span className="font-semibold" style={{ color: 'var(--t3)' }}>{t('mcp.url')}</span>{' '}
|
||||
<code className="text-[.66rem]" style={{ fontFamily: 'var(--fm)' }}>{srv.url}</code>
|
||||
</div>
|
||||
)}
|
||||
{srv.module_path && (
|
||||
<div>
|
||||
<span className="font-semibold" style={{ color: 'var(--t3)' }}>{t('mcp.module')}</span>{' '}
|
||||
<code className="text-[.66rem]" style={{ fontFamily: 'var(--fm)' }}>{srv.module_path}</code>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-semibold" style={{ color: 'var(--t3)' }}>{t('mcp.adb')}</span>{' '}
|
||||
{adb ? (
|
||||
<span className="px-1.5 py-0.5 rounded text-[.6rem] font-semibold" style={{ background: 'var(--gnl)', color: 'var(--gn)' }}>{adb.config_name}</span>
|
||||
) : t('mcp.noAdb')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools section */}
|
||||
<div className="mt-2" style={{ borderTop: '1px solid var(--bd)', paddingTop: 8 }}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer select-none mb-1"
|
||||
onClick={() => setExpandedTools((p) => ({ ...p, [srv.id]: !p[srv.id] }))}
|
||||
>
|
||||
<Wrench size={12} style={{ color: 'var(--t2)' }} />
|
||||
<span className="text-[.74rem] font-semibold" style={{ color: 'var(--t2)' }}>
|
||||
{t('mcp.tools')} ({tools.length})
|
||||
</span>
|
||||
{isExpanded ? <ChevronUp size={12} style={{ color: 'var(--t4)' }} /> : <ChevronDown size={12} style={{ color: 'var(--t4)' }} />}
|
||||
</div>
|
||||
|
||||
{isExpanded && tools.length > 0 && (
|
||||
<div className="flex flex-col gap-1 mt-1">
|
||||
{tools.map((tool, i) => (
|
||||
<div key={i} className="flex items-start gap-2 px-3 py-1.5 rounded-md text-[.72rem]" style={{ background: 'var(--bg)', border: '1px solid var(--bd)' }}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<strong style={{ color: 'var(--t1)' }}>{typeof tool === 'string' ? tool : tool.name}</strong>
|
||||
{typeof tool === 'object' && tool.description && (
|
||||
<span style={{ color: 'var(--t4)' }}> — {tool.description}</span>
|
||||
)}
|
||||
{typeof tool === 'object' && tool.input_schema && (tool.input_schema as { properties?: Record<string, unknown> }).properties && (
|
||||
<div className="text-[.64rem] mt-0.5" style={{ color: 'var(--t4)' }}>
|
||||
Params: {Object.keys((tool.input_schema as { properties: Record<string, unknown> }).properties).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveTool(srv.id, i)}
|
||||
className="flex-shrink-0 px-1.5 py-0.5 rounded text-[.6rem] font-semibold transition-colors"
|
||||
style={{ color: 'var(--rd)', background: 'color-mix(in srgb, var(--rd) 8%, transparent)' }}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isExpanded && tools.length === 0 && (
|
||||
<div className="text-[.72rem] py-1" style={{ color: 'var(--t4)' }}>{t('mcp.noTools')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2 mt-3 flex-wrap">
|
||||
<button
|
||||
onClick={() => handleDiscover(srv.id)}
|
||||
disabled={discovering[srv.id]}
|
||||
className="btn btn-success btn-sm"
|
||||
>
|
||||
{discovering[srv.id] ? <Loader2 size={12} className="animate-spin" /> : <Search size={12} />}
|
||||
{t('mcp.discoverTools')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div ref={formRef} className="card" style={{ overflow: 'hidden' }}>
|
||||
<div className="flex items-center gap-2.5 px-5 py-3.5 cursor-pointer select-none" onClick={() => { if (!editing) setFormOpen(!formOpen); }}>
|
||||
<ChevronRight size={14} style={{ color: 'var(--t3)', transition: 'transform .2s', transform: isFormVisible ? 'rotate(90deg)' : 'rotate(0)' }} />
|
||||
<div className="flex items-center gap-2">
|
||||
{editing ? <Pencil size={14} style={{ color: 'var(--gn)' }} /> : <Plus size={14} style={{ color: 'var(--gn)' }} />}
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
|
||||
{editing ? `${t('mcp.editServer')} — ${editing.name}` : t('mcp.newServer')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFormVisible && (
|
||||
<div className="px-5 pb-5 flex flex-col gap-4" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<p className="text-[.7rem] pt-3" style={{ color: 'var(--t4)' }}>
|
||||
{editing
|
||||
? <>{t('mcp.editingLabel')} <strong style={{ color: 'var(--t2)' }}>{editing.name}</strong></>
|
||||
: t('mcp.configDesc')}
|
||||
</p>
|
||||
|
||||
{formMsg && <Msg type={formMsg.type} text={formMsg.text} />}
|
||||
|
||||
{/* Name + description */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.name')}</label>
|
||||
<input type="text" value={sName} onChange={(e) => setSName(e.target.value)} placeholder="CIS Benchmark Server" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.description')}</label>
|
||||
<input type="text" value={sDesc} onChange={(e) => setSDesc(e.target.value)} placeholder="Executa checks CIS 3.0" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server type */}
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.serverType')}</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
{(['stdio', 'sse', 'module'] as const).map((tp) => {
|
||||
const icons = { stdio: <Terminal size={12} />, sse: <Globe size={12} />, module: <FileCode2 size={12} /> };
|
||||
const labels = { stdio: 'stdio', sse: 'SSE (HTTP)', module: 'Python Module' };
|
||||
const selected = sType === tp;
|
||||
return (
|
||||
<button
|
||||
key={tp}
|
||||
type="button"
|
||||
onClick={() => setSType(tp)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.72rem] font-semibold transition-all"
|
||||
style={{
|
||||
background: selected ? 'color-mix(in srgb, var(--gn) 15%, transparent)' : 'var(--bg2)',
|
||||
color: selected ? 'var(--gn)' : 'var(--t4)',
|
||||
border: `1.5px solid ${selected ? 'var(--gn)' : 'var(--bd)'}`,
|
||||
}}
|
||||
>
|
||||
{icons[tp]} {labels[tp]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conditional fields */}
|
||||
{sType !== 'sse' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.commandLabel')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('mcp.commandHint')}</p>
|
||||
<input type="text" value={sCmd} onChange={(e) => setSCmd(e.target.value)} placeholder="python3 server.py" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.argsLabel')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('mcp.argsHint')}</p>
|
||||
<input type="text" value={sArgs} onChange={(e) => setSArgs(e.target.value)} placeholder='["--config", "/path/to/config"]' className={inputCls} style={{ ...inputStyle, fontFamily: 'var(--fm)', fontSize: '.72rem' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sType === 'sse' && (
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.urlLabel')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('mcp.urlHint')}</p>
|
||||
<input type="text" value={sUrl} onChange={(e) => setSUrl(e.target.value)} placeholder="http://localhost:8001/sse" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ADB link */}
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.linkAdb')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('mcp.linkAdbHint')}</p>
|
||||
<select value={sAdb} onChange={(e) => setSAdb(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
<option value="">{t('mcp.noAdb')}</option>
|
||||
{adbCfg.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.config_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button onClick={handleSave} disabled={saving} className="btn btn-success">
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{editing ? t('mcp.saveChanges') : t('mcp.saveServer')}
|
||||
</button>
|
||||
{editing && (
|
||||
<button onClick={resetForm} className="btn btn-secondary">
|
||||
{t('mcp.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
775
frontend/src/pages/config/OciConfigPage.tsx
Normal file
775
frontend/src/pages/config/OciConfigPage.tsx
Normal file
@@ -0,0 +1,775 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { ociApi, type OciConfigFull, type OciTestResult } from '@/api/endpoints/oci';
|
||||
import {
|
||||
Cloud, ChevronRight, Key, Lock, Plus, Pencil,
|
||||
Trash2, FlaskConical, Building2, User, Package,
|
||||
Check, X, Loader2, Search,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Static fallback OCI regions ── */
|
||||
const FALLBACK_REGIONS: Record<string, string[]> = {
|
||||
'Americas': [
|
||||
'us-ashburn-1', 'us-phoenix-1', 'us-sanjose-1', 'us-chicago-1',
|
||||
'ca-toronto-1', 'ca-montreal-1', 'sa-saopaulo-1', 'sa-vinhedo-1',
|
||||
'sa-santiago-1', 'sa-bogota-1', 'mx-queretaro-1', 'mx-monterrey-1',
|
||||
],
|
||||
'Europe': [
|
||||
'eu-frankfurt-1', 'eu-amsterdam-1', 'eu-zurich-1', 'eu-madrid-1',
|
||||
'eu-marseille-1', 'eu-milan-1', 'eu-stockholm-1', 'eu-paris-1',
|
||||
'uk-london-1', 'uk-cardiff-1', 'eu-jovanovac-1', 'eu-dcc-milan-1',
|
||||
],
|
||||
'Asia Pacific': [
|
||||
'ap-tokyo-1', 'ap-osaka-1', 'ap-seoul-1', 'ap-sydney-1',
|
||||
'ap-melbourne-1', 'ap-mumbai-1', 'ap-hyderabad-1', 'ap-singapore-1',
|
||||
'ap-chuncheon-1', 'ap-singapore-2',
|
||||
],
|
||||
'Middle East & Africa': [
|
||||
'me-jeddah-1', 'me-dubai-1', 'me-abudhabi-1',
|
||||
'af-johannesburg-1', 'il-jerusalem-1',
|
||||
],
|
||||
};
|
||||
|
||||
type AuthType = 'api_key' | 'session_token';
|
||||
|
||||
/* ── Msg component ── */
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Region Dropdown ── */
|
||||
function RegionDropdown({
|
||||
value, onChange, regionsMap, t,
|
||||
}: { value: string; onChange: (r: string) => void; regionsMap: Record<string, string[]>; t: (key: string) => string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const f = filter.toLowerCase();
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<div
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center justify-between cursor-pointer px-3 py-2 rounded-lg text-[.78rem]"
|
||||
style={{ background: 'var(--bg2)', border: '1.5px solid var(--bd)', minHeight: 36 }}
|
||||
>
|
||||
<span style={{ color: value ? 'var(--t1)' : 'var(--t4)' }}>
|
||||
{value || t('oci.selectRegion')}
|
||||
</span>
|
||||
<span className="text-[.6rem]" style={{ color: 'var(--t4)' }}>▼</span>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute top-full left-0 right-0 rounded-lg overflow-hidden z-50"
|
||||
style={{ background: 'var(--bg)', border: '1px solid var(--bd)', boxShadow: '0 4px 16px rgba(0,0,0,.25)', maxHeight: 280 }}
|
||||
>
|
||||
<div className="p-1.5" style={{ borderBottom: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center gap-1.5 px-2" style={{ background: 'var(--bg2)', borderRadius: 6 }}>
|
||||
<Search size={12} style={{ color: 'var(--t4)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('oci.searchRegion')}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full border-none outline-none text-xs py-1.5"
|
||||
style={{ background: 'transparent', color: 'var(--t1)' }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto" style={{ maxHeight: 230 }}>
|
||||
{Object.entries(regionsMap).map(([grp, regs]) => {
|
||||
const filtered = regs.filter((r) => !f || r.includes(f));
|
||||
if (!filtered.length) return null;
|
||||
return (
|
||||
<div key={grp}>
|
||||
<div
|
||||
className="px-3 py-1 text-[.62rem] font-semibold uppercase tracking-wider"
|
||||
style={{ color: 'var(--t4)', background: 'var(--bg1)' }}
|
||||
>
|
||||
{grp}
|
||||
</div>
|
||||
{filtered.map((r) => (
|
||||
<div
|
||||
key={r}
|
||||
onClick={() => { onChange(r); setOpen(false); setFilter(''); }}
|
||||
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
|
||||
style={{
|
||||
color: value === r ? 'var(--ac)' : 'var(--t2)',
|
||||
background: value === r ? 'color-mix(in srgb, var(--ac) 8%, transparent)' : undefined,
|
||||
}}
|
||||
onMouseEnter={(e) => { if (value !== r) (e.target as HTMLDivElement).style.background = 'var(--bg2)'; }}
|
||||
onMouseLeave={(e) => { if (value !== r) (e.target as HTMLDivElement).style.background = ''; }}
|
||||
>
|
||||
{r}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Object.entries(regionsMap).every(([, regs]) => !regs.some((r) => !f || r.includes(f))) && (
|
||||
<div className="p-3 text-xs" style={{ color: 'var(--t4)' }}>{t('oci.noRegion')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Auth Type Tag ── */
|
||||
function AuthTag({ type }: { type: string }) {
|
||||
const isToken = type === 'session_token';
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.62rem] font-semibold"
|
||||
style={{
|
||||
background: isToken ? 'rgba(230,126,34,0.12)' : 'var(--gnl)',
|
||||
color: isToken ? '#e67e22' : 'var(--gn)',
|
||||
}}
|
||||
>
|
||||
{isToken ? <Lock size={10} /> : <Key size={10} />}
|
||||
{isToken ? 'Session Token' : 'API Key'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main Page ── */
|
||||
export default function OciConfigPage() {
|
||||
const ociRegions = useAppStore((s) => s.ociRegions);
|
||||
const { t } = useI18n();
|
||||
|
||||
const [configs, setConfigs] = useState<OciConfigFull[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Form state
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<OciConfigFull | null>(null);
|
||||
const [authType, setAuthType] = useState<AuthType>('api_key');
|
||||
const [region, setRegion] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formMsg, setFormMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
|
||||
// Table msg
|
||||
const [tableMsg, setTableMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
|
||||
// Test results per config
|
||||
const [testResults, setTestResults] = useState<Record<string, OciTestResult>>({});
|
||||
const [testing, setTesting] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Delete confirm
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
|
||||
// Form refs
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
const tnRef = useRef<HTMLInputElement>(null);
|
||||
const toRef = useRef<HTMLInputElement>(null);
|
||||
const uoRef = useRef<HTMLInputElement>(null);
|
||||
const fpRef = useRef<HTMLInputElement>(null);
|
||||
const cpRef = useRef<HTMLInputElement>(null);
|
||||
const kpRef = useRef<HTMLInputElement>(null);
|
||||
const skRef = useRef<HTMLInputElement>(null);
|
||||
const pkRef = useRef<HTMLInputElement>(null);
|
||||
const stkRef = useRef<HTMLTextAreaElement>(null);
|
||||
const sshRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Build regions map
|
||||
const regionsMap: Record<string, string[]> = (() => {
|
||||
if (ociRegions && typeof ociRegions === 'object') {
|
||||
const entries = Object.entries(ociRegions);
|
||||
if (entries.length > 0) {
|
||||
// ociRegions can be { group: string[] } or { group: string }
|
||||
// From the store it's Record<string,string>, but the backend returns Record<string,string[]>
|
||||
const first = entries[0][1];
|
||||
if (Array.isArray(first)) return ociRegions as unknown as Record<string, string[]>;
|
||||
// If it's flat, return as single-group
|
||||
const flat = entries.map(([, v]) => v as unknown as string);
|
||||
if (flat.length > 0) return { 'Regioes': flat };
|
||||
}
|
||||
}
|
||||
return FALLBACK_REGIONS;
|
||||
})();
|
||||
|
||||
const fetchConfigs = useCallback(async () => {
|
||||
try {
|
||||
const data = await ociApi.list();
|
||||
setConfigs(data);
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchConfigs(); }, [fetchConfigs]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setFormOpen(false);
|
||||
setAuthType('api_key');
|
||||
setRegion('');
|
||||
setFormMsg(null);
|
||||
setSaving(false);
|
||||
// Clear file inputs
|
||||
if (skRef.current) skRef.current.value = '';
|
||||
if (pkRef.current) pkRef.current.value = '';
|
||||
};
|
||||
|
||||
const startEdit = (cfg: OciConfigFull) => {
|
||||
setEditing(cfg);
|
||||
setAuthType(cfg.auth_type || 'api_key');
|
||||
setRegion(cfg.region);
|
||||
setFormOpen(true);
|
||||
setFormMsg(null);
|
||||
setTimeout(() => formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const tenancyName = tnRef.current?.value?.trim() || '';
|
||||
if (!tenancyName) { setFormMsg({ type: 'e', text: `${t('oci.fillField')} Tenancy Name` }); return; }
|
||||
|
||||
const isEdit = !!editing;
|
||||
const at = isEdit ? (editing!.auth_type || 'api_key') : authType;
|
||||
const isToken = at === 'session_token';
|
||||
|
||||
const tenancyOcid = toRef.current?.value?.trim() || '';
|
||||
const userOcid = uoRef.current?.value?.trim() || '';
|
||||
const fingerprint = fpRef.current?.value?.trim() || '';
|
||||
const compartmentId = cpRef.current?.value?.trim() || '';
|
||||
const keyPassphrase = kpRef.current?.value?.trim() || '';
|
||||
const securityToken = stkRef.current?.value?.trim() || '';
|
||||
const sshPubKey = sshRef.current?.value?.trim() || '';
|
||||
const privateKeyFile = skRef.current?.files?.[0];
|
||||
const publicKeyFile = pkRef.current?.files?.[0];
|
||||
|
||||
// Validations for new config
|
||||
if (!isEdit) {
|
||||
if (!tenancyOcid) { setFormMsg({ type: 'e', text: t('oci.fillOcidTenancy') }); return; }
|
||||
if (!isToken && (!userOcid || !fingerprint)) { setFormMsg({ type: 'e', text: t('oci.fillUserAndFP') }); return; }
|
||||
if (isToken && !securityToken) { setFormMsg({ type: 'e', text: t('oci.pasteToken') }); return; }
|
||||
if (!privateKeyFile) { setFormMsg({ type: 'e', text: isToken ? t('oci.selectSessionKey') : t('oci.selectKey') }); return; }
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('tenancy_name', tenancyName);
|
||||
fd.append('tenancy_ocid', tenancyOcid);
|
||||
fd.append('user_ocid', userOcid);
|
||||
fd.append('fingerprint', fingerprint);
|
||||
fd.append('region', region);
|
||||
fd.append('compartment_id', compartmentId);
|
||||
fd.append('key_passphrase', keyPassphrase);
|
||||
fd.append('ssh_public_key', sshPubKey);
|
||||
fd.append('auth_type', at);
|
||||
fd.append('security_token', securityToken);
|
||||
if (privateKeyFile) fd.append('private_key', privateKeyFile);
|
||||
if (publicKeyFile) fd.append('public_key', publicKeyFile);
|
||||
|
||||
setSaving(true);
|
||||
setFormMsg({ type: 'i', text: t('oci.saving') });
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await ociApi.update(editing!.id, fd);
|
||||
} else {
|
||||
await ociApi.create(fd);
|
||||
}
|
||||
setFormMsg({ type: 's', text: isEdit ? t('oci.updated') : t('oci.saved') });
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTimeout(resetForm, 1200);
|
||||
} catch (err) {
|
||||
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorSave') });
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setConfirmDelete(null);
|
||||
setTableMsg({ type: 'i', text: t('oci.deleting') });
|
||||
try {
|
||||
await ociApi.remove(id);
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: t('oci.deleted') });
|
||||
if (editing?.id === id) resetForm();
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorDelete') });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
setTesting((p) => ({ ...p, [id]: true }));
|
||||
setTestResults((p) => { const n = { ...p }; delete n[id]; return n; });
|
||||
try {
|
||||
const res = await ociApi.test(id);
|
||||
setTestResults((p) => ({ ...p, [id]: res }));
|
||||
} catch (err) {
|
||||
setTestResults((p) => ({ ...p, [id]: { status: 'error', message: err instanceof Error ? err.message : 'Erro' } }));
|
||||
} finally {
|
||||
setTesting((p) => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const isFormVisible = formOpen || !!editing;
|
||||
const currentAuthType = editing ? (editing.auth_type || 'api_key') : authType;
|
||||
const isToken = currentAuthType === 'session_token';
|
||||
|
||||
/* ── Input helper ── */
|
||||
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";
|
||||
const labelStyle = {};
|
||||
const hintCls = "text-[.64rem] mt-0.5";
|
||||
const hintStyle = { color: 'var(--t4)' };
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<Cloud size={24} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('oci.title')}</h1>
|
||||
<div className="subtitle">{t('oci.subtitle')}</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<span className="count">
|
||||
{configs.length} {configs.length === 1 ? t('oci.credential') : t('oci.credentials')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Table ── */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<Cloud size={16} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<h2>{t('oci.registered')}</h2>
|
||||
</div>
|
||||
|
||||
{tableMsg && <div className="px-5 py-2">{<Msg type={tableMsg.type} text={tableMsg.text} />}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--t4)' }} />
|
||||
</div>
|
||||
) : configs.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<Cloud size={28} style={{ color: 'var(--t4)', margin: '0 auto 8px' }} />
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>{t('oci.noCredentials')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{[t('oci.thTenancy'), t('oci.thType'), t('oci.thRegion'), t('oci.thDetails'), t('oci.thActions')].map((h) => (
|
||||
<th key={h}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{configs.map((c) => (
|
||||
<tr
|
||||
key={c.id}
|
||||
className="transition-colors"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--bd)',
|
||||
background: editing?.id === c.id ? 'color-mix(in srgb, var(--ac) 5%, var(--bg1))' : undefined,
|
||||
}}
|
||||
onMouseEnter={(e) => { if (editing?.id !== c.id) (e.currentTarget.style.background = 'var(--bg2)'); }}
|
||||
onMouseLeave={(e) => { if (editing?.id !== c.id) (e.currentTarget.style.background = ''); }}
|
||||
>
|
||||
{/* Tenancy */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-xs font-semibold" style={{ color: 'var(--t1)' }}>{c.tenancy_name}</div>
|
||||
<div className="text-[.6rem] mt-0.5" style={{ color: 'var(--t4)' }}>{c.created_at}</div>
|
||||
</td>
|
||||
{/* Auth Type */}
|
||||
<td className="px-4 py-3"><AuthTag type={c.auth_type} /></td>
|
||||
{/* Region */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-0.5 rounded text-[.64rem] font-medium" style={{ background: 'var(--bg2)', color: 'var(--t2)' }}>
|
||||
{c.region}
|
||||
</span>
|
||||
</td>
|
||||
{/* Details */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-0.5 text-[.62rem] leading-relaxed" style={{ color: 'var(--t4)', fontFamily: 'var(--fm)' }}>
|
||||
{(c.auth_type || 'api_key') === 'session_token' ? (
|
||||
<>
|
||||
<span className="flex items-center gap-1" title="Tenancy OCID"><Building2 size={10} /> {c.tenancy_ocid}</span>
|
||||
<span className="flex items-center gap-1" title="Auth"><Lock size={10} /> Session Token</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex items-center gap-1" title="Fingerprint"><Key size={10} /> {'*'.repeat(20)}</span>
|
||||
<span className="flex items-center gap-1" title="Tenancy OCID"><Building2 size={10} /> {c.tenancy_ocid}</span>
|
||||
<span className="flex items-center gap-1" title="User OCID"><User size={10} /> {c.user_ocid}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="flex items-center gap-1" title="Compartment"><Package size={10} /> {c.compartment_id || '\u2014'}</span>
|
||||
<span className="flex items-center gap-1" title="SSH Public Key"><Key size={10} /> SSH: {c.ssh_public_key_preview || <em>{t('oci.sshNotConfigured')}</em>}</span>
|
||||
</div>
|
||||
</td>
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={() => startEdit(c)}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
<Pencil size={10} /> {t('oci.edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTest(c.id)}
|
||||
disabled={testing[c.id]}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
{testing[c.id] ? <Loader2 size={10} className="animate-spin" /> : <FlaskConical size={10} />} {t('oci.test')}
|
||||
</button>
|
||||
{confirmDelete === c.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}
|
||||
>
|
||||
<Check size={10} /> {t('oci.yes')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
{t('oci.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(c.id)}
|
||||
className="btn btn-danger btn-sm"
|
||||
>
|
||||
<Trash2 size={10} /> {t('oci.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Test result inline */}
|
||||
{testResults[c.id] && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded text-[.62rem] font-medium"
|
||||
style={{
|
||||
background: testResults[c.id].status === 'success' ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: testResults[c.id].status === 'success' ? 'var(--gn)' : 'var(--rd)',
|
||||
}}
|
||||
>
|
||||
{testResults[c.id].status === 'success' ? <Check size={10} /> : <X size={10} />}
|
||||
{testResults[c.id].message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Form Card ── */}
|
||||
<div ref={formRef} className="card" style={{ overflow: 'hidden' }}>
|
||||
{/* Collapsible header */}
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-5 py-3.5 cursor-pointer select-none"
|
||||
onClick={() => { if (!editing) setFormOpen(!formOpen); }}
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
style={{ color: 'var(--t3)', transition: 'transform .2s', transform: isFormVisible ? 'rotate(90deg)' : 'rotate(0)' }}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{editing ? <Pencil size={14} style={{ color: 'var(--ac)' }} /> : <Plus size={14} style={{ color: 'var(--ac)' }} />}
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
|
||||
{editing ? t('oci.editCredential') : t('oci.newCredential')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFormVisible && (
|
||||
<div className="px-5 pb-5 flex flex-col gap-4" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-[.7rem] pt-3" style={{ color: 'var(--t4)' }}>
|
||||
{editing
|
||||
? <>{t('oci.editing')} <strong style={{ color: 'var(--t2)' }}>{editing.tenancy_name}</strong></>
|
||||
: t('oci.addDesc')}
|
||||
</p>
|
||||
|
||||
{/* Form message */}
|
||||
{formMsg && <Msg type={formMsg.type} text={formMsg.text} />}
|
||||
|
||||
{/* Auth type toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => { if (!editing) setAuthType('api_key'); }}
|
||||
disabled={!!editing}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.72rem] font-semibold transition-all disabled:opacity-70"
|
||||
style={{
|
||||
background: !isToken ? 'color-mix(in srgb, var(--ac) 15%, transparent)' : 'var(--bg2)',
|
||||
color: !isToken ? 'var(--ac)' : 'var(--t4)',
|
||||
border: `1.5px solid ${!isToken ? 'var(--ac)' : 'var(--bd)'}`,
|
||||
}}
|
||||
>
|
||||
<Key size={12} /> API Key
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (!editing) setAuthType('session_token'); }}
|
||||
disabled={!!editing}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.72rem] font-semibold transition-all disabled:opacity-70"
|
||||
style={{
|
||||
background: isToken ? 'color-mix(in srgb, #e67e22 15%, transparent)' : 'var(--bg2)',
|
||||
color: isToken ? '#e67e22' : 'var(--t4)',
|
||||
border: `1.5px solid ${isToken ? '#e67e22' : 'var(--bd)'}`,
|
||||
}}
|
||||
>
|
||||
<Lock size={12} /> Session Token
|
||||
</button>
|
||||
{editing && (
|
||||
<span className="text-[.6rem]" style={{ color: 'var(--t4)' }}>{t('oci.typeCannotChange')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 1: Tenancy Name + OCID Tenancy */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.tenancyName')}</label>
|
||||
<input
|
||||
ref={tnRef}
|
||||
type="text"
|
||||
placeholder="minha-empresa"
|
||||
defaultValue={editing?.tenancy_name || ''}
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.ocidTenancy')}</label>
|
||||
<input
|
||||
ref={toRef}
|
||||
type="password"
|
||||
placeholder="ocid1.tenancy.oc1.."
|
||||
autoComplete="off"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{editing && (
|
||||
<p className={hintCls} style={hintStyle}>{t('oci.changeHint')} <code style={{ color: 'var(--t3)' }}>{editing.tenancy_ocid}</code></p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Region + Compartment */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.region')}</label>
|
||||
<RegionDropdown value={region} onChange={setRegion} regionsMap={regionsMap} t={t} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.compartment')}</label>
|
||||
<input
|
||||
ref={cpRef}
|
||||
type="password"
|
||||
placeholder="ocid1.compartment.oc1.."
|
||||
autoComplete="off"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{editing && (
|
||||
<p className={hintCls} style={hintStyle}>{t('oci.changeHint')} <code style={{ color: 'var(--t3)' }}>{editing.compartment_id || '\u2014'}</code></p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key fields: User OCID + Fingerprint */}
|
||||
{!isToken && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.ocidUser')}</label>
|
||||
<input
|
||||
ref={uoRef}
|
||||
type="password"
|
||||
placeholder="ocid1.user.oc1.."
|
||||
autoComplete="off"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{editing && (
|
||||
<p className={hintCls} style={hintStyle}>{t('oci.changeHint')} <code style={{ color: 'var(--t3)' }}>{editing.user_ocid}</code></p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.fingerprint')}</label>
|
||||
<input
|
||||
ref={fpRef}
|
||||
type="password"
|
||||
placeholder="aa:bb:cc:dd:..."
|
||||
autoComplete="off"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{editing && (
|
||||
<p className={hintCls} style={hintStyle}>{t('oci.changeHint')} <code style={{ color: 'var(--t3)' }}>{editing.fingerprint}</code></p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session Token fields */}
|
||||
{isToken && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.sessionToken')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>
|
||||
{t('oci.sessionTokenHint')} <code style={{ color: 'var(--t3)' }}>oci session authenticate</code>
|
||||
</p>
|
||||
<textarea
|
||||
ref={stkRef}
|
||||
rows={3}
|
||||
placeholder="Conteudo do arquivo ~/.oci/sessions/DEFAULT/token"
|
||||
className={inputCls}
|
||||
style={{ ...inputStyle, resize: 'vertical', fontFamily: 'var(--fm)', fontSize: '.68rem' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>
|
||||
{t('oci.ocidUser')} <span style={{ fontSize: '.6rem', color: 'var(--t4)' }}>(opcional)</span>
|
||||
</label>
|
||||
<input
|
||||
ref={uoRef}
|
||||
type="password"
|
||||
placeholder="ocid1.user.oc1.. (preenchido automaticamente)"
|
||||
autoComplete="off"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<p className={hintCls} style={hintStyle}>O SDK pode resolver o user a partir do token. Deixe vazio se preferir.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keys row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>
|
||||
{isToken ? t('oci.sessionKeyLabel') : t('oci.privateKey')}
|
||||
</label>
|
||||
{editing && <p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('oci.keepKey')}</p>}
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>
|
||||
{isToken
|
||||
? <span>Arquivo <code style={{ color: 'var(--t3)' }}>oci_api_key.pem</code> da pasta de sessao</span>
|
||||
: 'Chave privada da API Key OCI'}
|
||||
</p>
|
||||
<input
|
||||
ref={skRef}
|
||||
type="file"
|
||||
accept=".pem"
|
||||
className="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>
|
||||
{!isToken ? (
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.publicKey')}</label>
|
||||
<input
|
||||
ref={pkRef}
|
||||
type="file"
|
||||
accept=".pem"
|
||||
className="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 />}
|
||||
</div>
|
||||
|
||||
{/* Passphrase + SSH */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{!isToken ? (
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.keyPassphrase')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('oci.keyPassphraseHint')}</p>
|
||||
<input
|
||||
ref={kpRef}
|
||||
type="password"
|
||||
placeholder="(opcional)"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
) : <div />}
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.sshPublicKey')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('oci.sshPublicKeyHint')}</p>
|
||||
<textarea
|
||||
ref={sshRef}
|
||||
rows={2}
|
||||
placeholder="ssh-rsa AAAAB3NzaC1yc2E... (opcional)"
|
||||
defaultValue={editing?.ssh_public_key || ''}
|
||||
className={inputCls}
|
||||
style={{ ...inputStyle, resize: 'vertical', fontFamily: 'var(--fm)', fontSize: '.72rem' }}
|
||||
/>
|
||||
{editing?.ssh_public_key_preview && (
|
||||
<p className={hintCls} style={hintStyle}>Atual: <code style={{ color: 'var(--t3)' }}>{editing.ssh_public_key_preview}</code></p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{editing ? t('oci.saveChanges') : t('oci.saveCredential')}
|
||||
</button>
|
||||
{editing && (
|
||||
<button
|
||||
onClick={resetForm}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('oci.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user