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:
1
frontend-react/.gitignore
vendored
1
frontend-react/.gitignore
vendored
@@ -8,6 +8,7 @@ pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
115
frontend-react/dist/assets/index-Dtsz7Biu.js
vendored
115
frontend-react/dist/assets/index-Dtsz7Biu.js
vendored
File diff suppressed because one or more lines are too long
22
frontend-react/dist/index.html
vendored
22
frontend-react/dist/index.html
vendored
@@ -1,22 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/app/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI Agent - Infrastructure & Security Engineer</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<script type="module" crossorigin src="/assets/index-Dtsz7Biu.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Ca4AYldT.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
// Restore theme before React hydration to avoid flash
|
||||
(function() {
|
||||
var t = localStorage.getItem('theme') || 'light';
|
||||
document.documentElement.className = t;
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
21
frontend-react/dist/logo.svg
vendored
21
frontend-react/dist/logo.svg
vendored
@@ -1,21 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="128" height="128">
|
||||
<!-- Oracle-style rounded rectangle -->
|
||||
<rect x="2" y="5" width="32" height="26" rx="13" ry="13" fill="rgba(199,70,52,0.08)" stroke="#C74634" stroke-width="2"/>
|
||||
<!-- Robot head -->
|
||||
<rect x="11" y="11" width="14" height="14" rx="4" fill="#fff" stroke="#C74634" stroke-width="0.5"/>
|
||||
<!-- Eyes -->
|
||||
<circle cx="15" cy="17" r="2" fill="#C74634"/>
|
||||
<circle cx="21" cy="17" r="2" fill="#C74634"/>
|
||||
<circle cx="15" cy="16.7" r="0.7" fill="#fff"/>
|
||||
<circle cx="21" cy="16.7" r="0.7" fill="#fff"/>
|
||||
<!-- Mouth -->
|
||||
<rect x="14" y="21" width="8" height="1.5" rx="0.75" fill="#C74634" opacity="0.6"/>
|
||||
<!-- Antenna -->
|
||||
<line x1="18" y1="11" x2="18" y2="6" stroke="#C74634" stroke-width="1.2" stroke-linecap="round"/>
|
||||
<circle cx="18" cy="5.5" r="1.5" fill="#C74634" opacity="0.3" stroke="#C74634" stroke-width="0.8"/>
|
||||
<!-- Ears/signal -->
|
||||
<line x1="11" y1="18" x2="6" y2="18" stroke="#C74634" stroke-width="1" stroke-linecap="round" opacity="0.5"/>
|
||||
<line x1="25" y1="18" x2="30" y2="18" stroke="#C74634" stroke-width="1" stroke-linecap="round" opacity="0.5"/>
|
||||
<circle cx="5.5" cy="18" r="1" fill="#C74634" opacity="0.4"/>
|
||||
<circle cx="30.5" cy="18" r="1" fill="#C74634" opacity="0.4"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -2,7 +2,7 @@
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/app/logo.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI Agent - Infrastructure & Security Engineer</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)'; }}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`}
|
||||
>
|
||||
×
|
||||
</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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
Reference in New Issue
Block a user