feat: comprehensive security, performance, UX and deployment audit

Security:
- CORS restricted to explicit methods/headers, configurable via CORS_ORIGINS env
- Auth added to /reports/{rid}/html and /compliance-report endpoints
- Ownership check on report downloads
- Rate limiting on login (10 attempts/5min per IP with threading.Lock)
- Non-root container user (agent via gosu entrypoint)
- Nginx security headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy)
- .gitignore and .dockerignore for secret leak prevention
- .env.example with documentation

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

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

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

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

Deployment:
- Docker resource limits (backend 4G, frontend 512M)
- Log rotation (json-file, 10MB x 3)
- Terraform version parameterized via ARG
This commit is contained in:
nogueiraguh
2026-03-20 13:48:30 -03:00
parent 61fbb3861d
commit fbcb1966d5
35 changed files with 1301 additions and 4903 deletions

View File

@@ -1,24 +1,34 @@
import { useEffect } from 'react';
import { useEffect, lazy, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth';
import { useAppStore } from '@/stores/app';
import AppShell from '@/components/layout/AppShell';
import LoginPage from '@/pages/LoginPage';
import ChatPage from '@/pages/ChatPage';
import TerraformPage from '@/pages/TerraformPage';
import PromptGeneratorPage from '@/pages/PromptGeneratorPage';
import WorkspacesPage from '@/pages/WorkspacesPage';
import ExplorerPage from '@/pages/ExplorerPage';
import ReportsPage from '@/pages/ReportsPage';
import OciConfigPage from '@/pages/config/OciConfigPage';
import GenAiConfigPage from '@/pages/config/GenAiConfigPage';
import McpServersPage from '@/pages/config/McpServersPage';
import AdbConfigPage from '@/pages/config/AdbConfigPage';
import EmbeddingsPage from '@/pages/config/EmbeddingsPage';
import EmbConsultPage from '@/pages/config/EmbConsultPage';
import UsersPage from '@/pages/admin/UsersPage';
import MfaPage from '@/pages/admin/MfaPage';
import AuditPage from '@/pages/admin/AuditPage';
import { Loader2 } from 'lucide-react';
// Lazy-loaded pages (code splitting)
const LoginPage = lazy(() => import('@/pages/LoginPage'));
const ChatPage = lazy(() => import('@/pages/ChatPage'));
const TerraformPage = lazy(() => import('@/pages/TerraformPage'));
const PromptGeneratorPage = lazy(() => import('@/pages/PromptGeneratorPage'));
const WorkspacesPage = lazy(() => import('@/pages/WorkspacesPage'));
const ExplorerPage = lazy(() => import('@/pages/ExplorerPage'));
const ReportsPage = lazy(() => import('@/pages/ReportsPage'));
const OciConfigPage = lazy(() => import('@/pages/config/OciConfigPage'));
const GenAiConfigPage = lazy(() => import('@/pages/config/GenAiConfigPage'));
const McpServersPage = lazy(() => import('@/pages/config/McpServersPage'));
const AdbConfigPage = lazy(() => import('@/pages/config/AdbConfigPage'));
const EmbeddingsPage = lazy(() => import('@/pages/config/EmbeddingsPage'));
const EmbConsultPage = lazy(() => import('@/pages/config/EmbConsultPage'));
const UsersPage = lazy(() => import('@/pages/admin/UsersPage'));
const AuditPage = lazy(() => import('@/pages/admin/AuditPage'));
function PageLoader() {
return (
<div className="flex items-center justify-center h-full" style={{ color: 'var(--t4)' }}>
<Loader2 size={24} className="animate-spin" />
</div>
);
}
export default function App() {
const checkAuth = useAuthStore((s) => s.checkAuth);
@@ -31,33 +41,33 @@ export default function App() {
});
}, [checkAuth, loadData]);
// Reload data when user changes (login)
useEffect(() => {
if (user) loadData();
}, [user, loadData]);
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<AppShell />}>
<Route index element={<Navigate to="/chat" replace />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/terraform" element={<TerraformPage />} />
<Route path="/terraform/prompt" element={<PromptGeneratorPage />} />
<Route path="/terraform/workspaces" element={<WorkspacesPage />} />
<Route path="/explorer" element={<ExplorerPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/config/oci" element={<OciConfigPage />} />
<Route path="/config/genai" element={<GenAiConfigPage />} />
<Route path="/config/mcp" element={<McpServersPage />} />
<Route path="/config/adb" element={<AdbConfigPage />} />
<Route path="/config/embeddings" element={<EmbeddingsPage />} />
<Route path="/config/embeddings/consult" element={<EmbConsultPage />} />
<Route path="/admin/users" element={<UsersPage />} />
<Route path="/admin/mfa" element={<MfaPage />} />
<Route path="/admin/audit" element={<AuditPage />} />
</Route>
<Route path="*" element={<Navigate to="/chat" replace />} />
</Routes>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<AppShell />}>
<Route index element={<Navigate to="/chat" replace />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/terraform" element={<TerraformPage />} />
<Route path="/terraform/prompt" element={<PromptGeneratorPage />} />
<Route path="/terraform/workspaces" element={<WorkspacesPage />} />
<Route path="/explorer" element={<ExplorerPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/config/oci" element={<OciConfigPage />} />
<Route path="/config/genai" element={<GenAiConfigPage />} />
<Route path="/config/mcp" element={<McpServersPage />} />
<Route path="/config/adb" element={<AdbConfigPage />} />
<Route path="/config/embeddings" element={<EmbeddingsPage />} />
<Route path="/config/embeddings/consult" element={<EmbConsultPage />} />
<Route path="/admin/users" element={<UsersPage />} />
<Route path="/admin/audit" element={<AuditPage />} />
</Route>
<Route path="*" element={<Navigate to="/chat" replace />} />
</Routes>
</Suspense>
);
}

View File

@@ -1,15 +1,16 @@
import { NavLink } from 'react-router-dom';
import { useState } from 'react';
import {
MessageSquare, Search, BarChart3, Cloud,
Brain, Plug, Database, Dna, Users, Lock, FileText,
Sparkles, Sun, Moon, LogOut, FolderOpen, Languages
Brain, Plug, Database, Dna, Users, FileText,
Sparkles, Sun, Moon, LogOut, FolderOpen, Languages, Settings, ArrowLeft
} from 'lucide-react';
import { useTheme } from '@/hooks/useTheme';
import { useAuthStore } from '@/stores/auth';
import { useI18n } from '@/i18n';
const LOGO = (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width={30} height={30} style={{ flexShrink: 0 }}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width={30} height={30} style={{ flexShrink: 0 }} aria-hidden="true">
<rect x="2" y="5" width="32" height="26" rx="13" ry="13" fill="rgba(255,255,255,0.12)" stroke="#fff" strokeWidth="2" />
<rect x="11" y="11" width="14" height="14" rx="4" fill="#fff" opacity="0.95" />
<circle cx="15" cy="17" r="2" fill="#C74634" />
@@ -25,7 +26,7 @@ const LOGO = (
const SZ = 18;
const TerraformIcon = ({ size = 18 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 64 64" fill="#7B42BC" style={{ flexShrink: 0 }}>
<svg width={size} height={size} viewBox="0 0 64 64" fill="#7B42BC" style={{ flexShrink: 0 }} aria-hidden="true">
<polygon points="22.4,0 41.6,11.1 41.6,33.2 22.4,22.1" />
<polygon points="44,12.3 63.2,23.4 63.2,45.4 44,34.4" />
<polygon points="0.8,12.3 20,23.4 20,45.4 0.8,34.4" />
@@ -41,43 +42,40 @@ interface NavItem {
sub?: boolean;
}
const NAV_SECTIONS: { titleKey: string; items: NavItem[] }[] = [
{
titleKey: 'nav.principal',
items: [
{ to: '/chat', icon: <MessageSquare size={SZ} />, labelKey: 'nav.chat' },
{ to: '/terraform', icon: <TerraformIcon size={SZ + 2} />, labelKey: 'nav.terraform' },
{ to: '/terraform/prompt', icon: <Sparkles size={SZ} />, labelKey: 'nav.promptGen', sub: true },
{ to: '/terraform/workspaces', icon: <FolderOpen size={SZ} />, labelKey: 'nav.workspaces', sub: true },
{ to: '/explorer', icon: <Search size={SZ} />, labelKey: 'nav.explorer' },
{ to: '/reports', icon: <BarChart3 size={SZ} />, labelKey: 'nav.reports' },
],
},
{
titleKey: 'nav.config',
items: [
{ to: '/config/oci', icon: <Cloud size={SZ} />, labelKey: 'nav.ociCreds' },
{ to: '/config/genai', icon: <Brain size={SZ} />, labelKey: 'nav.genai' },
{ to: '/config/mcp', icon: <Plug size={SZ} />, labelKey: 'nav.mcp' },
{ to: '/config/adb', icon: <Database size={SZ} />, labelKey: 'nav.adb' },
{ to: '/config/embeddings', icon: <Dna size={SZ} />, labelKey: 'nav.embeddings' },
{ to: '/config/embeddings/consult', icon: <MessageSquare size={SZ} />, labelKey: 'nav.embConsult', sub: true },
],
},
{
titleKey: 'nav.admin',
items: [
{ to: '/admin/users', icon: <Users size={SZ} />, labelKey: 'nav.users', adminOnly: true },
{ to: '/admin/mfa', icon: <Lock size={SZ} />, labelKey: 'nav.mfa' },
{ to: '/admin/audit', icon: <FileText size={SZ} />, labelKey: 'nav.audit', adminOnly: true },
],
},
const NAV_ITEMS: NavItem[] = [
{ to: '/chat', icon: <MessageSquare size={SZ} />, labelKey: 'nav.chat' },
{ to: '/terraform', icon: <TerraformIcon size={SZ + 2} />, labelKey: 'nav.terraform' },
{ to: '/terraform/prompt', icon: <Sparkles size={SZ} />, labelKey: 'nav.promptGen', sub: true },
{ to: '/terraform/workspaces', icon: <FolderOpen size={SZ} />, labelKey: 'nav.workspaces', sub: true },
{ to: '/explorer', icon: <Search size={SZ} />, labelKey: 'nav.explorer' },
{ to: '/reports', icon: <BarChart3 size={SZ} />, labelKey: 'nav.reports' },
{ to: '/config/embeddings/consult', icon: <MessageSquare size={SZ} />, labelKey: 'nav.embConsult' },
];
const ADMIN_ITEMS: NavItem[] = [
{ to: '/config/oci', icon: <Cloud size={SZ} />, labelKey: 'nav.ociCreds' },
{ to: '/config/genai', icon: <Brain size={SZ} />, labelKey: 'nav.genai' },
{ to: '/config/mcp', icon: <Plug size={SZ} />, labelKey: 'nav.mcp' },
{ to: '/config/adb', icon: <Database size={SZ} />, labelKey: 'nav.adb' },
{ to: '/config/embeddings', icon: <Dna size={SZ} />, labelKey: 'nav.embeddings' },
{ to: '/admin/users', icon: <Users size={SZ} />, labelKey: 'nav.users', adminOnly: true },
{ to: '/admin/audit', icon: <FileText size={SZ} />, labelKey: 'nav.audit', adminOnly: true },
];
export default function Sidebar() {
const { toggle, isDark } = useTheme();
const { user, logout } = useAuthStore();
const { t, lang, setLang } = useI18n();
const [showSettings, setShowSettings] = useState(false);
const linkCls = (sub?: boolean) =>
`flex items-center gap-3 rounded-xl font-medium transition-all duration-200 ${
sub ? 'pl-9 pr-3 py-2 text-[.78rem] opacity-90' : 'px-3 py-2.5 text-[.82rem]'
}`;
const linkStyle = (isActive: boolean) => isActive ? {
background: 'var(--acl2)', color: 'var(--ac)', boxShadow: 'inset 3px 0 0 var(--ac)',
} : { color: 'var(--t2)' };
return (
<aside className="fixed top-0 left-0 bottom-0 w-[260px] flex flex-col z-50"
@@ -101,38 +99,54 @@ export default function Sidebar() {
{/* Navigation */}
<nav className="flex-1 overflow-y-auto px-2.5 py-3">
{NAV_SECTIONS.map((section) => (
<div key={section.titleKey} className="mb-1">
<div className="text-[.6rem] uppercase tracking-[.14em] font-bold px-3 pt-4 pb-2"
{showSettings ? (
<>
{/* Back button */}
<button
onClick={() => setShowSettings(false)}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-[.82rem] font-medium w-full cursor-pointer transition-all duration-200 mb-1"
style={{ color: 'var(--ac)', background: 'transparent', border: 'none' }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg2)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<ArrowLeft size={SZ} />
{t('sidebar.backToMenu')}
</button>
<div className="h-px mx-3 mb-2" style={{ background: 'var(--bd)' }} />
<div className="text-[.6rem] uppercase tracking-[.14em] font-bold px-3 pb-2"
style={{ color: 'var(--t4)' }}>
{t(section.titleKey)}
{t('sidebar.settings')}
</div>
{section.items
{ADMIN_ITEMS
.filter((item) => !item.adminOnly || user?.role === 'admin')
.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/terraform' || item.to === '/config/embeddings'}
className={`flex items-center gap-3 rounded-xl font-medium transition-all duration-200 ${
item.sub
? 'pl-9 pr-3 py-2 text-[.78rem] opacity-90'
: 'px-3 py-2.5 text-[.82rem]'
}`}
style={({ isActive }) => isActive ? {
background: 'var(--acl2)',
color: 'var(--ac)',
boxShadow: 'inset 3px 0 0 var(--ac)',
} : {
color: 'var(--t2)',
}}
className={linkCls()}
style={({ isActive }) => linkStyle(isActive)}
>
{item.icon}
{t(item.labelKey)}
</NavLink>
))}
</div>
))}
</>
) : (
NAV_ITEMS
.filter((item) => !item.adminOnly || user?.role === 'admin')
.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/terraform' || item.to === '/config/embeddings'}
className={linkCls(item.sub)}
style={({ isActive }) => linkStyle(isActive)}
>
{item.icon}
{t(item.labelKey)}
</NavLink>
))
)}
</nav>
{/* Footer */}
@@ -142,6 +156,7 @@ export default function Sidebar() {
className="p-2.5 rounded-xl transition-all duration-200"
style={{ color: 'var(--t3)' }}
title={isDark ? t('sidebar.lightMode') : t('sidebar.darkMode')}
aria-label={isDark ? t('sidebar.lightMode') : t('sidebar.darkMode')}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg3)'; e.currentTarget.style.color = 'var(--t1)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = ''; e.currentTarget.style.color = 'var(--t3)'; }}
>
@@ -152,6 +167,7 @@ export default function Sidebar() {
className="p-2.5 rounded-xl transition-all duration-200 flex items-center gap-1"
style={{ color: 'var(--t3)' }}
title={lang === 'pt' ? 'English' : 'Português'}
aria-label={lang === 'pt' ? 'English' : 'Português'}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg3)'; e.currentTarget.style.color = 'var(--t1)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = ''; e.currentTarget.style.color = 'var(--t3)'; }}
>
@@ -170,11 +186,23 @@ export default function Sidebar() {
</p>
</div>
</div>
<button
onClick={() => setShowSettings(!showSettings)}
className="p-2.5 rounded-xl transition-all duration-200"
style={{ color: showSettings ? 'var(--ac)' : 'var(--t3)', background: showSettings ? 'var(--acl2)' : '' }}
title={t('sidebar.settings')}
aria-label={t('sidebar.settings')}
onMouseEnter={(e) => { if (!showSettings) { e.currentTarget.style.background = 'var(--bg3)'; e.currentTarget.style.color = 'var(--t1)'; } }}
onMouseLeave={(e) => { if (!showSettings) { e.currentTarget.style.background = ''; e.currentTarget.style.color = 'var(--t3)'; } }}
>
<Settings size={18} />
</button>
<button
onClick={logout}
className="p-2.5 rounded-xl transition-all duration-200"
style={{ color: 'var(--t3)' }}
title={t('sidebar.logout')}
aria-label={t('sidebar.logout')}
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(--t3)'; }}
>

View File

@@ -1,68 +0,0 @@
import type { ReactNode } from 'react';
import { ArrowRight, Wrench } from 'lucide-react';
interface StubPageProps {
icon: ReactNode;
title: string;
description: string;
legacyTab: string;
color?: string;
features?: string[];
}
export default function StubPage({ icon, title, description, legacyTab, color = 'var(--ac)', features }: StubPageProps) {
return (
<div className="flex items-center justify-center h-full p-8">
<div className="text-center max-w-md w-full animate-[fadeIn_.4s_ease]">
{/* Icon with glow */}
<div className="relative inline-flex items-center justify-center mb-6">
<div className="absolute inset-0 rounded-full blur-2xl opacity-20"
style={{ background: color, transform: 'scale(2)' }} />
<div className="relative w-20 h-20 rounded-2xl flex items-center justify-center"
style={{ background: `color-mix(in srgb, ${color} 10%, transparent)`, border: `1px solid color-mix(in srgb, ${color} 20%, transparent)` }}>
<div style={{ color }}>{icon}</div>
</div>
</div>
{/* Title */}
<h2 className="text-xl font-bold mb-2" style={{ color: 'var(--t1)' }}>{title}</h2>
<p className="text-sm mb-6" style={{ color: 'var(--t3)' }}>{description}</p>
{/* Features preview */}
{features && (
<div className="mb-6 text-left rounded-xl p-4 mx-auto max-w-xs"
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)' }}>
<div className="flex items-center gap-2 mb-3">
<Wrench size={14} style={{ color: 'var(--t4)' }} />
<span className="text-[.7rem] font-semibold uppercase tracking-wider" style={{ color: 'var(--t4)' }}>
Em desenvolvimento
</span>
</div>
{features.map((f, i) => (
<div key={i} className="flex items-center gap-2 py-1.5">
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: color, opacity: 0.6 }} />
<span className="text-xs" style={{ color: 'var(--t2)' }}>{f}</span>
</div>
))}
</div>
)}
{/* CTA */}
<a
href={`/?tab=${legacyTab}`}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold transition-all duration-200"
style={{ background: color, color: '#fff' }}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = 'var(--sh2)'; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = ''; }}
>
Abrir versao atual
<ArrowRight size={16} />
</a>
<p className="text-[.65rem] mt-4" style={{ color: 'var(--t4)' }}>
Migrando para React em breve nesta interface
</p>
</div>
</div>
);
}

