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:
nogueiraguh
2026-03-19 07:34:06 -03:00
parent baacc1ba21
commit 442e710b9f
60 changed files with 23094 additions and 13 deletions

73
frontend-react/README.md Normal file
View 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...
},
},
])
```

View 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
View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};

View 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

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

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

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

View 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[]>;
},
};

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

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

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

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

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

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

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

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

View 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[]>,
};

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,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>
);
}

View File

@@ -0,0 +1,536 @@
import { useState, useEffect, useCallback } from 'react';
import {
FileText,
ScrollText,
MessageSquare,
RefreshCw,
AlertCircle,
Loader2,
} from 'lucide-react';
import {
adminApi,
type AuditEntry,
type ConfigLogEntry,
type ChatLogEntry,
} from '@/api/endpoints/admin';
import { usePolling } from '@/hooks/usePolling';
import { useI18n } from '@/i18n';
/* ── Helpers ── */
function formatDate(iso: string) {
if (!iso) return '—';
try {
const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z');
return d.toLocaleString('pt-BR', {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
} catch {
return iso;
}
}
function SeverityBadge({ severity }: { severity: string }) {
const map: Record<string, { bg: string; fg: string }> = {
error: { bg: 'var(--rdl)', fg: 'var(--rd)' },
success: { bg: 'var(--gnl)', fg: 'var(--gn)' },
info: { bg: 'var(--bll)', fg: 'var(--bl)' },
warning: { bg: 'var(--yll)', fg: 'var(--yl)' },
};
const s = map[severity] ?? { bg: 'var(--bg3)', fg: 'var(--t3)' };
return (
<span
className="inline-block px-2 py-0.5 rounded-full text-[0.68rem] font-semibold"
style={{ background: s.bg, color: s.fg }}
>
{severity}
</span>
);
}
function ActionTag({ action }: { action: string }) {
return (
<span
className="inline-block px-2 py-0.5 rounded-md text-[0.7rem] font-medium"
style={{ background: 'var(--acl)', color: 'var(--ac)', border: '1px solid var(--acl2)' }}
>
{action}
</span>
);
}
function EmptyState({ text }: { text: string }) {
return (
<div
className="flex flex-col items-center justify-center py-12"
style={{ color: 'var(--t4)' }}
>
<FileText size={32} className="mb-3 opacity-40" />
<p className="text-sm">{text}</p>
</div>
);
}
function ErrorBanner({ message }: { message: string }) {
return (
<div
className="flex items-center gap-2 px-4 py-3 rounded-xl text-sm"
style={{ background: 'var(--rdl)', color: 'var(--rd)', border: '1px solid var(--rd)' }}
>
<AlertCircle size={16} />
{message}
</div>
);
}
function LoadingRow({ cols, text }: { cols: number; text: string }) {
return (
<tr>
<td colSpan={cols} className="text-center py-10">
<Loader2 size={22} className="inline animate-spin" style={{ color: 'var(--ac)' }} />
<span className="ml-2 text-sm" style={{ color: 'var(--t4)' }}>{text}</span>
</td>
</tr>
);
}
/* ── Shared table wrapper ── */
const tableContainerStyle: React.CSSProperties = {
background: 'var(--bg1)',
border: '1px solid var(--bd)',
borderRadius: 'var(--r)',
overflow: 'hidden',
};
const thStyle: React.CSSProperties = {
padding: '0.6rem 0.75rem',
fontSize: '0.7rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
color: 'var(--t3)',
background: 'var(--bg2)',
borderBottom: '1px solid var(--bd)',
whiteSpace: 'nowrap',
};
const tdStyle: React.CSSProperties = {
padding: '0.55rem 0.75rem',
fontSize: '0.76rem',
borderBottom: '1px solid var(--bg3)',
color: 'var(--t2)',
};
const tdDateStyle: React.CSSProperties = {
...tdStyle,
fontSize: '0.68rem',
color: 'var(--t4)',
whiteSpace: 'nowrap',
fontFamily: 'var(--fm)',
};
const tdMonoStyle: React.CSSProperties = {
...tdStyle,
fontSize: '0.68rem',
color: 'var(--t4)',
fontFamily: 'var(--fm)',
};
/* ── Toolbar ── */
interface ToolbarProps {
limit: number;
onLimitChange: (n: number) => void;
severity?: string;
onSeverityChange?: (s: string) => void;
onRefresh: () => void;
loading: boolean;
showSeverity?: boolean;
}
function Toolbar({
limit,
onLimitChange,
severity,
onSeverityChange,
onRefresh,
loading,
showSeverity = false,
}: ToolbarProps) {
const { t } = useI18n();
const selectStyle: React.CSSProperties = {
fontSize: '0.72rem',
padding: '0.3rem 0.5rem',
borderRadius: '8px',
border: '1px solid var(--bd)',
background: 'var(--bg2)',
color: 'var(--t2)',
outline: 'none',
};
return (
<div className="flex items-center gap-3 flex-wrap">
{showSeverity && onSeverityChange && (
<select
value={severity ?? ''}
onChange={(e) => onSeverityChange(e.target.value)}
style={selectStyle}
>
<option value="">{t('audit.all')}</option>
<option value="error">{t('audit.errors')}</option>
<option value="success">{t('audit.success')}</option>
<option value="info">{t('audit.info')}</option>
</select>
)}
<select
value={limit}
onChange={(e) => onLimitChange(Number(e.target.value))}
style={selectStyle}
>
<option value={50}>50 {t('audit.records')}</option>
<option value={100}>100 {t('audit.records')}</option>
<option value={200}>200 {t('audit.records')}</option>
<option value={500}>500 {t('audit.records')}</option>
</select>
<button
onClick={onRefresh}
disabled={loading}
className="btn btn-secondary btn-sm"
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
{t('audit.refresh')}
</button>
</div>
);
}
/* ── Tab definitions ── */
type TabId = 'audit' | 'config' | 'chat';
/* ── Audit Log Table ── */
function AuditLogTable() {
const { t } = useI18n();
const [data, setData] = useState<AuditEntry[]>([]);
const [limit, setLimit] = useState(100);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const rows = await adminApi.getAuditLog(limit);
setData(rows);
} catch (e: any) {
setError(e.message || 'Erro ao carregar audit log');
} finally {
setLoading(false);
}
}, [limit]);
useEffect(() => { load(); }, [load]);
usePolling(async () => { await load(); }, 30_000, true);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs" style={{ color: 'var(--t4)' }}>
{data.length > 0 && `${data.length} ${t('audit.records')}`}
</p>
<Toolbar limit={limit} onLimitChange={setLimit} onRefresh={load} loading={loading} />
</div>
{error && <ErrorBanner message={error} />}
<div className="overflow-x-auto" style={{ borderRadius: 'var(--r)', border: '1px solid var(--bd)' }}>
<table className="data-table">
<thead>
<tr>
<th>{t('audit.date')}</th>
<th>{t('audit.user')}</th>
<th>{t('audit.action')}</th>
<th>{t('audit.resource')}</th>
<th>{t('audit.details')}</th>
<th>{t('audit.ip')}</th>
</tr>
</thead>
<tbody>
{loading && data.length === 0 ? (
<LoadingRow cols={6} text={t('audit.loadingLogs')} />
) : data.length === 0 ? (
<tr>
<td colSpan={6}><EmptyState text={t('audit.noAudit')} /></td>
</tr>
) : (
data.map((row) => (
<tr key={row.id}>
<td style={{ whiteSpace: 'nowrap', fontFamily: 'var(--fm)', fontSize: '.72rem', color: 'var(--t4)' }}>{formatDate(row.created_at)}</td>
<td>{row.username || '—'}</td>
<td><ActionTag action={row.action} /></td>
<td style={{ fontFamily: 'var(--fm)', fontSize: '.72rem', color: 'var(--t4)' }} title={row.resource ?? ''}>
{row.resource ? (row.resource.length > 20 ? row.resource.slice(0, 20) + '...' : row.resource) : '—'}
</td>
<td
style={{ fontSize: '0.72rem', maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
title={row.details ?? ''}
>
{row.details?.slice(0, 120) || '—'}
</td>
<td style={{ fontSize: '0.72rem' }}>{row.ip || '—'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
/* ── Config Logs Table ── */
function ConfigLogsTable() {
const { t } = useI18n();
const [data, setData] = useState<ConfigLogEntry[]>([]);
const [limit, setLimit] = useState(50);
const [severity, setSeverity] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const rows = await adminApi.getConfigLogs({
severity: severity || undefined,
limit,
});
setData(rows);
} catch (e: any) {
setError(e.message || 'Erro ao carregar config logs');
} finally {
setLoading(false);
}
}, [limit, severity]);
useEffect(() => { load(); }, [load]);
usePolling(async () => { await load(); }, 30_000, true);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs" style={{ color: 'var(--t4)' }}>
{data.length > 0 && `${data.length} ${t('audit.records')}`}
</p>
<Toolbar
limit={limit}
onLimitChange={setLimit}
severity={severity}
onSeverityChange={setSeverity}
onRefresh={load}
loading={loading}
showSeverity
/>
</div>
{error && <ErrorBanner message={error} />}
<div className="overflow-x-auto" style={{ borderRadius: 'var(--r)', border: '1px solid var(--bd)' }}>
<table className="data-table">
<thead>
<tr>
<th>{t('audit.date')}</th>
<th>{t('audit.config')}</th>
<th>{t('audit.action')}</th>
<th>{t('audit.status')}</th>
<th>{t('audit.message')}</th>
</tr>
</thead>
<tbody>
{loading && data.length === 0 ? (
<LoadingRow cols={5} text={t('audit.loadingLogs')} />
) : data.length === 0 ? (
<tr>
<td colSpan={5}><EmptyState text={t('audit.noConfig')} /></td>
</tr>
) : (
data.map((row) => (
<tr key={row.id}>
<td style={{ whiteSpace: 'nowrap', fontFamily: 'var(--fm)', fontSize: '.72rem', color: 'var(--t4)' }}>{formatDate(row.created_at)}</td>
<td style={{ fontSize: '0.72rem' }}>
{row.config_name || row.config_id?.slice(0, 8) || '—'}
</td>
<td><ActionTag action={row.action} /></td>
<td><SeverityBadge severity={row.severity} /></td>
<td
style={{ fontSize: '0.72rem', maxWidth: '400px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
title={row.message || ''}
>
{row.message?.slice(0, 150) || '—'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
/* ── Chat Logs Table ── */
function ChatLogsTable() {
const { t } = useI18n();
const [data, setData] = useState<ChatLogEntry[]>([]);
const [limit, setLimit] = useState(50);
const [severity, setSeverity] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const rows = await adminApi.getChatLogs({
severity: severity || undefined,
limit,
});
setData(rows);
} catch (e: any) {
setError(e.message || 'Erro ao carregar chat logs');
} finally {
setLoading(false);
}
}, [limit, severity]);
useEffect(() => { load(); }, [load]);
usePolling(async () => { await load(); }, 30_000, true);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs" style={{ color: 'var(--t4)' }}>
{data.length > 0 && `${data.length} ${t('audit.records')}`}
</p>
<Toolbar
limit={limit}
onLimitChange={setLimit}
severity={severity}
onSeverityChange={setSeverity}
onRefresh={load}
loading={loading}
showSeverity
/>
</div>
{error && <ErrorBanner message={error} />}
<div className="overflow-x-auto" style={{ borderRadius: 'var(--r)', border: '1px solid var(--bd)' }}>
<table className="data-table">
<thead>
<tr>
<th>{t('audit.date')}</th>
<th>{t('audit.session')}</th>
<th>{t('audit.source')}</th>
<th>{t('audit.action')}</th>
<th>{t('audit.status')}</th>
<th>{t('audit.message')}</th>
</tr>
</thead>
<tbody>
{loading && data.length === 0 ? (
<LoadingRow cols={6} text={t('audit.loadingLogs')} />
) : data.length === 0 ? (
<tr>
<td colSpan={6}><EmptyState text={t('audit.noChat')} /></td>
</tr>
) : (
data.map((row) => (
<tr key={row.id}>
<td style={{ whiteSpace: 'nowrap', fontFamily: 'var(--fm)', fontSize: '.72rem', color: 'var(--t4)' }}>{formatDate(row.created_at)}</td>
<td style={{ fontFamily: 'var(--fm)', fontSize: '.72rem', color: 'var(--t4)' }} title={row.session_id ?? ''}>
{row.session_id ? row.session_id.slice(0, 8) + '...' : '—'}
</td>
<td style={{ fontSize: '0.72rem' }}>{row.source || '—'}</td>
<td><ActionTag action={row.action} /></td>
<td><SeverityBadge severity={row.severity} /></td>
<td
style={{ fontSize: '0.72rem', maxWidth: '400px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
title={row.message || ''}
>
{row.message?.slice(0, 150) || '—'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
/* ── Main Page ── */
export default function AuditPage() {
const { t } = useI18n();
const [activeTab, setActiveTab] = useState<TabId>('audit');
const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [
{ id: 'audit', label: t('audit.tabAudit'), icon: <FileText size={15} /> },
{ id: 'config', label: t('audit.tabConfig'), icon: <ScrollText size={15} /> },
{ id: 'chat', label: t('audit.tabChat'), icon: <MessageSquare size={15} /> },
];
return (
<div className="page" style={{ overflow: 'auto', height: '100%' }}>
{/* Header */}
<div className="page-header">
<div className="card-header icon" style={{ background: 'var(--acl)' }}>
<FileText size={20} style={{ color: 'var(--ac)' }} />
</div>
<div>
<h1>{t('audit.title')}</h1>
<div className="subtitle">
{t('audit.subtitle')}
</div>
</div>
</div>
{/* Card with tabs + content */}
<div className="card">
{/* Tab bar */}
<div
className="flex gap-1 p-1 rounded-xl mb-4"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}
>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className="btn btn-sm"
style={{
background: activeTab === tab.id ? 'var(--bg1)' : 'transparent',
color: activeTab === tab.id ? 'var(--t1)' : 'var(--t3)',
border: activeTab === tab.id ? '1px solid var(--bd)' : '1px solid transparent',
boxShadow: activeTab === tab.id ? 'var(--sh1)' : 'none',
}}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* Tab content */}
<div style={{ animation: 'fadeIn .3s ease-out' }}>
{activeTab === 'audit' && <AuditLogTable />}
{activeTab === 'config' && <ConfigLogsTable />}
{activeTab === 'chat' && <ChatLogsTable />}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,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>
);
}

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

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

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Bold
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Italic
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Inline code
html = html.replace(/`(.+?)`/g, '<code style="background:var(--bg);padding:1px 4px;border-radius:3px;font-size:.72rem;font-family:var(--fm)">$1</code>');
// Horizontal rule
html = html.replace(/\n---\n/g, '<hr style="border:none;border-top:1px solid var(--bd);margin:8px 0">');
// Line breaks
html = html.replace(/\n/g, '<br>');
return html;
}
/* ── Main ── */
export default function EmbConsultPage() {
const { t } = useI18n();
const { adbCfg } = 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>
);
}

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

View File

@@ -0,0 +1,446 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useAppStore } from '@/stores/app';
import { useI18n } from '@/i18n';
import { genaiApi, type GenAiConfigFull, type GenAiConfigPayload } from '@/api/endpoints/genai';
import {
Brain, Plus, Pencil, Trash2, FlaskConical, Check, X, Loader2,
ChevronRight, Star, Link as LinkIcon,
} from 'lucide-react';
/* ── Msg ── */
function Msg({ type, text }: { type: 's' | 'e' | 'i'; text: string }) {
const bg = type === 's' ? 'var(--gnl)' : type === 'e' ? 'var(--rdl)' : 'var(--bg2)';
const fg = type === 's' ? 'var(--gn)' : type === 'e' ? 'var(--rd)' : 'var(--t2)';
return (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs" style={{ background: bg, color: fg }}>
{type === 's' && <Check size={14} />}
{type === 'e' && <X size={14} />}
{type === 'i' && <Loader2 size={14} className="animate-spin" />}
<span dangerouslySetInnerHTML={{ __html: text }} />
</div>
);
}
/* ── Main ── */
export default function GenAiConfigPage() {
const { ociCfg, models, regions } = useAppStore();
const { t } = useI18n();
const [configs, setConfigs] = useState<GenAiConfigFull[]>([]);
const [loading, setLoading] = useState(true);
// Form
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<GenAiConfigFull | null>(null);
const [saving, setSaving] = useState(false);
const [formMsg, setFormMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
const formRef = useRef<HTMLDivElement>(null);
// Form fields
const [name, setName] = useState('');
const [ociConfigId, setOciConfigId] = useState('');
const [modelId, setModelId] = useState('openai.gpt-4.1');
const [modelOcid, setModelOcid] = useState('');
const [compartmentId, setCompartmentId] = useState('');
const [genaiRegion, setGenaiRegion] = useState('us-ashburn-1');
const [isDefault, setIsDefault] = useState(false);
// Table
const [tableMsg, setTableMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
const [testResults, setTestResults] = useState<Record<string, { type: 's' | 'e'; text: string }>>({});
const [testing, setTesting] = useState<Record<string, boolean>>({});
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const fetchConfigs = useCallback(async () => {
try {
const data = await genaiApi.list();
setConfigs(data);
} catch {} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchConfigs(); }, [fetchConfigs]);
const resetForm = () => {
setEditing(null);
setFormOpen(false);
setName('');
setOciConfigId('');
setModelId('openai.gpt-4.1');
setModelOcid('');
setCompartmentId('');
setGenaiRegion('us-ashburn-1');
setIsDefault(false);
setFormMsg(null);
setSaving(false);
};
const startEdit = (cfg: GenAiConfigFull) => {
setEditing(cfg);
setName(cfg.name || '');
setOciConfigId(cfg.oci_config_id || '');
setModelId(cfg.model_ocid ? '_custom' : (cfg.model_id || 'openai.gpt-4.1'));
setModelOcid(cfg.model_ocid || '');
setCompartmentId(cfg.compartment_id || '');
setGenaiRegion(cfg.genai_region || 'us-ashburn-1');
setIsDefault(!!cfg.is_default);
setFormOpen(true);
setFormMsg(null);
setTimeout(() => formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100);
};
const fillFromOci = (ociId: string) => {
setOciConfigId(ociId);
const cfg = ociCfg.find((c) => c.id === ociId);
if (!cfg) return;
setGenaiRegion(cfg.region);
if (cfg.compartment_id) setCompartmentId(cfg.compartment_id);
setFormMsg({ type: 'i', text: `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>
);
}

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

