refactor: reorganize project structure — frontend/, infra/, clean paths

This commit is contained in:
nogueiraguh
2026-04-05 21:57:18 -03:00
parent 03db9d6b8a
commit 426bde563e
76 changed files with 86 additions and 101 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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 &middot; {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>
);
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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 &lt;service&gt; &lt;resource&gt; &lt;action&gt;</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 &lt;name&gt;</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>
);
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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)' }}>&#9660;</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>
);
}