feat: LAD A-Team CIS Compliance Report, React SPA, i18n, report management, explorer UX fixes
- Professional Oracle-format compliance report with RAG remediation from ADB vector store - React 19 SPA at /app/ (16 pages, TypeScript, Vite, Zustand, i18n pt/en 625 keys) - Report delete endpoint, embed individual report files into vector store - Terraform ZIP download (client-side, pure JS) - Explorer silent polling during start/stop (no flickering) - HTML report and compliance report sections start minimized - Token auth fix for compliance report CSV download links
This commit is contained in:
73
frontend-react/README.md
Normal file
73
frontend-react/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-react/eslint.config.js
Normal file
23
frontend-react/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-react/index.html
Normal file
21
frontend-react/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="/app/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI Agent - Infrastructure & Security Engineer</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
</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-react/package-lock.json
generated
Normal file
5920
frontend-react/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend-react/package.json
Normal file
41
frontend-react/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-react/postcss.config.js
Normal file
5
frontend-react/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
21
frontend-react/public/logo.svg
Normal file
21
frontend-react/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 |
63
frontend-react/src/App.tsx
Normal file
63
frontend-react/src/App.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import LoginPage from '@/pages/LoginPage';
|
||||
import ChatPage from '@/pages/ChatPage';
|
||||
import TerraformPage from '@/pages/TerraformPage';
|
||||
import PromptGeneratorPage from '@/pages/PromptGeneratorPage';
|
||||
import WorkspacesPage from '@/pages/WorkspacesPage';
|
||||
import ExplorerPage from '@/pages/ExplorerPage';
|
||||
import ReportsPage from '@/pages/ReportsPage';
|
||||
import OciConfigPage from '@/pages/config/OciConfigPage';
|
||||
import GenAiConfigPage from '@/pages/config/GenAiConfigPage';
|
||||
import McpServersPage from '@/pages/config/McpServersPage';
|
||||
import AdbConfigPage from '@/pages/config/AdbConfigPage';
|
||||
import EmbeddingsPage from '@/pages/config/EmbeddingsPage';
|
||||
import EmbConsultPage from '@/pages/config/EmbConsultPage';
|
||||
import UsersPage from '@/pages/admin/UsersPage';
|
||||
import MfaPage from '@/pages/admin/MfaPage';
|
||||
import AuditPage from '@/pages/admin/AuditPage';
|
||||
|
||||
export default function App() {
|
||||
const checkAuth = useAuthStore((s) => s.checkAuth);
|
||||
const loadData = useAppStore((s) => s.loadData);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth().then(() => {
|
||||
if (useAuthStore.getState().user) loadData();
|
||||
});
|
||||
}, [checkAuth, loadData]);
|
||||
|
||||
// Reload data when user changes (login)
|
||||
useEffect(() => {
|
||||
if (user) loadData();
|
||||
}, [user, loadData]);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<Navigate to="/chat" replace />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/terraform" element={<TerraformPage />} />
|
||||
<Route path="/terraform/prompt" element={<PromptGeneratorPage />} />
|
||||
<Route path="/terraform/workspaces" element={<WorkspacesPage />} />
|
||||
<Route path="/explorer" element={<ExplorerPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/config/oci" element={<OciConfigPage />} />
|
||||
<Route path="/config/genai" element={<GenAiConfigPage />} />
|
||||
<Route path="/config/mcp" element={<McpServersPage />} />
|
||||
<Route path="/config/adb" element={<AdbConfigPage />} />
|
||||
<Route path="/config/embeddings" element={<EmbeddingsPage />} />
|
||||
<Route path="/config/embeddings/consult" element={<EmbConsultPage />} />
|
||||
<Route path="/admin/users" element={<UsersPage />} />
|
||||
<Route path="/admin/mfa" element={<MfaPage />} />
|
||||
<Route path="/admin/audit" element={<AuditPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/chat" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
30
frontend-react/src/api/client.ts
Normal file
30
frontend-react/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 = '/app/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-react/src/api/endpoints/adb.ts
Normal file
71
frontend-react/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 }>,
|
||||
};
|
||||
72
frontend-react/src/api/endpoints/admin.ts
Normal file
72
frontend-react/src/api/endpoints/admin.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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[]>;
|
||||
},
|
||||
};
|
||||
31
frontend-react/src/api/endpoints/auth.ts
Normal file
31
frontend-react/src/api/endpoints/auth.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import client from '../client';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
email?: string;
|
||||
mfa_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token?: string;
|
||||
user?: User;
|
||||
mfa_required?: boolean;
|
||||
mfa_setup?: boolean;
|
||||
totp_uri?: string;
|
||||
}
|
||||
|
||||
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'),
|
||||
|
||||
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-react/src/api/endpoints/chat.ts
Normal file
94
frontend-react/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 }>,
|
||||
};
|
||||
97
frontend-react/src/api/endpoints/embeddings.ts
Normal file
97
frontend-react/src/api/endpoints/embeddings.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
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) =>
|
||||
client.post('/embeddings/consult', {
|
||||
query,
|
||||
table_name: tableName || '',
|
||||
top_k: topK,
|
||||
}) as unknown as Promise<ConsultResult>,
|
||||
};
|
||||
129
frontend-react/src/api/endpoints/explorer.ts
Normal file
129
frontend-react/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-react/src/api/endpoints/genai.ts
Normal file
66
frontend-react/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-react/src/api/endpoints/mcp.ts
Normal file
68
frontend-react/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-react/src/api/endpoints/oci.ts
Normal file
39
frontend-react/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>,
|
||||
};
|
||||
142
frontend-react/src/api/endpoints/reports.ts
Normal file
142
frontend-react/src/api/endpoints/reports.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import client from '../client';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export interface Report {
|
||||
id: string;
|
||||
tenancy_name: 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;
|
||||
}
|
||||
|
||||
/* ── 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)}`;
|
||||
},
|
||||
|
||||
complianceReportUrl: (rid: string) => {
|
||||
const token = localStorage.getItem('t') || '';
|
||||
return `/api/reports/${rid}/compliance-report?token=${encodeURIComponent(token)}`;
|
||||
},
|
||||
|
||||
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 }>,
|
||||
|
||||
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; 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; message: string; chunks: number }>,
|
||||
};
|
||||
136
frontend-react/src/api/endpoints/terraform.ts
Normal file
136
frontend-react/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/sessions/${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-react/src/components/layout/AppShell.tsx
Normal file
27
frontend-react/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>
|
||||
);
|
||||
}
|
||||
186
frontend-react/src/components/layout/Sidebar.tsx
Normal file
186
frontend-react/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
MessageSquare, Search, BarChart3, Cloud,
|
||||
Brain, Plug, Database, Dna, Users, Lock, FileText,
|
||||
Sparkles, Sun, Moon, LogOut, FolderOpen, Languages
|
||||
} 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 }}>
|
||||
<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 }}>
|
||||
<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_SECTIONS: { titleKey: string; items: NavItem[] }[] = [
|
||||
{
|
||||
titleKey: 'nav.principal',
|
||||
items: [
|
||||
{ to: '/chat', icon: <MessageSquare size={SZ} />, labelKey: 'nav.chat' },
|
||||
{ to: '/terraform', icon: <TerraformIcon size={SZ + 2} />, labelKey: 'nav.terraform' },
|
||||
{ to: '/terraform/prompt', icon: <Sparkles size={SZ} />, labelKey: 'nav.promptGen', sub: true },
|
||||
{ to: '/terraform/workspaces', icon: <FolderOpen size={SZ} />, labelKey: 'nav.workspaces', sub: true },
|
||||
{ to: '/explorer', icon: <Search size={SZ} />, labelKey: 'nav.explorer' },
|
||||
{ to: '/reports', icon: <BarChart3 size={SZ} />, labelKey: 'nav.reports' },
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: 'nav.config',
|
||||
items: [
|
||||
{ to: '/config/oci', icon: <Cloud size={SZ} />, labelKey: 'nav.ociCreds' },
|
||||
{ to: '/config/genai', icon: <Brain size={SZ} />, labelKey: 'nav.genai' },
|
||||
{ to: '/config/mcp', icon: <Plug size={SZ} />, labelKey: 'nav.mcp' },
|
||||
{ to: '/config/adb', icon: <Database size={SZ} />, labelKey: 'nav.adb' },
|
||||
{ to: '/config/embeddings', icon: <Dna size={SZ} />, labelKey: 'nav.embeddings' },
|
||||
{ to: '/config/embeddings/consult', icon: <MessageSquare size={SZ} />, labelKey: 'nav.embConsult', sub: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: 'nav.admin',
|
||||
items: [
|
||||
{ to: '/admin/users', icon: <Users size={SZ} />, labelKey: 'nav.users', adminOnly: true },
|
||||
{ to: '/admin/mfa', icon: <Lock size={SZ} />, labelKey: 'nav.mfa' },
|
||||
{ to: '/admin/audit', icon: <FileText size={SZ} />, labelKey: 'nav.audit', adminOnly: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const { toggle, isDark } = useTheme();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { t, lang, setLang } = useI18n();
|
||||
|
||||
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">
|
||||
{NAV_SECTIONS.map((section) => (
|
||||
<div key={section.titleKey} className="mb-1">
|
||||
<div className="text-[.6rem] uppercase tracking-[.14em] font-bold px-3 pt-4 pb-2"
|
||||
style={{ color: 'var(--t4)' }}>
|
||||
{t(section.titleKey)}
|
||||
</div>
|
||||
{section.items
|
||||
.filter((item) => !item.adminOnly || user?.role === 'admin')
|
||||
.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/terraform' || item.to === '/config/embeddings'}
|
||||
className={`flex items-center gap-3 rounded-xl font-medium transition-all duration-200 ${
|
||||
item.sub
|
||||
? 'pl-9 pr-3 py-2 text-[.78rem] opacity-90'
|
||||
: 'px-3 py-2.5 text-[.82rem]'
|
||||
}`}
|
||||
style={({ isActive }) => isActive ? {
|
||||
background: 'var(--acl2)',
|
||||
color: 'var(--ac)',
|
||||
boxShadow: 'inset 3px 0 0 var(--ac)',
|
||||
} : {
|
||||
color: 'var(--t2)',
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
{t(item.labelKey)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</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')}
|
||||
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>
|
||||
<button
|
||||
onClick={() => setLang(lang === 'pt' ? 'en' : 'pt')}
|
||||
className="p-2.5 rounded-xl transition-all duration-200 flex items-center gap-1"
|
||||
style={{ color: 'var(--t3)' }}
|
||||
title={lang === 'pt' ? 'English' : 'Português'}
|
||||
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)'; }}
|
||||
>
|
||||
<Languages size={18} />
|
||||
<span className="text-[.65rem] font-bold uppercase">{lang}</span>
|
||||
</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={logout}
|
||||
className="p-2.5 rounded-xl transition-all duration-200"
|
||||
style={{ color: 'var(--t3)' }}
|
||||
title={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>
|
||||
);
|
||||
}
|
||||
68
frontend-react/src/components/shared/StubPage.tsx
Normal file
68
frontend-react/src/components/shared/StubPage.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { ArrowRight, Wrench } from 'lucide-react';
|
||||
|
||||
interface StubPageProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
legacyTab: string;
|
||||
color?: string;
|
||||
features?: string[];
|
||||
}
|
||||
|
||||
export default function StubPage({ icon, title, description, legacyTab, color = 'var(--ac)', features }: StubPageProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full p-8">
|
||||
<div className="text-center max-w-md w-full animate-[fadeIn_.4s_ease]">
|
||||
{/* Icon with glow */}
|
||||
<div className="relative inline-flex items-center justify-center mb-6">
|
||||
<div className="absolute inset-0 rounded-full blur-2xl opacity-20"
|
||||
style={{ background: color, transform: 'scale(2)' }} />
|
||||
<div className="relative w-20 h-20 rounded-2xl flex items-center justify-center"
|
||||
style={{ background: `color-mix(in srgb, ${color} 10%, transparent)`, border: `1px solid color-mix(in srgb, ${color} 20%, transparent)` }}>
|
||||
<div style={{ color }}>{icon}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-xl font-bold mb-2" style={{ color: 'var(--t1)' }}>{title}</h2>
|
||||
<p className="text-sm mb-6" style={{ color: 'var(--t3)' }}>{description}</p>
|
||||
|
||||
{/* Features preview */}
|
||||
{features && (
|
||||
<div className="mb-6 text-left rounded-xl p-4 mx-auto max-w-xs"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Wrench size={14} style={{ color: 'var(--t4)' }} />
|
||||
<span className="text-[.7rem] font-semibold uppercase tracking-wider" style={{ color: 'var(--t4)' }}>
|
||||
Em desenvolvimento
|
||||
</span>
|
||||
</div>
|
||||
{features.map((f, i) => (
|
||||
<div key={i} className="flex items-center gap-2 py-1.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: color, opacity: 0.6 }} />
|
||||
<span className="text-xs" style={{ color: 'var(--t2)' }}>{f}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<a
|
||||
href={`/?tab=${legacyTab}`}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold transition-all duration-200"
|
||||
style={{ background: color, color: '#fff' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = 'var(--sh2)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = ''; }}
|
||||
>
|
||||
Abrir versao atual
|
||||
<ArrowRight size={16} />
|
||||
</a>
|
||||
|
||||
<p className="text-[.65rem] mt-4" style={{ color: 'var(--t4)' }}>
|
||||
Migrando para React — em breve nesta interface
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
frontend-react/src/hooks/usePolling.ts
Normal file
25
frontend-react/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-react/src/hooks/useTheme.ts
Normal file
20
frontend-react/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' };
|
||||
}
|
||||
666
frontend-react/src/i18n/en.ts
Normal file
666
frontend-react/src/i18n/en.ts
Normal file
@@ -0,0 +1,666 @@
|
||||
const en: Record<string, string> = {
|
||||
// ── Sidebar ──
|
||||
'nav.principal': 'Main',
|
||||
'nav.config': 'Configuration',
|
||||
'nav.admin': 'Administration',
|
||||
'nav.chat': 'Chat Agent',
|
||||
'nav.terraform': 'Terraform',
|
||||
'nav.promptGen': 'Prompt Generator',
|
||||
'nav.workspaces': 'Workspaces',
|
||||
'nav.explorer': 'OCI Explorer',
|
||||
'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.users': 'Users',
|
||||
'nav.mfa': 'MFA',
|
||||
'nav.audit': 'Audit Log',
|
||||
'sidebar.lightMode': 'Light mode',
|
||||
'sidebar.darkMode': 'Dark mode',
|
||||
'sidebar.logout': 'Logout',
|
||||
'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',
|
||||
|
||||
// ── 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',
|
||||
|
||||
// ── 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:',
|
||||
|
||||
// ── Downloads ──
|
||||
'dl.title': 'Downloads',
|
||||
'dl.subtitle': 'Download generated CIS reports',
|
||||
'dl.allTenancies': 'All Tenancies',
|
||||
'dl.allSections': 'All sections',
|
||||
'dl.reports': 'report(s)',
|
||||
'dl.refresh': 'Refresh',
|
||||
'dl.loadError': 'Error loading reports',
|
||||
'dl.loading': 'Loading reports...',
|
||||
'dl.noReports': 'No completed reports.',
|
||||
'dl.noReportsHint': 'Run a scan in the Reports tab to generate reports.',
|
||||
'dl.files': 'file(s)',
|
||||
'dl.hide': 'Hide',
|
||||
'dl.viewFiles': 'View Files',
|
||||
'dl.loadingFiles': 'Loading files...',
|
||||
'dl.noFiles': 'No files found',
|
||||
'dl.filesInSections': 'file(s) in {0} sections',
|
||||
|
||||
// ── 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',
|
||||
|
||||
// ── 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',
|
||||
|
||||
// ── 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',
|
||||
};
|
||||
|
||||
export default en;
|
||||
31
frontend-react/src/i18n/index.ts
Normal file
31
frontend-react/src/i18n/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { create } from 'zustand';
|
||||
import pt from './pt';
|
||||
import en from './en';
|
||||
|
||||
type Lang = 'pt' | 'en';
|
||||
|
||||
const LANGS: Record<Lang, Record<string, string>> = { pt, en };
|
||||
|
||||
function detectLang(): Lang {
|
||||
const saved = localStorage.getItem('lang') as Lang | null;
|
||||
if (saved && LANGS[saved]) return saved;
|
||||
return navigator.language.startsWith('pt') ? 'pt' : '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;
|
||||
},
|
||||
}));
|
||||
666
frontend-react/src/i18n/pt.ts
Normal file
666
frontend-react/src/i18n/pt.ts
Normal file
@@ -0,0 +1,666 @@
|
||||
const pt: Record<string, string> = {
|
||||
// ── Sidebar ──
|
||||
'nav.principal': 'Principal',
|
||||
'nav.config': 'Configuração',
|
||||
'nav.admin': 'Administração',
|
||||
'nav.chat': 'Chat Agent',
|
||||
'nav.terraform': 'Terraform',
|
||||
'nav.promptGen': 'Prompt Generator',
|
||||
'nav.workspaces': 'Workspaces',
|
||||
'nav.explorer': 'OCI Explorer',
|
||||
'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.users': 'Usuários',
|
||||
'nav.mfa': 'MFA',
|
||||
'nav.audit': 'Audit Log',
|
||||
'sidebar.lightMode': 'Modo claro',
|
||||
'sidebar.darkMode': 'Modo escuro',
|
||||
'sidebar.logout': 'Sair',
|
||||
'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',
|
||||
|
||||
// ── 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',
|
||||
|
||||
// ── 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:',
|
||||
|
||||
// ── Downloads ──
|
||||
'dl.title': 'Downloads',
|
||||
'dl.subtitle': 'Baixe relatorios CIS gerados',
|
||||
'dl.allTenancies': 'Todas as Tenancies',
|
||||
'dl.allSections': 'Todas as secoes',
|
||||
'dl.reports': 'relatorio(s)',
|
||||
'dl.refresh': 'Atualizar',
|
||||
'dl.loadError': 'Erro ao carregar relatorios',
|
||||
'dl.loading': 'Carregando relatorios...',
|
||||
'dl.noReports': 'Nenhum relatorio concluido.',
|
||||
'dl.noReportsHint': 'Execute um scan na aba Reports para gerar relatorios.',
|
||||
'dl.files': 'arquivo(s)',
|
||||
'dl.hide': 'Ocultar',
|
||||
'dl.viewFiles': 'Ver Arquivos',
|
||||
'dl.loadingFiles': 'Carregando arquivos...',
|
||||
'dl.noFiles': 'Nenhum arquivo encontrado',
|
||||
'dl.filesInSections': 'arquivo(s) em {0} secoes',
|
||||
|
||||
// ── 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',
|
||||
|
||||
// ── 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',
|
||||
|
||||
// ── 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',
|
||||
};
|
||||
|
||||
export default pt;
|
||||
437
frontend-react/src/index.css
Normal file
437
frontend-react/src/index.css
Normal file
@@ -0,0 +1,437 @@
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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);
|
||||
}
|
||||
|
||||
/* Prevent text overflow on resize */
|
||||
.prose pre, .chat-markdown pre {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
.prose table, .chat-markdown table {
|
||||
overflow-x: auto;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
.prose code, .chat-markdown code {
|
||||
word-break: break-all;
|
||||
}
|
||||
.prose p, .chat-markdown 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-react/src/main.tsx
Normal file
13
frontend-react/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 basename="/app">
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
1138
frontend-react/src/pages/ChatPage.tsx
Normal file
1138
frontend-react/src/pages/ChatPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
453
frontend-react/src/pages/DownloadsPage.tsx
Normal file
453
frontend-react/src/pages/DownloadsPage.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
FolderDown, ChevronRight, RefreshCw, Download, FileText, Lock, Globe,
|
||||
Server, Activity, Database, Package, BarChart3, AlertCircle, Loader2, Filter,
|
||||
} from 'lucide-react';
|
||||
import client from '@/api/client';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
interface Report {
|
||||
id: string;
|
||||
tenancy_name: string;
|
||||
status: string;
|
||||
level: number | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
interface ReportFile {
|
||||
id: string;
|
||||
file_name: string;
|
||||
file_type: string;
|
||||
file_category: string;
|
||||
file_size: number;
|
||||
}
|
||||
|
||||
/* ── Helpers ── */
|
||||
|
||||
function extractSection(name: string): string {
|
||||
const m = name.match(/^(?:cis|obp)_([A-Za-z_]+?)_\d/);
|
||||
if (m) return m[1].replace(/_/g, ' ');
|
||||
if (name.includes('summary')) return 'Summary';
|
||||
if (name.includes('error')) return 'Error';
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function fileExt(name: string): string {
|
||||
return (name.split('.').pop() || '').toLowerCase();
|
||||
}
|
||||
|
||||
const extColors: Record<string, string> = {
|
||||
html: '#e44d26',
|
||||
csv: '#217346',
|
||||
json: '#f5a623',
|
||||
xlsx: '#217346',
|
||||
txt: '#6b7280',
|
||||
pdf: '#dc2626',
|
||||
};
|
||||
|
||||
const sectionIcons: Record<string, typeof Lock> = {
|
||||
'Identity and Access Management': Lock,
|
||||
'Networking': Globe,
|
||||
'Compute': Server,
|
||||
'Logging and Monitoring': Activity,
|
||||
'Storage Object Storage': Database,
|
||||
'Storage Block Volumes': Database,
|
||||
'Storage File Storage Service': Database,
|
||||
'Asset Management': Package,
|
||||
'Summary': BarChart3,
|
||||
'Error': AlertCircle,
|
||||
'Other': FileText,
|
||||
};
|
||||
|
||||
function sectionSort(a: string, b: string): number {
|
||||
if (a === 'Summary') return -1;
|
||||
if (b === 'Summary') return 1;
|
||||
if (a === 'Other') return 1;
|
||||
if (b === 'Other') return -1;
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
function groupBySection(files: ReportFile[]): Record<string, ReportFile[]> {
|
||||
const map: Record<string, ReportFile[]> = {};
|
||||
for (const f of files) {
|
||||
const sec = extractSection(f.file_name);
|
||||
(map[sec] ||= []).push(f);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/* ── Component ── */
|
||||
|
||||
export default function DownloadsPage() {
|
||||
const { t } = useI18n();
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [expandedRid, setExpandedRid] = useState<string | null>(null);
|
||||
const [filesCache, setFilesCache] = useState<Record<string, ReportFile[]>>({});
|
||||
const [loadingFiles, setLoadingFiles] = useState<string | null>(null);
|
||||
const [tenancyFilter, setTenancyFilter] = useState('');
|
||||
const [sectionFilter, setSectionFilter] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const token = localStorage.getItem('t') || '';
|
||||
|
||||
const loadReports = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await client.get('/reports') as unknown as Report[];
|
||||
setReports(data.filter((r) => r.status === 'completed'));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('dl.loadError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadReports(); }, [loadReports]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setFilesCache({});
|
||||
setExpandedRid(null);
|
||||
setSectionFilter('');
|
||||
loadReports();
|
||||
}, [loadReports]);
|
||||
|
||||
const toggleExpand = useCallback(async (rid: string) => {
|
||||
if (expandedRid === rid) {
|
||||
setExpandedRid(null);
|
||||
return;
|
||||
}
|
||||
setExpandedRid(rid);
|
||||
if (!filesCache[rid]) {
|
||||
setLoadingFiles(rid);
|
||||
try {
|
||||
const files = await client.get(`/reports/${rid}/files`) as unknown as ReportFile[];
|
||||
setFilesCache((prev) => ({ ...prev, [rid]: files }));
|
||||
} catch {
|
||||
setFilesCache((prev) => ({ ...prev, [rid]: [] }));
|
||||
} finally {
|
||||
setLoadingFiles(null);
|
||||
}
|
||||
}
|
||||
}, [expandedRid, filesCache]);
|
||||
|
||||
const handleDownload = useCallback((rid: string, fid: string) => {
|
||||
window.open(`/api/reports/${rid}/files/${fid}/download?token=${token}`, '_blank');
|
||||
}, [token]);
|
||||
|
||||
/* Derived data */
|
||||
const tenancies = [...new Set(reports.map((r) => r.tenancy_name))].sort();
|
||||
const filtered = tenancyFilter ? reports.filter((r) => r.tenancy_name === tenancyFilter) : reports;
|
||||
|
||||
/* Sections available in expanded report (for filter) */
|
||||
const expandedFiles = expandedRid ? filesCache[expandedRid] : null;
|
||||
const expandedSections = expandedFiles
|
||||
? [...new Set(expandedFiles.map((f) => extractSection(f.file_name)))].sort(sectionSort)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--bg3)' }}>
|
||||
<FolderDown size={24} style={{ color: 'var(--t3)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('dl.title')}</h1>
|
||||
<div className="subtitle">{t('dl.subtitle')}</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<span className="count">{filtered.length} {t('dl.reports')}</span>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="card" style={{ padding: '12px 20px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Tenancy filter */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Filter size={13} style={{ color: 'var(--t4)' }} />
|
||||
<select
|
||||
value={tenancyFilter}
|
||||
onChange={(e) => { setTenancyFilter(e.target.value); setExpandedRid(null); setSectionFilter(''); }}
|
||||
className="text-xs py-1.5 px-2.5 rounded-lg outline-none cursor-pointer"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 180 }}
|
||||
>
|
||||
<option value="">{t('dl.allTenancies')} ({reports.length})</option>
|
||||
{tenancies.map((tn) => (
|
||||
<option key={tn} value={tn}>
|
||||
{tn} ({reports.filter((r) => r.tenancy_name === tn).length})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Section filter (only when expanded) */}
|
||||
{expandedRid && expandedSections.length > 1 && (
|
||||
<select
|
||||
value={sectionFilter}
|
||||
onChange={(e) => setSectionFilter(e.target.value)}
|
||||
className="text-xs py-1.5 px-2.5 rounded-lg outline-none cursor-pointer"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 160 }}
|
||||
>
|
||||
<option value="">{t('dl.allSections')}</option>
|
||||
{expandedSections.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<span className="text-[.7rem]" style={{ color: 'var(--t4)' }}>
|
||||
{filtered.length} {t('dl.reports')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
||||
{t('dl.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg mb-4 text-xs font-medium"
|
||||
style={{ background: 'var(--rdl)', color: 'var(--rd)' }}
|
||||
>
|
||||
<AlertCircle size={14} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && !reports.length && (
|
||||
<div className="flex items-center justify-center gap-2 py-16" style={{ color: 'var(--t4)' }}>
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
<span className="text-sm">{t('dl.loading')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && !filtered.length && (
|
||||
<div className="card">
|
||||
<div className="empty-state">
|
||||
<FolderDown size={40} />
|
||||
<h3>{t('dl.noReports')}</h3>
|
||||
<p>{t('dl.noReportsHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Report list */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{filtered.map((report) => {
|
||||
const isExpanded = expandedRid === report.id;
|
||||
const files = filesCache[report.id];
|
||||
const isLoading = loadingFiles === report.id;
|
||||
const fileCount = files ? files.length : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={report.id}
|
||||
className="card"
|
||||
style={{
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
borderColor: isExpanded ? 'color-mix(in srgb, var(--ac) 30%, var(--bd))' : undefined,
|
||||
boxShadow: isExpanded ? 'var(--sh2)' : undefined,
|
||||
}}
|
||||
>
|
||||
{/* Report header */}
|
||||
<button
|
||||
onClick={() => toggleExpand(report.id)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-left transition-colors hover:bg-[var(--bg2)]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="transition-transform duration-200 flex-shrink-0"
|
||||
style={{
|
||||
color: 'var(--ac)',
|
||||
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-[.82rem] font-semibold" style={{ color: 'var(--t1)' }}>
|
||||
{report.tenancy_name}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>
|
||||
Level {report.level || 2}
|
||||
</span>
|
||||
<span className="text-[.5rem]" style={{ color: 'var(--t4)' }}>|</span>
|
||||
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>
|
||||
{report.created_at}
|
||||
</span>
|
||||
{fileCount != null && (
|
||||
<>
|
||||
<span className="text-[.5rem]" style={{ color: 'var(--t4)' }}>|</span>
|
||||
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>
|
||||
{fileCount} {t('dl.files')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[.68rem] flex-shrink-0" style={{ color: 'var(--t4)' }}>
|
||||
{isExpanded ? t('dl.hide') : t('dl.viewFiles')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded file list */}
|
||||
{isExpanded && (
|
||||
<div
|
||||
className="px-4 py-4"
|
||||
style={{ borderTop: '1px solid var(--bd)', background: 'var(--bg2)' }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6" style={{ color: 'var(--t4)' }}>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span className="text-xs">{t('dl.loadingFiles')}</span>
|
||||
</div>
|
||||
) : !files || !files.length ? (
|
||||
<p className="text-xs text-center py-6" style={{ color: 'var(--t4)' }}>
|
||||
{t('dl.noFiles')}
|
||||
</p>
|
||||
) : (
|
||||
<FileListGrouped
|
||||
files={files}
|
||||
reportId={report.id}
|
||||
sectionFilter={sectionFilter}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── FileListGrouped sub-component ── */
|
||||
|
||||
function FileListGrouped({
|
||||
files,
|
||||
reportId,
|
||||
sectionFilter,
|
||||
onDownload,
|
||||
}: {
|
||||
files: ReportFile[];
|
||||
reportId: string;
|
||||
sectionFilter: string;
|
||||
onDownload: (rid: string, fid: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const grouped = groupBySection(files);
|
||||
let sections = Object.keys(grouped).sort(sectionSort);
|
||||
if (sectionFilter) sections = sections.filter((s) => s === sectionFilter);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[.72rem]" style={{ color: 'var(--t3)' }}>
|
||||
{files.length} {t('dl.filesInSections').replace('{0}', String(Object.keys(grouped).length))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sections.map((sec) => {
|
||||
const secFiles = grouped[sec];
|
||||
const Icon = sectionIcons[sec] || FileText;
|
||||
return (
|
||||
<div key={sec} className="mb-3">
|
||||
{/* Section header */}
|
||||
<div
|
||||
className="flex items-center gap-2 mb-2 pb-1.5"
|
||||
style={{ borderBottom: '1px solid var(--bd)' }}
|
||||
>
|
||||
<Icon size={13} style={{ color: 'var(--t3)', opacity: 0.7 }} />
|
||||
<span className="text-[.72rem] font-bold" style={{ color: 'var(--t1)' }}>{sec}</span>
|
||||
<span className="text-[.6rem]" style={{ color: 'var(--t4)' }}>({secFiles.length})</span>
|
||||
</div>
|
||||
|
||||
{/* File grid */}
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(230px, 1fr))' }}>
|
||||
{secFiles.map((f) => {
|
||||
const ext = fileExt(f.file_name);
|
||||
const color = extColors[ext] || 'var(--ac)';
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => onDownload(reportId, f.id)}
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-left transition-all group"
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--bd)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = color;
|
||||
e.currentTarget.style.boxShadow = `0 2px 8px ${color}18`;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--bd)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
{/* File type badge */}
|
||||
<div
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: `${color}14` }}
|
||||
>
|
||||
<span
|
||||
className="text-[.58rem] font-bold uppercase tracking-wide"
|
||||
style={{ color }}
|
||||
>
|
||||
{ext}
|
||||
</span>
|
||||
</div>
|
||||
{/* File info */}
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div
|
||||
className="text-[.7rem] font-medium truncate"
|
||||
style={{ color: 'var(--t1)' }}
|
||||
title={f.file_name}
|
||||
>
|
||||
{f.file_name}
|
||||
</div>
|
||||
<div className="text-[.58rem] mt-0.5" style={{ color: 'var(--t4)' }}>
|
||||
{formatSize(f.file_size)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Download icon */}
|
||||
<Download
|
||||
size={12}
|
||||
className="flex-shrink-0 opacity-30 group-hover:opacity-70 transition-opacity"
|
||||
style={{ color: 'var(--t3)' }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1593
frontend-react/src/pages/ExplorerPage.tsx
Normal file
1593
frontend-react/src/pages/ExplorerPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
128
frontend-react/src/pages/LoginPage.tsx
Normal file
128
frontend-react/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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);
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
602
frontend-react/src/pages/PromptGeneratorPage.tsx
Normal file
602
frontend-react/src/pages/PromptGeneratorPage.tsx
Normal file
@@ -0,0 +1,602 @@
|
||||
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 { 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 { models, genaiCfg, ociCfg } = useAppStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
/* state */
|
||||
const [msgs, setMsgs] = useState<Msg[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
const [sid, setSid] = useState<string | null>(null);
|
||||
|
||||
/* model selection */
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [ddOpen, setDdOpen] = useState(false);
|
||||
const [ddSearch, setDdSearch] = useState('');
|
||||
|
||||
/* OCI config (for direct models) */
|
||||
const [ociId, setOciId] = useState('');
|
||||
const [region, setRegion] = useState('');
|
||||
const [compartment, setCompartment] = useState('');
|
||||
|
||||
/* history sidebar */
|
||||
const [histOpen, setHistOpen] = useState(false);
|
||||
const [history, setHistory] = useState<ChatSession[]>([]);
|
||||
|
||||
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: 'Erro: ' + (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]}
|
||||
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>
|
||||
);
|
||||
}
|
||||
1629
frontend-react/src/pages/ReportsPage.tsx
Normal file
1629
frontend-react/src/pages/ReportsPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1566
frontend-react/src/pages/TerraformPage.tsx
Normal file
1566
frontend-react/src/pages/TerraformPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
462
frontend-react/src/pages/WorkspacesPage.tsx
Normal file
462
frontend-react/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('Erro: ' + (e as Error).message); return; }
|
||||
load(); pollWs(wid);
|
||||
};
|
||||
const doApply = async (wid: string) => {
|
||||
try { await terraformApi.apply(wid); } catch (e: unknown) { alert('Erro: ' + (e as Error).message); return; }
|
||||
load(); pollWs(wid);
|
||||
};
|
||||
const doDestroy = async () => {
|
||||
if (!confirmDestroy || destroyInput !== 'DESTROY') return;
|
||||
const wid = confirmDestroy;
|
||||
setConfirmDestroy(null); setDestroyInput('');
|
||||
try { await terraformApi.destroy(wid); } catch (e: unknown) { alert('Erro: ' + (e as Error).message); load(); return; }
|
||||
load(); pollWs(wid);
|
||||
};
|
||||
const doDelete = async () => {
|
||||
if (!confirmDelete) return;
|
||||
const wid = confirmDelete;
|
||||
setConfirmDelete(null);
|
||||
try { await terraformApi.deleteWorkspace(wid); } catch (e: unknown) { alert('Erro: ' + (e as Error).message); }
|
||||
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')}
|
||||
{' '}
|
||||
Digite <strong>DESTROY</strong> para confirmar:
|
||||
</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-react/src/pages/admin/AuditPage.tsx
Normal file
536
frontend-react/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>
|
||||
);
|
||||
}
|
||||
281
frontend-react/src/pages/admin/MfaPage.tsx
Normal file
281
frontend-react/src/pages/admin/MfaPage.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Lock, ShieldCheck, ShieldOff, KeyRound, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import client from '@/api/client';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
interface MfaSetupResponse {
|
||||
secret: string;
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export default function MfaPage() {
|
||||
const { user, checkAuth } = useAuthStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
const [totpUri, setTotpUri] = useState<string | null>(null);
|
||||
const [code, setCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [msg, setMsg] = useState<{ text: string; type: 's' | 'e' | 'i' } | null>(null);
|
||||
|
||||
const mfaEnabled = !!user?.mfa_enabled;
|
||||
|
||||
const handleSetup = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const data = await client.post('/mfa/setup') as unknown as MfaSetupResponse;
|
||||
setSecret(data.secret);
|
||||
setTotpUri(data.uri);
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Erro ao gerar secret', type: 'e' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleVerify = useCallback(async () => {
|
||||
if (!code || code.length !== 6) {
|
||||
setMsg({ text: t('mfa.invalidCode'), type: 'e' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await client.post('/mfa/verify', { totp_code: code });
|
||||
setMsg({ text: t('mfa.activated'), type: 's' });
|
||||
setSecret(null);
|
||||
setTotpUri(null);
|
||||
setCode('');
|
||||
await checkAuth();
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Codigo invalido', type: 'e' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [code, checkAuth, t]);
|
||||
|
||||
const handleDisable = useCallback(async () => {
|
||||
if (!user) return;
|
||||
if (!window.confirm(t('mfa.confirmDisable'))) return;
|
||||
setLoading(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await client.post(`/mfa/disable/${user.id}`);
|
||||
setMsg({ text: t('mfa.deactivated'), type: 'i' });
|
||||
await checkAuth();
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Erro ao desativar MFA', type: 'e' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user, checkAuth, t]);
|
||||
|
||||
const alertStyles: Record<string, { bg: string; color: string; icon: typeof CheckCircle2 }> = {
|
||||
s: { bg: 'var(--gnl)', color: 'var(--gn)', icon: CheckCircle2 },
|
||||
e: { bg: 'var(--rdl)', color: 'var(--rd)', icon: AlertCircle },
|
||||
i: { bg: 'color-mix(in srgb, var(--bl) 12%, transparent)', color: 'var(--bl)', icon: AlertCircle },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page" style={{ maxWidth: 580 }}>
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="card-header icon" style={{ background: 'color-mix(in srgb, var(--ac) 10%, transparent)' }}>
|
||||
<Lock size={20} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('mfa.title')}</h1>
|
||||
<div className="subtitle">
|
||||
{t('mfa.subtitle')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
{/* Status banner */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-5 py-4"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--bd)',
|
||||
background: mfaEnabled
|
||||
? 'color-mix(in srgb, var(--gn) 6%, transparent)'
|
||||
: 'color-mix(in srgb, var(--yl) 6%, transparent)',
|
||||
}}
|
||||
>
|
||||
{mfaEnabled ? (
|
||||
<ShieldCheck size={22} style={{ color: 'var(--gn)' }} />
|
||||
) : (
|
||||
<ShieldOff size={22} style={{ color: 'var(--yl)' }} />
|
||||
)}
|
||||
<div>
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--t1)' }}>
|
||||
{mfaEnabled ? t('mfa.enabled') : t('mfa.disabled')}
|
||||
</span>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--t3)' }}>
|
||||
{mfaEnabled
|
||||
? t('mfa.enabledDesc')
|
||||
: t('mfa.disabledDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5">
|
||||
{/* Message */}
|
||||
{msg && (() => {
|
||||
const st = alertStyles[msg.type];
|
||||
const Icon = st.icon;
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg mb-4 text-xs font-medium"
|
||||
style={{ background: st.bg, color: st.color }}
|
||||
>
|
||||
<Icon size={15} />
|
||||
{msg.text}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{mfaEnabled ? (
|
||||
/* Enabled state: show disable button */
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
<div
|
||||
className="w-16 h-16 rounded-full flex items-center justify-center"
|
||||
style={{ background: 'color-mix(in srgb, var(--gn) 10%, transparent)' }}
|
||||
>
|
||||
<ShieldCheck size={32} style={{ color: 'var(--gn)' }} />
|
||||
</div>
|
||||
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>
|
||||
{t('mfa.disableHint')}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleDisable}
|
||||
disabled={loading}
|
||||
className="btn btn-danger btn-sm"
|
||||
>
|
||||
{loading ? <Loader2 size={14} className="animate-spin" /> : <ShieldOff size={14} />}
|
||||
{t('mfa.disableBtn')}
|
||||
</button>
|
||||
</div>
|
||||
) : secret && totpUri ? (
|
||||
/* Setup step 2: show secret + QR + verify */
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Instructions */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 text-[.65rem] font-bold"
|
||||
style={{ background: 'var(--ac)', color: '#fff' }}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold" style={{ color: 'var(--t1)' }}>
|
||||
{t('mfa.scanQr')}
|
||||
</p>
|
||||
<p className="text-[.68rem] mt-0.5" style={{ color: 'var(--t4)' }}>
|
||||
{t('mfa.openApp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR code */}
|
||||
<div className="flex justify-center">
|
||||
<div className="p-3 rounded-xl" style={{ background: '#fff' }}>
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(totpUri)}`}
|
||||
alt="QR Code TOTP"
|
||||
width={180}
|
||||
height={180}
|
||||
className="rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secret */}
|
||||
<div
|
||||
className="px-3.5 py-2.5 rounded-lg text-center"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}
|
||||
>
|
||||
<span className="text-[.62rem] block mb-1" style={{ color: 'var(--t4)' }}>SECRET</span>
|
||||
<code
|
||||
className="text-xs font-bold tracking-wider select-all cursor-pointer"
|
||||
style={{ color: 'var(--ac)', fontFamily: 'var(--fm)' }}
|
||||
title="Clique para selecionar"
|
||||
>
|
||||
{secret}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Verify */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 text-[.65rem] font-bold"
|
||||
style={{ background: 'var(--ac)', color: '#fff' }}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--t1)' }}>
|
||||
{t('mfa.enterCode')}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
className="flex-1 px-3 py-2 rounded-lg text-sm text-center tracking-[.3em] font-semibold outline-none transition-colors"
|
||||
style={{
|
||||
background: 'var(--bg2)',
|
||||
border: '1px solid var(--bd)',
|
||||
color: 'var(--t1)',
|
||||
fontFamily: 'var(--fm)',
|
||||
}}
|
||||
onFocus={(e) => { e.target.style.borderColor = 'var(--ac)'; }}
|
||||
onBlur={(e) => { e.target.style.borderColor = 'var(--bd)'; }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleVerify(); }}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleVerify}
|
||||
disabled={loading || code.length !== 6}
|
||||
className="btn btn-success btn-sm"
|
||||
>
|
||||
{loading ? <Loader2 size={14} className="animate-spin" /> : <KeyRound size={14} />}
|
||||
{t('mfa.activateMfa')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Setup step 1: generate secret */
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
<div
|
||||
className="w-16 h-16 rounded-full flex items-center justify-center"
|
||||
style={{ background: 'color-mix(in srgb, var(--yl) 10%, transparent)' }}
|
||||
>
|
||||
<KeyRound size={32} style={{ color: 'var(--yl)' }} />
|
||||
</div>
|
||||
<p className="text-xs text-center max-w-xs" style={{ color: 'var(--t3)' }}>
|
||||
{t('mfa.generateHint')}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSetup}
|
||||
disabled={loading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : <KeyRound size={16} />}
|
||||
{t('mfa.generateSecret')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
463
frontend-react/src/pages/admin/UsersPage.tsx
Normal file
463
frontend-react/src/pages/admin/UsersPage.tsx
Normal file
@@ -0,0 +1,463 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Users, Plus, Shield, Eye, User as UserIcon, Trash2, X, AlertTriangle, CheckCircle, Loader2, UserPlus } from 'lucide-react';
|
||||
import client from '@/api/client';
|
||||
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;
|
||||
}
|
||||
|
||||
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 [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);
|
||||
|
||||
// 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 : 'Erro ao carregar usuarios', 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 : 'Erro ao criar usuario', 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 : 'Erro ao atualizar role', 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 : 'Erro ao desativar usuario', 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;
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
</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 ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.65rem] font-semibold"
|
||||
style={{ background: 'var(--gnl)', color: 'var(--gn)' }}
|
||||
>
|
||||
<CheckCircle size={11} /> {t('usr.active')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs" style={{ color: 'var(--t4)' }}>---</span>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
644
frontend-react/src/pages/config/AdbConfigPage.tsx
Normal file
644
frontend-react/src/pages/config/AdbConfigPage.tsx
Normal file
@@ -0,0 +1,644 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
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 { 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: `Wallet analisado! ${result.dsn_names.length} DSN(s): <strong>${result.dsn_names.join(', ')}</strong>. Arquivos: ${result.files.join(', ')}` });
|
||||
} catch (err) {
|
||||
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao analisar wallet' });
|
||||
} 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: `Conexao ${editing ? 'atualizada' : 'salva'}!${walletFile ? ' Wallet incluido.' : ''}` });
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTimeout(resetForm, 1200);
|
||||
} catch (err) {
|
||||
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao salvar' });
|
||||
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: 'Conexao excluida com sucesso!' });
|
||||
if (editing?.id === id) resetForm();
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao excluir' });
|
||||
}
|
||||
};
|
||||
|
||||
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: 'Enviando wallet...' });
|
||||
try {
|
||||
const res = await adbApi.uploadWallet(walletUploadId, file);
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setWalletMsg({ type: 's', text: `Wallet enviada! Arquivos: ${res.files.join(', ')}` });
|
||||
if (walletUploadRef.current) walletUploadRef.current.value = '';
|
||||
} catch (err) {
|
||||
setWalletMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao enviar wallet' });
|
||||
} finally {
|
||||
setUploadingWallet(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTable = async (vid: string) => {
|
||||
if (!newTableName.trim()) { setTableMsg({ type: 'e', text: 'Digite o nome da tabela.' }); return; }
|
||||
try {
|
||||
await adbApi.addTable(vid, newTableName.trim(), newTableDesc.trim());
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: `Tabela ${newTableName.trim()} registrada!` });
|
||||
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('Excluir esta tabela do registro?')) return;
|
||||
try {
|
||||
await adbApi.removeTable(vid, tid);
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: 'Tabela removida.' });
|
||||
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>
|
||||
{['Nome', 'DSN', 'User', 'Tabelas', 'mTLS', 'Wallet', 'Embed', 'Acoes'].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">
|
||||
<strong className="text-xs" style={{ color: 'var(--t1)' }}>{c.config_name}</strong>
|
||||
</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>
|
||||
{['Tabela', 'Descrição', 'Ativa', 'Ações'].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">
|
||||
<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>
|
||||
{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
|
||||
? <>Editando: <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>
|
||||
);
|
||||
}
|
||||
305
frontend-react/src/pages/config/EmbConsultPage.tsx
Normal file
305
frontend-react/src/pages/config/EmbConsultPage.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
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 } = useAppStore();
|
||||
|
||||
// Config selectors
|
||||
const [selTable, setSelTable] = useState('');
|
||||
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);
|
||||
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: `Erro: ${err instanceof Error ? err.message : 'Erro desconhecido'}`,
|
||||
};
|
||||
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="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>
|
||||
);
|
||||
}
|
||||
505
frontend-react/src/pages/config/EmbeddingsPage.tsx
Normal file
505
frontend-react/src/pages/config/EmbeddingsPage.tsx
Normal file
@@ -0,0 +1,505 @@
|
||||
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);
|
||||
|
||||
// ── 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 : 'Erro ao carregar embeddings' });
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteEmb = async (docId: string) => {
|
||||
if (!confirm('Excluir este embedding?')) return;
|
||||
try {
|
||||
await embeddingsApi.remove(selVid, docId, selTable || undefined);
|
||||
loadEmbs();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Erro ao excluir');
|
||||
}
|
||||
};
|
||||
|
||||
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)' }}>Total: {total} documentos</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-react/src/pages/config/GenAiConfigPage.tsx
Normal file
446
frontend-react/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: `Regiao e Compartment preenchidos de <strong>${cfg.tenancy_name}</strong>` });
|
||||
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: `Modelo ${editing ? 'atualizado' : 'salvo'} com sucesso!` });
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTimeout(resetForm, 1200);
|
||||
} catch (err) {
|
||||
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao salvar' });
|
||||
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: 'Modelo excluido com sucesso!' });
|
||||
if (editing?.id === id) resetForm();
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao excluir' });
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
{['Config', 'Modelo', 'OCI Config', 'Regiao', 'Acoes'].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
|
||||
? <>Editando: <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>
|
||||
);
|
||||
}
|
||||
523
frontend-react/src/pages/config/McpServersPage.tsx
Normal file
523
frontend-react/src/pages/config/McpServersPage.tsx
Normal file
@@ -0,0 +1,523 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
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 { 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: `Server ${editing ? 'atualizado' : 'registrado'} com sucesso!` });
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
setTimeout(resetForm, 1200);
|
||||
} catch (err) {
|
||||
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao salvar' });
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setConfirmDelete(null);
|
||||
setTableMsg({ type: 'i', text: 'Excluindo server...' });
|
||||
try {
|
||||
await mcpApi.remove(id);
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: 'Server excluido com sucesso!' });
|
||||
if (editing?.id === id) resetForm();
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao excluir' });
|
||||
}
|
||||
};
|
||||
|
||||
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: `${res.discovered} tool(s) descoberta(s), ${res.total} total` });
|
||||
setExpandedTools((p) => ({ ...p, [id]: true }));
|
||||
setTimeout(() => setTableMsg(null), 4000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao descobrir tools' });
|
||||
} 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(`Remover tool "${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} />
|
||||
<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">
|
||||
<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
|
||||
? <>Editando: <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-react/src/pages/config/OciConfigPage.tsx
Normal file
775
frontend-react/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 : 'Erro ao salvar' });
|
||||
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 : 'Erro ao excluir' });
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
{['Tenancy', 'Tipo', 'Region', 'Detalhes', 'Acoes'].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>
|
||||
);
|
||||
}
|
||||
142
frontend-react/src/stores/app.ts
Normal file
142
frontend-react/src/stores/app.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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[];
|
||||
|
||||
// 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: [],
|
||||
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 {}
|
||||
},
|
||||
}));
|
||||
61
frontend-react/src/stores/auth.ts
Normal file
61
frontend-react/src/stores/auth.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
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;
|
||||
|
||||
login: (username: string, password: string, totp?: string) => Promise<LoginResponse>;
|
||||
logout: () => Promise<void>;
|
||||
checkAuth: () => Promise<void>;
|
||||
setToken: (token: string) => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
token: localStorage.getItem('t'),
|
||||
loading: true,
|
||||
mfaRequired: false,
|
||||
mfaSetup: false,
|
||||
totpUri: null,
|
||||
|
||||
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 });
|
||||
}
|
||||
return res;
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
try { await authApi.logout(); } catch {}
|
||||
localStorage.removeItem('t');
|
||||
set({ user: null, token: null, mfaRequired: false });
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
const token = get().token;
|
||||
if (!token) { set({ loading: false }); return; }
|
||||
try {
|
||||
const user = await authApi.me();
|
||||
set({ user, loading: false });
|
||||
} catch {
|
||||
localStorage.removeItem('t');
|
||||
set({ token: null, user: null, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setToken: (token) => {
|
||||
localStorage.setItem('t', token);
|
||||
set({ token });
|
||||
},
|
||||
}));
|
||||
28
frontend-react/tsconfig.app.json
Normal file
28
frontend-react/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-react/tsconfig.json
Normal file
7
frontend-react/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend-react/tsconfig.node.json
Normal file
26
frontend-react/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-react/vite.config.ts
Normal file
21
frontend-react/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: '/app/',
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user