View File

@@ -0,0 +1,775 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useAppStore } from '@/stores/app';
import { useI18n } from '@/i18n';
import { ociApi, type OciConfigFull, type OciTestResult } from '@/api/endpoints/oci';
import {
Cloud, ChevronRight, Key, Lock, Plus, Pencil,
Trash2, FlaskConical, Building2, User, Package,
Check, X, Loader2, Search,
} from 'lucide-react';
/* ── Static fallback OCI regions ── */
const FALLBACK_REGIONS: Record<string, string[]> = {
'Americas': [
'us-ashburn-1', 'us-phoenix-1', 'us-sanjose-1', 'us-chicago-1',
'ca-toronto-1', 'ca-montreal-1', 'sa-saopaulo-1', 'sa-vinhedo-1',
'sa-santiago-1', 'sa-bogota-1', 'mx-queretaro-1', 'mx-monterrey-1',
],
'Europe': [
'eu-frankfurt-1', 'eu-amsterdam-1', 'eu-zurich-1', 'eu-madrid-1',
'eu-marseille-1', 'eu-milan-1', 'eu-stockholm-1', 'eu-paris-1',
'uk-london-1', 'uk-cardiff-1', 'eu-jovanovac-1', 'eu-dcc-milan-1',
],
'Asia Pacific': [
'ap-tokyo-1', 'ap-osaka-1', 'ap-seoul-1', 'ap-sydney-1',
'ap-melbourne-1', 'ap-mumbai-1', 'ap-hyderabad-1', 'ap-singapore-1',
'ap-chuncheon-1', 'ap-singapore-2',
],
'Middle East & Africa': [
'me-jeddah-1', 'me-dubai-1', 'me-abudhabi-1',
'af-johannesburg-1', 'il-jerusalem-1',
],
};
type AuthType = 'api_key' | 'session_token';
/* ── Msg component ── */
function Msg({ type, text }: { type: 's' | 'e' | 'i'; text: string }) {
const bg = type === 's' ? 'var(--gnl)' : type === 'e' ? 'var(--rdl)' : 'var(--bg2)';
const fg = type === 's' ? 'var(--gn)' : type === 'e' ? 'var(--rd)' : 'var(--t2)';
return (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs" style={{ background: bg, color: fg }}>
{type === 's' && <Check size={14} />}
{type === 'e' && <X size={14} />}
{type === 'i' && <Loader2 size={14} className="animate-spin" />}
<span>{text}</span>
</div>
);
}
/* ── Region Dropdown ── */
function RegionDropdown({
value, onChange, regionsMap, t,
}: { value: string; onChange: (r: string) => void; regionsMap: Record<string, string[]>; t: (key: string) => string }) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState('');
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const f = filter.toLowerCase();
return (
<div ref={ref} className="relative">
<div
onClick={() => setOpen(!open)}
className="flex items-center justify-between cursor-pointer px-3 py-2 rounded-lg text-[.78rem]"
style={{ background: 'var(--bg2)', border: '1.5px solid var(--bd)', minHeight: 36 }}
>
<span style={{ color: value ? 'var(--t1)' : 'var(--t4)' }}>
{value || t('oci.selectRegion')}
</span>
<span className="text-[.6rem]" style={{ color: 'var(--t4)' }}>&#9660;</span>
</div>
{open && (
<div
className="absolute top-full left-0 right-0 rounded-lg overflow-hidden z-50"
style={{ background: 'var(--bg)', border: '1px solid var(--bd)', boxShadow: '0 4px 16px rgba(0,0,0,.25)', maxHeight: 280 }}
>
<div className="p-1.5" style={{ borderBottom: '1px solid var(--bd)' }}>
<div className="flex items-center gap-1.5 px-2" style={{ background: 'var(--bg2)', borderRadius: 6 }}>
<Search size={12} style={{ color: 'var(--t4)' }} />
<input
type="text"
placeholder={t('oci.searchRegion')}
value={filter}
onChange={(e) => setFilter(e.target.value)}
onClick={(e) => e.stopPropagation()}
className="w-full border-none outline-none text-xs py-1.5"
style={{ background: 'transparent', color: 'var(--t1)' }}
autoFocus
/>
</div>
</div>
<div className="overflow-y-auto" style={{ maxHeight: 230 }}>
{Object.entries(regionsMap).map(([grp, regs]) => {
const filtered = regs.filter((r) => !f || r.includes(f));
if (!filtered.length) return null;
return (
<div key={grp}>
<div
className="px-3 py-1 text-[.62rem] font-semibold uppercase tracking-wider"
style={{ color: 'var(--t4)', background: 'var(--bg1)' }}
>
{grp}
</div>
{filtered.map((r) => (
<div
key={r}
onClick={() => { onChange(r); setOpen(false); setFilter(''); }}
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
style={{
color: value === r ? 'var(--ac)' : 'var(--t2)',
background: value === r ? 'color-mix(in srgb, var(--ac) 8%, transparent)' : undefined,
}}
onMouseEnter={(e) => { if (value !== r) (e.target as HTMLDivElement).style.background = 'var(--bg2)'; }}
onMouseLeave={(e) => { if (value !== r) (e.target as HTMLDivElement).style.background = ''; }}
>
{r}
</div>
))}
</div>
);
})}
{Object.entries(regionsMap).every(([, regs]) => !regs.some((r) => !f || r.includes(f))) && (
<div className="p-3 text-xs" style={{ color: 'var(--t4)' }}>{t('oci.noRegion')}</div>
)}
</div>
</div>
)}
</div>
);
}
/* ── Auth Type Tag ── */
function AuthTag({ type }: { type: string }) {
const isToken = type === 'session_token';
return (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.62rem] font-semibold"
style={{
background: isToken ? 'rgba(230,126,34,0.12)' : 'var(--gnl)',
color: isToken ? '#e67e22' : 'var(--gn)',
}}
>
{isToken ? <Lock size={10} /> : <Key size={10} />}
{isToken ? 'Session Token' : 'API Key'}
</span>
);
}
/* ── Main Page ── */
export default function OciConfigPage() {
const ociRegions = useAppStore((s) => s.ociRegions);
const { t } = useI18n();
const [configs, setConfigs] = useState<OciConfigFull[]>([]);
const [loading, setLoading] = useState(true);
// Form state
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<OciConfigFull | null>(null);
const [authType, setAuthType] = useState<AuthType>('api_key');
const [region, setRegion] = useState('');
const [saving, setSaving] = useState(false);
const [formMsg, setFormMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
// Table msg
const [tableMsg, setTableMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
// Test results per config
const [testResults, setTestResults] = useState<Record<string, OciTestResult>>({});
const [testing, setTesting] = useState<Record<string, boolean>>({});
// Delete confirm
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
// Form refs
const formRef = useRef<HTMLDivElement>(null);
const tnRef = useRef<HTMLInputElement>(null);
const toRef = useRef<HTMLInputElement>(null);
const uoRef = useRef<HTMLInputElement>(null);
const fpRef = useRef<HTMLInputElement>(null);
const cpRef = useRef<HTMLInputElement>(null);
const kpRef = useRef<HTMLInputElement>(null);
const skRef = useRef<HTMLInputElement>(null);
const pkRef = useRef<HTMLInputElement>(null);
const stkRef = useRef<HTMLTextAreaElement>(null);
const sshRef = useRef<HTMLTextAreaElement>(null);
// Build regions map
const regionsMap: Record<string, string[]> = (() => {
if (ociRegions && typeof ociRegions === 'object') {
const entries = Object.entries(ociRegions);
if (entries.length > 0) {
// ociRegions can be { group: string[] } or { group: string }
// From the store it's Record<string,string>, but the backend returns Record<string,string[]>
const first = entries[0][1];
if (Array.isArray(first)) return ociRegions as unknown as Record<string, string[]>;
// If it's flat, return as single-group
const flat = entries.map(([, v]) => v as unknown as string);
if (flat.length > 0) return { 'Regioes': flat };
}
}
return FALLBACK_REGIONS;
})();
const fetchConfigs = useCallback(async () => {
try {
const data = await ociApi.list();
setConfigs(data);
} catch {
// silent
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchConfigs(); }, [fetchConfigs]);
const resetForm = () => {
setEditing(null);
setFormOpen(false);
setAuthType('api_key');
setRegion('');
setFormMsg(null);
setSaving(false);
// Clear file inputs
if (skRef.current) skRef.current.value = '';
if (pkRef.current) pkRef.current.value = '';
};
const startEdit = (cfg: OciConfigFull) => {
setEditing(cfg);
setAuthType(cfg.auth_type || 'api_key');
setRegion(cfg.region);
setFormOpen(true);
setFormMsg(null);
setTimeout(() => formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100);
};
const handleSave = async () => {
const tenancyName = tnRef.current?.value?.trim() || '';
if (!tenancyName) { setFormMsg({ type: 'e', text: `${t('oci.fillField')} Tenancy Name` }); return; }
const isEdit = !!editing;
const at = isEdit ? (editing!.auth_type || 'api_key') : authType;
const isToken = at === 'session_token';
const tenancyOcid = toRef.current?.value?.trim() || '';
const userOcid = uoRef.current?.value?.trim() || '';
const fingerprint = fpRef.current?.value?.trim() || '';
const compartmentId = cpRef.current?.value?.trim() || '';
const keyPassphrase = kpRef.current?.value?.trim() || '';
const securityToken = stkRef.current?.value?.trim() || '';
const sshPubKey = sshRef.current?.value?.trim() || '';
const privateKeyFile = skRef.current?.files?.[0];
const publicKeyFile = pkRef.current?.files?.[0];
// Validations for new config
if (!isEdit) {
if (!tenancyOcid) { setFormMsg({ type: 'e', text: t('oci.fillOcidTenancy') }); return; }
if (!isToken && (!userOcid || !fingerprint)) { setFormMsg({ type: 'e', text: t('oci.fillUserAndFP') }); return; }
if (isToken && !securityToken) { setFormMsg({ type: 'e', text: t('oci.pasteToken') }); return; }
if (!privateKeyFile) { setFormMsg({ type: 'e', text: isToken ? t('oci.selectSessionKey') : t('oci.selectKey') }); return; }
}
const fd = new FormData();
fd.append('tenancy_name', tenancyName);
fd.append('tenancy_ocid', tenancyOcid);
fd.append('user_ocid', userOcid);
fd.append('fingerprint', fingerprint);
fd.append('region', region);
fd.append('compartment_id', compartmentId);
fd.append('key_passphrase', keyPassphrase);
fd.append('ssh_public_key', sshPubKey);
fd.append('auth_type', at);
fd.append('security_token', securityToken);
if (privateKeyFile) fd.append('private_key', privateKeyFile);
if (publicKeyFile) fd.append('public_key', publicKeyFile);
setSaving(true);
setFormMsg({ type: 'i', text: t('oci.saving') });
try {
if (isEdit) {
await ociApi.update(editing!.id, fd);
} else {
await ociApi.create(fd);
}
setFormMsg({ type: 's', text: isEdit ? t('oci.updated') : t('oci.saved') });
await fetchConfigs();
useAppStore.getState().loadData();
setTimeout(resetForm, 1200);
} catch (err) {
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : '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>
);
}

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

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

View 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"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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"]
}

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