View File

@@ -1,7 +1,6 @@
const en: Record<string, string> = {
// ── Sidebar ──
'nav.principal': 'Main',
'nav.config': 'Configuration',
'nav.admin': 'Administration',
'nav.chat': 'Chat Agent',
'nav.terraform': 'Terraform',
@@ -16,11 +15,12 @@ const en: Record<string, string> = {
'nav.embeddings': 'Embeddings',
'nav.embConsult': 'Query Embeddings',
'nav.users': 'Users',
'nav.mfa': 'MFA',
'nav.audit': 'Audit Log',
'sidebar.lightMode': 'Light mode',
'sidebar.darkMode': 'Dark mode',
'sidebar.logout': 'Logout',
'sidebar.settings': 'Settings',
'sidebar.backToMenu': 'Back to menu',
'sidebar.roleAdmin': 'Administrator',
'sidebar.roleUser': 'User',
'sidebar.subtitle': 'Infrastructure & Security',
@@ -107,6 +107,14 @@ const en: Record<string, string> = {
'tf.copied': 'Copied',
'tf.noWs': 'No active workspace',
'tf.deleteWs': 'Delete',
'tf.noFilesToPlan': 'No files to plan',
'tf.selectOciConfig': 'Select OCI Config and Compartment',
'tf.running': 'Running...',
'tf.terminalTitle': 'Terraform output terminal',
'tf.terminalHint': 'Run Plan, Apply or Destroy to see results here',
'tf.autoFix': 'Auto-fix',
'tf.editSystemPrompt': 'Edit System Prompt',
'tf.refreshReference': 'Refresh Reference',
// ── Prompt Generator ──
'pg.history': 'History',
@@ -308,24 +316,15 @@ const en: Record<string, string> = {
'rpt.errorEmbedding': 'Error generating embedding',
'rpt.cancelled': 'Cancelled',
'rpt.completedAt': 'Completed:',
// ── Downloads ──
'dl.title': 'Downloads',
'dl.subtitle': 'Download generated CIS reports',
'dl.allTenancies': 'All Tenancies',
'dl.allSections': 'All sections',
'dl.reports': 'report(s)',
'dl.refresh': 'Refresh',
'dl.loadError': 'Error loading reports',
'dl.loading': 'Loading reports...',
'dl.noReports': 'No completed reports.',
'dl.noReportsHint': 'Run a scan in the Reports tab to generate reports.',
'dl.files': 'file(s)',
'dl.hide': 'Hide',
'dl.viewFiles': 'View Files',
'dl.loadingFiles': 'Loading files...',
'dl.noFiles': 'No files found',
'dl.filesInSections': 'file(s) in {0} sections',
'rpt.complianceGenerating': 'Generating Compliance Report...',
'rpt.complianceFetchingKB': 'Fetching remediations from CIS Knowledge Base',
'rpt.complianceNotGenerated': 'Report not generated yet',
'rpt.complianceGenerate': 'Generate Compliance Report',
'rpt.complianceRegenerate': 'Regenerate',
'rpt.complianceDownloadZip': 'Download ZIP',
'rpt.complianceRemediation': '+ Remediation',
'rpt.complianceGeneratingShort': 'Generating...',
'rpt.complianceTitle': 'LAD A-Team CIS Compliance Report',
// ── OCI Config ──
'oci.title': 'OCI Credentials',
@@ -605,6 +604,7 @@ const en: Record<string, string> = {
'mfa.activated': 'MFA activated successfully!',
'mfa.deactivated': 'MFA deactivated.',
'mfa.invalidCode': 'Enter a 6-digit code',
'mfa.configure': 'Configure',
// ── Audit ──
'audit.title': 'Audit Log',
@@ -634,6 +634,85 @@ const en: Record<string, string> = {
'audit.session': 'Session',
'audit.source': 'Source',
// ── Hardcoded fixes ──
'common.errorPrefix': 'Error: ',
'common.errorSave': 'Error saving',
'common.errorDelete': 'Error deleting',
'common.errorUnknown': 'Unknown error',
'common.errorLoad': 'Error loading',
'oci.thTenancy': 'Tenancy',
'oci.thType': 'Type',
'oci.thRegion': 'Region',
'oci.thDetails': 'Details',
'oci.thActions': 'Actions',
'genai.thConfig': 'Config',
'genai.thModel': 'Model',
'genai.thOciConfig': 'OCI Config',
'genai.thRegion': 'Region',
'genai.thActions': 'Actions',
'genai.savedSuccess': 'Model saved successfully!',
'genai.updatedSuccess': 'Model updated successfully!',
'genai.deletedSuccess': 'Model deleted successfully!',
'genai.editingLabel': 'Editing:',
'genai.regionFilled': 'Region and Compartment filled from <strong>{0}</strong>',
'mcp.savedSuccess': 'Server registered successfully!',
'mcp.updatedSuccess': 'Server updated successfully!',
'mcp.deletingServer': 'Deleting server...',
'mcp.deletedSuccess': 'Server deleted successfully!',
'mcp.discoveredTools': '{0} tool(s) discovered, {1} total',
'mcp.errorDiscoverTools': 'Error discovering tools',
'mcp.removeToolConfirm': 'Remove tool "{0}"?',
'mcp.editingLabel': 'Editing:',
'adb.thName': 'Name',
'adb.thDsn': 'DSN',
'adb.thUser': 'User',
'adb.thTables': 'Tables',
'adb.thMtls': 'mTLS',
'adb.thWallet': 'Wallet',
'adb.thEmbed': 'Embed',
'adb.thActions': 'Actions',
'adb.thTable': 'Table',
'adb.thDescription': 'Description',
'adb.thActive': 'Active',
'adb.thTableActions': 'Actions',
'adb.walletParsed': 'Wallet parsed! {0} DSN(s): <strong>{1}</strong>. Files: {2}',
'adb.savedSuccess': 'Connection saved!',
'adb.updatedSuccess': 'Connection updated!',
'adb.walletIncluded': ' Wallet included.',
'adb.deletedSuccess': 'Connection deleted successfully!',
'adb.walletSending': 'Sending wallet...',
'adb.walletSent': 'Wallet uploaded! Files: {0}',
'adb.enterTableName': 'Enter the table name.',
'adb.tableRegistered': 'Table {0} registered!',
'adb.confirmRemoveTable': 'Remove this table from registry?',
'adb.tableRemoved': 'Table removed.',
'adb.editingLabel': 'Editing:',
'emb.errorLoadEmb': 'Error loading embeddings',
'emb.confirmDeleteEmb': 'Delete this embedding?',
'emb.totalDocs': 'Total: {0} documents',
'ec.errorPrefix': 'Error: ',
'ec.errorUnknown': 'Unknown error',
'ec.docs': 'documents',
'usr.errorLoadUsers': 'Error loading users',
'usr.errorCreateUser': 'Error creating user',
'usr.errorUpdateRole': 'Error updating role',
'usr.errorDeactivate': 'Error deactivating user',
'usr.mfaSelfOnly': 'MFA can only be activated by the user themselves.',
'ws.typeDestroyLabel': 'Type <strong>DESTROY</strong> to confirm:',
'exp.tempAccess': 'Temporary access - ',
'exp.optional': 'Optional',
'exp.ipPrompt': 'IP to allow access (CIDR):',
'exp.nsgType': 'Type',
// ── Common ──
'common.save': 'Save',
'common.cancel': 'Cancel',

View File

@@ -1,7 +1,6 @@
const pt: Record<string, string> = {
// ── Sidebar ──
'nav.principal': 'Principal',
'nav.config': 'Configuração',
'nav.admin': 'Administração',
'nav.chat': 'Chat Agent',
'nav.terraform': 'Terraform',
@@ -16,11 +15,12 @@ const pt: Record<string, string> = {
'nav.embeddings': 'Embeddings',
'nav.embConsult': 'Consultar Embeddings',
'nav.users': 'Usuários',
'nav.mfa': 'MFA',
'nav.audit': 'Audit Log',
'sidebar.lightMode': 'Modo claro',
'sidebar.darkMode': 'Modo escuro',
'sidebar.logout': 'Sair',
'sidebar.settings': 'Configurações',
'sidebar.backToMenu': 'Voltar ao menu',
'sidebar.roleAdmin': 'Administrador',
'sidebar.roleUser': 'Usuário',
'sidebar.subtitle': 'Infrastructure & Security',
@@ -107,6 +107,14 @@ const pt: Record<string, string> = {
'tf.copied': 'Copiado',
'tf.noWs': 'Nenhum workspace ativo',
'tf.deleteWs': 'Excluir',
'tf.noFilesToPlan': 'Nenhum arquivo para planejar',
'tf.selectOciConfig': 'Selecione OCI Config e Compartment',
'tf.running': 'Executando...',
'tf.terminalTitle': 'Terminal de saida do Terraform',
'tf.terminalHint': 'Execute Plan, Apply ou Destroy para ver os resultados aqui',
'tf.autoFix': 'Auto-corrigir',
'tf.editSystemPrompt': 'Editar System Prompt',
'tf.refreshReference': 'Refresh Reference',
// ── Prompt Generator ──
'pg.history': 'Historico',
@@ -308,24 +316,15 @@ const pt: Record<string, string> = {
'rpt.errorEmbedding': 'Erro ao gerar embedding',
'rpt.cancelled': 'Cancelado',
'rpt.completedAt': 'Concluido:',
// ── Downloads ──
'dl.title': 'Downloads',
'dl.subtitle': 'Baixe relatorios CIS gerados',
'dl.allTenancies': 'Todas as Tenancies',
'dl.allSections': 'Todas as secoes',
'dl.reports': 'relatorio(s)',
'dl.refresh': 'Atualizar',
'dl.loadError': 'Erro ao carregar relatorios',
'dl.loading': 'Carregando relatorios...',
'dl.noReports': 'Nenhum relatorio concluido.',
'dl.noReportsHint': 'Execute um scan na aba Reports para gerar relatorios.',
'dl.files': 'arquivo(s)',
'dl.hide': 'Ocultar',
'dl.viewFiles': 'Ver Arquivos',
'dl.loadingFiles': 'Carregando arquivos...',
'dl.noFiles': 'Nenhum arquivo encontrado',
'dl.filesInSections': 'arquivo(s) em {0} secoes',
'rpt.complianceGenerating': 'Gerando Compliance Report...',
'rpt.complianceFetchingKB': 'Buscando remediações na Knowledge Base CIS',
'rpt.complianceNotGenerated': 'Report ainda não gerado',
'rpt.complianceGenerate': 'Gerar Compliance Report',
'rpt.complianceRegenerate': 'Regenerar',
'rpt.complianceDownloadZip': 'Download ZIP',
'rpt.complianceRemediation': '+ Remediation',
'rpt.complianceGeneratingShort': 'Gerando...',
'rpt.complianceTitle': 'LAD A-Team CIS Compliance Report',
// ── OCI Config ──
'oci.title': 'Credenciais OCI',
@@ -605,6 +604,7 @@ const pt: Record<string, string> = {
'mfa.activated': 'MFA ativado com sucesso!',
'mfa.deactivated': 'MFA desativado.',
'mfa.invalidCode': 'Informe um codigo de 6 digitos',
'mfa.configure': 'Configurar',
// ── Audit ──
'audit.title': 'Log de Auditoria',
@@ -634,6 +634,86 @@ const pt: Record<string, string> = {
'audit.session': 'Sessao',
'audit.source': 'Origem',
// ── Hardcoded fixes ──
'common.errorPrefix': 'Erro: ',
'common.errorSave': 'Erro ao salvar',
'common.errorDelete': 'Erro ao excluir',
'common.errorUnknown': 'Erro desconhecido',
'common.errorLoad': 'Erro ao carregar',
'oci.tableHeaders': 'Tenancy,Tipo,Region,Detalhes,Acoes',
'oci.thTenancy': 'Tenancy',
'oci.thType': 'Tipo',
'oci.thRegion': 'Region',
'oci.thDetails': 'Detalhes',
'oci.thActions': 'Acoes',
'genai.thConfig': 'Config',
'genai.thModel': 'Modelo',
'genai.thOciConfig': 'OCI Config',
'genai.thRegion': 'Regiao',
'genai.thActions': 'Acoes',
'genai.savedSuccess': 'Modelo salvo com sucesso!',
'genai.updatedSuccess': 'Modelo atualizado com sucesso!',
'genai.deletedSuccess': 'Modelo excluido com sucesso!',
'genai.editingLabel': 'Editando:',
'genai.regionFilled': 'Regiao e Compartment preenchidos de <strong>{0}</strong>',
'mcp.savedSuccess': 'Server registrado com sucesso!',
'mcp.updatedSuccess': 'Server atualizado com sucesso!',
'mcp.deletingServer': 'Excluindo server...',
'mcp.deletedSuccess': 'Server excluido com sucesso!',
'mcp.discoveredTools': '{0} tool(s) descoberta(s), {1} total',
'mcp.errorDiscoverTools': 'Erro ao descobrir tools',
'mcp.removeToolConfirm': 'Remover tool "{0}"?',
'mcp.editingLabel': 'Editando:',
'adb.thName': 'Nome',
'adb.thDsn': 'DSN',
'adb.thUser': 'User',
'adb.thTables': 'Tabelas',
'adb.thMtls': 'mTLS',
'adb.thWallet': 'Wallet',
'adb.thEmbed': 'Embed',
'adb.thActions': 'Acoes',
'adb.thTable': 'Tabela',
'adb.thDescription': 'Descricao',
'adb.thActive': 'Ativa',
'adb.thTableActions': 'Acoes',
'adb.walletParsed': 'Wallet analisado! {0} DSN(s): <strong>{1}</strong>. Arquivos: {2}',
'adb.savedSuccess': 'Conexao salva!',
'adb.updatedSuccess': 'Conexao atualizada!',
'adb.walletIncluded': ' Wallet incluido.',
'adb.deletedSuccess': 'Conexao excluida com sucesso!',
'adb.walletSending': 'Enviando wallet...',
'adb.walletSent': 'Wallet enviada! Arquivos: {0}',
'adb.enterTableName': 'Digite o nome da tabela.',
'adb.tableRegistered': 'Tabela {0} registrada!',
'adb.confirmRemoveTable': 'Excluir esta tabela do registro?',
'adb.tableRemoved': 'Tabela removida.',
'adb.editingLabel': 'Editando:',
'emb.errorLoadEmb': 'Erro ao carregar embeddings',
'emb.confirmDeleteEmb': 'Excluir este embedding?',
'emb.totalDocs': 'Total: {0} documentos',
'ec.errorPrefix': 'Erro: ',
'ec.errorUnknown': 'Erro desconhecido',
'ec.docs': 'documentos',
'usr.errorLoadUsers': 'Erro ao carregar usuarios',
'usr.errorCreateUser': 'Erro ao criar usuario',
'usr.errorUpdateRole': 'Erro ao atualizar role',
'usr.errorDeactivate': 'Erro ao desativar usuario',
'usr.mfaSelfOnly': 'MFA so pode ser ativado pelo proprio usuario.',
'ws.typeDestroyLabel': 'Digite <strong>DESTROY</strong> para confirmar:',
'exp.tempAccess': 'Acesso temporario - ',
'exp.optional': 'Opcional',
'exp.ipPrompt': 'IP para liberar acesso (CIDR):',
'exp.nsgType': 'Tipo',
// ── Common ──
'common.save': 'Salvar',
'common.cancel': 'Cancelar',

View File

@@ -137,6 +137,12 @@
}
}
/* ── Focus-visible for accessibility ── */
button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
outline: 2px solid var(--ac);
outline-offset: 2px;
}
/* ── Component styles — OUTSIDE layers so they override Tailwind utilities ── */
/* Nav item hover */

View File

@@ -6,7 +6,7 @@ import {
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import { useAppStore } from '@/stores/app';
import { useAppStore, type ChatParams, DEFAULT_CHAT_PARAMS } from '@/stores/app';
import { useI18n } from '@/i18n';
import {
chatApi,
@@ -31,29 +31,9 @@ interface AttachedFile {
preview?: string;
}
interface ChatParams {
temperature: number;
max_tokens: number;
top_p: number;
top_k: number;
frequency_penalty: number;
presence_penalty: number;
reasoning_effort: string;
}
const ACCEPT =
'image/*,.pdf,.txt,.csv,.json,.log,.xml,.yaml,.yml,.md,.py,.js,.ts,.html,.css,.sql,.tf,.hcl';
const DEFAULT_PARAMS: ChatParams = {
temperature: 1,
max_tokens: 6000,
top_p: 0.95,
top_k: 1,
frequency_penalty: 0,
presence_penalty: 0,
reasoning_effort: 'medium',
};
/* ── Helpers ── */
function nowHHmm(): string {
@@ -95,23 +75,27 @@ function groupSessions(sessions: ChatSession[]): Record<string, ChatSession[]> {
export default function ChatPage() {
/* ── Store ── */
const { ociCfg, genaiCfg, models, mcpSvr, adbCfg } = useAppStore();
const { ociCfg, genaiCfg, models, mcpSvr, adbCfg,
chatModel: model, setChatModel: setModel,
chatOciConfig: ociConfig, setChatOciConfig: setOciConfig,
chatParams, setChatParams,
chatUseTools: useTools, setChatUseTools: setUseTools,
chatMessages, setChatMessages,
chatSessionId, setChatSessionId,
chatSessions, setChatSessions,
} = useAppStore();
const { t } = useI18n();
// Aliases for store state (keep local names for minimal code changes)
const messages = chatMessages as UIMessage[];
const setMessages = setChatMessages as (v: UIMessage[] | ((prev: UIMessage[]) => UIMessage[])) => void;
const sessionId = chatSessionId;
const setSessionId = setChatSessionId;
const sessions = chatSessions as ChatSession[];
const setSessions = setChatSessions as (v: ChatSession[] | ((prev: ChatSession[]) => ChatSession[])) => void;
/* ── State ── */
const [messages, setMessages] = useState<UIMessage[]>([]);
const [sessionId, setSessionId] = useState<string | null>(null);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [model, setModel] = useState(() => {
// Auto-select first genai config as default model
const store = useAppStore.getState();
if (store.genaiCfg.length > 0) return `cfg:${store.genaiCfg[0].id}`;
return '';
});
const [ociConfig, setOciConfig] = useState('');
const [files, setFiles] = useState<AttachedFile[]>([]);
const [chatParams, setChatParams] = useState<ChatParams>({ ...DEFAULT_PARAMS });
const [useTools, setUseTools] = useState(true);
const [historyOpen, setHistoryOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [sending, setSending] = useState(false);
@@ -151,12 +135,15 @@ export default function ChatPage() {
[mcpSvr],
);
// Auto-select default model when genaiCfg loads
// Auto-select default model: prefer saved GenAI config, fallback to first direct model
useEffect(() => {
if (!model && genaiCfg.length > 0) {
setModel(`cfg:${genaiCfg[0].id}`);
}
}, [genaiCfg]); // eslint-disable-line react-hooks/exhaustive-deps
if (model) return;
if (genaiCfg.length > 0) { setModel(`cfg:${genaiCfg[0].id}`); return; }
const ids = Object.keys(models);
const preferred = ids.find((m) => m.includes('gpt-4.1') && !m.includes('mini'));
if (preferred) { setModel(preferred); return; }
if (ids.length > 0) setModel(ids[0]);
}, [genaiCfg, models, model, setModel]);
const hasRag = useMemo(
() => adbCfg.some((c) => c.is_active) && (genaiCfg.length > 0 || ociCfg.length > 0),
@@ -450,7 +437,7 @@ export default function ChatPage() {
} catch (err: any) {
setMessages((prev) => [
...prev.filter((m) => !m.thinking),
{ role: 'assistant', content: `Erro: ${err.message || t('chat.sendError')}`, timestamp: nowHHmm(), failed: true },
{ role: 'assistant', content: `${t('common.errorPrefix')}${err.message || t('chat.sendError')}`, timestamp: nowHHmm(), failed: true },
]);
} finally {
setSending(false);
@@ -618,6 +605,7 @@ export default function ChatPage() {
className="p-1.5 rounded cursor-pointer"
style={{ background: historyOpen ? 'var(--bg3)' : 'transparent', color: 'var(--t2)' }}
title={t('chat.history')}
aria-label={t('chat.history')}
>
<Menu size={18} />
</button>
@@ -738,6 +726,7 @@ export default function ChatPage() {
className="p-1.5 rounded cursor-pointer"
style={{ background: settingsOpen ? 'var(--bg3)' : 'transparent', color: 'var(--t2)' }}
title={t('chat.settings')}
aria-label={t('chat.settings')}
>
<Settings size={18} />
</button>
@@ -748,6 +737,7 @@ export default function ChatPage() {
className="p-1.5 rounded cursor-pointer"
style={{ color: 'var(--t2)' }}
title={t('chat.newChatTitle')}
aria-label={t('chat.newChatTitle')}
>
<Plus size={18} />
</button>
@@ -898,6 +888,7 @@ export default function ChatPage() {
className="p-2 rounded cursor-pointer flex-shrink-0"
style={{ color: 'var(--t3)', background: 'var(--bg2)', border: '1px solid var(--bd)' }}
title={t('chat.attachFile')}
aria-label={t('chat.attachFile')}
>
<Paperclip size={18} />
</button>
@@ -934,6 +925,7 @@ export default function ChatPage() {
opacity: sending ? 0.6 : 1,
}}
title={t('chat.send')}
aria-label={t('chat.send')}
>
{sending ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button>

View File

@@ -1,453 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import {
FolderDown, ChevronRight, RefreshCw, Download, FileText, Lock, Globe,
Server, Activity, Database, Package, BarChart3, AlertCircle, Loader2, Filter,
} from 'lucide-react';
import client from '@/api/client';
import { useI18n } from '@/i18n';
/* ── Types ── */
interface Report {
id: string;
tenancy_name: string;
status: string;
level: number | null;
created_at: string;
completed_at: string | null;
}
interface ReportFile {
id: string;
file_name: string;
file_type: string;
file_category: string;
file_size: number;
}
/* ── Helpers ── */
function extractSection(name: string): string {
const m = name.match(/^(?:cis|obp)_([A-Za-z_]+?)_\d/);
if (m) return m[1].replace(/_/g, ' ');
if (name.includes('summary')) return 'Summary';
if (name.includes('error')) return 'Error';
return 'Other';
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function fileExt(name: string): string {
return (name.split('.').pop() || '').toLowerCase();
}
const extColors: Record<string, string> = {
html: '#e44d26',
csv: '#217346',
json: '#f5a623',
xlsx: '#217346',
txt: '#6b7280',
pdf: '#dc2626',
};
const sectionIcons: Record<string, typeof Lock> = {
'Identity and Access Management': Lock,
'Networking': Globe,
'Compute': Server,
'Logging and Monitoring': Activity,
'Storage Object Storage': Database,
'Storage Block Volumes': Database,
'Storage File Storage Service': Database,
'Asset Management': Package,
'Summary': BarChart3,
'Error': AlertCircle,
'Other': FileText,
};
function sectionSort(a: string, b: string): number {
if (a === 'Summary') return -1;
if (b === 'Summary') return 1;
if (a === 'Other') return 1;
if (b === 'Other') return -1;
return a.localeCompare(b);
}
function groupBySection(files: ReportFile[]): Record<string, ReportFile[]> {
const map: Record<string, ReportFile[]> = {};
for (const f of files) {
const sec = extractSection(f.file_name);
(map[sec] ||= []).push(f);
}
return map;
}
/* ── Component ── */
export default function DownloadsPage() {
const { t } = useI18n();
const [reports, setReports] = useState<Report[]>([]);
const [expandedRid, setExpandedRid] = useState<string | null>(null);
const [filesCache, setFilesCache] = useState<Record<string, ReportFile[]>>({});
const [loadingFiles, setLoadingFiles] = useState<string | null>(null);
const [tenancyFilter, setTenancyFilter] = useState('');
const [sectionFilter, setSectionFilter] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const token = localStorage.getItem('t') || '';
const loadReports = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await client.get('/reports') as unknown as Report[];
setReports(data.filter((r) => r.status === 'completed'));
} catch (err) {
setError(err instanceof Error ? err.message : t('dl.loadError'));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadReports(); }, [loadReports]);
const handleRefresh = useCallback(() => {
setFilesCache({});
setExpandedRid(null);
setSectionFilter('');
loadReports();
}, [loadReports]);
const toggleExpand = useCallback(async (rid: string) => {
if (expandedRid === rid) {
setExpandedRid(null);
return;
}
setExpandedRid(rid);
if (!filesCache[rid]) {
setLoadingFiles(rid);
try {
const files = await client.get(`/reports/${rid}/files`) as unknown as ReportFile[];
setFilesCache((prev) => ({ ...prev, [rid]: files }));
} catch {
setFilesCache((prev) => ({ ...prev, [rid]: [] }));
} finally {
setLoadingFiles(null);
}
}
}, [expandedRid, filesCache]);
const handleDownload = useCallback((rid: string, fid: string) => {
window.open(`/api/reports/${rid}/files/${fid}/download?token=${token}`, '_blank');
}, [token]);
/* Derived data */
const tenancies = [...new Set(reports.map((r) => r.tenancy_name))].sort();
const filtered = tenancyFilter ? reports.filter((r) => r.tenancy_name === tenancyFilter) : reports;
/* Sections available in expanded report (for filter) */
const expandedFiles = expandedRid ? filesCache[expandedRid] : null;
const expandedSections = expandedFiles
? [...new Set(expandedFiles.map((f) => extractSection(f.file_name)))].sort(sectionSort)
: [];
return (
<div className="page">
{/* Header */}
<div className="page-header">
<div className="icon" style={{ background: 'var(--bg3)' }}>
<FolderDown size={24} style={{ color: 'var(--t3)' }} />
</div>
<div>
<h1>{t('dl.title')}</h1>
<div className="subtitle">{t('dl.subtitle')}</div>
</div>
<div className="spacer" />
<span className="count">{filtered.length} {t('dl.reports')}</span>
</div>
{/* Toolbar */}
<div className="card" style={{ padding: '12px 20px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div className="flex items-center gap-3 flex-wrap">
{/* Tenancy filter */}
<div className="flex items-center gap-1.5">
<Filter size={13} style={{ color: 'var(--t4)' }} />
<select
value={tenancyFilter}
onChange={(e) => { setTenancyFilter(e.target.value); setExpandedRid(null); setSectionFilter(''); }}
className="text-xs py-1.5 px-2.5 rounded-lg outline-none cursor-pointer"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 180 }}
>
<option value="">{t('dl.allTenancies')} ({reports.length})</option>
{tenancies.map((tn) => (
<option key={tn} value={tn}>
{tn} ({reports.filter((r) => r.tenancy_name === tn).length})
</option>
))}
</select>
</div>
{/* Section filter (only when expanded) */}
{expandedRid && expandedSections.length > 1 && (
<select
value={sectionFilter}
onChange={(e) => setSectionFilter(e.target.value)}
className="text-xs py-1.5 px-2.5 rounded-lg outline-none cursor-pointer"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 160 }}
>
<option value="">{t('dl.allSections')}</option>
{expandedSections.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
)}
<span className="text-[.7rem]" style={{ color: 'var(--t4)' }}>
{filtered.length} {t('dl.reports')}
</span>
</div>
<button
onClick={handleRefresh}
disabled={loading}
className="btn btn-secondary btn-sm"
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
{t('dl.refresh')}
</button>
</div>
{/* Error */}
{error && (
<div
className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg mb-4 text-xs font-medium"
style={{ background: 'var(--rdl)', color: 'var(--rd)' }}
>
<AlertCircle size={14} />
{error}
</div>
)}
{/* Loading */}
{loading && !reports.length && (
<div className="flex items-center justify-center gap-2 py-16" style={{ color: 'var(--t4)' }}>
<Loader2 size={18} className="animate-spin" />
<span className="text-sm">{t('dl.loading')}</span>
</div>
)}
{/* Empty state */}
{!loading && !filtered.length && (
<div className="card">
<div className="empty-state">
<FolderDown size={40} />
<h3>{t('dl.noReports')}</h3>
<p>{t('dl.noReportsHint')}</p>
</div>
</div>
)}
{/* Report list */}
<div className="flex flex-col gap-2.5">
{filtered.map((report) => {
const isExpanded = expandedRid === report.id;
const files = filesCache[report.id];
const isLoading = loadingFiles === report.id;
const fileCount = files ? files.length : null;
return (
<div
key={report.id}
className="card"
style={{
padding: 0,
overflow: 'hidden',
borderColor: isExpanded ? 'color-mix(in srgb, var(--ac) 30%, var(--bd))' : undefined,
boxShadow: isExpanded ? 'var(--sh2)' : undefined,
}}
>
{/* Report header */}
<button
onClick={() => toggleExpand(report.id)}
className="w-full flex items-center justify-between px-4 py-3 text-left transition-colors hover:bg-[var(--bg2)]"
>
<div className="flex items-center gap-3">
<ChevronRight
size={14}
className="transition-transform duration-200 flex-shrink-0"
style={{
color: 'var(--ac)',
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
}}
/>
<div>
<span className="text-[.82rem] font-semibold" style={{ color: 'var(--t1)' }}>
{report.tenancy_name}
</span>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>
Level {report.level || 2}
</span>
<span className="text-[.5rem]" style={{ color: 'var(--t4)' }}>|</span>
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>
{report.created_at}
</span>
{fileCount != null && (
<>
<span className="text-[.5rem]" style={{ color: 'var(--t4)' }}>|</span>
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>
{fileCount} {t('dl.files')}
</span>
</>
)}
</div>
</div>
</div>
<span className="text-[.68rem] flex-shrink-0" style={{ color: 'var(--t4)' }}>
{isExpanded ? t('dl.hide') : t('dl.viewFiles')}
</span>
</button>
{/* Expanded file list */}
{isExpanded && (
<div
className="px-4 py-4"
style={{ borderTop: '1px solid var(--bd)', background: 'var(--bg2)' }}
>
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-6" style={{ color: 'var(--t4)' }}>
<Loader2 size={16} className="animate-spin" />
<span className="text-xs">{t('dl.loadingFiles')}</span>
</div>
) : !files || !files.length ? (
<p className="text-xs text-center py-6" style={{ color: 'var(--t4)' }}>
{t('dl.noFiles')}
</p>
) : (
<FileListGrouped
files={files}
reportId={report.id}
sectionFilter={sectionFilter}
onDownload={handleDownload}
/>
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
/* ── FileListGrouped sub-component ── */
function FileListGrouped({
files,
reportId,
sectionFilter,
onDownload,
}: {
files: ReportFile[];
reportId: string;
sectionFilter: string;
onDownload: (rid: string, fid: string) => void;
}) {
const { t } = useI18n();
const grouped = groupBySection(files);
let sections = Object.keys(grouped).sort(sectionSort);
if (sectionFilter) sections = sections.filter((s) => s === sectionFilter);
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<span className="text-[.72rem]" style={{ color: 'var(--t3)' }}>
{files.length} {t('dl.filesInSections').replace('{0}', String(Object.keys(grouped).length))}
</span>
</div>
{sections.map((sec) => {
const secFiles = grouped[sec];
const Icon = sectionIcons[sec] || FileText;
return (
<div key={sec} className="mb-3">
{/* Section header */}
<div
className="flex items-center gap-2 mb-2 pb-1.5"
style={{ borderBottom: '1px solid var(--bd)' }}
>
<Icon size={13} style={{ color: 'var(--t3)', opacity: 0.7 }} />
<span className="text-[.72rem] font-bold" style={{ color: 'var(--t1)' }}>{sec}</span>
<span className="text-[.6rem]" style={{ color: 'var(--t4)' }}>({secFiles.length})</span>
</div>
{/* File grid */}
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(230px, 1fr))' }}>
{secFiles.map((f) => {
const ext = fileExt(f.file_name);
const color = extColors[ext] || 'var(--ac)';
return (
<button
key={f.id}
onClick={() => onDownload(reportId, f.id)}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-left transition-all group"
style={{
background: 'var(--bg)',
border: '1px solid var(--bd)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = color;
e.currentTarget.style.boxShadow = `0 2px 8px ${color}18`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--bd)';
e.currentTarget.style.boxShadow = 'none';
}}
>
{/* File type badge */}
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{ background: `${color}14` }}
>
<span
className="text-[.58rem] font-bold uppercase tracking-wide"
style={{ color }}
>
{ext}
</span>
</div>
{/* File info */}
<div className="flex-1 min-w-0 overflow-hidden">
<div
className="text-[.7rem] font-medium truncate"
style={{ color: 'var(--t1)' }}
title={f.file_name}
>
{f.file_name}
</div>
<div className="text-[.58rem] mt-0.5" style={{ color: 'var(--t4)' }}>
{formatSize(f.file_size)}
</div>
</div>
{/* Download icon */}
<Download
size={12}
className="flex-shrink-0 opacity-30 group-hover:opacity-70 transition-opacity"
style={{ color: 'var(--t3)' }}
/>
</button>
);
})}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
Search, ChevronRight, ChevronDown, FolderTree, RefreshCw, Loader2,
Server, HardDrive, Layers, Network, GitBranch, Shield, ShieldCheck,
@@ -186,7 +186,7 @@ function KV({ label, value, mono }: { label: string; value: React.ReactNode; mon
}
/* ── Compartment Tree Node ────────────────────────────────────────────────── */
function TreeItem({
const TreeItem = React.memo(function TreeItem({
node, selected, expanded, onSelect, onToggle,
}: {
node: TreeNode;
@@ -236,7 +236,7 @@ function TreeItem({
)}
</div>
);
}
});
/* ── Region Multi-Select ──────────────────────────────────────────────────── */
function RegionSelector({
@@ -381,7 +381,7 @@ function AdbCard({ r, configId, regions, onReload }: { r: ResourceItem; configId
const resp = await fetch('https://api.ipify.org?format=json');
const data = await resp.json();
const myIp = data.ip + '/32';
const ip = prompt('IP para liberar acesso (CIDR):', myIp);
const ip = prompt(t('exp.ipPrompt'), myIp);
if (!ip) return;
setUpdating(true);
await explorerApi.adbUpdateNetwork(r.id, ip.trim(), configId, region);
@@ -599,7 +599,7 @@ function SecurityListCard({ r, configId, regions, onReload }: { r: ResourceItem;
const resp = await fetch('https://api.ipify.org?format=json');
const data = await resp.json();
const myIp = data.ip + '/32';
setCidr(myIp); setDesc('Acesso temporário - ' + myIp);
setCidr(myIp); setDesc(t('exp.tempAccess') + myIp);
setFormMsg({ type: 's', text: t('exp.ipDetected').replace('{0}', myIp) });
} catch { setFormMsg({ type: 'e', text: t('exp.ipDetectFailed') }); }
};
@@ -641,7 +641,7 @@ function SecurityListCard({ r, configId, regions, onReload }: { r: ResourceItem;
<RuleInput label={t('exp.portMax')} value={portMax} onChange={setPortMax} placeholder="22" />
</div>
)}
<RuleInput label={t('exp.description')} value={desc} onChange={setDesc} placeholder="Opcional" />
<RuleInput label={t('exp.description')} value={desc} onChange={setDesc} placeholder={t('exp.optional')} />
<label className="flex items-center gap-1.5 text-xs cursor-pointer" style={{ color: 'var(--t2)' }}>
<input type="checkbox" checked={stateless} onChange={(e) => setStateless(e.target.checked)} className="accent-current" style={{ accentColor: 'var(--ac)' }} />
Stateless
@@ -745,7 +745,7 @@ function NsgCard({ r, configId, regions, onReload }: { r: ResourceItem; configId
const resp = await fetch('https://api.ipify.org?format=json');
const data = await resp.json();
const myIp = data.ip + '/32';
setCidr(myIp); setDesc('Acesso temporário - ' + myIp);
setCidr(myIp); setDesc(t('exp.tempAccess') + myIp);
setFormMsg({ type: 's', text: t('exp.ipDetected').replace('{0}', myIp) });
} catch { setFormMsg({ type: 'e', text: t('exp.ipDetectFailed') }); }
};
@@ -787,7 +787,7 @@ function NsgCard({ r, configId, regions, onReload }: { r: ResourceItem; configId
<RuleInput label={t('exp.portMax')} value={portMax} onChange={setPortMax} placeholder="22" />
</div>
)}
<RuleInput label={t('exp.description')} value={desc} onChange={setDesc} placeholder="Opcional" />
<RuleInput label={t('exp.description')} value={desc} onChange={setDesc} placeholder={t('exp.optional')} />
<label className="flex items-center gap-1.5 text-xs cursor-pointer" style={{ color: 'var(--t2)' }}>
<input type="checkbox" checked={stateless} onChange={(e) => setStateless(e.target.checked)} className="accent-current" style={{ accentColor: 'var(--ac)' }} />
Stateless
@@ -809,7 +809,7 @@ function NsgCard({ r, configId, regions, onReload }: { r: ResourceItem; configId
<th className="text-left py-1 pr-2">{t('exp.direction')}</th>
<th className="text-left py-1 pr-2">{t('exp.protocol')}</th>
<th className="text-left py-1 pr-2">{t('exp.originDest')}</th>
<th className="text-left py-1 pr-2">Tipo</th>
<th className="text-left py-1 pr-2">{t('exp.nsgType')}</th>
<th className="text-left py-1 pr-2">{t('exp.ports')}</th>
<th className="text-left py-1 pr-1">{t('exp.description')}</th>
<th className="w-6"></th>
@@ -883,23 +883,30 @@ function RouteTableCard({ r }: { r: ResourceItem }) {
══════════════════════════════════════════════════════════════════════════════ */
export default function ExplorerPage() {
const { ociCfg } = useAppStore();
const store = useAppStore();
const { ociCfg } = store;
const { t } = useI18n();
/* ── State ──────────────────────────────────────────────────────────────── */
const [selectedConfig, setSelectedConfig] = useState('');
/* ── State (from store for user selections) ──────────────────────────── */
const selectedConfig = store.expSelectedConfig;
const setSelectedConfig = store.setExpSelectedConfig;
const [compartmentTree, setCompartmentTree] = useState<TreeNode[]>([]);
const [selectedCompartment, setSelectedCompartment] = useState('');
const [selectedRegions, setSelectedRegions] = useState<string[]>([]);
const selectedCompartment = store.expSelectedCompartment;
const setSelectedCompartment = store.setExpSelectedCompartment;
const selectedRegions = store.expSelectedRegions;
const setSelectedRegions = store.setExpSelectedRegions;
const [availableRegions, setAvailableRegions] = useState<Region[]>([]);
const [selectedCategory, setSelectedCategory] = useState('Compute');
const [selectedResourceType, setSelectedResourceType] = useState('instances');
const selectedCategory = store.expSelectedCategory;
const setSelectedCategory = store.setExpSelectedCategory;
const selectedResourceType = store.expSelectedResourceType;
const setSelectedResourceType = store.setExpSelectedResourceType;
const [resourceData, setResourceData] = useState<ResourceItem[] | null>(null);
const [counts, setCounts] = useState<Record<string, number>>({});
const [loading, setLoading] = useState(false);
const [countsRefreshing, setCountsRefreshing] = useState(false);
const [treeOpen, setTreeOpen] = useState<Record<string, boolean>>({});
const [treeWidth, setTreeWidth] = useState(280);
const treeWidth = store.expTreeWidth;
const setTreeWidth = store.setExpTreeWidth;
const [treeLoading, setTreeLoading] = useState(false);
const [actionPending, setActionPending] = useState<Record<string, boolean>>({});
const [searchFilter, setSearchFilter] = useState('');
@@ -1239,6 +1246,8 @@ export default function ExplorerPage() {
className="font-bold text-[.7rem] cursor-pointer"
style={{ color: 'var(--ac)' }}
onClick={() => setSelectedRegions((prev) => prev.filter((x) => x !== r))}
title={t('common.delete')}
aria-label={`${t('common.delete')} ${r}`}
>
&times;
</button>
@@ -1425,7 +1434,7 @@ export default function ExplorerPage() {
)}
{!loading && filteredResources && filteredResources.length > 0 && (
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))' }}>
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))' }}>
{filteredResources.map((item, idx) => (
<ResourceCard
key={item.id || item.name || idx}

View File

@@ -39,28 +39,36 @@ interface Msg {
/* ── Component ── */
export default function PromptGeneratorPage() {
const { models, genaiCfg, ociCfg } = useAppStore();
const store = useAppStore();
const { models, genaiCfg, ociCfg } = store;
const { t } = useI18n();
/* state */
const [msgs, setMsgs] = useState<Msg[]>([]);
/* state (from store) */
const msgs = store.pgMessages as Msg[];
const setMsgs = store.setPgMessages;
const [loading, setLoading] = useState(false);
const [input, setInput] = useState('');
const [sid, setSid] = useState<string | null>(null);
const sid = store.pgSessionId;
const setSid = store.setPgSessionId;
/* model selection */
const [selectedModel, setSelectedModel] = useState('');
/* model selection (from store) */
const selectedModel = store.pgSelectedModel;
const setSelectedModel = store.setPgSelectedModel;
const [ddOpen, setDdOpen] = useState(false);
const [ddSearch, setDdSearch] = useState('');
/* OCI config (for direct models) */
const [ociId, setOciId] = useState('');
const [region, setRegion] = useState('');
const [compartment, setCompartment] = 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 */
/* history sidebar (from store) */
const [histOpen, setHistOpen] = useState(false);
const [history, setHistory] = useState<ChatSession[]>([]);
const history = store.pgHistory as ChatSession[];
const setHistory = store.setPgHistory;
const chatRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -174,7 +182,7 @@ export default function PromptGeneratorPage() {
setLoading(false);
}
} catch (e: unknown) {
setMsgs([...newMsgs, { role: 'assistant', content: 'Erro: ' + (e as Error).message, failed: true }]);
setMsgs([...newMsgs, { role: 'assistant', content: t('common.errorPrefix') + (e as Error).message, failed: true }]);
setLoading(false);
}
};

View File

@@ -536,45 +536,59 @@ function CustomTooltip({ active, payload, label }: { active?: boolean; payload?:
export default function ReportsPage() {
const { t } = useI18n();
const ociCfg = useAppStore((s) => s.ociCfg);
const ociRegions = useAppStore((s) => s.ociRegions);
const adbCfg = useAppStore((s) => s.adbCfg);
const store = useAppStore();
const ociCfg = store.ociCfg;
const ociRegions = store.ociRegions;
const adbCfg = store.adbCfg;
/* ── Reports list ── */
const [reports, setReports] = useState<Report[]>([]);
const [reportsLoading, setReportsLoading] = useState(true);
/* ── Form state ── */
const [formOpen, setFormOpen] = useState(true);
const [ociVal, setOciVal] = useState('');
const [level, setLevel] = useState<1 | 2>(2);
const [selectedRegions, setSelectedRegions] = useState<string[]>([]);
const [obp, setObp] = useState(true);
const [raw, setRaw] = useState(false);
const [redact, setRedact] = useState(true);
/* ── Form state (from store) ── */
const formOpen = store.rptFormOpen;
const setFormOpen = store.setRptFormOpen;
const ociVal = store.rptOciVal;
const setOciVal = store.setRptOciVal;
const level = store.rptLevel;
const setLevel = store.setRptLevel;
const selectedRegions = store.rptSelectedRegions;
const setSelectedRegions = store.setRptSelectedRegions;
const obp = store.rptObp;
const setObp = store.setRptObp;
const raw = store.rptRaw;
const setRaw = store.setRptRaw;
const redact = store.rptRedact;
const setRedact = store.setRptRedact;
/* ── Tracking / progress ── */
const [trackingId, setTrackingId] = useState<string | null>(null);
/* ── Tracking / progress (from store) ── */
const trackingId = store.rptTrackingId;
const setTrackingId = store.setRptTrackingId;
const [progress, setProgress] = useState<ReportProgress | null>(null);
const [logExpanded, setLogExpanded] = useState(false);
/* ── Selected report + KPI ── */
const [selectedRid, setSelectedRid] = useState('');
/* ── Selected report + KPI (from store) ── */
const selectedRid = store.rptSelectedRid;
const setSelectedRid = store.setRptSelectedRid;
const [kpi, setKpi] = useState<ReportSummary | null>(null);
const [kpiLoading, setKpiLoading] = useState(false);
/* ── Report files ── */
const [files, setFiles] = useState<ReportFile[]>([]);
/* ── Embedding state ── */
const [embedAdb, setEmbedAdb] = useState('');
const [embedTable, setEmbedTable] = useState('');
/* ── Embedding state (from store) ── */
const embedAdb = store.rptEmbedAdb;
const setEmbedAdb = store.setRptEmbedAdb;
const embedTable = store.rptEmbedTable;
const setEmbedTable = store.setRptEmbedTable;
const [embedLoading, setEmbedLoading] = useState(false);
const [embedMsg, setEmbedMsg] = useState<{ type: 's' | 'e'; text: string } | null>(null);
/* ── HTML iframe ── */
const [showIframe, setShowIframe] = useState(false);
const [showCompliance, setShowCompliance] = useState(false);
/* ── HTML iframe (from store) ── */
const showIframe = store.rptShowIframe;
const setShowIframe = store.setRptShowIframe;
const showCompliance = store.rptShowCompliance;
const setShowCompliance = store.setRptShowCompliance;
const [complianceReady, setComplianceReady] = useState(false);
const [complianceGenerating, setComplianceGenerating] = useState(false);
@@ -1305,16 +1319,16 @@ export default function ReportsPage() {
<div className="flex items-center gap-2">
{showCompliance ? <Eye size={14} style={{ color: 'var(--gn)' }} /> : <EyeOff size={14} style={{ color: 'var(--t4)' }} />}
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
LAD A-Team CIS Compliance Report
{t('rpt.complianceTitle')}
</span>
{complianceReady && (
<span className="text-[.6rem] px-2 py-0.5 rounded-full font-semibold" style={{ background: 'var(--gnl)', color: 'var(--gn)' }}>
+ Remediation
{t('rpt.complianceRemediation')}
</span>
)}
{complianceGenerating && (
<span className="text-[.6rem] px-2 py-0.5 rounded-full font-semibold flex items-center gap-1" style={{ background: 'var(--bll)', color: 'var(--bl)' }}>
<Loader2 size={10} className="animate-spin" /> Gerando...
<Loader2 size={10} className="animate-spin" /> {t('rpt.complianceGeneratingShort')}
</span>
)}
</div>
@@ -1338,7 +1352,7 @@ export default function ReportsPage() {
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer transition-colors no-underline"
style={{ background: 'var(--ac)', color: '#fff' }}
>
<Download size={12} /> Download ZIP
<Download size={12} /> {t('rpt.complianceDownloadZip')}
</a>
<button
onClick={startComplianceGeneration}
@@ -1346,26 +1360,26 @@ export default function ReportsPage() {
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer transition-colors"
style={{ background: 'var(--bg3)', color: 'var(--t2)', border: '1px solid var(--bd)' }}
>
<RefreshCw size={12} /> Regenerar
<RefreshCw size={12} /> {t('rpt.complianceRegenerate')}
</button>
</div>
</div>
) : complianceGenerating ? (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<Loader2 size={32} className="animate-spin" style={{ color: 'var(--gn)' }} />
<p className="text-[.82rem] font-medium" style={{ color: 'var(--t2)' }}>Gerando Compliance Report...</p>
<p className="text-[.7rem]" style={{ color: 'var(--t4)' }}>Buscando remediações na Knowledge Base CIS</p>
<p className="text-[.82rem] font-medium" style={{ color: 'var(--t2)' }}>{t('rpt.complianceGenerating')}</p>
<p className="text-[.7rem]" style={{ color: 'var(--t4)' }}>{t('rpt.complianceFetchingKB')}</p>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 gap-3">
<ShieldCheck size={32} style={{ color: 'var(--t4)', opacity: 0.5 }} />
<p className="text-[.78rem]" style={{ color: 'var(--t3)' }}>Report ainda não gerado</p>
<p className="text-[.78rem]" style={{ color: 'var(--t3)' }}>{t('rpt.complianceNotGenerated')}</p>
<button
onClick={startComplianceGeneration}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-[.76rem] font-semibold cursor-pointer"
style={{ background: 'var(--gn)', color: '#fff' }}
>
<ShieldCheck size={14} /> Gerar Compliance Report
<ShieldCheck size={14} /> {t('rpt.complianceGenerate')}
</button>
</div>
)

View File

@@ -359,44 +359,64 @@ function Modal({ children, onClose }: { children: React.ReactNode; onClose: () =
export default function TerraformPage() {
const { t } = useI18n();
const { ociCfg, genaiCfg } = useAppStore();
const store = useAppStore();
const { ociCfg, genaiCfg } = store;
/* ── Chat state ── */
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [sessionId, setSessionId] = useState<string | null>(null);
/* ── Chat state (from store) ── */
const messages = store.tfMessages as ChatMessage[];
const setMessages = store.setTfMessages;
const sessionId = store.tfSessionId;
const setSessionId = store.setTfSessionId;
const sessions = store.tfSessions as ChatSession[];
const setSessions = store.setTfSessions;
const [historyOpen, setHistoryOpen] = useState(false);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [chatInput, setChatInput] = useState('');
const [sending, setSending] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
/* ── Model state ── */
const [model, setModel] = useState('openai.gpt-4.1');
const [ociConfig, setOciConfig] = useState('');
const [region, setRegion] = useState('');
const [compartment, setCompartment] = useState('');
const [compartments, setCompartments] = useState<FlatCompartment[]>([]);
/* ── Model state (from store) ── */
const model = store.tfModel;
const setModel = store.setTfModel;
const ociConfig = store.tfOciConfig;
const setOciConfig = store.setTfOciConfig;
const region = store.tfRegion;
const setRegion = store.setTfRegion;
const compartment = store.tfCompartment;
const setCompartment = store.setTfCompartment;
const compartments = store.tfCompartments as FlatCompartment[];
const setCompartments = store.setTfCompartments;
const tfTemperature = store.tfTemperature;
const setTfTemperature = store.setTfTemperature;
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
const [modelSearch, setModelSearch] = useState('');
const [menuOpen, setMenuOpen] = useState(false);
const [tfTemperature, setTfTemperature] = useState(0.3);
/* ── Workspace state ── */
const [wsId, setWsId] = useState<string | null>(null);
const [wsStatus, setWsStatus] = useState<WsStatus>('draft');
const [wsName, setWsName] = useState('');
const [files, setFiles] = useState<TfFile[]>([]);
const [planOutput, setPlanOutput] = useState('');
const [applyOutput, setApplyOutput] = useState('');
const [destroyOutput, setDestroyOutput] = useState('');
const [wsError, setWsError] = useState('');
/* ── Workspace state (from store) ── */
const wsId = store.tfWsId;
const setWsId = store.setTfWsId;
const wsStatus = store.tfWsStatus as WsStatus;
const setWsStatus = store.setTfWsStatus;
const wsName = store.tfWsName;
const setWsName = store.setTfWsName;
const files = store.tfFiles as TfFile[];
const setFiles = store.setTfFiles;
const planOutput = store.tfPlanOutput;
const setPlanOutput = store.setTfPlanOutput;
const applyOutput = store.tfApplyOutput;
const setApplyOutput = store.setTfApplyOutput;
const destroyOutput = store.tfDestroyOutput;
const setDestroyOutput = store.setTfDestroyOutput;
const wsError = store.tfWsError;
const setWsError = store.setTfWsError;
/* ── UI state ── */
const [editingFileIdx, setEditingFileIdx] = useState<number | null>(null);
const [bottomTab, setBottomTab] = useState<'files' | 'plan' | 'resources'>('files');
const bottomTab = store.tfBottomTab as 'files' | 'plan' | 'resources';
const setBottomTab = store.setTfBottomTab;
const [running, setRunning] = useState(false);
const [terminalView, setTerminalView] = useState<'plan' | 'apply' | 'destroy'>('plan');
const terminalView = store.tfTerminalView as 'plan' | 'apply' | 'destroy';
const setTerminalView = store.setTfTerminalView;
const terminalRef = useRef<HTMLPreElement>(null);
/* ── Resources ── */
@@ -506,7 +526,7 @@ export default function TerraformPage() {
pollWorkspaceStatus(ws.id);
}
} catch (e: unknown) {
alert('Erro ao carregar workspace: ' + (e as Error).message);
setWsError('Erro ao carregar workspace: ' + (e as Error).message);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -538,7 +558,7 @@ export default function TerraformPage() {
}
} catch { /* no workspace */ }
} catch (e: unknown) {
alert('Erro: ' + (e as Error).message);
setWsError('Erro: ' + (e as Error).message);
}
}, [loadWorkspace]);
@@ -686,7 +706,7 @@ export default function TerraformPage() {
/* ── Save & Plan ── */
const doPlan = useCallback(async () => {
if (files.length === 0) { alert('Nenhum arquivo para planejar'); return; }
if (files.length === 0) { setWsError(t('tf.noFilesToPlan')); return; }
const code = joinFiles(files);
try {
let wid = wsId;
@@ -712,7 +732,7 @@ export default function TerraformPage() {
setWsError('');
pollWorkspaceStatus(wid);
} catch (e: unknown) {
alert('Erro: ' + (e as Error).message);
setWsError('Erro: ' + (e as Error).message);
}
}, [files, wsId, sessionId, ociConfig, compartment, wsName, pollWorkspaceStatus]);
@@ -726,7 +746,7 @@ export default function TerraformPage() {
setApplyOutput('');
pollWorkspaceStatus(wsId);
} catch (e: unknown) {
alert('Erro: ' + (e as Error).message);
setWsError('Erro: ' + (e as Error).message);
}
}, [wsId, pollWorkspaceStatus]);
@@ -742,7 +762,7 @@ export default function TerraformPage() {
setDestroyOutput('');
pollWorkspaceStatus(wsId);
} catch (e: unknown) {
alert('Erro: ' + (e as Error).message);
setWsError('Erro: ' + (e as Error).message);
}
}, [wsId, destroyInput, pollWorkspaceStatus]);
@@ -781,7 +801,7 @@ export default function TerraformPage() {
/* ── Load resources ── */
const loadResources = useCallback(async () => {
if (!ociConfig || !compartment) {
alert('Selecione OCI Config e Compartment');
setWsError(t('tf.selectOciConfig'));
return;
}
setResourcesLoading(true);
@@ -789,7 +809,7 @@ export default function TerraformPage() {
const r = await terraformApi.loadResources(ociConfig, compartment, region || undefined);
setResources(r);
} catch (e: unknown) {
alert('Erro: ' + (e as Error).message);
setWsError('Erro: ' + (e as Error).message);
setResources({});
}
setResourcesLoading(false);
@@ -1095,13 +1115,13 @@ export default function TerraformPage() {
className="flex items-center gap-2 w-full px-3 py-2 text-left cursor-pointer transition-colors"
style={{ color: 'var(--t2)' }}>
<Settings size={14} />
<span className="text-[.72rem]">Editar System Prompt</span>
<span className="text-[.72rem]">{t('tf.editSystemPrompt')}</span>
</button>
<button onClick={() => { setMenuOpen(false); /* TODO: refresh reference */ }}
className="flex items-center gap-2 w-full px-3 py-2 text-left cursor-pointer transition-colors"
style={{ color: 'var(--t2)' }}>
<RefreshCw size={14} />
<span className="text-[.72rem]">Refresh Reference</span>
<span className="text-[.72rem]">{t('tf.refreshReference')}</span>
</button>
</div>
)}
@@ -1205,7 +1225,7 @@ export default function TerraformPage() {
{running && (
<span className="flex items-center gap-1.5 text-[.68rem]" style={{ color: 'var(--yl)' }}>
<Loader2 size={13} className="animate-spin" /> Executando...
<Loader2 size={13} className="animate-spin" /> {t('tf.running')}
</span>
)}
</div>
@@ -1221,7 +1241,7 @@ export default function TerraformPage() {
}}>
{!terminalOutput && !running && (
<span style={{ color: 'var(--t4)', opacity: 0.5 }}>
{`# Terminal de saida do Terraform\n# Execute Plan, Apply ou Destroy para ver os resultados aqui`}
{`# ${t('tf.terminalTitle')}\n# ${t('tf.terminalHint')}`}
</span>
)}
{wsError && terminalView === 'plan' && (
@@ -1305,7 +1325,7 @@ export default function TerraformPage() {
<button onClick={doAutoFix}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[.66rem] font-medium cursor-pointer transition-colors"
style={{ background: 'var(--yl)', color: '#000', border: '1px solid var(--yl)' }}>
<Wrench size={12} /> Auto-corrigir
<Wrench size={12} /> {t('tf.autoFix')}
</button>
)}
{wsStatus === 'applied' && !running && (

View File

@@ -130,25 +130,25 @@ export default function WorkspacesPage() {
/* ── actions ── */
const doPlan = async (wid: string) => {
try { await terraformApi.plan(wid); } catch (e: unknown) { alert('Erro: ' + (e as Error).message); return; }
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('Erro: ' + (e as Error).message); return; }
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('Erro: ' + (e as Error).message); load(); return; }
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('Erro: ' + (e as Error).message); }
try { await terraformApi.deleteWorkspace(wid); } catch (e: unknown) { alert(t('common.errorPrefix') + (e as Error).message); }
load();
};
const doCancel = async (wid: string) => {
@@ -378,7 +378,7 @@ export default function WorkspacesPage() {
<p className="text-[.76rem] mb-3" style={{ color: 'var(--t2)' }}>
{t('ws.confirmDestroyMsg')}
{' '}
Digite <strong>DESTROY</strong> para confirmar:
<span dangerouslySetInnerHTML={{ __html: t('ws.typeDestroyLabel') }} />
</p>
<input type="text" value={destroyInput}
onChange={(e) => setDestroyInput(e.target.value)}

View File

@@ -1,281 +0,0 @@
import { useState, useCallback } from 'react';
import { Lock, ShieldCheck, ShieldOff, KeyRound, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
import { useAuthStore } from '@/stores/auth';
import client from '@/api/client';
import { useI18n } from '@/i18n';
interface MfaSetupResponse {
secret: string;
uri: string;
}
export default function MfaPage() {
const { user, checkAuth } = useAuthStore();
const { t } = useI18n();
const [secret, setSecret] = useState<string | null>(null);
const [totpUri, setTotpUri] = useState<string | null>(null);
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState<{ text: string; type: 's' | 'e' | 'i' } | null>(null);
const mfaEnabled = !!user?.mfa_enabled;
const handleSetup = useCallback(async () => {
setLoading(true);
setMsg(null);
try {
const data = await client.post('/mfa/setup') as unknown as MfaSetupResponse;
setSecret(data.secret);
setTotpUri(data.uri);
} catch (err) {
setMsg({ text: err instanceof Error ? err.message : 'Erro ao gerar secret', type: 'e' });
} finally {
setLoading(false);
}
}, []);
const handleVerify = useCallback(async () => {
if (!code || code.length !== 6) {
setMsg({ text: t('mfa.invalidCode'), type: 'e' });
return;
}
setLoading(true);
setMsg(null);
try {
await client.post('/mfa/verify', { totp_code: code });
setMsg({ text: t('mfa.activated'), type: 's' });
setSecret(null);
setTotpUri(null);
setCode('');
await checkAuth();
} catch (err) {
setMsg({ text: err instanceof Error ? err.message : 'Codigo invalido', type: 'e' });
} finally {
setLoading(false);
}
}, [code, checkAuth, t]);
const handleDisable = useCallback(async () => {
if (!user) return;
if (!window.confirm(t('mfa.confirmDisable'))) return;
setLoading(true);
setMsg(null);
try {
await client.post(`/mfa/disable/${user.id}`);
setMsg({ text: t('mfa.deactivated'), type: 'i' });
await checkAuth();
} catch (err) {
setMsg({ text: err instanceof Error ? err.message : 'Erro ao desativar MFA', type: 'e' });
} finally {
setLoading(false);
}
}, [user, checkAuth, t]);
const alertStyles: Record<string, { bg: string; color: string; icon: typeof CheckCircle2 }> = {
s: { bg: 'var(--gnl)', color: 'var(--gn)', icon: CheckCircle2 },
e: { bg: 'var(--rdl)', color: 'var(--rd)', icon: AlertCircle },
i: { bg: 'color-mix(in srgb, var(--bl) 12%, transparent)', color: 'var(--bl)', icon: AlertCircle },
};
return (
<div className="page" style={{ maxWidth: 580 }}>
{/* Header */}
<div className="page-header">
<div className="card-header icon" style={{ background: 'color-mix(in srgb, var(--ac) 10%, transparent)' }}>
<Lock size={20} style={{ color: 'var(--ac)' }} />
</div>
<div>
<h1>{t('mfa.title')}</h1>
<div className="subtitle">
{t('mfa.subtitle')}
</div>
</div>
</div>
{/* Card */}
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
{/* Status banner */}
<div
className="flex items-center gap-3 px-5 py-4"
style={{
borderBottom: '1px solid var(--bd)',
background: mfaEnabled
? 'color-mix(in srgb, var(--gn) 6%, transparent)'
: 'color-mix(in srgb, var(--yl) 6%, transparent)',
}}
>
{mfaEnabled ? (
<ShieldCheck size={22} style={{ color: 'var(--gn)' }} />
) : (
<ShieldOff size={22} style={{ color: 'var(--yl)' }} />
)}
<div>
<span className="text-sm font-semibold" style={{ color: 'var(--t1)' }}>
{mfaEnabled ? t('mfa.enabled') : t('mfa.disabled')}
</span>
<p className="text-xs mt-0.5" style={{ color: 'var(--t3)' }}>
{mfaEnabled
? t('mfa.enabledDesc')
: t('mfa.disabledDesc')}
</p>
</div>
</div>
{/* Content */}
<div className="p-5">
{/* Message */}
{msg && (() => {
const st = alertStyles[msg.type];
const Icon = st.icon;
return (
<div
className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg mb-4 text-xs font-medium"
style={{ background: st.bg, color: st.color }}
>
<Icon size={15} />
{msg.text}
</div>
);
})()}
{mfaEnabled ? (
/* Enabled state: show disable button */
<div className="flex flex-col items-center gap-4 py-4">
<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.disableHint')}
</p>
<button
onClick={handleDisable}
disabled={loading}
className="btn btn-danger btn-sm"
>
{loading ? <Loader2 size={14} className="animate-spin" /> : <ShieldOff size={14} />}
{t('mfa.disableBtn')}
</button>
</div>
) : secret && totpUri ? (
/* Setup step 2: show secret + QR + verify */
<div className="flex flex-col gap-4">
{/* Instructions */}
<div className="flex items-start gap-3">
<div
className="w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 text-[.65rem] font-bold"
style={{ background: 'var(--ac)', color: '#fff' }}
>
1
</div>
<div>
<p className="text-xs font-semibold" style={{ color: 'var(--t1)' }}>
{t('mfa.scanQr')}
</p>
<p className="text-[.68rem] mt-0.5" style={{ color: 'var(--t4)' }}>
{t('mfa.openApp')}
</p>
</div>
</div>
{/* QR code */}
<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(totpUri)}`}
alt="QR Code TOTP"
width={180}
height={180}
className="rounded"
/>
</div>
</div>
{/* Secret */}
<div
className="px-3.5 py-2.5 rounded-lg text-center"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}
>
<span className="text-[.62rem] block mb-1" style={{ color: 'var(--t4)' }}>SECRET</span>
<code
className="text-xs font-bold tracking-wider select-all cursor-pointer"
style={{ color: 'var(--ac)', fontFamily: 'var(--fm)' }}
title="Clique para selecionar"
>
{secret}
</code>
</div>
{/* Verify */}
<div className="flex items-start gap-3">
<div
className="w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 text-[.65rem] font-bold"
style={{ background: 'var(--ac)', color: '#fff' }}
>
2
</div>
<div className="flex-1">
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--t1)' }}>
{t('mfa.enterCode')}
</p>
<div className="flex gap-2">
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
maxLength={6}
className="flex-1 px-3 py-2 rounded-lg text-sm text-center tracking-[.3em] font-semibold outline-none transition-colors"
style={{
background: 'var(--bg2)',
border: '1px solid var(--bd)',
color: 'var(--t1)',
fontFamily: 'var(--fm)',
}}
onFocus={(e) => { e.target.style.borderColor = 'var(--ac)'; }}
onBlur={(e) => { e.target.style.borderColor = 'var(--bd)'; }}
onKeyDown={(e) => { if (e.key === 'Enter') handleVerify(); }}
autoFocus
/>
<button
onClick={handleVerify}
disabled={loading || code.length !== 6}
className="btn btn-success btn-sm"
>
{loading ? <Loader2 size={14} className="animate-spin" /> : <KeyRound size={14} />}
{t('mfa.activateMfa')}
</button>
</div>
</div>
</div>
</div>
) : (
/* Setup step 1: generate secret */
<div className="flex flex-col items-center gap-4 py-4">
<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-xs" style={{ color: 'var(--t3)' }}>
{t('mfa.generateHint')}
</p>
<button
onClick={handleSetup}
disabled={loading}
className="btn btn-primary"
>
{loading ? <Loader2 size={16} className="animate-spin" /> : <KeyRound size={16} />}
{t('mfa.generateSecret')}
</button>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { Users, Plus, Shield, Eye, User as UserIcon, Trash2, X, AlertTriangle, CheckCircle, Loader2, UserPlus } from 'lucide-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 ── */
@@ -28,6 +29,7 @@ const ROLE_CONFIG: Record<Role, { label: string; color: string; bg: string; icon
/* ── 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);
@@ -35,6 +37,14 @@ export default function UsersPage() {
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('');
@@ -48,7 +58,7 @@ export default function UsersPage() {
const data = await client.get('/users') as unknown as AppUser[];
setUsers(data);
} catch (err) {
setMsg({ text: err instanceof Error ? err.message : 'Erro ao carregar usuarios', type: 'error' });
setMsg({ text: err instanceof Error ? err.message : t('usr.errorLoadUsers'), type: 'error' });
} finally {
setLoading(false);
}
@@ -97,7 +107,7 @@ export default function UsersPage() {
setShowForm(false);
await fetchUsers();
} catch (err) {
setMsg({ text: err instanceof Error ? err.message : 'Erro ao criar usuario', type: 'error' });
setMsg({ text: err instanceof Error ? err.message : t('usr.errorCreateUser'), type: 'error' });
} finally {
setSubmitting(false);
}
@@ -109,7 +119,7 @@ export default function UsersPage() {
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 : 'Erro ao atualizar role', type: 'error' });
setMsg({ text: err instanceof Error ? err.message : t('usr.errorUpdateRole'), type: 'error' });
}
};
@@ -120,7 +130,7 @@ export default function UsersPage() {
setMsg({ text: t('usr.deactivated'), type: 'success' });
await fetchUsers();
} catch (err) {
setMsg({ text: err instanceof Error ? err.message : 'Erro ao desativar usuario', type: 'error' });
setMsg({ text: err instanceof Error ? err.message : t('usr.errorDeactivate'), type: 'error' });
}
};
@@ -136,6 +146,63 @@ export default function UsersPage() {
}
};
// 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);
@@ -365,14 +432,23 @@ export default function UsersPage() {
{/* MFA */}
<td className="px-4 py-3 text-center">
{u.mfa_enabled ? (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.65rem] font-semibold"
<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')}
>
<CheckCircle size={11} /> {t('usr.active')}
</span>
<ShieldCheck size={11} /> {t('usr.active')}
</button>
) : (
<span className="text-xs" style={{ color: 'var(--t4)' }}>---</span>
<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>
@@ -458,6 +534,106 @@ export default function UsersPage() {
</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

@@ -115,9 +115,9 @@ export default function AdbConfigPage() {
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: `Wallet analisado! ${result.dsn_names.length} DSN(s): <strong>${result.dsn_names.join(', ')}</strong>. Arquivos: ${result.files.join(', ')}` });
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 : 'Erro ao analisar wallet' });
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorLoad') });
} finally {
setParsingWallet(false);
}
@@ -150,12 +150,12 @@ export default function AdbConfigPage() {
} else {
await adbApi.create(fd);
}
setFormMsg({ type: 's', text: `Conexao ${editing ? 'atualizada' : 'salva'}!${walletFile ? ' Wallet incluido.' : ''}` });
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 : 'Erro ao salvar' });
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorSave') });
setSaving(false);
}
};
@@ -167,11 +167,11 @@ export default function AdbConfigPage() {
await adbApi.remove(id);
await fetchConfigs();
useAppStore.getState().loadData();
setTableMsg({ type: 's', text: 'Conexao excluida com sucesso!' });
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 : 'Erro ao excluir' });
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorDelete') });
}
};
@@ -196,27 +196,27 @@ export default function AdbConfigPage() {
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: 'Enviando wallet...' });
setWalletMsg({ type: 'i', text: t('adb.walletSending') });
try {
const res = await adbApi.uploadWallet(walletUploadId, file);
await fetchConfigs();
useAppStore.getState().loadData();
setWalletMsg({ type: 's', text: `Wallet enviada! Arquivos: ${res.files.join(', ')}` });
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 : 'Erro ao enviar wallet' });
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: 'Digite o nome da tabela.' }); return; }
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: `Tabela ${newTableName.trim()} registrada!` });
setTableMsg({ type: 's', text: t('adb.tableRegistered').replace('{0}', newTableName.trim()) });
setNewTableName('');
setNewTableDesc('');
setTimeout(() => setTableMsg(null), 3000);
@@ -236,12 +236,12 @@ export default function AdbConfigPage() {
};
const handleRemoveTable = async (vid: string, tid: string) => {
if (!confirm('Excluir esta tabela do registro?')) return;
if (!confirm(t('adb.confirmRemoveTable'))) return;
try {
await adbApi.removeTable(vid, tid);
await fetchConfigs();
useAppStore.getState().loadData();
setTableMsg({ type: 's', text: 'Tabela removida.' });
setTableMsg({ type: 's', text: t('adb.tableRemoved') });
setTimeout(() => setTableMsg(null), 3000);
} catch (err) {
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
@@ -299,7 +299,7 @@ export default function AdbConfigPage() {
<table className="data-table">
<thead>
<tr>
{['Nome', 'DSN', 'User', 'Tabelas', 'mTLS', 'Wallet', 'Embed', 'Acoes'].map((h) => (
{[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>
@@ -355,7 +355,7 @@ export default function AdbConfigPage() {
<table className="w-full text-[.68rem]" style={{ borderCollapse: 'collapse' }}>
<thead>
<tr>
{['Tabela', 'Descrição', 'Ativa', 'Ações'].map((h) => (
{[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>
@@ -480,7 +480,7 @@ export default function AdbConfigPage() {
<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
? <>Editando: <strong style={{ color: 'var(--t2)' }}>{editing.config_name}</strong></>
? <>{t('adb.editingLabel')} <strong style={{ color: 'var(--t2)' }}>{editing.config_name}</strong></>
: <>{t('adb.connDesc')}</>}
</p>

View File

@@ -114,7 +114,7 @@ export default function EmbConsultPage() {
} catch (err) {
const assistantMsg: ChatMessage = {
role: 'assistant',
content: `Erro: ${err instanceof Error ? err.message : 'Erro desconhecido'}`,
content: `${t('ec.errorPrefix')}${err instanceof Error ? err.message : t('ec.errorUnknown')}`,
};
setMessages((prev) => [...prev, assistantMsg]);
} finally {

View File

@@ -112,19 +112,19 @@ export default function EmbeddingsPage() {
setTotal(d.total);
if (!d.documents.length) setListMsg({ type: 'i', text: t('emb.noEmbeddings') });
} catch (err) {
setListMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao carregar embeddings' });
setListMsg({ type: 'e', text: err instanceof Error ? err.message : t('emb.errorLoadEmb') });
} finally {
setListLoading(false);
}
};
const deleteEmb = async (docId: string) => {
if (!confirm('Excluir este embedding?')) return;
if (!confirm(t('emb.confirmDeleteEmb'))) return;
try {
await embeddingsApi.remove(selVid, docId, selTable || undefined);
loadEmbs();
} catch (err) {
alert(err instanceof Error ? err.message : 'Erro ao excluir');
alert(err instanceof Error ? err.message : t('common.errorDelete'));
}
};
@@ -311,7 +311,7 @@ export default function EmbeddingsPage() {
</tbody>
</table>
</div>
<p className="text-[.68rem]" style={{ color: 'var(--t4)' }}>Total: {total} documentos</p>
<p className="text-[.68rem]" style={{ color: 'var(--t4)' }}>{t('emb.totalDocs').replace('{0}', String(total))}</p>
</>
)}
</div>

View File

@@ -96,7 +96,7 @@ export default function GenAiConfigPage() {
if (!cfg) return;
setGenaiRegion(cfg.region);
if (cfg.compartment_id) setCompartmentId(cfg.compartment_id);
setFormMsg({ type: 'i', text: `Regiao e Compartment preenchidos de <strong>${cfg.tenancy_name}</strong>` });
setFormMsg({ type: 'i', text: t('genai.regionFilled').replace('{0}', cfg.tenancy_name) });
setTimeout(() => setFormMsg(null), 3000);
};
@@ -128,12 +128,12 @@ export default function GenAiConfigPage() {
} else {
await genaiApi.create(body);
}
setFormMsg({ type: 's', text: `Modelo ${editing ? 'atualizado' : 'salvo'} com sucesso!` });
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 : 'Erro ao salvar' });
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorSave') });
setSaving(false);
}
};
@@ -145,11 +145,11 @@ export default function GenAiConfigPage() {
await genaiApi.remove(id);
await fetchConfigs();
useAppStore.getState().loadData();
setTableMsg({ type: 's', text: 'Modelo excluido com sucesso!' });
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 : 'Erro ao excluir' });
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorDelete') });
}
};
@@ -231,7 +231,7 @@ export default function GenAiConfigPage() {
<table className="data-table">
<thead>
<tr>
{['Config', 'Modelo', 'OCI Config', 'Regiao', 'Acoes'].map((h) => (
{[t('genai.thConfig'), t('genai.thModel'), t('genai.thOciConfig'), t('genai.thRegion'), t('genai.thActions')].map((h) => (
<th key={h}>{h}</th>
))}
</tr>
@@ -345,7 +345,7 @@ export default function GenAiConfigPage() {
<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
? <>Editando: <strong style={{ color: 'var(--t2)' }}>{editing.name}</strong></>
? <>{t('genai.editingLabel')} <strong style={{ color: 'var(--t2)' }}>{editing.name}</strong></>
: t('genai.addDesc')}
</p>

View File

@@ -133,28 +133,28 @@ export default function McpServersPage() {
} else {
await mcpApi.create(body);
}
setFormMsg({ type: 's', text: `Server ${editing ? 'atualizado' : 'registrado'} com sucesso!` });
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 : 'Erro ao salvar' });
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: 'Excluindo server...' });
setTableMsg({ type: 'i', text: t('mcp.deletingServer') });
try {
await mcpApi.remove(id);
await fetchServers();
useAppStore.getState().loadData();
setTableMsg({ type: 's', text: 'Server excluido com sucesso!' });
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 : 'Erro ao excluir' });
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorDelete') });
}
};
@@ -178,11 +178,11 @@ export default function McpServersPage() {
const res = await mcpApi.discoverTools(id);
await fetchServers();
useAppStore.getState().loadData();
setTableMsg({ type: 's', text: `${res.discovered} tool(s) descoberta(s), ${res.total} total` });
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 : 'Erro ao descobrir tools' });
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('mcp.errorDiscoverTools') });
} finally {
setDiscovering((p) => ({ ...p, [id]: false }));
}
@@ -193,7 +193,7 @@ export default function McpServersPage() {
if (!srv?.tools) return;
const tool = srv.tools[idx];
const toolName = typeof tool === 'string' ? tool : (tool as McpToolDef).name;
if (!confirm(`Remover tool "${toolName}"?`)) return;
if (!confirm(t('mcp.removeToolConfirm').replace('{0}', toolName))) return;
const newTools = [...srv.tools];
newTools.splice(idx, 1);
try {
@@ -422,7 +422,7 @@ export default function McpServersPage() {
<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
? <>Editando: <strong style={{ color: 'var(--t2)' }}>{editing.name}</strong></>
? <>{t('mcp.editingLabel')} <strong style={{ color: 'var(--t2)' }}>{editing.name}</strong></>
: t('mcp.configDesc')}
</p>

View File

@@ -299,7 +299,7 @@ export default function OciConfigPage() {
useAppStore.getState().loadData();
setTimeout(resetForm, 1200);
} catch (err) {
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao salvar' });
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorSave') });
setSaving(false);
}
};
@@ -315,7 +315,7 @@ export default function OciConfigPage() {
if (editing?.id === id) resetForm();
setTimeout(() => setTableMsg(null), 3000);
} catch (err) {
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao excluir' });
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorDelete') });
}
};
@@ -387,7 +387,7 @@ export default function OciConfigPage() {
<table className="data-table">
<thead>
<tr>
{['Tenancy', 'Tipo', 'Region', 'Detalhes', 'Acoes'].map((h) => (
{[t('oci.thTenancy'), t('oci.thType'), t('oci.thRegion'), t('oci.thDetails'), t('oci.thActions')].map((h) => (
<th key={h}>{h}</th>
))}
</tr>

View File

@@ -75,6 +75,34 @@ export interface AdbConfig {
created_at?: string;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ChatMessage = Record<string, any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ChatSessionInfo = Record<string, any>;
export interface ChatParams {
temperature: number;
max_tokens: number;
top_p: number;
top_k: number;
frequency_penalty: number;
presence_penalty: number;
reasoning_effort: string;
}
export const DEFAULT_CHAT_PARAMS: ChatParams = {
temperature: 1,
max_tokens: 6000,
top_p: 0.95,
top_k: 1,
frequency_penalty: 0,
presence_penalty: 0,
reasoning_effort: 'medium',
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Updater<T> = T | ((prev: T) => T);
interface AppState {
// Shared config data
ociCfg: OciConfig[];
@@ -86,6 +114,128 @@ interface AppState {
mcpSvr: McpServer[];
adbCfg: AdbConfig[];
// Chat Agent persistent settings
chatModel: string;
chatOciConfig: string;
chatParams: ChatParams;
chatUseTools: boolean;
setChatModel: (v: string) => void;
setChatOciConfig: (v: string) => void;
setChatParams: (v: Updater<ChatParams>) => void;
setChatUseTools: (v: boolean) => void;
// Chat Agent session state (persists across tab switches)
chatMessages: ChatMessage[];
chatSessionId: string | null;
chatSessions: ChatSessionInfo[];
setChatMessages: (v: Updater<ChatMessage[]>) => void;
setChatSessionId: (v: string | null) => void;
setChatSessions: (v: Updater<ChatSessionInfo[]>) => void;
// ── Terraform Page state ──
tfMessages: ChatMessage[];
tfSessionId: string | null;
tfSessions: ChatSessionInfo[];
tfModel: string;
tfOciConfig: string;
tfRegion: string;
tfCompartment: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tfCompartments: any[];
tfTemperature: number;
tfWsId: string | null;
tfWsStatus: string;
tfWsName: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tfFiles: any[];
tfPlanOutput: string;
tfApplyOutput: string;
tfDestroyOutput: string;
tfWsError: string;
tfBottomTab: string;
tfTerminalView: string;
setTfMessages: (v: Updater<ChatMessage[]>) => void;
setTfSessionId: (v: string | null) => void;
setTfSessions: (v: Updater<ChatSessionInfo[]>) => void;
setTfModel: (v: string) => void;
setTfOciConfig: (v: string) => void;
setTfRegion: (v: string) => void;
setTfCompartment: (v: string) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setTfCompartments: (v: Updater<any[]>) => void;
setTfTemperature: (v: number) => void;
setTfWsId: (v: string | null) => void;
setTfWsStatus: (v: string) => void;
setTfWsName: (v: string) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setTfFiles: (v: Updater<any[]>) => void;
setTfPlanOutput: (v: string) => void;
setTfApplyOutput: (v: string) => void;
setTfDestroyOutput: (v: string) => void;
setTfWsError: (v: string) => void;
setTfBottomTab: (v: string) => void;
setTfTerminalView: (v: string) => void;
// ── Prompt Generator Page state ──
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pgMessages: any[];
pgSessionId: string | null;
pgSelectedModel: string;
pgOciId: string;
pgRegion: string;
pgCompartment: string;
pgHistory: ChatSessionInfo[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setPgMessages: (v: Updater<any[]>) => void;
setPgSessionId: (v: string | null) => void;
setPgSelectedModel: (v: string) => void;
setPgOciId: (v: string) => void;
setPgRegion: (v: string) => void;
setPgCompartment: (v: string) => void;
setPgHistory: (v: Updater<ChatSessionInfo[]>) => void;
// ── Explorer Page state ──
expSelectedConfig: string;
expSelectedCompartment: string;
expSelectedRegions: string[];
expSelectedCategory: string;
expSelectedResourceType: string;
expTreeWidth: number;
setExpSelectedConfig: (v: string) => void;
setExpSelectedCompartment: (v: string) => void;
setExpSelectedRegions: (v: Updater<string[]>) => void;
setExpSelectedCategory: (v: string) => void;
setExpSelectedResourceType: (v: string) => void;
setExpTreeWidth: (v: number) => void;
// ── Reports Page state ──
rptOciVal: string;
rptLevel: 1 | 2;
rptSelectedRegions: string[];
rptObp: boolean;
rptRaw: boolean;
rptRedact: boolean;
rptFormOpen: boolean;
rptSelectedRid: string;
rptTrackingId: string | null;
rptShowIframe: boolean;
rptShowCompliance: boolean;
rptEmbedAdb: string;
rptEmbedTable: string;
setRptOciVal: (v: string) => void;
setRptLevel: (v: 1 | 2) => void;
setRptSelectedRegions: (v: Updater<string[]>) => void;
setRptObp: (v: boolean) => void;
setRptRaw: (v: boolean) => void;
setRptRedact: (v: boolean) => void;
setRptFormOpen: (v: boolean) => void;
setRptSelectedRid: (v: string) => void;
setRptTrackingId: (v: string | null) => void;
setRptShowIframe: (v: boolean) => void;
setRptShowCompliance: (v: boolean) => void;
setRptEmbedAdb: (v: string) => void;
setRptEmbedTable: (v: string) => void;
// UI
sidebarOpen: boolean;
toggleSidebar: () => void;
@@ -103,6 +253,120 @@ export const useAppStore = create<AppState>((set) => ({
embModels: {},
mcpSvr: [],
adbCfg: [],
chatModel: '',
chatOciConfig: '',
chatParams: { ...DEFAULT_CHAT_PARAMS },
chatUseTools: true,
setChatModel: (v) => set({ chatModel: v }),
setChatOciConfig: (v) => set({ chatOciConfig: v }),
setChatParams: (v) => set((s) => ({ chatParams: typeof v === 'function' ? v(s.chatParams) : v })),
setChatUseTools: (v) => set({ chatUseTools: v }),
chatMessages: [],
chatSessionId: null,
chatSessions: [],
setChatMessages: (v) => set((s) => ({ chatMessages: typeof v === 'function' ? v(s.chatMessages) : v })),
setChatSessionId: (v) => set({ chatSessionId: v }),
setChatSessions: (v) => set((s) => ({ chatSessions: typeof v === 'function' ? v(s.chatSessions) : v })),
// ── Terraform Page ──
tfMessages: [],
tfSessionId: null,
tfSessions: [],
tfModel: 'openai.gpt-4.1',
tfOciConfig: '',
tfRegion: '',
tfCompartment: '',
tfCompartments: [],
tfTemperature: 0.3,
tfWsId: null,
tfWsStatus: 'draft',
tfWsName: '',
tfFiles: [],
tfPlanOutput: '',
tfApplyOutput: '',
tfDestroyOutput: '',
tfWsError: '',
tfBottomTab: 'files',
tfTerminalView: 'plan',
setTfMessages: (v) => set((s) => ({ tfMessages: typeof v === 'function' ? v(s.tfMessages) : v })),
setTfSessionId: (v) => set({ tfSessionId: v }),
setTfSessions: (v) => set((s) => ({ tfSessions: typeof v === 'function' ? v(s.tfSessions) : v })),
setTfModel: (v) => set({ tfModel: v }),
setTfOciConfig: (v) => set({ tfOciConfig: v }),
setTfRegion: (v) => set({ tfRegion: v }),
setTfCompartment: (v) => set({ tfCompartment: v }),
setTfCompartments: (v) => set((s) => ({ tfCompartments: typeof v === 'function' ? v(s.tfCompartments) : v })),
setTfTemperature: (v) => set({ tfTemperature: v }),
setTfWsId: (v) => set({ tfWsId: v }),
setTfWsStatus: (v) => set({ tfWsStatus: v }),
setTfWsName: (v) => set({ tfWsName: v }),
setTfFiles: (v) => set((s) => ({ tfFiles: typeof v === 'function' ? v(s.tfFiles) : v })),
setTfPlanOutput: (v) => set({ tfPlanOutput: v }),
setTfApplyOutput: (v) => set({ tfApplyOutput: v }),
setTfDestroyOutput: (v) => set({ tfDestroyOutput: v }),
setTfWsError: (v) => set({ tfWsError: v }),
setTfBottomTab: (v) => set({ tfBottomTab: v }),
setTfTerminalView: (v) => set({ tfTerminalView: v }),
// ── Prompt Generator Page ──
pgMessages: [],
pgSessionId: null,
pgSelectedModel: '',
pgOciId: '',
pgRegion: '',
pgCompartment: '',
pgHistory: [],
setPgMessages: (v) => set((s) => ({ pgMessages: typeof v === 'function' ? v(s.pgMessages) : v })),
setPgSessionId: (v) => set({ pgSessionId: v }),
setPgSelectedModel: (v) => set({ pgSelectedModel: v }),
setPgOciId: (v) => set({ pgOciId: v }),
setPgRegion: (v) => set({ pgRegion: v }),
setPgCompartment: (v) => set({ pgCompartment: v }),
setPgHistory: (v) => set((s) => ({ pgHistory: typeof v === 'function' ? v(s.pgHistory) : v })),
// ── Explorer Page ──
expSelectedConfig: '',
expSelectedCompartment: '',
expSelectedRegions: [],
expSelectedCategory: 'Compute',
expSelectedResourceType: 'instances',
expTreeWidth: 280,
setExpSelectedConfig: (v) => set({ expSelectedConfig: v }),
setExpSelectedCompartment: (v) => set({ expSelectedCompartment: v }),
setExpSelectedRegions: (v) => set((s) => ({ expSelectedRegions: typeof v === 'function' ? v(s.expSelectedRegions) : v })),
setExpSelectedCategory: (v) => set({ expSelectedCategory: v }),
setExpSelectedResourceType: (v) => set({ expSelectedResourceType: v }),
setExpTreeWidth: (v) => set({ expTreeWidth: v }),
// ── Reports Page ──
rptOciVal: '',
rptLevel: 2,
rptSelectedRegions: [],
rptObp: true,
rptRaw: false,
rptRedact: true,
rptFormOpen: true,
rptSelectedRid: '',
rptTrackingId: null,
rptShowIframe: false,
rptShowCompliance: false,
rptEmbedAdb: '',
rptEmbedTable: '',
setRptOciVal: (v) => set({ rptOciVal: v }),
setRptLevel: (v) => set({ rptLevel: v }),
setRptSelectedRegions: (v) => set((s) => ({ rptSelectedRegions: typeof v === 'function' ? v(s.rptSelectedRegions) : v })),
setRptObp: (v) => set({ rptObp: v }),
setRptRaw: (v) => set({ rptRaw: v }),
setRptRedact: (v) => set({ rptRedact: v }),
setRptFormOpen: (v) => set({ rptFormOpen: v }),
setRptSelectedRid: (v) => set({ rptSelectedRid: v }),
setRptTrackingId: (v) => set({ rptTrackingId: v }),
setRptShowIframe: (v) => set({ rptShowIframe: v }),
setRptShowCompliance: (v) => set({ rptShowCompliance: v }),
setRptEmbedAdb: (v) => set({ rptEmbedAdb: v }),
setRptEmbedTable: (v) => set({ rptEmbedTable: v }),
sidebarOpen: true,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),