refactor: reorganize project structure — frontend/, infra/, clean paths
This commit is contained in:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
21
frontend/index.html
Normal file
21
frontend/index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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">
|
||||
</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>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5920
frontend/package-lock.json
generated
Normal file
5920
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend/package.json
Normal file
41
frontend/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "frontend-react",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.6",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"recharts": "^3.8.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
5
frontend/postcss.config.js
Normal file
5
frontend/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
21
frontend/public/logo.svg
Normal file
21
frontend/public/logo.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
253
frontend/src/App.tsx
Normal file
253
frontend/src/App.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { useEffect, useState, useMemo, lazy, Suspense } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { authApi } from '@/api/endpoints/auth';
|
||||
import { useI18n } from '@/i18n';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import { Loader2, Lock, CheckCircle, XCircle, Shield } 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 OciServicesPage = lazy(() => import('@/pages/OciServicesPage'));
|
||||
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 TerminalPage = lazy(() => import('@/pages/TerminalPage'));
|
||||
const UsersPage = lazy(() => import('@/pages/admin/UsersPage'));
|
||||
const MySettingsPage = lazy(() => import('@/pages/admin/MySettingsPage'));
|
||||
const OracleIamPage = lazy(() => import('@/pages/admin/OracleIamPage'));
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function ForcePasswordModal() {
|
||||
const { t } = useI18n();
|
||||
const [step, setStep] = useState<'welcome' | 'form' | 'done'>('welcome');
|
||||
const [curPw, setCurPw] = useState('');
|
||||
const [newPw, setNewPw] = useState('');
|
||||
const [confirmPw, setConfirmPw] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const checkAuth = useAuthStore((s) => s.checkAuth);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const rules = useMemo(() => [
|
||||
{ label: t('pw.minLength') || 'Minimum 8 characters', ok: newPw.length >= 8 },
|
||||
{ label: t('pw.hasUpper') || 'One uppercase letter', ok: /[A-Z]/.test(newPw) },
|
||||
{ label: t('pw.hasLower') || 'One lowercase letter', ok: /[a-z]/.test(newPw) },
|
||||
{ label: t('pw.hasNumber') || 'One number', ok: /\d/.test(newPw) },
|
||||
{ label: t('pw.hasSpecial') || 'One special character (!@#$...)', ok: /[^A-Za-z0-9]/.test(newPw) },
|
||||
{ label: t('pw.match') || 'Passwords match', ok: newPw.length > 0 && newPw === confirmPw },
|
||||
], [newPw, confirmPw, t]);
|
||||
|
||||
const allValid = rules.every(r => r.ok) && curPw.length > 0 && newPw !== curPw;
|
||||
const strength = rules.filter(r => r.ok).length;
|
||||
const strengthColor = strength <= 2 ? 'var(--rd)' : strength <= 4 ? 'var(--yl, #e6a817)' : 'var(--gn)';
|
||||
const strengthLabel = strength <= 2 ? (t('pw.weak') || 'Weak') : strength <= 4 ? (t('pw.medium') || 'Medium') : (t('pw.strong') || 'Strong');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (!allValid) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await authApi.changePassword(curPw, newPw);
|
||||
setStep('done');
|
||||
setTimeout(async () => {
|
||||
await checkAuth();
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('pw.error') || 'Error changing password');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(4px)' }}>
|
||||
<div className="w-[420px] p-8 rounded-2xl" style={{ background: 'var(--bg1)', boxShadow: 'var(--sh3)', border: '1px solid var(--bd)' }}>
|
||||
|
||||
{step === 'welcome' && (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-14 h-14 rounded-xl flex items-center justify-center mb-4" style={{ background: 'var(--acl)' }}>
|
||||
<Shield size={28} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<h2 className="text-lg font-bold mb-2" style={{ color: 'var(--t1)' }}>
|
||||
{t('pw.welcomeTitle') || 'Welcome!'}
|
||||
</h2>
|
||||
<p className="text-xs text-center mb-1" style={{ color: 'var(--t2)' }}>
|
||||
{t('pw.welcomeUser') || 'Logged in as'} <strong>{user?.username}</strong>
|
||||
</p>
|
||||
<p className="text-xs text-center mb-6" style={{ color: 'var(--t3)' }}>
|
||||
{t('pw.welcomeMsg') || 'For security, you must change your password before continuing.'}
|
||||
</p>
|
||||
<button onClick={() => setStep('form')}
|
||||
className="w-full py-2.5 rounded-lg text-sm font-semibold text-white transition-colors"
|
||||
style={{ background: 'var(--ac)' }}>
|
||||
{t('pw.continue') || 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'form' && (
|
||||
<>
|
||||
<div className="flex flex-col items-center mb-5">
|
||||
<div className="w-12 h-12 rounded-xl flex items-center justify-center mb-3" style={{ background: 'var(--rdl)' }}>
|
||||
<Lock size={24} style={{ color: 'var(--rd)' }} />
|
||||
</div>
|
||||
<h2 className="text-base font-bold" style={{ color: 'var(--t1)' }}>
|
||||
{t('pw.changeTitle') || 'Change Password'}
|
||||
</h2>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<div>
|
||||
<label className="text-[.65rem] font-medium mb-1 block" style={{ color: 'var(--t3)' }}>
|
||||
{t('pw.currentPassword') || 'Current password'}
|
||||
</label>
|
||||
<input type="password" value={curPw} onChange={e => setCurPw(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[.65rem] font-medium mb-1 block" style={{ color: 'var(--t3)' }}>
|
||||
{t('pw.newPassword') || 'New password'}
|
||||
</label>
|
||||
<input type="password" value={newPw} onChange={e => setNewPw(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[.65rem] font-medium mb-1 block" style={{ color: 'var(--t3)' }}>
|
||||
{t('pw.confirmPassword') || 'Confirm new password'}
|
||||
</label>
|
||||
<input type="password" value={confirmPw} onChange={e => setConfirmPw(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} />
|
||||
</div>
|
||||
|
||||
{newPw.length > 0 && (
|
||||
<div className="rounded-lg px-3 py-2.5" style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[.6rem] font-semibold" style={{ color: 'var(--t3)' }}>
|
||||
{t('pw.strength') || 'Password strength'}
|
||||
</span>
|
||||
<span className="text-[.6rem] font-bold" style={{ color: strengthColor }}>{strengthLabel}</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 rounded-full mb-2.5" style={{ background: 'var(--bg3)' }}>
|
||||
<div className="h-full rounded-full transition-all duration-300"
|
||||
style={{ width: `${(strength / 6) * 100}%`, background: strengthColor }} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{rules.map((r, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
{r.ok
|
||||
? <CheckCircle size={11} style={{ color: 'var(--gn)', flexShrink: 0 }} />
|
||||
: <XCircle size={11} style={{ color: 'var(--t4)', flexShrink: 0 }} />}
|
||||
<span className="text-[.6rem]" style={{ color: r.ok ? 'var(--gn)' : 'var(--t4)' }}>{r.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newPw.length > 0 && newPw === curPw && (
|
||||
<div className="text-[.65rem] px-3 py-2 rounded-lg" style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>
|
||||
{t('pw.sameAsOld') || 'New password must be different from the current one'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-[.65rem] px-3 py-2 rounded-lg" style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>{error}</div>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={loading || !allValid}
|
||||
className="w-full py-2.5 rounded-lg text-sm font-semibold text-white transition-colors disabled:opacity-40"
|
||||
style={{ background: 'var(--ac)' }}>
|
||||
{loading ? (t('pw.saving') || 'Saving...') : (t('pw.changeButton') || 'Change Password')}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'done' && (
|
||||
<div className="flex flex-col items-center py-4">
|
||||
<div className="w-14 h-14 rounded-xl flex items-center justify-center mb-4" style={{ background: 'var(--gnl)' }}>
|
||||
<CheckCircle size={28} style={{ color: 'var(--gn)' }} />
|
||||
</div>
|
||||
<h2 className="text-base font-bold mb-2" style={{ color: 'var(--t1)' }}>
|
||||
{t('pw.successTitle') || 'Password changed!'}
|
||||
</h2>
|
||||
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>
|
||||
{t('pw.successMsg') || 'Redirecting...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const checkAuth = useAuthStore((s) => s.checkAuth);
|
||||
const loadData = useAppStore((s) => s.loadData);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const mustChangePassword = useAuthStore((s) => s.mustChangePassword);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth().then(() => {
|
||||
if (useAuthStore.getState().user) loadData();
|
||||
});
|
||||
}, [checkAuth, loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) loadData();
|
||||
}, [user, loadData]);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
{user && mustChangePassword && <ForcePasswordModal />}
|
||||
<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="/oci-services" element={<OciServicesPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/terminal" element={<TerminalPage />} />
|
||||
<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/user-settings" element={<MySettingsPage />} />
|
||||
<Route path="/admin/iam" element={<OracleIamPage />} />
|
||||
<Route path="/admin/audit" element={<AuditPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/chat" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
30
frontend/src/api/client.ts
Normal file
30
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
// Request: attach JWT
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('t');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response: handle 401 + extract data
|
||||
client.interceptors.response.use(
|
||||
(res) => res.data,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('t');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
const detail = err.response?.data?.detail || err.response?.data?.message || err.message || 'Erro';
|
||||
return Promise.reject(new Error(detail));
|
||||
}
|
||||
);
|
||||
|
||||
export default client;
|
||||
71
frontend/src/api/endpoints/adb.ts
Normal file
71
frontend/src/api/endpoints/adb.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import client from '../client';
|
||||
|
||||
export interface AdbTableDef {
|
||||
id: string;
|
||||
table_name: string;
|
||||
description?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface AdbConfigFull {
|
||||
id: string;
|
||||
config_name: string;
|
||||
dsn: string;
|
||||
username: string;
|
||||
table_name?: string;
|
||||
use_mtls: boolean;
|
||||
is_active: boolean;
|
||||
wallet_dir?: string;
|
||||
genai_config_id?: string;
|
||||
embedding_model_id?: string;
|
||||
tables?: AdbTableDef[];
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface AdbTestResult {
|
||||
status: 'success' | 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface WalletParseResult {
|
||||
dsn_names: string[];
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export const adbApi = {
|
||||
list: () =>
|
||||
client.get('/adb/configs') as unknown as Promise<AdbConfigFull[]>,
|
||||
|
||||
create: (fd: FormData) =>
|
||||
client.post('/adb/config', fd, { headers: { 'Content-Type': 'multipart/form-data' } }) as unknown as Promise<{ id: string; config_name: string }>,
|
||||
|
||||
update: (id: string, fd: FormData) =>
|
||||
client.put(`/adb/configs/${id}`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }) as unknown as Promise<{ id: string; config_name: string }>,
|
||||
|
||||
remove: (id: string) =>
|
||||
client.delete(`/adb/configs/${id}`) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
test: (id: string) =>
|
||||
client.post(`/adb/test/${id}`) as unknown as Promise<AdbTestResult>,
|
||||
|
||||
parseWallet: (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('wallet', file);
|
||||
return client.post('/adb/parse-wallet', fd, { headers: { 'Content-Type': 'multipart/form-data' } }) as unknown as Promise<WalletParseResult>;
|
||||
},
|
||||
|
||||
uploadWallet: (id: string, file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('wallet', file);
|
||||
return client.post(`/adb/${id}/upload-wallet`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }) as unknown as Promise<{ ok: boolean; files: string[] }>;
|
||||
},
|
||||
|
||||
addTable: (vid: string, tableName: string, description: string) =>
|
||||
client.post(`/adb/${vid}/tables`, { table_name: tableName, description }) as unknown as Promise<{ id: string }>,
|
||||
|
||||
updateTable: (vid: string, tid: string, body: { table_name?: string; description?: string; is_active?: boolean }) =>
|
||||
client.put(`/adb/${vid}/tables/${tid}`, body) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
removeTable: (vid: string, tid: string) =>
|
||||
client.delete(`/adb/${vid}/tables/${tid}`) as unknown as Promise<{ ok: boolean }>,
|
||||
};
|
||||
81
frontend/src/api/endpoints/admin.ts
Normal file
81
frontend/src/api/endpoints/admin.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import client from '../client';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export interface AuditEntry {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
username: string | null;
|
||||
action: string;
|
||||
resource: string | null;
|
||||
details: string | null;
|
||||
ip: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ConfigLogEntry {
|
||||
id: string;
|
||||
config_type: string;
|
||||
config_id: string;
|
||||
config_name: string | null;
|
||||
severity: string;
|
||||
action: string;
|
||||
message: string;
|
||||
user_id: string | null;
|
||||
username: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ChatLogEntry {
|
||||
id: string;
|
||||
session_id: string | null;
|
||||
message_id: string | null;
|
||||
user_id: string | null;
|
||||
severity: string;
|
||||
source: string;
|
||||
action: string;
|
||||
message: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/* ── API calls ── */
|
||||
|
||||
export const adminApi = {
|
||||
getAuditLog: (limit = 100) =>
|
||||
client.get(`/audit-log?limit=${limit}`) as unknown as Promise<AuditEntry[]>,
|
||||
|
||||
getConfigLogs: (params: {
|
||||
config_type?: string;
|
||||
config_id?: string;
|
||||
severity?: string;
|
||||
limit?: number;
|
||||
} = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.config_type) qs.set('config_type', params.config_type);
|
||||
if (params.config_id) qs.set('config_id', params.config_id);
|
||||
if (params.severity) qs.set('severity', params.severity);
|
||||
qs.set('limit', String(params.limit ?? 50));
|
||||
return client.get(`/config-logs?${qs}`) as unknown as Promise<ConfigLogEntry[]>;
|
||||
},
|
||||
|
||||
getChatLogs: (params: {
|
||||
severity?: string;
|
||||
session_id?: string;
|
||||
limit?: number;
|
||||
} = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.severity) qs.set('severity', params.severity);
|
||||
if (params.session_id) qs.set('session_id', params.session_id);
|
||||
qs.set('limit', String(params.limit ?? 50));
|
||||
return client.get(`/chat-logs?${qs}`) as unknown as Promise<ChatLogEntry[]>;
|
||||
},
|
||||
|
||||
getMyTimezone: () =>
|
||||
client.get('/users/me/timezone') as unknown as Promise<{ timezone: string }>,
|
||||
|
||||
setMyTimezone: (timezone: string) =>
|
||||
client.put('/users/me/timezone', { timezone }) as unknown as Promise<{ ok: boolean; timezone: string }>,
|
||||
|
||||
getTimezoneOptions: () =>
|
||||
client.get('/settings/timezone/options') as unknown as Promise<{ timezones: string[] }>,
|
||||
};
|
||||
38
frontend/src/api/endpoints/auth.ts
Normal file
38
frontend/src/api/endpoints/auth.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import client from '../client';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
email?: string;
|
||||
mfa_enabled?: boolean;
|
||||
timezone?: string;
|
||||
auth_provider?: string;
|
||||
must_change_password?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token?: string;
|
||||
user?: User;
|
||||
mfa_required?: boolean;
|
||||
mfa_setup?: boolean;
|
||||
totp_uri?: string;
|
||||
must_change_password?: boolean;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
login: (username: string, password: string, totp_code?: string) =>
|
||||
client.post<never, LoginResponse>('/auth/login', { username, password, ...(totp_code && { totp_code }) }),
|
||||
|
||||
logout: () => client.post('/auth/logout'),
|
||||
|
||||
oidcLogout: () =>
|
||||
client.post<never, { ok: boolean; idp_logout_url?: string }>('/auth/oidc/logout'),
|
||||
|
||||
me: () => client.get<never, User>('/users/me'),
|
||||
|
||||
changePassword: (current_password: string, new_password: string) =>
|
||||
client.post('/auth/change-password', { current_password, new_password }),
|
||||
};
|
||||
94
frontend/src/api/endpoints/chat.ts
Normal file
94
frontend/src/api/endpoints/chat.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import client from '../client';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
agent_type: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
message_count?: number;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
session_id?: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
model_id?: string;
|
||||
status?: 'processing' | 'done' | 'failed';
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface ChatSendBody {
|
||||
message: string;
|
||||
session_id?: string;
|
||||
genai_config_id?: string;
|
||||
model_id?: string;
|
||||
oci_config_id?: string;
|
||||
genai_region?: string;
|
||||
compartment_id?: string;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
top_p?: number;
|
||||
top_k?: number;
|
||||
frequency_penalty?: number;
|
||||
presence_penalty?: number;
|
||||
reasoning_effort?: string;
|
||||
use_tools?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatSendResponse {
|
||||
message_id: string;
|
||||
session_id: string;
|
||||
status: 'processing' | 'done';
|
||||
}
|
||||
|
||||
export interface MessageStatusResponse {
|
||||
id: string;
|
||||
session_id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
model_id?: string;
|
||||
status: 'processing' | 'done' | 'failed';
|
||||
}
|
||||
|
||||
export interface SessionMessagesResponse {
|
||||
session: ChatSession;
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
/* ── API ── */
|
||||
|
||||
export const chatApi = {
|
||||
/** Send a chat message (text only) */
|
||||
send: (body: ChatSendBody) =>
|
||||
client.post('/chat', body) as unknown as Promise<ChatSendResponse>,
|
||||
|
||||
/** Send a chat message with file attachments */
|
||||
upload: (formData: FormData) =>
|
||||
client.post('/chat/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}) as unknown as Promise<ChatSendResponse>,
|
||||
|
||||
/** Poll message processing status */
|
||||
pollStatus: (messageId: string) =>
|
||||
client.get(`/chat/${messageId}/status`) as unknown as Promise<MessageStatusResponse>,
|
||||
|
||||
/** List chat sessions */
|
||||
listSessions: (agentType = 'chat', limit = 50) =>
|
||||
client.get(`/chat/sessions?agent_type=${agentType}&limit=${limit}`) as unknown as Promise<ChatSession[]>,
|
||||
|
||||
/** Load session with all messages */
|
||||
loadSession: (sessionId: string) =>
|
||||
client.get(`/chat/sessions/${sessionId}/messages`) as unknown as Promise<SessionMessagesResponse>,
|
||||
|
||||
/** Delete a session */
|
||||
deleteSession: (sessionId: string) =>
|
||||
client.delete(`/chat/${sessionId}`) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
/** Rename a session */
|
||||
renameSession: (sessionId: string, title: string) =>
|
||||
client.put(`/chat/sessions/${sessionId}/title`, { title }) as unknown as Promise<{ ok: boolean }>,
|
||||
};
|
||||
98
frontend/src/api/endpoints/embeddings.ts
Normal file
98
frontend/src/api/endpoints/embeddings.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import client from '../client';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export interface EmbeddingDoc {
|
||||
id: string;
|
||||
metadata: string;
|
||||
}
|
||||
|
||||
export interface EmbeddingListResult {
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
documents: EmbeddingDoc[];
|
||||
}
|
||||
|
||||
export interface EmbedUploadResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
chunks: number;
|
||||
filename?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface PurgeResult {
|
||||
ok: boolean;
|
||||
deleted: number;
|
||||
table: string;
|
||||
tenancy: string;
|
||||
}
|
||||
|
||||
export interface ConsultResult {
|
||||
answer: string;
|
||||
documents: {
|
||||
content: string;
|
||||
source: string;
|
||||
distance: number;
|
||||
metadata: string;
|
||||
}[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/* ── API ── */
|
||||
|
||||
export const embeddingsApi = {
|
||||
/** List embeddings for a given ADB config + optional table */
|
||||
list: (vid: string, tableName?: string, limit = 50, offset = 0) => {
|
||||
const params = new URLSearchParams();
|
||||
if (tableName) params.set('table_name', tableName);
|
||||
params.set('limit', String(limit));
|
||||
params.set('offset', String(offset));
|
||||
return client.get(`/embeddings/${vid}/list?${params}`) as unknown as Promise<EmbeddingListResult>;
|
||||
},
|
||||
|
||||
/** Delete a single embedding document */
|
||||
remove: (vid: string, docId: string, tableName?: string) => {
|
||||
const qs = tableName ? `?table_name=${encodeURIComponent(tableName)}` : '';
|
||||
return client.delete(`/embeddings/${vid}/${docId}${qs}`) as unknown as Promise<{ ok: boolean }>;
|
||||
},
|
||||
|
||||
/** Upload a file to generate embeddings */
|
||||
uploadFile: (adbConfigId: string, tableName: string, file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('adb_config_id', adbConfigId);
|
||||
fd.append('table_name', tableName);
|
||||
fd.append('file', file);
|
||||
return client.post('/embeddings/upload', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}) as unknown as Promise<EmbedUploadResult>;
|
||||
},
|
||||
|
||||
/** Import a URL to generate embeddings */
|
||||
uploadUrl: (adbConfigId: string, tableName: string, url: string) => {
|
||||
const fd = new FormData();
|
||||
fd.append('adb_config_id', adbConfigId);
|
||||
fd.append('table_name', tableName);
|
||||
fd.append('url', url);
|
||||
return client.post('/embeddings/upload-url', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}) as unknown as Promise<EmbedUploadResult>;
|
||||
},
|
||||
|
||||
/** Purge embeddings from a table (optionally filtered by tenancy) */
|
||||
purge: (vid: string, tableName: string, tenancy?: string) =>
|
||||
client.post(`/embeddings/${vid}/purge`, {
|
||||
table_name: tableName,
|
||||
tenancy: tenancy || '',
|
||||
}) as unknown as Promise<PurgeResult>,
|
||||
|
||||
/** Consult embeddings with a question */
|
||||
consult: (query: string, tableName?: string, topK = 10, ociConfigId?: string) =>
|
||||
client.post('/embeddings/consult', {
|
||||
query,
|
||||
table_name: tableName || '',
|
||||
top_k: topK,
|
||||
oci_config_id: ociConfigId || '',
|
||||
}) as unknown as Promise<ConsultResult>,
|
||||
};
|
||||
129
frontend/src/api/endpoints/explorer.ts
Normal file
129
frontend/src/api/endpoints/explorer.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import client from '../client';
|
||||
|
||||
/* ── Types ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface CompartmentNode {
|
||||
id: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
lifecycle_state?: string;
|
||||
}
|
||||
|
||||
export interface Region {
|
||||
name: string;
|
||||
key: string;
|
||||
status: string;
|
||||
is_home: boolean;
|
||||
}
|
||||
|
||||
export interface CountEntry {
|
||||
count: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type CountsMap = Record<string, CountEntry>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type ResourceItem = Record<string, any>;
|
||||
|
||||
export interface ActionResult {
|
||||
ok: boolean;
|
||||
action: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MyIpResult {
|
||||
ip: string;
|
||||
}
|
||||
|
||||
/* ── API ───────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const explorerApi = {
|
||||
/* Compartment tree (flat list with parent_id) */
|
||||
getCompartmentTree: (cid: string) =>
|
||||
client.get(`/oci/explore/${cid}/compartment-tree`) as unknown as Promise<CompartmentNode[]>,
|
||||
|
||||
/* Regions subscribed */
|
||||
getRegions: (cid: string) =>
|
||||
client.get(`/oci/explore/${cid}/regions`) as unknown as Promise<Region[]>,
|
||||
|
||||
/* Cached counts */
|
||||
getCounts: (cid: string, compartmentId: string, regions: string = '') =>
|
||||
client.get(`/oci/explore/${cid}/counts`, {
|
||||
params: { compartment_id: compartmentId, regions },
|
||||
}) as unknown as Promise<CountsMap>,
|
||||
|
||||
/* Trigger background counts refresh */
|
||||
refreshCounts: (cid: string, compartmentId: string, regions: string[]) =>
|
||||
client.post(`/oci/explore/${cid}/counts/refresh`, {
|
||||
compartment_id: compartmentId,
|
||||
regions,
|
||||
}) as unknown as Promise<{ status: string; types: number }>,
|
||||
|
||||
/* Fetch resources of a given type */
|
||||
getResources: (cid: string, resourceType: string, compartmentId: string, region?: string) =>
|
||||
client.get(`/oci/explore/${cid}/${resourceType}`, {
|
||||
params: { compartment_id: compartmentId, region: region || undefined },
|
||||
}) as unknown as Promise<ResourceItem[]>,
|
||||
|
||||
/* ── Actions ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
instanceAction: (id: string, action: 'START' | 'STOP', ociConfigId: string, region?: string) =>
|
||||
client.post(`/oci/instances/${id}/action`, {
|
||||
action, oci_config_id: ociConfigId, region,
|
||||
}) as unknown as Promise<ActionResult>,
|
||||
|
||||
adbAction: (id: string, action: 'start' | 'stop', ociConfigId: string, region?: string) =>
|
||||
client.post(`/oci/autonomous-databases/${id}/action`, {
|
||||
action, oci_config_id: ociConfigId, region,
|
||||
}) as unknown as Promise<ActionResult>,
|
||||
|
||||
dbSystemAction: (id: string, action: 'START' | 'STOP', ociConfigId: string, region?: string) =>
|
||||
client.post(`/oci/db-systems/${id}/action`, {
|
||||
action, oci_config_id: ociConfigId, region,
|
||||
}) as unknown as Promise<ActionResult>,
|
||||
|
||||
mysqlAction: (id: string, action: 'start' | 'stop', ociConfigId: string, region?: string) =>
|
||||
client.post(`/oci/mysql-db-systems/${id}/action`, {
|
||||
action, oci_config_id: ociConfigId, region,
|
||||
}) as unknown as Promise<ActionResult>,
|
||||
|
||||
containerAction: (id: string, action: 'start' | 'stop', ociConfigId: string, region?: string) =>
|
||||
client.post(`/oci/container-instances/${id}/action`, {
|
||||
action, oci_config_id: ociConfigId, region,
|
||||
}) as unknown as Promise<ActionResult>,
|
||||
|
||||
getMyIp: () =>
|
||||
client.get('/oci/my-ip') as unknown as Promise<MyIpResult>,
|
||||
|
||||
adbUpdateNetwork: (id: string, ip: string, ociConfigId: string, region?: string) =>
|
||||
client.post(`/oci/autonomous-databases/${id}/update-network-access`, {
|
||||
ip, oci_config_id: ociConfigId, region,
|
||||
}) as unknown as Promise<ActionResult>,
|
||||
|
||||
/* ── Security List Rules ─────────────────────────────────────────────────── */
|
||||
|
||||
addSlRule: (cid: string, slId: string, body: {
|
||||
direction: string; protocol: string; source_dest: string;
|
||||
port_min?: number; port_max?: number; description?: string;
|
||||
is_stateless?: boolean; region?: string;
|
||||
}) =>
|
||||
client.post(`/oci/explore/${cid}/security_lists/${slId}/rules`, body) as unknown as Promise<ActionResult>,
|
||||
|
||||
removeSlRule: (cid: string, slId: string, body: {
|
||||
direction: string; rule_index: number; region?: string;
|
||||
}) =>
|
||||
client.delete(`/oci/explore/${cid}/security_lists/${slId}/rules`, { data: body }) as unknown as Promise<ActionResult>,
|
||||
|
||||
/* ── NSG Rules ───────────────────────────────────────────────────────────── */
|
||||
|
||||
addNsgRule: (cid: string, nsgId: string, body: {
|
||||
direction: string; protocol: string; source_dest: string;
|
||||
source_dest_type?: string; port_min?: number; port_max?: number;
|
||||
description?: string; is_stateless?: boolean; region?: string;
|
||||
}) =>
|
||||
client.post(`/oci/explore/${cid}/nsgs/${nsgId}/rules`, body) as unknown as Promise<ActionResult>,
|
||||
|
||||
removeNsgRule: (cid: string, nsgId: string, ruleId: string, region?: string) =>
|
||||
client.delete(`/oci/explore/${cid}/nsgs/${nsgId}/rules/${ruleId}${region ? `?region=${region}` : ''}`) as unknown as Promise<ActionResult>,
|
||||
};
|
||||
66
frontend/src/api/endpoints/genai.ts
Normal file
66
frontend/src/api/endpoints/genai.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import client from '../client';
|
||||
|
||||
export interface GenAiConfigFull {
|
||||
id: string;
|
||||
name: string;
|
||||
oci_config_id: string;
|
||||
model_id: string;
|
||||
model_ocid?: string;
|
||||
compartment_id: string;
|
||||
genai_region: string;
|
||||
endpoint?: string;
|
||||
serving_type: string;
|
||||
dedicated_endpoint_id?: string;
|
||||
temperature: number;
|
||||
max_tokens: number;
|
||||
top_p: number;
|
||||
top_k: number;
|
||||
frequency_penalty: number;
|
||||
presence_penalty: number;
|
||||
reasoning_effort?: string;
|
||||
is_default: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface GenAiConfigPayload {
|
||||
name: string;
|
||||
oci_config_id: string;
|
||||
model_id: string;
|
||||
model_ocid?: string | null;
|
||||
compartment_id: string;
|
||||
genai_region: string;
|
||||
endpoint?: string | null;
|
||||
serving_type?: string;
|
||||
dedicated_endpoint_id?: string | null;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
top_p?: number;
|
||||
top_k?: number;
|
||||
frequency_penalty?: number;
|
||||
presence_penalty?: number;
|
||||
reasoning_effort?: string | null;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface GenAiTestResult {
|
||||
status: 'success' | 'error';
|
||||
message: string;
|
||||
response?: string;
|
||||
}
|
||||
|
||||
export const genaiApi = {
|
||||
list: () =>
|
||||
client.get('/genai/configs') as unknown as Promise<GenAiConfigFull[]>,
|
||||
|
||||
create: (body: GenAiConfigPayload) =>
|
||||
client.post('/genai/config', body) as unknown as Promise<{ id: string; model_id: string; endpoint: string }>,
|
||||
|
||||
update: (id: string, body: GenAiConfigPayload) =>
|
||||
client.put(`/genai/configs/${id}`, body) as unknown as Promise<{ id: string; model_id: string; endpoint: string }>,
|
||||
|
||||
remove: (id: string) =>
|
||||
client.delete(`/genai/configs/${id}`) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
test: (id: string) =>
|
||||
client.post(`/genai/test/${id}`) as unknown as Promise<GenAiTestResult>,
|
||||
};
|
||||
68
frontend/src/api/endpoints/mcp.ts
Normal file
68
frontend/src/api/endpoints/mcp.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import client from '../client';
|
||||
|
||||
export interface McpServerFull {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
server_type: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env_vars?: Record<string, string>;
|
||||
url?: string;
|
||||
module_path?: string;
|
||||
is_active: boolean;
|
||||
tools?: McpToolDef[];
|
||||
linked_adb_id?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface McpToolDef {
|
||||
name: string;
|
||||
description?: string;
|
||||
input_schema?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface McpServerPayload {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
server_type: string;
|
||||
command?: string | null;
|
||||
args?: string[] | null;
|
||||
env_vars?: Record<string, string> | null;
|
||||
url?: string | null;
|
||||
module_path?: string | null;
|
||||
tools?: McpToolDef[] | null;
|
||||
linked_adb_id?: string | null;
|
||||
}
|
||||
|
||||
export const mcpApi = {
|
||||
list: () =>
|
||||
client.get('/mcp/servers') as unknown as Promise<McpServerFull[]>,
|
||||
|
||||
create: (body: McpServerPayload) =>
|
||||
client.post('/mcp/servers', body) as unknown as Promise<{ id: string; name: string; server_type: string }>,
|
||||
|
||||
update: (id: string, body: McpServerPayload) =>
|
||||
client.put(`/mcp/servers/${id}`, body) as unknown as Promise<{ id: string; name: string; server_type: string }>,
|
||||
|
||||
remove: (id: string) =>
|
||||
client.delete(`/mcp/servers/${id}`) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
toggle: (id: string) =>
|
||||
client.put(`/mcp/servers/${id}/toggle`) as unknown as Promise<{ ok: boolean; is_active: boolean }>,
|
||||
|
||||
discoverTools: (id: string) =>
|
||||
client.post(`/mcp/servers/${id}/discover-tools`) as unknown as Promise<{ ok: boolean; discovered: number; total: number; tools: McpToolDef[] }>,
|
||||
|
||||
updateTools: (id: string, tools: McpToolDef[]) =>
|
||||
client.put(`/mcp/servers/${id}/tools`, { tools }) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
upload: (id: string, file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return client.post(`/mcp/servers/${id}/upload`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }) as unknown as Promise<{ ok: boolean; path: string }>;
|
||||
},
|
||||
|
||||
linkAdb: (id: string, adbId: string) =>
|
||||
client.put(`/mcp/servers/${id}/link-adb?adb_id=${adbId}`) as unknown as Promise<{ ok: boolean }>,
|
||||
};
|
||||
39
frontend/src/api/endpoints/oci.ts
Normal file
39
frontend/src/api/endpoints/oci.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import client from '../client';
|
||||
|
||||
export interface OciConfigFull {
|
||||
id: string;
|
||||
user_id: string;
|
||||
tenancy_name: string;
|
||||
tenancy_ocid: string;
|
||||
user_ocid: string;
|
||||
fingerprint: string;
|
||||
region: string;
|
||||
compartment_id?: string;
|
||||
ssh_public_key?: string;
|
||||
ssh_public_key_preview?: string;
|
||||
auth_type: 'api_key' | 'session_token';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface OciTestResult {
|
||||
status: 'success' | 'error';
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export const ociApi = {
|
||||
list: () =>
|
||||
client.get('/oci/configs') as unknown as Promise<OciConfigFull[]>,
|
||||
|
||||
create: (fd: FormData) =>
|
||||
client.post('/oci/config', fd, { headers: { 'Content-Type': 'multipart/form-data' } }) as unknown as Promise<{ id: string; tenancy_name: string; region: string }>,
|
||||
|
||||
update: (id: string, fd: FormData) =>
|
||||
client.put(`/oci/configs/${id}`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }) as unknown as Promise<{ id: string; tenancy_name: string; region: string }>,
|
||||
|
||||
remove: (id: string) =>
|
||||
client.delete(`/oci/configs/${id}`) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
test: (id: string) =>
|
||||
client.post(`/oci/test/${id}`) as unknown as Promise<OciTestResult>,
|
||||
};
|
||||
202
frontend/src/api/endpoints/reports.ts
Normal file
202
frontend/src/api/endpoints/reports.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import client from '../client';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export interface Report {
|
||||
id: string;
|
||||
tenancy_name: string;
|
||||
config_id?: string;
|
||||
status: string;
|
||||
level: number | null;
|
||||
regions?: string[];
|
||||
obp?: boolean;
|
||||
raw?: boolean;
|
||||
redact_output?: boolean;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
error_msg?: string | null;
|
||||
}
|
||||
|
||||
export interface RunReportBody {
|
||||
config_id: string;
|
||||
regions?: string[];
|
||||
level: 1 | 2;
|
||||
obp: boolean;
|
||||
raw: boolean;
|
||||
redact_output: boolean;
|
||||
}
|
||||
|
||||
export interface RunReportResult {
|
||||
report_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ReportProgress {
|
||||
status: string;
|
||||
progress: string[];
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
error_msg: string | null;
|
||||
}
|
||||
|
||||
export interface ReportSummary {
|
||||
tenancy: string;
|
||||
level: number;
|
||||
regions: string[];
|
||||
generated_at: string;
|
||||
total: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
total_findings: number;
|
||||
total_compliant: number;
|
||||
score: number;
|
||||
sections: Record<string, { passed: number; failed: number; total: number }>;
|
||||
}
|
||||
|
||||
export interface ReportFile {
|
||||
id: string;
|
||||
file_name: string;
|
||||
file_type: string;
|
||||
file_category: string;
|
||||
file_size: number;
|
||||
}
|
||||
|
||||
export interface CisEngineVersion {
|
||||
version: string;
|
||||
updated_date: string;
|
||||
file_path: string;
|
||||
}
|
||||
|
||||
export interface CisEngineUpdate {
|
||||
local_version: string;
|
||||
local_date: string;
|
||||
remote_version: string;
|
||||
remote_date: string;
|
||||
update_available: boolean;
|
||||
}
|
||||
|
||||
export interface OciServiceStatus {
|
||||
service: string;
|
||||
auto_status?: string;
|
||||
auto_description?: string;
|
||||
status: string;
|
||||
description: string;
|
||||
overridden: boolean;
|
||||
}
|
||||
|
||||
export interface OciHealthService {
|
||||
serviceId: string;
|
||||
serviceName: string;
|
||||
serviceCanonicalName: string;
|
||||
serviceCategoryName: string;
|
||||
serviceStatus: string;
|
||||
incidents: { incidentId: string; title: string; status: string }[];
|
||||
}
|
||||
|
||||
export interface OciHealthRegion {
|
||||
regionId: string;
|
||||
regionName: string;
|
||||
regionCanonicalName: string;
|
||||
geographicAreaName: string;
|
||||
serviceHealthReports: OciHealthService[];
|
||||
}
|
||||
|
||||
export interface OciHealthResponse {
|
||||
realm?: string;
|
||||
regionHealthReports: OciHealthRegion[];
|
||||
}
|
||||
|
||||
/* ── API ── */
|
||||
|
||||
export const reportsApi = {
|
||||
run: (body: RunReportBody) =>
|
||||
client.post('/reports/run', body) as unknown as Promise<RunReportResult>,
|
||||
|
||||
list: () =>
|
||||
client.get('/reports') as unknown as Promise<Report[]>,
|
||||
|
||||
progress: (rid: string) =>
|
||||
client.get(`/reports/${rid}/progress`) as unknown as Promise<ReportProgress>,
|
||||
|
||||
cancel: (rid: string) =>
|
||||
client.post(`/reports/${rid}/cancel`) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
delete: (rid: string) =>
|
||||
client.delete(`/reports/${rid}`) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
summary: (rid: string) =>
|
||||
client.get(`/reports/${rid}/summary`) as unknown as Promise<ReportSummary>,
|
||||
|
||||
htmlUrl: (rid: string) => {
|
||||
const token = localStorage.getItem('t') || '';
|
||||
return `/api/reports/${rid}/html?token=${encodeURIComponent(token)}&_t=${Date.now()}`;
|
||||
},
|
||||
|
||||
complianceReportUrl: (rid: string) => {
|
||||
const token = localStorage.getItem('t') || '';
|
||||
return `/api/reports/${rid}/compliance-report?token=${encodeURIComponent(token)}&_t=${Date.now()}`;
|
||||
},
|
||||
|
||||
generateComplianceReport: (rid: string) =>
|
||||
client.post(`/reports/${rid}/compliance-report/generate`) as unknown as Promise<{ status: string; has_rag: boolean }>,
|
||||
|
||||
complianceReportStatus: (rid: string) =>
|
||||
client.get(`/reports/${rid}/compliance-report/status`) as unknown as Promise<{ ready: boolean; generating: boolean }>,
|
||||
|
||||
complianceReportZipUrl: (rid: string) => {
|
||||
const token = localStorage.getItem('t') || '';
|
||||
return `/api/reports/${rid}/compliance-report/download?token=${encodeURIComponent(token)}`;
|
||||
},
|
||||
|
||||
complianceReportDocxUrl: (rid: string) => {
|
||||
const token = localStorage.getItem('t') || '';
|
||||
return `/api/reports/${rid}/compliance-report/docx?token=${encodeURIComponent(token)}`;
|
||||
},
|
||||
|
||||
files: (rid: string) =>
|
||||
client.get(`/reports/${rid}/files`) as unknown as Promise<ReportFile[]>,
|
||||
|
||||
downloadUrl: (rid: string, fid: string) => {
|
||||
const token = localStorage.getItem('t') || '';
|
||||
return `/api/reports/${rid}/files/${fid}/download?token=${encodeURIComponent(token)}`;
|
||||
},
|
||||
|
||||
engineVersion: () =>
|
||||
client.get('/cis-engine/version') as unknown as Promise<CisEngineVersion>,
|
||||
|
||||
engineCheckUpdate: () =>
|
||||
client.get('/cis-engine/check-update') as unknown as Promise<CisEngineUpdate>,
|
||||
|
||||
engineUpdate: () =>
|
||||
client.post('/cis-engine/update') as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
embedReport: (rid: string, adbConfigId: string, tableName?: string) =>
|
||||
client.post(`/embeddings/report/${rid}`, {
|
||||
adb_config_id: adbConfigId,
|
||||
...(tableName ? { table_name: tableName } : {}),
|
||||
}) as unknown as Promise<{ ok: boolean; task_id: string; message: string; sections: number; tenancy: string }>,
|
||||
|
||||
embedFile: (rid: string, fid: string, adbConfigId: string, tableName?: string) =>
|
||||
client.post(`/embeddings/report/${rid}/file/${fid}`, {
|
||||
adb_config_id: adbConfigId,
|
||||
...(tableName ? { table_name: tableName } : {}),
|
||||
}) as unknown as Promise<{ ok: boolean; task_id: string; message: string; chunks: number }>,
|
||||
|
||||
embedSection: (rid: string, fileNames: string[], adbConfigId?: string) =>
|
||||
client.post(`/embeddings/report/${rid}/section`, {
|
||||
file_names: fileNames,
|
||||
...(adbConfigId ? { adb_config_id: adbConfigId } : {}),
|
||||
}) as unknown as Promise<{ ok: boolean; task_id: string; tables: string[]; total: number; message: string }>,
|
||||
|
||||
embeddingStatus: (taskId: string) =>
|
||||
client.get(`/embeddings/status/${taskId}`) as unknown as Promise<{ status: string; message: string; inserted?: number; total?: number }>,
|
||||
|
||||
getOciServices: (configId: string, detect = true) =>
|
||||
client.get(`/oci-services/${configId}?detect=${detect ? 1 : 0}`) as unknown as Promise<OciServiceStatus[]>,
|
||||
|
||||
setOciServices: (configId: string, services: OciServiceStatus[]) =>
|
||||
client.put(`/oci-services/${configId}`, services) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
getOciHealth: () =>
|
||||
client.get('/oci-health') as unknown as Promise<OciHealthResponse>,
|
||||
};
|
||||
136
frontend/src/api/endpoints/terraform.ts
Normal file
136
frontend/src/api/endpoints/terraform.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import client from '../client';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export type WsStatus =
|
||||
| 'draft' | 'planning' | 'planned'
|
||||
| 'applying' | 'applied'
|
||||
| 'destroying' | 'destroyed'
|
||||
| 'failed';
|
||||
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
session_id: string;
|
||||
session_title?: string;
|
||||
oci_config_id: string;
|
||||
compartment_id?: string;
|
||||
status: WsStatus;
|
||||
plan_output?: string;
|
||||
apply_output?: string;
|
||||
destroy_output?: string;
|
||||
error?: string;
|
||||
tf_code?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WsStatusResult {
|
||||
status: WsStatus;
|
||||
plan_output?: string;
|
||||
apply_output?: string;
|
||||
destroy_output?: string;
|
||||
error?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
agent_type: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface PromptResponse {
|
||||
message_id: string;
|
||||
session_id: string;
|
||||
status: 'processing' | 'done';
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
export interface MessageStatus {
|
||||
status: 'processing' | 'done' | 'failed';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/* ── Workspace API ── */
|
||||
|
||||
export const terraformApi = {
|
||||
/* Workspaces */
|
||||
listWorkspaces: () =>
|
||||
client.get('/terraform/workspaces') as unknown as Promise<Workspace[]>,
|
||||
|
||||
getWorkspace: (wid: string) =>
|
||||
client.get(`/terraform/workspaces/${wid}`) as unknown as Promise<Workspace>,
|
||||
|
||||
deleteWorkspace: (wid: string) =>
|
||||
client.delete(`/terraform/workspaces/${wid}`) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
plan: (wid: string) =>
|
||||
client.post(`/terraform/workspaces/${wid}/plan`) as unknown as Promise<{ status: string }>,
|
||||
|
||||
apply: (wid: string) =>
|
||||
client.post(`/terraform/workspaces/${wid}/apply`) as unknown as Promise<{ status: string }>,
|
||||
|
||||
destroy: (wid: string) =>
|
||||
client.post(`/terraform/workspaces/${wid}/destroy`, { confirm: 'DESTROY' }) as unknown as Promise<{ status: string }>,
|
||||
|
||||
cancel: (wid: string) =>
|
||||
client.post(`/terraform/workspaces/${wid}/cancel`) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
status: (wid: string) =>
|
||||
client.get(`/terraform/workspaces/${wid}/status`) as unknown as Promise<WsStatusResult>,
|
||||
|
||||
downloadUrl: (wid: string) => {
|
||||
const token = localStorage.getItem('t') || '';
|
||||
return `/api/terraform/workspaces/${wid}/download?token=${encodeURIComponent(token)}`;
|
||||
},
|
||||
|
||||
/* Prompt Generator */
|
||||
generatePrompt: (body: {
|
||||
message: string;
|
||||
session_id?: string;
|
||||
genai_config: Record<string, string | undefined>;
|
||||
history?: { role: string; content: string }[] | null;
|
||||
}) =>
|
||||
client.post('/terraform/generate-prompt', body) as unknown as Promise<PromptResponse>,
|
||||
|
||||
/* Chat sessions / history */
|
||||
listSessions: (agentType: string, limit = 50) =>
|
||||
client.get(`/chat/sessions?agent_type=${agentType}&limit=${limit}`) as unknown as Promise<ChatSession[]>,
|
||||
|
||||
loadSession: (sid: string) =>
|
||||
client.get(`/chat/sessions/${sid}/messages`) as unknown as Promise<{ messages: ChatMessage[] }>,
|
||||
|
||||
deleteSession: (sid: string) =>
|
||||
client.delete(`/chat/${sid}`) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
pollMessageStatus: (mid: string) =>
|
||||
client.get(`/chat/${mid}/status`) as unknown as Promise<MessageStatus>,
|
||||
|
||||
createWorkspace: (body: { session_id?: string; oci_config_id: string; compartment_id?: string; name: string; tf_code: string }) =>
|
||||
client.post('/terraform/workspaces', body) as unknown as Promise<Workspace>,
|
||||
|
||||
updateCode: (wid: string, tf_code: string) =>
|
||||
client.put(`/terraform/workspaces/${wid}/code`, { tf_code }) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
chat: (body: { message: string; session_id?: string; genai_config_id?: string; model_id?: string; oci_config_id?: string; genai_region?: string; compartment_id?: string; temperature?: number; max_tokens?: number; use_tools?: boolean }) =>
|
||||
client.post('/terraform/chat', body) as unknown as Promise<{ message_id: string; session_id: string; status: string }>,
|
||||
|
||||
loadResources: (ociConfigId: string, compartmentId: string, region?: string) => {
|
||||
const params = new URLSearchParams({ oci_config_id: ociConfigId, compartment_id: compartmentId });
|
||||
if (region) params.set('region', region);
|
||||
return client.get(`/terraform/resources?${params}`) as unknown as Promise<Record<string, any[]>>;
|
||||
},
|
||||
|
||||
getCompartmentTree: (cid: string) =>
|
||||
client.get(`/oci/explore/${cid}/compartment-tree`) as unknown as Promise<any[]>,
|
||||
};
|
||||
27
frontend/src/components/layout/AppShell.tsx
Normal file
27
frontend/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Outlet, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
export default function AppShell() {
|
||||
const { user, loading } = useAuthStore();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen" style={{ background: 'var(--bg)' }}>
|
||||
<div className="animate-spin w-8 h-8 rounded-full border-2 border-t-transparent"
|
||||
style={{ borderColor: 'var(--ac)', borderTopColor: 'transparent' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-[260px] overflow-y-auto h-screen" style={{ background: 'var(--bg)' }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
237
frontend/src/components/layout/Sidebar.tsx
Normal file
237
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
MessageSquare, Search, BarChart3, Cloud, Shield, Terminal,
|
||||
Brain, Plug, Database, Dna, Users, FileText,
|
||||
Sparkles, Sun, Moon, LogOut, FolderOpen, Settings, ArrowLeft,
|
||||
UserCog, ChevronDown, ChevronRight,
|
||||
} 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 }} 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" />
|
||||
<circle cx="21" cy="17" r="2" fill="#C74634" />
|
||||
<circle cx="15" cy="16.7" r="0.7" fill="#fff" />
|
||||
<circle cx="21" cy="16.7" r="0.7" fill="#fff" />
|
||||
<rect x="14" y="21" width="8" height="1.5" rx="0.75" fill="#C74634" opacity="0.6" />
|
||||
<line x1="18" y1="11" x2="18" y2="6" stroke="#fff" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<circle cx="18" cy="5.5" r="1.5" fill="#fff" opacity="0.9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SZ = 18;
|
||||
|
||||
const TerraformIcon = ({ size = 18 }: { size?: number }) => (
|
||||
<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" />
|
||||
<polygon points="22.4,35.5 41.6,46.6 41.6,68.7 22.4,57.6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
icon: React.ReactNode;
|
||||
labelKey: string;
|
||||
adminOnly?: boolean;
|
||||
sub?: boolean;
|
||||
}
|
||||
|
||||
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: '/oci-services', icon: <Shield size={SZ} />, labelKey: 'nav.ociServices' },
|
||||
{ to: '/reports', icon: <BarChart3 size={SZ} />, labelKey: 'nav.reports' },
|
||||
{ to: '/config/embeddings/consult', icon: <MessageSquare size={SZ} />, labelKey: 'nav.embConsult' },
|
||||
{ to: '/terminal', icon: <Terminal size={SZ} />, labelKey: 'nav.terminal' },
|
||||
];
|
||||
|
||||
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' },
|
||||
// User Management group — rendered separately with expand/collapse
|
||||
{ to: '/admin/audit', icon: <FileText size={SZ} />, labelKey: 'nav.audit', adminOnly: true },
|
||||
];
|
||||
|
||||
const USER_MGMT_ITEMS: NavItem[] = [
|
||||
{ to: '/admin/users', icon: <Users size={SZ} />, labelKey: 'nav.users', adminOnly: true },
|
||||
{ to: '/admin/user-settings', icon: <UserCog size={SZ} />, labelKey: 'nav.mySettings' },
|
||||
{ to: '/admin/iam', icon: <Shield size={SZ} />, labelKey: 'nav.oracleIam', adminOnly: true },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const { toggle, isDark } = useTheme();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { t } = useI18n();
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [userMgmtOpen, setUserMgmtOpen] = 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"
|
||||
style={{ background: 'var(--bg1)', borderRight: '1px solid var(--bd)' }}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-5 py-5 relative overflow-hidden"
|
||||
style={{ background: 'linear-gradient(145deg, #c74634 0%, #a83428 50%, #8b2a20 100%)' }}>
|
||||
<div className="absolute top-[-30%] right-[-20%] w-[120px] h-[120px] rounded-full"
|
||||
style={{ background: 'radial-gradient(circle, rgba(255,255,255,.08) 0%, transparent 70%)' }} />
|
||||
<div className="flex items-center gap-3 relative z-10">
|
||||
{LOGO}
|
||||
<div>
|
||||
<h1 className="text-sm font-bold text-white tracking-tight">AI Agent</h1>
|
||||
<p className="text-[.65rem] text-white/60 mt-0.5" style={{ fontFamily: 'var(--fm)' }}>
|
||||
{t('sidebar.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto px-2.5 py-3">
|
||||
{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('sidebar.settings')}
|
||||
</div>
|
||||
{ADMIN_ITEMS
|
||||
.filter((item) => !item.adminOnly || user?.role === 'admin')
|
||||
.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={linkCls()}
|
||||
style={({ isActive }) => linkStyle(isActive)}
|
||||
>
|
||||
{item.icon}
|
||||
{t(item.labelKey)}
|
||||
</NavLink>
|
||||
))}
|
||||
{/* User Management group */}
|
||||
<button
|
||||
onClick={() => setUserMgmtOpen(!userMgmtOpen)}
|
||||
className={linkCls()}
|
||||
style={{ color: 'var(--t2)', background: 'transparent', border: 'none', width: '100%', cursor: 'pointer' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg2)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<Users size={SZ} />
|
||||
<span className="flex-1 text-left">{t('nav.userMgmt')}</span>
|
||||
{userMgmtOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
{userMgmtOpen && USER_MGMT_ITEMS
|
||||
.filter((item) => !item.adminOnly || user?.role === 'admin')
|
||||
.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={linkCls(true)}
|
||||
style={({ isActive }) => linkStyle(isActive)}
|
||||
>
|
||||
{item.icon}
|
||||
{t(item.labelKey)}
|
||||
</NavLink>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
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 */}
|
||||
<div className="px-3 py-3.5 flex items-center gap-2" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<button
|
||||
onClick={toggle}
|
||||
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)'; }}
|
||||
>
|
||||
{isDark ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||
<div className="w-7 h-7 rounded-lg flex items-center justify-center text-[.7rem] font-bold text-white flex-shrink-0"
|
||||
style={{ background: 'var(--ac)' }}>
|
||||
{user?.username?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[.75rem] font-medium truncate" style={{ color: 'var(--t1)' }}>{user?.username}</p>
|
||||
<p className="text-[.6rem] truncate" style={{ color: 'var(--t4)' }}>
|
||||
{user?.role === 'admin' ? t('sidebar.roleAdmin') : t('sidebar.roleUser')}
|
||||
</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)'; }}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
25
frontend/src/hooks/usePolling.ts
Normal file
25
frontend/src/hooks/usePolling.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export function usePolling(
|
||||
callback: () => Promise<boolean | void>,
|
||||
intervalMs: number,
|
||||
enabled: boolean
|
||||
) {
|
||||
const savedCallback = useRef(callback);
|
||||
savedCallback.current = callback;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
let active = true;
|
||||
const poll = async () => {
|
||||
if (!active) return;
|
||||
const shouldStop = await savedCallback.current();
|
||||
if (shouldStop || !active) return;
|
||||
setTimeout(poll, intervalMs);
|
||||
};
|
||||
poll();
|
||||
|
||||
return () => { active = false; };
|
||||
}, [intervalMs, enabled]);
|
||||
}
|
||||
20
frontend/src/hooks/useTheme.ts
Normal file
20
frontend/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
return (localStorage.getItem('theme') as Theme) || 'light';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.className = theme;
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
|
||||
}, []);
|
||||
|
||||
return { theme, toggle, isDark: theme === 'dark' };
|
||||
}
|
||||
839
frontend/src/i18n/en.ts
Normal file
839
frontend/src/i18n/en.ts
Normal file
@@ -0,0 +1,839 @@
|
||||
const en: Record<string, string> = {
|
||||
// ── Sidebar ──
|
||||
'nav.principal': 'Main',
|
||||
'nav.admin': 'Administration',
|
||||
'nav.chat': 'Chat Agent',
|
||||
'nav.terraform': 'Terraform',
|
||||
'nav.promptGen': 'Prompt Generator',
|
||||
'nav.workspaces': 'Workspaces',
|
||||
'nav.explorer': 'OCI Explorer',
|
||||
'nav.ociServices': 'OCI Services',
|
||||
'svc.title': 'OCI Services Status',
|
||||
'svc.subtitle': 'Check and configure OCI security services status per tenancy',
|
||||
'svc.redetect': 'Re-detect',
|
||||
'svc.checking': 'Checking OCI services...',
|
||||
'svc.save': 'Save',
|
||||
'nav.reports': 'Reports',
|
||||
'nav.ociCreds': 'OCI Credentials',
|
||||
'nav.genai': 'GenAI Config',
|
||||
'nav.mcp': 'MCP Servers',
|
||||
'nav.adb': 'ADB Vector',
|
||||
'nav.embeddings': 'Embeddings',
|
||||
'nav.embConsult': 'Query Embeddings',
|
||||
'nav.terminal': 'Terminal',
|
||||
'nav.userMgmt': 'User Management',
|
||||
'nav.users': 'Users',
|
||||
'nav.mySettings': 'My Settings',
|
||||
'nav.oracleIam': 'Oracle IAM',
|
||||
'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',
|
||||
|
||||
// ── Login ──
|
||||
'login.subtitle': 'Infrastructure & Security Engineer',
|
||||
'login.userPlaceholder': 'Username',
|
||||
'login.passPlaceholder': 'Password',
|
||||
'login.mfaScanQr': 'Scan the QR code with your authenticator app:',
|
||||
'login.totpPlaceholder': 'TOTP Code',
|
||||
'login.loading': 'Signing in...',
|
||||
'login.verify': 'Verify',
|
||||
'login.submit': 'Sign in',
|
||||
'login.error': 'Login failed',
|
||||
'login.or': 'or',
|
||||
'login.oidcButton': 'Sign in with Oracle IAM',
|
||||
|
||||
// ── Password Change ──
|
||||
'pw.welcomeTitle': 'Welcome!',
|
||||
'pw.welcomeUser': 'Logged in as',
|
||||
'pw.welcomeMsg': 'For security, you must change your password before continuing.',
|
||||
'pw.continue': 'Continue',
|
||||
'pw.changeTitle': 'Change Password',
|
||||
'pw.currentPassword': 'Current password',
|
||||
'pw.newPassword': 'New password',
|
||||
'pw.confirmPassword': 'Confirm new password',
|
||||
'pw.strength': 'Password strength',
|
||||
'pw.weak': 'Weak',
|
||||
'pw.medium': 'Medium',
|
||||
'pw.strong': 'Strong',
|
||||
'pw.minLength': 'Minimum 8 characters',
|
||||
'pw.hasUpper': 'One uppercase letter',
|
||||
'pw.hasLower': 'One lowercase letter',
|
||||
'pw.hasNumber': 'One number',
|
||||
'pw.hasSpecial': 'One special character (!@#$...)',
|
||||
'pw.match': 'Passwords match',
|
||||
'pw.sameAsOld': 'New password must be different from the current one',
|
||||
'pw.error': 'Error changing password',
|
||||
'pw.saving': 'Saving...',
|
||||
'pw.changeButton': 'Change Password',
|
||||
'pw.successTitle': 'Password changed!',
|
||||
'pw.successMsg': 'Redirecting...',
|
||||
|
||||
// ── Chat ──
|
||||
'chat.timeNow': 'now',
|
||||
'chat.today': 'Today',
|
||||
'chat.yesterday': 'Yesterday',
|
||||
'chat.last7': 'Last 7 days',
|
||||
'chat.last30': 'Last 30 days',
|
||||
'chat.older': 'Older',
|
||||
'chat.other': 'Other',
|
||||
'chat.history': 'History',
|
||||
'chat.newChat': 'New Chat',
|
||||
'chat.untitled': 'Untitled',
|
||||
'chat.noConversations': 'No conversations yet',
|
||||
'chat.selectModel': 'Select a model...',
|
||||
'chat.savedConfigs': 'Saved Configs',
|
||||
'chat.searchModel': 'Search model...',
|
||||
'chat.noModels': 'No models found',
|
||||
'chat.ociCred': 'OCI Credential...',
|
||||
'chat.settings': 'Settings',
|
||||
'chat.newChatTitle': 'New chat',
|
||||
'chat.emptyTitle': 'Start a conversation with the agent.',
|
||||
'chat.emptySubtitle': 'Select a model to get started.',
|
||||
'chat.thinking': 'Thinking...',
|
||||
'chat.retry': 'Retry',
|
||||
'chat.attachFile': 'Attach file',
|
||||
'chat.placeholder': 'Type your message... (Shift+Enter for new line)',
|
||||
'chat.send': 'Send',
|
||||
'chat.selectOciCred': 'Select an OCI credential to use direct models.',
|
||||
'chat.sendError': 'Failed to send message',
|
||||
'chat.requestFailed': 'The request failed.',
|
||||
'chat.timeout': 'Timeout: the response is taking too long. Try again.',
|
||||
'chat.toolsAvailable': 'tools available',
|
||||
'chat.ociCredLabel': 'OCI Credential',
|
||||
'chat.select': 'Select...',
|
||||
'chat.analyzeFiles': 'Analyze the attached files.',
|
||||
|
||||
// ── Terraform ──
|
||||
'tf.history': 'History',
|
||||
'tf.newChat': 'New',
|
||||
'tf.noConversations': 'No conversations',
|
||||
'tf.searchModel': 'Search model...',
|
||||
'tf.selectModel': 'Select model...',
|
||||
'tf.savedConfigs': 'Saved Configs',
|
||||
'tf.ociConfig': 'OCI Config...',
|
||||
'tf.settings': 'Settings',
|
||||
'tf.newConversation': 'New conversation',
|
||||
'tf.emptyTitle': 'Describe the OCI infrastructure',
|
||||
'tf.emptySubtitle': 'The agent will generate Terraform code based on your description.',
|
||||
'tf.thinking': 'Processing...',
|
||||
'tf.retry': 'Retry',
|
||||
'tf.placeholder': 'Describe the desired infrastructure... (Shift+Enter for new line)',
|
||||
'tf.send': 'Send',
|
||||
'tf.workspace': 'Workspace',
|
||||
'tf.files': 'Files',
|
||||
'tf.terminal': 'Terminal',
|
||||
'tf.resources': 'Resources',
|
||||
'tf.plan': 'Plan',
|
||||
'tf.apply': 'Apply',
|
||||
'tf.destroy': 'Destroy',
|
||||
'tf.cancel': 'Cancel',
|
||||
'tf.download': 'Download ZIP',
|
||||
'tf.createWs': 'Create Workspace',
|
||||
'tf.noFiles': 'No files generated',
|
||||
'tf.timeout': 'Timeout: generation is taking too long.',
|
||||
'tf.selectOci': 'Select an OCI credential',
|
||||
'tf.compartment': 'Compartment',
|
||||
'tf.region': 'Region',
|
||||
'tf.rollback': 'Rollback',
|
||||
'tf.copy': 'Copy',
|
||||
'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',
|
||||
'pg.new': 'New',
|
||||
'pg.noConversations': 'No conversations',
|
||||
'pg.emptyTitle': 'Describe the OCI infrastructure you need',
|
||||
'pg.emptySubtitle': 'The agent will generate a structured, optimized prompt for the Terraform Agent.',
|
||||
'pg.loading': 'Generating prompt...',
|
||||
'pg.placeholder': 'Describe the desired infrastructure... (Shift+Enter for new line)',
|
||||
'pg.copy': 'Copy',
|
||||
'pg.copied': 'Copied',
|
||||
'pg.retry': 'Retry',
|
||||
'pg.timeout': 'Timeout: generation is taking too long.',
|
||||
'pg.search': 'Search...',
|
||||
'pg.selectModel': 'Select model...',
|
||||
|
||||
// ── Workspaces ──
|
||||
'ws.title': 'Terraform Workspaces',
|
||||
'ws.all': 'All',
|
||||
'ws.active': 'Active',
|
||||
'ws.draft': 'Draft',
|
||||
'ws.failed': 'Failed',
|
||||
'ws.destroyed': 'Destroyed',
|
||||
'ws.loading': 'Loading...',
|
||||
'ws.refresh': 'Refresh',
|
||||
'ws.noWs': 'No workspaces found',
|
||||
'ws.noWsHint': 'Create workspaces via the Terraform Agent',
|
||||
'ws.loadingWs': 'Loading workspaces...',
|
||||
'ws.open': 'Open',
|
||||
'ws.plan': 'Plan',
|
||||
'ws.apply': 'Apply',
|
||||
'ws.destroy': 'Destroy',
|
||||
'ws.delete': 'Delete',
|
||||
'ws.cancel': 'Cancel',
|
||||
'ws.hideOutput': 'hide',
|
||||
'ws.viewOutput': 'view output',
|
||||
'ws.confirmDestroy': 'Confirm Destroy',
|
||||
'ws.confirmDestroyMsg': 'This action will destroy all resources provisioned by this workspace.',
|
||||
'ws.typeDestroy': 'Type <strong>DESTROY</strong> to confirm:',
|
||||
'ws.deleteWs': 'Delete Workspace',
|
||||
'ws.deleteWsMsg': 'Delete this workspace permanently? Local files will be removed.',
|
||||
'ws.noDate': 'No date',
|
||||
|
||||
// ── Explorer ──
|
||||
'exp.title': 'OCI Explorer',
|
||||
'exp.subtitle': 'Explore your Oracle Cloud Infrastructure resources',
|
||||
'exp.search': 'Search resource...',
|
||||
'exp.refresh': 'Refresh',
|
||||
'exp.loading': 'Loading...',
|
||||
'exp.noResources': 'No resources found',
|
||||
'exp.selectResource': 'Select a resource type',
|
||||
'exp.compartment': 'Compartment',
|
||||
'exp.region': 'Region',
|
||||
'exp.allRegions': 'All regions',
|
||||
'exp.start': 'Start',
|
||||
'exp.stop': 'Stop',
|
||||
'exp.details': 'Details',
|
||||
'exp.total': 'Total',
|
||||
'exp.network': 'Network',
|
||||
'exp.observability': 'Observability',
|
||||
'exp.security': 'Security',
|
||||
'exp.selectRegions': 'Select regions',
|
||||
'exp.nRegions': '{0} regions',
|
||||
'exp.all': 'All',
|
||||
'exp.none': 'None',
|
||||
'exp.noCompartment': 'No compartments found',
|
||||
'exp.selectConfig': 'Select an OCI config',
|
||||
'exp.loadingResources': 'Loading resources...',
|
||||
'exp.clearFilter': 'Try clearing the search filter',
|
||||
'exp.resources': 'resources',
|
||||
'exp.updating': 'updating...',
|
||||
'exp.regions': 'region(s)',
|
||||
'exp.filter': 'Filter...',
|
||||
'exp.startAction': 'Start',
|
||||
'exp.stopAction': 'Stop',
|
||||
'exp.updatingState': 'Updating...',
|
||||
'exp.createdAt': 'Created at',
|
||||
'exp.public': 'Public',
|
||||
'exp.yesPublic': 'Yes',
|
||||
'exp.noPrivate': 'No (private)',
|
||||
'exp.freeTier': 'Free Tier',
|
||||
'exp.yes': 'Yes',
|
||||
'exp.no': 'No',
|
||||
'exp.noRestrictions': 'No restrictions (open)',
|
||||
'exp.updateNetworkAccess': 'Update Network Access',
|
||||
'exp.accessUpdated': 'Access updated: ',
|
||||
'exp.objects': 'Objects',
|
||||
'exp.size': 'Size',
|
||||
'exp.visibility': 'Visibility',
|
||||
'exp.private': 'Private',
|
||||
'exp.rules': 'Rules',
|
||||
'exp.routes': 'Routes',
|
||||
'exp.hideRules': 'Hide rules',
|
||||
'exp.viewRules': 'View rules',
|
||||
'exp.add': 'Add',
|
||||
'exp.direction': 'Direction',
|
||||
'exp.protocol': 'Protocol',
|
||||
'exp.originDest': 'Source/Dest',
|
||||
'exp.ports': 'Ports',
|
||||
'exp.description': 'Description',
|
||||
'exp.portMin': 'Port Min',
|
||||
'exp.portMax': 'Port Max',
|
||||
'exp.myIp': 'My IP',
|
||||
'exp.save': 'Save',
|
||||
'exp.cancel': 'Cancel',
|
||||
'exp.ruleAdded': 'Rule added.',
|
||||
'exp.enterCidr': 'Enter the CIDR.',
|
||||
'exp.removeRule': 'Remove this rule?',
|
||||
'exp.removeNsgRule': 'Remove this NSG rule?',
|
||||
'exp.ipDetected': 'IP detected: {0} — adjust port and click Save',
|
||||
'exp.ipDetectFailed': 'Could not detect IP.',
|
||||
'exp.destination': 'Destination',
|
||||
'exp.destType': 'Dest Type',
|
||||
'exp.version': 'Version',
|
||||
'exp.ha': 'HA',
|
||||
|
||||
// ── Reports ──
|
||||
'rpt.title': 'CIS Reports',
|
||||
'rpt.subtitle': 'Run CIS OCI Benchmark compliance scans',
|
||||
'rpt.startScan': 'Start Scan',
|
||||
'rpt.running': 'Running...',
|
||||
'rpt.cancel': 'Cancel',
|
||||
'rpt.tenancy': 'Tenancy',
|
||||
'rpt.level': 'Level',
|
||||
'rpt.region': 'Region',
|
||||
'rpt.allRegions': 'All regions',
|
||||
'rpt.noReports': 'No reports found',
|
||||
'rpt.noReportsHint': 'Run a scan to generate compliance reports.',
|
||||
'rpt.progress': 'Progress',
|
||||
'rpt.completed': 'Completed',
|
||||
'rpt.failed': 'Failed',
|
||||
'rpt.viewReport': 'View Report',
|
||||
'rpt.download': 'Download',
|
||||
'rpt.delete': 'Delete',
|
||||
'rpt.summary': 'Summary',
|
||||
'rpt.passed': 'Passed',
|
||||
'rpt.findings': 'Findings',
|
||||
'rpt.recommendations': 'Recommendations',
|
||||
'rpt.selectTenancy': 'Select a tenancy',
|
||||
'rpt.selectCredential': 'Select a credential',
|
||||
'rpt.loadingReports': 'Loading reports...',
|
||||
'rpt.refresh': 'Refresh',
|
||||
'rpt.pageTitle': 'CIS Reports',
|
||||
'rpt.pageSubtitle': 'CIS Benchmark 3.0 — Compliance Scan & Dashboard',
|
||||
'rpt.runScanCis': 'Run CIS Scan',
|
||||
'rpt.inExecution': 'Running',
|
||||
'rpt.configParams': 'Configure the parameters and run the CIS compliance scan.',
|
||||
'rpt.ociCredential': 'OCI Credential',
|
||||
'rpt.selectOciCred': 'Select OCI credential...',
|
||||
'rpt.cisLevel': 'CIS Level',
|
||||
'rpt.level1Desc': 'Essential security checks',
|
||||
'rpt.level2Desc': 'Complete checks (includes Level 1)',
|
||||
'rpt.regions': 'Regions',
|
||||
'rpt.leaveEmpty': 'Leave empty to scan all subscribed regions',
|
||||
'rpt.executeCompliance': 'Run CIS Compliance',
|
||||
'rpt.scanProgress': 'Scan Progress',
|
||||
'rpt.hideLog': 'Hide log',
|
||||
'rpt.showLog': 'View full log',
|
||||
'rpt.lines': 'lines',
|
||||
'rpt.cancelScan': 'Cancel Scan',
|
||||
'rpt.selectReport': 'Select Report',
|
||||
'rpt.selectCompletedReport': 'Select a completed report...',
|
||||
'rpt.searchTenancy': 'Search tenancy...',
|
||||
'rpt.searchByTenancy': 'Search by tenancy...',
|
||||
'rpt.noCredentials': 'No credentials found',
|
||||
'rpt.allRegionsDefault': 'All regions (default)',
|
||||
'rpt.searchRegion': 'Search region...',
|
||||
'rpt.loadingDashboard': 'Loading dashboard...',
|
||||
'rpt.complianceScore': 'Compliance Score',
|
||||
'rpt.compliant': 'Compliant',
|
||||
'rpt.nonCompliant': 'Non-Compliant',
|
||||
'rpt.ofTotal': 'of total',
|
||||
'rpt.compliantItems': 'compliant items',
|
||||
'rpt.totalFindings': 'total findings',
|
||||
'rpt.evaluated': 'Evaluated',
|
||||
'rpt.controlsVerified': 'Controls verified',
|
||||
'rpt.regionsCount': 'region(s)',
|
||||
'rpt.complianceDist': 'Compliance Distribution',
|
||||
'rpt.findingsBySection': 'Findings by Section',
|
||||
'rpt.generatedAt': 'Generated at',
|
||||
'rpt.htmlReport': 'HTML Report',
|
||||
'rpt.files': 'Files',
|
||||
'rpt.inSections': 'sections',
|
||||
'rpt.fullEmbedding': 'Full Embedding',
|
||||
'rpt.noActiveTables': 'No active tables',
|
||||
'rpt.executionHistory': 'Execution History',
|
||||
'rpt.reports': 'report(s)',
|
||||
'rpt.loading': 'Loading...',
|
||||
'rpt.firstScanHint': 'Run the first CIS scan above.',
|
||||
'rpt.selected': 'Selected',
|
||||
'rpt.deleteConfirm': 'Delete this report?',
|
||||
'rpt.deleteFailed': 'Failed to delete',
|
||||
'rpt.selectOciCredError': 'Select an OCI credential',
|
||||
'rpt.alreadyRunning': 'A scan is already running',
|
||||
'rpt.errorLoadReports': 'Error loading reports',
|
||||
'rpt.errorStartScan': 'Error starting scan',
|
||||
'rpt.selectAdb': 'Select an ADB connection.',
|
||||
'rpt.selectTable': 'Select a table.',
|
||||
'rpt.errorEmbedding': 'Error generating embedding',
|
||||
'rpt.cancelled': 'Cancelled',
|
||||
'rpt.completedAt': 'Completed:',
|
||||
'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',
|
||||
'oci.subtitle': 'Manage Oracle Cloud Infrastructure credentials',
|
||||
'oci.registered': 'Registered Credentials',
|
||||
'oci.credential': 'credential',
|
||||
'oci.credentials': 'credentials',
|
||||
'oci.noCredentials': 'No credentials registered.',
|
||||
'oci.edit': 'Edit',
|
||||
'oci.test': 'Test',
|
||||
'oci.delete': 'Delete',
|
||||
'oci.yes': 'Yes',
|
||||
'oci.no': 'No',
|
||||
'oci.newCredential': 'New Credential',
|
||||
'oci.editCredential': 'Edit Credential',
|
||||
'oci.addDesc': 'Add your OCI account credentials to run reports and access services.',
|
||||
'oci.editing': 'Editing:',
|
||||
'oci.tenancyName': 'Tenancy Name',
|
||||
'oci.ocidTenancy': 'OCID Tenancy',
|
||||
'oci.region': 'Region',
|
||||
'oci.selectRegion': 'Select region...',
|
||||
'oci.searchRegion': 'Search region...',
|
||||
'oci.noRegion': 'No regions found',
|
||||
'oci.compartment': 'Compartment OCID',
|
||||
'oci.ocidUser': 'OCID User',
|
||||
'oci.fingerprint': 'Fingerprint',
|
||||
'oci.sessionToken': 'Session Token',
|
||||
'oci.sessionTokenHint': 'Paste the token file content generated by',
|
||||
'oci.sessionKeyLabel': 'Session Key (.pem)',
|
||||
'oci.privateKey': 'Private Key (.pem)',
|
||||
'oci.publicKey': 'Public Key (.pem)',
|
||||
'oci.keyPassphrase': 'Key Passphrase',
|
||||
'oci.keyPassphraseHint': 'Only if the private key is password protected',
|
||||
'oci.sshPublicKey': 'SSH Public Key',
|
||||
'oci.sshPublicKeyHint': 'SSH key for compute instance access (e.g. ssh-rsa AAAA...)',
|
||||
'oci.saveCredential': 'Save Credential',
|
||||
'oci.saveChanges': 'Save Changes',
|
||||
'oci.cancel': 'Cancel',
|
||||
'oci.saving': 'Saving credential...',
|
||||
'oci.saved': 'Credential saved successfully!',
|
||||
'oci.updated': 'Credential updated successfully!',
|
||||
'oci.deleting': 'Deleting credential...',
|
||||
'oci.deleted': 'Credential deleted successfully!',
|
||||
'oci.fillField': 'Fill in',
|
||||
'oci.fillOcidTenancy': 'Fill in OCID Tenancy',
|
||||
'oci.fillUserAndFP': 'Fill in OCID User and Fingerprint',
|
||||
'oci.pasteToken': 'Paste the Session Token',
|
||||
'oci.selectKey': 'Select the private key (.pem)',
|
||||
'oci.selectSessionKey': 'Select the session key (.pem)',
|
||||
'oci.changeHint': 'Fill to change. Current:',
|
||||
'oci.keepKey': 'Leave empty to keep current key',
|
||||
'oci.typeCannotChange': '(type cannot be changed)',
|
||||
'oci.sshNotConfigured': 'not configured',
|
||||
|
||||
// ── GenAI Config ──
|
||||
'genai.title': 'GenAI Models',
|
||||
'genai.subtitle': 'Configure generative AI models for the Chat Agent',
|
||||
'genai.configured': 'Configured Models',
|
||||
'genai.model': 'model',
|
||||
'genai.models': 'models',
|
||||
'genai.noModels': 'No models configured.',
|
||||
'genai.newModel': 'New GenAI Model',
|
||||
'genai.editModel': 'Edit Model',
|
||||
'genai.configName': 'Config Name',
|
||||
'genai.ociCredential': 'OCI Credential',
|
||||
'genai.modelSelect': 'Model',
|
||||
'genai.genaiRegion': 'GenAI Region',
|
||||
'genai.compartment': 'Compartment OCID',
|
||||
'genai.modelOcid': 'Model OCID',
|
||||
'genai.modelOcidHint': 'Paste the model OCID from OCI portal (optional)',
|
||||
'genai.customOcid': 'Custom (use OCID)',
|
||||
'genai.default': 'Default Model',
|
||||
'genai.setDefault': 'Set as Default',
|
||||
'genai.saveModel': 'Save Model',
|
||||
'genai.saveChanges': 'Save Changes',
|
||||
'genai.cancel': 'Cancel',
|
||||
'genai.saving': 'Saving model...',
|
||||
'genai.deleting': 'Deleting model...',
|
||||
'genai.addDesc': 'Add custom models or create presets with specific credentials.',
|
||||
'genai.fillName': 'Fill in config name',
|
||||
'genai.selectOci': 'Select an OCI credential',
|
||||
'genai.fillOcid': 'Enter Model OCID for custom model',
|
||||
|
||||
// ── MCP Servers ──
|
||||
'mcp.title': 'MCP Servers',
|
||||
'mcp.subtitle': 'MCP servers for tool integration and task execution',
|
||||
'mcp.registered': 'Registered Servers',
|
||||
'mcp.noServers': 'No MCP servers registered.',
|
||||
'mcp.edit': 'Edit',
|
||||
'mcp.activate': 'Activate',
|
||||
'mcp.deactivate': 'Deactivate',
|
||||
'mcp.active': 'Active',
|
||||
'mcp.inactive': 'Inactive',
|
||||
'mcp.delete': 'Delete',
|
||||
'mcp.yes': 'Yes',
|
||||
'mcp.no': 'No',
|
||||
'mcp.command': 'Command:',
|
||||
'mcp.url': 'URL:',
|
||||
'mcp.module': 'Module:',
|
||||
'mcp.adb': 'ADB:',
|
||||
'mcp.noAdb': 'None',
|
||||
'mcp.tools': 'Tools',
|
||||
'mcp.noTools': 'No tools registered',
|
||||
'mcp.discoverTools': 'Discover Tools',
|
||||
'mcp.discovering': 'Connecting to MCP server to discover tools...',
|
||||
'mcp.newServer': 'Register New Server',
|
||||
'mcp.editServer': 'Edit Server',
|
||||
'mcp.name': 'Name',
|
||||
'mcp.description': 'Description',
|
||||
'mcp.serverType': 'Server Type',
|
||||
'mcp.commandLabel': 'Command',
|
||||
'mcp.commandHint': 'Command to start the server',
|
||||
'mcp.argsLabel': 'Arguments (JSON)',
|
||||
'mcp.argsHint': 'Array of arguments passed to the command',
|
||||
'mcp.urlLabel': 'Server URL',
|
||||
'mcp.urlHint': 'MCP server SSE endpoint',
|
||||
'mcp.linkAdb': 'Link ADB Vector',
|
||||
'mcp.linkAdbHint': 'Optional — the MCP server can use this database as a tool.',
|
||||
'mcp.saveServer': 'Register Server',
|
||||
'mcp.saveChanges': 'Save Changes',
|
||||
'mcp.cancel': 'Cancel',
|
||||
'mcp.saving': 'Registering server...',
|
||||
'mcp.updating': 'Updating server...',
|
||||
'mcp.fillName': 'Fill in server name',
|
||||
'mcp.invalidArgs': 'Arguments must be a valid JSON array',
|
||||
'mcp.configDesc': 'Configure a new MCP server. Fields adjust based on the selected type.',
|
||||
|
||||
// ── ADB Config ──
|
||||
'adb.title': 'ADB Connections',
|
||||
'adb.subtitle': 'Oracle Autonomous Database connections via Wallet (mTLS)',
|
||||
'adb.registered': 'Registered Connections',
|
||||
'adb.connection': 'connection',
|
||||
'adb.connections': 'connections',
|
||||
'adb.noConnections': 'No ADB connections registered.',
|
||||
'adb.edit': 'Edit',
|
||||
'adb.test': 'Test',
|
||||
'adb.delete': 'Delete',
|
||||
'adb.yes': 'Yes',
|
||||
'adb.no': 'No',
|
||||
'adb.newConnection': 'New Connection',
|
||||
'adb.editConnection': 'Edit Connection',
|
||||
'adb.connName': 'Connection Name',
|
||||
'adb.walletZip': 'Wallet ZIP',
|
||||
'adb.walletHint': 'Upload wallet to extract DSNs from tnsnames.ora',
|
||||
'adb.walletEditHint': 'Upload new wallet or leave empty to keep current',
|
||||
'adb.analyze': 'Analyze',
|
||||
'adb.dsn': 'DSN (TNS Name)',
|
||||
'adb.dsnHint': 'Select or type the DSN. E.g. myatp_high',
|
||||
'adb.username': 'Username',
|
||||
'adb.password': 'Password',
|
||||
'adb.walletPassword': 'Wallet Password',
|
||||
'adb.genaiConfig': 'GenAI Config (Embeddings)',
|
||||
'adb.genaiConfigHint': 'OCI credentials used to generate embeddings via GenAI.',
|
||||
'adb.noRag': 'None (no RAG)',
|
||||
'adb.embeddingModel': 'Embedding Model',
|
||||
'adb.embeddingModelHint': 'OCI GenAI model to generate vectors.',
|
||||
'adb.saveConnection': 'Save Connection',
|
||||
'adb.saveChanges': 'Save Changes',
|
||||
'adb.cancel': 'Cancel',
|
||||
'adb.saving': 'Saving connection...',
|
||||
'adb.deleting': 'Deleting connection...',
|
||||
'adb.updateWallet': 'Update Wallet',
|
||||
'adb.adbConnection': 'ADB Connection',
|
||||
'adb.uploadWallet': 'Upload Wallet',
|
||||
'adb.connDesc': 'Connection via python-oracledb Thin mode with Wallet (mTLS) to Oracle Autonomous Database.',
|
||||
'adb.selectWallet': 'Select a wallet ZIP file.',
|
||||
'adb.selectConnection': 'Select an ADB connection.',
|
||||
'adb.fillName': 'Fill in connection name',
|
||||
'adb.fillDsn': 'Fill in DSN',
|
||||
'adb.fillUsername': 'Fill in username',
|
||||
'adb.fillPassword': 'Fill in password',
|
||||
|
||||
// ── Embeddings ──
|
||||
'emb.title': 'Embeddings',
|
||||
'emb.subtitle': 'Vector knowledge base — upload, import and management',
|
||||
'emb.noAdb': 'No ADB connection configured.',
|
||||
'emb.noAdbHint': 'Configure in ADB Vector.',
|
||||
'emb.noOci': 'No OCI credential configured.',
|
||||
'emb.noOciHint': 'Configure in Config → OCI.',
|
||||
'emb.existing': 'Existing Embeddings',
|
||||
'emb.existingDesc': 'Query documents stored in ADB embedding tables.',
|
||||
'emb.adbConnection': 'ADB Connection',
|
||||
'emb.table': 'Table',
|
||||
'emb.load': 'Load',
|
||||
'emb.noEmbeddings': 'No embeddings found.',
|
||||
'emb.total': 'Total: {0} documents',
|
||||
'emb.cisTitle': 'CIS Recommendations',
|
||||
'emb.cisDesc': 'Upload the latest PDF version to keep CIS Oracle Cloud recommendations updated in the vector database.',
|
||||
'emb.pdfFile': 'PDF File',
|
||||
'emb.uploadCis': 'Upload CIS Recommendations',
|
||||
'emb.kbTitle': 'Knowledge Base',
|
||||
'emb.kbDesc': 'Upload documents to feed the team knowledge base. Files will be vectorized and available for chat queries.',
|
||||
'emb.fileLabel': 'File (.txt, .pdf, .csv, .json, .md)',
|
||||
'emb.uploadFile': 'Upload File',
|
||||
'emb.urlLabel': 'URL (web page or PDF)',
|
||||
'emb.importUrl': 'Import URL',
|
||||
'emb.purgeTitle': 'Purge & Re-embed',
|
||||
'emb.purgeDesc': 'Clear embeddings from a table to free space or prepare for re-ingestion. This action is irreversible.',
|
||||
'emb.tenancyOptional': 'Tenancy (optional)',
|
||||
'emb.purge': 'Purge',
|
||||
'emb.confirm': 'Confirm',
|
||||
'emb.selectTable': 'Select a table.',
|
||||
'emb.selectAdbAndFile': 'Select ADB connection and file.',
|
||||
'emb.selectAdbAndUrl': 'Select ADB connection and enter URL.',
|
||||
'emb.processing': 'Processing PDF and generating embeddings...',
|
||||
'emb.uploading': 'Uploading and generating embeddings...',
|
||||
'emb.fetchingUrl': 'Fetching content and generating embeddings...',
|
||||
'emb.purging': 'Clearing embeddings...',
|
||||
|
||||
// ── EmbConsult ──
|
||||
'ec.title': 'Query Embeddings',
|
||||
'ec.subtitle': 'Ask about data stored in vector databases',
|
||||
'ec.clear': 'Clear',
|
||||
'ec.tableOptional': 'Table (optional)',
|
||||
'ec.allTables': 'All tables',
|
||||
'ec.emptyTitle': 'Make a query to search embeddings.',
|
||||
'ec.emptyHint': 'E.g. "findings for CIS 1.1", "networking recommendations", "how to fix MFA"',
|
||||
'ec.searching': 'Querying vector databases...',
|
||||
'ec.placeholder': 'Type your query...',
|
||||
'ec.submit': 'Query',
|
||||
'ec.sources': 'Consulted sources',
|
||||
'ec.noAnswer': 'No model response.',
|
||||
|
||||
// ── Users ──
|
||||
'usr.title': 'Users',
|
||||
'usr.activeCount': 'active',
|
||||
'usr.activesCount': 'active',
|
||||
'usr.inactiveCount': 'inactive',
|
||||
'usr.inactivesCount': 'inactive',
|
||||
'usr.total': 'total',
|
||||
'usr.newUser': 'New User',
|
||||
'usr.cancel': 'Cancel',
|
||||
'usr.createUser': 'Create New User',
|
||||
'usr.firstName': 'First Name',
|
||||
'usr.lastName': 'Last Name',
|
||||
'usr.username': 'Username',
|
||||
'usr.email': 'Email',
|
||||
'usr.password': 'Password',
|
||||
'usr.role': 'Role',
|
||||
'usr.creating': 'Creating...',
|
||||
'usr.create': 'Create User',
|
||||
'usr.noUsers': 'No users registered',
|
||||
'usr.noUsersHint': 'Click "New User" to create the first user.',
|
||||
'usr.name': 'Name',
|
||||
'usr.mfa': 'MFA',
|
||||
'usr.status': 'Status',
|
||||
'usr.lastLogin': 'Last Login',
|
||||
'usr.actions': 'Actions',
|
||||
'usr.active': 'Active',
|
||||
'usr.inactive': 'Inactive',
|
||||
'usr.never': 'Never',
|
||||
'usr.confirm': 'Confirm',
|
||||
'usr.deactivate': 'Deactivate user',
|
||||
'usr.created': 'User created successfully!',
|
||||
'usr.roleUpdated': 'Role updated',
|
||||
'usr.deactivated': 'User deactivated',
|
||||
'usr.requiredName': 'First and last name are required',
|
||||
'usr.requiredUsername': 'Username is required',
|
||||
'usr.requiredPassword': 'Password is required',
|
||||
|
||||
// ── MFA ──
|
||||
'mfa.title': 'MFA Authentication (TOTP)',
|
||||
'mfa.subtitle': 'Protect your account with two-factor authentication via Google Authenticator or Authy.',
|
||||
'mfa.enabled': 'MFA is enabled',
|
||||
'mfa.disabled': 'MFA is disabled',
|
||||
'mfa.enabledDesc': 'Your account is protected with two-factor authentication.',
|
||||
'mfa.disabledDesc': 'Enable MFA to add an extra layer of security.',
|
||||
'mfa.disableHint': 'To disable MFA, click the button below. This will reduce your account security.',
|
||||
'mfa.disableBtn': 'Disable MFA',
|
||||
'mfa.confirmDisable': 'Are you sure you want to disable MFA for your account?',
|
||||
'mfa.scanQr': 'Scan the QR Code or copy the secret',
|
||||
'mfa.openApp': 'Open your authenticator app (Google Authenticator, Authy, etc.)',
|
||||
'mfa.enterCode': 'Enter the code from your app',
|
||||
'mfa.activateMfa': 'Activate MFA',
|
||||
'mfa.generateSecret': 'Generate Secret',
|
||||
'mfa.generateHint': 'Click the button below to generate a TOTP secret. You will need an authenticator app to scan the QR Code.',
|
||||
'mfa.activated': 'MFA activated successfully!',
|
||||
'mfa.deactivated': 'MFA deactivated.',
|
||||
'mfa.invalidCode': 'Enter a 6-digit code',
|
||||
'mfa.configure': 'Configure',
|
||||
|
||||
// ── Audit ──
|
||||
'audit.title': 'Audit Log',
|
||||
'audit.subtitle': 'System activity monitoring, configurations and chat sessions.',
|
||||
'audit.tabAudit': 'Audit Log',
|
||||
'audit.tabConfig': 'Config Logs',
|
||||
'audit.tabChat': 'Chat Logs',
|
||||
'audit.records': 'records',
|
||||
'audit.refresh': 'Refresh',
|
||||
'audit.all': 'All',
|
||||
'audit.errors': 'Errors',
|
||||
'audit.success': 'Success',
|
||||
'audit.info': 'Info',
|
||||
'audit.loadingLogs': 'Loading logs...',
|
||||
'audit.noAudit': 'No audit records.',
|
||||
'audit.noConfig': 'No configuration logs recorded.',
|
||||
'audit.noChat': 'No chat logs recorded.',
|
||||
'audit.date': 'Date',
|
||||
'audit.user': 'User',
|
||||
'audit.action': 'Action',
|
||||
'audit.resource': 'Resource',
|
||||
'audit.details': 'Details',
|
||||
'audit.ip': 'IP',
|
||||
'audit.config': 'Config',
|
||||
'audit.status': 'Status',
|
||||
'audit.message': 'Message',
|
||||
'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.',
|
||||
'usr.myTimezone': 'My Timezone',
|
||||
|
||||
'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',
|
||||
'common.delete': 'Delete',
|
||||
'common.edit': 'Edit',
|
||||
'common.create': 'Create',
|
||||
'common.update': 'Refresh',
|
||||
'common.close': 'Close',
|
||||
'common.confirm': 'Confirm',
|
||||
'common.back': 'Back',
|
||||
'common.search': 'Search',
|
||||
'common.refresh': 'Refresh',
|
||||
'common.download': 'Download',
|
||||
'common.upload': 'Upload',
|
||||
'common.copy': 'Copy',
|
||||
'common.retry': 'Retry',
|
||||
'common.send': 'Send',
|
||||
'common.clear': 'Clear',
|
||||
'common.test': 'Test',
|
||||
'common.yes': 'Yes',
|
||||
'common.no': 'No',
|
||||
'common.loading': 'Loading...',
|
||||
'common.saving': 'Saving...',
|
||||
'common.select': 'Select...',
|
||||
'common.noData': 'No data found',
|
||||
'common.error': 'Error',
|
||||
'common.success': 'Success',
|
||||
// ── My Settings ──
|
||||
'settings.title': 'My Settings',
|
||||
'settings.timezone': 'Timezone',
|
||||
'settings.tzDesc': 'Set your timezone for date and time display.',
|
||||
'settings.tzChanged': 'Timezone changed successfully',
|
||||
'settings.language': 'Language',
|
||||
'settings.langDesc': 'Select the interface language.',
|
||||
'settings.changePassword': 'Change Password',
|
||||
'settings.currentPw': 'Current Password',
|
||||
'settings.newPw': 'New Password',
|
||||
'settings.confirmPw': 'Confirm New Password',
|
||||
'settings.savePw': 'Change Password',
|
||||
'settings.saving': 'Saving...',
|
||||
'settings.pwChanged': 'Password changed successfully',
|
||||
'settings.pwRequired': 'Fill in all fields',
|
||||
'settings.pwMismatch': 'Passwords do not match',
|
||||
'settings.pwMinLength': 'New password must be at least 8 characters',
|
||||
'settings.federatedInfo': 'Password and MFA are managed by Oracle IAM Identity Domain. Configure directly in the OCI portal.',
|
||||
'settings.mfa': 'Multi-Factor Authentication (MFA)',
|
||||
// ── Terminal ──
|
||||
'term.title': 'OCI CLI Terminal',
|
||||
'term.subtitle': 'Execute OCI CLI commands directly in the browser',
|
||||
'term.config': 'Configuration',
|
||||
'term.selectConfig': 'Select an OCI configuration',
|
||||
'term.welcome': 'Type OCI CLI commands. Ex: oci iam user list --compartment-id <ocid>',
|
||||
'term.clearHint': 'to clear',
|
||||
'term.historyHint': 'to browse history',
|
||||
'term.executing': 'Executing...',
|
||||
'term.noOutput': 'Command executed with no output',
|
||||
// ── Oracle IAM ──
|
||||
'iam.title': 'Oracle IAM Identity Domains',
|
||||
'iam.subtitle': 'OIDC integration for corporate authentication',
|
||||
'iam.authMode': 'Authentication Mode',
|
||||
'iam.authModeDesc': 'Defines how users authenticate to the system.',
|
||||
'iam.modeLocal': 'Local',
|
||||
'iam.modeLocalDesc': 'Local users with password',
|
||||
'iam.modeOidc': 'Oracle IAM',
|
||||
'iam.modeOidcDesc': 'SSO only',
|
||||
'iam.modeBoth': 'Both',
|
||||
'iam.modeBothDesc': 'Local + Oracle IAM',
|
||||
'iam.oidcConfig': 'OIDC Configuration',
|
||||
'iam.testConnection': 'Test Connection',
|
||||
'iam.issuerUrl': 'Issuer URL (Identity Domain)',
|
||||
'iam.clientId': 'Client ID',
|
||||
'iam.clientSecret': 'Client Secret',
|
||||
'iam.redirectUri': 'Redirect URI',
|
||||
'iam.groupMapping': 'Group Mapping',
|
||||
'iam.groupMappingDesc': 'Map OCI Identity Domain groups to system roles.',
|
||||
'iam.save': 'Save Configuration',
|
||||
'iam.saving': 'Saving...',
|
||||
'iam.saved': 'Configuration saved successfully',
|
||||
'iam.issuerRequired': 'Issuer URL is required',
|
||||
'iam.testOk': 'Connection OK',
|
||||
'iam.testInvalid': 'Invalid discovery endpoint response',
|
||||
'iam.testFail': 'Connection failed',
|
||||
};
|
||||
|
||||
export default en;
|
||||
839
frontend/src/i18n/es.ts
Normal file
839
frontend/src/i18n/es.ts
Normal file
@@ -0,0 +1,839 @@
|
||||
const es: Record<string, string> = {
|
||||
// ── Sidebar ──
|
||||
'nav.principal': 'Principal',
|
||||
'nav.admin': 'Administracion',
|
||||
'nav.chat': 'Chat Agent',
|
||||
'nav.terraform': 'Terraform',
|
||||
'nav.promptGen': 'Prompt Generator',
|
||||
'nav.workspaces': 'Workspaces',
|
||||
'nav.explorer': 'OCI Explorer',
|
||||
'nav.ociServices': 'OCI Services',
|
||||
'svc.title': 'Estado de Servicios OCI',
|
||||
'svc.subtitle': 'Verifique y configure el estado de los servicios de seguridad OCI por tenencia',
|
||||
'svc.redetect': 'Re-detectar',
|
||||
'svc.checking': 'Verificando servicios OCI...',
|
||||
'svc.save': 'Guardar',
|
||||
'nav.reports': 'Reportes',
|
||||
'nav.ociCreds': 'Credenciales OCI',
|
||||
'nav.genai': 'Configuracion GenAI',
|
||||
'nav.mcp': 'Servidores MCP',
|
||||
'nav.adb': 'ADB Vector',
|
||||
'nav.embeddings': 'Embeddings',
|
||||
'nav.embConsult': 'Consultar Embeddings',
|
||||
'nav.terminal': 'Terminal',
|
||||
'nav.userMgmt': 'Gestión de Usuarios',
|
||||
'nav.users': 'Usuarios',
|
||||
'nav.mySettings': 'Mi Configuracion',
|
||||
'nav.oracleIam': 'Oracle IAM',
|
||||
'nav.audit': 'Registro de Auditoria',
|
||||
'sidebar.lightMode': 'Modo claro',
|
||||
'sidebar.darkMode': 'Modo oscuro',
|
||||
'sidebar.logout': 'Cerrar sesion',
|
||||
'sidebar.settings': 'Configuracion',
|
||||
'sidebar.backToMenu': 'Volver al menu',
|
||||
'sidebar.roleAdmin': 'Administrador',
|
||||
'sidebar.roleUser': 'Usuario',
|
||||
'sidebar.subtitle': 'Infraestructura y Seguridad',
|
||||
|
||||
// ── Login ──
|
||||
'login.subtitle': 'Ingeniero de Infraestructura y Seguridad',
|
||||
'login.userPlaceholder': 'Usuario',
|
||||
'login.passPlaceholder': 'Contrasena',
|
||||
'login.mfaScanQr': 'Escanee el codigo QR con su aplicacion de autenticacion:',
|
||||
'login.totpPlaceholder': 'Codigo TOTP',
|
||||
'login.loading': 'Iniciando sesion...',
|
||||
'login.verify': 'Verificar',
|
||||
'login.submit': 'Iniciar sesion',
|
||||
'login.error': 'Error de inicio de sesion',
|
||||
'login.or': 'o',
|
||||
'login.oidcButton': 'Iniciar sesion con Oracle IAM',
|
||||
|
||||
// ── Password Change ──
|
||||
'pw.welcomeTitle': 'Bienvenido!',
|
||||
'pw.welcomeUser': 'Conectado como',
|
||||
'pw.welcomeMsg': 'Por seguridad, cambie su contrasena antes de continuar.',
|
||||
'pw.continue': 'Continuar',
|
||||
'pw.changeTitle': 'Cambiar Contrasena',
|
||||
'pw.currentPassword': 'Contrasena actual',
|
||||
'pw.newPassword': 'Nueva contrasena',
|
||||
'pw.confirmPassword': 'Confirmar nueva contrasena',
|
||||
'pw.strength': 'Fuerza de la contrasena',
|
||||
'pw.weak': 'Debil',
|
||||
'pw.medium': 'Media',
|
||||
'pw.strong': 'Fuerte',
|
||||
'pw.minLength': 'Minimo 8 caracteres',
|
||||
'pw.hasUpper': 'Una letra mayuscula',
|
||||
'pw.hasLower': 'Una letra minuscula',
|
||||
'pw.hasNumber': 'Un numero',
|
||||
'pw.hasSpecial': 'Un caracter especial (!@#$...)',
|
||||
'pw.match': 'Las contrasenas coinciden',
|
||||
'pw.sameAsOld': 'La nueva contrasena debe ser diferente de la actual',
|
||||
'pw.error': 'Error al cambiar contrasena',
|
||||
'pw.saving': 'Guardando...',
|
||||
'pw.changeButton': 'Cambiar Contrasena',
|
||||
'pw.successTitle': 'Contrasena cambiada!',
|
||||
'pw.successMsg': 'Redirigiendo...',
|
||||
|
||||
// ── Chat ──
|
||||
'chat.timeNow': 'ahora',
|
||||
'chat.today': 'Hoy',
|
||||
'chat.yesterday': 'Ayer',
|
||||
'chat.last7': 'Ultimos 7 dias',
|
||||
'chat.last30': 'Ultimos 30 dias',
|
||||
'chat.older': 'Anteriores',
|
||||
'chat.other': 'Otros',
|
||||
'chat.history': 'Historial',
|
||||
'chat.newChat': 'Nuevo Chat',
|
||||
'chat.untitled': 'Sin titulo',
|
||||
'chat.noConversations': 'Aun no hay conversaciones',
|
||||
'chat.selectModel': 'Seleccione un modelo...',
|
||||
'chat.savedConfigs': 'Configuraciones Guardadas',
|
||||
'chat.searchModel': 'Buscar modelo...',
|
||||
'chat.noModels': 'No se encontraron modelos',
|
||||
'chat.ociCred': 'Credencial OCI...',
|
||||
'chat.settings': 'Configuracion',
|
||||
'chat.newChatTitle': 'Nuevo chat',
|
||||
'chat.emptyTitle': 'Inicie una conversacion con el agente.',
|
||||
'chat.emptySubtitle': 'Seleccione un modelo para comenzar.',
|
||||
'chat.thinking': 'Pensando...',
|
||||
'chat.retry': 'Reintentar',
|
||||
'chat.attachFile': 'Adjuntar archivo',
|
||||
'chat.placeholder': 'Escriba su mensaje... (Shift+Enter para nueva linea)',
|
||||
'chat.send': 'Enviar',
|
||||
'chat.selectOciCred': 'Seleccione una credencial OCI para usar modelos directos.',
|
||||
'chat.sendError': 'Error al enviar mensaje',
|
||||
'chat.requestFailed': 'La solicitud fallo.',
|
||||
'chat.timeout': 'Tiempo agotado: la respuesta esta tardando demasiado. Intente de nuevo.',
|
||||
'chat.toolsAvailable': 'herramientas disponibles',
|
||||
'chat.ociCredLabel': 'Credencial OCI',
|
||||
'chat.select': 'Seleccione...',
|
||||
'chat.analyzeFiles': 'Analizar los archivos adjuntos.',
|
||||
|
||||
// ── Terraform ──
|
||||
'tf.history': 'Historial',
|
||||
'tf.newChat': 'Nuevo',
|
||||
'tf.noConversations': 'Sin conversaciones',
|
||||
'tf.searchModel': 'Buscar modelo...',
|
||||
'tf.selectModel': 'Seleccione modelo...',
|
||||
'tf.savedConfigs': 'Configuraciones Guardadas',
|
||||
'tf.ociConfig': 'Configuracion OCI...',
|
||||
'tf.settings': 'Configuracion',
|
||||
'tf.newConversation': 'Nueva conversacion',
|
||||
'tf.emptyTitle': 'Describa la infraestructura OCI',
|
||||
'tf.emptySubtitle': 'El agente generara codigo Terraform basado en su descripcion.',
|
||||
'tf.thinking': 'Procesando...',
|
||||
'tf.retry': 'Reintentar',
|
||||
'tf.placeholder': 'Describa la infraestructura deseada... (Shift+Enter para nueva linea)',
|
||||
'tf.send': 'Enviar',
|
||||
'tf.workspace': 'Workspace',
|
||||
'tf.files': 'Archivos',
|
||||
'tf.terminal': 'Terminal',
|
||||
'tf.resources': 'Recursos',
|
||||
'tf.plan': 'Plan',
|
||||
'tf.apply': 'Apply',
|
||||
'tf.destroy': 'Destroy',
|
||||
'tf.cancel': 'Cancelar',
|
||||
'tf.download': 'Descargar ZIP',
|
||||
'tf.createWs': 'Crear Workspace',
|
||||
'tf.noFiles': 'No se generaron archivos',
|
||||
'tf.timeout': 'Tiempo agotado: la generacion esta tardando demasiado.',
|
||||
'tf.selectOci': 'Seleccione una credencial OCI',
|
||||
'tf.compartment': 'Compartimiento',
|
||||
'tf.region': 'Region',
|
||||
'tf.rollback': 'Rollback',
|
||||
'tf.copy': 'Copiar',
|
||||
'tf.copied': 'Copiado',
|
||||
'tf.noWs': 'Sin workspace activo',
|
||||
'tf.deleteWs': 'Eliminar',
|
||||
'tf.noFilesToPlan': 'Sin archivos para planificar',
|
||||
'tf.selectOciConfig': 'Seleccione Configuracion OCI y Compartimiento',
|
||||
'tf.running': 'Ejecutando...',
|
||||
'tf.terminalTitle': 'Terminal de salida Terraform',
|
||||
'tf.terminalHint': 'Ejecute Plan, Apply o Destroy para ver los resultados aqui',
|
||||
'tf.autoFix': 'Auto-corregir',
|
||||
'tf.editSystemPrompt': 'Editar System Prompt',
|
||||
'tf.refreshReference': 'Actualizar Referencia',
|
||||
|
||||
// ── Prompt Generator ──
|
||||
'pg.history': 'Historial',
|
||||
'pg.new': 'Nuevo',
|
||||
'pg.noConversations': 'Sin conversaciones',
|
||||
'pg.emptyTitle': 'Describa la infraestructura OCI que necesita',
|
||||
'pg.emptySubtitle': 'El agente generara un prompt estructurado y optimizado para el Terraform Agent.',
|
||||
'pg.loading': 'Generando prompt...',
|
||||
'pg.placeholder': 'Describa la infraestructura deseada... (Shift+Enter para nueva linea)',
|
||||
'pg.copy': 'Copiar',
|
||||
'pg.copied': 'Copiado',
|
||||
'pg.retry': 'Reintentar',
|
||||
'pg.timeout': 'Tiempo agotado: la generacion esta tardando demasiado.',
|
||||
'pg.search': 'Buscar...',
|
||||
'pg.selectModel': 'Seleccione modelo...',
|
||||
|
||||
// ── Workspaces ──
|
||||
'ws.title': 'Terraform Workspaces',
|
||||
'ws.all': 'Todos',
|
||||
'ws.active': 'Activo',
|
||||
'ws.draft': 'Borrador',
|
||||
'ws.failed': 'Fallido',
|
||||
'ws.destroyed': 'Destruido',
|
||||
'ws.loading': 'Cargando...',
|
||||
'ws.refresh': 'Actualizar',
|
||||
'ws.noWs': 'No se encontraron workspaces',
|
||||
'ws.noWsHint': 'Cree workspaces a traves del Terraform Agent',
|
||||
'ws.loadingWs': 'Cargando workspaces...',
|
||||
'ws.open': 'Abrir',
|
||||
'ws.plan': 'Plan',
|
||||
'ws.apply': 'Apply',
|
||||
'ws.destroy': 'Destroy',
|
||||
'ws.delete': 'Eliminar',
|
||||
'ws.cancel': 'Cancelar',
|
||||
'ws.hideOutput': 'ocultar',
|
||||
'ws.viewOutput': 'ver salida',
|
||||
'ws.confirmDestroy': 'Confirmar Destruccion',
|
||||
'ws.confirmDestroyMsg': 'Esta accion destruira todos los recursos aprovisionados por este workspace.',
|
||||
'ws.typeDestroy': 'Escriba <strong>DESTROY</strong> para confirmar:',
|
||||
'ws.deleteWs': 'Eliminar Workspace',
|
||||
'ws.deleteWsMsg': 'Eliminar este workspace permanentemente? Los archivos locales seran removidos.',
|
||||
'ws.noDate': 'Sin fecha',
|
||||
|
||||
// ── Explorer ──
|
||||
'exp.title': 'OCI Explorer',
|
||||
'exp.subtitle': 'Explore los recursos de su Oracle Cloud Infrastructure',
|
||||
'exp.search': 'Buscar recurso...',
|
||||
'exp.refresh': 'Actualizar',
|
||||
'exp.loading': 'Cargando...',
|
||||
'exp.noResources': 'No se encontraron recursos',
|
||||
'exp.selectResource': 'Seleccione un tipo de recurso',
|
||||
'exp.compartment': 'Compartimiento',
|
||||
'exp.region': 'Region',
|
||||
'exp.allRegions': 'Todas las regiones',
|
||||
'exp.start': 'Iniciar',
|
||||
'exp.stop': 'Detener',
|
||||
'exp.details': 'Detalles',
|
||||
'exp.total': 'Total',
|
||||
'exp.network': 'Red',
|
||||
'exp.observability': 'Observabilidad',
|
||||
'exp.security': 'Seguridad',
|
||||
'exp.selectRegions': 'Seleccione regiones',
|
||||
'exp.nRegions': '{0} regiones',
|
||||
'exp.all': 'Todas',
|
||||
'exp.none': 'Ninguna',
|
||||
'exp.noCompartment': 'No se encontraron compartimientos',
|
||||
'exp.selectConfig': 'Seleccione una configuracion OCI',
|
||||
'exp.loadingResources': 'Cargando recursos...',
|
||||
'exp.clearFilter': 'Intente limpiar el filtro de busqueda',
|
||||
'exp.resources': 'recursos',
|
||||
'exp.updating': 'actualizando...',
|
||||
'exp.regions': 'region(es)',
|
||||
'exp.filter': 'Filtrar...',
|
||||
'exp.startAction': 'Iniciar',
|
||||
'exp.stopAction': 'Detener',
|
||||
'exp.updatingState': 'Actualizando...',
|
||||
'exp.createdAt': 'Creado el',
|
||||
'exp.public': 'Publico',
|
||||
'exp.yesPublic': 'Si',
|
||||
'exp.noPrivate': 'No (privado)',
|
||||
'exp.freeTier': 'Free Tier',
|
||||
'exp.yes': 'Si',
|
||||
'exp.no': 'No',
|
||||
'exp.noRestrictions': 'Sin restricciones (abierto)',
|
||||
'exp.updateNetworkAccess': 'Actualizar Acceso de Red',
|
||||
'exp.accessUpdated': 'Acceso actualizado: ',
|
||||
'exp.objects': 'Objetos',
|
||||
'exp.size': 'Tamano',
|
||||
'exp.visibility': 'Visibilidad',
|
||||
'exp.private': 'Privado',
|
||||
'exp.rules': 'Reglas',
|
||||
'exp.routes': 'Rutas',
|
||||
'exp.hideRules': 'Ocultar reglas',
|
||||
'exp.viewRules': 'Ver reglas',
|
||||
'exp.add': 'Agregar',
|
||||
'exp.direction': 'Direccion',
|
||||
'exp.protocol': 'Protocolo',
|
||||
'exp.originDest': 'Origen/Destino',
|
||||
'exp.ports': 'Puertos',
|
||||
'exp.description': 'Descripcion',
|
||||
'exp.portMin': 'Puerto Min',
|
||||
'exp.portMax': 'Puerto Max',
|
||||
'exp.myIp': 'Mi IP',
|
||||
'exp.save': 'Guardar',
|
||||
'exp.cancel': 'Cancelar',
|
||||
'exp.ruleAdded': 'Regla agregada.',
|
||||
'exp.enterCidr': 'Ingrese el CIDR.',
|
||||
'exp.removeRule': 'Eliminar esta regla?',
|
||||
'exp.removeNsgRule': 'Eliminar esta regla NSG?',
|
||||
'exp.ipDetected': 'IP detectada: {0} — ajuste el puerto y haga clic en Guardar',
|
||||
'exp.ipDetectFailed': 'No se pudo detectar la IP.',
|
||||
'exp.destination': 'Destino',
|
||||
'exp.destType': 'Tipo Destino',
|
||||
'exp.version': 'Version',
|
||||
'exp.ha': 'HA',
|
||||
|
||||
// ── Reports ──
|
||||
'rpt.title': 'Reportes CIS',
|
||||
'rpt.subtitle': 'Ejecute escaneos de cumplimiento CIS OCI Benchmark',
|
||||
'rpt.startScan': 'Iniciar Escaneo',
|
||||
'rpt.running': 'Ejecutando...',
|
||||
'rpt.cancel': 'Cancelar',
|
||||
'rpt.tenancy': 'Tenencia',
|
||||
'rpt.level': 'Nivel',
|
||||
'rpt.region': 'Region',
|
||||
'rpt.allRegions': 'Todas las regiones',
|
||||
'rpt.noReports': 'No se encontraron reportes',
|
||||
'rpt.noReportsHint': 'Ejecute un escaneo para generar reportes de cumplimiento.',
|
||||
'rpt.progress': 'Progreso',
|
||||
'rpt.completed': 'Completado',
|
||||
'rpt.failed': 'Fallido',
|
||||
'rpt.viewReport': 'Ver Reporte',
|
||||
'rpt.download': 'Descargar',
|
||||
'rpt.delete': 'Eliminar',
|
||||
'rpt.summary': 'Resumen',
|
||||
'rpt.passed': 'Aprobado',
|
||||
'rpt.findings': 'Hallazgos',
|
||||
'rpt.recommendations': 'Recomendaciones',
|
||||
'rpt.selectTenancy': 'Seleccione una tenencia',
|
||||
'rpt.selectCredential': 'Seleccione una credencial',
|
||||
'rpt.loadingReports': 'Cargando reportes...',
|
||||
'rpt.refresh': 'Actualizar',
|
||||
'rpt.pageTitle': 'Reportes CIS',
|
||||
'rpt.pageSubtitle': 'CIS Benchmark 3.0 — Escaneo de Cumplimiento y Dashboard',
|
||||
'rpt.runScanCis': 'Ejecutar Escaneo CIS',
|
||||
'rpt.inExecution': 'En ejecucion',
|
||||
'rpt.configParams': 'Configure los parametros y ejecute el escaneo de cumplimiento CIS.',
|
||||
'rpt.ociCredential': 'Credencial OCI',
|
||||
'rpt.selectOciCred': 'Seleccione credencial OCI...',
|
||||
'rpt.cisLevel': 'Nivel CIS',
|
||||
'rpt.level1Desc': 'Verificaciones esenciales de seguridad',
|
||||
'rpt.level2Desc': 'Verificaciones completas (incluye Nivel 1)',
|
||||
'rpt.regions': 'Regiones',
|
||||
'rpt.leaveEmpty': 'Deje vacio para escanear todas las regiones suscritas',
|
||||
'rpt.executeCompliance': 'Ejecutar Cumplimiento CIS',
|
||||
'rpt.scanProgress': 'Progreso del Escaneo',
|
||||
'rpt.hideLog': 'Ocultar log',
|
||||
'rpt.showLog': 'Ver log completo',
|
||||
'rpt.lines': 'lineas',
|
||||
'rpt.cancelScan': 'Cancelar Escaneo',
|
||||
'rpt.selectReport': 'Seleccionar Reporte',
|
||||
'rpt.selectCompletedReport': 'Seleccione un reporte completado...',
|
||||
'rpt.searchTenancy': 'Buscar tenencia...',
|
||||
'rpt.searchByTenancy': 'Buscar por tenencia...',
|
||||
'rpt.noCredentials': 'No se encontraron credenciales',
|
||||
'rpt.allRegionsDefault': 'Todas las regiones (predeterminado)',
|
||||
'rpt.searchRegion': 'Buscar region...',
|
||||
'rpt.loadingDashboard': 'Cargando dashboard...',
|
||||
'rpt.complianceScore': 'Puntuacion de Cumplimiento',
|
||||
'rpt.compliant': 'Conforme',
|
||||
'rpt.nonCompliant': 'No Conforme',
|
||||
'rpt.ofTotal': 'del total',
|
||||
'rpt.compliantItems': 'items conformes',
|
||||
'rpt.totalFindings': 'hallazgos totales',
|
||||
'rpt.evaluated': 'Evaluados',
|
||||
'rpt.controlsVerified': 'Controles verificados',
|
||||
'rpt.regionsCount': 'region(es)',
|
||||
'rpt.complianceDist': 'Distribucion de Cumplimiento',
|
||||
'rpt.findingsBySection': 'Hallazgos por Seccion',
|
||||
'rpt.generatedAt': 'Generado el',
|
||||
'rpt.htmlReport': 'Reporte HTML',
|
||||
'rpt.files': 'Archivos',
|
||||
'rpt.inSections': 'secciones',
|
||||
'rpt.fullEmbedding': 'Embedding Completo',
|
||||
'rpt.noActiveTables': 'Sin tablas activas',
|
||||
'rpt.executionHistory': 'Historial de Ejecuciones',
|
||||
'rpt.reports': 'reporte(s)',
|
||||
'rpt.loading': 'Cargando...',
|
||||
'rpt.firstScanHint': 'Ejecute el primer escaneo CIS arriba.',
|
||||
'rpt.selected': 'Seleccionado',
|
||||
'rpt.deleteConfirm': 'Eliminar este reporte?',
|
||||
'rpt.deleteFailed': 'Error al eliminar',
|
||||
'rpt.selectOciCredError': 'Seleccione una credencial OCI',
|
||||
'rpt.alreadyRunning': 'Ya hay un escaneo en ejecucion',
|
||||
'rpt.errorLoadReports': 'Error al cargar reportes',
|
||||
'rpt.errorStartScan': 'Error al iniciar escaneo',
|
||||
'rpt.selectAdb': 'Seleccione una conexion ADB.',
|
||||
'rpt.selectTable': 'Seleccione una tabla.',
|
||||
'rpt.errorEmbedding': 'Error al generar embedding',
|
||||
'rpt.cancelled': 'Cancelado',
|
||||
'rpt.completedAt': 'Completado:',
|
||||
'rpt.complianceGenerating': 'Generando Reporte de Cumplimiento...',
|
||||
'rpt.complianceFetchingKB': 'Obteniendo remediaciones de la Base de Conocimiento CIS',
|
||||
'rpt.complianceNotGenerated': 'Reporte aun no generado',
|
||||
'rpt.complianceGenerate': 'Generar Reporte de Cumplimiento',
|
||||
'rpt.complianceRegenerate': 'Regenerar',
|
||||
'rpt.complianceDownloadZip': 'Descargar ZIP',
|
||||
'rpt.complianceRemediation': '+ Remediacion',
|
||||
'rpt.complianceGeneratingShort': 'Generando...',
|
||||
'rpt.complianceTitle': 'LAD A-Team CIS Compliance Report',
|
||||
|
||||
// ── OCI Config ──
|
||||
'oci.title': 'Credenciales OCI',
|
||||
'oci.subtitle': 'Administre las credenciales de Oracle Cloud Infrastructure',
|
||||
'oci.registered': 'Credenciales Registradas',
|
||||
'oci.credential': 'credencial',
|
||||
'oci.credentials': 'credenciales',
|
||||
'oci.noCredentials': 'No hay credenciales registradas.',
|
||||
'oci.edit': 'Editar',
|
||||
'oci.test': 'Probar',
|
||||
'oci.delete': 'Eliminar',
|
||||
'oci.yes': 'Si',
|
||||
'oci.no': 'No',
|
||||
'oci.newCredential': 'Nueva Credencial',
|
||||
'oci.editCredential': 'Editar Credencial',
|
||||
'oci.addDesc': 'Agregue las credenciales de su cuenta OCI para ejecutar reportes y acceder a servicios.',
|
||||
'oci.editing': 'Editando:',
|
||||
'oci.tenancyName': 'Nombre de Tenencia',
|
||||
'oci.ocidTenancy': 'OCID Tenencia',
|
||||
'oci.region': 'Region',
|
||||
'oci.selectRegion': 'Seleccione region...',
|
||||
'oci.searchRegion': 'Buscar region...',
|
||||
'oci.noRegion': 'No se encontraron regiones',
|
||||
'oci.compartment': 'OCID Compartimiento',
|
||||
'oci.ocidUser': 'OCID Usuario',
|
||||
'oci.fingerprint': 'Fingerprint',
|
||||
'oci.sessionToken': 'Token de Sesion',
|
||||
'oci.sessionTokenHint': 'Pegue el contenido del archivo de token generado por',
|
||||
'oci.sessionKeyLabel': 'Clave de Sesion (.pem)',
|
||||
'oci.privateKey': 'Clave Privada (.pem)',
|
||||
'oci.publicKey': 'Clave Publica (.pem)',
|
||||
'oci.keyPassphrase': 'Frase de Paso de la Clave',
|
||||
'oci.keyPassphraseHint': 'Solo si la clave privada esta protegida con contrasena',
|
||||
'oci.sshPublicKey': 'Clave Publica SSH',
|
||||
'oci.sshPublicKeyHint': 'Clave SSH para acceso a instancias de computo (ej. ssh-rsa AAAA...)',
|
||||
'oci.saveCredential': 'Guardar Credencial',
|
||||
'oci.saveChanges': 'Guardar Cambios',
|
||||
'oci.cancel': 'Cancelar',
|
||||
'oci.saving': 'Guardando credencial...',
|
||||
'oci.saved': 'Credencial guardada exitosamente!',
|
||||
'oci.updated': 'Credencial actualizada exitosamente!',
|
||||
'oci.deleting': 'Eliminando credencial...',
|
||||
'oci.deleted': 'Credencial eliminada exitosamente!',
|
||||
'oci.fillField': 'Complete',
|
||||
'oci.fillOcidTenancy': 'Complete OCID Tenencia',
|
||||
'oci.fillUserAndFP': 'Complete OCID Usuario y Fingerprint',
|
||||
'oci.pasteToken': 'Pegue el Token de Sesion',
|
||||
'oci.selectKey': 'Seleccione la clave privada (.pem)',
|
||||
'oci.selectSessionKey': 'Seleccione la clave de sesion (.pem)',
|
||||
'oci.changeHint': 'Complete para cambiar. Actual:',
|
||||
'oci.keepKey': 'Deje vacio para mantener la clave actual',
|
||||
'oci.typeCannotChange': '(el tipo no se puede cambiar)',
|
||||
'oci.sshNotConfigured': 'no configurado',
|
||||
|
||||
// ── GenAI Config ──
|
||||
'genai.title': 'Modelos GenAI',
|
||||
'genai.subtitle': 'Configure modelos de inteligencia artificial generativa para el Chat Agent',
|
||||
'genai.configured': 'Modelos Configurados',
|
||||
'genai.model': 'modelo',
|
||||
'genai.models': 'modelos',
|
||||
'genai.noModels': 'No hay modelos configurados.',
|
||||
'genai.newModel': 'Nuevo Modelo GenAI',
|
||||
'genai.editModel': 'Editar Modelo',
|
||||
'genai.configName': 'Nombre de Configuracion',
|
||||
'genai.ociCredential': 'Credencial OCI',
|
||||
'genai.modelSelect': 'Modelo',
|
||||
'genai.genaiRegion': 'Region GenAI',
|
||||
'genai.compartment': 'OCID Compartimiento',
|
||||
'genai.modelOcid': 'OCID del Modelo',
|
||||
'genai.modelOcidHint': 'Pegue el OCID del modelo desde el portal OCI (opcional)',
|
||||
'genai.customOcid': 'Personalizado (usar OCID)',
|
||||
'genai.default': 'Modelo Predeterminado',
|
||||
'genai.setDefault': 'Establecer como Predeterminado',
|
||||
'genai.saveModel': 'Guardar Modelo',
|
||||
'genai.saveChanges': 'Guardar Cambios',
|
||||
'genai.cancel': 'Cancelar',
|
||||
'genai.saving': 'Guardando modelo...',
|
||||
'genai.deleting': 'Eliminando modelo...',
|
||||
'genai.addDesc': 'Agregue modelos personalizados o cree presets con credenciales especificas.',
|
||||
'genai.fillName': 'Complete el nombre de configuracion',
|
||||
'genai.selectOci': 'Seleccione una credencial OCI',
|
||||
'genai.fillOcid': 'Ingrese el OCID del modelo para modelo personalizado',
|
||||
|
||||
// ── MCP Servers ──
|
||||
'mcp.title': 'Servidores MCP',
|
||||
'mcp.subtitle': 'Servidores MCP para integracion de herramientas y ejecucion de tareas',
|
||||
'mcp.registered': 'Servidores Registrados',
|
||||
'mcp.noServers': 'No hay servidores MCP registrados.',
|
||||
'mcp.edit': 'Editar',
|
||||
'mcp.activate': 'Activar',
|
||||
'mcp.deactivate': 'Desactivar',
|
||||
'mcp.active': 'Activo',
|
||||
'mcp.inactive': 'Inactivo',
|
||||
'mcp.delete': 'Eliminar',
|
||||
'mcp.yes': 'Si',
|
||||
'mcp.no': 'No',
|
||||
'mcp.command': 'Comando:',
|
||||
'mcp.url': 'URL:',
|
||||
'mcp.module': 'Modulo:',
|
||||
'mcp.adb': 'ADB:',
|
||||
'mcp.noAdb': 'Ninguno',
|
||||
'mcp.tools': 'Herramientas',
|
||||
'mcp.noTools': 'Sin herramientas registradas',
|
||||
'mcp.discoverTools': 'Descubrir Herramientas',
|
||||
'mcp.discovering': 'Conectando al servidor MCP para descubrir herramientas...',
|
||||
'mcp.newServer': 'Registrar Nuevo Servidor',
|
||||
'mcp.editServer': 'Editar Servidor',
|
||||
'mcp.name': 'Nombre',
|
||||
'mcp.description': 'Descripcion',
|
||||
'mcp.serverType': 'Tipo de Servidor',
|
||||
'mcp.commandLabel': 'Comando',
|
||||
'mcp.commandHint': 'Comando para iniciar el servidor',
|
||||
'mcp.argsLabel': 'Argumentos (JSON)',
|
||||
'mcp.argsHint': 'Arreglo de argumentos pasados al comando',
|
||||
'mcp.urlLabel': 'URL del Servidor',
|
||||
'mcp.urlHint': 'Endpoint SSE del servidor MCP',
|
||||
'mcp.linkAdb': 'Vincular ADB Vector',
|
||||
'mcp.linkAdbHint': 'Opcional — el servidor MCP puede usar esta base de datos como herramienta.',
|
||||
'mcp.saveServer': 'Registrar Servidor',
|
||||
'mcp.saveChanges': 'Guardar Cambios',
|
||||
'mcp.cancel': 'Cancelar',
|
||||
'mcp.saving': 'Registrando servidor...',
|
||||
'mcp.updating': 'Actualizando servidor...',
|
||||
'mcp.fillName': 'Complete el nombre del servidor',
|
||||
'mcp.invalidArgs': 'Los argumentos deben ser un arreglo JSON valido',
|
||||
'mcp.configDesc': 'Configure un nuevo servidor MCP. Los campos se ajustan segun el tipo seleccionado.',
|
||||
|
||||
// ── ADB Config ──
|
||||
'adb.title': 'Conexiones ADB',
|
||||
'adb.subtitle': 'Conexiones a Oracle Autonomous Database via Wallet (mTLS)',
|
||||
'adb.registered': 'Conexiones Registradas',
|
||||
'adb.connection': 'conexion',
|
||||
'adb.connections': 'conexiones',
|
||||
'adb.noConnections': 'No hay conexiones ADB registradas.',
|
||||
'adb.edit': 'Editar',
|
||||
'adb.test': 'Probar',
|
||||
'adb.delete': 'Eliminar',
|
||||
'adb.yes': 'Si',
|
||||
'adb.no': 'No',
|
||||
'adb.newConnection': 'Nueva Conexion',
|
||||
'adb.editConnection': 'Editar Conexion',
|
||||
'adb.connName': 'Nombre de Conexion',
|
||||
'adb.walletZip': 'Wallet ZIP',
|
||||
'adb.walletHint': 'Suba el wallet para extraer DSNs de tnsnames.ora',
|
||||
'adb.walletEditHint': 'Suba un nuevo wallet o deje vacio para mantener el actual',
|
||||
'adb.analyze': 'Analizar',
|
||||
'adb.dsn': 'DSN (Nombre TNS)',
|
||||
'adb.dsnHint': 'Seleccione o escriba el DSN. Ej. myatp_high',
|
||||
'adb.username': 'Usuario',
|
||||
'adb.password': 'Contrasena',
|
||||
'adb.walletPassword': 'Contrasena del Wallet',
|
||||
'adb.genaiConfig': 'Configuracion GenAI (Embeddings)',
|
||||
'adb.genaiConfigHint': 'Credenciales OCI utilizadas para generar embeddings via GenAI.',
|
||||
'adb.noRag': 'Ninguno (sin RAG)',
|
||||
'adb.embeddingModel': 'Modelo de Embedding',
|
||||
'adb.embeddingModelHint': 'Modelo OCI GenAI para generar vectores.',
|
||||
'adb.saveConnection': 'Guardar Conexion',
|
||||
'adb.saveChanges': 'Guardar Cambios',
|
||||
'adb.cancel': 'Cancelar',
|
||||
'adb.saving': 'Guardando conexion...',
|
||||
'adb.deleting': 'Eliminando conexion...',
|
||||
'adb.updateWallet': 'Actualizar Wallet',
|
||||
'adb.adbConnection': 'Conexion ADB',
|
||||
'adb.uploadWallet': 'Subir Wallet',
|
||||
'adb.connDesc': 'Conexion via python-oracledb modo Thin con Wallet (mTLS) a Oracle Autonomous Database.',
|
||||
'adb.selectWallet': 'Seleccione un archivo ZIP de wallet.',
|
||||
'adb.selectConnection': 'Seleccione una conexion ADB.',
|
||||
'adb.fillName': 'Complete el nombre de la conexion',
|
||||
'adb.fillDsn': 'Complete el DSN',
|
||||
'adb.fillUsername': 'Complete el usuario',
|
||||
'adb.fillPassword': 'Complete la contrasena',
|
||||
|
||||
// ── Embeddings ──
|
||||
'emb.title': 'Embeddings',
|
||||
'emb.subtitle': 'Base de conocimiento vectorial — carga, importacion y gestion',
|
||||
'emb.noAdb': 'No hay conexion ADB configurada.',
|
||||
'emb.noAdbHint': 'Configure en ADB Vector.',
|
||||
'emb.noOci': 'No hay credencial OCI configurada.',
|
||||
'emb.noOciHint': 'Configure en Config → OCI.',
|
||||
'emb.existing': 'Embeddings Existentes',
|
||||
'emb.existingDesc': 'Consulte documentos almacenados en las tablas de embedding de ADB.',
|
||||
'emb.adbConnection': 'Conexion ADB',
|
||||
'emb.table': 'Tabla',
|
||||
'emb.load': 'Cargar',
|
||||
'emb.noEmbeddings': 'No se encontraron embeddings.',
|
||||
'emb.total': 'Total: {0} documentos',
|
||||
'emb.cisTitle': 'Recomendaciones CIS',
|
||||
'emb.cisDesc': 'Suba la ultima version del PDF para mantener actualizadas las recomendaciones CIS de Oracle Cloud en la base de datos vectorial.',
|
||||
'emb.pdfFile': 'Archivo PDF',
|
||||
'emb.uploadCis': 'Subir Recomendaciones CIS',
|
||||
'emb.kbTitle': 'Base de Conocimiento',
|
||||
'emb.kbDesc': 'Suba documentos para alimentar la base de conocimiento del equipo. Los archivos seran vectorizados y estaran disponibles para consultas en el chat.',
|
||||
'emb.fileLabel': 'Archivo (.txt, .pdf, .csv, .json, .md)',
|
||||
'emb.uploadFile': 'Subir Archivo',
|
||||
'emb.urlLabel': 'URL (pagina web o PDF)',
|
||||
'emb.importUrl': 'Importar URL',
|
||||
'emb.purgeTitle': 'Purgar y Re-embeber',
|
||||
'emb.purgeDesc': 'Limpie los embeddings de una tabla para liberar espacio o preparar para re-ingestion. Esta accion es irreversible.',
|
||||
'emb.tenancyOptional': 'Tenencia (opcional)',
|
||||
'emb.purge': 'Purgar',
|
||||
'emb.confirm': 'Confirmar',
|
||||
'emb.selectTable': 'Seleccione una tabla.',
|
||||
'emb.selectAdbAndFile': 'Seleccione la conexion ADB y el archivo.',
|
||||
'emb.selectAdbAndUrl': 'Seleccione la conexion ADB e ingrese la URL.',
|
||||
'emb.processing': 'Procesando PDF y generando embeddings...',
|
||||
'emb.uploading': 'Subiendo y generando embeddings...',
|
||||
'emb.fetchingUrl': 'Obteniendo contenido y generando embeddings...',
|
||||
'emb.purging': 'Limpiando embeddings...',
|
||||
|
||||
// ── EmbConsult ──
|
||||
'ec.title': 'Consultar Embeddings',
|
||||
'ec.subtitle': 'Consulte datos almacenados en bases de datos vectoriales',
|
||||
'ec.clear': 'Limpiar',
|
||||
'ec.tableOptional': 'Tabla (opcional)',
|
||||
'ec.allTables': 'Todas las tablas',
|
||||
'ec.emptyTitle': 'Realice una consulta para buscar embeddings.',
|
||||
'ec.emptyHint': 'Ej. "hallazgos para CIS 1.1", "recomendaciones de red", "como corregir MFA"',
|
||||
'ec.searching': 'Consultando bases de datos vectoriales...',
|
||||
'ec.placeholder': 'Escriba su consulta...',
|
||||
'ec.submit': 'Consultar',
|
||||
'ec.sources': 'Fuentes consultadas',
|
||||
'ec.noAnswer': 'Sin respuesta del modelo.',
|
||||
|
||||
// ── Users ──
|
||||
'usr.title': 'Usuarios',
|
||||
'usr.activeCount': 'activo',
|
||||
'usr.activesCount': 'activos',
|
||||
'usr.inactiveCount': 'inactivo',
|
||||
'usr.inactivesCount': 'inactivos',
|
||||
'usr.total': 'total',
|
||||
'usr.newUser': 'Nuevo Usuario',
|
||||
'usr.cancel': 'Cancelar',
|
||||
'usr.createUser': 'Crear Nuevo Usuario',
|
||||
'usr.firstName': 'Nombre',
|
||||
'usr.lastName': 'Apellido',
|
||||
'usr.username': 'Usuario',
|
||||
'usr.email': 'Correo Electronico',
|
||||
'usr.password': 'Contrasena',
|
||||
'usr.role': 'Rol',
|
||||
'usr.creating': 'Creando...',
|
||||
'usr.create': 'Crear Usuario',
|
||||
'usr.noUsers': 'No hay usuarios registrados',
|
||||
'usr.noUsersHint': 'Haga clic en "Nuevo Usuario" para crear el primer usuario.',
|
||||
'usr.name': 'Nombre',
|
||||
'usr.mfa': 'MFA',
|
||||
'usr.status': 'Estado',
|
||||
'usr.lastLogin': 'Ultimo Ingreso',
|
||||
'usr.actions': 'Acciones',
|
||||
'usr.active': 'Activo',
|
||||
'usr.inactive': 'Inactivo',
|
||||
'usr.never': 'Nunca',
|
||||
'usr.confirm': 'Confirmar',
|
||||
'usr.deactivate': 'Desactivar usuario',
|
||||
'usr.created': 'Usuario creado exitosamente!',
|
||||
'usr.roleUpdated': 'Rol actualizado',
|
||||
'usr.deactivated': 'Usuario desactivado',
|
||||
'usr.requiredName': 'Nombre y apellido son requeridos',
|
||||
'usr.requiredUsername': 'El usuario es requerido',
|
||||
'usr.requiredPassword': 'La contrasena es requerida',
|
||||
|
||||
// ── MFA ──
|
||||
'mfa.title': 'Autenticacion MFA (TOTP)',
|
||||
'mfa.subtitle': 'Proteja su cuenta con autenticacion de dos factores via Google Authenticator o Authy.',
|
||||
'mfa.enabled': 'MFA esta habilitado',
|
||||
'mfa.disabled': 'MFA esta deshabilitado',
|
||||
'mfa.enabledDesc': 'Su cuenta esta protegida con autenticacion de dos factores.',
|
||||
'mfa.disabledDesc': 'Habilite MFA para agregar una capa adicional de seguridad.',
|
||||
'mfa.disableHint': 'Para deshabilitar MFA, haga clic en el boton de abajo. Esto reducira la seguridad de su cuenta.',
|
||||
'mfa.disableBtn': 'Deshabilitar MFA',
|
||||
'mfa.confirmDisable': 'Esta seguro de que desea deshabilitar MFA para su cuenta?',
|
||||
'mfa.scanQr': 'Escanee el Codigo QR o copie el secreto',
|
||||
'mfa.openApp': 'Abra su aplicacion de autenticacion (Google Authenticator, Authy, etc.)',
|
||||
'mfa.enterCode': 'Ingrese el codigo de su aplicacion',
|
||||
'mfa.activateMfa': 'Activar MFA',
|
||||
'mfa.generateSecret': 'Generar Secreto',
|
||||
'mfa.generateHint': 'Haga clic en el boton de abajo para generar un secreto TOTP. Necesitara una aplicacion de autenticacion para escanear el Codigo QR.',
|
||||
'mfa.activated': 'MFA activado exitosamente!',
|
||||
'mfa.deactivated': 'MFA desactivado.',
|
||||
'mfa.invalidCode': 'Ingrese un codigo de 6 digitos',
|
||||
'mfa.configure': 'Configurar',
|
||||
|
||||
// ── Audit ──
|
||||
'audit.title': 'Registro de Auditoria',
|
||||
'audit.subtitle': 'Monitoreo de actividad del sistema, configuraciones y sesiones de chat.',
|
||||
'audit.tabAudit': 'Registro de Auditoria',
|
||||
'audit.tabConfig': 'Logs de Configuracion',
|
||||
'audit.tabChat': 'Logs de Chat',
|
||||
'audit.records': 'registros',
|
||||
'audit.refresh': 'Actualizar',
|
||||
'audit.all': 'Todos',
|
||||
'audit.errors': 'Errores',
|
||||
'audit.success': 'Exitoso',
|
||||
'audit.info': 'Info',
|
||||
'audit.loadingLogs': 'Cargando logs...',
|
||||
'audit.noAudit': 'Sin registros de auditoria.',
|
||||
'audit.noConfig': 'Sin logs de configuracion registrados.',
|
||||
'audit.noChat': 'Sin logs de chat registrados.',
|
||||
'audit.date': 'Fecha',
|
||||
'audit.user': 'Usuario',
|
||||
'audit.action': 'Accion',
|
||||
'audit.resource': 'Recurso',
|
||||
'audit.details': 'Detalles',
|
||||
'audit.ip': 'IP',
|
||||
'audit.config': 'Configuracion',
|
||||
'audit.status': 'Estado',
|
||||
'audit.message': 'Mensaje',
|
||||
'audit.session': 'Sesion',
|
||||
'audit.source': 'Fuente',
|
||||
|
||||
// ── Hardcoded fixes ──
|
||||
'common.errorPrefix': 'Error: ',
|
||||
'common.errorSave': 'Error al guardar',
|
||||
'common.errorDelete': 'Error al eliminar',
|
||||
'common.errorUnknown': 'Error desconocido',
|
||||
'common.errorLoad': 'Error al cargar',
|
||||
|
||||
'oci.thTenancy': 'Tenencia',
|
||||
'oci.thType': 'Tipo',
|
||||
'oci.thRegion': 'Region',
|
||||
'oci.thDetails': 'Detalles',
|
||||
'oci.thActions': 'Acciones',
|
||||
|
||||
'genai.thConfig': 'Configuracion',
|
||||
'genai.thModel': 'Modelo',
|
||||
'genai.thOciConfig': 'Configuracion OCI',
|
||||
'genai.thRegion': 'Region',
|
||||
'genai.thActions': 'Acciones',
|
||||
'genai.savedSuccess': 'Modelo guardado exitosamente!',
|
||||
'genai.updatedSuccess': 'Modelo actualizado exitosamente!',
|
||||
'genai.deletedSuccess': 'Modelo eliminado exitosamente!',
|
||||
'genai.editingLabel': 'Editando:',
|
||||
'genai.regionFilled': 'Region y Compartimiento completados desde <strong>{0}</strong>',
|
||||
|
||||
'mcp.savedSuccess': 'Servidor registrado exitosamente!',
|
||||
'mcp.updatedSuccess': 'Servidor actualizado exitosamente!',
|
||||
'mcp.deletingServer': 'Eliminando servidor...',
|
||||
'mcp.deletedSuccess': 'Servidor eliminado exitosamente!',
|
||||
'mcp.discoveredTools': '{0} herramienta(s) descubierta(s), {1} total',
|
||||
'mcp.errorDiscoverTools': 'Error al descubrir herramientas',
|
||||
'mcp.removeToolConfirm': 'Eliminar herramienta "{0}"?',
|
||||
'mcp.editingLabel': 'Editando:',
|
||||
|
||||
'adb.thName': 'Nombre',
|
||||
'adb.thDsn': 'DSN',
|
||||
'adb.thUser': 'Usuario',
|
||||
'adb.thTables': 'Tablas',
|
||||
'adb.thMtls': 'mTLS',
|
||||
'adb.thWallet': 'Wallet',
|
||||
'adb.thEmbed': 'Embed',
|
||||
'adb.thActions': 'Acciones',
|
||||
'adb.thTable': 'Tabla',
|
||||
'adb.thDescription': 'Descripcion',
|
||||
'adb.thActive': 'Activo',
|
||||
'adb.thTableActions': 'Acciones',
|
||||
'adb.walletParsed': 'Wallet analizado! {0} DSN(s): <strong>{1}</strong>. Archivos: {2}',
|
||||
'adb.savedSuccess': 'Conexion guardada!',
|
||||
'adb.updatedSuccess': 'Conexion actualizada!',
|
||||
'adb.walletIncluded': ' Wallet incluido.',
|
||||
'adb.deletedSuccess': 'Conexion eliminada exitosamente!',
|
||||
'adb.walletSending': 'Enviando wallet...',
|
||||
'adb.walletSent': 'Wallet subido! Archivos: {0}',
|
||||
'adb.enterTableName': 'Ingrese el nombre de la tabla.',
|
||||
'adb.tableRegistered': 'Tabla {0} registrada!',
|
||||
'adb.confirmRemoveTable': 'Eliminar esta tabla del registro?',
|
||||
'adb.tableRemoved': 'Tabla eliminada.',
|
||||
'adb.editingLabel': 'Editando:',
|
||||
|
||||
'emb.errorLoadEmb': 'Error al cargar embeddings',
|
||||
'emb.confirmDeleteEmb': 'Eliminar este embedding?',
|
||||
'emb.totalDocs': 'Total: {0} documentos',
|
||||
|
||||
'ec.errorPrefix': 'Error: ',
|
||||
'ec.errorUnknown': 'Error desconocido',
|
||||
'ec.docs': 'documentos',
|
||||
|
||||
'usr.errorLoadUsers': 'Error al cargar usuarios',
|
||||
'usr.errorCreateUser': 'Error al crear usuario',
|
||||
'usr.errorUpdateRole': 'Error al actualizar rol',
|
||||
'usr.errorDeactivate': 'Error al desactivar usuario',
|
||||
'usr.mfaSelfOnly': 'MFA solo puede ser activado por el propio usuario.',
|
||||
'usr.myTimezone': 'Mi Zona Horaria',
|
||||
|
||||
'ws.typeDestroyLabel': 'Escriba <strong>DESTROY</strong> para confirmar:',
|
||||
|
||||
'exp.tempAccess': 'Acceso temporal - ',
|
||||
'exp.optional': 'Opcional',
|
||||
'exp.ipPrompt': 'IP para permitir acceso (CIDR):',
|
||||
'exp.nsgType': 'Tipo',
|
||||
|
||||
// ── Common ──
|
||||
'common.save': 'Guardar',
|
||||
'common.cancel': 'Cancelar',
|
||||
'common.delete': 'Eliminar',
|
||||
'common.edit': 'Editar',
|
||||
'common.create': 'Crear',
|
||||
'common.update': 'Actualizar',
|
||||
'common.close': 'Cerrar',
|
||||
'common.confirm': 'Confirmar',
|
||||
'common.back': 'Volver',
|
||||
'common.search': 'Buscar',
|
||||
'common.refresh': 'Actualizar',
|
||||
'common.download': 'Descargar',
|
||||
'common.upload': 'Subir',
|
||||
'common.copy': 'Copiar',
|
||||
'common.retry': 'Reintentar',
|
||||
'common.send': 'Enviar',
|
||||
'common.clear': 'Limpiar',
|
||||
'common.test': 'Probar',
|
||||
'common.yes': 'Si',
|
||||
'common.no': 'No',
|
||||
'common.loading': 'Cargando...',
|
||||
'common.saving': 'Guardando...',
|
||||
'common.select': 'Seleccione...',
|
||||
'common.noData': 'No se encontraron datos',
|
||||
'common.error': 'Error',
|
||||
'common.success': 'Exitoso',
|
||||
// ── My Settings ──
|
||||
'settings.title': 'Mi Configuracion',
|
||||
'settings.timezone': 'Zona Horaria',
|
||||
'settings.tzDesc': 'Establezca su zona horaria para la visualizacion de fecha y hora.',
|
||||
'settings.tzChanged': 'Zona horaria cambiada exitosamente',
|
||||
'settings.language': 'Idioma',
|
||||
'settings.langDesc': 'Seleccione el idioma de la interfaz.',
|
||||
'settings.changePassword': 'Cambiar Contrasena',
|
||||
'settings.currentPw': 'Contrasena Actual',
|
||||
'settings.newPw': 'Nueva Contrasena',
|
||||
'settings.confirmPw': 'Confirmar Nueva Contrasena',
|
||||
'settings.savePw': 'Cambiar Contrasena',
|
||||
'settings.saving': 'Guardando...',
|
||||
'settings.pwChanged': 'Contrasena cambiada exitosamente',
|
||||
'settings.pwRequired': 'Complete todos los campos',
|
||||
'settings.pwMismatch': 'Las contrasenas no coinciden',
|
||||
'settings.pwMinLength': 'La nueva contrasena debe tener al menos 8 caracteres',
|
||||
'settings.federatedInfo': 'La contrasena y MFA son administrados por Oracle IAM Identity Domain. Configure directamente en el portal OCI.',
|
||||
'settings.mfa': 'Autenticacion Multi-Factor (MFA)',
|
||||
// ── Terminal ──
|
||||
'term.title': 'OCI CLI Terminal',
|
||||
'term.subtitle': 'Ejecute comandos OCI CLI directamente en el navegador',
|
||||
'term.config': 'Configuración',
|
||||
'term.selectConfig': 'Seleccione una configuración OCI',
|
||||
'term.welcome': 'Escriba comandos OCI CLI. Ej: oci iam user list --compartment-id <ocid>',
|
||||
'term.clearHint': 'para limpiar',
|
||||
'term.historyHint': 'para navegar en el historial',
|
||||
'term.executing': 'Ejecutando...',
|
||||
'term.noOutput': 'Comando ejecutado sin output',
|
||||
// ── Oracle IAM ──
|
||||
'iam.title': 'Oracle IAM Identity Domains',
|
||||
'iam.subtitle': 'Integracion OIDC para autenticacion corporativa',
|
||||
'iam.authMode': 'Modo de Autenticacion',
|
||||
'iam.authModeDesc': 'Define como los usuarios se autentican en el sistema.',
|
||||
'iam.modeLocal': 'Local',
|
||||
'iam.modeLocalDesc': 'Usuarios locales con contrasena',
|
||||
'iam.modeOidc': 'Oracle IAM',
|
||||
'iam.modeOidcDesc': 'Solo SSO',
|
||||
'iam.modeBoth': 'Ambos',
|
||||
'iam.modeBothDesc': 'Local + Oracle IAM',
|
||||
'iam.oidcConfig': 'Configuracion OIDC',
|
||||
'iam.testConnection': 'Probar Conexion',
|
||||
'iam.issuerUrl': 'URL del Emisor (Identity Domain)',
|
||||
'iam.clientId': 'Client ID',
|
||||
'iam.clientSecret': 'Client Secret',
|
||||
'iam.redirectUri': 'URI de Redireccion',
|
||||
'iam.groupMapping': 'Mapeo de Grupos',
|
||||
'iam.groupMappingDesc': 'Mapee los grupos de OCI Identity Domain a roles del sistema.',
|
||||
'iam.save': 'Guardar Configuracion',
|
||||
'iam.saving': 'Guardando...',
|
||||
'iam.saved': 'Configuracion guardada exitosamente',
|
||||
'iam.issuerRequired': 'La URL del emisor es requerida',
|
||||
'iam.testOk': 'Conexion OK',
|
||||
'iam.testInvalid': 'Respuesta invalida del endpoint de descubrimiento',
|
||||
'iam.testFail': 'Fallo en la conexion',
|
||||
};
|
||||
|
||||
export default es;
|
||||
35
frontend/src/i18n/index.ts
Normal file
35
frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { create } from 'zustand';
|
||||
import pt from './pt';
|
||||
import en from './en';
|
||||
import es from './es';
|
||||
|
||||
type Lang = 'pt' | 'en' | 'es';
|
||||
|
||||
const LANGS: Record<Lang, Record<string, string>> = { pt, en, es };
|
||||
|
||||
function detectLang(): Lang {
|
||||
const saved = localStorage.getItem('lang') as Lang | null;
|
||||
if (saved && LANGS[saved]) return saved;
|
||||
const nav = navigator.language.toLowerCase();
|
||||
if (nav.startsWith('pt')) return 'pt';
|
||||
if (nav.startsWith('es')) return 'es';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
interface I18nState {
|
||||
lang: Lang;
|
||||
setLang: (lang: Lang) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export const useI18n = create<I18nState>((set, get) => ({
|
||||
lang: detectLang(),
|
||||
setLang: (lang: Lang) => {
|
||||
localStorage.setItem('lang', lang);
|
||||
set({ lang });
|
||||
},
|
||||
t: (key: string) => {
|
||||
const { lang } = get();
|
||||
return LANGS[lang]?.[key] ?? LANGS.pt[key] ?? key;
|
||||
},
|
||||
}));
|
||||
840
frontend/src/i18n/pt.ts
Normal file
840
frontend/src/i18n/pt.ts
Normal file
@@ -0,0 +1,840 @@
|
||||
const pt: Record<string, string> = {
|
||||
// ── Sidebar ──
|
||||
'nav.principal': 'Principal',
|
||||
'nav.admin': 'Administração',
|
||||
'nav.chat': 'Chat Agent',
|
||||
'nav.terraform': 'Terraform',
|
||||
'nav.promptGen': 'Prompt Generator',
|
||||
'nav.workspaces': 'Workspaces',
|
||||
'nav.explorer': 'OCI Explorer',
|
||||
'nav.ociServices': 'OCI Services',
|
||||
'svc.title': 'OCI Services Status',
|
||||
'svc.subtitle': 'Verifique e configure o status dos servicos de seguranca OCI por tenancy',
|
||||
'svc.redetect': 'Re-detect',
|
||||
'svc.checking': 'Verificando servicos OCI...',
|
||||
'svc.save': 'Salvar',
|
||||
'nav.reports': 'Relatórios',
|
||||
'nav.ociCreds': 'Credenciais OCI',
|
||||
'nav.genai': 'GenAI Config',
|
||||
'nav.mcp': 'MCP Servers',
|
||||
'nav.adb': 'ADB Vector',
|
||||
'nav.embeddings': 'Embeddings',
|
||||
'nav.embConsult': 'Consultar Embeddings',
|
||||
'nav.terminal': 'Terminal',
|
||||
'nav.userMgmt': 'Gestão de Usuários',
|
||||
'nav.users': 'Usuários',
|
||||
'nav.mySettings': 'Minhas Configurações',
|
||||
'nav.oracleIam': 'Oracle IAM',
|
||||
'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',
|
||||
|
||||
// ── Login ──
|
||||
'login.subtitle': 'Infrastructure & Security Engineer',
|
||||
'login.userPlaceholder': 'Usuario',
|
||||
'login.passPlaceholder': 'Senha',
|
||||
'login.mfaScanQr': 'Escaneie o QR code no seu app autenticador:',
|
||||
'login.totpPlaceholder': 'Codigo TOTP',
|
||||
'login.loading': 'Entrando...',
|
||||
'login.verify': 'Verificar',
|
||||
'login.submit': 'Entrar',
|
||||
'login.error': 'Erro ao fazer login',
|
||||
'login.or': 'ou',
|
||||
'login.oidcButton': 'Login com Oracle IAM',
|
||||
|
||||
// ── Password Change ──
|
||||
'pw.welcomeTitle': 'Bem-vindo!',
|
||||
'pw.welcomeUser': 'Conectado como',
|
||||
'pw.welcomeMsg': 'Por seguranca, altere sua senha antes de continuar.',
|
||||
'pw.continue': 'Continuar',
|
||||
'pw.changeTitle': 'Alterar Senha',
|
||||
'pw.currentPassword': 'Senha atual',
|
||||
'pw.newPassword': 'Nova senha',
|
||||
'pw.confirmPassword': 'Confirmar nova senha',
|
||||
'pw.strength': 'Forca da senha',
|
||||
'pw.weak': 'Fraca',
|
||||
'pw.medium': 'Media',
|
||||
'pw.strong': 'Forte',
|
||||
'pw.minLength': 'Minimo 8 caracteres',
|
||||
'pw.hasUpper': 'Uma letra maiuscula',
|
||||
'pw.hasLower': 'Uma letra minuscula',
|
||||
'pw.hasNumber': 'Um numero',
|
||||
'pw.hasSpecial': 'Um caractere especial (!@#$...)',
|
||||
'pw.match': 'Senhas coincidem',
|
||||
'pw.sameAsOld': 'A nova senha deve ser diferente da atual',
|
||||
'pw.error': 'Erro ao alterar senha',
|
||||
'pw.saving': 'Salvando...',
|
||||
'pw.changeButton': 'Alterar Senha',
|
||||
'pw.successTitle': 'Senha alterada!',
|
||||
'pw.successMsg': 'Redirecionando...',
|
||||
|
||||
// ── Chat ──
|
||||
'chat.timeNow': 'agora',
|
||||
'chat.today': 'Hoje',
|
||||
'chat.yesterday': 'Ontem',
|
||||
'chat.last7': 'Ultimos 7 dias',
|
||||
'chat.last30': 'Ultimos 30 dias',
|
||||
'chat.older': 'Anteriores',
|
||||
'chat.other': 'Outras',
|
||||
'chat.history': 'Historico',
|
||||
'chat.newChat': 'Nova Conversa',
|
||||
'chat.untitled': 'Sem titulo',
|
||||
'chat.noConversations': 'Nenhuma conversa ainda',
|
||||
'chat.selectModel': 'Selecione um modelo...',
|
||||
'chat.savedConfigs': 'Configs Salvas',
|
||||
'chat.searchModel': 'Buscar modelo...',
|
||||
'chat.noModels': 'Nenhum modelo encontrado',
|
||||
'chat.ociCred': 'Credencial OCI...',
|
||||
'chat.settings': 'Configuracoes',
|
||||
'chat.newChatTitle': 'Nova conversa',
|
||||
'chat.emptyTitle': 'Inicie uma conversa com o agente.',
|
||||
'chat.emptySubtitle': 'Selecione um modelo para comecar.',
|
||||
'chat.thinking': 'Pensando...',
|
||||
'chat.retry': 'Reenviar',
|
||||
'chat.attachFile': 'Anexar arquivo',
|
||||
'chat.placeholder': 'Digite sua mensagem... (Shift+Enter para nova linha)',
|
||||
'chat.send': 'Enviar',
|
||||
'chat.selectOciCred': 'Selecione uma credencial OCI para usar modelos diretos.',
|
||||
'chat.sendError': 'Falha ao enviar mensagem',
|
||||
'chat.requestFailed': 'A requisicao falhou.',
|
||||
'chat.timeout': 'Timeout: a resposta esta demorando muito. Tente novamente.',
|
||||
'chat.toolsAvailable': 'tools disponiveis',
|
||||
'chat.ociCredLabel': 'Credencial OCI',
|
||||
'chat.select': 'Selecionar...',
|
||||
'chat.analyzeFiles': 'Analise os arquivos anexados.',
|
||||
|
||||
// ── Terraform ──
|
||||
'tf.history': 'Historico',
|
||||
'tf.newChat': 'Nova',
|
||||
'tf.noConversations': 'Nenhuma conversa',
|
||||
'tf.searchModel': 'Buscar modelo...',
|
||||
'tf.selectModel': 'Selecione modelo...',
|
||||
'tf.savedConfigs': 'Configs Salvas',
|
||||
'tf.ociConfig': 'OCI Config...',
|
||||
'tf.settings': 'Configuracoes',
|
||||
'tf.newConversation': 'Nova conversa',
|
||||
'tf.emptyTitle': 'Descreva a infraestrutura OCI',
|
||||
'tf.emptySubtitle': 'O agente vai gerar codigo Terraform baseado na sua descricao.',
|
||||
'tf.thinking': 'Processando...',
|
||||
'tf.retry': 'Reenviar',
|
||||
'tf.placeholder': 'Descreva a infraestrutura desejada... (Shift+Enter para nova linha)',
|
||||
'tf.send': 'Enviar',
|
||||
'tf.workspace': 'Workspace',
|
||||
'tf.files': 'Arquivos',
|
||||
'tf.terminal': 'Terminal',
|
||||
'tf.resources': 'Recursos',
|
||||
'tf.plan': 'Plan',
|
||||
'tf.apply': 'Apply',
|
||||
'tf.destroy': 'Destroy',
|
||||
'tf.cancel': 'Cancelar',
|
||||
'tf.download': 'Download ZIP',
|
||||
'tf.createWs': 'Criar Workspace',
|
||||
'tf.noFiles': 'Nenhum arquivo gerado',
|
||||
'tf.timeout': 'Timeout: a geracao esta demorando muito.',
|
||||
'tf.selectOci': 'Selecione uma credencial OCI',
|
||||
'tf.compartment': 'Compartimento',
|
||||
'tf.region': 'Regiao',
|
||||
'tf.rollback': 'Rollback',
|
||||
'tf.copy': 'Copiar',
|
||||
'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',
|
||||
'pg.new': 'Nova',
|
||||
'pg.noConversations': 'Nenhuma conversa',
|
||||
'pg.emptyTitle': 'Descreva a infraestrutura OCI que voce precisa',
|
||||
'pg.emptySubtitle': 'O agente vai gerar um prompt estruturado e otimizado para o Terraform Agent.',
|
||||
'pg.loading': 'Gerando prompt...',
|
||||
'pg.placeholder': 'Descreva a infraestrutura desejada... (Shift+Enter para nova linha)',
|
||||
'pg.copy': 'Copiar',
|
||||
'pg.copied': 'Copiado',
|
||||
'pg.retry': 'Reenviar',
|
||||
'pg.timeout': 'Timeout: a geracao esta demorando muito.',
|
||||
'pg.search': 'Buscar...',
|
||||
'pg.selectModel': 'Selecione modelo...',
|
||||
|
||||
// ── Workspaces ──
|
||||
'ws.title': 'Terraform Workspaces',
|
||||
'ws.all': 'Todos',
|
||||
'ws.active': 'Ativos',
|
||||
'ws.draft': 'Draft',
|
||||
'ws.failed': 'Falha',
|
||||
'ws.destroyed': 'Destruídos',
|
||||
'ws.loading': 'Carregando...',
|
||||
'ws.refresh': 'Atualizar',
|
||||
'ws.noWs': 'Nenhum workspace encontrado',
|
||||
'ws.noWsHint': 'Crie workspaces pelo Terraform Agent',
|
||||
'ws.loadingWs': 'Carregando workspaces...',
|
||||
'ws.open': 'Abrir',
|
||||
'ws.plan': 'Plan',
|
||||
'ws.apply': 'Apply',
|
||||
'ws.destroy': 'Destroy',
|
||||
'ws.delete': 'Excluir',
|
||||
'ws.cancel': 'Cancelar',
|
||||
'ws.hideOutput': 'ocultar',
|
||||
'ws.viewOutput': 'ver output',
|
||||
'ws.confirmDestroy': 'Confirmar Destroy',
|
||||
'ws.confirmDestroyMsg': 'Esta acao ira destruir todos os recursos provisionados por este workspace.',
|
||||
'ws.typeDestroy': 'Digite <strong>DESTROY</strong> para confirmar:',
|
||||
'ws.deleteWs': 'Excluir Workspace',
|
||||
'ws.deleteWsMsg': 'Deseja excluir este workspace permanentemente? Arquivos locais serao removidos.',
|
||||
'ws.noDate': 'Sem data',
|
||||
|
||||
// ── Explorer ──
|
||||
'exp.title': 'OCI Explorer',
|
||||
'exp.subtitle': 'Explore recursos da sua infraestrutura Oracle Cloud',
|
||||
'exp.search': 'Buscar recurso...',
|
||||
'exp.refresh': 'Atualizar',
|
||||
'exp.loading': 'Carregando...',
|
||||
'exp.noResources': 'Nenhum recurso encontrado',
|
||||
'exp.selectResource': 'Selecione um tipo de recurso',
|
||||
'exp.compartment': 'Compartimento',
|
||||
'exp.region': 'Regiao',
|
||||
'exp.allRegions': 'Todas as regioes',
|
||||
'exp.start': 'Start',
|
||||
'exp.stop': 'Stop',
|
||||
'exp.details': 'Detalhes',
|
||||
'exp.total': 'Total',
|
||||
'exp.network': 'Rede',
|
||||
'exp.observability': 'Observabilidade',
|
||||
'exp.security': 'Seguranca',
|
||||
'exp.selectRegions': 'Selecionar regioes',
|
||||
'exp.nRegions': '{0} regioes',
|
||||
'exp.all': 'Todas',
|
||||
'exp.none': 'Nenhuma',
|
||||
'exp.noCompartment': 'Nenhum compartment encontrado',
|
||||
'exp.selectConfig': 'Selecione uma config OCI',
|
||||
'exp.loadingResources': 'Carregando recursos...',
|
||||
'exp.clearFilter': 'Tente limpar o filtro de busca',
|
||||
'exp.resources': 'recursos',
|
||||
'exp.updating': 'atualizando...',
|
||||
'exp.regions': 'regiao(oes)',
|
||||
'exp.filter': 'Filtrar...',
|
||||
'exp.startAction': 'Iniciar',
|
||||
'exp.stopAction': 'Parar',
|
||||
'exp.updatingState': 'Atualizando...',
|
||||
'exp.createdAt': 'Criado em',
|
||||
'exp.public': 'Publico',
|
||||
'exp.yesPublic': 'Sim',
|
||||
'exp.noPrivate': 'Nao (privada)',
|
||||
'exp.freeTier': 'Free Tier',
|
||||
'exp.yes': 'Sim',
|
||||
'exp.no': 'Nao',
|
||||
'exp.noRestrictions': 'Sem restricoes (aberto)',
|
||||
'exp.updateNetworkAccess': 'Atualizar Acesso de Rede',
|
||||
'exp.accessUpdated': 'Acesso atualizado: ',
|
||||
'exp.objects': 'Objetos',
|
||||
'exp.size': 'Tamanho',
|
||||
'exp.visibility': 'Visibilidade',
|
||||
'exp.private': 'Privado',
|
||||
'exp.rules': 'Regras',
|
||||
'exp.routes': 'Rotas',
|
||||
'exp.hideRules': 'Ocultar regras',
|
||||
'exp.viewRules': 'Ver regras',
|
||||
'exp.add': 'Adicionar',
|
||||
'exp.direction': 'Direcao',
|
||||
'exp.protocol': 'Protocolo',
|
||||
'exp.originDest': 'Origem/Destino',
|
||||
'exp.ports': 'Portas',
|
||||
'exp.description': 'Descricao',
|
||||
'exp.portMin': 'Porta Min',
|
||||
'exp.portMax': 'Porta Max',
|
||||
'exp.myIp': 'Meu IP',
|
||||
'exp.save': 'Salvar',
|
||||
'exp.cancel': 'Cancelar',
|
||||
'exp.ruleAdded': 'Regra adicionada.',
|
||||
'exp.enterCidr': 'Informe o CIDR.',
|
||||
'exp.removeRule': 'Remover esta regra?',
|
||||
'exp.removeNsgRule': 'Remover esta regra do NSG?',
|
||||
'exp.ipDetected': 'IP detectado: {0} — ajuste porta e clique Salvar',
|
||||
'exp.ipDetectFailed': 'Nao foi possivel detectar o IP.',
|
||||
'exp.destination': 'Destino',
|
||||
'exp.destType': 'Tipo Destino',
|
||||
'exp.version': 'Versao',
|
||||
'exp.ha': 'HA',
|
||||
|
||||
// ── Reports ──
|
||||
'rpt.title': 'CIS Reports',
|
||||
'rpt.subtitle': 'Execute scans de conformidade CIS OCI Benchmark',
|
||||
'rpt.startScan': 'Iniciar Scan',
|
||||
'rpt.running': 'Executando...',
|
||||
'rpt.cancel': 'Cancelar',
|
||||
'rpt.tenancy': 'Tenancy',
|
||||
'rpt.level': 'Level',
|
||||
'rpt.region': 'Regiao',
|
||||
'rpt.allRegions': 'Todas as regioes',
|
||||
'rpt.noReports': 'Nenhum relatorio encontrado',
|
||||
'rpt.noReportsHint': 'Execute um scan para gerar relatorios de conformidade.',
|
||||
'rpt.progress': 'Progresso',
|
||||
'rpt.completed': 'Concluido',
|
||||
'rpt.failed': 'Falhou',
|
||||
'rpt.viewReport': 'Ver Relatorio',
|
||||
'rpt.download': 'Download',
|
||||
'rpt.delete': 'Excluir',
|
||||
'rpt.summary': 'Resumo',
|
||||
'rpt.passed': 'Passou',
|
||||
'rpt.findings': 'Findings',
|
||||
'rpt.recommendations': 'Recomendacoes',
|
||||
'rpt.selectTenancy': 'Selecione uma tenancy',
|
||||
'rpt.selectCredential': 'Selecione uma credencial',
|
||||
'rpt.loadingReports': 'Carregando relatorios...',
|
||||
'rpt.refresh': 'Atualizar',
|
||||
'rpt.pageTitle': 'Relatorios CIS',
|
||||
'rpt.pageSubtitle': 'CIS Benchmark 3.0 — Compliance Scan & Dashboard',
|
||||
'rpt.runScanCis': 'Executar Scan CIS',
|
||||
'rpt.inExecution': 'Em execucao',
|
||||
'rpt.configParams': 'Configure os parametros e execute o scan de compliance CIS.',
|
||||
'rpt.ociCredential': 'Credencial OCI',
|
||||
'rpt.selectOciCred': 'Selecione a credencial OCI...',
|
||||
'rpt.cisLevel': 'CIS Level',
|
||||
'rpt.level1Desc': 'Verificacoes essenciais de seguranca',
|
||||
'rpt.level2Desc': 'Verificacoes completas (inclui Level 1)',
|
||||
'rpt.regions': 'Regioes',
|
||||
'rpt.leaveEmpty': 'Deixe vazio para escanear todas as regioes subscritas',
|
||||
'rpt.executeCompliance': 'Executar CIS Compliance',
|
||||
'rpt.scanProgress': 'Progresso do Scan',
|
||||
'rpt.hideLog': 'Ocultar log',
|
||||
'rpt.showLog': 'Ver log completo',
|
||||
'rpt.lines': 'linhas',
|
||||
'rpt.cancelScan': 'Cancelar Scan',
|
||||
'rpt.selectReport': 'Selecionar Relatorio',
|
||||
'rpt.selectCompletedReport': 'Selecione um relatorio concluido...',
|
||||
'rpt.searchTenancy': 'Buscar tenancy...',
|
||||
'rpt.searchByTenancy': 'Buscar por tenancy...',
|
||||
'rpt.noCredentials': 'Nenhuma credencial encontrada',
|
||||
'rpt.allRegionsDefault': 'Todas as regioes (padrao)',
|
||||
'rpt.searchRegion': 'Buscar regiao...',
|
||||
'rpt.loadingDashboard': 'Carregando dashboard...',
|
||||
'rpt.complianceScore': 'Score de Compliance',
|
||||
'rpt.compliant': 'Conforme',
|
||||
'rpt.nonCompliant': 'Nao Conforme',
|
||||
'rpt.ofTotal': 'do total',
|
||||
'rpt.compliantItems': 'itens conformes',
|
||||
'rpt.totalFindings': 'achados totais',
|
||||
'rpt.evaluated': 'Avaliados',
|
||||
'rpt.controlsVerified': 'Controles verificados',
|
||||
'rpt.regionsCount': 'regiao(oes)',
|
||||
'rpt.complianceDist': 'Distribuicao de Compliance',
|
||||
'rpt.findingsBySection': 'Achados por Secao',
|
||||
'rpt.generatedAt': 'Gerado em',
|
||||
'rpt.htmlReport': 'Relatorio HTML',
|
||||
'rpt.files': 'Arquivos',
|
||||
'rpt.inSections': 'secoes',
|
||||
'rpt.fullEmbedding': 'Embedding Completo',
|
||||
'rpt.noActiveTables': 'Sem tabelas ativas',
|
||||
'rpt.executionHistory': 'Historico de Execucoes',
|
||||
'rpt.reports': 'relatorio(s)',
|
||||
'rpt.loading': 'Carregando...',
|
||||
'rpt.firstScanHint': 'Execute o primeiro scan CIS acima.',
|
||||
'rpt.selected': 'Selecionado',
|
||||
'rpt.deleteConfirm': 'Excluir este relatorio?',
|
||||
'rpt.deleteFailed': 'Falha ao excluir',
|
||||
'rpt.selectOciCredError': 'Selecione uma credencial OCI',
|
||||
'rpt.alreadyRunning': 'Ja existe um scan em execucao',
|
||||
'rpt.errorLoadReports': 'Erro ao carregar relatorios',
|
||||
'rpt.errorStartScan': 'Erro ao iniciar scan',
|
||||
'rpt.selectAdb': 'Selecione uma conexao ADB.',
|
||||
'rpt.selectTable': 'Selecione uma tabela.',
|
||||
'rpt.errorEmbedding': 'Erro ao gerar embedding',
|
||||
'rpt.cancelled': 'Cancelado',
|
||||
'rpt.completedAt': 'Concluido:',
|
||||
'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',
|
||||
'oci.subtitle': 'Gerencie credenciais Oracle Cloud Infrastructure',
|
||||
'oci.registered': 'Credenciais Registradas',
|
||||
'oci.credential': 'credencial',
|
||||
'oci.credentials': 'credenciais',
|
||||
'oci.noCredentials': 'Nenhuma credencial registrada.',
|
||||
'oci.edit': 'Editar',
|
||||
'oci.test': 'Testar',
|
||||
'oci.delete': 'Excluir',
|
||||
'oci.yes': 'Sim',
|
||||
'oci.no': 'Nao',
|
||||
'oci.newCredential': 'Nova Credencial',
|
||||
'oci.editCredential': 'Editar Credencial',
|
||||
'oci.addDesc': 'Adicione as credenciais da sua conta OCI para executar reports e acessar servicos.',
|
||||
'oci.editing': 'Editando:',
|
||||
'oci.tenancyName': 'Tenancy Name',
|
||||
'oci.ocidTenancy': 'OCID Tenancy',
|
||||
'oci.region': 'Region',
|
||||
'oci.selectRegion': 'Selecione a regiao...',
|
||||
'oci.searchRegion': 'Buscar regiao...',
|
||||
'oci.noRegion': 'Nenhuma regiao encontrada',
|
||||
'oci.compartment': 'Compartment OCID',
|
||||
'oci.ocidUser': 'OCID User',
|
||||
'oci.fingerprint': 'Fingerprint',
|
||||
'oci.sessionToken': 'Session Token',
|
||||
'oci.sessionTokenHint': 'Cole o conteudo do arquivo token gerado por',
|
||||
'oci.sessionKeyLabel': 'Chave de Sessao (.pem)',
|
||||
'oci.privateKey': 'Private Key (.pem)',
|
||||
'oci.publicKey': 'Public Key (.pem)',
|
||||
'oci.keyPassphrase': 'Key Passphrase',
|
||||
'oci.keyPassphraseHint': 'Apenas se a chave privada for protegida por senha',
|
||||
'oci.sshPublicKey': 'SSH Public Key',
|
||||
'oci.sshPublicKeyHint': 'Chave SSH para acesso a instancias compute (ex: ssh-rsa AAAA...)',
|
||||
'oci.saveCredential': 'Salvar Credencial',
|
||||
'oci.saveChanges': 'Salvar Alteracoes',
|
||||
'oci.cancel': 'Cancelar',
|
||||
'oci.saving': 'Salvando credencial...',
|
||||
'oci.saved': 'Credencial salva com sucesso!',
|
||||
'oci.updated': 'Credencial atualizada com sucesso!',
|
||||
'oci.deleting': 'Excluindo credencial...',
|
||||
'oci.deleted': 'Credencial excluida com sucesso!',
|
||||
'oci.fillField': 'Preencha o',
|
||||
'oci.fillOcidTenancy': 'Preencha o OCID Tenancy',
|
||||
'oci.fillUserAndFP': 'Preencha OCID User e Fingerprint',
|
||||
'oci.pasteToken': 'Cole o Session Token',
|
||||
'oci.selectKey': 'Selecione a chave privada (.pem)',
|
||||
'oci.selectSessionKey': 'Selecione a chave de sessao (.pem)',
|
||||
'oci.changeHint': 'Preencha para alterar. Atual:',
|
||||
'oci.keepKey': 'Deixe vazio para manter a chave atual',
|
||||
'oci.typeCannotChange': '(tipo nao pode ser alterado)',
|
||||
'oci.sshNotConfigured': 'nao configurada',
|
||||
|
||||
// ── GenAI Config ──
|
||||
'genai.title': 'Modelos GenAI',
|
||||
'genai.subtitle': 'Configure modelos de IA generativa para o Chat Agent',
|
||||
'genai.configured': 'Modelos Configurados',
|
||||
'genai.model': 'modelo',
|
||||
'genai.models': 'modelos',
|
||||
'genai.noModels': 'Nenhum modelo configurado.',
|
||||
'genai.newModel': 'Novo Modelo GenAI',
|
||||
'genai.editModel': 'Editar Modelo',
|
||||
'genai.configName': 'Nome da Config',
|
||||
'genai.ociCredential': 'Credencial OCI',
|
||||
'genai.modelSelect': 'Modelo',
|
||||
'genai.genaiRegion': 'Regiao GenAI',
|
||||
'genai.compartment': 'Compartment OCID',
|
||||
'genai.modelOcid': 'Model OCID',
|
||||
'genai.modelOcidHint': 'Cole o OCID do modelo do portal OCI (opcional)',
|
||||
'genai.customOcid': 'Personalizado (usar OCID)',
|
||||
'genai.default': 'Modelo Padrao',
|
||||
'genai.setDefault': 'Definir como Padrao',
|
||||
'genai.saveModel': 'Salvar Modelo',
|
||||
'genai.saveChanges': 'Salvar Alteracoes',
|
||||
'genai.cancel': 'Cancelar',
|
||||
'genai.saving': 'Salvando modelo...',
|
||||
'genai.deleting': 'Excluindo modelo...',
|
||||
'genai.addDesc': 'Adicione modelos personalizados ou crie presets com credenciais especificas.',
|
||||
'genai.fillName': 'Preencha o nome da config',
|
||||
'genai.selectOci': 'Selecione uma credencial OCI',
|
||||
'genai.fillOcid': 'Informe o Model OCID para modelo personalizado',
|
||||
|
||||
// ── MCP Servers ──
|
||||
'mcp.title': 'MCP Servers',
|
||||
'mcp.subtitle': 'Servidores MCP para integracao com ferramentas e execucao de tarefas',
|
||||
'mcp.registered': 'Servidores Registrados',
|
||||
'mcp.noServers': 'Nenhum MCP server registrado.',
|
||||
'mcp.edit': 'Editar',
|
||||
'mcp.activate': 'Ativar',
|
||||
'mcp.deactivate': 'Desativar',
|
||||
'mcp.active': 'Ativo',
|
||||
'mcp.inactive': 'Inativo',
|
||||
'mcp.delete': 'Excluir',
|
||||
'mcp.yes': 'Sim',
|
||||
'mcp.no': 'Nao',
|
||||
'mcp.command': 'Comando:',
|
||||
'mcp.url': 'URL:',
|
||||
'mcp.module': 'Modulo:',
|
||||
'mcp.adb': 'ADB:',
|
||||
'mcp.noAdb': 'Nenhum',
|
||||
'mcp.tools': 'Tools',
|
||||
'mcp.noTools': 'Nenhuma tool registrada',
|
||||
'mcp.discoverTools': 'Descobrir Tools',
|
||||
'mcp.discovering': 'Conectando ao MCP server para descobrir tools...',
|
||||
'mcp.newServer': 'Registrar Novo Server',
|
||||
'mcp.editServer': 'Editar Server',
|
||||
'mcp.name': 'Nome',
|
||||
'mcp.description': 'Descricao',
|
||||
'mcp.serverType': 'Tipo de Servidor',
|
||||
'mcp.commandLabel': 'Comando',
|
||||
'mcp.commandHint': 'Comando para iniciar o servidor',
|
||||
'mcp.argsLabel': 'Argumentos (JSON)',
|
||||
'mcp.argsHint': 'Array de argumentos passados ao comando',
|
||||
'mcp.urlLabel': 'URL do Servidor',
|
||||
'mcp.urlHint': 'Endpoint SSE do servidor MCP',
|
||||
'mcp.linkAdb': 'Vincular ADB Vector',
|
||||
'mcp.linkAdbHint': 'Opcional — o MCP server podera usar este banco como ferramenta.',
|
||||
'mcp.saveServer': 'Registrar Server',
|
||||
'mcp.saveChanges': 'Salvar Alteracoes',
|
||||
'mcp.cancel': 'Cancelar',
|
||||
'mcp.saving': 'Registrando servidor...',
|
||||
'mcp.updating': 'Atualizando servidor...',
|
||||
'mcp.fillName': 'Preencha o nome do server',
|
||||
'mcp.invalidArgs': 'Argumentos deve ser um JSON array valido',
|
||||
'mcp.configDesc': 'Configure um novo servidor MCP. Os campos se ajustam ao tipo selecionado.',
|
||||
|
||||
// ── ADB Config ──
|
||||
'adb.title': 'ADB Connections',
|
||||
'adb.subtitle': 'Conexoes com Oracle Autonomous Database via Wallet (mTLS)',
|
||||
'adb.registered': 'Conexoes Registradas',
|
||||
'adb.connection': 'conexao',
|
||||
'adb.connections': 'conexoes',
|
||||
'adb.noConnections': 'Nenhuma conexao ADB registrada.',
|
||||
'adb.edit': 'Editar',
|
||||
'adb.test': 'Testar',
|
||||
'adb.delete': 'Excluir',
|
||||
'adb.yes': 'Sim',
|
||||
'adb.no': 'Nao',
|
||||
'adb.newConnection': 'Nova Conexao',
|
||||
'adb.editConnection': 'Editar Conexao',
|
||||
'adb.connName': 'Nome da Conexao',
|
||||
'adb.walletZip': 'Wallet ZIP',
|
||||
'adb.walletHint': 'Envie o wallet para extrair os DSNs do tnsnames.ora',
|
||||
'adb.walletEditHint': 'Envie novo wallet ou deixe vazio para manter o atual',
|
||||
'adb.analyze': 'Analisar',
|
||||
'adb.dsn': 'DSN (TNS Name)',
|
||||
'adb.dsnHint': 'Selecione ou digite o DSN. Ex: myatp_high',
|
||||
'adb.username': 'Username',
|
||||
'adb.password': 'Password',
|
||||
'adb.walletPassword': 'Wallet Password',
|
||||
'adb.genaiConfig': 'Config GenAI (Embeddings)',
|
||||
'adb.genaiConfigHint': 'Credenciais OCI usadas para gerar embeddings via GenAI.',
|
||||
'adb.noRag': 'Nenhum (sem RAG)',
|
||||
'adb.embeddingModel': 'Modelo de Embedding',
|
||||
'adb.embeddingModelHint': 'Modelo OCI GenAI para gerar vetores.',
|
||||
'adb.saveConnection': 'Salvar Conexao',
|
||||
'adb.saveChanges': 'Salvar Alteracoes',
|
||||
'adb.cancel': 'Cancelar',
|
||||
'adb.saving': 'Salvando conexao...',
|
||||
'adb.deleting': 'Excluindo conexao...',
|
||||
'adb.updateWallet': 'Atualizar Wallet',
|
||||
'adb.adbConnection': 'Conexao ADB',
|
||||
'adb.uploadWallet': 'Upload Wallet',
|
||||
'adb.connDesc': 'Conexao via python-oracledb Thin mode com Wallet (mTLS) para Oracle Autonomous Database.',
|
||||
'adb.selectWallet': 'Selecione um arquivo wallet ZIP.',
|
||||
'adb.selectConnection': 'Selecione uma conexao ADB.',
|
||||
'adb.fillName': 'Preencha o nome da conexao',
|
||||
'adb.fillDsn': 'Preencha o DSN',
|
||||
'adb.fillUsername': 'Preencha o username',
|
||||
'adb.fillPassword': 'Preencha a senha',
|
||||
|
||||
// ── Embeddings ──
|
||||
'emb.title': 'Embeddings',
|
||||
'emb.subtitle': 'Base de conhecimento vetorial — upload, importacao e gerenciamento',
|
||||
'emb.noAdb': 'Nenhuma conexao ADB configurada.',
|
||||
'emb.noAdbHint': 'Configure em ADB Vector.',
|
||||
'emb.noOci': 'Nenhuma credencial OCI configurada.',
|
||||
'emb.noOciHint': 'Configure em Config → OCI.',
|
||||
'emb.existing': 'Embeddings Existentes',
|
||||
'emb.existingDesc': 'Consulte os documentos armazenados nas tabelas de embeddings do ADB.',
|
||||
'emb.adbConnection': 'Conexao ADB',
|
||||
'emb.table': 'Tabela',
|
||||
'emb.load': 'Carregar',
|
||||
'emb.noEmbeddings': 'Nenhum embedding encontrado.',
|
||||
'emb.total': 'Total: {0} documentos',
|
||||
'emb.cisTitle': 'CIS Recommendations',
|
||||
'emb.cisDesc': 'Envie a versao mais recente do PDF para manter as recomendacoes CIS Oracle Cloud atualizadas na base vetorial.',
|
||||
'emb.pdfFile': 'Arquivo PDF',
|
||||
'emb.uploadCis': 'Upload CIS Recommendations',
|
||||
'emb.kbTitle': 'Base de Conhecimento',
|
||||
'emb.kbDesc': 'Envie documentos para alimentar a base de conhecimento da equipe. Os arquivos serao vetorizados e disponibilizados para consulta via chat.',
|
||||
'emb.fileLabel': 'Arquivo (.txt, .pdf, .csv, .json, .md)',
|
||||
'emb.uploadFile': 'Upload Arquivo',
|
||||
'emb.urlLabel': 'URL (pagina web ou PDF)',
|
||||
'emb.importUrl': 'Importar URL',
|
||||
'emb.purgeTitle': 'Purge & Re-embed',
|
||||
'emb.purgeDesc': 'Limpe os embeddings de uma tabela para liberar espaco ou preparar para re-ingestao. Esta acao e irreversivel.',
|
||||
'emb.tenancyOptional': 'Tenancy (opcional)',
|
||||
'emb.purge': 'Purge',
|
||||
'emb.confirm': 'Confirmar',
|
||||
'emb.selectTable': 'Selecione uma tabela.',
|
||||
'emb.selectAdbAndFile': 'Selecione conexao ADB e arquivo.',
|
||||
'emb.selectAdbAndUrl': 'Selecione conexao ADB e informe a URL.',
|
||||
'emb.processing': 'Processando PDF e gerando embeddings...',
|
||||
'emb.uploading': 'Enviando e gerando embeddings...',
|
||||
'emb.fetchingUrl': 'Buscando conteudo e gerando embeddings...',
|
||||
'emb.purging': 'Limpando embeddings...',
|
||||
|
||||
// ── EmbConsult ──
|
||||
'ec.title': 'Consultar Embeddings',
|
||||
'ec.subtitle': 'Pergunte sobre os dados armazenados nas bases vetoriais',
|
||||
'ec.clear': 'Limpar',
|
||||
'ec.tableOptional': 'Tabela (opcional)',
|
||||
'ec.allTables': 'Todas as tabelas',
|
||||
'ec.emptyTitle': 'Faca uma consulta para pesquisar nos embeddings.',
|
||||
'ec.emptyHint': 'Ex: "findings para CIS 1.1", "recomendacoes de networking", "como corrigir MFA"',
|
||||
'ec.searching': 'Consultando bases vetoriais...',
|
||||
'ec.placeholder': 'Digite sua consulta...',
|
||||
'ec.submit': 'Consultar',
|
||||
'ec.sources': 'Fontes consultadas',
|
||||
'ec.noAnswer': 'Sem resposta do modelo.',
|
||||
|
||||
// ── Users ──
|
||||
'usr.title': 'Usuarios',
|
||||
'usr.activeCount': 'ativo',
|
||||
'usr.activesCount': 'ativos',
|
||||
'usr.inactiveCount': 'inativo',
|
||||
'usr.inactivesCount': 'inativos',
|
||||
'usr.total': 'total',
|
||||
'usr.newUser': 'Novo Usuario',
|
||||
'usr.cancel': 'Cancelar',
|
||||
'usr.createUser': 'Criar Novo Usuario',
|
||||
'usr.firstName': 'Nome',
|
||||
'usr.lastName': 'Sobrenome',
|
||||
'usr.username': 'Usuario (login)',
|
||||
'usr.email': 'Email',
|
||||
'usr.password': 'Senha',
|
||||
'usr.role': 'Role',
|
||||
'usr.creating': 'Criando...',
|
||||
'usr.create': 'Criar Usuario',
|
||||
'usr.noUsers': 'Nenhum usuario cadastrado',
|
||||
'usr.noUsersHint': 'Clique em "Novo Usuario" para criar o primeiro usuario.',
|
||||
'usr.name': 'Nome',
|
||||
'usr.mfa': 'MFA',
|
||||
'usr.status': 'Status',
|
||||
'usr.lastLogin': 'Ultimo Login',
|
||||
'usr.actions': 'Acoes',
|
||||
'usr.active': 'Ativo',
|
||||
'usr.inactive': 'Inativo',
|
||||
'usr.never': 'Nunca',
|
||||
'usr.confirm': 'Confirmar',
|
||||
'usr.deactivate': 'Desativar usuario',
|
||||
'usr.created': 'Usuario criado com sucesso!',
|
||||
'usr.roleUpdated': 'Role atualizada',
|
||||
'usr.deactivated': 'Usuario desativado',
|
||||
'usr.requiredName': 'Nome e sobrenome sao obrigatorios',
|
||||
'usr.requiredUsername': 'Usuario (login) e obrigatorio',
|
||||
'usr.requiredPassword': 'Senha e obrigatoria',
|
||||
|
||||
// ── MFA ──
|
||||
'mfa.title': 'Autenticacao MFA (TOTP)',
|
||||
'mfa.subtitle': 'Proteja sua conta com autenticacao em dois fatores via Google Authenticator ou Authy.',
|
||||
'mfa.enabled': 'MFA esta ativado',
|
||||
'mfa.disabled': 'MFA esta desativado',
|
||||
'mfa.enabledDesc': 'Sua conta esta protegida com autenticacao de dois fatores.',
|
||||
'mfa.disabledDesc': 'Ative o MFA para adicionar uma camada extra de seguranca.',
|
||||
'mfa.disableHint': 'Para desativar o MFA, clique no botao abaixo. Isso reduzira a seguranca da sua conta.',
|
||||
'mfa.disableBtn': 'Desativar MFA',
|
||||
'mfa.confirmDisable': 'Deseja realmente desativar o MFA da sua conta?',
|
||||
'mfa.scanQr': 'Escaneie o QR Code ou copie o secret',
|
||||
'mfa.openApp': 'Abra seu app autenticador (Google Authenticator, Authy, etc.)',
|
||||
'mfa.enterCode': 'Insira o codigo gerado pelo app',
|
||||
'mfa.activateMfa': 'Ativar MFA',
|
||||
'mfa.generateSecret': 'Gerar Secret',
|
||||
'mfa.generateHint': 'Clique no botao abaixo para gerar um secret TOTP. Voce precisara de um app autenticador para escanear o QR Code.',
|
||||
'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',
|
||||
'audit.subtitle': 'Monitoramento de atividades do sistema, configuracoes e sessoes de chat.',
|
||||
'audit.tabAudit': 'Audit Log',
|
||||
'audit.tabConfig': 'Config Logs',
|
||||
'audit.tabChat': 'Chat Logs',
|
||||
'audit.records': 'registros',
|
||||
'audit.refresh': 'Atualizar',
|
||||
'audit.all': 'Todos',
|
||||
'audit.errors': 'Erros',
|
||||
'audit.success': 'Sucesso',
|
||||
'audit.info': 'Info',
|
||||
'audit.loadingLogs': 'Carregando logs...',
|
||||
'audit.noAudit': 'Nenhum registro de auditoria.',
|
||||
'audit.noConfig': 'Nenhum log de configuracao registrado.',
|
||||
'audit.noChat': 'Nenhum log de chat registrado.',
|
||||
'audit.date': 'Data',
|
||||
'audit.user': 'Usuario',
|
||||
'audit.action': 'Acao',
|
||||
'audit.resource': 'Recurso',
|
||||
'audit.details': 'Detalhes',
|
||||
'audit.ip': 'IP',
|
||||
'audit.config': 'Config',
|
||||
'audit.status': 'Status',
|
||||
'audit.message': 'Mensagem',
|
||||
'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.',
|
||||
'usr.myTimezone': 'Meu Timezone',
|
||||
|
||||
'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',
|
||||
'common.delete': 'Excluir',
|
||||
'common.edit': 'Editar',
|
||||
'common.create': 'Criar',
|
||||
'common.update': 'Atualizar',
|
||||
'common.close': 'Fechar',
|
||||
'common.confirm': 'Confirmar',
|
||||
'common.back': 'Voltar',
|
||||
'common.search': 'Buscar',
|
||||
'common.refresh': 'Atualizar',
|
||||
'common.download': 'Download',
|
||||
'common.upload': 'Upload',
|
||||
'common.copy': 'Copiar',
|
||||
'common.retry': 'Tentar novamente',
|
||||
'common.send': 'Enviar',
|
||||
'common.clear': 'Limpar',
|
||||
'common.test': 'Testar',
|
||||
'common.yes': 'Sim',
|
||||
'common.no': 'Nao',
|
||||
'common.loading': 'Carregando...',
|
||||
'common.saving': 'Salvando...',
|
||||
'common.select': 'Selecione...',
|
||||
'common.noData': 'Nenhum dado encontrado',
|
||||
'common.error': 'Erro',
|
||||
'common.success': 'Sucesso',
|
||||
// ── My Settings ──
|
||||
'settings.title': 'Minhas Configurações',
|
||||
'settings.timezone': 'Timezone',
|
||||
'settings.tzDesc': 'Define o fuso horário para exibição de datas e horários.',
|
||||
'settings.tzChanged': 'Timezone alterado com sucesso',
|
||||
'settings.language': 'Idioma',
|
||||
'settings.langDesc': 'Selecione o idioma da interface.',
|
||||
'settings.changePassword': 'Alterar Senha',
|
||||
'settings.currentPw': 'Senha Atual',
|
||||
'settings.newPw': 'Nova Senha',
|
||||
'settings.confirmPw': 'Confirmar Nova Senha',
|
||||
'settings.savePw': 'Alterar Senha',
|
||||
'settings.saving': 'Salvando...',
|
||||
'settings.pwChanged': 'Senha alterada com sucesso',
|
||||
'settings.pwRequired': 'Preencha todos os campos',
|
||||
'settings.pwMismatch': 'As senhas não conferem',
|
||||
'settings.pwMinLength': 'A nova senha deve ter pelo menos 8 caracteres',
|
||||
'settings.federatedInfo': 'Senha e MFA são gerenciados pelo Oracle IAM Identity Domain. Configure diretamente no portal OCI.',
|
||||
'settings.mfa': 'Autenticação Multi-Fator (MFA)',
|
||||
// ── Terminal ──
|
||||
'term.title': 'OCI CLI Terminal',
|
||||
'term.subtitle': 'Execute comandos OCI CLI diretamente no navegador',
|
||||
'term.config': 'Configuração',
|
||||
'term.selectConfig': 'Selecione uma configuração OCI',
|
||||
'term.welcome': 'Digite comandos OCI CLI. Ex: oci iam user list --compartment-id <ocid>',
|
||||
'term.clearHint': 'para limpar',
|
||||
'term.historyHint': 'para navegar no histórico',
|
||||
'term.executing': 'Executando...',
|
||||
'term.noOutput': 'Comando executado sem output',
|
||||
// ── Oracle IAM ──
|
||||
'iam.title': 'Oracle IAM Identity Domains',
|
||||
'iam.subtitle': 'Integração OIDC para autenticação corporativa',
|
||||
'iam.authMode': 'Modo de Autenticação',
|
||||
'iam.authModeDesc': 'Define como os usuários se autenticam no sistema.',
|
||||
'iam.modeLocal': 'Local',
|
||||
'iam.modeLocalDesc': 'Usuários locais com senha',
|
||||
'iam.modeOidc': 'Oracle IAM',
|
||||
'iam.modeOidcDesc': 'Somente SSO corporativo',
|
||||
'iam.modeBoth': 'Ambos',
|
||||
'iam.modeBothDesc': 'Local + Oracle IAM',
|
||||
'iam.oidcConfig': 'Configuração OIDC',
|
||||
'iam.testConnection': 'Testar Conexão',
|
||||
'iam.issuerUrl': 'Issuer URL (Identity Domain)',
|
||||
'iam.clientId': 'Client ID',
|
||||
'iam.clientSecret': 'Client Secret',
|
||||
'iam.redirectUri': 'Redirect URI',
|
||||
'iam.groupMapping': 'Mapeamento de Grupos',
|
||||
'iam.groupMappingDesc': 'Mapeie os grupos do OCI Identity Domain para as roles do sistema.',
|
||||
'iam.save': 'Salvar Configuração',
|
||||
'iam.saving': 'Salvando...',
|
||||
'iam.saved': 'Configuração salva com sucesso',
|
||||
'iam.issuerRequired': 'Issuer URL é obrigatório',
|
||||
'iam.testOk': 'Conexão OK',
|
||||
'iam.testInvalid': 'Resposta inválida do discovery endpoint',
|
||||
'iam.testFail': 'Falha na conexão',
|
||||
};
|
||||
|
||||
export default pt;
|
||||
573
frontend/src/index.css
Normal file
573
frontend/src/index.css
Normal file
@@ -0,0 +1,573 @@
|
||||
@import 'tailwindcss/theme' layer(theme);
|
||||
@import 'tailwindcss/preflight' layer(base);
|
||||
@import 'tailwindcss/utilities' layer(utilities);
|
||||
|
||||
/* ── Oracle Dark Premium Theme (in theme layer) ── */
|
||||
@layer theme {
|
||||
:root, html.light {
|
||||
--bg: #f4f2ef; --bg1: #ffffff; --bg2: #faf9f7; --bg3: #efeee9; --bg4: #e5e3dd;
|
||||
--bd: #ddd9d2; --bdf: #c5c0b8;
|
||||
--t1: #1c1917; --t2: #44403c; --t3: #78716c; --t4: #a8a29e;
|
||||
--ac: #c74634; --ach: #b13d2e; --acd: #9a3427; --acl: rgba(199,70,52,.06); --acl2: rgba(199,70,52,.1);
|
||||
--gn: #16a34a; --gnl: rgba(22,163,74,.08); --rd: #dc2626; --rdl: rgba(220,38,38,.06);
|
||||
--bl: #2563eb; --bll: rgba(37,99,235,.06); --yl: #ca8a04; --yll: rgba(202,138,4,.06);
|
||||
--pp: #7c3aed; --ppl: rgba(124,58,237,.06);
|
||||
--ok: #16a34a; --w: #ca8a04; --err: #dc2626;
|
||||
--r: 12px; --rl: 16px; --rr: 20px;
|
||||
--sh1: 0 1px 2px rgba(28,25,23,.04), 0 1px 3px rgba(28,25,23,.06);
|
||||
--sh2: 0 4px 6px -1px rgba(28,25,23,.05), 0 2px 4px -2px rgba(28,25,23,.04);
|
||||
--sh3: 0 10px 15px -3px rgba(28,25,23,.06), 0 4px 6px -4px rgba(28,25,23,.04);
|
||||
--shh: 0 20px 25px -5px rgba(28,25,23,.08), 0 8px 10px -6px rgba(28,25,23,.04);
|
||||
--tb-bg: rgba(255,255,255,.8);
|
||||
--fm: 'JetBrains Mono', monospace;
|
||||
--fs: 'Plus Jakarta Sans', -apple-system, system-ui, sans-serif;
|
||||
--trans: cubic-bezier(.22,1,.36,1);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--bg: #0d0f14; --bg1: #151820; --bg2: #1a1e28; --bg3: #222733; --bg4: #2a3040;
|
||||
--bd: #2a3040; --bdf: #3a4258;
|
||||
--t1: #f0f0f4; --t2: #c8cad0; --t3: #8b8fa3; --t4: #5a5e6e;
|
||||
--ac: #c74634; --ach: #d9523f; --acd: #a83428; --acl: rgba(199,70,52,.1); --acl2: rgba(199,70,52,.16);
|
||||
--gn: #34c759; --gnl: rgba(52,199,89,.1); --rd: #ff453a; --rdl: rgba(255,69,58,.1);
|
||||
--bl: #0a84ff; --bll: rgba(10,132,255,.1); --yl: #ffd60a; --yll: rgba(255,214,10,.1);
|
||||
--pp: #bf5af2; --ppl: rgba(191,90,242,.1);
|
||||
--ok: #34c759; --w: #ffd60a; --err: #ff453a;
|
||||
--sh1: 0 1px 3px rgba(0,0,0,.3), 0 1px 2px rgba(0,0,0,.2);
|
||||
--sh2: 0 4px 8px rgba(0,0,0,.3), 0 2px 4px rgba(0,0,0,.2);
|
||||
--sh3: 0 10px 20px rgba(0,0,0,.35), 0 4px 8px rgba(0,0,0,.2);
|
||||
--shh: 0 20px 30px rgba(0,0,0,.4), 0 8px 12px rgba(0,0,0,.25);
|
||||
--tb-bg: rgba(21,24,32,.85);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Base resets (in base layer) ── */
|
||||
@layer base {
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: var(--fs);
|
||||
background: var(--bg);
|
||||
color: var(--t1);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
::selection { background: rgba(199,70,52,.2); color: var(--t1); }
|
||||
|
||||
html.light *, html.dark * {
|
||||
transition: background-color .25s var(--trans), border-color .25s var(--trans), color .15s var(--trans);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--bd); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--bdf); }
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
select {
|
||||
appearance: none;
|
||||
background: var(--bg1) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2378716c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E") no-repeat right 12px center;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
padding: 9px 36px 9px 14px;
|
||||
font-size: .82rem;
|
||||
font-family: var(--fs);
|
||||
color: var(--t1);
|
||||
cursor: pointer;
|
||||
transition: border-color .2s, box-shadow .2s;
|
||||
min-width: 0;
|
||||
}
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--ac);
|
||||
box-shadow: 0 0 0 3px var(--acl);
|
||||
}
|
||||
select:hover { border-color: var(--bdf); }
|
||||
|
||||
input[type="text"], input[type="email"], input[type="password"], input[type="number"], input[type="search"],
|
||||
textarea {
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
padding: 9px 14px;
|
||||
font-size: .82rem;
|
||||
font-family: var(--fs);
|
||||
color: var(--t1);
|
||||
transition: border-color .2s, box-shadow .2s;
|
||||
width: 100%;
|
||||
}
|
||||
input[type="text"]:focus, input[type="email"]:focus, input[type="password"]:focus,
|
||||
input[type="number"]:focus, input[type="search"]:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--ac);
|
||||
box-shadow: 0 0 0 3px var(--acl);
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--bd);
|
||||
border-radius: 5px;
|
||||
background: var(--bg1);
|
||||
cursor: pointer;
|
||||
transition: all .15s;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
input[type="checkbox"]:checked {
|
||||
background: var(--ac);
|
||||
border-color: var(--ac);
|
||||
}
|
||||
input[type="checkbox"]:checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 6px;
|
||||
height: 10px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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 */
|
||||
nav a:hover { background: var(--bg3); color: var(--t1) !important; }
|
||||
|
||||
/* Page wrapper */
|
||||
.page {
|
||||
padding: 28px 32px;
|
||||
max-width: 1280px;
|
||||
animation: fadeIn .35s ease;
|
||||
}
|
||||
.page-full {
|
||||
height: 100%;
|
||||
animation: fadeIn .35s ease;
|
||||
}
|
||||
|
||||
/* Page header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.page-header h1 {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--t1);
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
.page-header .subtitle {
|
||||
font-size: .8rem;
|
||||
color: var(--t3);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.page-header .spacer { flex: 1; }
|
||||
.page-header .count {
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
color: var(--t3);
|
||||
background: var(--bg3);
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--rl);
|
||||
box-shadow: var(--sh1);
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--bd);
|
||||
}
|
||||
.card-header h2 {
|
||||
font-size: .92rem;
|
||||
font-weight: 700;
|
||||
color: var(--t1);
|
||||
}
|
||||
.card-header .icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: .82rem;
|
||||
}
|
||||
.data-table thead th {
|
||||
text-align: left;
|
||||
font-size: .7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
color: var(--t3);
|
||||
padding: 10px 14px;
|
||||
background: var(--bg2);
|
||||
border-bottom: 1px solid var(--bd);
|
||||
}
|
||||
.data-table thead th:first-child { border-radius: var(--r) 0 0 0; }
|
||||
.data-table thead th:last-child { border-radius: 0 var(--r) 0 0; }
|
||||
.data-table tbody td {
|
||||
padding: 12px 14px;
|
||||
color: var(--t2);
|
||||
border-bottom: 1px solid var(--bg3);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.data-table tbody tr:last-child td { border-bottom: none; }
|
||||
.data-table tbody tr:hover td { background: var(--bg2); }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 18px;
|
||||
border-radius: var(--r);
|
||||
font-size: .82rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--fs);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all .2s var(--trans);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--ac);
|
||||
color: #fff;
|
||||
border-color: var(--ac);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { background: var(--ach); }
|
||||
.btn-secondary {
|
||||
background: var(--bg1);
|
||||
color: var(--t2);
|
||||
border-color: var(--bd);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) { background: var(--bg3); color: var(--t1); }
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
color: var(--rd);
|
||||
border-color: var(--rd);
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) { background: var(--rdl); }
|
||||
.btn-success {
|
||||
background: var(--gn);
|
||||
color: #fff;
|
||||
border-color: var(--gn);
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: .75rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: .68rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-green { background: var(--gnl); color: var(--gn); }
|
||||
.badge-red { background: var(--rdl); color: var(--rd); }
|
||||
.badge-blue { background: var(--bll); color: var(--bl); }
|
||||
.badge-yellow { background: var(--yll); color: var(--yl); }
|
||||
.badge-purple { background: var(--ppl); color: var(--pp); }
|
||||
.badge-gray { background: var(--bg3); color: var(--t3); }
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: var(--t4);
|
||||
}
|
||||
.empty-state svg { opacity: .35; margin-bottom: 12px; }
|
||||
.empty-state h3 { font-size: .92rem; font-weight: 600; color: var(--t3); margin-bottom: 4px; }
|
||||
.empty-state p { font-size: .78rem; }
|
||||
|
||||
/* KPI cards */
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.kpi-card {
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--rl);
|
||||
padding: 18px 20px;
|
||||
box-shadow: var(--sh1);
|
||||
}
|
||||
.kpi-card .label {
|
||||
font-size: .7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
color: var(--t3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.kpi-card .value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--t1);
|
||||
letter-spacing: -.02em;
|
||||
}
|
||||
.kpi-card .sub {
|
||||
font-size: .72rem;
|
||||
color: var(--t4);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Form groups */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
color: var(--t2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Tags/pills */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: .72rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg3);
|
||||
color: var(--t2);
|
||||
border: 1px solid var(--bd);
|
||||
}
|
||||
.tag button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--t3);
|
||||
padding: 0;
|
||||
font-size: .82rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.tag button:hover { color: var(--rd); }
|
||||
|
||||
/* Section divider */
|
||||
.section-title {
|
||||
font-size: .72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
color: var(--t4);
|
||||
margin: 24px 0 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--bg3);
|
||||
}
|
||||
|
||||
/* ── Chat Markdown Styling ───────────────────────────────────── */
|
||||
.chat-markdown {
|
||||
font-size: .82rem;
|
||||
line-height: 1.7;
|
||||
color: var(--t1);
|
||||
}
|
||||
.chat-markdown p {
|
||||
margin: 0 0 .65em;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
.chat-markdown p:last-child { margin-bottom: 0; }
|
||||
|
||||
/* Headings */
|
||||
.chat-markdown h1, .chat-markdown h2, .chat-markdown h3,
|
||||
.chat-markdown h4, .chat-markdown h5, .chat-markdown h6 {
|
||||
font-weight: 700;
|
||||
color: var(--t1);
|
||||
margin: 1em 0 .4em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.chat-markdown h1 { font-size: 1.15em; }
|
||||
.chat-markdown h2 { font-size: 1.05em; }
|
||||
.chat-markdown h3 { font-size: .95em; }
|
||||
.chat-markdown h4, .chat-markdown h5, .chat-markdown h6 { font-size: .88em; }
|
||||
.chat-markdown > h1:first-child,
|
||||
.chat-markdown > h2:first-child,
|
||||
.chat-markdown > h3:first-child { margin-top: 0; }
|
||||
|
||||
/* Lists */
|
||||
.chat-markdown ul, .chat-markdown ol {
|
||||
margin: .4em 0 .65em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.chat-markdown ul { list-style: disc; }
|
||||
.chat-markdown ol { list-style: decimal; }
|
||||
.chat-markdown li { margin: .2em 0; }
|
||||
.chat-markdown li > ul, .chat-markdown li > ol { margin: .15em 0; }
|
||||
.chat-markdown li > p { margin: .15em 0; }
|
||||
|
||||
/* Inline code */
|
||||
.chat-markdown code:not(pre code) {
|
||||
background: color-mix(in srgb, var(--ac) 10%, transparent);
|
||||
color: var(--ac);
|
||||
padding: .12em .35em;
|
||||
border-radius: 4px;
|
||||
font-size: .88em;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
.chat-markdown pre {
|
||||
background: #1e1e2e;
|
||||
color: #cdd6f4;
|
||||
border-radius: 8px;
|
||||
padding: .85em 1em;
|
||||
margin: .5em 0 .75em;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
font-size: .78rem;
|
||||
line-height: 1.55;
|
||||
border: 1px solid color-mix(in srgb, var(--bd) 50%, transparent);
|
||||
}
|
||||
.chat-markdown pre code {
|
||||
background: none !important;
|
||||
color: inherit !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
font-size: inherit !important;
|
||||
word-break: normal;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.chat-markdown table {
|
||||
border-collapse: collapse;
|
||||
margin: .5em 0 .75em;
|
||||
font-size: .8rem;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
.chat-markdown th, .chat-markdown td {
|
||||
border: 1px solid var(--bd);
|
||||
padding: .4em .7em;
|
||||
text-align: left;
|
||||
}
|
||||
.chat-markdown th {
|
||||
background: color-mix(in srgb, var(--ac) 8%, transparent);
|
||||
font-weight: 700;
|
||||
font-size: .78rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chat-markdown tr:nth-child(even) {
|
||||
background: color-mix(in srgb, var(--bd) 15%, transparent);
|
||||
}
|
||||
|
||||
/* Blockquotes */
|
||||
.chat-markdown blockquote {
|
||||
border-left: 3px solid var(--ac);
|
||||
margin: .5em 0 .75em;
|
||||
padding: .4em .8em;
|
||||
background: color-mix(in srgb, var(--ac) 5%, transparent);
|
||||
color: var(--t2);
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
.chat-markdown blockquote p { margin: 0; }
|
||||
|
||||
/* Horizontal rule */
|
||||
.chat-markdown hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--bd);
|
||||
margin: .8em 0;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.chat-markdown a {
|
||||
color: var(--ac);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Strong / emphasis */
|
||||
.chat-markdown strong { font-weight: 700; color: var(--t1); }
|
||||
.chat-markdown em { font-style: italic; }
|
||||
|
||||
/* ── Syntax Highlighting (Catppuccin Mocha) ─────────────────── */
|
||||
.hljs-keyword, .hljs-selector-tag { color: #cba6f7; }
|
||||
.hljs-string, .hljs-attr { color: #a6e3a1; }
|
||||
.hljs-number, .hljs-literal { color: #fab387; }
|
||||
.hljs-comment { color: #6c7086; font-style: italic; }
|
||||
.hljs-built_in, .hljs-builtin-name { color: #89dceb; }
|
||||
.hljs-function .hljs-title, .hljs-title.function_ { color: #89b4fa; }
|
||||
.hljs-type, .hljs-title.class_ { color: #f9e2af; }
|
||||
.hljs-variable, .hljs-template-variable { color: #f38ba8; }
|
||||
.hljs-meta { color: #fab387; }
|
||||
.hljs-punctuation { color: #bac2de; }
|
||||
.hljs-tag { color: #89b4fa; }
|
||||
.hljs-name { color: #89b4fa; }
|
||||
.hljs-attribute { color: #f9e2af; }
|
||||
|
||||
/* ── Prose overflow (legacy compat) ─────────────────────────── */
|
||||
.prose pre { overflow-x: auto; max-width: 100%; }
|
||||
.prose table { overflow-x: auto; display: block; max-width: 100%; }
|
||||
.prose code { word-break: break-all; }
|
||||
.prose p { overflow-wrap: break-word; word-break: break-word; }
|
||||
|
||||
/* Tooltip-like labels */
|
||||
.field-label {
|
||||
font-size: .7rem;
|
||||
font-weight: 600;
|
||||
color: var(--t3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
1144
frontend/src/pages/ChatPage.tsx
Normal file
1144
frontend/src/pages/ChatPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1614
frontend/src/pages/ExplorerPage.tsx
Normal file
1614
frontend/src/pages/ExplorerPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
157
frontend/src/pages/LoginPage.tsx
Normal file
157
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useState, useEffect, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const { login, mfaRequired, mfaSetup, totpUri, user } = useAuthStore();
|
||||
const { loadData } = useAppStore();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [totp, setTotp] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [authMode, setAuthMode] = useState('local');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/oidc/config').then(r => r.json()).then(d => setAuthMode(d.auth_mode || 'local')).catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (user) {
|
||||
navigate('/chat', { replace: true });
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await login(username, password, totp || undefined);
|
||||
if (!res.mfa_required && res.token) {
|
||||
await loadData();
|
||||
navigate('/chat', { replace: true });
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('login.error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen" style={{ background: 'var(--bg)' }}>
|
||||
<div className="w-[380px] p-8 rounded-2xl" style={{ background: 'var(--bg1)', boxShadow: 'var(--sh3)', border: '1px solid var(--bd)' }}>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width={52} height={52}>
|
||||
<rect x="2" y="5" width="32" height="26" rx="13" ry="13" fill="rgba(199,70,52,0.08)" stroke="#C74634" strokeWidth="1.8" />
|
||||
<rect x="11" y="11" width="14" height="14" rx="4" fill="#fff" stroke="#C74634" strokeWidth="0.4" />
|
||||
<circle cx="15" cy="17" r="2" fill="#C74634" />
|
||||
<circle cx="21" cy="17" r="2" fill="#C74634" />
|
||||
<circle cx="15" cy="16.7" r="0.7" fill="#fff" />
|
||||
<circle cx="21" cy="16.7" r="0.7" fill="#fff" />
|
||||
<rect x="14" y="21" width="8" height="1.5" rx="0.75" fill="#C74634" opacity="0.6" />
|
||||
</svg>
|
||||
<h1 className="text-lg font-bold mt-3" style={{ color: 'var(--t1)' }}>AI Agent</h1>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--t3)' }}>{t('login.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{authMode !== 'oidc' && (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
{!mfaRequired ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('login.userPlaceholder')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t('login.passPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{mfaSetup && totpUri && (
|
||||
<div className="text-center mb-2">
|
||||
<p className="text-xs mb-2" style={{ color: 'var(--t3)' }}>
|
||||
{t('login.mfaScanQr')}
|
||||
</p>
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpUri)}`}
|
||||
alt="QR Code"
|
||||
className="mx-auto rounded-lg"
|
||||
width={180}
|
||||
height={180}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('login.totpPlaceholder')}
|
||||
value={totp}
|
||||
onChange={(e) => setTotp(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none text-center tracking-[.3em]"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', fontFamily: 'var(--fm)' }}
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-xs px-3 py-2 rounded-lg" style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 rounded-lg text-sm font-semibold text-white transition-colors disabled:opacity-60"
|
||||
style={{ background: 'var(--ac)' }}
|
||||
>
|
||||
{loading ? t('login.loading') : mfaRequired ? t('login.verify') : t('login.submit')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{(authMode === 'oidc' || authMode === 'both') && (
|
||||
<>
|
||||
{authMode === 'both' && (
|
||||
<div className="flex items-center gap-2 my-3">
|
||||
<div className="flex-1 h-px" style={{ background: 'var(--bd)' }} />
|
||||
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>{t('login.or')}</span>
|
||||
<div className="flex-1 h-px" style={{ background: 'var(--bd)' }} />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { window.location.href = '/api/auth/oidc/login'; }}
|
||||
className="w-full py-2.5 rounded-lg text-sm font-semibold transition-colors flex items-center justify-center gap-2"
|
||||
style={{ background: 'var(--rdl)', color: 'var(--rd)', border: '1px solid color-mix(in srgb, var(--rd) 30%, transparent)' }}
|
||||
>
|
||||
<Shield size={16} />
|
||||
{t('login.oidcButton')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
454
frontend/src/pages/OciServicesPage.tsx
Normal file
454
frontend/src/pages/OciServicesPage.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useI18n } from '@/i18n';
|
||||
import {
|
||||
reportsApi,
|
||||
type OciServiceStatus,
|
||||
type OciHealthRegion,
|
||||
} from '@/api/endpoints/reports';
|
||||
import {
|
||||
Shield, RefreshCw, Check, Loader2, ChevronDown, Server,
|
||||
Activity, Search, X, Globe, CheckCircle2, AlertTriangle, XCircle, ChevronRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Status helpers ── */
|
||||
const STATUS_MAP: Record<string, { label: string; color: string; bg: string; dot: string }> = {
|
||||
NormalPerformance: { label: 'Operational', color: '#16a34a', bg: '#dcfce7', dot: '#22c55e' },
|
||||
DegradedPerformance: { label: 'Degraded', color: '#d97706', bg: '#fef3c7', dot: '#f59e0b' },
|
||||
ServiceDisruption: { label: 'Disruption', color: '#dc2626', bg: '#fef2f2', dot: '#ef4444' },
|
||||
MaintenanceInProgress: { label: 'Maintenance', color: '#2563eb', bg: '#eff6ff', dot: '#3b82f6' },
|
||||
};
|
||||
const getStatus = (s: string) => STATUS_MAP[s] || STATUS_MAP.NormalPerformance;
|
||||
|
||||
const GEO_ORDER = ['North America', 'LAD', 'EMEA', 'APAC', 'EU Sovereign'];
|
||||
const GEO_LABELS: Record<string, string> = {
|
||||
'North America': 'Americas',
|
||||
LAD: 'Latin America',
|
||||
EMEA: 'Europe, Middle East & Africa',
|
||||
APAC: 'Asia Pacific',
|
||||
'EU Sovereign': 'EU Sovereign',
|
||||
};
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════════
|
||||
SERVICE STATUS TAB (existing functionality)
|
||||
══════════════════════════════════════════════════════════════════════════════ */
|
||||
function ServiceStatusTab() {
|
||||
const { t } = useI18n();
|
||||
const ociCfg = useAppStore((s) => s.ociCfg);
|
||||
const [selectedConfig, setSelectedConfig] = useState('');
|
||||
const [services, setServices] = useState<OciServiceStatus[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<{ type: 's' | 'e'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedConfig && ociCfg.length > 0) setSelectedConfig(ociCfg[0].id);
|
||||
}, [ociCfg, selectedConfig]);
|
||||
|
||||
const loadServices = useCallback(async (detect = true) => {
|
||||
if (!selectedConfig) return;
|
||||
setLoading(true); setMsg(null);
|
||||
try { setServices(await reportsApi.getOciServices(selectedConfig, detect)); }
|
||||
catch { setMsg({ type: 'e', text: 'Erro ao verificar servicos' }); }
|
||||
finally { setLoading(false); }
|
||||
}, [selectedConfig]);
|
||||
|
||||
useEffect(() => { if (selectedConfig) loadServices(true); }, [selectedConfig]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const saveServices = useCallback(async () => {
|
||||
if (!selectedConfig) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await reportsApi.setOciServices(selectedConfig, services);
|
||||
setMsg({ type: 's', text: 'Status salvo com sucesso' });
|
||||
} catch { setMsg({ type: 'e', text: 'Erro ao salvar' }); }
|
||||
finally { setSaving(false); }
|
||||
}, [selectedConfig, services]);
|
||||
|
||||
const toggleStatus = (idx: number) => {
|
||||
setServices((p) => p.map((s, i) => i !== idx ? s : { ...s, status: s.status === 'OK' ? 'WARNING' : 'OK', overridden: true }));
|
||||
};
|
||||
const updateDescription = (idx: number, desc: string) => {
|
||||
setServices((p) => p.map((s, i) => i !== idx ? s : { ...s, description: desc, overridden: true }));
|
||||
};
|
||||
const resetToAuto = (idx: number) => {
|
||||
setServices((p) => p.map((s, i) => i !== idx ? s : { ...s, status: s.auto_status || s.status, description: s.auto_description || s.description, overridden: false }));
|
||||
};
|
||||
|
||||
const configName = ociCfg.find((c) => c.id === selectedConfig)?.tenancy_name || '';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Tenancy selector */}
|
||||
<div className="card" style={{ padding: '14px 20px' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Server size={16} style={{ color: 'var(--ac)' }} />
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--t1)' }}>Tenancy</span>
|
||||
<div className="relative flex-1" style={{ maxWidth: 350 }}>
|
||||
<select value={selectedConfig} onChange={(e) => setSelectedConfig(e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-xs outline-none cursor-pointer appearance-none pr-8"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }}>
|
||||
<option value="">Selecione...</option>
|
||||
{ociCfg.map((c) => <option key={c.id} value={c.id}>{c.tenancy_name}</option>)}
|
||||
</select>
|
||||
<ChevronDown size={14} className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none" style={{ color: 'var(--t3)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className="px-4 py-2 rounded-lg text-xs font-medium"
|
||||
style={{ background: msg.type === 's' ? 'var(--gnl)' : 'var(--rdl)', color: msg.type === 's' ? 'var(--gn)' : 'var(--rd)' }}>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedConfig && (
|
||||
<div className="card overflow-hidden" style={{ padding: 0 }}>
|
||||
<div className="px-5 py-3 flex items-center justify-between" style={{ borderBottom: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={16} style={{ color: 'var(--ac)' }} />
|
||||
<span className="text-[.78rem] font-semibold" style={{ color: 'var(--t1)' }}>{configName}</span>
|
||||
<span className="text-[.65rem] px-2 py-0.5 rounded-full font-bold" style={{ background: 'var(--gnl)', color: 'var(--gn)' }}>
|
||||
{services.filter((s) => s.status === 'OK').length}/{services.length || 6} OK
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => loadServices(true)} disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer"
|
||||
style={{ background: 'var(--bg3)', color: 'var(--t2)', border: '1px solid var(--bd)' }}>
|
||||
{loading ? <Loader2 size={12} className="animate-spin" /> : <RefreshCw size={12} />} Re-detect
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 gap-2">
|
||||
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--ac)' }} />
|
||||
<span className="text-[.76rem]" style={{ color: 'var(--t3)' }}>Verificando servicos OCI...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[.74rem]" style={{ borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--bg2)' }}>
|
||||
<th className="py-2.5 px-4 text-left font-semibold" style={{ color: 'var(--t3)', width: 90 }}>STATUS</th>
|
||||
<th className="py-2.5 px-4 text-left font-semibold" style={{ color: 'var(--t3)', width: 200 }}>SERVICE</th>
|
||||
<th className="py-2.5 px-4 text-left font-semibold" style={{ color: 'var(--t3)' }}>DESCRIPTION</th>
|
||||
<th className="py-2.5 px-4 text-center font-semibold" style={{ color: 'var(--t3)', width: 70 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{services.map((svc, i) => (
|
||||
<tr key={svc.service} style={{ borderBottom: '1px solid var(--bd)' }}>
|
||||
<td className="py-2.5 px-4">
|
||||
<button onClick={() => toggleStatus(i)} className="px-3 py-1 rounded text-[.68rem] font-bold cursor-pointer"
|
||||
style={{ background: svc.status === 'OK' ? '#dcfce7' : '#fef3c7', color: svc.status === 'OK' ? '#16a34a' : '#d97706', border: 'none', minWidth: 65 }}
|
||||
title="Clique para alternar OK/WARNING">{svc.status}</button>
|
||||
</td>
|
||||
<td className="py-2.5 px-4">
|
||||
<span className="font-semibold" style={{ color: 'var(--t1)' }}>{svc.service}</span>
|
||||
{svc.overridden && <span className="ml-2 text-[.58rem] px-1.5 py-0.5 rounded" style={{ background: 'var(--bll)', color: 'var(--bl)' }}>editado</span>}
|
||||
</td>
|
||||
<td className="py-2.5 px-4">
|
||||
<input type="text" value={svc.description} onChange={(e) => updateDescription(i, e.target.value)}
|
||||
className="w-full px-2.5 py-1.5 rounded-lg text-[.72rem] outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} />
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-center">
|
||||
{svc.overridden && <button onClick={() => resetToAuto(i)} className="text-[.6rem] px-2 py-0.5 rounded cursor-pointer"
|
||||
style={{ background: 'var(--bg3)', color: 'var(--t3)', border: '1px solid var(--bd)' }}>reset</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{!loading && services.length > 0 && (
|
||||
<div className="px-5 py-3 flex justify-end" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<button onClick={saveServices} disabled={saving}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-[.74rem] font-semibold cursor-pointer"
|
||||
style={{ background: 'var(--ac)', color: '#fff', border: 'none' }}>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} Salvar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════════
|
||||
OCI HEALTH TAB (Oracle public status)
|
||||
══════════════════════════════════════════════════════════════════════════════ */
|
||||
function OciHealthTab() {
|
||||
const [regions, setRegions] = useState<OciHealthRegion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [geoFilter, setGeoFilter] = useState('all');
|
||||
const [expandedRegion, setExpandedRegion] = useState<string | null>(null);
|
||||
|
||||
const loadHealth = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await reportsApi.getOciHealth();
|
||||
setRegions(data.regionHealthReports || []);
|
||||
setLastUpdate(new Date());
|
||||
} catch {}
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadHealth(); }, [loadHealth]);
|
||||
|
||||
// Stats
|
||||
const stats = useMemo(() => {
|
||||
let totalServices = 0, operational = 0, degraded = 0, disrupted = 0;
|
||||
regions.forEach((r) => r.serviceHealthReports.forEach((s) => {
|
||||
totalServices++;
|
||||
if (s.serviceStatus === 'NormalPerformance') operational++;
|
||||
else if (s.serviceStatus === 'DegradedPerformance') degraded++;
|
||||
else disrupted++;
|
||||
}));
|
||||
return { totalServices, operational, degraded, disrupted, regions: regions.length };
|
||||
}, [regions]);
|
||||
|
||||
// Filter
|
||||
const filteredRegions = useMemo(() => {
|
||||
let filtered = regions;
|
||||
if (geoFilter !== 'all') filtered = filtered.filter((r) => r.geographicAreaName === geoFilter);
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
filtered = filtered.map((r) => ({
|
||||
...r,
|
||||
serviceHealthReports: r.serviceHealthReports.filter((s) =>
|
||||
s.serviceName.toLowerCase().includes(q) || r.regionName.toLowerCase().includes(q)
|
||||
),
|
||||
})).filter((r) => r.serviceHealthReports.length > 0);
|
||||
}
|
||||
return filtered;
|
||||
}, [regions, geoFilter, search]);
|
||||
|
||||
// Group by geography
|
||||
const grouped = useMemo(() => {
|
||||
const map: Record<string, OciHealthRegion[]> = {};
|
||||
filteredRegions.forEach((r) => {
|
||||
const geo = r.geographicAreaName || 'Other';
|
||||
if (!map[geo]) map[geo] = [];
|
||||
map[geo].push(r);
|
||||
});
|
||||
return GEO_ORDER.filter((g) => map[g]).map((g) => ({ geo: g, label: GEO_LABELS[g] || g, regions: map[g] }));
|
||||
}, [filteredRegions]);
|
||||
|
||||
// Geos available
|
||||
const availableGeos = useMemo(() => {
|
||||
const s = new Set(regions.map((r) => r.geographicAreaName));
|
||||
return GEO_ORDER.filter((g) => s.has(g));
|
||||
}, [regions]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<Loader2 size={28} className="animate-spin" style={{ color: 'var(--ac)' }} />
|
||||
<span className="text-[.8rem]" style={{ color: 'var(--t3)' }}>Loading OCI Health Status...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ── Global Status Banner ── */}
|
||||
<div className="rounded-xl overflow-hidden" style={{ background: stats.disrupted > 0 ? 'linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%)' : stats.degraded > 0 ? 'linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%)' : 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)', border: '1px solid var(--bd)' }}>
|
||||
<div className="px-6 py-5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center"
|
||||
style={{ background: stats.disrupted > 0 ? '#fee2e2' : stats.degraded > 0 ? '#fef3c7' : '#dcfce7' }}>
|
||||
{stats.disrupted > 0 ? <XCircle size={24} style={{ color: '#dc2626' }} /> :
|
||||
stats.degraded > 0 ? <AlertTriangle size={24} style={{ color: '#d97706' }} /> :
|
||||
<CheckCircle2 size={24} style={{ color: '#16a34a' }} />}
|
||||
</div>
|
||||
{stats.disrupted === 0 && stats.degraded === 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 rounded-full animate-ping" style={{ background: '#22c55e', opacity: 0.5 }} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[1rem] font-bold" style={{ color: stats.disrupted > 0 ? '#dc2626' : stats.degraded > 0 ? '#d97706' : '#16a34a' }}>
|
||||
{stats.disrupted > 0 ? 'Service Disruption Detected' : stats.degraded > 0 ? 'Degraded Performance' : 'All Systems Operational'}
|
||||
</h2>
|
||||
<p className="text-[.72rem] mt-0.5" style={{ color: 'var(--t3)' }}>
|
||||
{stats.regions} regions · {stats.totalServices.toLocaleString()} services monitored
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{lastUpdate && (
|
||||
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>
|
||||
Updated {lastUpdate.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={loadHealth} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer"
|
||||
style={{ background: 'rgba(255,255,255,.7)', color: 'var(--t2)', border: '1px solid var(--bd)' }}>
|
||||
<RefreshCw size={12} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats pills */}
|
||||
<div className="px-6 pb-4 flex gap-3">
|
||||
{[
|
||||
{ label: 'Operational', count: stats.operational, color: '#16a34a', bg: '#f0fdf4' },
|
||||
{ label: 'Degraded', count: stats.degraded, color: '#d97706', bg: '#fffbeb' },
|
||||
{ label: 'Disrupted', count: stats.disrupted, color: '#dc2626', bg: '#fef2f2' },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-[.7rem] font-semibold"
|
||||
style={{ background: s.bg, color: s.color, border: `1px solid ${s.color}20` }}>
|
||||
<span className="w-2 h-2 rounded-full" style={{ background: s.color }} />
|
||||
{s.count.toLocaleString()} {s.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Search + Geo Filter ── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--t4)' }} />
|
||||
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search services or regions..."
|
||||
className="w-full pl-9 pr-8 py-2 rounded-lg text-[.76rem] outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} />
|
||||
{search && <button onClick={() => setSearch('')} className="absolute right-2.5 top-1/2 -translate-y-1/2 cursor-pointer" style={{ color: 'var(--t4)' }}><X size={14} /></button>}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => setGeoFilter('all')}
|
||||
className="px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer transition-colors"
|
||||
style={{ background: geoFilter === 'all' ? 'var(--ac)' : 'var(--bg2)', color: geoFilter === 'all' ? '#fff' : 'var(--t2)', border: '1px solid var(--bd)' }}>
|
||||
<Globe size={12} className="inline mr-1" style={{ verticalAlign: -2 }} />All
|
||||
</button>
|
||||
{availableGeos.map((g) => (
|
||||
<button key={g} onClick={() => setGeoFilter(geoFilter === g ? 'all' : g)}
|
||||
className="px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer transition-colors"
|
||||
style={{ background: geoFilter === g ? 'var(--ac)' : 'var(--bg2)', color: geoFilter === g ? '#fff' : 'var(--t2)', border: '1px solid var(--bd)' }}>
|
||||
{GEO_LABELS[g]?.split(',')[0]?.split('&')[0]?.trim() || g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Region Cards ── */}
|
||||
{grouped.map(({ geo, label, regions: geoRegions }) => (
|
||||
<div key={geo}>
|
||||
<h3 className="text-[.72rem] font-bold uppercase tracking-wider mb-2 px-1" style={{ color: 'var(--t4)' }}>
|
||||
{label}
|
||||
</h3>
|
||||
<div className="grid gap-2">
|
||||
{geoRegions.map((region) => {
|
||||
const svcs = region.serviceHealthReports;
|
||||
const ok = svcs.filter((s) => s.serviceStatus === 'NormalPerformance').length;
|
||||
const issues = svcs.length - ok;
|
||||
const isExpanded = expandedRegion === region.regionName;
|
||||
|
||||
return (
|
||||
<div key={region.regionName} className="rounded-xl overflow-hidden transition-all"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center justify-between px-4 py-3 cursor-pointer"
|
||||
onClick={() => setExpandedRegion(isExpanded ? null : region.regionName)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ background: issues > 0 ? '#f59e0b' : '#22c55e', boxShadow: issues > 0 ? '0 0 8px #f59e0b40' : '0 0 8px #22c55e40' }} />
|
||||
<span className="text-[.78rem] font-semibold" style={{ color: 'var(--t1)' }}>
|
||||
{region.regionName}
|
||||
</span>
|
||||
<span className="text-[.65rem] px-2 py-0.5 rounded-full" style={{ background: 'var(--bg3)', color: 'var(--t3)' }}>
|
||||
{region.regionCanonicalName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[.65rem] font-medium" style={{ color: issues > 0 ? '#d97706' : '#16a34a' }}>
|
||||
{issues > 0 ? `${issues} issue(s)` : `${ok} services OK`}
|
||||
</span>
|
||||
<ChevronRight size={14}
|
||||
style={{ color: 'var(--t4)', transition: 'transform .2s', transform: isExpanded ? 'rotate(90deg)' : 'rotate(0)' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-1.5">
|
||||
{svcs.map((svc) => {
|
||||
const st = getStatus(svc.serviceStatus);
|
||||
return (
|
||||
<div key={svc.serviceId} className="flex items-center gap-2 px-2.5 py-2 rounded-lg transition-colors"
|
||||
style={{ background: 'var(--bg2)' }}>
|
||||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: st.dot }} />
|
||||
<span className="text-[.65rem] truncate" style={{ color: 'var(--t2)' }} title={svc.serviceName}>
|
||||
{svc.serviceName.replace(/^Oracle Cloud Infrastructure /, '').replace(/^Oracle /, '')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════════
|
||||
MAIN PAGE WITH TABS
|
||||
══════════════════════════════════════════════════════════════════════════════ */
|
||||
export default function OciServicesPage() {
|
||||
const { t } = useI18n();
|
||||
const [activeTab, setActiveTab] = useState<'status' | 'health'>('status');
|
||||
|
||||
const tabs = [
|
||||
{ id: 'status' as const, label: 'Service Status', icon: <Shield size={14} /> },
|
||||
{ id: 'health' as const, label: 'OCI Health', icon: <Activity size={14} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-5">
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<Shield size={24} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('svc.title') || 'OCI Services'}</h1>
|
||||
<p className="text-[.78rem]" style={{ color: 'var(--t3)' }}>
|
||||
{activeTab === 'status'
|
||||
? (t('svc.subtitle') || 'Verifique e configure o status dos servicos de seguranca OCI por tenancy')
|
||||
: 'Real-time Oracle Cloud Infrastructure service health across all regions'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 p-1 rounded-xl" style={{ background: 'var(--bg2)', width: 'fit-content' }}>
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-[.76rem] font-semibold cursor-pointer transition-all"
|
||||
style={{
|
||||
background: activeTab === tab.id ? 'var(--bg1)' : 'transparent',
|
||||
color: activeTab === tab.id ? 'var(--ac)' : 'var(--t3)',
|
||||
boxShadow: activeTab === tab.id ? '0 1px 3px rgba(0,0,0,.08)' : 'none',
|
||||
border: 'none',
|
||||
}}>
|
||||
{tab.icon} {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'status' ? <ServiceStatusTab /> : <OciHealthTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
611
frontend/src/pages/PromptGeneratorPage.tsx
Normal file
611
frontend/src/pages/PromptGeneratorPage.tsx
Normal file
@@ -0,0 +1,611 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Sparkles, Send, RefreshCw, Copy, Check, PanelLeftOpen,
|
||||
PanelLeftClose, Plus, Trash2, ChevronDown, Loader2, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import { useAppStore, type ModelInfo } from '@/stores/app';
|
||||
import { terraformApi, type ChatSession } from '@/api/endpoints/terraform';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
/* ── Constants ── */
|
||||
|
||||
const TFP_EXAMPLES = [
|
||||
'VCN com subnets publica e privada, internet gateway, NAT gateway e 2 compute instances Ubuntu',
|
||||
'Ambiente multi-region MAD1 + MAD3 com DRG, RPC e VCNs espelhadas',
|
||||
'Cluster OKE com 3 node pools, load balancer e container registry',
|
||||
'Banco Autonomous Database com vault, encryption key e bastion para acesso',
|
||||
'Infraestrutura completa: VCN, compute, block storage, object storage, IAM policies e monitoring',
|
||||
];
|
||||
|
||||
const TFP_ALLOWED = [
|
||||
'openai.gpt-4.1', 'openai.o3', 'openai.o4-mini',
|
||||
'openai.gpt-5.1', 'openai.gpt-5.2',
|
||||
'google.gemini-2.5-pro', 'google.gemini-2.5-flash',
|
||||
];
|
||||
|
||||
const PROV_ORDER = ['openai', 'google'];
|
||||
|
||||
interface Msg {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
raw?: string;
|
||||
failed?: boolean;
|
||||
copied?: boolean;
|
||||
}
|
||||
|
||||
/* ── Component ── */
|
||||
|
||||
export default function PromptGeneratorPage() {
|
||||
const store = useAppStore();
|
||||
const { models, genaiCfg, ociCfg } = store;
|
||||
const { t } = useI18n();
|
||||
|
||||
/* state (from store) */
|
||||
const msgs = store.pgMessages as Msg[];
|
||||
const setMsgs = store.setPgMessages;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
const sid = store.pgSessionId;
|
||||
const setSid = store.setPgSessionId;
|
||||
|
||||
/* model selection (from store) */
|
||||
const selectedModel = store.pgSelectedModel;
|
||||
const setSelectedModel = store.setPgSelectedModel;
|
||||
const [ddOpen, setDdOpen] = useState(false);
|
||||
const [ddSearch, setDdSearch] = useState('');
|
||||
|
||||
/* OCI config (from store) */
|
||||
const ociId = store.pgOciId;
|
||||
const setOciId = store.setPgOciId;
|
||||
const region = store.pgRegion;
|
||||
const setRegion = store.setPgRegion;
|
||||
const compartment = store.pgCompartment;
|
||||
const setCompartment = store.setPgCompartment;
|
||||
|
||||
/* history sidebar (from store) */
|
||||
const [histOpen, setHistOpen] = useState(false);
|
||||
const history = store.pgHistory as ChatSession[];
|
||||
const setHistory = store.setPgHistory;
|
||||
|
||||
const chatRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const ddRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/* ── auto-scroll ── */
|
||||
const scrollBottom = useCallback(() => {
|
||||
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
|
||||
}, []);
|
||||
|
||||
useEffect(() => { scrollBottom(); }, [msgs, loading, scrollBottom]);
|
||||
|
||||
/* ── close dropdown on outside click ── */
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ddRef.current && !ddRef.current.contains(e.target as Node)) setDdOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
/* ── history ── */
|
||||
const loadHistory = useCallback(async () => {
|
||||
try {
|
||||
const h = await terraformApi.listSessions('tf-prompt');
|
||||
setHistory(h);
|
||||
} catch { setHistory([]); }
|
||||
}, []);
|
||||
|
||||
const loadSession = useCallback(async (sessionId: string) => {
|
||||
try {
|
||||
const d = await terraformApi.loadSession(sessionId);
|
||||
setSid(sessionId);
|
||||
setMsgs(
|
||||
d.messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
raw: m.role === 'assistant' ? m.content : undefined,
|
||||
})),
|
||||
);
|
||||
} catch { /* noop */ }
|
||||
}, []);
|
||||
|
||||
const deleteSession = useCallback(async (sessionId: string) => {
|
||||
try { await terraformApi.deleteSession(sessionId); } catch { /* noop */ }
|
||||
if (sid === sessionId) { setSid(null); setMsgs([]); }
|
||||
loadHistory();
|
||||
}, [sid, loadHistory]);
|
||||
|
||||
const newConversation = () => { setSid(null); setMsgs([]); };
|
||||
|
||||
/* ── resolve genai config ── */
|
||||
const resolveGenai = (): Record<string, string | undefined> => {
|
||||
if (selectedModel.startsWith('cfg:')) return { genai_config_id: selectedModel.slice(4) };
|
||||
if (selectedModel && ociId) return { oci_config_id: ociId, model_id: selectedModel, genai_region: region, compartment_id: compartment };
|
||||
const def = genaiCfg.find((g) => g.is_default) || genaiCfg[0];
|
||||
if (def) return { genai_config_id: def.id };
|
||||
if (!ociCfg.length) return {};
|
||||
const oc = ociCfg[0];
|
||||
return { oci_config_id: oc.id, model_id: 'openai.gpt-4.1', genai_region: oc.region, compartment_id: oc.compartment_id };
|
||||
};
|
||||
|
||||
/* ── pick model ── */
|
||||
const pickModel = (v: string) => {
|
||||
setSelectedModel(v);
|
||||
setDdOpen(false);
|
||||
if (v && !v.startsWith('cfg:') && !ociId && ociCfg.length) {
|
||||
setOciId(ociCfg[0].id);
|
||||
setRegion(ociCfg[0].region);
|
||||
setCompartment(ociCfg[0].compartment_id || '');
|
||||
} else if (v.startsWith('cfg:')) {
|
||||
const g = genaiCfg.find((x) => x.id === v.slice(4));
|
||||
if (g?.oci_config_id && !ociId) {
|
||||
setOciId(g.oci_config_id);
|
||||
const c = ociCfg.find((x) => x.id === g.oci_config_id);
|
||||
if (c) { setRegion(c.region); setCompartment(c.compartment_id || ''); }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* ── send ── */
|
||||
const send = async (overrideMsg?: string) => {
|
||||
const m = overrideMsg || input.trim();
|
||||
if (!m || loading) return;
|
||||
if (!overrideMsg) setInput('');
|
||||
|
||||
const newMsgs: Msg[] = [...msgs, { role: 'user', content: m }];
|
||||
setMsgs(newMsgs);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const gc = resolveGenai();
|
||||
const hist = newMsgs.slice(0, -1)
|
||||
.filter((x) => x.role === 'user' || x.role === 'assistant')
|
||||
.map((x) => ({ role: x.role === 'user' ? 'USER' : 'CHATBOT', content: x.raw || x.content }));
|
||||
|
||||
const body = {
|
||||
message: m,
|
||||
genai_config: gc,
|
||||
history: hist.length ? hist : null,
|
||||
...(sid ? { session_id: sid } : {}),
|
||||
};
|
||||
|
||||
const d = await terraformApi.generatePrompt(body);
|
||||
if (d.session_id) setSid(d.session_id);
|
||||
|
||||
if (d.status === 'processing' && d.message_id) {
|
||||
await pollResult(d.message_id, newMsgs);
|
||||
} else if (d.prompt) {
|
||||
setMsgs([...newMsgs, { role: 'assistant', content: d.prompt, raw: d.prompt }]);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setMsgs([...newMsgs, { role: 'assistant', content: t('common.errorPrefix') + (e as Error).message, failed: true }]);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pollResult = async (mid: string, currentMsgs: Msg[]) => {
|
||||
for (let i = 0; i < 600; i++) {
|
||||
await new Promise((r) => setTimeout(r, i < 10 ? 1000 : 3000));
|
||||
try {
|
||||
const r = await terraformApi.pollMessageStatus(mid);
|
||||
if (r.status === 'done') {
|
||||
setMsgs([...currentMsgs, { role: 'assistant', content: r.content, raw: r.content }]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (r.status === 'failed') {
|
||||
setMsgs([...currentMsgs, { role: 'assistant', content: r.content, failed: true }]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch { /* keep polling */ }
|
||||
}
|
||||
setMsgs([...currentMsgs, { role: 'assistant', content: t('pg.timeout'), failed: true }]);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
/* ── retry ── */
|
||||
const retry = (idx: number) => {
|
||||
let userMsg = '';
|
||||
for (let i = idx - 1; i >= 0; i--) { if (msgs[i].role === 'user') { userMsg = msgs[i].content; break; } }
|
||||
if (!userMsg) return;
|
||||
const next = [...msgs];
|
||||
next.splice(idx, 1);
|
||||
setMsgs(next);
|
||||
send(userMsg);
|
||||
};
|
||||
|
||||
/* ── copy ── */
|
||||
const copyPrompt = async (idx: number) => {
|
||||
const m = msgs[idx];
|
||||
if (!m?.raw) return;
|
||||
await navigator.clipboard.writeText(m.raw);
|
||||
setMsgs((prev) => prev.map((msg, i) => (i === idx ? { ...msg, copied: true } : msg)));
|
||||
setTimeout(() => {
|
||||
setMsgs((prev) => prev.map((msg, i) => (i === idx ? { ...msg, copied: false } : msg)));
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
/* ── model dropdown data ── */
|
||||
const provGroups: Record<string, { id: string; name: string }[]> = {};
|
||||
TFP_ALLOWED.forEach((mid) => {
|
||||
const mi: ModelInfo | undefined = models[mid];
|
||||
if (mi) {
|
||||
const p = mi.provider || 'other';
|
||||
if (!provGroups[p]) provGroups[p] = [];
|
||||
provGroups[p].push({ id: mid, name: mi.name });
|
||||
}
|
||||
});
|
||||
|
||||
const cfgFiltered = genaiCfg.filter((g) => TFP_ALLOWED.includes(g.model_id));
|
||||
|
||||
let curLabel = t('pg.selectModel');
|
||||
if (selectedModel.startsWith('cfg:')) {
|
||||
const g = genaiCfg.find((x) => x.id === selectedModel.slice(4));
|
||||
curLabel = g ? (g.name || g.model_id) : 'Config...';
|
||||
} else if (selectedModel) {
|
||||
const mi = models[selectedModel];
|
||||
curLabel = mi ? mi.name : selectedModel;
|
||||
}
|
||||
|
||||
const isDirect = selectedModel && !selectedModel.startsWith('cfg:');
|
||||
|
||||
/* ── auto-grow textarea ── */
|
||||
const autoGrow = (el: HTMLTextAreaElement) => {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
|
||||
};
|
||||
|
||||
/* ── history date grouping ── */
|
||||
const histDateGroup = (ts: string | undefined) => {
|
||||
const tFn = useI18n.getState().t;
|
||||
if (!ts) return '';
|
||||
const dt = ts.slice(0, 10);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
||||
if (dt === today) return tFn('chat.today');
|
||||
if (dt === yesterday) return tFn('chat.yesterday');
|
||||
const d = new Date(dt + 'T00:00:00');
|
||||
const diff = Math.floor((Date.now() - d.getTime()) / 86400000);
|
||||
if (diff < 7) return d.toLocaleDateString('pt-BR', { weekday: 'long' });
|
||||
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
};
|
||||
|
||||
/* ── render ── */
|
||||
return (
|
||||
<div className="page-full flex">
|
||||
{/* ── History sidebar ── */}
|
||||
{histOpen && (
|
||||
<div className="flex flex-col w-64 border-r shrink-0"
|
||||
style={{ background: 'var(--bg1)', borderColor: 'var(--bd)' }}>
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b"
|
||||
style={{ borderColor: 'var(--bd)' }}>
|
||||
<span className="text-xs font-semibold" style={{ color: 'var(--t2)' }}>{t('pg.history')}</span>
|
||||
<button onClick={newConversation}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[.66rem] cursor-pointer"
|
||||
style={{ background: 'var(--ppl)', color: 'var(--pp)', border: 'none' }}>
|
||||
<Plus size={12} /> {t('pg.new')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-1.5">
|
||||
{history.length === 0 && (
|
||||
<p className="text-center text-[.7rem] p-4" style={{ color: 'var(--t4)' }}>
|
||||
{t('pg.noConversations')}
|
||||
</p>
|
||||
)}
|
||||
{(() => {
|
||||
let lastGroup = '';
|
||||
return history.map((s) => {
|
||||
const g = histDateGroup(s.updated_at || s.created_at);
|
||||
const showGroup = g !== lastGroup;
|
||||
lastGroup = g;
|
||||
return (
|
||||
<div key={s.id}>
|
||||
{showGroup && (
|
||||
<div className="text-[.62rem] font-semibold uppercase tracking-wider px-2 pt-3 pb-1"
|
||||
style={{ color: 'var(--t4)' }}>{g}</div>
|
||||
)}
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1.5 rounded-md cursor-pointer group transition-colors"
|
||||
style={{
|
||||
background: s.id === sid ? 'var(--ppl)' : 'transparent',
|
||||
color: s.id === sid ? 'var(--pp)' : 'var(--t2)',
|
||||
}}
|
||||
onClick={() => loadSession(s.id)}>
|
||||
<span className="flex-1 truncate text-[.72rem]">{s.title || t('tf.newConversation')}</span>
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 rounded cursor-pointer"
|
||||
style={{ color: 'var(--rd)', background: 'none', border: 'none' }}
|
||||
onClick={(e) => { e.stopPropagation(); deleteSession(s.id); }}
|
||||
title={t('common.delete')}>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Main chat area ── */}
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
{/* toolbar */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b"
|
||||
style={{ background: 'var(--bg1)', borderColor: 'var(--bd)' }}>
|
||||
<button onClick={() => { setHistOpen(!histOpen); if (!histOpen) loadHistory(); }}
|
||||
className="p-1.5 rounded-md cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: histOpen ? 'var(--ppl)' : 'transparent',
|
||||
color: histOpen ? 'var(--pp)' : 'var(--t3)',
|
||||
border: 'none',
|
||||
}}
|
||||
title={t('pg.history')}>
|
||||
{histOpen ? <PanelLeftClose size={16} /> : <PanelLeftOpen size={16} />}
|
||||
</button>
|
||||
|
||||
<Sparkles size={16} style={{ color: 'var(--pp)' }} />
|
||||
|
||||
{/* Model dropdown */}
|
||||
<div className="relative" ref={ddRef}>
|
||||
<button onClick={() => setDdOpen(!ddOpen)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[.72rem] font-medium cursor-pointer"
|
||||
style={{ background: 'var(--bg2)', color: 'var(--t2)', border: '1px solid var(--bd)' }}>
|
||||
{curLabel}
|
||||
<ChevronDown size={13} className={`transition-transform ${ddOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{ddOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 w-64 rounded-xl overflow-hidden z-50 animate-[fadeIn_.15s_ease]"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)', boxShadow: 'var(--sh3)' }}>
|
||||
<div className="p-2">
|
||||
<input type="text" placeholder={t('pg.search')}
|
||||
value={ddSearch} onChange={(e) => setDdSearch(e.target.value)}
|
||||
className="w-full px-2.5 py-1.5 rounded-md text-[.72rem]"
|
||||
style={{ background: 'var(--bg2)', color: 'var(--t1)', border: '1px solid var(--bd)' }}
|
||||
autoFocus />
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto px-1 pb-1">
|
||||
{cfgFiltered.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[.62rem] font-semibold uppercase tracking-wider"
|
||||
style={{ color: 'var(--t4)' }}>{t('chat.savedConfigs')}</div>
|
||||
{cfgFiltered
|
||||
.filter((g) => !ddSearch || (g.name || g.model_id).toLowerCase().includes(ddSearch.toLowerCase()))
|
||||
.map((g) => {
|
||||
const mi = models[g.model_id];
|
||||
return (
|
||||
<DdItem key={'cfg:' + g.id} label={g.name || mi?.name || g.model_id}
|
||||
selected={selectedModel === 'cfg:' + g.id}
|
||||
onClick={() => pickModel('cfg:' + g.id)} />
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{PROV_ORDER.filter((p) => provGroups[p]).map((p) => (
|
||||
<div key={p}>
|
||||
<div className="px-2 py-1 text-[.62rem] font-semibold uppercase tracking-wider"
|
||||
style={{ color: 'var(--t4)' }}>{p[0].toUpperCase() + p.slice(1)}</div>
|
||||
{provGroups[p]
|
||||
.filter((m) => !ddSearch || m.name.toLowerCase().includes(ddSearch.toLowerCase()))
|
||||
.map((m) => (
|
||||
<DdItem key={m.id} label={m.name}
|
||||
selected={selectedModel === m.id}
|
||||
onClick={() => pickModel(m.id)} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OCI config selector (direct models only) */}
|
||||
{isDirect && (
|
||||
<select
|
||||
value={ociId}
|
||||
onChange={(e) => {
|
||||
setOciId(e.target.value);
|
||||
const c = ociCfg.find((x) => x.id === e.target.value);
|
||||
if (c) { setRegion(c.region); setCompartment(c.compartment_id || ''); }
|
||||
}}
|
||||
className="px-2 py-1.5 rounded-lg text-[.72rem] max-w-[180px]"
|
||||
style={{ background: 'var(--bg2)', color: 'var(--t2)', border: '1px solid var(--bd)' }}>
|
||||
<option value="">{t('tf.ociConfig')}</option>
|
||||
{ociCfg.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.tenancy_name} ({c.region})</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
{/* messages area */}
|
||||
<div ref={chatRef} className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
||||
{msgs.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<Sparkles size={32} className="mb-3 opacity-30" style={{ color: 'var(--pp)' }} />
|
||||
<p className="text-sm font-medium mb-1" style={{ color: 'var(--t2)' }}>
|
||||
{t('pg.emptyTitle')}
|
||||
</p>
|
||||
<p className="text-xs mb-5 opacity-60" style={{ color: 'var(--t3)' }}>
|
||||
{t('pg.emptySubtitle')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center max-w-2xl">
|
||||
{TFP_EXAMPLES.map((ex, i) => (
|
||||
<button key={i} onClick={() => { setInput(ex); inputRef.current?.focus(); }}
|
||||
className="px-3 py-2 rounded-lg text-[.68rem] text-left cursor-pointer transition-colors leading-snug"
|
||||
style={{
|
||||
background: 'var(--bg2)', color: 'var(--t3)',
|
||||
border: '1px solid var(--bd)', maxWidth: 280,
|
||||
}}>
|
||||
{ex}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msgs.map((m, i) => (
|
||||
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className="max-w-[80%] animate-[fadeIn_.25s_ease]">
|
||||
<div className="rounded-xl px-4 py-3 text-[.8rem] leading-relaxed"
|
||||
style={{
|
||||
background: m.role === 'user' ? 'var(--pp)' : 'var(--bg2)',
|
||||
color: m.role === 'user' ? '#fff' : 'var(--t1)',
|
||||
border: m.role === 'user' ? 'none' : '1px solid var(--bd)',
|
||||
}}>
|
||||
{m.role === 'assistant' ? (
|
||||
<div className="prose-sm prose-invert max-w-none [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:text-[.72rem] [&_pre]:overflow-auto [&_code]:text-[.72rem] [&_pre]:relative [&_pre]:group"
|
||||
style={{
|
||||
['--tw-prose-body' as string]: 'var(--t1)',
|
||||
['--tw-prose-headings' as string]: 'var(--t1)',
|
||||
['--tw-prose-code' as string]: 'var(--pp)',
|
||||
}}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}
|
||||
components={{
|
||||
pre: ({ children }) => (
|
||||
<div className="relative group">
|
||||
<pre className="rounded-lg p-3 text-[.72rem] overflow-auto"
|
||||
style={{ background: 'var(--bg3)', fontFamily: 'var(--fm)', color: 'var(--t2)' }}>
|
||||
{children}
|
||||
</pre>
|
||||
<CopyCodeBtn>{children}</CopyCodeBtn>
|
||||
</div>
|
||||
),
|
||||
code: ({ children, className }) => {
|
||||
if (className?.includes('language-')) return <code className={className}>{children}</code>;
|
||||
return (
|
||||
<code className="px-1 py-0.5 rounded text-[.72rem]"
|
||||
style={{ background: 'var(--bg3)', color: 'var(--pp)', fontFamily: 'var(--fm)' }}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
}}>
|
||||
{m.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ whiteSpace: 'pre-wrap' }}>{m.content}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* assistant actions */}
|
||||
{m.role === 'assistant' && m.raw && !m.failed && (
|
||||
<div className="flex gap-1.5 mt-1.5">
|
||||
<button onClick={() => copyPrompt(i)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[.64rem] cursor-pointer transition-colors"
|
||||
style={{ background: 'var(--bg3)', color: m.copied ? 'var(--gn)' : 'var(--t3)', border: '1px solid var(--bd)' }}>
|
||||
{m.copied ? <Check size={11} /> : <Copy size={11} />}
|
||||
{m.copied ? t('pg.copied') : t('pg.copy')}
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
const url = `/terraform`;
|
||||
window.open(url, '_blank');
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[.64rem] cursor-pointer transition-colors"
|
||||
style={{ background: 'var(--ppl)', color: 'var(--pp)', border: '1px solid var(--pp)' }}>
|
||||
<ExternalLink size={11} /> Terraform
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* retry on failure */}
|
||||
{m.failed && (
|
||||
<button onClick={() => retry(i)}
|
||||
className="flex items-center gap-1 mt-1.5 px-2 py-1 rounded-md text-[.64rem] cursor-pointer"
|
||||
style={{ background: 'var(--rdl)', color: 'var(--rd)', border: '1px solid var(--rd)' }}>
|
||||
<RefreshCw size={11} /> {t('pg.retry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* loading indicator */}
|
||||
{loading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-xl px-4 py-3 text-[.8rem] flex items-center gap-2"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t3)' }}>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{t('pg.loading')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* input area */}
|
||||
<div className="px-4 py-3 border-t" style={{ borderColor: 'var(--bd)', background: 'var(--bg1)' }}>
|
||||
<div className="flex items-end gap-2 max-w-3xl mx-auto">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); autoGrow(e.target); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
|
||||
}}
|
||||
placeholder={t('pg.placeholder')}
|
||||
rows={1}
|
||||
className="flex-1 px-3 py-2.5 rounded-xl text-[.8rem] resize-none outline-none"
|
||||
style={{
|
||||
background: 'var(--bg2)', color: 'var(--t1)',
|
||||
border: '1px solid var(--bd)', maxHeight: 200, overflow: 'auto',
|
||||
}}
|
||||
/>
|
||||
<button onClick={() => send()} disabled={loading || !input.trim()}
|
||||
className="p-2.5 rounded-xl cursor-pointer transition-colors disabled:opacity-40"
|
||||
style={{ background: '#7b42bc', color: '#fff', border: 'none' }}>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Copy code block button ── */
|
||||
|
||||
function CopyCodeBtn({ children }: { children: React.ReactNode }) {
|
||||
const extractText = (node: React.ReactNode): string => {
|
||||
if (typeof node === 'string') return node;
|
||||
if (Array.isArray(node)) return node.map(extractText).join('');
|
||||
if (node && typeof node === 'object' && 'props' in node) {
|
||||
return extractText((node as React.ReactElement<{ children?: React.ReactNode }>).props.children);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className="absolute top-2 right-2 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
style={{ background: 'var(--bg4)', color: 'var(--t3)' }}
|
||||
onClick={() => navigator.clipboard.writeText(extractText(children))}
|
||||
title={useI18n.getState().t('common.copy')}>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Dropdown item ── */
|
||||
|
||||
function DdItem({ label, selected, onClick }: { label: string; selected: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button onClick={onClick}
|
||||
className="w-full text-left px-2.5 py-1.5 rounded-md text-[.72rem] cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: selected ? 'var(--ppl)' : 'transparent',
|
||||
color: selected ? 'var(--pp)' : 'var(--t2)',
|
||||
border: 'none',
|
||||
}}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
1804
frontend/src/pages/ReportsPage.tsx
Normal file
1804
frontend/src/pages/ReportsPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
329
frontend/src/pages/TerminalPage.tsx
Normal file
329
frontend/src/pages/TerminalPage.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Terminal, Loader2, ChevronDown, HelpCircle, X } from 'lucide-react';
|
||||
import client from '@/api/client';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { useTerminalStore } from '@/stores/terminal';
|
||||
|
||||
interface OciCfg { id: string; tenancy_name: string; }
|
||||
|
||||
const FONT = "'JetBrains Mono','Fira Code','Cascadia Code','SF Mono','Consolas','Liberation Mono',monospace";
|
||||
const BG = '#0c0c0c';
|
||||
const FG = '#cccccc';
|
||||
const GREEN = '#16c60c';
|
||||
const BLUE = '#3b78ff';
|
||||
const RED = '#e74856';
|
||||
const DIM = '#767676';
|
||||
const YELLOW = '#f9f1a5';
|
||||
|
||||
export default function TerminalPage() {
|
||||
const { t } = useI18n();
|
||||
const { lines, selectedCfg, cmdHistory, addLine, clearLines, setSelectedCfg, setCmdHistory, addToHistory } = useTerminalStore();
|
||||
const [configs, setConfigs] = useState<OciCfg[]>([]);
|
||||
const [command, setCommand] = useState('');
|
||||
const [running, setRunning] = useState(false);
|
||||
const [histIdx, setHistIdx] = useState(-1);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [tabCount, setTabCount] = useState(0);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
client.get('/oci/configs').then((data: any) => {
|
||||
setConfigs(data);
|
||||
if (data.length > 0 && !selectedCfg) setSelectedCfg(data[0].id);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (containerRef.current) containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(scrollToBottom, [lines, running, suggestions, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCfg && cmdHistory.length === 0) {
|
||||
client.get(`/terminal/history?oci_config_id=${selectedCfg}&limit=100`).then((data: any) => {
|
||||
setCmdHistory(data.map((h: any) => h.command).reverse());
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, [selectedCfg]);
|
||||
|
||||
|
||||
const cfgName = configs.find(c => c.id === selectedCfg)?.tenancy_name || 'oci';
|
||||
const PS1_user = `agent@${cfgName}`;
|
||||
|
||||
const execute = useCallback(async () => {
|
||||
const cmd = command.trim();
|
||||
if (!cmd) return;
|
||||
if (!selectedCfg) { addLine('error', t('term.selectConfig')); return; }
|
||||
if (cmd === 'clear') { clearLines(); setCommand(''); return; }
|
||||
if (cmd === 'history') {
|
||||
addLine('input', cmd);
|
||||
cmdHistory.forEach((h, i) => addLine('output', ` ${String(i + 1).padStart(4)} ${h}`));
|
||||
setCommand(''); return;
|
||||
}
|
||||
if (cmd === 'help') {
|
||||
addLine('input', cmd);
|
||||
addLine('info', 'Commands:');
|
||||
addLine('info', ' oci <service> <resource> <action> [opts] Run OCI CLI command');
|
||||
addLine('info', ' ocid1.instance.oc1.xxx... Auto-lookup resource by OCID');
|
||||
addLine('info', ' find <name> Search resource by display name');
|
||||
addLine('info', ' find %partial% Search with partial match');
|
||||
addLine('info', ' find 10.0.1.5 Search by private IP');
|
||||
addLine('info', ' clear Clear screen');
|
||||
addLine('info', ' history Command history');
|
||||
addLine('info', ' help This message');
|
||||
addLine('info', '');
|
||||
addLine('info', 'Shortcuts:');
|
||||
addLine('info', ' Tab Autocomplete');
|
||||
addLine('info', ' ↑ / ↓ Browse history');
|
||||
addLine('info', ' Ctrl+L Clear screen');
|
||||
setCommand(''); return;
|
||||
}
|
||||
|
||||
addLine('input', cmd);
|
||||
setCommand('');
|
||||
addToHistory(cmd);
|
||||
setHistIdx(-1);
|
||||
setSuggestions([]);
|
||||
setRunning(true);
|
||||
|
||||
try {
|
||||
const resp = await client.post('/terminal/execute', { oci_config_id: selectedCfg, command: cmd }) as any;
|
||||
if (resp.resolved_cmd) addLine('info', `→ ${resp.resolved_cmd}`);
|
||||
if (resp.output) addLine('output', resp.output);
|
||||
if (resp.error) addLine('error', resp.error);
|
||||
} catch (err: any) {
|
||||
addLine('error', err?.message || err?.detail || String(err));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [command, selectedCfg, addLine, cmdHistory, t]);
|
||||
|
||||
const handleTab = useCallback(async () => {
|
||||
if (!command.trim() || running) return;
|
||||
try {
|
||||
const resp = await client.get(`/terminal/completions?prefix=${encodeURIComponent(command)}`) as any;
|
||||
const sugs: string[] = resp.suggestions || [];
|
||||
if (sugs.length === 0) return;
|
||||
if (sugs.length === 1) {
|
||||
const parts = command.trimEnd().split(' ');
|
||||
if (command.endsWith(' ')) setCommand(command + sugs[0] + ' ');
|
||||
else { parts[parts.length - 1] = sugs[0]; setCommand(parts.join(' ') + ' '); }
|
||||
setSuggestions([]); setTabCount(0);
|
||||
} else {
|
||||
if (tabCount > 0) { addLine('input', command); addLine('info', sugs.join(' ')); }
|
||||
setSuggestions(sugs); setTabCount(prev => prev + 1);
|
||||
let common = sugs[0];
|
||||
for (const s of sugs) { while (!s.startsWith(common)) common = common.slice(0, -1); }
|
||||
if (common) {
|
||||
const parts = command.trimEnd().split(' ');
|
||||
if (command.endsWith(' ')) { if (common.length > 0) setCommand(command + common); }
|
||||
else if (common.length > (parts[parts.length - 1] || '').length) {
|
||||
parts[parts.length - 1] = common; setCommand(parts.join(' '));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [command, running, tabCount, addLine]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Tab') { e.preventDefault(); handleTab(); return; }
|
||||
setTabCount(0); setSuggestions([]);
|
||||
if (e.key === 'Enter' && !running) execute();
|
||||
else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (cmdHistory.length > 0) {
|
||||
const n = histIdx < cmdHistory.length - 1 ? histIdx + 1 : histIdx;
|
||||
setHistIdx(n); setCommand(cmdHistory[cmdHistory.length - 1 - n] || '');
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (histIdx > 0) { setHistIdx(histIdx - 1); setCommand(cmdHistory[cmdHistory.length - histIdx] || ''); }
|
||||
else { setHistIdx(-1); setCommand(''); }
|
||||
} else if (e.key === 'l' && e.ctrlKey) { e.preventDefault(); clearLines(); }
|
||||
};
|
||||
|
||||
const Prompt = () => (
|
||||
<span>
|
||||
<span style={{ color: GREEN, fontWeight: 700 }}>{PS1_user}</span>
|
||||
<span style={{ color: DIM }}>:</span>
|
||||
<span style={{ color: BLUE, fontWeight: 700 }}>~</span>
|
||||
<span style={{ color: FG }}>$ </span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 0 }}>
|
||||
{/* Top bar — config selector */}
|
||||
<div className="flex items-center gap-3 px-3 py-2" style={{ flexShrink: 0, background: 'var(--bg1)', borderBottom: '1px solid var(--bd)' }}>
|
||||
<Terminal size={15} style={{ color: 'var(--ac)' }} />
|
||||
<span className="text-xs font-bold" style={{ color: 'var(--t1)' }}>OCI CLI</span>
|
||||
<div className="relative">
|
||||
<select value={selectedCfg} onChange={e => { setSelectedCfg(e.target.value); clearLines(); setCmdHistory([]); }}
|
||||
className="pl-2.5 pr-7 py-1.5 rounded text-xs font-medium outline-none cursor-pointer appearance-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 180 }}>
|
||||
<option value="">-- {t('term.selectConfig')} --</option>
|
||||
{configs.map(c => <option key={c.id} value={c.id}>{c.tenancy_name}</option>)}
|
||||
</select>
|
||||
<ChevronDown size={12} className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none" style={{ color: 'var(--t3)' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded text-xs font-medium transition-all"
|
||||
style={{
|
||||
background: showHelp ? 'var(--acl)' : 'var(--bg2)',
|
||||
border: `1px solid ${showHelp ? 'var(--ac)' : 'var(--bd)'}`,
|
||||
color: showHelp ? 'var(--ac)' : 'var(--t3)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<HelpCircle size={13} />
|
||||
Help
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Help panel */}
|
||||
{showHelp && (
|
||||
<div style={{ flexShrink: 0, background: 'var(--bg2)', borderBottom: '1px solid var(--bd)', padding: '12px 16px', fontSize: '0.75rem' }}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-bold" style={{ color: 'var(--t1)' }}>Terminal Commands</span>
|
||||
<button onClick={() => setShowHelp(false)} style={{ color: 'var(--t4)', cursor: 'pointer', background: 'none', border: 'none' }}><X size={14} /></button>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 24px', color: 'var(--t2)' }}>
|
||||
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>oci <service> <resource> <action></code><span style={{ color: 'var(--t4)' }}>Execute OCI CLI command</span></div>
|
||||
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>oci</code><span style={{ color: 'var(--t4)' }}>Show OCI CLI help</span></div>
|
||||
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>ocid1.instance.oc1.xxx...</code><span style={{ color: 'var(--t4)' }}>Auto-lookup resource by OCID</span></div>
|
||||
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>find <name></code><span style={{ color: 'var(--t4)' }}>Search resource by display name</span></div>
|
||||
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>find %partial%</code><span style={{ color: 'var(--t4)' }}>Search with partial match (LIKE)</span></div>
|
||||
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>find 10.0.1.5</code><span style={{ color: 'var(--t4)' }}>Search by private IP address</span></div>
|
||||
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>clear</code><span style={{ color: 'var(--t4)' }}>Clear screen</span></div>
|
||||
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>history</code><span style={{ color: 'var(--t4)' }}>Show command history</span></div>
|
||||
</div>
|
||||
<div className="mt-2 pt-2" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<span className="text-xs font-bold" style={{ color: 'var(--t1)' }}>Shortcuts</span>
|
||||
<div className="flex gap-6 mt-1" style={{ color: 'var(--t4)' }}>
|
||||
<span><kbd style={{ background: 'var(--bg3)', padding: '1px 5px', borderRadius: 3, fontSize: '0.65rem', fontFamily: FONT }}>Tab</kbd> Autocomplete</span>
|
||||
<span><kbd style={{ background: 'var(--bg3)', padding: '1px 5px', borderRadius: 3, fontSize: '0.65rem', fontFamily: FONT }}>↑↓</kbd> History</span>
|
||||
<span><kbd style={{ background: 'var(--bg3)', padding: '1px 5px', borderRadius: 3, fontSize: '0.65rem', fontFamily: FONT }}>Ctrl+L</kbd> Clear</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terminal body — single continuous scroll */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
background: BG,
|
||||
padding: '8px 12px',
|
||||
fontFamily: FONT,
|
||||
fontSize: '13px',
|
||||
lineHeight: '20px',
|
||||
color: FG,
|
||||
cursor: 'text',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `#333 ${BG}`,
|
||||
}}
|
||||
>
|
||||
{/* MOTD */}
|
||||
{lines.length === 0 && !running && (
|
||||
<>
|
||||
<span style={{ color: GREEN }}>Oracle Cloud Infrastructure CLI</span>{'\n'}
|
||||
<span style={{ color: DIM }}>Type </span>
|
||||
<span style={{ color: YELLOW }}>help</span>
|
||||
<span style={{ color: DIM }}> for commands, </span>
|
||||
<span style={{ color: YELLOW }}>Tab</span>
|
||||
<span style={{ color: DIM }}> for autocomplete</span>{'\n\n'}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* All output lines — continuous flow */}
|
||||
{lines.map((line, i) => {
|
||||
if (line.type === 'input') {
|
||||
return <span key={i}><Prompt /><span style={{ color: FG }}>{line.text}</span>{'\n'}</span>;
|
||||
}
|
||||
if (line.type === 'error') {
|
||||
return <span key={i} style={{ color: RED }}>{line.text}{'\n'}</span>;
|
||||
}
|
||||
if (line.type === 'info') {
|
||||
return <span key={i} style={{ color: DIM }}>{line.text}{'\n'}</span>;
|
||||
}
|
||||
// output
|
||||
return <span key={i} style={{ color: FG }}>{line.text}{'\n'}</span>;
|
||||
})}
|
||||
|
||||
{/* Autocomplete suggestions */}
|
||||
{suggestions.length > 1 && !running && (
|
||||
<span style={{ color: DIM }}>{suggestions.join(' ')}{'\n'}</span>
|
||||
)}
|
||||
|
||||
{/* Running indicator */}
|
||||
{running && (
|
||||
<span style={{ color: BLUE }}>
|
||||
<Loader2 size={12} className="animate-spin" style={{ display: 'inline-block', verticalAlign: 'middle', marginRight: 6 }} />
|
||||
{t('term.executing')}{'\n'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Active input line — part of the scroll, not a separate bar */}
|
||||
{!running && (
|
||||
<span style={{ display: 'inline' }}>
|
||||
<Prompt />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={command}
|
||||
onChange={e => { setCommand(e.target.value); setTabCount(0); setSuggestions([]); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: FG,
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
width: `${Math.max(1, command.length + 1)}ch`,
|
||||
caretColor: FG,
|
||||
verticalAlign: 'baseline',
|
||||
}}
|
||||
/>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: '8px',
|
||||
height: '15px',
|
||||
background: FG,
|
||||
verticalAlign: 'text-bottom',
|
||||
animation: 'termBlink 1s step-end infinite',
|
||||
marginLeft: command ? 0 : -1,
|
||||
}} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes termBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1586
frontend/src/pages/TerraformPage.tsx
Normal file
1586
frontend/src/pages/TerraformPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
462
frontend/src/pages/WorkspacesPage.tsx
Normal file
462
frontend/src/pages/WorkspacesPage.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
FolderOpen, RefreshCw, ChevronRight, Cloud, Download,
|
||||
Play, Rocket, Flame, X, ExternalLink, Ban, Clock,
|
||||
} from 'lucide-react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { terraformApi, type Workspace, type WsStatus } from '@/api/endpoints/terraform';
|
||||
|
||||
/* ── helpers ── */
|
||||
|
||||
type FilterKey = 'all' | 'active' | 'draft' | 'failed' | 'destroyed';
|
||||
|
||||
const RUNNING: WsStatus[] = ['planning', 'applying', 'destroying'];
|
||||
const ACTIVE: WsStatus[] = ['applied', 'planned', 'planning', 'applying'];
|
||||
|
||||
function dateLabel(dt: string | undefined): string {
|
||||
const tFn = useI18n.getState().t;
|
||||
if (!dt) return tFn('ws.noDate');
|
||||
const slice = dt.slice(0, 10);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
||||
if (slice === today) return tFn('chat.today');
|
||||
if (slice === yesterday) return tFn('chat.yesterday');
|
||||
const d = new Date(slice + 'T00:00:00');
|
||||
const diff = Math.floor((Date.now() - d.getTime()) / 86400000);
|
||||
if (diff < 7) return d.toLocaleDateString('pt-BR', { weekday: 'long' });
|
||||
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
function timeAgo(ts: string | undefined): string {
|
||||
if (!ts) return '—';
|
||||
const d = new Date(ts + 'Z');
|
||||
const s = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
const tFn = useI18n.getState().t;
|
||||
if (s < 60) return tFn('chat.timeNow');
|
||||
if (s < 3600) return Math.floor(s / 60) + 'min';
|
||||
if (s < 86400) return Math.floor(s / 3600) + 'h';
|
||||
return Math.floor(s / 86400) + 'd';
|
||||
}
|
||||
|
||||
const BADGE: Record<string, { cls: string; label: string }> = {
|
||||
draft: { cls: 'bg-[var(--bg3)] text-[var(--t3)]', label: 'Draft' },
|
||||
planning: { cls: 'bg-[var(--ppl)] text-[var(--pp)]', label: 'Planning...' },
|
||||
planned: { cls: 'bg-[var(--gnl)] text-[var(--gn)]', label: 'Plan OK' },
|
||||
applying: { cls: 'bg-[var(--bll)] text-[var(--bl)]', label: 'Applying...' },
|
||||
applied: { cls: 'bg-[var(--gnl)] text-[var(--gn)]', label: 'Applied' },
|
||||
destroying: { cls: 'bg-[var(--rdl)] text-[var(--rd)]', label: 'Destroying...' },
|
||||
destroyed: { cls: 'bg-[var(--bg3)] text-[var(--t4)]', label: 'Destroyed' },
|
||||
failed: { cls: 'bg-[var(--rdl)] text-[var(--rd)]', label: 'Failed' },
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: WsStatus }) {
|
||||
const b = BADGE[status] || BADGE.draft;
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[.68rem] font-semibold whitespace-nowrap ${b.cls}`}>
|
||||
{(RUNNING.includes(status)) && (
|
||||
<span className="inline-block w-2 h-2 rounded-full mr-1.5 animate-pulse"
|
||||
style={{ background: 'currentColor' }} />
|
||||
)}
|
||||
{b.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── component ── */
|
||||
|
||||
export default function WorkspacesPage() {
|
||||
const ociCfg = useAppStore((s) => s.ociCfg);
|
||||
const { t } = useI18n();
|
||||
|
||||
const [list, setList] = useState<Workspace[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<FilterKey>('all');
|
||||
const [closedGroups, setClosedGroups] = useState<Set<string>>(new Set());
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
/* confirm modals */
|
||||
const [confirmDestroy, setConfirmDestroy] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [destroyInput, setDestroyInput] = useState('');
|
||||
|
||||
const pollRef = useRef<Set<string>>(new Set());
|
||||
|
||||
/* ── load ── */
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const ws = await terraformApi.listWorkspaces();
|
||||
setList(ws);
|
||||
} catch {
|
||||
setList([]);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
/* ── polling ── */
|
||||
const pollWs = useCallback(async (wid: string) => {
|
||||
if (pollRef.current.has(wid)) return;
|
||||
pollRef.current.add(wid);
|
||||
for (let i = 0; i < 300; i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
try {
|
||||
const r = await terraformApi.status(wid);
|
||||
setList((prev) =>
|
||||
prev.map((w) =>
|
||||
w.id === wid
|
||||
? {
|
||||
...w,
|
||||
status: r.status,
|
||||
plan_output: r.plan_output || w.plan_output,
|
||||
apply_output: r.apply_output || w.apply_output,
|
||||
destroy_output: r.destroy_output || w.destroy_output,
|
||||
error: r.error || '',
|
||||
}
|
||||
: w,
|
||||
),
|
||||
);
|
||||
if (!RUNNING.includes(r.status)) {
|
||||
pollRef.current.delete(wid);
|
||||
load();
|
||||
return;
|
||||
}
|
||||
} catch { /* keep polling */ }
|
||||
}
|
||||
pollRef.current.delete(wid);
|
||||
}, [load]);
|
||||
|
||||
/* ── actions ── */
|
||||
const doPlan = async (wid: string) => {
|
||||
try { await terraformApi.plan(wid); } catch (e: unknown) { alert(t('common.errorPrefix') + (e as Error).message); return; }
|
||||
load(); pollWs(wid);
|
||||
};
|
||||
const doApply = async (wid: string) => {
|
||||
try { await terraformApi.apply(wid); } catch (e: unknown) { alert(t('common.errorPrefix') + (e as Error).message); return; }
|
||||
load(); pollWs(wid);
|
||||
};
|
||||
const doDestroy = async () => {
|
||||
if (!confirmDestroy || destroyInput !== 'DESTROY') return;
|
||||
const wid = confirmDestroy;
|
||||
setConfirmDestroy(null); setDestroyInput('');
|
||||
try { await terraformApi.destroy(wid); } catch (e: unknown) { alert(t('common.errorPrefix') + (e as Error).message); load(); return; }
|
||||
load(); pollWs(wid);
|
||||
};
|
||||
const doDelete = async () => {
|
||||
if (!confirmDelete) return;
|
||||
const wid = confirmDelete;
|
||||
setConfirmDelete(null);
|
||||
try { await terraformApi.deleteWorkspace(wid); } catch (e: unknown) { alert(t('common.errorPrefix') + (e as Error).message); }
|
||||
load();
|
||||
};
|
||||
const doCancel = async (wid: string) => {
|
||||
try { await terraformApi.cancel(wid); } catch { /* noop */ }
|
||||
load();
|
||||
};
|
||||
|
||||
/* ── filter + group ── */
|
||||
let items = list;
|
||||
if (filter === 'active') items = list.filter((w) => ACTIVE.includes(w.status));
|
||||
else if (filter === 'destroyed') items = list.filter((w) => w.status === 'destroyed');
|
||||
else if (filter === 'failed') items = list.filter((w) => w.status === 'failed');
|
||||
else if (filter === 'draft') items = list.filter((w) => w.status === 'draft');
|
||||
|
||||
const counts = {
|
||||
all: list.length,
|
||||
active: list.filter((w) => ACTIVE.includes(w.status)).length,
|
||||
draft: list.filter((w) => w.status === 'draft').length,
|
||||
failed: list.filter((w) => w.status === 'failed').length,
|
||||
destroyed: list.filter((w) => w.status === 'destroyed').length,
|
||||
};
|
||||
|
||||
const groups: { label: string; date: string; items: Workspace[] }[] = [];
|
||||
const gMap: Record<string, number> = {};
|
||||
for (const w of items) {
|
||||
const dt = (w.updated_at || w.created_at || '').slice(0, 10);
|
||||
const label = dateLabel(dt);
|
||||
if (gMap[label] === undefined) { gMap[label] = groups.length; groups.push({ label, date: dt, items: [] }); }
|
||||
groups[gMap[label]].items.push(w);
|
||||
}
|
||||
|
||||
const toggleGroup = (label: string) =>
|
||||
setClosedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(label)) next.delete(label); else next.add(label);
|
||||
return next;
|
||||
});
|
||||
|
||||
const ociName = (id: string) => {
|
||||
const oc = ociCfg.find((c) => c.id === id);
|
||||
return oc ? (oc.tenancy_name || oc.region || id) : '—';
|
||||
};
|
||||
|
||||
/* ── render ── */
|
||||
return (
|
||||
<div className="page-full flex flex-col">
|
||||
{/* header */}
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b"
|
||||
style={{ borderColor: 'var(--bd)', background: 'var(--bg1)' }}>
|
||||
<FolderOpen size={20} style={{ color: 'var(--pp)' }} />
|
||||
<h2 className="text-base font-bold" style={{ color: 'var(--t1)' }}>{t('ws.title')}</h2>
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* filters */}
|
||||
<div className="flex gap-1">
|
||||
{([
|
||||
['all', t('ws.all')],
|
||||
['active', t('ws.active')],
|
||||
['draft', t('ws.draft')],
|
||||
['failed', t('ws.failed')],
|
||||
['destroyed', t('ws.destroyed')],
|
||||
] as [FilterKey, string][]).map(([k, l]) => (
|
||||
<button key={k} onClick={() => setFilter(k)}
|
||||
className="px-2.5 py-1 rounded-full text-[.68rem] font-medium transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: filter === k ? 'var(--pp)' : 'var(--bg3)',
|
||||
color: filter === k ? '#fff' : 'var(--t3)',
|
||||
border: 'none',
|
||||
}}>
|
||||
{l} ({counts[k]})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button onClick={load} disabled={loading}
|
||||
className="btn btn-secondary btn-sm">
|
||||
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
||||
{loading ? t('ws.loading') : t('ws.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* body */}
|
||||
<div className="flex-1 overflow-y-auto p-4" style={{ background: 'var(--bg)' }}>
|
||||
{groups.length === 0 && !loading && (
|
||||
<div className="empty-state">
|
||||
<FolderOpen size={40} />
|
||||
<h3>{t('ws.noWs')}</h3>
|
||||
<p>{t('ws.noWsHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.length === 0 && loading && (
|
||||
<div className="flex items-center justify-center py-16" style={{ color: 'var(--t4)' }}>
|
||||
<RefreshCw size={18} className="animate-spin mr-2" />
|
||||
<span className="text-sm">{t('ws.loadingWs')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map((g) => {
|
||||
const open = !closedGroups.has(g.label);
|
||||
return (
|
||||
<div key={g.label} className="mb-3">
|
||||
{/* group header */}
|
||||
<button onClick={() => toggleGroup(g.label)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg cursor-pointer transition-colors"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)' }}>
|
||||
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''}`}
|
||||
style={{ color: 'var(--t3)' }} />
|
||||
<span className="text-[.78rem] font-semibold" style={{ color: 'var(--t1)' }}>{g.label}</span>
|
||||
<span className="text-[.66rem]" style={{ color: 'var(--t4)' }}>
|
||||
{g.items.length} workspace{g.items.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* group body */}
|
||||
{open && (
|
||||
<div className="mt-1 space-y-1 pl-1">
|
||||
{g.items.map((w) => {
|
||||
const running = RUNNING.includes(w.status);
|
||||
const hasOutput = !!(w.plan_output || w.apply_output || w.destroy_output || w.error);
|
||||
const isExpanded = expanded === w.id;
|
||||
const title = w.session_title || w.name || 'workspace';
|
||||
|
||||
return (
|
||||
<div key={w.id}>
|
||||
{/* row */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors group"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}
|
||||
onClick={() => {
|
||||
if (hasOutput && !running) setExpanded(isExpanded ? null : w.id);
|
||||
}}>
|
||||
<StatusBadge status={w.status} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[.78rem] font-medium truncate" style={{ color: 'var(--t1)' }}
|
||||
title={title}>
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5 text-[.66rem]" style={{ color: 'var(--t4)' }}>
|
||||
<span className="flex items-center gap-1">
|
||||
<Cloud size={10} /> {ociName(w.oci_config_id)}
|
||||
</span>
|
||||
{w.compartment_id && (
|
||||
<span title={w.compartment_id}>...{w.compartment_id.slice(-8)}</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={10} /> {timeAgo(w.updated_at || w.created_at)}
|
||||
</span>
|
||||
{hasOutput && !running && (
|
||||
<span style={{ color: 'var(--pp)', cursor: 'pointer' }}>
|
||||
{isExpanded ? `▾ ${t('ws.hideOutput')}` : `▸ ${t('ws.viewOutput')}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* actions */}
|
||||
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
{running ? (
|
||||
<ActionBtn color="var(--rd)" icon={<Ban size={12} />}
|
||||
label={t('ws.cancel')} onClick={() => doCancel(w.id)} />
|
||||
) : (
|
||||
<>
|
||||
<ActionBtn color="var(--bg3)" textColor="var(--t2)"
|
||||
icon={<ExternalLink size={12} />} label={t('ws.open')}
|
||||
onClick={() => window.open(`/terraform?ws=${w.id}`, '_blank')} />
|
||||
|
||||
{['draft', 'failed', 'destroyed', 'planned', 'applied'].includes(w.status) && (
|
||||
<ActionBtn color="var(--pp)" icon={<Play size={12} />}
|
||||
label={t('ws.plan')} onClick={() => doPlan(w.id)} />
|
||||
)}
|
||||
|
||||
{w.status === 'planned' && (
|
||||
<ActionBtn color="var(--gn)" icon={<Rocket size={12} />}
|
||||
label={t('ws.apply')} onClick={() => doApply(w.id)} />
|
||||
)}
|
||||
|
||||
{(w.status === 'applied' || w.status === 'planned') && (
|
||||
<ActionBtn color="var(--rd)" icon={<Flame size={12} />}
|
||||
label={t('ws.destroy')} onClick={() => { setConfirmDestroy(w.id); setDestroyInput(''); }} />
|
||||
)}
|
||||
|
||||
<ActionBtn color="transparent" textColor="var(--rd)"
|
||||
borderColor="var(--rd)" icon={<X size={12} />}
|
||||
label="" title={t('ws.delete')}
|
||||
onClick={() => setConfirmDelete(w.id)} />
|
||||
|
||||
<a href={terraformApi.downloadUrl(w.id)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[.66rem] font-medium transition-colors cursor-pointer"
|
||||
style={{ background: 'var(--bg3)', color: 'var(--t3)', border: '1px solid var(--bd)' }}
|
||||
title="Download ZIP" download
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<Download size={12} />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* expanded output */}
|
||||
{isExpanded && hasOutput && (
|
||||
<div className="mt-1 ml-4 rounded-lg overflow-hidden"
|
||||
style={{ border: '1px solid var(--bd)', background: 'var(--bg1)' }}>
|
||||
<pre className="p-3 text-[.68rem] overflow-auto max-h-80 whitespace-pre-wrap"
|
||||
style={{ color: w.error ? 'var(--rd)' : 'var(--t2)', fontFamily: 'var(--fm)' }}>
|
||||
{w.error
|
||||
? 'ERROR: ' + w.error
|
||||
: (w.destroy_output || w.apply_output || w.plan_output || '')}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Destroy confirm modal */}
|
||||
{confirmDestroy && (
|
||||
<Modal onClose={() => setConfirmDestroy(null)}>
|
||||
<h3 className="text-sm font-bold mb-2" style={{ color: 'var(--t1)' }}>{t('ws.confirmDestroy')}</h3>
|
||||
<p className="text-[.76rem] mb-3" style={{ color: 'var(--t2)' }}>
|
||||
{t('ws.confirmDestroyMsg')}
|
||||
{' '}
|
||||
<span dangerouslySetInnerHTML={{ __html: t('ws.typeDestroyLabel') }} />
|
||||
</p>
|
||||
<input type="text" value={destroyInput}
|
||||
onChange={(e) => setDestroyInput(e.target.value)}
|
||||
placeholder="DESTROY"
|
||||
className="w-full px-3 py-2 rounded-lg text-sm text-center"
|
||||
style={{
|
||||
background: 'var(--bg2)', color: 'var(--t1)',
|
||||
border: `1px solid ${destroyInput === 'DESTROY' ? 'var(--gn)' : 'var(--rd)'}`,
|
||||
}}
|
||||
autoFocus
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') doDestroy(); }}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 mt-3">
|
||||
<button onClick={() => setConfirmDestroy(null)} className="btn btn-secondary btn-sm">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={doDestroy} disabled={destroyInput !== 'DESTROY'}
|
||||
className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff', borderColor: 'var(--rd)' }}>
|
||||
{t('ws.destroy')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Delete confirm modal */}
|
||||
{confirmDelete && (
|
||||
<Modal onClose={() => setConfirmDelete(null)}>
|
||||
<h3 className="text-sm font-bold mb-2" style={{ color: 'var(--t1)' }}>{t('ws.deleteWs')}</h3>
|
||||
<p className="text-[.76rem] mb-3" style={{ color: 'var(--t2)' }}>
|
||||
{t('ws.deleteWsMsg')}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 mt-3">
|
||||
<button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-sm">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={doDelete} className="btn btn-sm"
|
||||
style={{ background: 'var(--rd)', color: '#fff', borderColor: 'var(--rd)' }}>
|
||||
{t('ws.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Small shared components ── */
|
||||
|
||||
function ActionBtn({
|
||||
color, textColor, borderColor, icon, label, title, onClick,
|
||||
}: {
|
||||
color: string; textColor?: string; borderColor?: string;
|
||||
icon: React.ReactNode; label: string; title?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button onClick={onClick} title={title || label}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[.66rem] font-medium transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: color,
|
||||
color: textColor || '#fff',
|
||||
border: `1px solid ${borderColor || color}`,
|
||||
}}>
|
||||
{icon}
|
||||
{label && <span>{label}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ background: 'rgba(0,0,0,.55)' }} onClick={onClose}>
|
||||
<div className="w-full max-w-sm rounded-xl p-5 animate-[fadeIn_.2s_ease]"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)', boxShadow: 'var(--sh3)' }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
536
frontend/src/pages/admin/AuditPage.tsx
Normal file
536
frontend/src/pages/admin/AuditPage.tsx
Normal file
@@ -0,0 +1,536 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
ScrollText,
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
adminApi,
|
||||
type AuditEntry,
|
||||
type ConfigLogEntry,
|
||||
type ChatLogEntry,
|
||||
} from '@/api/endpoints/admin';
|
||||
import { usePolling } from '@/hooks/usePolling';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
/* ── Helpers ── */
|
||||
|
||||
function formatDate(iso: string) {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z');
|
||||
return d.toLocaleString('pt-BR', {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: string }) {
|
||||
const map: Record<string, { bg: string; fg: string }> = {
|
||||
error: { bg: 'var(--rdl)', fg: 'var(--rd)' },
|
||||
success: { bg: 'var(--gnl)', fg: 'var(--gn)' },
|
||||
info: { bg: 'var(--bll)', fg: 'var(--bl)' },
|
||||
warning: { bg: 'var(--yll)', fg: 'var(--yl)' },
|
||||
};
|
||||
const s = map[severity] ?? { bg: 'var(--bg3)', fg: 'var(--t3)' };
|
||||
return (
|
||||
<span
|
||||
className="inline-block px-2 py-0.5 rounded-full text-[0.68rem] font-semibold"
|
||||
style={{ background: s.bg, color: s.fg }}
|
||||
>
|
||||
{severity}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionTag({ action }: { action: string }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-block px-2 py-0.5 rounded-md text-[0.7rem] font-medium"
|
||||
style={{ background: 'var(--acl)', color: 'var(--ac)', border: '1px solid var(--acl2)' }}
|
||||
>
|
||||
{action}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center py-12"
|
||||
style={{ color: 'var(--t4)' }}
|
||||
>
|
||||
<FileText size={32} className="mb-3 opacity-40" />
|
||||
<p className="text-sm">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-3 rounded-xl text-sm"
|
||||
style={{ background: 'var(--rdl)', color: 'var(--rd)', border: '1px solid var(--rd)' }}
|
||||
>
|
||||
<AlertCircle size={16} />
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingRow({ cols, text }: { cols: number; text: string }) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={cols} className="text-center py-10">
|
||||
<Loader2 size={22} className="inline animate-spin" style={{ color: 'var(--ac)' }} />
|
||||
<span className="ml-2 text-sm" style={{ color: 'var(--t4)' }}>{text}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Shared table wrapper ── */
|
||||
|
||||
const tableContainerStyle: React.CSSProperties = {
|
||||
background: 'var(--bg1)',
|
||||
border: '1px solid var(--bd)',
|
||||
borderRadius: 'var(--r)',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
padding: '0.6rem 0.75rem',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
color: 'var(--t3)',
|
||||
background: 'var(--bg2)',
|
||||
borderBottom: '1px solid var(--bd)',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: '0.55rem 0.75rem',
|
||||
fontSize: '0.76rem',
|
||||
borderBottom: '1px solid var(--bg3)',
|
||||
color: 'var(--t2)',
|
||||
};
|
||||
|
||||
const tdDateStyle: React.CSSProperties = {
|
||||
...tdStyle,
|
||||
fontSize: '0.68rem',
|
||||
color: 'var(--t4)',
|
||||
whiteSpace: 'nowrap',
|
||||
fontFamily: 'var(--fm)',
|
||||
};
|
||||
|
||||
const tdMonoStyle: React.CSSProperties = {
|
||||
...tdStyle,
|
||||
fontSize: '0.68rem',
|
||||
color: 'var(--t4)',
|
||||
fontFamily: 'var(--fm)',
|
||||
};
|
||||
|
||||
/* ── Toolbar ── */
|
||||
|
||||
interface ToolbarProps {
|
||||
limit: number;
|
||||
onLimitChange: (n: number) => void;
|
||||
severity?: string;
|
||||
onSeverityChange?: (s: string) => void;
|
||||
onRefresh: () => void;
|
||||
loading: boolean;
|
||||
showSeverity?: boolean;
|
||||
}
|
||||
|
||||
function Toolbar({
|
||||
limit,
|
||||
onLimitChange,
|
||||
severity,
|
||||
onSeverityChange,
|
||||
onRefresh,
|
||||
loading,
|
||||
showSeverity = false,
|
||||
}: ToolbarProps) {
|
||||
const { t } = useI18n();
|
||||
const selectStyle: React.CSSProperties = {
|
||||
fontSize: '0.72rem',
|
||||
padding: '0.3rem 0.5rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--bd)',
|
||||
background: 'var(--bg2)',
|
||||
color: 'var(--t2)',
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{showSeverity && onSeverityChange && (
|
||||
<select
|
||||
value={severity ?? ''}
|
||||
onChange={(e) => onSeverityChange(e.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">{t('audit.all')}</option>
|
||||
<option value="error">{t('audit.errors')}</option>
|
||||
<option value="success">{t('audit.success')}</option>
|
||||
<option value="info">{t('audit.info')}</option>
|
||||
</select>
|
||||
)}
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(e) => onLimitChange(Number(e.target.value))}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value={50}>50 {t('audit.records')}</option>
|
||||
<option value={100}>100 {t('audit.records')}</option>
|
||||
<option value={200}>200 {t('audit.records')}</option>
|
||||
<option value={500}>500 {t('audit.records')}</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
||||
{t('audit.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Tab definitions ── */
|
||||
|
||||
type TabId = 'audit' | 'config' | 'chat';
|
||||
|
||||
/* ── Audit Log Table ── */
|
||||
|
||||
function AuditLogTable() {
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<AuditEntry[]>([]);
|
||||
const [limit, setLimit] = useState(100);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const rows = await adminApi.getAuditLog(limit);
|
||||
setData(rows);
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Erro ao carregar audit log');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [limit]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
usePolling(async () => { await load(); }, 30_000, true);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>
|
||||
{data.length > 0 && `${data.length} ${t('audit.records')}`}
|
||||
</p>
|
||||
<Toolbar limit={limit} onLimitChange={setLimit} onRefresh={load} loading={loading} />
|
||||
</div>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<div className="overflow-x-auto" style={{ borderRadius: 'var(--r)', border: '1px solid var(--bd)' }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('audit.date')}</th>
|
||||
<th>{t('audit.user')}</th>
|
||||
<th>{t('audit.action')}</th>
|
||||
<th>{t('audit.resource')}</th>
|
||||
<th>{t('audit.details')}</th>
|
||||
<th>{t('audit.ip')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && data.length === 0 ? (
|
||||
<LoadingRow cols={6} text={t('audit.loadingLogs')} />
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6}><EmptyState text={t('audit.noAudit')} /></td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td style={{ whiteSpace: 'nowrap', fontFamily: 'var(--fm)', fontSize: '.72rem', color: 'var(--t4)' }}>{formatDate(row.created_at)}</td>
|
||||
<td>{row.username || '—'}</td>
|
||||
<td><ActionTag action={row.action} /></td>
|
||||
<td style={{ fontFamily: 'var(--fm)', fontSize: '.72rem', color: 'var(--t4)' }} title={row.resource ?? ''}>
|
||||
{row.resource ? (row.resource.length > 20 ? row.resource.slice(0, 20) + '...' : row.resource) : '—'}
|
||||
</td>
|
||||
<td
|
||||
style={{ fontSize: '0.72rem', maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
title={row.details ?? ''}
|
||||
>
|
||||
{row.details?.slice(0, 120) || '—'}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.72rem' }}>{row.ip || '—'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Config Logs Table ── */
|
||||
|
||||
function ConfigLogsTable() {
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<ConfigLogEntry[]>([]);
|
||||
const [limit, setLimit] = useState(50);
|
||||
const [severity, setSeverity] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const rows = await adminApi.getConfigLogs({
|
||||
severity: severity || undefined,
|
||||
limit,
|
||||
});
|
||||
setData(rows);
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Erro ao carregar config logs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [limit, severity]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
usePolling(async () => { await load(); }, 30_000, true);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>
|
||||
{data.length > 0 && `${data.length} ${t('audit.records')}`}
|
||||
</p>
|
||||
<Toolbar
|
||||
limit={limit}
|
||||
onLimitChange={setLimit}
|
||||
severity={severity}
|
||||
onSeverityChange={setSeverity}
|
||||
onRefresh={load}
|
||||
loading={loading}
|
||||
showSeverity
|
||||
/>
|
||||
</div>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<div className="overflow-x-auto" style={{ borderRadius: 'var(--r)', border: '1px solid var(--bd)' }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('audit.date')}</th>
|
||||
<th>{t('audit.config')}</th>
|
||||
<th>{t('audit.action')}</th>
|
||||
<th>{t('audit.status')}</th>
|
||||
<th>{t('audit.message')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && data.length === 0 ? (
|
||||
<LoadingRow cols={5} text={t('audit.loadingLogs')} />
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5}><EmptyState text={t('audit.noConfig')} /></td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td style={{ whiteSpace: 'nowrap', fontFamily: 'var(--fm)', fontSize: '.72rem', color: 'var(--t4)' }}>{formatDate(row.created_at)}</td>
|
||||
<td style={{ fontSize: '0.72rem' }}>
|
||||
{row.config_name || row.config_id?.slice(0, 8) || '—'}
|
||||
</td>
|
||||
<td><ActionTag action={row.action} /></td>
|
||||
<td><SeverityBadge severity={row.severity} /></td>
|
||||
<td
|
||||
style={{ fontSize: '0.72rem', maxWidth: '400px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
title={row.message || ''}
|
||||
>
|
||||
{row.message?.slice(0, 150) || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Chat Logs Table ── */
|
||||
|
||||
function ChatLogsTable() {
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<ChatLogEntry[]>([]);
|
||||
const [limit, setLimit] = useState(50);
|
||||
const [severity, setSeverity] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const rows = await adminApi.getChatLogs({
|
||||
severity: severity || undefined,
|
||||
limit,
|
||||
});
|
||||
setData(rows);
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Erro ao carregar chat logs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [limit, severity]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
usePolling(async () => { await load(); }, 30_000, true);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>
|
||||
{data.length > 0 && `${data.length} ${t('audit.records')}`}
|
||||
</p>
|
||||
<Toolbar
|
||||
limit={limit}
|
||||
onLimitChange={setLimit}
|
||||
severity={severity}
|
||||
onSeverityChange={setSeverity}
|
||||
onRefresh={load}
|
||||
loading={loading}
|
||||
showSeverity
|
||||
/>
|
||||
</div>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<div className="overflow-x-auto" style={{ borderRadius: 'var(--r)', border: '1px solid var(--bd)' }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('audit.date')}</th>
|
||||
<th>{t('audit.session')}</th>
|
||||
<th>{t('audit.source')}</th>
|
||||
<th>{t('audit.action')}</th>
|
||||
<th>{t('audit.status')}</th>
|
||||
<th>{t('audit.message')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && data.length === 0 ? (
|
||||
<LoadingRow cols={6} text={t('audit.loadingLogs')} />
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6}><EmptyState text={t('audit.noChat')} /></td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td style={{ whiteSpace: 'nowrap', fontFamily: 'var(--fm)', fontSize: '.72rem', color: 'var(--t4)' }}>{formatDate(row.created_at)}</td>
|
||||
<td style={{ fontFamily: 'var(--fm)', fontSize: '.72rem', color: 'var(--t4)' }} title={row.session_id ?? ''}>
|
||||
{row.session_id ? row.session_id.slice(0, 8) + '...' : '—'}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.72rem' }}>{row.source || '—'}</td>
|
||||
<td><ActionTag action={row.action} /></td>
|
||||
<td><SeverityBadge severity={row.severity} /></td>
|
||||
<td
|
||||
style={{ fontSize: '0.72rem', maxWidth: '400px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
title={row.message || ''}
|
||||
>
|
||||
{row.message?.slice(0, 150) || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main Page ── */
|
||||
|
||||
export default function AuditPage() {
|
||||
const { t } = useI18n();
|
||||
const [activeTab, setActiveTab] = useState<TabId>('audit');
|
||||
|
||||
const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'audit', label: t('audit.tabAudit'), icon: <FileText size={15} /> },
|
||||
{ id: 'config', label: t('audit.tabConfig'), icon: <ScrollText size={15} /> },
|
||||
{ id: 'chat', label: t('audit.tabChat'), icon: <MessageSquare size={15} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="page" style={{ overflow: 'auto', height: '100%' }}>
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="card-header icon" style={{ background: 'var(--acl)' }}>
|
||||
<FileText size={20} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('audit.title')}</h1>
|
||||
<div className="subtitle">
|
||||
{t('audit.subtitle')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card with tabs + content */}
|
||||
<div className="card">
|
||||
{/* Tab bar */}
|
||||
<div
|
||||
className="flex gap-1 p-1 rounded-xl mb-4"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className="btn btn-sm"
|
||||
style={{
|
||||
background: activeTab === tab.id ? 'var(--bg1)' : 'transparent',
|
||||
color: activeTab === tab.id ? 'var(--t1)' : 'var(--t3)',
|
||||
border: activeTab === tab.id ? '1px solid var(--bd)' : '1px solid transparent',
|
||||
boxShadow: activeTab === tab.id ? 'var(--sh1)' : 'none',
|
||||
}}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div style={{ animation: 'fadeIn .3s ease-out' }}>
|
||||
{activeTab === 'audit' && <AuditLogTable />}
|
||||
{activeTab === 'config' && <ConfigLogsTable />}
|
||||
{activeTab === 'chat' && <ChatLogsTable />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
356
frontend/src/pages/admin/MySettingsPage.tsx
Normal file
356
frontend/src/pages/admin/MySettingsPage.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Settings, Clock, ShieldCheck, ShieldOff, KeyRound, Lock, Loader2,
|
||||
CheckCircle, AlertTriangle, Eye, EyeOff, Save, Languages,
|
||||
} from 'lucide-react';
|
||||
import client from '@/api/client';
|
||||
import { adminApi } from '@/api/endpoints/admin';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
export default function MySettingsPage() {
|
||||
const { t, lang, setLang } = useI18n();
|
||||
const { user: currentUser, checkAuth } = useAuthStore();
|
||||
const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
||||
|
||||
const isLocal = !currentUser?.auth_provider || currentUser.auth_provider === 'local';
|
||||
|
||||
// Timezone
|
||||
const [timezone, setTimezone] = useState('');
|
||||
const [tzOptions, setTzOptions] = useState<string[]>([]);
|
||||
const [tzSaving, setTzSaving] = useState(false);
|
||||
|
||||
// Password
|
||||
const [currentPw, setCurrentPw] = useState('');
|
||||
const [newPw, setNewPw] = useState('');
|
||||
const [confirmPw, setConfirmPw] = useState('');
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [pwSaving, setPwSaving] = useState(false);
|
||||
|
||||
// MFA
|
||||
const [mfaSecret, setMfaSecret] = useState<string | null>(null);
|
||||
const [mfaTotpUri, setMfaTotpUri] = useState<string | null>(null);
|
||||
const [mfaCode, setMfaCode] = useState('');
|
||||
const [mfaLoading, setMfaLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.getMyTimezone().then((d) => setTimezone(d.timezone)).catch(() => {});
|
||||
adminApi.getTimezoneOptions().then((d) => setTzOptions(d.timezones)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (msg) { const timer = setTimeout(() => setMsg(null), 4000); return () => clearTimeout(timer); }
|
||||
}, [msg]);
|
||||
|
||||
/* ── Timezone ── */
|
||||
const handleTzChange = async (tz: string) => {
|
||||
setTzSaving(true);
|
||||
try {
|
||||
await adminApi.setMyTimezone(tz);
|
||||
setTimezone(tz);
|
||||
setMsg({ text: t('settings.tzChanged') || `Timezone alterado para ${tz}`, type: 'success' });
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Erro ao alterar timezone', type: 'error' });
|
||||
} finally {
|
||||
setTzSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Password ── */
|
||||
const handleChangePw = async () => {
|
||||
if (!currentPw || !newPw) { setMsg({ text: t('settings.pwRequired') || 'Preencha todos os campos', type: 'error' }); return; }
|
||||
if (newPw !== confirmPw) { setMsg({ text: t('settings.pwMismatch') || 'As senhas não conferem', type: 'error' }); return; }
|
||||
if (newPw.length < 8) { setMsg({ text: t('settings.pwMinLength') || 'A nova senha deve ter pelo menos 8 caracteres', type: 'error' }); return; }
|
||||
setPwSaving(true);
|
||||
try {
|
||||
await client.post('/auth/change-password', { current_password: currentPw, new_password: newPw });
|
||||
setMsg({ text: t('settings.pwChanged') || 'Senha alterada com sucesso', type: 'success' });
|
||||
setCurrentPw(''); setNewPw(''); setConfirmPw('');
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Erro ao alterar senha', type: 'error' });
|
||||
} finally {
|
||||
setPwSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── MFA ── */
|
||||
const handleMfaSetup = async () => {
|
||||
setMfaLoading(true);
|
||||
try {
|
||||
const data = await client.post('/mfa/setup') as unknown as { secret: string; uri: string };
|
||||
setMfaSecret(data.secret);
|
||||
setMfaTotpUri(data.uri);
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Erro', type: 'error' });
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMfaVerify = async () => {
|
||||
if (mfaCode.length !== 6) { setMsg({ text: t('mfa.invalidCode'), type: 'error' }); return; }
|
||||
setMfaLoading(true);
|
||||
try {
|
||||
await client.post('/mfa/verify', { totp_code: mfaCode });
|
||||
setMsg({ text: t('mfa.activated'), type: 'success' });
|
||||
setMfaSecret(null); setMfaTotpUri(null); setMfaCode('');
|
||||
await checkAuth();
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Codigo invalido', type: 'error' });
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMfaDisable = async () => {
|
||||
if (!currentUser) return;
|
||||
if (!window.confirm(t('mfa.confirmDisable'))) return;
|
||||
setMfaLoading(true);
|
||||
try {
|
||||
await client.post(`/mfa/disable/${currentUser.id}`);
|
||||
setMsg({ text: t('mfa.deactivated'), type: 'success' });
|
||||
setMfaSecret(null); setMfaTotpUri(null); setMfaCode('');
|
||||
await checkAuth();
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Erro', type: 'error' });
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const mfaOn = !!currentUser?.mfa_enabled;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
{msg && (
|
||||
<div className="fixed top-4 right-4 z-50 flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium shadow-lg"
|
||||
style={{
|
||||
background: msg.type === 'success' ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: msg.type === 'success' ? 'var(--gn)' : 'var(--rd)',
|
||||
border: `1px solid color-mix(in srgb, ${msg.type === 'success' ? 'var(--gn)' : 'var(--rd)'} 30%, transparent)`,
|
||||
animation: 'fadeIn .3s ease',
|
||||
}}>
|
||||
{msg.type === 'success' ? <CheckCircle size={16} /> : <AlertTriangle size={16} />}
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<Settings size={24} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('settings.title') || 'Minhas Configurações'}</h1>
|
||||
<div className="subtitle">
|
||||
{currentUser?.first_name} {currentUser?.last_name} — {currentUser?.username}
|
||||
{!isLocal && (
|
||||
<span className="ml-2 px-1.5 py-0.5 rounded text-[.55rem] font-bold" style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>
|
||||
ORACLE IAM
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timezone */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<Clock size={16} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<h2>{t('settings.timezone') || 'Timezone'}</h2>
|
||||
</div>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--t3)' }}>
|
||||
{t('settings.tzDesc') || 'Define o fuso horário para exibição de datas e horários.'}
|
||||
</p>
|
||||
<select
|
||||
value={timezone}
|
||||
onChange={(e) => handleTzChange(e.target.value)}
|
||||
disabled={tzSaving}
|
||||
className="px-3 py-2 rounded-lg text-sm outline-none cursor-pointer"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 280 }}
|
||||
>
|
||||
{tzOptions.map((tz) => (
|
||||
<option key={tz} value={tz}>{tz}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Language */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<Languages size={16} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<h2>{t('settings.language') || 'Idioma'}</h2>
|
||||
</div>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--t3)' }}>
|
||||
{t('settings.langDesc') || 'Selecione o idioma da interface.'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{([['pt', 'Português', '🇧🇷'], ['en', 'English', '🇺🇸'], ['es', 'Español', '🇪🇸']] as const).map(([code, label, flag]) => (
|
||||
<button key={code} onClick={() => setLang(code as any)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-all"
|
||||
style={{
|
||||
background: lang === code ? 'var(--acl)' : 'var(--bg2)',
|
||||
border: `1.5px solid ${lang === code ? 'var(--ac)' : 'var(--bd)'}`,
|
||||
color: lang === code ? 'var(--ac)' : 'var(--t3)',
|
||||
}}>
|
||||
<span className="text-base">{flag}</span>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Change — only for local users */}
|
||||
{isLocal && (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--bll)' }}>
|
||||
<Lock size={16} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
<h2>{t('settings.changePassword') || 'Alterar Senha'}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3" style={{ maxWidth: 400 }}>
|
||||
<div className="form-group">
|
||||
<label>{t('settings.currentPw') || 'Senha Atual'}</label>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
type={showPw ? 'text' : 'password'}
|
||||
value={currentPw}
|
||||
onChange={e => setCurrentPw(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="flex-1"
|
||||
style={{ fontFamily: 'var(--fm)' }}
|
||||
/>
|
||||
<button onClick={() => setShowPw(!showPw)} className="btn btn-secondary btn-sm" style={{ padding: '0 8px' }}>
|
||||
{showPw ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('settings.newPw') || 'Nova Senha'}</label>
|
||||
<input
|
||||
type={showPw ? 'text' : 'password'}
|
||||
value={newPw}
|
||||
onChange={e => setNewPw(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
style={{ fontFamily: 'var(--fm)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('settings.confirmPw') || 'Confirmar Nova Senha'}</label>
|
||||
<input
|
||||
type={showPw ? 'text' : 'password'}
|
||||
value={confirmPw}
|
||||
onChange={e => setConfirmPw(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
style={{ fontFamily: 'var(--fm)' }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleChangePw(); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button onClick={handleChangePw} disabled={pwSaving || !currentPw || !newPw || !confirmPw}
|
||||
className="btn btn-primary self-start disabled:opacity-50">
|
||||
{pwSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
{pwSaving ? (t('settings.saving') || 'Salvando...') : (t('settings.savePw') || 'Alterar Senha')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MFA — only for local users */}
|
||||
{isLocal && (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: mfaOn ? 'var(--gnl)' : 'var(--bg3)' }}>
|
||||
{mfaOn ? <ShieldCheck size={16} style={{ color: 'var(--gn)' }} /> : <Lock size={16} style={{ color: 'var(--t4)' }} />}
|
||||
</div>
|
||||
<h2>{t('settings.mfa') || 'Autenticação Multi-Fator (MFA)'}</h2>
|
||||
<div className="spacer" />
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[.7rem] font-semibold"
|
||||
style={{ background: mfaOn ? 'var(--gnl)' : 'var(--bg3)', color: mfaOn ? 'var(--gn)' : 'var(--t4)' }}>
|
||||
{mfaOn ? t('usr.active') || 'Ativo' : t('usr.inactive') || 'Inativo'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{mfaOn ? (
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<div className="w-16 h-16 rounded-full flex items-center justify-center" style={{ background: 'color-mix(in srgb, var(--gn) 10%, transparent)' }}>
|
||||
<ShieldCheck size={32} style={{ color: 'var(--gn)' }} />
|
||||
</div>
|
||||
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>{t('mfa.enabledDesc')}</p>
|
||||
<button onClick={handleMfaDisable} disabled={mfaLoading}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer"
|
||||
style={{ background: 'var(--rdl)', color: 'var(--rd)', border: '1px solid color-mix(in srgb, var(--rd) 25%, transparent)' }}>
|
||||
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <ShieldOff size={14} />}
|
||||
{t('mfa.disableBtn')}
|
||||
</button>
|
||||
</div>
|
||||
) : mfaSecret && mfaTotpUri ? (
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div className="flex justify-center">
|
||||
<div className="p-3 rounded-xl" style={{ background: '#fff' }}>
|
||||
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(mfaTotpUri)}`}
|
||||
alt="QR Code" width={180} height={180} className="rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2 rounded-lg text-center" style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}>
|
||||
<span className="text-[.6rem] block mb-1" style={{ color: 'var(--t4)' }}>SECRET</span>
|
||||
<code className="text-xs font-bold tracking-wider select-all" style={{ color: 'var(--ac)', fontFamily: 'var(--fm)' }}>{mfaSecret}</code>
|
||||
</div>
|
||||
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>
|
||||
{t('mfa.scanQr') || 'Escaneie o QR code com seu app autenticador e insira o código de 6 dígitos.'}
|
||||
</p>
|
||||
<div className="flex gap-2 justify-center">
|
||||
<input type="text" value={mfaCode} onChange={e => setMfaCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000" maxLength={6} autoFocus
|
||||
className="px-3 py-2 rounded-lg text-sm text-center tracking-[.3em] font-semibold outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', fontFamily: 'var(--fm)', width: 160 }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleMfaVerify(); }} />
|
||||
<button onClick={handleMfaVerify} disabled={mfaLoading || mfaCode.length !== 6}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer disabled:opacity-50"
|
||||
style={{ background: 'var(--gn)', color: '#fff' }}>
|
||||
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <KeyRound size={14} />}
|
||||
{t('mfa.activateMfa')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<div className="w-16 h-16 rounded-full flex items-center justify-center" style={{ background: 'color-mix(in srgb, var(--yl) 10%, transparent)' }}>
|
||||
<KeyRound size={32} style={{ color: 'var(--yl)' }} />
|
||||
</div>
|
||||
<p className="text-xs text-center max-w-sm" style={{ color: 'var(--t3)' }}>
|
||||
{t('mfa.generateHint')}
|
||||
</p>
|
||||
<button onClick={handleMfaSetup} disabled={mfaLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer"
|
||||
style={{ background: 'var(--ac)', color: '#fff' }}>
|
||||
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <KeyRound size={14} />}
|
||||
{t('mfa.generateSecret')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info for federated users */}
|
||||
{!isLocal && (
|
||||
<div className="card" style={{ opacity: 0.7 }}>
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<ShieldCheck size={18} style={{ color: 'var(--t3)' }} />
|
||||
<p className="text-xs" style={{ color: 'var(--t3)' }}>
|
||||
{t('settings.federatedInfo') || 'Senha e MFA são gerenciados pelo Oracle IAM Identity Domain. Configure diretamente no portal OCI.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
266
frontend/src/pages/admin/OracleIamPage.tsx
Normal file
266
frontend/src/pages/admin/OracleIamPage.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Shield, Globe, Key, Users, FlaskConical, Loader2, CheckCircle, AlertTriangle, Save, Eye, EyeOff } from 'lucide-react';
|
||||
import client from '@/api/client';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
interface IamConfig {
|
||||
auth_mode: string;
|
||||
oidc_issuer: string;
|
||||
oidc_client_id: string;
|
||||
oidc_client_secret: string;
|
||||
oidc_redirect_uri: string;
|
||||
oidc_group_admin: string;
|
||||
oidc_group_user: string;
|
||||
oidc_group_viewer: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: IamConfig = {
|
||||
auth_mode: 'local',
|
||||
oidc_issuer: '',
|
||||
oidc_client_id: '',
|
||||
oidc_client_secret: '',
|
||||
oidc_redirect_uri: '',
|
||||
oidc_group_admin: 'CISAgent_Admins',
|
||||
oidc_group_user: 'CISAgent_Users',
|
||||
oidc_group_viewer: 'CISAgent_Viewers',
|
||||
};
|
||||
|
||||
const SETTINGS_KEYS = [
|
||||
'auth_mode', 'oidc_issuer', 'oidc_client_id', 'oidc_client_secret',
|
||||
'oidc_redirect_uri', 'oidc_group_admin', 'oidc_group_user', 'oidc_group_viewer',
|
||||
];
|
||||
|
||||
export default function OracleIamPage() {
|
||||
const { t } = useI18n();
|
||||
const [config, setConfig] = useState<IamConfig>(DEFAULT_CONFIG);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
SETTINGS_KEYS.map(k => client.get(`/settings/${k}`) as unknown as { key: string; value: string })
|
||||
);
|
||||
const loaded: any = { ...DEFAULT_CONFIG };
|
||||
results.forEach(r => { if (r.value) loaded[r.key] = r.value; });
|
||||
setConfig(loaded);
|
||||
} catch {
|
||||
// Settings not found — use defaults
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (msg) { const t = setTimeout(() => setMsg(null), 5000); return () => clearTimeout(t); }
|
||||
}, [msg]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await Promise.all(
|
||||
SETTINGS_KEYS.filter(k => !(k === 'oidc_client_secret' && (config as any)[k]?.includes('\u2022')))
|
||||
.map(k => client.put(`/settings/${k}`, { value: (config as any)[k] || '' }))
|
||||
);
|
||||
setMsg({ text: t('iam.saved') || 'Configuração salva com sucesso', type: 'success' });
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Erro ao salvar', type: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!config.oidc_issuer) {
|
||||
setMsg({ text: t('iam.issuerRequired') || 'Issuer URL é obrigatório', type: 'error' });
|
||||
return;
|
||||
}
|
||||
setTesting(true);
|
||||
try {
|
||||
const resp = await client.post('/settings/oidc/test') as unknown as {
|
||||
ok: boolean; issuer: string;
|
||||
authorization_endpoint: boolean; token_endpoint: boolean; jwks_uri: boolean;
|
||||
};
|
||||
if (resp.ok) {
|
||||
setMsg({ text: `${t('iam.testOk') || 'Conexão OK'} — ${resp.issuer}`, type: 'success' });
|
||||
} else {
|
||||
setMsg({ text: t('iam.testInvalid') || 'Resposta inválida do discovery endpoint', type: 'error' });
|
||||
}
|
||||
} catch (err) {
|
||||
setMsg({ text: `${t('iam.testFail') || 'Falha na conexão'}: ${err instanceof Error ? err.message : 'Erro'}`, type: 'error' });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const update = (key: keyof IamConfig, value: string) => setConfig(prev => ({ ...prev, [key]: value }));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 size={28} className="animate-spin" style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
{msg && (
|
||||
<div className="fixed top-4 right-4 z-50 flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium shadow-lg"
|
||||
style={{
|
||||
background: msg.type === 'success' ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: msg.type === 'success' ? 'var(--gn)' : 'var(--rd)',
|
||||
border: `1px solid color-mix(in srgb, ${msg.type === 'success' ? 'var(--gn)' : 'var(--rd)'} 30%, transparent)`,
|
||||
animation: 'fadeIn .3s ease',
|
||||
}}>
|
||||
{msg.type === 'success' ? <CheckCircle size={16} /> : <AlertTriangle size={16} />}
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--rdl)' }}>
|
||||
<Shield size={24} style={{ color: 'var(--rd)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('iam.title') || 'Oracle IAM Identity Domains'}</h1>
|
||||
<div className="subtitle">{t('iam.subtitle') || 'Integração OIDC para autenticação corporativa'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth Mode */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<Key size={16} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<h2>{t('iam.authMode') || 'Modo de Autenticação'}</h2>
|
||||
</div>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--t3)' }}>
|
||||
{t('iam.authModeDesc') || 'Define como os usuários se autenticam no sistema.'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{(['local', 'oidc', 'both'] as const).map(mode => {
|
||||
const selected = config.auth_mode === mode;
|
||||
const labels: Record<string, { label: string; desc: string }> = {
|
||||
local: { label: t('iam.modeLocal') || 'Local', desc: t('iam.modeLocalDesc') || 'Usuários locais com senha' },
|
||||
oidc: { label: t('iam.modeOidc') || 'Oracle IAM', desc: t('iam.modeOidcDesc') || 'Somente SSO corporativo' },
|
||||
both: { label: t('iam.modeBoth') || 'Ambos', desc: t('iam.modeBothDesc') || 'Local + Oracle IAM' },
|
||||
};
|
||||
return (
|
||||
<button key={mode} onClick={() => update('auth_mode', mode)}
|
||||
className="flex-1 flex flex-col items-center gap-1 py-3 rounded-lg text-xs font-semibold transition-all"
|
||||
style={{
|
||||
background: selected ? 'var(--acl)' : 'var(--bg2)',
|
||||
border: `1.5px solid ${selected ? 'var(--ac)' : 'var(--bd)'}`,
|
||||
color: selected ? 'var(--ac)' : 'var(--t3)',
|
||||
}}>
|
||||
<span className="text-sm">{labels[mode].label}</span>
|
||||
<span className="text-[.6rem] font-normal" style={{ color: selected ? 'var(--ac)' : 'var(--t4)' }}>{labels[mode].desc}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OIDC Configuration */}
|
||||
{config.auth_mode !== 'local' && (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--rdl)' }}>
|
||||
<Globe size={16} style={{ color: 'var(--rd)' }} />
|
||||
</div>
|
||||
<h2>{t('iam.oidcConfig') || 'Configuração OIDC'}</h2>
|
||||
<div className="spacer" />
|
||||
<button onClick={handleTest} disabled={testing} className="btn btn-secondary btn-sm">
|
||||
{testing ? <Loader2 size={12} className="animate-spin" /> : <FlaskConical size={12} />}
|
||||
{t('iam.testConnection') || 'Testar Conexão'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-row" style={{ marginBottom: 12 }}>
|
||||
<div className="form-group" style={{ flex: 2 }}>
|
||||
<label>{t('iam.issuerUrl') || 'Issuer URL (Identity Domain)'}</label>
|
||||
<input type="text" value={config.oidc_issuer} onChange={e => update('oidc_issuer', e.target.value)}
|
||||
placeholder="https://idcs-xxx.identity.oraclecloud.com" style={{ fontFamily: 'var(--fm)' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row" style={{ marginBottom: 12 }}>
|
||||
<div className="form-group">
|
||||
<label>{t('iam.clientId') || 'Client ID'}</label>
|
||||
<input type="text" value={config.oidc_client_id} onChange={e => update('oidc_client_id', e.target.value)}
|
||||
placeholder="ea58b04047fd4d8182a8bad4e3d0aff5" style={{ fontFamily: 'var(--fm)' }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('iam.clientSecret') || 'Client Secret'}</label>
|
||||
<div className="flex gap-1">
|
||||
<input type={showSecret ? 'text' : 'password'} value={config.oidc_client_secret}
|
||||
onChange={e => update('oidc_client_secret', e.target.value)}
|
||||
placeholder="••••••••" className="flex-1" style={{ fontFamily: 'var(--fm)' }} />
|
||||
<button onClick={() => setShowSecret(!showSecret)} className="btn btn-secondary btn-sm" style={{ padding: '0 8px' }}>
|
||||
{showSecret ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>{t('iam.redirectUri') || 'Redirect URI'}</label>
|
||||
<input type="text" value={config.oidc_redirect_uri} onChange={e => update('oidc_redirect_uri', e.target.value)}
|
||||
placeholder="https://yourapp.com/api/auth/oidc/callback" style={{ fontFamily: 'var(--fm)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group Mapping */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--bll)' }}>
|
||||
<Users size={16} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
<h2>{t('iam.groupMapping') || 'Mapeamento de Grupos'}</h2>
|
||||
</div>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--t3)' }}>
|
||||
{t('iam.groupMappingDesc') || 'Mapeie os grupos do OCI Identity Domain para as roles do sistema.'}
|
||||
</p>
|
||||
|
||||
<div className="form-row" style={{ marginBottom: 12 }}>
|
||||
<div className="form-group">
|
||||
<label style={{ color: 'var(--rd)' }}>Admin</label>
|
||||
<input type="text" value={config.oidc_group_admin} onChange={e => update('oidc_group_admin', e.target.value)}
|
||||
placeholder="CISAgent_Admins" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ color: 'var(--bl)' }}>User</label>
|
||||
<input type="text" value={config.oidc_group_user} onChange={e => update('oidc_group_user', e.target.value)}
|
||||
placeholder="CISAgent_Users" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ color: 'var(--t3)' }}>Viewer</label>
|
||||
<input type="text" value={config.oidc_group_viewer} onChange={e => update('oidc_group_viewer', e.target.value)}
|
||||
placeholder="CISAgent_Viewers" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Save */}
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<button onClick={handleSave} disabled={saving} className="btn btn-primary">
|
||||
{saving ? <Loader2 size={15} className="animate-spin" /> : <Save size={15} />}
|
||||
{saving ? (t('iam.saving') || 'Salvando...') : (t('iam.save') || 'Salvar Configuração')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
644
frontend/src/pages/admin/UsersPage.tsx
Normal file
644
frontend/src/pages/admin/UsersPage.tsx
Normal file
@@ -0,0 +1,644 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Users, Plus, Shield, Eye, User as UserIcon, Trash2, X, AlertTriangle, CheckCircle, Loader2, UserPlus, Lock, ShieldCheck, ShieldOff, KeyRound } from 'lucide-react';
|
||||
import client from '@/api/client';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
/* ── Types ── */
|
||||
interface AppUser {
|
||||
id: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
username: string;
|
||||
email: string | null;
|
||||
role: 'admin' | 'user' | 'viewer';
|
||||
mfa_enabled: boolean | number;
|
||||
is_active: boolean | number;
|
||||
created_at: string | null;
|
||||
last_login: string | null;
|
||||
auth_provider?: string;
|
||||
}
|
||||
|
||||
type Role = 'admin' | 'user' | 'viewer';
|
||||
|
||||
const ROLE_CONFIG: Record<Role, { label: string; color: string; bg: string; icon: typeof Shield }> = {
|
||||
admin: { label: 'Admin', color: 'var(--rd)', bg: 'var(--rdl)', icon: Shield },
|
||||
user: { label: 'User', color: 'var(--bl)', bg: 'var(--bll)', icon: UserIcon },
|
||||
viewer: { label: 'Viewer', color: 'var(--t3)', bg: 'var(--bg3)', icon: Eye },
|
||||
};
|
||||
|
||||
/* ── Component ── */
|
||||
export default function UsersPage() {
|
||||
const { t } = useI18n();
|
||||
const { user: currentUser, checkAuth } = useAuthStore();
|
||||
const [users, setUsers] = useState<AppUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// MFA modal state
|
||||
const [mfaUserId, setMfaUserId] = useState<string | null>(null);
|
||||
const [mfaSecret, setMfaSecret] = useState<string | null>(null);
|
||||
const [mfaTotpUri, setMfaTotpUri] = useState<string | null>(null);
|
||||
const [mfaCode, setMfaCode] = useState('');
|
||||
const [mfaLoading, setMfaLoading] = useState(false);
|
||||
const [mfaMsg, setMfaMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
||||
|
||||
// Form state
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState<Role>('viewer');
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const data = await client.get('/users') as unknown as AppUser[];
|
||||
setUsers(data);
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : t('usr.errorLoadUsers'), type: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchUsers(); }, [fetchUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (msg) {
|
||||
const timer = setTimeout(() => setMsg(null), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [msg]);
|
||||
|
||||
const resetForm = () => {
|
||||
setFirstName(''); setLastName(''); setUsername('');
|
||||
setEmail(''); setPassword(''); setRole('viewer');
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!firstName.trim() || !lastName.trim()) {
|
||||
setMsg({ text: t('usr.requiredName'), type: 'error' });
|
||||
return;
|
||||
}
|
||||
if (!username.trim()) {
|
||||
setMsg({ text: t('usr.requiredUsername'), type: 'error' });
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
setMsg({ text: t('usr.requiredPassword'), type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await client.post('/auth/register', {
|
||||
first_name: firstName.trim(),
|
||||
last_name: lastName.trim(),
|
||||
username: username.trim(),
|
||||
email: email.trim() || undefined,
|
||||
password,
|
||||
role,
|
||||
});
|
||||
setMsg({ text: t('usr.created'), type: 'success' });
|
||||
resetForm();
|
||||
setShowForm(false);
|
||||
await fetchUsers();
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : t('usr.errorCreateUser'), type: 'error' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = async (userId: string, newRole: Role) => {
|
||||
try {
|
||||
await client.put(`/users/${userId}`, { role: newRole });
|
||||
setUsers(prev => prev.map(u => u.id === userId ? { ...u, role: newRole } : u));
|
||||
setMsg({ text: t('usr.roleUpdated'), type: 'success' });
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : t('usr.errorUpdateRole'), type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async (userId: string) => {
|
||||
try {
|
||||
await client.delete(`/users/${userId}`);
|
||||
setConfirmDelete(null);
|
||||
setMsg({ text: t('usr.deactivated'), type: 'success' });
|
||||
await fetchUsers();
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : t('usr.errorDeactivate'), type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (d: string | null) => {
|
||||
if (!d) return '---';
|
||||
try {
|
||||
return new Intl.DateTimeFormat('pt-BR', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
}).format(new Date(d));
|
||||
} catch {
|
||||
return d;
|
||||
}
|
||||
};
|
||||
|
||||
// MFA handlers
|
||||
const openMfa = (userId: string) => {
|
||||
setMfaUserId(userId);
|
||||
setMfaSecret(null);
|
||||
setMfaTotpUri(null);
|
||||
setMfaCode('');
|
||||
setMfaMsg(null);
|
||||
};
|
||||
const closeMfa = () => { setMfaUserId(null); setMfaSecret(null); setMfaTotpUri(null); setMfaCode(''); setMfaMsg(null); };
|
||||
|
||||
const handleMfaSetup = async () => {
|
||||
setMfaLoading(true);
|
||||
setMfaMsg(null);
|
||||
try {
|
||||
const data = await client.post('/mfa/setup') as unknown as { secret: string; uri: string };
|
||||
setMfaSecret(data.secret);
|
||||
setMfaTotpUri(data.uri);
|
||||
} catch (err) {
|
||||
setMfaMsg({ text: err instanceof Error ? err.message : 'Erro', type: 'error' });
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMfaVerify = async () => {
|
||||
if (mfaCode.length !== 6) { setMfaMsg({ text: t('mfa.invalidCode'), type: 'error' }); return; }
|
||||
setMfaLoading(true);
|
||||
setMfaMsg(null);
|
||||
try {
|
||||
await client.post('/mfa/verify', { totp_code: mfaCode });
|
||||
setMfaMsg({ text: t('mfa.activated'), type: 'success' });
|
||||
setMfaSecret(null); setMfaTotpUri(null); setMfaCode('');
|
||||
await fetchUsers();
|
||||
await checkAuth();
|
||||
} catch (err) {
|
||||
setMfaMsg({ text: err instanceof Error ? err.message : 'Codigo invalido', type: 'error' });
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMfaDisable = async (userId: string) => {
|
||||
if (!window.confirm(t('mfa.confirmDisable'))) return;
|
||||
setMfaLoading(true);
|
||||
setMfaMsg(null);
|
||||
try {
|
||||
await client.post(`/mfa/disable/${userId}`);
|
||||
setMfaMsg({ text: t('mfa.deactivated'), type: 'success' });
|
||||
await fetchUsers();
|
||||
if (userId === currentUser?.id) await checkAuth();
|
||||
} catch (err) {
|
||||
setMfaMsg({ text: err instanceof Error ? err.message : 'Erro', type: 'error' });
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activeUsers = users.filter(u => u.is_active);
|
||||
const inactiveUsers = users.filter(u => !u.is_active);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 size={28} className="animate-spin" style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
{/* Toast */}
|
||||
{msg && (
|
||||
<div
|
||||
className="fixed top-4 right-4 z-50 flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium shadow-lg"
|
||||
style={{
|
||||
background: msg.type === 'success' ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: msg.type === 'success' ? 'var(--gn)' : 'var(--rd)',
|
||||
border: `1px solid ${msg.type === 'success' ? 'var(--gn)' : 'var(--rd)'}`,
|
||||
borderColor: `color-mix(in srgb, ${msg.type === 'success' ? 'var(--gn)' : 'var(--rd)'} 30%, transparent)`,
|
||||
animation: 'fadeIn .3s ease',
|
||||
}}
|
||||
>
|
||||
{msg.type === 'success' ? <CheckCircle size={16} /> : <AlertTriangle size={16} />}
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<Users size={24} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('usr.title')}</h1>
|
||||
<div className="subtitle">
|
||||
{activeUsers.length} {activeUsers.length !== 1 ? t('usr.activesCount') : t('usr.activeCount')}
|
||||
{inactiveUsers.length > 0 && ` · ${inactiveUsers.length} ${inactiveUsers.length !== 1 ? t('usr.inactivesCount') : t('usr.inactiveCount')}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<span className="count">{users.length} {t('usr.total')}</span>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{showForm ? <X size={16} /> : <Plus size={16} />}
|
||||
{showForm ? t('usr.cancel') : t('usr.newUser')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create User Form */}
|
||||
{showForm && (
|
||||
<div className="card" style={{ animation: 'fadeIn .3s ease' }}>
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<UserPlus size={16} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<h2>{t('usr.createUser')}</h2>
|
||||
</div>
|
||||
|
||||
<div className="form-row" style={{ marginBottom: 12 }}>
|
||||
<div className="form-group">
|
||||
<label>{t('usr.firstName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Joao"
|
||||
value={firstName}
|
||||
onChange={e => setFirstName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('usr.lastName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Silva"
|
||||
value={lastName}
|
||||
onChange={e => setLastName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('usr.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="joao.silva"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
style={{ fontFamily: 'var(--fm)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row" style={{ marginBottom: 16 }}>
|
||||
<div className="form-group">
|
||||
<label>{t('usr.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="joao@empresa.com"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('usr.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('usr.role')}</label>
|
||||
<div className="flex gap-2">
|
||||
{(['viewer', 'user', 'admin'] as Role[]).map(r => {
|
||||
const cfg = ROLE_CONFIG[r];
|
||||
const Icon = cfg.icon;
|
||||
const selected = role === r;
|
||||
return (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRole(r)}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-semibold transition-all duration-200"
|
||||
style={{
|
||||
background: selected ? cfg.bg : 'var(--bg2)',
|
||||
border: `1.5px solid ${selected ? cfg.color : 'var(--bd)'}`,
|
||||
color: selected ? cfg.color : 'var(--t3)',
|
||||
}}
|
||||
>
|
||||
<Icon size={13} />
|
||||
{cfg.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={submitting}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{submitting ? <Loader2 size={15} className="animate-spin" /> : <Plus size={15} />}
|
||||
{submitting ? t('usr.creating') : t('usr.create')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Table */}
|
||||
{users.length === 0 ? (
|
||||
<div className="card">
|
||||
<div className="empty-state">
|
||||
<Users size={40} style={{ color: 'var(--ac)' }} />
|
||||
<h3>{t('usr.noUsers')}</h3>
|
||||
<p>{t('usr.noUsersHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('usr.name')}</th>
|
||||
<th>{t('usr.username')}</th>
|
||||
<th>{t('usr.email')}</th>
|
||||
<th>{t('usr.role')}</th>
|
||||
<th style={{ textAlign: 'center' }}>{t('usr.mfa')}</th>
|
||||
<th style={{ textAlign: 'center' }}>{t('usr.status')}</th>
|
||||
<th>{t('usr.lastLogin')}</th>
|
||||
<th style={{ textAlign: 'right' }}>{t('usr.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u, idx) => {
|
||||
const cfg = ROLE_CONFIG[u.role];
|
||||
const isActive = !!u.is_active;
|
||||
const isAdmin = u.username === 'admin';
|
||||
const RoleIcon = cfg.icon;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={u.id}
|
||||
className="transition-colors duration-150"
|
||||
style={{
|
||||
borderBottom: idx < users.length - 1 ? '1px solid var(--bd)' : undefined,
|
||||
opacity: isActive ? 1 : 0.5,
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg2)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = ''; }}
|
||||
>
|
||||
{/* Name */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--t1)' }}>
|
||||
{u.first_name || ''} {u.last_name || ''}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Username */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs" style={{ color: 'var(--t2)', fontFamily: 'var(--fm)' }}>
|
||||
{u.username}
|
||||
</span>
|
||||
{u.auth_provider === 'oidc' && (
|
||||
<span className="ml-1.5 text-[.55rem] font-bold px-1.5 py-0.5 rounded"
|
||||
style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>OIDC</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Email */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs" style={{ color: 'var(--t3)' }}>
|
||||
{u.email || '---'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Role Badge */}
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[.7rem] font-semibold"
|
||||
style={{ background: cfg.bg, color: cfg.color }}
|
||||
>
|
||||
<RoleIcon size={12} />
|
||||
{cfg.label}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* MFA */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
{u.mfa_enabled ? (
|
||||
<button
|
||||
onClick={() => openMfa(u.id)}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.65rem] font-semibold cursor-pointer border-none"
|
||||
style={{ background: 'var(--gnl)', color: 'var(--gn)' }}
|
||||
title={t('mfa.configure')}
|
||||
>
|
||||
<ShieldCheck size={11} /> {t('usr.active')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => openMfa(u.id)}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.65rem] font-semibold cursor-pointer border-none"
|
||||
style={{ background: 'var(--bg3)', color: 'var(--t4)' }}
|
||||
title={t('mfa.configure')}
|
||||
>
|
||||
<Lock size={11} /> {t('mfa.configure')}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span
|
||||
className="inline-block px-2 py-0.5 rounded-md text-[.65rem] font-semibold"
|
||||
style={{
|
||||
background: isActive ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: isActive ? 'var(--gn)' : 'var(--rd)',
|
||||
}}
|
||||
>
|
||||
{isActive ? t('usr.active') : t('usr.inactive')}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Last login */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs" style={{ color: 'var(--t4)' }}>
|
||||
{u.last_login ? formatDate(u.last_login) : t('usr.never')}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{/* Role selector */}
|
||||
<select
|
||||
value={u.role}
|
||||
onChange={e => handleRoleChange(u.id, e.target.value as Role)}
|
||||
disabled={isAdmin}
|
||||
className="px-2 py-1 rounded-lg text-xs outline-none cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: 'var(--bg2)',
|
||||
border: '1px solid var(--bd)',
|
||||
color: 'var(--t2)',
|
||||
}}
|
||||
>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="user">User</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
|
||||
{/* Deactivate */}
|
||||
{!isAdmin && isActive && (
|
||||
<>
|
||||
{confirmDelete === u.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleDeactivate(u.id)}
|
||||
className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}
|
||||
>
|
||||
{t('usr.confirm')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
{t('common.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(u.id)}
|
||||
className="p-1.5 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--t4)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--rdl)'; e.currentTarget.style.color = 'var(--rd)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = ''; e.currentTarget.style.color = 'var(--t4)'; }}
|
||||
title={t('usr.deactivate')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MFA Modal */}
|
||||
{mfaUserId && (() => {
|
||||
const targetUser = users.find(u => u.id === mfaUserId);
|
||||
if (!targetUser) return null;
|
||||
const isSelf = mfaUserId === currentUser?.id;
|
||||
const mfaOn = !!targetUser.mfa_enabled;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,.5)' }} onClick={closeMfa}>
|
||||
<div className="rounded-xl overflow-hidden" style={{ background: 'var(--bg1)', border: '1px solid var(--bd)', width: 440, maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock size={16} style={{ color: 'var(--ac)' }} />
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--t1)' }}>
|
||||
MFA — {targetUser.first_name} {targetUser.last_name}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={closeMfa} className="p-1 rounded cursor-pointer" style={{ color: 'var(--t4)' }}><X size={16} /></button>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
{/* Message */}
|
||||
{mfaMsg && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg mb-4 text-xs font-medium"
|
||||
style={{ background: mfaMsg.type === 'success' ? 'var(--gnl)' : 'var(--rdl)', color: mfaMsg.type === 'success' ? 'var(--gn)' : 'var(--rd)' }}>
|
||||
{mfaMsg.type === 'success' ? <CheckCircle size={14} /> : <AlertTriangle size={14} />}
|
||||
{mfaMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mfaOn ? (
|
||||
/* MFA enabled — show disable */
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
<div className="w-14 h-14 rounded-full flex items-center justify-center" style={{ background: 'color-mix(in srgb, var(--gn) 10%, transparent)' }}>
|
||||
<ShieldCheck size={28} style={{ color: 'var(--gn)' }} />
|
||||
</div>
|
||||
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>{t('mfa.enabledDesc')}</p>
|
||||
<button onClick={() => handleMfaDisable(mfaUserId)} disabled={mfaLoading}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer"
|
||||
style={{ background: 'var(--rdl)', color: 'var(--rd)', border: '1px solid color-mix(in srgb, var(--rd) 25%, transparent)' }}>
|
||||
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <ShieldOff size={14} />}
|
||||
{t('mfa.disableBtn')}
|
||||
</button>
|
||||
</div>
|
||||
) : isSelf && mfaSecret && mfaTotpUri ? (
|
||||
/* Setup step 2: QR + verify (only for self) */
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-center">
|
||||
<div className="p-3 rounded-xl" style={{ background: '#fff' }}>
|
||||
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(mfaTotpUri)}`}
|
||||
alt="QR Code" width={160} height={160} className="rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2 rounded-lg text-center" style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}>
|
||||
<span className="text-[.6rem] block mb-1" style={{ color: 'var(--t4)' }}>SECRET</span>
|
||||
<code className="text-xs font-bold tracking-wider select-all" style={{ color: 'var(--ac)', fontFamily: 'var(--fm)' }}>{mfaSecret}</code>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={mfaCode} onChange={e => setMfaCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000" maxLength={6} autoFocus
|
||||
className="flex-1 px-3 py-2 rounded-lg text-sm text-center tracking-[.3em] font-semibold outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', fontFamily: 'var(--fm)' }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleMfaVerify(); }} />
|
||||
<button onClick={handleMfaVerify} disabled={mfaLoading || mfaCode.length !== 6}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer disabled:opacity-50"
|
||||
style={{ background: 'var(--gn)', color: '#fff' }}>
|
||||
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <KeyRound size={14} />}
|
||||
{t('mfa.activateMfa')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : isSelf ? (
|
||||
/* Setup step 1: generate (only for self) */
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
<div className="w-14 h-14 rounded-full flex items-center justify-center" style={{ background: 'color-mix(in srgb, var(--yl) 10%, transparent)' }}>
|
||||
<KeyRound size={28} style={{ color: 'var(--yl)' }} />
|
||||
</div>
|
||||
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>{t('mfa.generateHint')}</p>
|
||||
<button onClick={handleMfaSetup} disabled={mfaLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer"
|
||||
style={{ background: 'var(--ac)', color: '#fff' }}>
|
||||
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <KeyRound size={14} />}
|
||||
{t('mfa.generateSecret')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Not self — can only disable */
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>
|
||||
{t('usr.mfaSelfOnly')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
655
frontend/src/pages/config/AdbConfigPage.tsx
Normal file
655
frontend/src/pages/config/AdbConfigPage.tsx
Normal file
@@ -0,0 +1,655 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { adbApi, type AdbConfigFull } from '@/api/endpoints/adb';
|
||||
import {
|
||||
Database, Plus, Pencil, Trash2, FlaskConical, Check, X, Loader2,
|
||||
ChevronRight, Upload, Shield, Table2, ChevronDown, ChevronUp, Search,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Msg ── */
|
||||
function Msg({ type, text }: { type: 's' | 'e' | 'i'; text: string }) {
|
||||
const bg = type === 's' ? 'var(--gnl)' : type === 'e' ? 'var(--rdl)' : 'var(--bg2)';
|
||||
const fg = type === 's' ? 'var(--gn)' : type === 'e' ? 'var(--rd)' : 'var(--t2)';
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs" style={{ background: bg, color: fg }}>
|
||||
{type === 's' && <Check size={14} />}
|
||||
{type === 'e' && <X size={14} />}
|
||||
{type === 'i' && <Loader2 size={14} className="animate-spin" />}
|
||||
<span dangerouslySetInnerHTML={{ __html: text }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
export default function AdbConfigPage() {
|
||||
const { genaiCfg, embModels } = useAppStore();
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === 'admin');
|
||||
const { t } = useI18n();
|
||||
|
||||
const [configs, setConfigs] = useState<AdbConfigFull[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Form
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<AdbConfigFull | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formMsg, setFormMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
const walletRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Form fields
|
||||
const [cfgName, setCfgName] = useState('');
|
||||
const [dsn, setDsn] = useState('');
|
||||
const [dsnOptions, setDsnOptions] = useState<string[]>([]);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [walletPassword, setWalletPassword] = useState('');
|
||||
const [genaiConfigId, setGenaiConfigId] = useState('');
|
||||
const [embeddingModelId, setEmbeddingModelId] = useState('cohere.embed-v4.0');
|
||||
const [parsingWallet, setParsingWallet] = useState(false);
|
||||
|
||||
// Table
|
||||
const [tableMsg, setTableMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
const [testResults, setTestResults] = useState<Record<string, { type: 's' | 'e'; text: string }>>({});
|
||||
const [testingMap, setTestingMap] = useState<Record<string, boolean>>({});
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [expandedTables, setExpandedTables] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Wallet upload section
|
||||
const walletUploadRef = useRef<HTMLInputElement>(null);
|
||||
const [walletUploadId, setWalletUploadId] = useState('');
|
||||
const [uploadingWallet, setUploadingWallet] = useState(false);
|
||||
const [walletMsg, setWalletMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
|
||||
// New table form
|
||||
const [newTableName, setNewTableName] = useState('');
|
||||
const [newTableDesc, setNewTableDesc] = useState('');
|
||||
|
||||
const fetchConfigs = useCallback(async () => {
|
||||
try {
|
||||
const data = await adbApi.list();
|
||||
setConfigs(data);
|
||||
} catch {} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchConfigs(); }, [fetchConfigs]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setFormOpen(false);
|
||||
setCfgName('');
|
||||
setDsn('');
|
||||
setDsnOptions([]);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setWalletPassword('');
|
||||
setGenaiConfigId('');
|
||||
setEmbeddingModelId('cohere.embed-v4.0');
|
||||
setFormMsg(null);
|
||||
setSaving(false);
|
||||
if (walletRef.current) walletRef.current.value = '';
|
||||
};
|
||||
|
||||
const startEdit = (cfg: AdbConfigFull) => {
|
||||
setEditing(cfg);
|
||||
setCfgName(cfg.config_name);
|
||||
setDsn(cfg.dsn);
|
||||
setDsnOptions([]);
|
||||
setUsername(cfg.username);
|
||||
setPassword('');
|
||||
setWalletPassword('');
|
||||
setGenaiConfigId(cfg.genai_config_id || '');
|
||||
setEmbeddingModelId(cfg.embedding_model_id || 'cohere.embed-v4.0');
|
||||
setFormOpen(true);
|
||||
setFormMsg(null);
|
||||
setTimeout(() => formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100);
|
||||
};
|
||||
|
||||
const parseWallet = async () => {
|
||||
const file = walletRef.current?.files?.[0];
|
||||
if (!file) { setFormMsg({ type: 'e', text: t('adb.selectWallet') }); return; }
|
||||
setParsingWallet(true);
|
||||
try {
|
||||
const result = await adbApi.parseWallet(file);
|
||||
setDsnOptions(result.dsn_names);
|
||||
if (result.dsn_names.length > 0) setDsn(result.dsn_names[0]);
|
||||
setFormMsg({ type: 's', text: t('adb.walletParsed').replace('{0}', String(result.dsn_names.length)).replace('{1}', result.dsn_names.join(', ')).replace('{2}', result.files.join(', ')) });
|
||||
} catch (err) {
|
||||
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorLoad') });
|
||||
} finally {
|
||||
setParsingWallet(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!cfgName.trim()) { setFormMsg({ type: 'e', text: t('adb.fillName') }); return; }
|
||||
if (!dsn.trim()) { setFormMsg({ type: 'e', text: t('adb.fillDsn') }); return; }
|
||||
if (!username.trim()) { setFormMsg({ type: 'e', text: t('adb.fillUsername') }); return; }
|
||||
if (!editing && !password) { setFormMsg({ type: 'e', text: t('adb.fillPassword') }); return; }
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('config_name', cfgName.trim());
|
||||
fd.append('dsn', dsn.trim());
|
||||
fd.append('username', username.trim());
|
||||
fd.append('password', password);
|
||||
fd.append('wallet_password', walletPassword);
|
||||
fd.append('use_mtls', 'true');
|
||||
fd.append('genai_config_id', genaiConfigId);
|
||||
fd.append('embedding_model_id', embeddingModelId);
|
||||
const walletFile = walletRef.current?.files?.[0];
|
||||
if (walletFile) fd.append('wallet', walletFile);
|
||||
|
||||
setSaving(true);
|
||||
setFormMsg({ type: 'i', text: t('adb.saving') });
|
||||
|
||||
try {
|
||||
if (editing) {
|
||||
await adbApi.update(editing.id, fd);
|
||||
} else {
|
||||
await adbApi.create(fd);
|
||||
}
|
||||
setFormMsg({ type: 's', text: (editing ? t('adb.updatedSuccess') : t('adb.savedSuccess')) + (walletFile ? t('adb.walletIncluded') : '') });
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTimeout(resetForm, 1200);
|
||||
} catch (err) {
|
||||
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorSave') });
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setConfirmDelete(null);
|
||||
setTableMsg({ type: 'i', text: t('adb.deleting') });
|
||||
try {
|
||||
await adbApi.remove(id);
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: t('adb.deletedSuccess') });
|
||||
if (editing?.id === id) resetForm();
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorDelete') });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
setTestingMap((p) => ({ ...p, [id]: true }));
|
||||
setTestResults((p) => { const n = { ...p }; delete n[id]; return n; });
|
||||
try {
|
||||
const res = await adbApi.test(id);
|
||||
setTestResults((p) => ({
|
||||
...p,
|
||||
[id]: { type: res.status === 'success' ? 's' : 'e', text: res.message },
|
||||
}));
|
||||
} catch (err) {
|
||||
setTestResults((p) => ({ ...p, [id]: { type: 'e', text: err instanceof Error ? err.message : 'Erro' } }));
|
||||
} finally {
|
||||
setTestingMap((p) => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleWalletUpload = async () => {
|
||||
const file = walletUploadRef.current?.files?.[0];
|
||||
if (!file) { setWalletMsg({ type: 'e', text: t('adb.selectWallet') }); return; }
|
||||
if (!walletUploadId) { setWalletMsg({ type: 'e', text: t('adb.selectConnection') }); return; }
|
||||
setUploadingWallet(true);
|
||||
setWalletMsg({ type: 'i', text: t('adb.walletSending') });
|
||||
try {
|
||||
const res = await adbApi.uploadWallet(walletUploadId, file);
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setWalletMsg({ type: 's', text: t('adb.walletSent').replace('{0}', res.files.join(', ')) });
|
||||
if (walletUploadRef.current) walletUploadRef.current.value = '';
|
||||
} catch (err) {
|
||||
setWalletMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorSave') });
|
||||
} finally {
|
||||
setUploadingWallet(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTable = async (vid: string) => {
|
||||
if (!newTableName.trim()) { setTableMsg({ type: 'e', text: t('adb.enterTableName') }); return; }
|
||||
try {
|
||||
await adbApi.addTable(vid, newTableName.trim(), newTableDesc.trim());
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: t('adb.tableRegistered').replace('{0}', newTableName.trim()) });
|
||||
setNewTableName('');
|
||||
setNewTableDesc('');
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTable = async (vid: string, tid: string, currentActive: boolean) => {
|
||||
try {
|
||||
await adbApi.updateTable(vid, tid, { is_active: !currentActive });
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTable = async (vid: string, tid: string) => {
|
||||
if (!confirm(t('adb.confirmRemoveTable'))) return;
|
||||
try {
|
||||
await adbApi.removeTable(vid, tid);
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: t('adb.tableRemoved') });
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
}
|
||||
};
|
||||
|
||||
const isFormVisible = formOpen || !!editing;
|
||||
const inputCls = "w-full px-3 py-2 rounded-lg text-[.78rem] outline-none transition-colors focus:ring-1";
|
||||
const inputStyle = { background: 'var(--bg2)', border: '1.5px solid var(--bd)', color: 'var(--t1)' };
|
||||
const labelCls = "block";
|
||||
const labelStyle = {};
|
||||
const hintCls = "text-[.64rem] mt-0.5";
|
||||
const hintStyle = { color: 'var(--t4)' };
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--bll)' }}>
|
||||
<Database size={24} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('adb.title')}</h1>
|
||||
<div className="subtitle">{t('adb.subtitle')}</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<span className="count">
|
||||
{configs.length} {configs.length === 1 ? t('adb.connection') : t('adb.connections')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--bll)' }}>
|
||||
<Database size={16} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
<h2>{t('adb.registered')}</h2>
|
||||
</div>
|
||||
|
||||
{tableMsg && <div className="px-5 py-2"><Msg type={tableMsg.type} text={tableMsg.text} /></div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--t4)' }} />
|
||||
</div>
|
||||
) : configs.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<Database size={28} style={{ color: 'var(--t4)', margin: '0 auto 8px' }} />
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>{t('adb.noConnections')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{[t('adb.thName'), t('adb.thDsn'), t('adb.thUser'), t('adb.thTables'), t('adb.thMtls'), t('adb.thWallet'), t('adb.thEmbed'), t('adb.thActions')].map((h) => (
|
||||
<th key={h}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{configs.map((c) => {
|
||||
const tables = c.tables || [];
|
||||
const emb = c.embedding_model_id ? embModels[c.embedding_model_id] : null;
|
||||
const isExpanded = expandedTables[c.id];
|
||||
return (
|
||||
<tr
|
||||
key={c.id}
|
||||
className="transition-colors align-top"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--bd)',
|
||||
background: editing?.id === c.id ? 'color-mix(in srgb, var(--bl) 5%, var(--bg1))' : undefined,
|
||||
}}
|
||||
onMouseEnter={(e) => { if (editing?.id !== c.id) e.currentTarget.style.background = 'var(--bg2)'; }}
|
||||
onMouseLeave={(e) => { if (editing?.id !== c.id) e.currentTarget.style.background = ''; }}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<strong className="text-xs" style={{ color: 'var(--t1)' }}>{c.config_name}</strong>
|
||||
{(c as any).is_global === 1 && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[.55rem] font-bold" style={{ background: 'var(--bll)', color: 'var(--bl)' }}>GLOBAL</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-[.68rem]" style={{ fontFamily: 'var(--fm)', color: 'var(--t4)' }}>{c.dsn}</code>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[.72rem]" style={{ color: 'var(--t2)' }}>{c.username}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{tables.length > 0 ? (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<button
|
||||
onClick={() => setExpandedTables((p) => ({ ...p, [c.id]: !p[c.id] }))}
|
||||
className="flex items-center gap-1 text-[.62rem] font-semibold px-1.5 py-0.5 rounded"
|
||||
style={{ background: 'var(--bg)', color: 'var(--t3)' }}
|
||||
>
|
||||
<Table2 size={10} /> {tables.length}
|
||||
{isExpanded ? <ChevronUp size={8} /> : <ChevronDown size={8} />}
|
||||
</button>
|
||||
{!isExpanded && tables.slice(0, 3).map((tb) => (
|
||||
<span key={tb.id} className="px-1.5 py-0.5 rounded text-[.58rem]" style={{ background: 'var(--bg)', color: 'var(--t4)' }}>{tb.table_name}</span>
|
||||
))}
|
||||
{!isExpanded && tables.length > 3 && (
|
||||
<span className="text-[.58rem]" style={{ color: 'var(--t4)' }}>+{tables.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[.72rem]" style={{ color: 'var(--t4)' }}>{'\u2014'}</span>
|
||||
)}
|
||||
{isExpanded && tables.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<table className="w-full text-[.68rem]" style={{ borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{[t('adb.thTable'), t('adb.thDescription'), t('adb.thActive'), t('adb.thTableActions')].map((h) => (
|
||||
<th key={h} className="text-left px-2 py-1 text-[.6rem] font-semibold uppercase" style={{ color: 'var(--t4)', borderBottom: '1px solid var(--bd)' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tables.map((tb) => (
|
||||
<tr key={tb.id} style={{ opacity: tb.is_active ? 1 : 0.55 }}>
|
||||
<td className="px-2 py-1.5">
|
||||
<span className="font-mono text-[.68rem]" style={{ color: 'var(--t1)' }}>{tb.table_name}</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<span className="text-[.66rem]" style={{ color: 'var(--t3)' }}>{tb.description || '—'}</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<button onClick={() => handleToggleTable(c.id, tb.id, tb.is_active)} className="text-[.6rem] px-1.5 py-0.5 rounded font-medium" style={{ background: tb.is_active ? 'var(--gnl)' : 'var(--bg)', color: tb.is_active ? 'var(--gn)' : 'var(--t4)' }}>
|
||||
{tb.is_active ? t('adb.yes') : t('adb.no')}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<button onClick={() => handleRemoveTable(c.id, tb.id)} className="text-[.58rem] px-1.5 py-0.5 rounded" style={{ color: 'var(--rd)', background: 'var(--rdl)' }}>
|
||||
{t('adb.delete')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* Add table inline */}
|
||||
{editing?.id === c.id && (
|
||||
<div className="flex gap-1 items-end mt-1">
|
||||
<input type="text" value={newTableName} onChange={(e) => setNewTableName(e.target.value)} placeholder="Nome" className="px-2 py-1 rounded text-[.64rem] w-28" style={{ background: 'var(--bg)', border: '1px solid var(--bd)', color: 'var(--t1)' }} />
|
||||
<input type="text" value={newTableDesc} onChange={(e) => setNewTableDesc(e.target.value)} placeholder="Descricao" className="px-2 py-1 rounded text-[.64rem] w-32" style={{ background: 'var(--bg)', border: '1px solid var(--bd)', color: 'var(--t1)' }} />
|
||||
<button onClick={() => handleAddTable(c.id)} className="px-2 py-1 rounded text-[.62rem] font-semibold" style={{ background: 'var(--bl)', color: '#fff' }}>
|
||||
<Plus size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{c.use_mtls ? (
|
||||
<Shield size={14} style={{ color: 'var(--gn)' }} />
|
||||
) : (
|
||||
<span style={{ color: 'var(--t4)' }}>{'\u2014'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{c.wallet_dir ? (
|
||||
<Check size={14} style={{ color: 'var(--gn)' }} />
|
||||
) : (
|
||||
<X size={14} style={{ color: 'var(--rd)' }} />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{c.genai_config_id ? (
|
||||
<span className="px-1.5 py-0.5 rounded text-[.6rem] font-semibold" style={{ background: 'var(--gnl)', color: 'var(--gn)' }}>
|
||||
{emb?.name || c.embedding_model_id}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--t4)' }}>{'\u2014'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{(isAdmin || !(c as any).is_global) && (
|
||||
<button onClick={() => startEdit(c)} className="btn btn-secondary btn-sm">
|
||||
<Pencil size={10} /> {t('adb.edit')}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => handleTest(c.id)} disabled={testingMap[c.id]} className="btn btn-secondary btn-sm">
|
||||
{testingMap[c.id] ? <Loader2 size={10} className="animate-spin" /> : <FlaskConical size={10} />} {t('adb.test')}
|
||||
</button>
|
||||
{(isAdmin || !(c as any).is_global) && (
|
||||
confirmDelete === c.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleDelete(c.id)} className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}>
|
||||
<Check size={10} /> {t('adb.yes')}
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-sm">
|
||||
{t('adb.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setConfirmDelete(c.id)} className="btn btn-danger btn-sm">
|
||||
<Trash2 size={10} /> {t('adb.delete')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{testResults[c.id] && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded text-[.62rem] font-medium"
|
||||
style={{
|
||||
background: testResults[c.id].type === 's' ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: testResults[c.id].type === 's' ? 'var(--gn)' : 'var(--rd)',
|
||||
}}>
|
||||
{testResults[c.id].type === 's' ? <Check size={10} /> : <X size={10} />}
|
||||
{testResults[c.id].text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div ref={formRef} className="card" style={{ overflow: 'hidden' }}>
|
||||
<div className="flex items-center gap-2.5 px-5 py-3.5 cursor-pointer select-none" onClick={() => { if (!editing) setFormOpen(!formOpen); }}>
|
||||
<ChevronRight size={14} style={{ color: 'var(--t3)', transition: 'transform .2s', transform: isFormVisible ? 'rotate(90deg)' : 'rotate(0)' }} />
|
||||
<div className="flex items-center gap-2">
|
||||
{editing ? <Pencil size={14} style={{ color: 'var(--bl)' }} /> : <Plus size={14} style={{ color: 'var(--bl)' }} />}
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
|
||||
{editing ? `${t('adb.editConnection')} — ${editing.config_name}` : t('adb.newConnection')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFormVisible && (
|
||||
<div className="px-5 pb-5 flex flex-col gap-4" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<p className="text-[.7rem] pt-3" style={{ color: 'var(--t4)' }}>
|
||||
{editing
|
||||
? <>{t('adb.editingLabel')} <strong style={{ color: 'var(--t2)' }}>{editing.config_name}</strong></>
|
||||
: <>{t('adb.connDesc')}</>}
|
||||
</p>
|
||||
|
||||
{formMsg && <Msg type={formMsg.type} text={formMsg.text} />}
|
||||
|
||||
{/* Row 1 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.connName')}</label>
|
||||
<input type="text" value={cfgName} onChange={(e) => setCfgName(e.target.value)} placeholder="Producao ADB" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.walletZip')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>
|
||||
{editing ? t('adb.walletEditHint') : t('adb.walletHint')}
|
||||
</p>
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<input
|
||||
ref={walletRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="flex-1 text-[.72rem] file:mr-2 file:px-3 file:py-1.5 file:rounded-lg file:border-0 file:text-[.68rem] file:font-semibold file:cursor-pointer"
|
||||
style={{ color: 'var(--t3)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={parseWallet}
|
||||
disabled={parsingWallet}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-[.68rem] font-semibold disabled:opacity-50"
|
||||
style={{ background: 'var(--bg2)', color: 'var(--t2)', border: '1px solid var(--bd)' }}
|
||||
>
|
||||
{parsingWallet ? <Loader2 size={12} className="animate-spin" /> : <Search size={12} />}
|
||||
{t('adb.analyze')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.dsn')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('adb.dsnHint')}</p>
|
||||
{dsnOptions.length > 0 ? (
|
||||
<select value={dsn} onChange={(e) => setDsn(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
{dsnOptions.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input type="text" value={dsn} onChange={(e) => setDsn(e.target.value)} placeholder="myatp_high" className={inputCls} style={inputStyle} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.username')}</label>
|
||||
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="ADMIN" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.password')}</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder={editing ? '(manter atual)' : ''} className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.walletPassword')}</label>
|
||||
<input type="password" value={walletPassword} onChange={(e) => setWalletPassword(e.target.value)} placeholder="(opcional)" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4 - Embedding config */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.genaiConfig')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('adb.genaiConfigHint')}</p>
|
||||
<select value={genaiConfigId} onChange={(e) => setGenaiConfigId(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
<option value="">{t('adb.noRag')}</option>
|
||||
{genaiCfg.map((g) => (
|
||||
<option key={g.id} value={g.id}>{g.name || g.model_id} {g.genai_region ? `- ${g.genai_region}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.embeddingModel')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('adb.embeddingModelHint')}</p>
|
||||
<select value={embeddingModelId} onChange={(e) => setEmbeddingModelId(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
{Object.entries(embModels).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.name} ({v.dims}d)</option>
|
||||
))}
|
||||
{Object.keys(embModels).length === 0 && (
|
||||
<option value="cohere.embed-v4.0">cohere.embed-v4.0</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button onClick={handleSave} disabled={saving} className="btn btn-primary" style={{ background: 'var(--bl)', borderColor: 'var(--bl)' }}>
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{editing ? t('adb.saveChanges') : t('adb.saveConnection')}
|
||||
</button>
|
||||
{editing && (
|
||||
<button onClick={resetForm} className="btn btn-secondary">
|
||||
{t('adb.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Wallet Upload card */}
|
||||
{configs.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--bll)' }}>
|
||||
<Upload size={16} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
<h2>{t('adb.updateWallet')}</h2>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex flex-col gap-3">
|
||||
{walletMsg && <Msg type={walletMsg.type} text={walletMsg.text} />}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.adbConnection')}</label>
|
||||
<select value={walletUploadId} onChange={(e) => setWalletUploadId(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
<option value="">{t('common.select')}</option>
|
||||
{configs.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.config_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.walletZip')}</label>
|
||||
<input
|
||||
ref={walletUploadRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="text-[.72rem] file:mr-2 file:px-3 file:py-1.5 file:rounded-lg file:border-0 file:text-[.68rem] file:font-semibold file:cursor-pointer"
|
||||
style={{ color: 'var(--t3)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={handleWalletUpload}
|
||||
disabled={uploadingWallet}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
{uploadingWallet ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
|
||||
{t('adb.uploadWallet')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
320
frontend/src/pages/config/EmbConsultPage.tsx
Normal file
320
frontend/src/pages/config/EmbConsultPage.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useAppStore, type AdbConfig, type AdbTable } from '@/stores/app';
|
||||
import { embeddingsApi, type ConsultResult } from '@/api/endpoints/embeddings';
|
||||
import { useI18n } from '@/i18n';
|
||||
import {
|
||||
Search, Send, Loader2, Database, Trash2, User, Bot,
|
||||
FileText, Hash,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Types ── */
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
sources?: ConsultResult['documents'];
|
||||
totalSources?: number;
|
||||
}
|
||||
|
||||
/* ── Markdown-lite renderer ── */
|
||||
function renderMarkdown(text: string): string {
|
||||
let html = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// Bold
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
// Italic
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
// Inline code
|
||||
html = html.replace(/`(.+?)`/g, '<code style="background:var(--bg);padding:1px 4px;border-radius:3px;font-size:.72rem;font-family:var(--fm)">$1</code>');
|
||||
// Horizontal rule
|
||||
html = html.replace(/\n---\n/g, '<hr style="border:none;border-top:1px solid var(--bd);margin:8px 0">');
|
||||
// Line breaks
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
export default function EmbConsultPage() {
|
||||
const { t } = useI18n();
|
||||
const { adbCfg, ociCfg } = useAppStore();
|
||||
|
||||
// Config selectors
|
||||
const [selTable, setSelTable] = useState('');
|
||||
const [selOci, setSelOci] = useState(ociCfg.length > 0 ? ociCfg[0].id : '');
|
||||
const [topK, setTopK] = useState(10);
|
||||
|
||||
// Chat
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const msgsRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Auto scroll
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (msgsRef.current) {
|
||||
msgsRef.current.scrollTop = msgsRef.current.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, loading, scrollToBottom]);
|
||||
|
||||
// Focus input on mount
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Table options from all active ADB configs
|
||||
const allTables: { name: string; configName: string }[] = [];
|
||||
for (const cfg of adbCfg) {
|
||||
for (const t of (cfg.tables || []).filter((t) => t.is_active)) {
|
||||
allTables.push({ name: t.table_name, configName: cfg.config_name });
|
||||
}
|
||||
}
|
||||
|
||||
// Preconditions
|
||||
if (!adbCfg.length) {
|
||||
return (
|
||||
<div className="page-full flex items-center justify-center">
|
||||
<div className="empty-state">
|
||||
<Database size={36} />
|
||||
<h3>{t('emb.noAdb')}</h3>
|
||||
<p>{t('emb.noAdbHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Submit ── */
|
||||
const handleSubmit = async () => {
|
||||
const q = query.trim();
|
||||
if (!q || loading) return;
|
||||
|
||||
const userMsg: ChatMessage = { role: 'user', content: q };
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setQuery('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const d = await embeddingsApi.consult(q, selTable || undefined, topK, selOci || undefined);
|
||||
let answer = d.answer || t('ec.noAnswer');
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: answer,
|
||||
sources: d.documents,
|
||||
totalSources: d.total,
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
} catch (err) {
|
||||
const assistantMsg: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: `${t('ec.errorPrefix')}${err instanceof Error ? err.message : t('ec.errorUnknown')}`,
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const clearChat = () => {
|
||||
setMessages([]);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-full flex flex-col">
|
||||
<div className="max-w-5xl mx-auto w-full flex flex-col h-full p-6 gap-3">
|
||||
|
||||
{/* Header */}
|
||||
<div className="card flex items-center justify-between flex-shrink-0" style={{ marginBottom: 0, padding: '14px 20px' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="card-header icon" style={{ background: 'color-mix(in srgb, var(--bl) 12%, transparent)', margin: 0, padding: 0, border: 'none' }}>
|
||||
<Search size={18} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '1rem', fontWeight: 700, color: 'var(--t1)' }}>{t('ec.title')}</h1>
|
||||
<p className="subtitle" style={{ fontSize: '.72rem' }}>
|
||||
{t('ec.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{messages.length > 0 && (
|
||||
<button onClick={clearChat} className="btn btn-secondary btn-sm">
|
||||
<Trash2 size={12} />
|
||||
{t('ec.clear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config bar */}
|
||||
<div className="flex gap-3 flex-wrap flex-shrink-0">
|
||||
<div className="min-w-[160px]">
|
||||
<label className="block text-[.68rem] font-semibold mb-1" style={{ color: 'var(--t4)' }}>Tenancy</label>
|
||||
<select
|
||||
value={selOci}
|
||||
onChange={(e) => setSelOci(e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-[.76rem] outline-none"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)', color: 'var(--t1)' }}
|
||||
>
|
||||
<option value="">Todas</option>
|
||||
{ociCfg.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.tenancy_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[180px]">
|
||||
<label className="block text-[.68rem] font-semibold mb-1" style={{ color: 'var(--t4)' }}>{t('ec.tableOptional')}</label>
|
||||
<select
|
||||
value={selTable}
|
||||
onChange={(e) => setSelTable(e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-[.76rem] outline-none"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)', color: 'var(--t1)' }}
|
||||
>
|
||||
<option value="">{t('ec.allTables')}</option>
|
||||
{allTables.map((t) => (
|
||||
<option key={t.name} value={t.name}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-[80px]">
|
||||
<label className="block text-[.68rem] font-semibold mb-1" style={{ color: 'var(--t4)' }}>Top-K</label>
|
||||
<input
|
||||
type="number"
|
||||
value={topK}
|
||||
onChange={(e) => setTopK(Math.max(1, Math.min(30, parseInt(e.target.value) || 10)))}
|
||||
min={1}
|
||||
max={30}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-[.76rem] outline-none text-center"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)', color: 'var(--t1)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages area */}
|
||||
<div
|
||||
ref={msgsRef}
|
||||
className="flex-1 overflow-y-auto rounded-xl p-4 flex flex-col gap-3 min-h-0"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)' }}
|
||||
>
|
||||
{messages.length === 0 && !loading && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Search size={32} style={{ color: 'var(--t4)', margin: '0 auto 10px', opacity: 0.5 }} />
|
||||
<p className="text-[.82rem] font-medium" style={{ color: 'var(--t3)' }}>
|
||||
{t('ec.emptyTitle')}
|
||||
</p>
|
||||
<p className="text-[.7rem] mt-2" style={{ color: 'var(--t4)' }}>
|
||||
{t('ec.emptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex gap-2.5 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center mt-0.5" style={{ background: 'color-mix(in srgb, var(--bl) 12%, transparent)' }}>
|
||||
<Bot size={14} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="max-w-[80%] rounded-xl px-4 py-3"
|
||||
style={{
|
||||
background: msg.role === 'user' ? 'var(--ac)' : 'var(--bg2)',
|
||||
color: msg.role === 'user' ? '#fff' : 'var(--t1)',
|
||||
}}
|
||||
>
|
||||
{msg.role === 'user' ? (
|
||||
<p className="text-[.8rem] leading-relaxed">{msg.content}</p>
|
||||
) : (
|
||||
<div className="text-[.8rem] leading-relaxed" dangerouslySetInnerHTML={{ __html: renderMarkdown(msg.content) }} />
|
||||
)}
|
||||
|
||||
{/* Sources */}
|
||||
{msg.sources && msg.sources.length > 0 && (
|
||||
<div className="mt-3 pt-3" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<FileText size={12} style={{ color: 'var(--t4)' }} />
|
||||
<span className="text-[.66rem] font-semibold" style={{ color: 'var(--t4)' }}>
|
||||
{t('ec.sources')} ({msg.sources.length} documentos{msg.totalSources ? `, ${msg.totalSources} total` : ''})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{msg.sources.map((src, j) => (
|
||||
<div key={j} className="flex items-center gap-2 px-2 py-1 rounded" style={{ background: 'var(--bg)' }}>
|
||||
<Hash size={10} style={{ color: 'var(--t4)' }} />
|
||||
<span className="text-[.66rem] font-semibold" style={{ color: 'var(--bl)' }}>{src.source}</span>
|
||||
<span className="text-[.6rem]" style={{ color: 'var(--t4)' }}>
|
||||
dist: {src.distance.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{msg.role === 'user' && (
|
||||
<div className="flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center mt-0.5" style={{ background: 'color-mix(in srgb, var(--ac) 15%, transparent)' }}>
|
||||
<User size={14} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{loading && (
|
||||
<div className="flex gap-2.5 justify-start">
|
||||
<div className="flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center mt-0.5" style={{ background: 'color-mix(in srgb, var(--bl) 12%, transparent)' }}>
|
||||
<Bot size={14} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
<div className="rounded-xl px-4 py-3 flex items-center gap-2" style={{ background: 'var(--bg2)' }}>
|
||||
<Loader2 size={14} className="animate-spin" style={{ color: 'var(--bl)' }} />
|
||||
<span className="text-[.76rem]" style={{ color: 'var(--t3)' }}>{t('ec.searching')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
<div className="flex gap-2.5 flex-shrink-0">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('ec.placeholder')}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl text-[.82rem] outline-none transition-colors focus:ring-1 disabled:opacity-60"
|
||||
style={{ background: 'var(--bg1)', border: '1.5px solid var(--bd)', color: 'var(--t1)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !query.trim()}
|
||||
className="btn"
|
||||
style={{ background: 'var(--bl)', color: '#fff', borderColor: 'var(--bl)', borderRadius: '12px' }}
|
||||
>
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||
{t('ec.submit')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
516
frontend/src/pages/config/EmbeddingsPage.tsx
Normal file
516
frontend/src/pages/config/EmbeddingsPage.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useAppStore, type AdbConfig, type AdbTable } from '@/stores/app';
|
||||
import { embeddingsApi, type EmbeddingDoc } from '@/api/endpoints/embeddings';
|
||||
import { useI18n } from '@/i18n';
|
||||
import {
|
||||
Dna, Shield, ScrollText, Upload, Loader2, Check, X,
|
||||
Trash2, RefreshCw, Link, FileUp, Database, Search, AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Helpers ── */
|
||||
function Msg({ type, text }: { type: 's' | 'e' | 'i'; text: string }) {
|
||||
const bg = type === 's' ? 'var(--gnl)' : type === 'e' ? 'var(--rdl)' : 'var(--bg2)';
|
||||
const fg = type === 's' ? 'var(--gn)' : type === 'e' ? 'var(--rd)' : 'var(--t2)';
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs" style={{ background: bg, color: fg }}>
|
||||
{type === 's' && <Check size={14} />}
|
||||
{type === 'e' && <X size={14} />}
|
||||
{type === 'i' && <Loader2 size={14} className="animate-spin" />}
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MsgState = { type: 's' | 'e' | 'i'; text: string } | null;
|
||||
|
||||
function getActiveTables(cfg: AdbConfig): AdbTable[] {
|
||||
return (cfg.tables || []).filter((t) => t.is_active);
|
||||
}
|
||||
|
||||
function getAllTables(cfg: AdbConfig): AdbTable[] {
|
||||
return cfg.tables || [];
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
export default function EmbeddingsPage() {
|
||||
const { t } = useI18n();
|
||||
const { adbCfg, ociCfg, genaiCfg } = useAppStore();
|
||||
|
||||
// ── Existing Embeddings section ──
|
||||
const [selVid, setSelVid] = useState(() => adbCfg.find((c) => c.is_active)?.id || adbCfg[0]?.id || '');
|
||||
const [selTable, setSelTable] = useState('');
|
||||
const [docs, setDocs] = useState<EmbeddingDoc[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
const [listMsg, setListMsg] = useState<MsgState>(null);
|
||||
|
||||
// ── CIS Recommendations section ──
|
||||
const [cisVid, setCisVid] = useState(() => adbCfg.find((c) => c.is_active)?.id || adbCfg[0]?.id || '');
|
||||
const cisPdfRef = useRef<HTMLInputElement>(null);
|
||||
const [cisUploading, setCisUploading] = useState(false);
|
||||
const [cisMsg, setCisMsg] = useState<MsgState>(null);
|
||||
|
||||
// ── Knowledge Base section ──
|
||||
const [kbVid, setKbVid] = useState(() => adbCfg.find((c) => c.is_active)?.id || adbCfg[0]?.id || '');
|
||||
const kbFileRef = useRef<HTMLInputElement>(null);
|
||||
const [kbUrl, setKbUrl] = useState('');
|
||||
const [kbFileUploading, setKbFileUploading] = useState(false);
|
||||
const [kbUrlUploading, setKbUrlUploading] = useState(false);
|
||||
const [kbMsg, setKbMsg] = useState<MsgState>(null);
|
||||
|
||||
// Auto-select ADB config when data loads (fixes empty dropdown after navigation)
|
||||
useEffect(() => {
|
||||
const defaultId = adbCfg.find((c) => c.is_active)?.id || adbCfg[0]?.id || '';
|
||||
if (defaultId) {
|
||||
if (!selVid) setSelVid(defaultId);
|
||||
if (!cisVid) setCisVid(defaultId);
|
||||
if (!kbVid) setKbVid(defaultId);
|
||||
if (!purgeVid) setPurgeVid(defaultId);
|
||||
}
|
||||
}, [adbCfg]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Purge section ──
|
||||
const [purgeVid, setPurgeVid] = useState(() => adbCfg.find((c) => c.is_active)?.id || adbCfg[0]?.id || '');
|
||||
const [purgeTable, setPurgeTable] = useState('');
|
||||
const [purgeTenancy, setPurgeTenancy] = useState('');
|
||||
const [purging, setPurging] = useState(false);
|
||||
const [purgeMsg, setPurgeMsg] = useState<MsgState>(null);
|
||||
const [purgeConfirm, setPurgeConfirm] = useState(false);
|
||||
|
||||
// Update selTable when selVid changes
|
||||
useEffect(() => {
|
||||
const cfg = adbCfg.find((c) => c.id === selVid);
|
||||
if (cfg) {
|
||||
const tables = getActiveTables(cfg);
|
||||
setSelTable(tables[0]?.table_name || '');
|
||||
}
|
||||
}, [selVid, adbCfg]);
|
||||
|
||||
// Preconditions
|
||||
if (!adbCfg.length) {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="empty-state">
|
||||
<Database size={36} />
|
||||
<h3>{t('emb.noAdb')}</h3>
|
||||
<p>{t('emb.noAdbHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!genaiCfg.length && !ociCfg.length) {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="empty-state">
|
||||
<Dna size={36} />
|
||||
<h3>{t('emb.noOci')}</h3>
|
||||
<p>{t('emb.noOciHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Handlers ── */
|
||||
|
||||
const loadEmbs = async () => {
|
||||
if (!selVid) return;
|
||||
setListLoading(true);
|
||||
setListMsg(null);
|
||||
setDocs([]);
|
||||
try {
|
||||
const d = await embeddingsApi.list(selVid, selTable || undefined);
|
||||
setDocs(d.documents);
|
||||
setTotal(d.total);
|
||||
if (!d.documents.length) setListMsg({ type: 'i', text: t('emb.noEmbeddings') });
|
||||
} catch (err) {
|
||||
setListMsg({ type: 'e', text: err instanceof Error ? err.message : t('emb.errorLoadEmb') });
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteEmb = async (docId: string) => {
|
||||
if (!confirm(t('emb.confirmDeleteEmb'))) return;
|
||||
try {
|
||||
await embeddingsApi.remove(selVid, docId, selTable || undefined);
|
||||
loadEmbs();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : t('common.errorDelete'));
|
||||
}
|
||||
};
|
||||
|
||||
const uploadCis = async () => {
|
||||
const f = cisPdfRef.current?.files?.[0];
|
||||
if (!cisVid || !f) { setCisMsg({ type: 'e', text: t('emb.selectAdbAndFile') }); return; }
|
||||
setCisUploading(true);
|
||||
setCisMsg({ type: 'i', text: t('emb.processing') });
|
||||
try {
|
||||
const d = await embeddingsApi.uploadFile(cisVid, 'cisrecom', f);
|
||||
setCisMsg({ type: 's', text: `${d.message} -> tabela CISRECOM` });
|
||||
if (cisPdfRef.current) cisPdfRef.current.value = '';
|
||||
} catch (err) {
|
||||
setCisMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
} finally {
|
||||
setCisUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadKbFile = async () => {
|
||||
const f = kbFileRef.current?.files?.[0];
|
||||
if (!kbVid || !f) { setKbMsg({ type: 'e', text: t('emb.selectAdbAndFile') }); return; }
|
||||
setKbFileUploading(true);
|
||||
setKbMsg({ type: 'i', text: t('emb.uploading') });
|
||||
try {
|
||||
const d = await embeddingsApi.uploadFile(kbVid, 'engineerknowledgebase', f);
|
||||
setKbMsg({ type: 's', text: d.message });
|
||||
if (kbFileRef.current) kbFileRef.current.value = '';
|
||||
} catch (err) {
|
||||
setKbMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
} finally {
|
||||
setKbFileUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadKbUrl = async () => {
|
||||
if (!kbVid || !kbUrl.trim()) { setKbMsg({ type: 'e', text: t('emb.selectAdbAndUrl') }); return; }
|
||||
setKbUrlUploading(true);
|
||||
setKbMsg({ type: 'i', text: t('emb.fetchingUrl') });
|
||||
try {
|
||||
const d = await embeddingsApi.uploadUrl(kbVid, 'engineerknowledgebase', kbUrl.trim());
|
||||
setKbMsg({ type: 's', text: d.message });
|
||||
setKbUrl('');
|
||||
} catch (err) {
|
||||
setKbMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
} finally {
|
||||
setKbUrlUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const executePurge = async () => {
|
||||
if (!purgeVid || !purgeTable) { setPurgeMsg({ type: 'e', text: t('emb.selectTable') }); return; }
|
||||
setPurging(true);
|
||||
setPurgeMsg({ type: 'i', text: t('emb.purging') });
|
||||
try {
|
||||
const d = await embeddingsApi.purge(purgeVid, purgeTable, purgeTenancy || undefined);
|
||||
setPurgeMsg({ type: 's', text: `${d.deleted} embeddings removidos da tabela ${d.table} (tenancy: ${d.tenancy})` });
|
||||
setPurgeConfirm(false);
|
||||
} catch (err) {
|
||||
setPurgeMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
} finally {
|
||||
setPurging(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Shared styles ── */
|
||||
const inputCls = 'w-full px-3 py-2 rounded-lg text-[.78rem] outline-none transition-colors focus:ring-1';
|
||||
const inputStyle = { background: 'var(--bg2)', border: '1.5px solid var(--bd)', color: 'var(--t1)' };
|
||||
const labelCls = 'block text-[.72rem] font-semibold mb-1';
|
||||
const labelStyle = { color: 'var(--t3)' };
|
||||
const cardCls = 'card';
|
||||
const cardStyle = { padding: 0 };
|
||||
const cardHeaderCls = 'card-header';
|
||||
const cardHeaderStyle = { margin: 0, padding: '14px 20px' };
|
||||
|
||||
const adbOptions = adbCfg.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.config_name}</option>
|
||||
));
|
||||
|
||||
const tableOptions = (vid: string) => {
|
||||
const cfg = adbCfg.find((c) => c.id === vid);
|
||||
if (!cfg) return null;
|
||||
return getActiveTables(cfg).map((t) => (
|
||||
<option key={t.table_name} value={t.table_name}>{t.table_name}</option>
|
||||
));
|
||||
};
|
||||
|
||||
const allTableOptions = (vid: string) => {
|
||||
const cfg = adbCfg.find((c) => c.id === vid);
|
||||
if (!cfg) return null;
|
||||
return getAllTables(cfg).map((t) => (
|
||||
<option key={t.table_name} value={t.table_name}>{t.table_name}</option>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page" style={{ overflow: 'auto', height: '100%', maxWidth: 1100 }}>
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="card-header icon" style={{ background: 'color-mix(in srgb, var(--yl) 12%, transparent)' }}>
|
||||
<Dna size={18} style={{ color: 'var(--yl)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('emb.title')}</h1>
|
||||
<div className="subtitle">
|
||||
{t('emb.subtitle')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ────── Existing Embeddings ────── */}
|
||||
<div className={cardCls} style={cardStyle}>
|
||||
<div className={cardHeaderCls} style={cardHeaderStyle}>
|
||||
<Search size={14} style={{ color: 'var(--bl)' }} />
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>{t('emb.existing')}</span>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex flex-col gap-3">
|
||||
<p className="text-[.7rem]" style={{ color: 'var(--t4)' }}>
|
||||
{t('emb.existingDesc')}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('emb.adbConnection')}</label>
|
||||
<select value={selVid} onChange={(e) => setSelVid(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
{adbOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('emb.table')}</label>
|
||||
<select value={selTable} onChange={(e) => setSelTable(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
{tableOptions(selVid)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={loadEmbs}
|
||||
disabled={listLoading}
|
||||
className="btn btn-secondary btn-sm w-full justify-center"
|
||||
>
|
||||
{listLoading ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
|
||||
{t('emb.load')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{listMsg && <Msg type={listMsg.type} text={listMsg.text} />}
|
||||
|
||||
{docs.length > 0 && (
|
||||
<>
|
||||
<div className="overflow-x-auto" style={{ borderRadius: 'var(--r)', border: '1px solid var(--bd)' }}>
|
||||
<table className="data-table" style={{ tableLayout: 'fixed' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 100 }}>ID</th>
|
||||
<th>Metadata</th>
|
||||
<th style={{ width: 60 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{docs.map((doc) => {
|
||||
let meta = doc.metadata || '\u2014';
|
||||
if (typeof meta === 'object') meta = JSON.stringify(meta);
|
||||
if (meta.length > 200) meta = meta.substring(0, 200) + '\u2026';
|
||||
return (
|
||||
<tr key={doc.id}>
|
||||
<td>
|
||||
<code className="text-[.68rem]" style={{ fontFamily: 'var(--fm)', color: 'var(--t4)', wordBreak: 'break-all' }}>
|
||||
{doc.id.substring(0, 12)}
|
||||
</code>
|
||||
</td>
|
||||
<td>
|
||||
<span className="text-[.72rem]" style={{ color: 'var(--t3)', wordBreak: 'break-word', whiteSpace: 'pre-wrap' }}>
|
||||
{meta}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button onClick={() => deleteEmb(doc.id)} className="btn btn-sm" style={{ color: 'var(--rd)', background: 'none', border: 'none', padding: 4 }}>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-[.68rem]" style={{ color: 'var(--t4)' }}>{t('emb.totalDocs').replace('{0}', String(total))}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ────── CIS Recommendations ────── */}
|
||||
<div className={cardCls} style={cardStyle}>
|
||||
<div className={cardHeaderCls} style={cardHeaderStyle}>
|
||||
<Shield size={14} style={{ color: 'var(--gn)' }} />
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>{t('emb.cisTitle')}</span>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex flex-col gap-3">
|
||||
<p className="text-[.7rem]" style={{ color: 'var(--t4)' }}>
|
||||
{t('emb.cisDesc')}
|
||||
</p>
|
||||
{cisMsg && <Msg type={cisMsg.type} text={cisMsg.text} />}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('emb.adbConnection')}</label>
|
||||
<select value={cisVid} onChange={(e) => setCisVid(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
{adbOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('emb.pdfFile')}</label>
|
||||
<input
|
||||
ref={cisPdfRef}
|
||||
type="file"
|
||||
accept=".pdf,.txt"
|
||||
className="w-full text-[.72rem] file:mr-2 file:px-3 file:py-1.5 file:rounded-lg file:border-0 file:text-[.68rem] file:font-semibold file:cursor-pointer"
|
||||
style={{ color: 'var(--t3)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={uploadCis}
|
||||
disabled={cisUploading}
|
||||
className="btn btn-success btn-sm w-full justify-center"
|
||||
>
|
||||
{cisUploading ? <Loader2 size={14} className="animate-spin" /> : <Upload size={14} />}
|
||||
{t('emb.uploadCis')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ────── Knowledge Base ────── */}
|
||||
<div className={cardCls} style={cardStyle}>
|
||||
<div className={cardHeaderCls} style={cardHeaderStyle}>
|
||||
<ScrollText size={14} style={{ color: 'var(--yl)' }} />
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>{t('emb.kbTitle')}</span>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex flex-col gap-3">
|
||||
<p className="text-[.7rem]" style={{ color: 'var(--t4)' }}>
|
||||
{t('emb.kbDesc')}
|
||||
</p>
|
||||
{kbMsg && <Msg type={kbMsg.type} text={kbMsg.text} />}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('emb.adbConnection')}</label>
|
||||
<select value={kbVid} onChange={(e) => setKbVid(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
{adbOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 flex-wrap mt-1">
|
||||
{/* File upload */}
|
||||
<div className="flex-1 min-w-[240px] flex flex-col gap-2">
|
||||
<label className={labelCls} style={labelStyle}>{t('emb.fileLabel')}</label>
|
||||
<input
|
||||
ref={kbFileRef}
|
||||
type="file"
|
||||
accept=".txt,.pdf,.csv,.json,.md"
|
||||
className="w-full text-[.72rem] file:mr-2 file:px-3 file:py-1.5 file:rounded-lg file:border-0 file:text-[.68rem] file:font-semibold file:cursor-pointer"
|
||||
style={{ color: 'var(--t3)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={uploadKbFile}
|
||||
disabled={kbFileUploading}
|
||||
className="btn btn-sm w-full justify-center"
|
||||
style={{ background: 'var(--bl)', color: '#fff', borderColor: 'var(--bl)' }}
|
||||
>
|
||||
{kbFileUploading ? <Loader2 size={14} className="animate-spin" /> : <FileUp size={14} />}
|
||||
{t('emb.uploadFile')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px self-stretch my-1" style={{ background: 'var(--bd)' }} />
|
||||
|
||||
{/* URL import */}
|
||||
<div className="flex-1 min-w-[240px] flex flex-col gap-2">
|
||||
<label className={labelCls} style={labelStyle}>{t('emb.urlLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={kbUrl}
|
||||
onChange={(e) => setKbUrl(e.target.value)}
|
||||
placeholder="https://docs.oracle.com/..."
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<button
|
||||
onClick={uploadKbUrl}
|
||||
disabled={kbUrlUploading}
|
||||
className="btn btn-sm w-full justify-center"
|
||||
style={{ background: 'var(--bl)', color: '#fff', borderColor: 'var(--bl)' }}
|
||||
>
|
||||
{kbUrlUploading ? <Loader2 size={14} className="animate-spin" /> : <Link size={14} />}
|
||||
{t('emb.importUrl')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ────── Purge ────── */}
|
||||
<div className={cardCls} style={{ ...cardStyle, borderColor: 'color-mix(in srgb, var(--rd) 25%, var(--bd))' }}>
|
||||
<div className={cardHeaderCls} style={cardHeaderStyle}>
|
||||
<AlertTriangle size={14} style={{ color: 'var(--rd)' }} />
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>{t('emb.purgeTitle')}</span>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex flex-col gap-3">
|
||||
<p className="text-[.7rem]" style={{ color: 'var(--t4)' }}>
|
||||
{t('emb.purgeDesc')}
|
||||
</p>
|
||||
{purgeMsg && <Msg type={purgeMsg.type} text={purgeMsg.text} />}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('emb.adbConnection')}</label>
|
||||
<select value={purgeVid} onChange={(e) => setPurgeVid(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
{adbOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('emb.table')}</label>
|
||||
<select value={purgeTable} onChange={(e) => setPurgeTable(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
<option value="">{t('common.select')}</option>
|
||||
{allTableOptions(purgeVid)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('emb.tenancyOptional')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={purgeTenancy}
|
||||
onChange={(e) => setPurgeTenancy(e.target.value)}
|
||||
placeholder="Filtrar por tenancy"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
{!purgeConfirm ? (
|
||||
<button
|
||||
onClick={() => { if (!purgeTable) { setPurgeMsg({ type: 'e', text: t('emb.selectTable') }); return; } setPurgeConfirm(true); }}
|
||||
className="btn btn-danger btn-sm w-full justify-center"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('emb.purge')}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2 w-full">
|
||||
<button
|
||||
onClick={executePurge}
|
||||
disabled={purging}
|
||||
className="flex items-center gap-1 px-3 py-2 rounded-lg text-[.72rem] font-semibold text-white flex-1 justify-center disabled:opacity-50"
|
||||
style={{ background: 'var(--rd)' }}
|
||||
>
|
||||
{purging ? <Loader2 size={12} className="animate-spin" /> : <Check size={12} />}
|
||||
{t('emb.confirm')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPurgeConfirm(false)}
|
||||
className="px-3 py-2 rounded-lg text-[.72rem] font-medium"
|
||||
style={{ background: 'var(--bg2)', color: 'var(--t3)', border: '1px solid var(--bd)' }}
|
||||
>
|
||||
{t('common.no')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
446
frontend/src/pages/config/GenAiConfigPage.tsx
Normal file
446
frontend/src/pages/config/GenAiConfigPage.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { genaiApi, type GenAiConfigFull, type GenAiConfigPayload } from '@/api/endpoints/genai';
|
||||
import {
|
||||
Brain, Plus, Pencil, Trash2, FlaskConical, Check, X, Loader2,
|
||||
ChevronRight, Star, Link as LinkIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Msg ── */
|
||||
function Msg({ type, text }: { type: 's' | 'e' | 'i'; text: string }) {
|
||||
const bg = type === 's' ? 'var(--gnl)' : type === 'e' ? 'var(--rdl)' : 'var(--bg2)';
|
||||
const fg = type === 's' ? 'var(--gn)' : type === 'e' ? 'var(--rd)' : 'var(--t2)';
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs" style={{ background: bg, color: fg }}>
|
||||
{type === 's' && <Check size={14} />}
|
||||
{type === 'e' && <X size={14} />}
|
||||
{type === 'i' && <Loader2 size={14} className="animate-spin" />}
|
||||
<span dangerouslySetInnerHTML={{ __html: text }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
export default function GenAiConfigPage() {
|
||||
const { ociCfg, models, regions } = useAppStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const [configs, setConfigs] = useState<GenAiConfigFull[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Form
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<GenAiConfigFull | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formMsg, setFormMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Form fields
|
||||
const [name, setName] = useState('');
|
||||
const [ociConfigId, setOciConfigId] = useState('');
|
||||
const [modelId, setModelId] = useState('openai.gpt-4.1');
|
||||
const [modelOcid, setModelOcid] = useState('');
|
||||
const [compartmentId, setCompartmentId] = useState('');
|
||||
const [genaiRegion, setGenaiRegion] = useState('us-ashburn-1');
|
||||
const [isDefault, setIsDefault] = useState(false);
|
||||
|
||||
// Table
|
||||
const [tableMsg, setTableMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
const [testResults, setTestResults] = useState<Record<string, { type: 's' | 'e'; text: string }>>({});
|
||||
const [testing, setTesting] = useState<Record<string, boolean>>({});
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
|
||||
const fetchConfigs = useCallback(async () => {
|
||||
try {
|
||||
const data = await genaiApi.list();
|
||||
setConfigs(data);
|
||||
} catch {} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchConfigs(); }, [fetchConfigs]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setFormOpen(false);
|
||||
setName('');
|
||||
setOciConfigId('');
|
||||
setModelId('openai.gpt-4.1');
|
||||
setModelOcid('');
|
||||
setCompartmentId('');
|
||||
setGenaiRegion('us-ashburn-1');
|
||||
setIsDefault(false);
|
||||
setFormMsg(null);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const startEdit = (cfg: GenAiConfigFull) => {
|
||||
setEditing(cfg);
|
||||
setName(cfg.name || '');
|
||||
setOciConfigId(cfg.oci_config_id || '');
|
||||
setModelId(cfg.model_ocid ? '_custom' : (cfg.model_id || 'openai.gpt-4.1'));
|
||||
setModelOcid(cfg.model_ocid || '');
|
||||
setCompartmentId(cfg.compartment_id || '');
|
||||
setGenaiRegion(cfg.genai_region || 'us-ashburn-1');
|
||||
setIsDefault(!!cfg.is_default);
|
||||
setFormOpen(true);
|
||||
setFormMsg(null);
|
||||
setTimeout(() => formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100);
|
||||
};
|
||||
|
||||
const fillFromOci = (ociId: string) => {
|
||||
setOciConfigId(ociId);
|
||||
const cfg = ociCfg.find((c) => c.id === ociId);
|
||||
if (!cfg) return;
|
||||
setGenaiRegion(cfg.region);
|
||||
if (cfg.compartment_id) setCompartmentId(cfg.compartment_id);
|
||||
setFormMsg({ type: 'i', text: t('genai.regionFilled').replace('{0}', cfg.tenancy_name) });
|
||||
setTimeout(() => setFormMsg(null), 3000);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) { setFormMsg({ type: 'e', text: t('genai.fillName') }); return; }
|
||||
if (!ociConfigId) { setFormMsg({ type: 'e', text: t('genai.selectOci') }); return; }
|
||||
if (modelId === '_custom' && !modelOcid) { setFormMsg({ type: 'e', text: t('genai.fillOcid') }); return; }
|
||||
|
||||
const body: GenAiConfigPayload = {
|
||||
name: name.trim(),
|
||||
oci_config_id: ociConfigId,
|
||||
model_id: modelId === '_custom' ? 'custom' : modelId,
|
||||
model_ocid: modelOcid || null,
|
||||
compartment_id: compartmentId,
|
||||
genai_region: genaiRegion,
|
||||
endpoint: null,
|
||||
serving_type: 'ON_DEMAND',
|
||||
dedicated_endpoint_id: null,
|
||||
is_default: editing ? !!editing.is_default : configs.length === 0,
|
||||
};
|
||||
if (isDefault) body.is_default = true;
|
||||
|
||||
setSaving(true);
|
||||
setFormMsg({ type: 'i', text: t('genai.saving') });
|
||||
|
||||
try {
|
||||
if (editing) {
|
||||
await genaiApi.update(editing.id, body);
|
||||
} else {
|
||||
await genaiApi.create(body);
|
||||
}
|
||||
setFormMsg({ type: 's', text: editing ? t('genai.updatedSuccess') : t('genai.savedSuccess') });
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTimeout(resetForm, 1200);
|
||||
} catch (err) {
|
||||
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorSave') });
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setConfirmDelete(null);
|
||||
setTableMsg({ type: 'i', text: t('genai.deleting') });
|
||||
try {
|
||||
await genaiApi.remove(id);
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: t('genai.deletedSuccess') });
|
||||
if (editing?.id === id) resetForm();
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorDelete') });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
setTesting((p) => ({ ...p, [id]: true }));
|
||||
setTestResults((p) => { const n = { ...p }; delete n[id]; return n; });
|
||||
try {
|
||||
const res = await genaiApi.test(id);
|
||||
setTestResults((p) => ({
|
||||
...p,
|
||||
[id]: {
|
||||
type: res.status === 'success' ? 's' : 'e',
|
||||
text: res.message + (res.response ? ` — ${res.response}` : ''),
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
setTestResults((p) => ({ ...p, [id]: { type: 'e', text: err instanceof Error ? err.message : 'Erro' } }));
|
||||
} finally {
|
||||
setTesting((p) => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Group models by provider
|
||||
const groupedModels = Object.entries(models).reduce<Record<string, { key: string; name: string }[]>>((acc, [key, m]) => {
|
||||
const provider = m.provider || 'Other';
|
||||
if (!acc[provider]) acc[provider] = [];
|
||||
acc[provider].push({ key, name: m.name });
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const isFormVisible = formOpen || !!editing;
|
||||
const inputCls = "w-full px-3 py-2 rounded-lg text-[.78rem] outline-none transition-colors focus:ring-1";
|
||||
const inputStyle = { background: 'var(--bg2)', border: '1.5px solid var(--bd)', color: 'var(--t1)' };
|
||||
const labelCls = "block";
|
||||
const labelStyle = {};
|
||||
const hintCls = "text-[.64rem] mt-0.5";
|
||||
const hintStyle = { color: 'var(--t4)' };
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--ppl)' }}>
|
||||
<Brain size={24} style={{ color: 'var(--pp)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('genai.title')}</h1>
|
||||
<div className="subtitle">{t('genai.subtitle')}</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<span className="count">
|
||||
{configs.length} {configs.length === 1 ? t('genai.model') : t('genai.models')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--ppl)' }}>
|
||||
<Brain size={16} style={{ color: 'var(--pp)' }} />
|
||||
</div>
|
||||
<h2>{t('genai.configured')}</h2>
|
||||
</div>
|
||||
|
||||
{tableMsg && <div className="px-5 py-2"><Msg type={tableMsg.type} text={tableMsg.text} /></div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--t4)' }} />
|
||||
</div>
|
||||
) : configs.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<Brain size={28} style={{ color: 'var(--t4)', margin: '0 auto 8px' }} />
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>{t('genai.noModels')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{[t('genai.thConfig'), t('genai.thModel'), t('genai.thOciConfig'), t('genai.thRegion'), t('genai.thActions')].map((h) => (
|
||||
<th key={h}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{configs.map((c) => {
|
||||
const mi = models[c.model_id];
|
||||
const oci = ociCfg.find((o) => o.id === c.oci_config_id);
|
||||
return (
|
||||
<tr
|
||||
key={c.id}
|
||||
className="transition-colors"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--bd)',
|
||||
background: editing?.id === c.id ? 'color-mix(in srgb, var(--pp) 5%, var(--bg1))' : undefined,
|
||||
}}
|
||||
onMouseEnter={(e) => { if (editing?.id !== c.id) e.currentTarget.style.background = 'var(--bg2)'; }}
|
||||
onMouseLeave={(e) => { if (editing?.id !== c.id) e.currentTarget.style.background = ''; }}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold" style={{ color: 'var(--t1)' }}>{c.name || '\u2014'}</span>
|
||||
{c.is_default && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[.58rem] font-semibold"
|
||||
style={{ background: 'color-mix(in srgb, #f39c12 15%, transparent)', color: '#f39c12' }}>
|
||||
<Star size={8} /> {t('genai.default')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-xs" style={{ color: 'var(--t1)' }}>
|
||||
{mi?.name || (c.model_ocid ? 'OCID Personalizado' : c.model_id)}
|
||||
</div>
|
||||
<span className="inline-block mt-0.5 px-1.5 py-0.5 rounded text-[.58rem] font-semibold"
|
||||
style={{ background: 'var(--bg2)', color: 'var(--t4)' }}>
|
||||
{mi?.provider || (c.model_ocid ? 'custom' : '?')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[.7rem]" style={{ color: 'var(--t2)' }}>
|
||||
{oci?.tenancy_name || '\u2014'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-0.5 rounded text-[.64rem] font-medium" style={{ background: 'var(--bg2)', color: 'var(--t2)', fontFamily: 'var(--fm)' }}>
|
||||
{c.genai_region}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-1.5">
|
||||
<button onClick={() => startEdit(c)} className="btn btn-secondary btn-sm">
|
||||
<Pencil size={10} /> {t('oci.edit')}
|
||||
</button>
|
||||
<button onClick={() => handleTest(c.id)} disabled={testing[c.id]} className="btn btn-secondary btn-sm">
|
||||
{testing[c.id] ? <Loader2 size={10} className="animate-spin" /> : <FlaskConical size={10} />} {t('oci.test')}
|
||||
</button>
|
||||
{confirmDelete === c.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleDelete(c.id)} className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}>
|
||||
<Check size={10} /> {t('common.yes')}
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-sm">
|
||||
{t('common.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setConfirmDelete(c.id)} className="btn btn-danger btn-sm">
|
||||
<Trash2 size={10} /> {t('oci.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{testResults[c.id] && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded text-[.62rem] font-medium"
|
||||
style={{
|
||||
background: testResults[c.id].type === 's' ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: testResults[c.id].type === 's' ? 'var(--gn)' : 'var(--rd)',
|
||||
}}>
|
||||
{testResults[c.id].type === 's' ? <Check size={10} /> : <X size={10} />}
|
||||
<span className="truncate max-w-xs">{testResults[c.id].text}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div ref={formRef} className="card" style={{ overflow: 'hidden' }}>
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-5 py-3.5 cursor-pointer select-none"
|
||||
onClick={() => { if (!editing) setFormOpen(!formOpen); }}
|
||||
>
|
||||
<ChevronRight size={14} style={{ color: 'var(--t3)', transition: 'transform .2s', transform: isFormVisible ? 'rotate(90deg)' : 'rotate(0)' }} />
|
||||
<div className="flex items-center gap-2">
|
||||
{editing ? <Pencil size={14} style={{ color: 'var(--pp)' }} /> : <Plus size={14} style={{ color: 'var(--pp)' }} />}
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
|
||||
{editing ? `${t('genai.editModel')} — ${editing.name}` : t('genai.newModel')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFormVisible && (
|
||||
<div className="px-5 pb-5 flex flex-col gap-4" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<p className="text-[.7rem] pt-3" style={{ color: 'var(--t4)' }}>
|
||||
{editing
|
||||
? <>{t('genai.editingLabel')} <strong style={{ color: 'var(--t2)' }}>{editing.name}</strong></>
|
||||
: t('genai.addDesc')}
|
||||
</p>
|
||||
|
||||
{formMsg && <Msg type={formMsg.type} text={formMsg.text} />}
|
||||
|
||||
{/* Row 1 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('genai.configName')}</label>
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Meu Modelo Custom" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('genai.ociCredential')}</label>
|
||||
<select value={ociConfigId} onChange={(e) => fillFromOci(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
<option value="">{t('common.select')}</option>
|
||||
{ociCfg.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.tenancy_name} ({c.region})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('genai.modelSelect')}</label>
|
||||
<select value={modelId} onChange={(e) => setModelId(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
<option value="_custom"><LinkIcon size={10} className="inline" /> {t('genai.customOcid')}</option>
|
||||
{Object.entries(groupedModels).map(([provider, items]) => (
|
||||
<optgroup key={provider} label={provider}>
|
||||
{items.map((m) => (
|
||||
<option key={m.key} value={m.key}>{m.name} ({provider})</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('genai.genaiRegion')}</label>
|
||||
<select value={genaiRegion} onChange={(e) => setGenaiRegion(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
{regions.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('genai.compartment')}</label>
|
||||
<input type="text" value={compartmentId} onChange={(e) => setCompartmentId(e.target.value)} placeholder="ocid1.compartment.oc1.." className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('genai.modelOcid')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('genai.modelOcidHint')}</p>
|
||||
<input
|
||||
type="text"
|
||||
value={modelOcid}
|
||||
onChange={(e) => setModelOcid(e.target.value)}
|
||||
placeholder="ocid1.generativeaimodel.oc1.iad.amaaaaaa..."
|
||||
className={inputCls}
|
||||
style={{ ...inputStyle, borderColor: modelOcid ? 'var(--pp)' : undefined, fontFamily: 'var(--fm)', fontSize: '.72rem' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default toggle */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDefault(!isDefault)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-[.72rem] font-semibold transition-all"
|
||||
style={{
|
||||
background: isDefault ? 'color-mix(in srgb, #f39c12 15%, transparent)' : 'var(--bg2)',
|
||||
color: isDefault ? '#f39c12' : 'var(--t4)',
|
||||
border: `1.5px solid ${isDefault ? '#f39c12' : 'var(--bd)'}`,
|
||||
}}
|
||||
>
|
||||
<Star size={12} /> {isDefault ? t('genai.default') : t('genai.setDefault')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button onClick={handleSave} disabled={saving} className="btn btn-primary" style={{ background: 'var(--pp)', borderColor: 'var(--pp)' }}>
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{editing ? t('genai.saveChanges') : t('genai.saveModel')}
|
||||
</button>
|
||||
{editing && (
|
||||
<button onClick={resetForm} className="btn btn-secondary">
|
||||
{t('genai.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
532
frontend/src/pages/config/McpServersPage.tsx
Normal file
532
frontend/src/pages/config/McpServersPage.tsx
Normal file
@@ -0,0 +1,532 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { mcpApi, type McpServerFull, type McpServerPayload, type McpToolDef } from '@/api/endpoints/mcp';
|
||||
import {
|
||||
Plug, Plus, Pencil, Trash2, Check, X, Loader2, ChevronRight,
|
||||
Power, PowerOff, Search, Wrench, Terminal, Globe, FileCode2, ChevronDown, ChevronUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Msg ── */
|
||||
function Msg({ type, text }: { type: 's' | 'e' | 'i'; text: string }) {
|
||||
const bg = type === 's' ? 'var(--gnl)' : type === 'e' ? 'var(--rdl)' : 'var(--bg2)';
|
||||
const fg = type === 's' ? 'var(--gn)' : type === 'e' ? 'var(--rd)' : 'var(--t2)';
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs" style={{ background: bg, color: fg }}>
|
||||
{type === 's' && <Check size={14} />}
|
||||
{type === 'e' && <X size={14} />}
|
||||
{type === 'i' && <Loader2 size={14} className="animate-spin" />}
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Type badge ── */
|
||||
function TypeBadge({ type }: { type: string }) {
|
||||
const cfg: Record<string, { icon: React.ReactNode; bg: string; fg: string }> = {
|
||||
stdio: { icon: <Terminal size={10} />, bg: 'color-mix(in srgb, var(--ac) 12%, transparent)', fg: 'var(--ac)' },
|
||||
sse: { icon: <Globe size={10} />, bg: 'color-mix(in srgb, var(--gn) 12%, transparent)', fg: 'var(--gn)' },
|
||||
module: { icon: <FileCode2 size={10} />, bg: 'color-mix(in srgb, #e67e22 12%, transparent)', fg: '#e67e22' },
|
||||
};
|
||||
const c = cfg[type] || cfg.stdio;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.62rem] font-semibold" style={{ background: c.bg, color: c.fg }}>
|
||||
{c.icon} {type}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
export default function McpServersPage() {
|
||||
const { adbCfg } = useAppStore();
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === 'admin');
|
||||
const { t } = useI18n();
|
||||
|
||||
const [servers, setServers] = useState<McpServerFull[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Form
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<McpServerFull | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formMsg, setFormMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Form fields
|
||||
const [sName, setSName] = useState('');
|
||||
const [sDesc, setSDesc] = useState('');
|
||||
const [sType, setSType] = useState<'stdio' | 'sse' | 'module'>('stdio');
|
||||
const [sCmd, setSCmd] = useState('');
|
||||
const [sArgs, setSArgs] = useState('');
|
||||
const [sUrl, setSUrl] = useState('');
|
||||
const [sAdb, setSAdb] = useState('');
|
||||
|
||||
// Table
|
||||
const [tableMsg, setTableMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [toggling, setToggling] = useState<Record<string, boolean>>({});
|
||||
const [discovering, setDiscovering] = useState<Record<string, boolean>>({});
|
||||
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>({});
|
||||
|
||||
const fetchServers = useCallback(async () => {
|
||||
try {
|
||||
const data = await mcpApi.list();
|
||||
setServers(data);
|
||||
} catch {} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchServers(); }, [fetchServers]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setFormOpen(false);
|
||||
setSName('');
|
||||
setSDesc('');
|
||||
setSType('stdio');
|
||||
setSCmd('');
|
||||
setSArgs('');
|
||||
setSUrl('');
|
||||
setSAdb('');
|
||||
setFormMsg(null);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const startEdit = (srv: McpServerFull) => {
|
||||
setEditing(srv);
|
||||
setSName(srv.name);
|
||||
setSDesc(srv.description || '');
|
||||
setSType((srv.server_type || 'stdio') as 'stdio' | 'sse' | 'module');
|
||||
setSCmd(srv.command || '');
|
||||
setSArgs(srv.args ? JSON.stringify(srv.args) : '');
|
||||
setSUrl(srv.url || '');
|
||||
setSAdb(srv.linked_adb_id || '');
|
||||
setFormOpen(true);
|
||||
setFormMsg(null);
|
||||
setTimeout(() => formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!sName.trim()) { setFormMsg({ type: 'e', text: t('mcp.fillName') }); return; }
|
||||
|
||||
let args: string[] | null = null;
|
||||
if (sArgs.trim()) {
|
||||
try { args = JSON.parse(sArgs); } catch { setFormMsg({ type: 'e', text: t('mcp.invalidArgs') }); return; }
|
||||
}
|
||||
|
||||
const body: McpServerPayload = {
|
||||
name: sName.trim(),
|
||||
description: sDesc.trim() || null,
|
||||
server_type: sType,
|
||||
command: sType !== 'sse' ? (sCmd.trim() || null) : null,
|
||||
url: sType === 'sse' ? (sUrl.trim() || null) : null,
|
||||
args,
|
||||
linked_adb_id: sAdb || null,
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
setFormMsg({ type: 'i', text: editing ? t('mcp.updating') : t('mcp.saving') });
|
||||
|
||||
try {
|
||||
if (editing) {
|
||||
await mcpApi.update(editing.id, body);
|
||||
} else {
|
||||
await mcpApi.create(body);
|
||||
}
|
||||
setFormMsg({ type: 's', text: editing ? t('mcp.updatedSuccess') : t('mcp.savedSuccess') });
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
setTimeout(resetForm, 1200);
|
||||
} catch (err) {
|
||||
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorSave') });
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setConfirmDelete(null);
|
||||
setTableMsg({ type: 'i', text: t('mcp.deletingServer') });
|
||||
try {
|
||||
await mcpApi.remove(id);
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: t('mcp.deletedSuccess') });
|
||||
if (editing?.id === id) resetForm();
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorDelete') });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (id: string) => {
|
||||
setToggling((p) => ({ ...p, [id]: true }));
|
||||
try {
|
||||
await mcpApi.toggle(id);
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
} finally {
|
||||
setToggling((p) => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscover = async (id: string) => {
|
||||
setDiscovering((p) => ({ ...p, [id]: true }));
|
||||
setTableMsg({ type: 'i', text: t('mcp.discovering') });
|
||||
try {
|
||||
const res = await mcpApi.discoverTools(id);
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: t('mcp.discoveredTools').replace('{0}', String(res.discovered)).replace('{1}', String(res.total)) });
|
||||
setExpandedTools((p) => ({ ...p, [id]: true }));
|
||||
setTimeout(() => setTableMsg(null), 4000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('mcp.errorDiscoverTools') });
|
||||
} finally {
|
||||
setDiscovering((p) => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTool = async (srvId: string, idx: number) => {
|
||||
const srv = servers.find((s) => s.id === srvId);
|
||||
if (!srv?.tools) return;
|
||||
const tool = srv.tools[idx];
|
||||
const toolName = typeof tool === 'string' ? tool : (tool as McpToolDef).name;
|
||||
if (!confirm(t('mcp.removeToolConfirm').replace('{0}', toolName))) return;
|
||||
const newTools = [...srv.tools];
|
||||
newTools.splice(idx, 1);
|
||||
try {
|
||||
await mcpApi.updateTools(srvId, newTools);
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
}
|
||||
};
|
||||
|
||||
const isFormVisible = formOpen || !!editing;
|
||||
const inputCls = "w-full px-3 py-2 rounded-lg text-[.78rem] outline-none transition-colors focus:ring-1";
|
||||
const inputStyle = { background: 'var(--bg2)', border: '1.5px solid var(--bd)', color: 'var(--t1)' };
|
||||
const labelCls = "block";
|
||||
const labelStyle = {};
|
||||
const hintCls = "text-[.64rem] mt-0.5";
|
||||
const hintStyle = { color: 'var(--t4)' };
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--gnl)' }}>
|
||||
<Plug size={24} style={{ color: 'var(--gn)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('mcp.title')}</h1>
|
||||
<div className="subtitle">{t('mcp.subtitle')}</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<span className="count">
|
||||
{servers.length} {servers.length === 1 ? 'server' : 'servers'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Server cards */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--gnl)' }}>
|
||||
<Plug size={16} style={{ color: 'var(--gn)' }} />
|
||||
</div>
|
||||
<h2>{t('mcp.registered')}</h2>
|
||||
</div>
|
||||
|
||||
{tableMsg && <div className="px-5 py-2"><Msg type={tableMsg.type} text={tableMsg.text} /></div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--t4)' }} />
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<Plug size={28} style={{ color: 'var(--t4)', margin: '0 auto 8px' }} />
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>{t('mcp.noServers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{servers.map((srv) => {
|
||||
const tools: McpToolDef[] = Array.isArray(srv.tools) ? srv.tools : [];
|
||||
const isExpanded = expandedTools[srv.id];
|
||||
const adb = srv.linked_adb_id ? adbCfg.find((a) => a.id === srv.linked_adb_id) : null;
|
||||
return (
|
||||
<div
|
||||
key={srv.id}
|
||||
className="px-5 py-4 transition-colors"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--bd)',
|
||||
background: editing?.id === srv.id ? 'color-mix(in srgb, var(--gn) 5%, var(--bg1))' : undefined,
|
||||
}}
|
||||
>
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<strong className="text-[.88rem]" style={{ color: 'var(--t1)' }}>{srv.name}</strong>
|
||||
<TypeBadge type={srv.server_type} />
|
||||
{(srv as any).is_global === 1 && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[.55rem] font-bold" style={{ background: 'var(--bll)', color: 'var(--bl)' }}>GLOBAL</span>
|
||||
)}
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.6rem] font-semibold"
|
||||
style={{
|
||||
background: srv.is_active ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: srv.is_active ? 'var(--gn)' : 'var(--rd)',
|
||||
}}
|
||||
>
|
||||
{srv.is_active ? <Power size={8} /> : <PowerOff size={8} />}
|
||||
{srv.is_active ? t('mcp.active') : t('mcp.inactive')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{(isAdmin || !(srv as any).is_global) && (
|
||||
<>
|
||||
<button onClick={() => startEdit(srv)} className="btn btn-secondary btn-sm">
|
||||
<Pencil size={10} /> {t('mcp.edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggle(srv.id)}
|
||||
disabled={toggling[srv.id]}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
{toggling[srv.id] ? <Loader2 size={10} className="animate-spin" /> : srv.is_active ? <PowerOff size={10} /> : <Power size={10} />}
|
||||
{srv.is_active ? t('mcp.deactivate') : t('mcp.activate')}
|
||||
</button>
|
||||
{confirmDelete === srv.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleDelete(srv.id)} className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}>
|
||||
<Check size={10} /> {t('common.yes')}
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-sm">
|
||||
{t('common.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setConfirmDelete(srv.id)} className="btn btn-danger btn-sm">
|
||||
<Trash2 size={10} /> {t('mcp.delete')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{srv.description && (
|
||||
<div className="text-[.72rem] mb-2" style={{ color: 'var(--t3)' }}>{srv.description}</div>
|
||||
)}
|
||||
|
||||
{/* Details row */}
|
||||
<div className="flex gap-4 flex-wrap text-[.72rem] mb-2" style={{ color: 'var(--t4)' }}>
|
||||
{srv.command && (
|
||||
<div>
|
||||
<span className="font-semibold" style={{ color: 'var(--t3)' }}>{t('mcp.command')}</span>{' '}
|
||||
<code className="text-[.66rem]" style={{ fontFamily: 'var(--fm)' }}>{srv.command}</code>
|
||||
</div>
|
||||
)}
|
||||
{srv.url && (
|
||||
<div>
|
||||
<span className="font-semibold" style={{ color: 'var(--t3)' }}>{t('mcp.url')}</span>{' '}
|
||||
<code className="text-[.66rem]" style={{ fontFamily: 'var(--fm)' }}>{srv.url}</code>
|
||||
</div>
|
||||
)}
|
||||
{srv.module_path && (
|
||||
<div>
|
||||
<span className="font-semibold" style={{ color: 'var(--t3)' }}>{t('mcp.module')}</span>{' '}
|
||||
<code className="text-[.66rem]" style={{ fontFamily: 'var(--fm)' }}>{srv.module_path}</code>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-semibold" style={{ color: 'var(--t3)' }}>{t('mcp.adb')}</span>{' '}
|
||||
{adb ? (
|
||||
<span className="px-1.5 py-0.5 rounded text-[.6rem] font-semibold" style={{ background: 'var(--gnl)', color: 'var(--gn)' }}>{adb.config_name}</span>
|
||||
) : t('mcp.noAdb')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools section */}
|
||||
<div className="mt-2" style={{ borderTop: '1px solid var(--bd)', paddingTop: 8 }}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer select-none mb-1"
|
||||
onClick={() => setExpandedTools((p) => ({ ...p, [srv.id]: !p[srv.id] }))}
|
||||
>
|
||||
<Wrench size={12} style={{ color: 'var(--t2)' }} />
|
||||
<span className="text-[.74rem] font-semibold" style={{ color: 'var(--t2)' }}>
|
||||
{t('mcp.tools')} ({tools.length})
|
||||
</span>
|
||||
{isExpanded ? <ChevronUp size={12} style={{ color: 'var(--t4)' }} /> : <ChevronDown size={12} style={{ color: 'var(--t4)' }} />}
|
||||
</div>
|
||||
|
||||
{isExpanded && tools.length > 0 && (
|
||||
<div className="flex flex-col gap-1 mt-1">
|
||||
{tools.map((tool, i) => (
|
||||
<div key={i} className="flex items-start gap-2 px-3 py-1.5 rounded-md text-[.72rem]" style={{ background: 'var(--bg)', border: '1px solid var(--bd)' }}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<strong style={{ color: 'var(--t1)' }}>{typeof tool === 'string' ? tool : tool.name}</strong>
|
||||
{typeof tool === 'object' && tool.description && (
|
||||
<span style={{ color: 'var(--t4)' }}> — {tool.description}</span>
|
||||
)}
|
||||
{typeof tool === 'object' && tool.input_schema && (tool.input_schema as { properties?: Record<string, unknown> }).properties && (
|
||||
<div className="text-[.64rem] mt-0.5" style={{ color: 'var(--t4)' }}>
|
||||
Params: {Object.keys((tool.input_schema as { properties: Record<string, unknown> }).properties).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveTool(srv.id, i)}
|
||||
className="flex-shrink-0 px-1.5 py-0.5 rounded text-[.6rem] font-semibold transition-colors"
|
||||
style={{ color: 'var(--rd)', background: 'color-mix(in srgb, var(--rd) 8%, transparent)' }}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isExpanded && tools.length === 0 && (
|
||||
<div className="text-[.72rem] py-1" style={{ color: 'var(--t4)' }}>{t('mcp.noTools')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2 mt-3 flex-wrap">
|
||||
<button
|
||||
onClick={() => handleDiscover(srv.id)}
|
||||
disabled={discovering[srv.id]}
|
||||
className="btn btn-success btn-sm"
|
||||
>
|
||||
{discovering[srv.id] ? <Loader2 size={12} className="animate-spin" /> : <Search size={12} />}
|
||||
{t('mcp.discoverTools')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div ref={formRef} className="card" style={{ overflow: 'hidden' }}>
|
||||
<div className="flex items-center gap-2.5 px-5 py-3.5 cursor-pointer select-none" onClick={() => { if (!editing) setFormOpen(!formOpen); }}>
|
||||
<ChevronRight size={14} style={{ color: 'var(--t3)', transition: 'transform .2s', transform: isFormVisible ? 'rotate(90deg)' : 'rotate(0)' }} />
|
||||
<div className="flex items-center gap-2">
|
||||
{editing ? <Pencil size={14} style={{ color: 'var(--gn)' }} /> : <Plus size={14} style={{ color: 'var(--gn)' }} />}
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
|
||||
{editing ? `${t('mcp.editServer')} — ${editing.name}` : t('mcp.newServer')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFormVisible && (
|
||||
<div className="px-5 pb-5 flex flex-col gap-4" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<p className="text-[.7rem] pt-3" style={{ color: 'var(--t4)' }}>
|
||||
{editing
|
||||
? <>{t('mcp.editingLabel')} <strong style={{ color: 'var(--t2)' }}>{editing.name}</strong></>
|
||||
: t('mcp.configDesc')}
|
||||
</p>
|
||||
|
||||
{formMsg && <Msg type={formMsg.type} text={formMsg.text} />}
|
||||
|
||||
{/* Name + description */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.name')}</label>
|
||||
<input type="text" value={sName} onChange={(e) => setSName(e.target.value)} placeholder="CIS Benchmark Server" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.description')}</label>
|
||||
<input type="text" value={sDesc} onChange={(e) => setSDesc(e.target.value)} placeholder="Executa checks CIS 3.0" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server type */}
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.serverType')}</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
{(['stdio', 'sse', 'module'] as const).map((tp) => {
|
||||
const icons = { stdio: <Terminal size={12} />, sse: <Globe size={12} />, module: <FileCode2 size={12} /> };
|
||||
const labels = { stdio: 'stdio', sse: 'SSE (HTTP)', module: 'Python Module' };
|
||||
const selected = sType === tp;
|
||||
return (
|
||||
<button
|
||||
key={tp}
|
||||
type="button"
|
||||
onClick={() => setSType(tp)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.72rem] font-semibold transition-all"
|
||||
style={{
|
||||
background: selected ? 'color-mix(in srgb, var(--gn) 15%, transparent)' : 'var(--bg2)',
|
||||
color: selected ? 'var(--gn)' : 'var(--t4)',
|
||||
border: `1.5px solid ${selected ? 'var(--gn)' : 'var(--bd)'}`,
|
||||
}}
|
||||
>
|
||||
{icons[tp]} {labels[tp]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conditional fields */}
|
||||
{sType !== 'sse' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.commandLabel')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('mcp.commandHint')}</p>
|
||||
<input type="text" value={sCmd} onChange={(e) => setSCmd(e.target.value)} placeholder="python3 server.py" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.argsLabel')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('mcp.argsHint')}</p>
|
||||
<input type="text" value={sArgs} onChange={(e) => setSArgs(e.target.value)} placeholder='["--config", "/path/to/config"]' className={inputCls} style={{ ...inputStyle, fontFamily: 'var(--fm)', fontSize: '.72rem' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sType === 'sse' && (
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.urlLabel')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('mcp.urlHint')}</p>
|
||||
<input type="text" value={sUrl} onChange={(e) => setSUrl(e.target.value)} placeholder="http://localhost:8001/sse" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ADB link */}
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.linkAdb')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('mcp.linkAdbHint')}</p>
|
||||
<select value={sAdb} onChange={(e) => setSAdb(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
<option value="">{t('mcp.noAdb')}</option>
|
||||
{adbCfg.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.config_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button onClick={handleSave} disabled={saving} className="btn btn-success">
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{editing ? t('mcp.saveChanges') : t('mcp.saveServer')}
|
||||
</button>
|
||||
{editing && (
|
||||
<button onClick={resetForm} className="btn btn-secondary">
|
||||
{t('mcp.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
775
frontend/src/pages/config/OciConfigPage.tsx
Normal file
775
frontend/src/pages/config/OciConfigPage.tsx
Normal file
@@ -0,0 +1,775 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { ociApi, type OciConfigFull, type OciTestResult } from '@/api/endpoints/oci';
|
||||
import {
|
||||
Cloud, ChevronRight, Key, Lock, Plus, Pencil,
|
||||
Trash2, FlaskConical, Building2, User, Package,
|
||||
Check, X, Loader2, Search,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Static fallback OCI regions ── */
|
||||
const FALLBACK_REGIONS: Record<string, string[]> = {
|
||||
'Americas': [
|
||||
'us-ashburn-1', 'us-phoenix-1', 'us-sanjose-1', 'us-chicago-1',
|
||||
'ca-toronto-1', 'ca-montreal-1', 'sa-saopaulo-1', 'sa-vinhedo-1',
|
||||
'sa-santiago-1', 'sa-bogota-1', 'mx-queretaro-1', 'mx-monterrey-1',
|
||||
],
|
||||
'Europe': [
|
||||
'eu-frankfurt-1', 'eu-amsterdam-1', 'eu-zurich-1', 'eu-madrid-1',
|
||||
'eu-marseille-1', 'eu-milan-1', 'eu-stockholm-1', 'eu-paris-1',
|
||||
'uk-london-1', 'uk-cardiff-1', 'eu-jovanovac-1', 'eu-dcc-milan-1',
|
||||
],
|
||||
'Asia Pacific': [
|
||||
'ap-tokyo-1', 'ap-osaka-1', 'ap-seoul-1', 'ap-sydney-1',
|
||||
'ap-melbourne-1', 'ap-mumbai-1', 'ap-hyderabad-1', 'ap-singapore-1',
|
||||
'ap-chuncheon-1', 'ap-singapore-2',
|
||||
],
|
||||
'Middle East & Africa': [
|
||||
'me-jeddah-1', 'me-dubai-1', 'me-abudhabi-1',
|
||||
'af-johannesburg-1', 'il-jerusalem-1',
|
||||
],
|
||||
};
|
||||
|
||||
type AuthType = 'api_key' | 'session_token';
|
||||
|
||||
/* ── Msg component ── */
|
||||
function Msg({ type, text }: { type: 's' | 'e' | 'i'; text: string }) {
|
||||
const bg = type === 's' ? 'var(--gnl)' : type === 'e' ? 'var(--rdl)' : 'var(--bg2)';
|
||||
const fg = type === 's' ? 'var(--gn)' : type === 'e' ? 'var(--rd)' : 'var(--t2)';
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs" style={{ background: bg, color: fg }}>
|
||||
{type === 's' && <Check size={14} />}
|
||||
{type === 'e' && <X size={14} />}
|
||||
{type === 'i' && <Loader2 size={14} className="animate-spin" />}
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Region Dropdown ── */
|
||||
function RegionDropdown({
|
||||
value, onChange, regionsMap, t,
|
||||
}: { value: string; onChange: (r: string) => void; regionsMap: Record<string, string[]>; t: (key: string) => string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const f = filter.toLowerCase();
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<div
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center justify-between cursor-pointer px-3 py-2 rounded-lg text-[.78rem]"
|
||||
style={{ background: 'var(--bg2)', border: '1.5px solid var(--bd)', minHeight: 36 }}
|
||||
>
|
||||
<span style={{ color: value ? 'var(--t1)' : 'var(--t4)' }}>
|
||||
{value || t('oci.selectRegion')}
|
||||
</span>
|
||||
<span className="text-[.6rem]" style={{ color: 'var(--t4)' }}>▼</span>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute top-full left-0 right-0 rounded-lg overflow-hidden z-50"
|
||||
style={{ background: 'var(--bg)', border: '1px solid var(--bd)', boxShadow: '0 4px 16px rgba(0,0,0,.25)', maxHeight: 280 }}
|
||||
>
|
||||
<div className="p-1.5" style={{ borderBottom: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center gap-1.5 px-2" style={{ background: 'var(--bg2)', borderRadius: 6 }}>
|
||||
<Search size={12} style={{ color: 'var(--t4)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('oci.searchRegion')}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full border-none outline-none text-xs py-1.5"
|
||||
style={{ background: 'transparent', color: 'var(--t1)' }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto" style={{ maxHeight: 230 }}>
|
||||
{Object.entries(regionsMap).map(([grp, regs]) => {
|
||||
const filtered = regs.filter((r) => !f || r.includes(f));
|
||||
if (!filtered.length) return null;
|
||||
return (
|
||||
<div key={grp}>
|
||||
<div
|
||||
className="px-3 py-1 text-[.62rem] font-semibold uppercase tracking-wider"
|
||||
style={{ color: 'var(--t4)', background: 'var(--bg1)' }}
|
||||
>
|
||||
{grp}
|
||||
</div>
|
||||
{filtered.map((r) => (
|
||||
<div
|
||||
key={r}
|
||||
onClick={() => { onChange(r); setOpen(false); setFilter(''); }}
|
||||
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
|
||||
style={{
|
||||
color: value === r ? 'var(--ac)' : 'var(--t2)',
|
||||
background: value === r ? 'color-mix(in srgb, var(--ac) 8%, transparent)' : undefined,
|
||||
}}
|
||||
onMouseEnter={(e) => { if (value !== r) (e.target as HTMLDivElement).style.background = 'var(--bg2)'; }}
|
||||
onMouseLeave={(e) => { if (value !== r) (e.target as HTMLDivElement).style.background = ''; }}
|
||||
>
|
||||
{r}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Object.entries(regionsMap).every(([, regs]) => !regs.some((r) => !f || r.includes(f))) && (
|
||||
<div className="p-3 text-xs" style={{ color: 'var(--t4)' }}>{t('oci.noRegion')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Auth Type Tag ── */
|
||||
function AuthTag({ type }: { type: string }) {
|
||||
const isToken = type === 'session_token';
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.62rem] font-semibold"
|
||||
style={{
|
||||
background: isToken ? 'rgba(230,126,34,0.12)' : 'var(--gnl)',
|
||||
color: isToken ? '#e67e22' : 'var(--gn)',
|
||||
}}
|
||||
>
|
||||
{isToken ? <Lock size={10} /> : <Key size={10} />}
|
||||
{isToken ? 'Session Token' : 'API Key'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main Page ── */
|
||||
export default function OciConfigPage() {
|
||||
const ociRegions = useAppStore((s) => s.ociRegions);
|
||||
const { t } = useI18n();
|
||||
|
||||
const [configs, setConfigs] = useState<OciConfigFull[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Form state
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<OciConfigFull | null>(null);
|
||||
const [authType, setAuthType] = useState<AuthType>('api_key');
|
||||
const [region, setRegion] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formMsg, setFormMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
|
||||
// Table msg
|
||||
const [tableMsg, setTableMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
|
||||
// Test results per config
|
||||
const [testResults, setTestResults] = useState<Record<string, OciTestResult>>({});
|
||||
const [testing, setTesting] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Delete confirm
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
|
||||
// Form refs
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
const tnRef = useRef<HTMLInputElement>(null);
|
||||
const toRef = useRef<HTMLInputElement>(null);
|
||||
const uoRef = useRef<HTMLInputElement>(null);
|
||||
const fpRef = useRef<HTMLInputElement>(null);
|
||||
const cpRef = useRef<HTMLInputElement>(null);
|
||||
const kpRef = useRef<HTMLInputElement>(null);
|
||||
const skRef = useRef<HTMLInputElement>(null);
|
||||
const pkRef = useRef<HTMLInputElement>(null);
|
||||
const stkRef = useRef<HTMLTextAreaElement>(null);
|
||||
const sshRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Build regions map
|
||||
const regionsMap: Record<string, string[]> = (() => {
|
||||
if (ociRegions && typeof ociRegions === 'object') {
|
||||
const entries = Object.entries(ociRegions);
|
||||
if (entries.length > 0) {
|
||||
// ociRegions can be { group: string[] } or { group: string }
|
||||
// From the store it's Record<string,string>, but the backend returns Record<string,string[]>
|
||||
const first = entries[0][1];
|
||||
if (Array.isArray(first)) return ociRegions as unknown as Record<string, string[]>;
|
||||
// If it's flat, return as single-group
|
||||
const flat = entries.map(([, v]) => v as unknown as string);
|
||||
if (flat.length > 0) return { 'Regioes': flat };
|
||||
}
|
||||
}
|
||||
return FALLBACK_REGIONS;
|
||||
})();
|
||||
|
||||
const fetchConfigs = useCallback(async () => {
|
||||
try {
|
||||
const data = await ociApi.list();
|
||||
setConfigs(data);
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchConfigs(); }, [fetchConfigs]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setFormOpen(false);
|
||||
setAuthType('api_key');
|
||||
setRegion('');
|
||||
setFormMsg(null);
|
||||
setSaving(false);
|
||||
// Clear file inputs
|
||||
if (skRef.current) skRef.current.value = '';
|
||||
if (pkRef.current) pkRef.current.value = '';
|
||||
};
|
||||
|
||||
const startEdit = (cfg: OciConfigFull) => {
|
||||
setEditing(cfg);
|
||||
setAuthType(cfg.auth_type || 'api_key');
|
||||
setRegion(cfg.region);
|
||||
setFormOpen(true);
|
||||
setFormMsg(null);
|
||||
setTimeout(() => formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const tenancyName = tnRef.current?.value?.trim() || '';
|
||||
if (!tenancyName) { setFormMsg({ type: 'e', text: `${t('oci.fillField')} Tenancy Name` }); return; }
|
||||
|
||||
const isEdit = !!editing;
|
||||
const at = isEdit ? (editing!.auth_type || 'api_key') : authType;
|
||||
const isToken = at === 'session_token';
|
||||
|
||||
const tenancyOcid = toRef.current?.value?.trim() || '';
|
||||
const userOcid = uoRef.current?.value?.trim() || '';
|
||||
const fingerprint = fpRef.current?.value?.trim() || '';
|
||||
const compartmentId = cpRef.current?.value?.trim() || '';
|
||||
const keyPassphrase = kpRef.current?.value?.trim() || '';
|
||||
const securityToken = stkRef.current?.value?.trim() || '';
|
||||
const sshPubKey = sshRef.current?.value?.trim() || '';
|
||||
const privateKeyFile = skRef.current?.files?.[0];
|
||||
const publicKeyFile = pkRef.current?.files?.[0];
|
||||
|
||||
// Validations for new config
|
||||
if (!isEdit) {
|
||||
if (!tenancyOcid) { setFormMsg({ type: 'e', text: t('oci.fillOcidTenancy') }); return; }
|
||||
if (!isToken && (!userOcid || !fingerprint)) { setFormMsg({ type: 'e', text: t('oci.fillUserAndFP') }); return; }
|
||||
if (isToken && !securityToken) { setFormMsg({ type: 'e', text: t('oci.pasteToken') }); return; }
|
||||
if (!privateKeyFile) { setFormMsg({ type: 'e', text: isToken ? t('oci.selectSessionKey') : t('oci.selectKey') }); return; }
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('tenancy_name', tenancyName);
|
||||
fd.append('tenancy_ocid', tenancyOcid);
|
||||
fd.append('user_ocid', userOcid);
|
||||
fd.append('fingerprint', fingerprint);
|
||||
fd.append('region', region);
|
||||
fd.append('compartment_id', compartmentId);
|
||||
fd.append('key_passphrase', keyPassphrase);
|
||||
fd.append('ssh_public_key', sshPubKey);
|
||||
fd.append('auth_type', at);
|
||||
fd.append('security_token', securityToken);
|
||||
if (privateKeyFile) fd.append('private_key', privateKeyFile);
|
||||
if (publicKeyFile) fd.append('public_key', publicKeyFile);
|
||||
|
||||
setSaving(true);
|
||||
setFormMsg({ type: 'i', text: t('oci.saving') });
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await ociApi.update(editing!.id, fd);
|
||||
} else {
|
||||
await ociApi.create(fd);
|
||||
}
|
||||
setFormMsg({ type: 's', text: isEdit ? t('oci.updated') : t('oci.saved') });
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTimeout(resetForm, 1200);
|
||||
} catch (err) {
|
||||
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorSave') });
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setConfirmDelete(null);
|
||||
setTableMsg({ type: 'i', text: t('oci.deleting') });
|
||||
try {
|
||||
await ociApi.remove(id);
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: t('oci.deleted') });
|
||||
if (editing?.id === id) resetForm();
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorDelete') });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
setTesting((p) => ({ ...p, [id]: true }));
|
||||
setTestResults((p) => { const n = { ...p }; delete n[id]; return n; });
|
||||
try {
|
||||
const res = await ociApi.test(id);
|
||||
setTestResults((p) => ({ ...p, [id]: res }));
|
||||
} catch (err) {
|
||||
setTestResults((p) => ({ ...p, [id]: { status: 'error', message: err instanceof Error ? err.message : 'Erro' } }));
|
||||
} finally {
|
||||
setTesting((p) => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const isFormVisible = formOpen || !!editing;
|
||||
const currentAuthType = editing ? (editing.auth_type || 'api_key') : authType;
|
||||
const isToken = currentAuthType === 'session_token';
|
||||
|
||||
/* ── Input helper ── */
|
||||
const inputCls = "w-full px-3 py-2 rounded-lg text-[.78rem] outline-none transition-colors focus:ring-1";
|
||||
const inputStyle = { background: 'var(--bg2)', border: '1.5px solid var(--bd)', color: 'var(--t1)' };
|
||||
const labelCls = "block";
|
||||
const labelStyle = {};
|
||||
const hintCls = "text-[.64rem] mt-0.5";
|
||||
const hintStyle = { color: 'var(--t4)' };
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<Cloud size={24} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('oci.title')}</h1>
|
||||
<div className="subtitle">{t('oci.subtitle')}</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<span className="count">
|
||||
{configs.length} {configs.length === 1 ? t('oci.credential') : t('oci.credentials')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Table ── */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<Cloud size={16} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<h2>{t('oci.registered')}</h2>
|
||||
</div>
|
||||
|
||||
{tableMsg && <div className="px-5 py-2">{<Msg type={tableMsg.type} text={tableMsg.text} />}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--t4)' }} />
|
||||
</div>
|
||||
) : configs.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<Cloud size={28} style={{ color: 'var(--t4)', margin: '0 auto 8px' }} />
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>{t('oci.noCredentials')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{[t('oci.thTenancy'), t('oci.thType'), t('oci.thRegion'), t('oci.thDetails'), t('oci.thActions')].map((h) => (
|
||||
<th key={h}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{configs.map((c) => (
|
||||
<tr
|
||||
key={c.id}
|
||||
className="transition-colors"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--bd)',
|
||||
background: editing?.id === c.id ? 'color-mix(in srgb, var(--ac) 5%, var(--bg1))' : undefined,
|
||||
}}
|
||||
onMouseEnter={(e) => { if (editing?.id !== c.id) (e.currentTarget.style.background = 'var(--bg2)'); }}
|
||||
onMouseLeave={(e) => { if (editing?.id !== c.id) (e.currentTarget.style.background = ''); }}
|
||||
>
|
||||
{/* Tenancy */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-xs font-semibold" style={{ color: 'var(--t1)' }}>{c.tenancy_name}</div>
|
||||
<div className="text-[.6rem] mt-0.5" style={{ color: 'var(--t4)' }}>{c.created_at}</div>
|
||||
</td>
|
||||
{/* Auth Type */}
|
||||
<td className="px-4 py-3"><AuthTag type={c.auth_type} /></td>
|
||||
{/* Region */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-0.5 rounded text-[.64rem] font-medium" style={{ background: 'var(--bg2)', color: 'var(--t2)' }}>
|
||||
{c.region}
|
||||
</span>
|
||||
</td>
|
||||
{/* Details */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-0.5 text-[.62rem] leading-relaxed" style={{ color: 'var(--t4)', fontFamily: 'var(--fm)' }}>
|
||||
{(c.auth_type || 'api_key') === 'session_token' ? (
|
||||
<>
|
||||
<span className="flex items-center gap-1" title="Tenancy OCID"><Building2 size={10} /> {c.tenancy_ocid}</span>
|
||||
<span className="flex items-center gap-1" title="Auth"><Lock size={10} /> Session Token</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex items-center gap-1" title="Fingerprint"><Key size={10} /> {'*'.repeat(20)}</span>
|
||||
<span className="flex items-center gap-1" title="Tenancy OCID"><Building2 size={10} /> {c.tenancy_ocid}</span>
|
||||
<span className="flex items-center gap-1" title="User OCID"><User size={10} /> {c.user_ocid}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="flex items-center gap-1" title="Compartment"><Package size={10} /> {c.compartment_id || '\u2014'}</span>
|
||||
<span className="flex items-center gap-1" title="SSH Public Key"><Key size={10} /> SSH: {c.ssh_public_key_preview || <em>{t('oci.sshNotConfigured')}</em>}</span>
|
||||
</div>
|
||||
</td>
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={() => startEdit(c)}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
<Pencil size={10} /> {t('oci.edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTest(c.id)}
|
||||
disabled={testing[c.id]}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
{testing[c.id] ? <Loader2 size={10} className="animate-spin" /> : <FlaskConical size={10} />} {t('oci.test')}
|
||||
</button>
|
||||
{confirmDelete === c.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}
|
||||
>
|
||||
<Check size={10} /> {t('oci.yes')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
{t('oci.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(c.id)}
|
||||
className="btn btn-danger btn-sm"
|
||||
>
|
||||
<Trash2 size={10} /> {t('oci.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Test result inline */}
|
||||
{testResults[c.id] && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded text-[.62rem] font-medium"
|
||||
style={{
|
||||
background: testResults[c.id].status === 'success' ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: testResults[c.id].status === 'success' ? 'var(--gn)' : 'var(--rd)',
|
||||
}}
|
||||
>
|
||||
{testResults[c.id].status === 'success' ? <Check size={10} /> : <X size={10} />}
|
||||
{testResults[c.id].message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Form Card ── */}
|
||||
<div ref={formRef} className="card" style={{ overflow: 'hidden' }}>
|
||||
{/* Collapsible header */}
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-5 py-3.5 cursor-pointer select-none"
|
||||
onClick={() => { if (!editing) setFormOpen(!formOpen); }}
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
style={{ color: 'var(--t3)', transition: 'transform .2s', transform: isFormVisible ? 'rotate(90deg)' : 'rotate(0)' }}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{editing ? <Pencil size={14} style={{ color: 'var(--ac)' }} /> : <Plus size={14} style={{ color: 'var(--ac)' }} />}
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
|
||||
{editing ? t('oci.editCredential') : t('oci.newCredential')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFormVisible && (
|
||||
<div className="px-5 pb-5 flex flex-col gap-4" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-[.7rem] pt-3" style={{ color: 'var(--t4)' }}>
|
||||
{editing
|
||||
? <>{t('oci.editing')} <strong style={{ color: 'var(--t2)' }}>{editing.tenancy_name}</strong></>
|
||||
: t('oci.addDesc')}
|
||||
</p>
|
||||
|
||||
{/* Form message */}
|
||||
{formMsg && <Msg type={formMsg.type} text={formMsg.text} />}
|
||||
|
||||
{/* Auth type toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => { if (!editing) setAuthType('api_key'); }}
|
||||
disabled={!!editing}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.72rem] font-semibold transition-all disabled:opacity-70"
|
||||
style={{
|
||||
background: !isToken ? 'color-mix(in srgb, var(--ac) 15%, transparent)' : 'var(--bg2)',
|
||||
color: !isToken ? 'var(--ac)' : 'var(--t4)',
|
||||
border: `1.5px solid ${!isToken ? 'var(--ac)' : 'var(--bd)'}`,
|
||||
}}
|
||||
>
|
||||
<Key size={12} /> API Key
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (!editing) setAuthType('session_token'); }}
|
||||
disabled={!!editing}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.72rem] font-semibold transition-all disabled:opacity-70"
|
||||
style={{
|
||||
background: isToken ? 'color-mix(in srgb, #e67e22 15%, transparent)' : 'var(--bg2)',
|
||||
color: isToken ? '#e67e22' : 'var(--t4)',
|
||||
border: `1.5px solid ${isToken ? '#e67e22' : 'var(--bd)'}`,
|
||||
}}
|
||||
>
|
||||
<Lock size={12} /> Session Token
|
||||
</button>
|
||||
{editing && (
|
||||
<span className="text-[.6rem]" style={{ color: 'var(--t4)' }}>{t('oci.typeCannotChange')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 1: Tenancy Name + OCID Tenancy */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.tenancyName')}</label>
|
||||
<input
|
||||
ref={tnRef}
|
||||
type="text"
|
||||
placeholder="minha-empresa"
|
||||
defaultValue={editing?.tenancy_name || ''}
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.ocidTenancy')}</label>
|
||||
<input
|
||||
ref={toRef}
|
||||
type="password"
|
||||
placeholder="ocid1.tenancy.oc1.."
|
||||
autoComplete="off"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{editing && (
|
||||
<p className={hintCls} style={hintStyle}>{t('oci.changeHint')} <code style={{ color: 'var(--t3)' }}>{editing.tenancy_ocid}</code></p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Region + Compartment */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.region')}</label>
|
||||
<RegionDropdown value={region} onChange={setRegion} regionsMap={regionsMap} t={t} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.compartment')}</label>
|
||||
<input
|
||||
ref={cpRef}
|
||||
type="password"
|
||||
placeholder="ocid1.compartment.oc1.."
|
||||
autoComplete="off"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{editing && (
|
||||
<p className={hintCls} style={hintStyle}>{t('oci.changeHint')} <code style={{ color: 'var(--t3)' }}>{editing.compartment_id || '\u2014'}</code></p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key fields: User OCID + Fingerprint */}
|
||||
{!isToken && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.ocidUser')}</label>
|
||||
<input
|
||||
ref={uoRef}
|
||||
type="password"
|
||||
placeholder="ocid1.user.oc1.."
|
||||
autoComplete="off"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{editing && (
|
||||
<p className={hintCls} style={hintStyle}>{t('oci.changeHint')} <code style={{ color: 'var(--t3)' }}>{editing.user_ocid}</code></p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.fingerprint')}</label>
|
||||
<input
|
||||
ref={fpRef}
|
||||
type="password"
|
||||
placeholder="aa:bb:cc:dd:..."
|
||||
autoComplete="off"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{editing && (
|
||||
<p className={hintCls} style={hintStyle}>{t('oci.changeHint')} <code style={{ color: 'var(--t3)' }}>{editing.fingerprint}</code></p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session Token fields */}
|
||||
{isToken && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.sessionToken')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>
|
||||
{t('oci.sessionTokenHint')} <code style={{ color: 'var(--t3)' }}>oci session authenticate</code>
|
||||
</p>
|
||||
<textarea
|
||||
ref={stkRef}
|
||||
rows={3}
|
||||
placeholder="Conteudo do arquivo ~/.oci/sessions/DEFAULT/token"
|
||||
className={inputCls}
|
||||
style={{ ...inputStyle, resize: 'vertical', fontFamily: 'var(--fm)', fontSize: '.68rem' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>
|
||||
{t('oci.ocidUser')} <span style={{ fontSize: '.6rem', color: 'var(--t4)' }}>(opcional)</span>
|
||||
</label>
|
||||
<input
|
||||
ref={uoRef}
|
||||
type="password"
|
||||
placeholder="ocid1.user.oc1.. (preenchido automaticamente)"
|
||||
autoComplete="off"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<p className={hintCls} style={hintStyle}>O SDK pode resolver o user a partir do token. Deixe vazio se preferir.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keys row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>
|
||||
{isToken ? t('oci.sessionKeyLabel') : t('oci.privateKey')}
|
||||
</label>
|
||||
{editing && <p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('oci.keepKey')}</p>}
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>
|
||||
{isToken
|
||||
? <span>Arquivo <code style={{ color: 'var(--t3)' }}>oci_api_key.pem</code> da pasta de sessao</span>
|
||||
: 'Chave privada da API Key OCI'}
|
||||
</p>
|
||||
<input
|
||||
ref={skRef}
|
||||
type="file"
|
||||
accept=".pem"
|
||||
className="text-[.72rem] file:mr-2 file:px-3 file:py-1.5 file:rounded-lg file:border-0 file:text-[.68rem] file:font-semibold file:cursor-pointer"
|
||||
style={{ color: 'var(--t3)' }}
|
||||
/>
|
||||
</div>
|
||||
{!isToken ? (
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.publicKey')}</label>
|
||||
<input
|
||||
ref={pkRef}
|
||||
type="file"
|
||||
accept=".pem"
|
||||
className="text-[.72rem] file:mr-2 file:px-3 file:py-1.5 file:rounded-lg file:border-0 file:text-[.68rem] file:font-semibold file:cursor-pointer"
|
||||
style={{ color: 'var(--t3)' }}
|
||||
/>
|
||||
</div>
|
||||
) : <div />}
|
||||
</div>
|
||||
|
||||
{/* Passphrase + SSH */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{!isToken ? (
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.keyPassphrase')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('oci.keyPassphraseHint')}</p>
|
||||
<input
|
||||
ref={kpRef}
|
||||
type="password"
|
||||
placeholder="(opcional)"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
) : <div />}
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.sshPublicKey')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('oci.sshPublicKeyHint')}</p>
|
||||
<textarea
|
||||
ref={sshRef}
|
||||
rows={2}
|
||||
placeholder="ssh-rsa AAAAB3NzaC1yc2E... (opcional)"
|
||||
defaultValue={editing?.ssh_public_key || ''}
|
||||
className={inputCls}
|
||||
style={{ ...inputStyle, resize: 'vertical', fontFamily: 'var(--fm)', fontSize: '.72rem' }}
|
||||
/>
|
||||
{editing?.ssh_public_key_preview && (
|
||||
<p className={hintCls} style={hintStyle}>Atual: <code style={{ color: 'var(--t3)' }}>{editing.ssh_public_key_preview}</code></p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{editing ? t('oci.saveChanges') : t('oci.saveCredential')}
|
||||
</button>
|
||||
{editing && (
|
||||
<button
|
||||
onClick={resetForm}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('oci.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
422
frontend/src/stores/app.ts
Normal file
422
frontend/src/stores/app.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import { create } from 'zustand';
|
||||
import client from '@/api/client';
|
||||
|
||||
export interface OciConfig {
|
||||
id: string;
|
||||
tenancy_name: string;
|
||||
region: string;
|
||||
compartment_id?: string;
|
||||
auth_type?: string;
|
||||
}
|
||||
|
||||
export interface GenAiConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
model_id: string;
|
||||
model_ocid?: string;
|
||||
oci_config_id: string;
|
||||
compartment_id?: string;
|
||||
genai_region?: string;
|
||||
endpoint?: string;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface EmbeddingModelInfo {
|
||||
name: string;
|
||||
dims: number;
|
||||
}
|
||||
|
||||
export interface McpServer {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
server_type: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
url?: string;
|
||||
module_path?: string;
|
||||
is_active: boolean;
|
||||
tools?: McpTool[];
|
||||
linked_adb_id?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface McpTool {
|
||||
name: string;
|
||||
description?: string;
|
||||
input_schema?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AdbTable {
|
||||
id: string;
|
||||
table_name: string;
|
||||
description?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface AdbConfig {
|
||||
id: string;
|
||||
config_name: string;
|
||||
dsn: string;
|
||||
username: string;
|
||||
table_name?: string;
|
||||
use_mtls: boolean;
|
||||
is_active: boolean;
|
||||
wallet_dir?: string;
|
||||
genai_config_id?: string;
|
||||
embedding_model_id?: string;
|
||||
tables?: AdbTable[];
|
||||
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[];
|
||||
genaiCfg: GenAiConfig[];
|
||||
models: Record<string, ModelInfo>;
|
||||
regions: string[];
|
||||
ociRegions: Record<string, string[]>;
|
||||
embModels: Record<string, EmbeddingModelInfo>;
|
||||
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;
|
||||
expCompartmentTree: any[];
|
||||
expAvailableRegions: any[];
|
||||
expCounts: Record<string, 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;
|
||||
setExpCompartmentTree: (v: any[]) => void;
|
||||
setExpAvailableRegions: (v: any[]) => void;
|
||||
setExpCounts: (v: Record<string, 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;
|
||||
rptEmbedTaskId: string;
|
||||
setRptEmbedTaskId: (v: string) => void;
|
||||
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;
|
||||
|
||||
// Load all shared data after auth
|
||||
loadData: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
ociCfg: [],
|
||||
genaiCfg: [],
|
||||
models: {},
|
||||
regions: [],
|
||||
ociRegions: {},
|
||||
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 }),
|
||||
expCompartmentTree: [],
|
||||
expAvailableRegions: [],
|
||||
expCounts: {},
|
||||
setExpCompartmentTree: (v) => set({ expCompartmentTree: v }),
|
||||
setExpAvailableRegions: (v) => set({ expAvailableRegions: v }),
|
||||
setExpCounts: (v) => set({ expCounts: v }),
|
||||
|
||||
// ── Reports Page ──
|
||||
rptOciVal: '',
|
||||
rptLevel: 2,
|
||||
rptSelectedRegions: [],
|
||||
rptObp: true,
|
||||
rptRaw: false,
|
||||
rptRedact: true,
|
||||
rptFormOpen: true,
|
||||
rptSelectedRid: '',
|
||||
rptTrackingId: null,
|
||||
rptShowIframe: false,
|
||||
rptShowCompliance: false,
|
||||
rptEmbedAdb: '',
|
||||
rptEmbedTable: '',
|
||||
rptEmbedTaskId: '',
|
||||
setRptEmbedTaskId: (v) => set({ rptEmbedTaskId: v }),
|
||||
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 })),
|
||||
|
||||
loadData: async () => {
|
||||
try {
|
||||
const [ociCfg, genaiCfg, mcpSvr] = await Promise.all([
|
||||
client.get('/oci/configs'),
|
||||
client.get('/genai/configs'),
|
||||
client.get('/mcp/servers'),
|
||||
]) as unknown as [OciConfig[], GenAiConfig[], McpServer[]];
|
||||
set({ ociCfg, genaiCfg, mcpSvr });
|
||||
|
||||
try {
|
||||
const m = await client.get('/genai/models') as unknown as {
|
||||
models: Record<string, ModelInfo>;
|
||||
regions: string[];
|
||||
oci_regions: Record<string, string[]>;
|
||||
embedding_models: Record<string, EmbeddingModelInfo>;
|
||||
};
|
||||
set({
|
||||
models: m.models,
|
||||
regions: m.regions,
|
||||
ociRegions: m.oci_regions || {},
|
||||
embModels: m.embedding_models || {},
|
||||
});
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const adbCfg = await client.get('/adb/configs') as unknown as AdbConfig[];
|
||||
set({ adbCfg });
|
||||
} catch {
|
||||
set({ adbCfg: [] });
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
}));
|
||||
77
frontend/src/stores/auth.ts
Normal file
77
frontend/src/stores/auth.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { create } from 'zustand';
|
||||
import { authApi, type User, type LoginResponse } from '@/api/endpoints/auth';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
mfaRequired: boolean;
|
||||
mfaSetup: boolean;
|
||||
totpUri: string | null;
|
||||
mustChangePassword: boolean;
|
||||
|
||||
login: (username: string, password: string, totp?: string) => Promise<LoginResponse>;
|
||||
logout: () => Promise<void>;
|
||||
checkAuth: () => Promise<void>;
|
||||
setToken: (token: string) => void;
|
||||
clearMustChangePassword: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
token: localStorage.getItem('t'),
|
||||
loading: true,
|
||||
mfaRequired: false,
|
||||
mfaSetup: false,
|
||||
totpUri: null,
|
||||
mustChangePassword: false,
|
||||
|
||||
login: async (username, password, totp) => {
|
||||
const res = await authApi.login(username, password, totp);
|
||||
if (res.mfa_required) {
|
||||
set({ mfaRequired: true, mfaSetup: !!res.mfa_setup, totpUri: res.totp_uri || null });
|
||||
return res;
|
||||
}
|
||||
if (res.token && res.user) {
|
||||
localStorage.setItem('t', res.token);
|
||||
set({ token: res.token, user: res.user, mfaRequired: false, mfaSetup: false, totpUri: null,
|
||||
mustChangePassword: !!res.must_change_password });
|
||||
}
|
||||
return res;
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
const currentUser = get().user;
|
||||
try {
|
||||
if (currentUser?.auth_provider === 'oidc') {
|
||||
const res = await authApi.oidcLogout();
|
||||
localStorage.removeItem('t');
|
||||
set({ user: null, token: null, mfaRequired: false, mustChangePassword: false });
|
||||
if (res.idp_logout_url) window.location.href = res.idp_logout_url;
|
||||
return;
|
||||
}
|
||||
await authApi.logout();
|
||||
} catch {}
|
||||
localStorage.removeItem('t');
|
||||
set({ user: null, token: null, mfaRequired: false, mustChangePassword: false });
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
const token = get().token;
|
||||
if (!token) { set({ loading: false }); return; }
|
||||
try {
|
||||
const user = await authApi.me();
|
||||
set({ user, loading: false, mustChangePassword: !!user.must_change_password });
|
||||
} catch {
|
||||
localStorage.removeItem('t');
|
||||
set({ token: null, user: null, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setToken: (token) => {
|
||||
localStorage.setItem('t', token);
|
||||
set({ token });
|
||||
},
|
||||
|
||||
clearMustChangePassword: () => set({ mustChangePassword: false }),
|
||||
}));
|
||||
30
frontend/src/stores/terminal.ts
Normal file
30
frontend/src/stores/terminal.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface TermLine {
|
||||
type: 'input' | 'output' | 'error' | 'info';
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface TerminalState {
|
||||
lines: TermLine[];
|
||||
selectedCfg: string;
|
||||
cmdHistory: string[];
|
||||
addLine: (type: TermLine['type'], text: string) => void;
|
||||
setLines: (lines: TermLine[]) => void;
|
||||
clearLines: () => void;
|
||||
setSelectedCfg: (cfg: string) => void;
|
||||
setCmdHistory: (history: string[]) => void;
|
||||
addToHistory: (cmd: string) => void;
|
||||
}
|
||||
|
||||
export const useTerminalStore = create<TerminalState>((set) => ({
|
||||
lines: [],
|
||||
selectedCfg: '',
|
||||
cmdHistory: [],
|
||||
addLine: (type, text) => set((s) => ({ lines: [...s.lines, { type, text }] })),
|
||||
setLines: (lines) => set({ lines }),
|
||||
clearLines: () => set({ lines: [] }),
|
||||
setSelectedCfg: (cfg) => set({ selectedCfg: cfg }),
|
||||
setCmdHistory: (history) => set({ cmdHistory: history }),
|
||||
addToHistory: (cmd) => set((s) => ({ cmdHistory: [...s.cmdHistory, cmd] })),
|
||||
}));
|
||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
21
frontend/vite.config.ts
Normal file
21
frontend/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
base: '/',
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user