feat: OCI CLI Terminal, Spanish i18n, language in My Settings

Terminal:
- New /terminal page with Linux-style terminal UI (dark theme, title bar, prompt)
- OCI Config selector — isolated per user (ownership check)
- Tab autocomplete: parses oci --help dynamically, caches results, commands first
- Command history (↑/↓) persisted in DB per user
- Security: only 'oci' commands, blocks pipes/redirects/shell injection
- Built-in commands: clear, history, help
- Timeout 60s per command

i18n:
- Spanish (es.ts): 748+ keys, Latin American formal
- Language selector moved from sidebar to My Settings (🇧🇷/🇺🇸/🇪🇸)
- Auto-detect browser language (pt/es/en)

Backend:
- POST /api/terminal/execute — run OCI CLI with selected config
- GET /api/terminal/completions — dynamic autocomplete from oci --help
- GET /api/terminal/history — per-user command history
- terminal_history table in SQLite
This commit is contained in:
nogueiraguh
2026-04-01 17:45:25 -03:00
parent 62f2a3d1ab
commit 8caa032055
9 changed files with 1363 additions and 20 deletions

View File

@@ -323,6 +323,12 @@ def init_db():
action TEXT NOT NULL, resource TEXT, details TEXT, action TEXT NOT NULL, resource TEXT, details TEXT,
ip TEXT, created_at TEXT DEFAULT (datetime('now')) ip TEXT, created_at TEXT DEFAULT (datetime('now'))
); );
CREATE TABLE IF NOT EXISTS terminal_history (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
oci_config_id TEXT NOT NULL, command TEXT NOT NULL,
exit_code INTEGER, output TEXT, error TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS config_logs ( CREATE TABLE IF NOT EXISTS config_logs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
config_type TEXT NOT NULL, config_type TEXT NOT NULL,
@@ -1089,6 +1095,145 @@ async def test_oci(cid: str, u=Depends(require("admin","user"))):
_config_log("oci", cid, cname, "error", "test", str(e)[:500], u["id"], u["username"]) _config_log("oci", cid, cname, "error", "test", str(e)[:500], u["id"], u["username"])
return {"status":"error","message":str(e)[:500]} return {"status":"error","message":str(e)[:500]}
# ── OCI CLI Terminal ─────────────────────────────────────────────────────────
_TERMINAL_BLOCKED_PATTERNS = re.compile(
r'(rm\s|mv\s|cp\s|chmod\s|chown\s|sudo\s|bash\s|sh\s|curl\s|wget\s|python|pip\s|apt\s|yum\s|>\s|>>\s|\|)',
re.IGNORECASE
)
@app.post("/api/terminal/execute")
async def terminal_execute(req: dict, u=Depends(current_user)):
"""Execute an OCI CLI command using the selected OCI config. Only 'oci' commands allowed."""
oci_config_id = req.get("oci_config_id", "")
command = (req.get("command", "") or "").strip()
if not oci_config_id:
raise HTTPException(400, "oci_config_id obrigatório")
if not command:
raise HTTPException(400, "Comando vazio")
_verify_config_access("oci", oci_config_id, u)
# Security: only allow 'oci' commands
if not command.startswith("oci "):
raise HTTPException(400, "Apenas comandos 'oci' são permitidos. Ex: oci iam user list")
if _TERMINAL_BLOCKED_PATTERNS.search(command):
raise HTTPException(400, "Comando contém operações não permitidas")
# Build config path
cfg_path = OCI_DIR / oci_config_id / "config"
if not cfg_path.exists():
raise HTTPException(400, "OCI config file não encontrado. Reconfigure as credenciais.")
# Execute with timeout
import shlex, subprocess
cmd_args = shlex.split(command) + ["--config-file", str(cfg_path)]
try:
loop = asyncio.get_event_loop()
result = await asyncio.wait_for(
loop.run_in_executor(_chat_executor, lambda: subprocess.run(
cmd_args, capture_output=True, text=True, timeout=60, cwd="/tmp",
env={**os.environ, "OCI_CLI_SUPPRESS_FILE_PERMISSIONS_WARNING": "True", "HOME": "/tmp"}
)), timeout=65)
output = result.stdout or ""
error = result.stderr or ""
exit_code = result.returncode
# Save to history
with db() as c:
c.execute("INSERT INTO terminal_history (id,user_id,oci_config_id,command,exit_code,output,error) VALUES (?,?,?,?,?,?,?)",
(str(uuid.uuid4()), u["id"], oci_config_id, command, exit_code, output[:10000], error[:2000]))
return {
"exit_code": exit_code,
"output": output[:50000],
"error": error[:5000] if exit_code != 0 else "",
}
except asyncio.TimeoutError:
with db() as c:
c.execute("INSERT INTO terminal_history (id,user_id,oci_config_id,command,exit_code,error) VALUES (?,?,?,?,?,?)",
(str(uuid.uuid4()), u["id"], oci_config_id, command, -1, "Timeout: 60s"))
return {"exit_code": -1, "output": "", "error": "Timeout: comando excedeu 60 segundos"}
except Exception as e:
return {"exit_code": -1, "output": "", "error": str(e)[:2000]}
@app.get("/api/terminal/history")
async def terminal_history(oci_config_id: str = Query(""), limit: int = Query(50), u=Depends(current_user)):
"""Get recent terminal commands for the user."""
with db() as c:
if oci_config_id:
rows = c.execute(
"SELECT * FROM terminal_history WHERE user_id=? AND oci_config_id=? ORDER BY created_at DESC LIMIT ?",
(u["id"], oci_config_id, limit)).fetchall()
else:
rows = c.execute(
"SELECT * FROM terminal_history WHERE user_id=? ORDER BY created_at DESC LIMIT ?",
(u["id"], limit)).fetchall()
return [dict(r) for r in rows]
# OCI CLI autocomplete cache
_oci_completions_cache: dict = {}
def _parse_oci_help(args: list[str]) -> list[str]:
"""Run oci <args> --help and extract available commands/options."""
import subprocess
try:
result = subprocess.run(
["oci"] + args + ["--help"], capture_output=True, text=True, timeout=10,
env={**os.environ, "OCI_CLI_SUPPRESS_FILE_PERMISSIONS_WARNING": "True", "HOME": "/tmp"})
text = result.stdout + result.stderr
commands = []
in_commands = False
for line in text.splitlines():
stripped = line.strip()
# Detect transition into command/service listing
if not in_commands:
if re.match(r'^(Commands|Options|Core|Others|Networking|Database|Identity|Storage|Compute|Security)', stripped):
in_commands = True
continue
if not stripped:
continue
# Section headers (single word or capitalized phrase not indented) — skip
if not line.startswith(" "):
continue
# Capture " command-name Description text" pattern
m = re.match(r'^ ([a-z][a-z0-9\-_]+)\s', line)
if m and len(m.group(1)) > 1:
commands.append(m.group(1))
continue
# Capture 2-space indent commands (sub-command help)
m = re.match(r'^ ([a-z][a-z0-9\-_]+)\s', line)
if m and len(m.group(1)) > 2:
commands.append(m.group(1))
continue
# Capture --options
if stripped.startswith("--"):
opt = stripped.split()[0].rstrip(",")
if opt.startswith("--"): commands.append(opt)
return sorted(set(commands))
except Exception:
return []
@app.get("/api/terminal/completions")
async def terminal_completions(prefix: str = Query(""), u=Depends(current_user)):
"""Get OCI CLI autocomplete suggestions for a partial command."""
parts = prefix.strip().split()
if not parts or parts[0] != "oci":
return {"suggestions": []}
# Build cache key from command path (without final partial word)
# e.g. "oci iam us" → lookup completions for ["oci", "iam"], filter by "us"
if prefix.endswith(" "):
cmd_parts = parts[1:]
filter_prefix = ""
else:
cmd_parts = parts[1:-1]
filter_prefix = parts[-1] if len(parts) > 1 else ""
cache_key = " ".join(cmd_parts)
if cache_key not in _oci_completions_cache:
loop = asyncio.get_event_loop()
completions = await loop.run_in_executor(_chat_executor, lambda: _parse_oci_help(cmd_parts))
_oci_completions_cache[cache_key] = completions
suggestions = _oci_completions_cache[cache_key]
if filter_prefix:
suggestions = [s for s in suggestions if s.startswith(filter_prefix)]
# Commands first, then options
cmds = [s for s in suggestions if not s.startswith("--")]
opts = [s for s in suggestions if s.startswith("--")]
return {"suggestions": (cmds + opts)[:50]}
# ── OCI Account Explorer ────────────────────────────────────────────────────── # ── OCI Account Explorer ──────────────────────────────────────────────────────
@app.get("/api/oci/explore/{cid}/compartments") @app.get("/api/oci/explore/{cid}/compartments")
async def explore_compartments(cid: str, u=Depends(current_user)): async def explore_compartments(cid: str, u=Depends(current_user)):

View File

@@ -20,6 +20,7 @@ const McpServersPage = lazy(() => import('@/pages/config/McpServersPage'));
const AdbConfigPage = lazy(() => import('@/pages/config/AdbConfigPage')); const AdbConfigPage = lazy(() => import('@/pages/config/AdbConfigPage'));
const EmbeddingsPage = lazy(() => import('@/pages/config/EmbeddingsPage')); const EmbeddingsPage = lazy(() => import('@/pages/config/EmbeddingsPage'));
const EmbConsultPage = lazy(() => import('@/pages/config/EmbConsultPage')); const EmbConsultPage = lazy(() => import('@/pages/config/EmbConsultPage'));
const TerminalPage = lazy(() => import('@/pages/TerminalPage'));
const UsersPage = lazy(() => import('@/pages/admin/UsersPage')); const UsersPage = lazy(() => import('@/pages/admin/UsersPage'));
const MySettingsPage = lazy(() => import('@/pages/admin/MySettingsPage')); const MySettingsPage = lazy(() => import('@/pages/admin/MySettingsPage'));
const OracleIamPage = lazy(() => import('@/pages/admin/OracleIamPage')); const OracleIamPage = lazy(() => import('@/pages/admin/OracleIamPage'));
@@ -61,6 +62,7 @@ export default function App() {
<Route path="/explorer" element={<ExplorerPage />} /> <Route path="/explorer" element={<ExplorerPage />} />
<Route path="/oci-services" element={<OciServicesPage />} /> <Route path="/oci-services" element={<OciServicesPage />} />
<Route path="/reports" element={<ReportsPage />} /> <Route path="/reports" element={<ReportsPage />} />
<Route path="/terminal" element={<TerminalPage />} />
<Route path="/config/oci" element={<OciConfigPage />} /> <Route path="/config/oci" element={<OciConfigPage />} />
<Route path="/config/genai" element={<GenAiConfigPage />} /> <Route path="/config/genai" element={<GenAiConfigPage />} />
<Route path="/config/mcp" element={<McpServersPage />} /> <Route path="/config/mcp" element={<McpServersPage />} />

View File

@@ -1,9 +1,9 @@
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { useState } from 'react'; import { useState } from 'react';
import { import {
MessageSquare, Search, BarChart3, Cloud, Shield, MessageSquare, Search, BarChart3, Cloud, Shield, Terminal,
Brain, Plug, Database, Dna, Users, FileText, Brain, Plug, Database, Dna, Users, FileText,
Sparkles, Sun, Moon, LogOut, FolderOpen, Languages, Settings, ArrowLeft, Sparkles, Sun, Moon, LogOut, FolderOpen, Settings, ArrowLeft,
UserCog, ChevronDown, ChevronRight, UserCog, ChevronDown, ChevronRight,
} from 'lucide-react'; } from 'lucide-react';
import { useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
@@ -52,6 +52,7 @@ const NAV_ITEMS: NavItem[] = [
{ to: '/oci-services', icon: <Shield size={SZ} />, labelKey: 'nav.ociServices' }, { to: '/oci-services', icon: <Shield size={SZ} />, labelKey: 'nav.ociServices' },
{ to: '/reports', icon: <BarChart3 size={SZ} />, labelKey: 'nav.reports' }, { to: '/reports', icon: <BarChart3 size={SZ} />, labelKey: 'nav.reports' },
{ to: '/config/embeddings/consult', icon: <MessageSquare size={SZ} />, labelKey: 'nav.embConsult' }, { to: '/config/embeddings/consult', icon: <MessageSquare size={SZ} />, labelKey: 'nav.embConsult' },
{ to: '/terminal', icon: <Terminal size={SZ} />, labelKey: 'nav.terminal' },
]; ];
const ADMIN_ITEMS: NavItem[] = [ const ADMIN_ITEMS: NavItem[] = [
@@ -73,7 +74,7 @@ const USER_MGMT_ITEMS: NavItem[] = [
export default function Sidebar() { export default function Sidebar() {
const { toggle, isDark } = useTheme(); const { toggle, isDark } = useTheme();
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
const { t, lang, setLang } = useI18n(); const { t } = useI18n();
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [userMgmtOpen, setUserMgmtOpen] = useState(false); const [userMgmtOpen, setUserMgmtOpen] = useState(false);
@@ -196,18 +197,6 @@ export default function Sidebar() {
> >
{isDark ? <Sun size={18} /> : <Moon size={18} />} {isDark ? <Sun size={18} /> : <Moon size={18} />}
</button> </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'}
aria-label={lang === 'pt' ? 'English' : 'Português'}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg3)'; e.currentTarget.style.color = 'var(--t1)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = ''; e.currentTarget.style.color = 'var(--t3)'; }}
>
<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="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" <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)' }}> style={{ background: 'var(--ac)' }}>

View File

@@ -20,6 +20,7 @@ const en: Record<string, string> = {
'nav.adb': 'ADB Vector', 'nav.adb': 'ADB Vector',
'nav.embeddings': 'Embeddings', 'nav.embeddings': 'Embeddings',
'nav.embConsult': 'Query Embeddings', 'nav.embConsult': 'Query Embeddings',
'nav.terminal': 'Terminal',
'nav.userMgmt': 'User Management', 'nav.userMgmt': 'User Management',
'nav.users': 'Users', 'nav.users': 'Users',
'nav.mySettings': 'My Settings', 'nav.mySettings': 'My Settings',
@@ -755,6 +756,8 @@ const en: Record<string, string> = {
'settings.timezone': 'Timezone', 'settings.timezone': 'Timezone',
'settings.tzDesc': 'Set your timezone for date and time display.', 'settings.tzDesc': 'Set your timezone for date and time display.',
'settings.tzChanged': 'Timezone changed successfully', 'settings.tzChanged': 'Timezone changed successfully',
'settings.language': 'Language',
'settings.langDesc': 'Select the interface language.',
'settings.changePassword': 'Change Password', 'settings.changePassword': 'Change Password',
'settings.currentPw': 'Current Password', 'settings.currentPw': 'Current Password',
'settings.newPw': 'New Password', 'settings.newPw': 'New Password',
@@ -767,6 +770,16 @@ const en: Record<string, string> = {
'settings.pwMinLength': 'New password must be at least 8 characters', 'settings.pwMinLength': 'New password must be at least 8 characters',
'settings.federatedInfo': 'Password and MFA are managed by Oracle IAM Identity Domain. Configure directly in the OCI portal.', 'settings.federatedInfo': 'Password and MFA are managed by Oracle IAM Identity Domain. Configure directly in the OCI portal.',
'settings.mfa': 'Multi-Factor Authentication (MFA)', 'settings.mfa': 'Multi-Factor Authentication (MFA)',
// ── Terminal ──
'term.title': 'OCI CLI Terminal',
'term.subtitle': 'Execute OCI CLI commands directly in the browser',
'term.config': 'Configuration',
'term.selectConfig': 'Select an OCI configuration',
'term.welcome': 'Type OCI CLI commands. Ex: oci iam user list --compartment-id <ocid>',
'term.clearHint': 'to clear',
'term.historyHint': 'to browse history',
'term.executing': 'Executing...',
'term.noOutput': 'Command executed with no output',
// ── Oracle IAM ── // ── Oracle IAM ──
'iam.title': 'Oracle IAM Identity Domains', 'iam.title': 'Oracle IAM Identity Domains',
'iam.subtitle': 'OIDC integration for corporate authentication', 'iam.subtitle': 'OIDC integration for corporate authentication',

View File

@@ -0,0 +1,811 @@
const es: Record<string, string> = {
// ── Sidebar ──
'nav.principal': 'Principal',
'nav.admin': 'Administracion',
'nav.chat': 'Chat Agent',
'nav.terraform': 'Terraform',
'nav.promptGen': 'Prompt Generator',
'nav.workspaces': 'Workspaces',
'nav.explorer': 'OCI Explorer',
'nav.ociServices': 'OCI Services',
'svc.title': 'Estado de Servicios OCI',
'svc.subtitle': 'Verifique y configure el estado de los servicios de seguridad OCI por tenencia',
'svc.redetect': 'Re-detectar',
'svc.checking': 'Verificando servicios OCI...',
'svc.save': 'Guardar',
'nav.reports': 'Reportes',
'nav.ociCreds': 'Credenciales OCI',
'nav.genai': 'Configuracion GenAI',
'nav.mcp': 'Servidores MCP',
'nav.adb': 'ADB Vector',
'nav.embeddings': 'Embeddings',
'nav.embConsult': 'Consultar Embeddings',
'nav.terminal': 'Terminal',
'nav.userMgmt': 'Gestión de Usuarios',
'nav.users': 'Usuarios',
'nav.mySettings': 'Mi Configuracion',
'nav.oracleIam': 'Oracle IAM',
'nav.audit': 'Registro de Auditoria',
'sidebar.lightMode': 'Modo claro',
'sidebar.darkMode': 'Modo oscuro',
'sidebar.logout': 'Cerrar sesion',
'sidebar.settings': 'Configuracion',
'sidebar.backToMenu': 'Volver al menu',
'sidebar.roleAdmin': 'Administrador',
'sidebar.roleUser': 'Usuario',
'sidebar.subtitle': 'Infraestructura y Seguridad',
// ── Login ──
'login.subtitle': 'Ingeniero de Infraestructura y Seguridad',
'login.userPlaceholder': 'Usuario',
'login.passPlaceholder': 'Contrasena',
'login.mfaScanQr': 'Escanee el codigo QR con su aplicacion de autenticacion:',
'login.totpPlaceholder': 'Codigo TOTP',
'login.loading': 'Iniciando sesion...',
'login.verify': 'Verificar',
'login.submit': 'Iniciar sesion',
'login.error': 'Error de inicio de sesion',
// ── Chat ──
'chat.timeNow': 'ahora',
'chat.today': 'Hoy',
'chat.yesterday': 'Ayer',
'chat.last7': 'Ultimos 7 dias',
'chat.last30': 'Ultimos 30 dias',
'chat.older': 'Anteriores',
'chat.other': 'Otros',
'chat.history': 'Historial',
'chat.newChat': 'Nuevo Chat',
'chat.untitled': 'Sin titulo',
'chat.noConversations': 'Aun no hay conversaciones',
'chat.selectModel': 'Seleccione un modelo...',
'chat.savedConfigs': 'Configuraciones Guardadas',
'chat.searchModel': 'Buscar modelo...',
'chat.noModels': 'No se encontraron modelos',
'chat.ociCred': 'Credencial OCI...',
'chat.settings': 'Configuracion',
'chat.newChatTitle': 'Nuevo chat',
'chat.emptyTitle': 'Inicie una conversacion con el agente.',
'chat.emptySubtitle': 'Seleccione un modelo para comenzar.',
'chat.thinking': 'Pensando...',
'chat.retry': 'Reintentar',
'chat.attachFile': 'Adjuntar archivo',
'chat.placeholder': 'Escriba su mensaje... (Shift+Enter para nueva linea)',
'chat.send': 'Enviar',
'chat.selectOciCred': 'Seleccione una credencial OCI para usar modelos directos.',
'chat.sendError': 'Error al enviar mensaje',
'chat.requestFailed': 'La solicitud fallo.',
'chat.timeout': 'Tiempo agotado: la respuesta esta tardando demasiado. Intente de nuevo.',
'chat.toolsAvailable': 'herramientas disponibles',
'chat.ociCredLabel': 'Credencial OCI',
'chat.select': 'Seleccione...',
'chat.analyzeFiles': 'Analizar los archivos adjuntos.',
// ── Terraform ──
'tf.history': 'Historial',
'tf.newChat': 'Nuevo',
'tf.noConversations': 'Sin conversaciones',
'tf.searchModel': 'Buscar modelo...',
'tf.selectModel': 'Seleccione modelo...',
'tf.savedConfigs': 'Configuraciones Guardadas',
'tf.ociConfig': 'Configuracion OCI...',
'tf.settings': 'Configuracion',
'tf.newConversation': 'Nueva conversacion',
'tf.emptyTitle': 'Describa la infraestructura OCI',
'tf.emptySubtitle': 'El agente generara codigo Terraform basado en su descripcion.',
'tf.thinking': 'Procesando...',
'tf.retry': 'Reintentar',
'tf.placeholder': 'Describa la infraestructura deseada... (Shift+Enter para nueva linea)',
'tf.send': 'Enviar',
'tf.workspace': 'Workspace',
'tf.files': 'Archivos',
'tf.terminal': 'Terminal',
'tf.resources': 'Recursos',
'tf.plan': 'Plan',
'tf.apply': 'Apply',
'tf.destroy': 'Destroy',
'tf.cancel': 'Cancelar',
'tf.download': 'Descargar ZIP',
'tf.createWs': 'Crear Workspace',
'tf.noFiles': 'No se generaron archivos',
'tf.timeout': 'Tiempo agotado: la generacion esta tardando demasiado.',
'tf.selectOci': 'Seleccione una credencial OCI',
'tf.compartment': 'Compartimiento',
'tf.region': 'Region',
'tf.rollback': 'Rollback',
'tf.copy': 'Copiar',
'tf.copied': 'Copiado',
'tf.noWs': 'Sin workspace activo',
'tf.deleteWs': 'Eliminar',
'tf.noFilesToPlan': 'Sin archivos para planificar',
'tf.selectOciConfig': 'Seleccione Configuracion OCI y Compartimiento',
'tf.running': 'Ejecutando...',
'tf.terminalTitle': 'Terminal de salida Terraform',
'tf.terminalHint': 'Ejecute Plan, Apply o Destroy para ver los resultados aqui',
'tf.autoFix': 'Auto-corregir',
'tf.editSystemPrompt': 'Editar System Prompt',
'tf.refreshReference': 'Actualizar Referencia',
// ── Prompt Generator ──
'pg.history': 'Historial',
'pg.new': 'Nuevo',
'pg.noConversations': 'Sin conversaciones',
'pg.emptyTitle': 'Describa la infraestructura OCI que necesita',
'pg.emptySubtitle': 'El agente generara un prompt estructurado y optimizado para el Terraform Agent.',
'pg.loading': 'Generando prompt...',
'pg.placeholder': 'Describa la infraestructura deseada... (Shift+Enter para nueva linea)',
'pg.copy': 'Copiar',
'pg.copied': 'Copiado',
'pg.retry': 'Reintentar',
'pg.timeout': 'Tiempo agotado: la generacion esta tardando demasiado.',
'pg.search': 'Buscar...',
'pg.selectModel': 'Seleccione modelo...',
// ── Workspaces ──
'ws.title': 'Terraform Workspaces',
'ws.all': 'Todos',
'ws.active': 'Activo',
'ws.draft': 'Borrador',
'ws.failed': 'Fallido',
'ws.destroyed': 'Destruido',
'ws.loading': 'Cargando...',
'ws.refresh': 'Actualizar',
'ws.noWs': 'No se encontraron workspaces',
'ws.noWsHint': 'Cree workspaces a traves del Terraform Agent',
'ws.loadingWs': 'Cargando workspaces...',
'ws.open': 'Abrir',
'ws.plan': 'Plan',
'ws.apply': 'Apply',
'ws.destroy': 'Destroy',
'ws.delete': 'Eliminar',
'ws.cancel': 'Cancelar',
'ws.hideOutput': 'ocultar',
'ws.viewOutput': 'ver salida',
'ws.confirmDestroy': 'Confirmar Destruccion',
'ws.confirmDestroyMsg': 'Esta accion destruira todos los recursos aprovisionados por este workspace.',
'ws.typeDestroy': 'Escriba <strong>DESTROY</strong> para confirmar:',
'ws.deleteWs': 'Eliminar Workspace',
'ws.deleteWsMsg': 'Eliminar este workspace permanentemente? Los archivos locales seran removidos.',
'ws.noDate': 'Sin fecha',
// ── Explorer ──
'exp.title': 'OCI Explorer',
'exp.subtitle': 'Explore los recursos de su Oracle Cloud Infrastructure',
'exp.search': 'Buscar recurso...',
'exp.refresh': 'Actualizar',
'exp.loading': 'Cargando...',
'exp.noResources': 'No se encontraron recursos',
'exp.selectResource': 'Seleccione un tipo de recurso',
'exp.compartment': 'Compartimiento',
'exp.region': 'Region',
'exp.allRegions': 'Todas las regiones',
'exp.start': 'Iniciar',
'exp.stop': 'Detener',
'exp.details': 'Detalles',
'exp.total': 'Total',
'exp.network': 'Red',
'exp.observability': 'Observabilidad',
'exp.security': 'Seguridad',
'exp.selectRegions': 'Seleccione regiones',
'exp.nRegions': '{0} regiones',
'exp.all': 'Todas',
'exp.none': 'Ninguna',
'exp.noCompartment': 'No se encontraron compartimientos',
'exp.selectConfig': 'Seleccione una configuracion OCI',
'exp.loadingResources': 'Cargando recursos...',
'exp.clearFilter': 'Intente limpiar el filtro de busqueda',
'exp.resources': 'recursos',
'exp.updating': 'actualizando...',
'exp.regions': 'region(es)',
'exp.filter': 'Filtrar...',
'exp.startAction': 'Iniciar',
'exp.stopAction': 'Detener',
'exp.updatingState': 'Actualizando...',
'exp.createdAt': 'Creado el',
'exp.public': 'Publico',
'exp.yesPublic': 'Si',
'exp.noPrivate': 'No (privado)',
'exp.freeTier': 'Free Tier',
'exp.yes': 'Si',
'exp.no': 'No',
'exp.noRestrictions': 'Sin restricciones (abierto)',
'exp.updateNetworkAccess': 'Actualizar Acceso de Red',
'exp.accessUpdated': 'Acceso actualizado: ',
'exp.objects': 'Objetos',
'exp.size': 'Tamano',
'exp.visibility': 'Visibilidad',
'exp.private': 'Privado',
'exp.rules': 'Reglas',
'exp.routes': 'Rutas',
'exp.hideRules': 'Ocultar reglas',
'exp.viewRules': 'Ver reglas',
'exp.add': 'Agregar',
'exp.direction': 'Direccion',
'exp.protocol': 'Protocolo',
'exp.originDest': 'Origen/Destino',
'exp.ports': 'Puertos',
'exp.description': 'Descripcion',
'exp.portMin': 'Puerto Min',
'exp.portMax': 'Puerto Max',
'exp.myIp': 'Mi IP',
'exp.save': 'Guardar',
'exp.cancel': 'Cancelar',
'exp.ruleAdded': 'Regla agregada.',
'exp.enterCidr': 'Ingrese el CIDR.',
'exp.removeRule': 'Eliminar esta regla?',
'exp.removeNsgRule': 'Eliminar esta regla NSG?',
'exp.ipDetected': 'IP detectada: {0} — ajuste el puerto y haga clic en Guardar',
'exp.ipDetectFailed': 'No se pudo detectar la IP.',
'exp.destination': 'Destino',
'exp.destType': 'Tipo Destino',
'exp.version': 'Version',
'exp.ha': 'HA',
// ── Reports ──
'rpt.title': 'Reportes CIS',
'rpt.subtitle': 'Ejecute escaneos de cumplimiento CIS OCI Benchmark',
'rpt.startScan': 'Iniciar Escaneo',
'rpt.running': 'Ejecutando...',
'rpt.cancel': 'Cancelar',
'rpt.tenancy': 'Tenencia',
'rpt.level': 'Nivel',
'rpt.region': 'Region',
'rpt.allRegions': 'Todas las regiones',
'rpt.noReports': 'No se encontraron reportes',
'rpt.noReportsHint': 'Ejecute un escaneo para generar reportes de cumplimiento.',
'rpt.progress': 'Progreso',
'rpt.completed': 'Completado',
'rpt.failed': 'Fallido',
'rpt.viewReport': 'Ver Reporte',
'rpt.download': 'Descargar',
'rpt.delete': 'Eliminar',
'rpt.summary': 'Resumen',
'rpt.passed': 'Aprobado',
'rpt.findings': 'Hallazgos',
'rpt.recommendations': 'Recomendaciones',
'rpt.selectTenancy': 'Seleccione una tenencia',
'rpt.selectCredential': 'Seleccione una credencial',
'rpt.loadingReports': 'Cargando reportes...',
'rpt.refresh': 'Actualizar',
'rpt.pageTitle': 'Reportes CIS',
'rpt.pageSubtitle': 'CIS Benchmark 3.0 — Escaneo de Cumplimiento y Dashboard',
'rpt.runScanCis': 'Ejecutar Escaneo CIS',
'rpt.inExecution': 'En ejecucion',
'rpt.configParams': 'Configure los parametros y ejecute el escaneo de cumplimiento CIS.',
'rpt.ociCredential': 'Credencial OCI',
'rpt.selectOciCred': 'Seleccione credencial OCI...',
'rpt.cisLevel': 'Nivel CIS',
'rpt.level1Desc': 'Verificaciones esenciales de seguridad',
'rpt.level2Desc': 'Verificaciones completas (incluye Nivel 1)',
'rpt.regions': 'Regiones',
'rpt.leaveEmpty': 'Deje vacio para escanear todas las regiones suscritas',
'rpt.executeCompliance': 'Ejecutar Cumplimiento CIS',
'rpt.scanProgress': 'Progreso del Escaneo',
'rpt.hideLog': 'Ocultar log',
'rpt.showLog': 'Ver log completo',
'rpt.lines': 'lineas',
'rpt.cancelScan': 'Cancelar Escaneo',
'rpt.selectReport': 'Seleccionar Reporte',
'rpt.selectCompletedReport': 'Seleccione un reporte completado...',
'rpt.searchTenancy': 'Buscar tenencia...',
'rpt.searchByTenancy': 'Buscar por tenencia...',
'rpt.noCredentials': 'No se encontraron credenciales',
'rpt.allRegionsDefault': 'Todas las regiones (predeterminado)',
'rpt.searchRegion': 'Buscar region...',
'rpt.loadingDashboard': 'Cargando dashboard...',
'rpt.complianceScore': 'Puntuacion de Cumplimiento',
'rpt.compliant': 'Conforme',
'rpt.nonCompliant': 'No Conforme',
'rpt.ofTotal': 'del total',
'rpt.compliantItems': 'items conformes',
'rpt.totalFindings': 'hallazgos totales',
'rpt.evaluated': 'Evaluados',
'rpt.controlsVerified': 'Controles verificados',
'rpt.regionsCount': 'region(es)',
'rpt.complianceDist': 'Distribucion de Cumplimiento',
'rpt.findingsBySection': 'Hallazgos por Seccion',
'rpt.generatedAt': 'Generado el',
'rpt.htmlReport': 'Reporte HTML',
'rpt.files': 'Archivos',
'rpt.inSections': 'secciones',
'rpt.fullEmbedding': 'Embedding Completo',
'rpt.noActiveTables': 'Sin tablas activas',
'rpt.executionHistory': 'Historial de Ejecuciones',
'rpt.reports': 'reporte(s)',
'rpt.loading': 'Cargando...',
'rpt.firstScanHint': 'Ejecute el primer escaneo CIS arriba.',
'rpt.selected': 'Seleccionado',
'rpt.deleteConfirm': 'Eliminar este reporte?',
'rpt.deleteFailed': 'Error al eliminar',
'rpt.selectOciCredError': 'Seleccione una credencial OCI',
'rpt.alreadyRunning': 'Ya hay un escaneo en ejecucion',
'rpt.errorLoadReports': 'Error al cargar reportes',
'rpt.errorStartScan': 'Error al iniciar escaneo',
'rpt.selectAdb': 'Seleccione una conexion ADB.',
'rpt.selectTable': 'Seleccione una tabla.',
'rpt.errorEmbedding': 'Error al generar embedding',
'rpt.cancelled': 'Cancelado',
'rpt.completedAt': 'Completado:',
'rpt.complianceGenerating': 'Generando Reporte de Cumplimiento...',
'rpt.complianceFetchingKB': 'Obteniendo remediaciones de la Base de Conocimiento CIS',
'rpt.complianceNotGenerated': 'Reporte aun no generado',
'rpt.complianceGenerate': 'Generar Reporte de Cumplimiento',
'rpt.complianceRegenerate': 'Regenerar',
'rpt.complianceDownloadZip': 'Descargar ZIP',
'rpt.complianceRemediation': '+ Remediacion',
'rpt.complianceGeneratingShort': 'Generando...',
'rpt.complianceTitle': 'LAD A-Team CIS Compliance Report',
// ── OCI Config ──
'oci.title': 'Credenciales OCI',
'oci.subtitle': 'Administre las credenciales de Oracle Cloud Infrastructure',
'oci.registered': 'Credenciales Registradas',
'oci.credential': 'credencial',
'oci.credentials': 'credenciales',
'oci.noCredentials': 'No hay credenciales registradas.',
'oci.edit': 'Editar',
'oci.test': 'Probar',
'oci.delete': 'Eliminar',
'oci.yes': 'Si',
'oci.no': 'No',
'oci.newCredential': 'Nueva Credencial',
'oci.editCredential': 'Editar Credencial',
'oci.addDesc': 'Agregue las credenciales de su cuenta OCI para ejecutar reportes y acceder a servicios.',
'oci.editing': 'Editando:',
'oci.tenancyName': 'Nombre de Tenencia',
'oci.ocidTenancy': 'OCID Tenencia',
'oci.region': 'Region',
'oci.selectRegion': 'Seleccione region...',
'oci.searchRegion': 'Buscar region...',
'oci.noRegion': 'No se encontraron regiones',
'oci.compartment': 'OCID Compartimiento',
'oci.ocidUser': 'OCID Usuario',
'oci.fingerprint': 'Fingerprint',
'oci.sessionToken': 'Token de Sesion',
'oci.sessionTokenHint': 'Pegue el contenido del archivo de token generado por',
'oci.sessionKeyLabel': 'Clave de Sesion (.pem)',
'oci.privateKey': 'Clave Privada (.pem)',
'oci.publicKey': 'Clave Publica (.pem)',
'oci.keyPassphrase': 'Frase de Paso de la Clave',
'oci.keyPassphraseHint': 'Solo si la clave privada esta protegida con contrasena',
'oci.sshPublicKey': 'Clave Publica SSH',
'oci.sshPublicKeyHint': 'Clave SSH para acceso a instancias de computo (ej. ssh-rsa AAAA...)',
'oci.saveCredential': 'Guardar Credencial',
'oci.saveChanges': 'Guardar Cambios',
'oci.cancel': 'Cancelar',
'oci.saving': 'Guardando credencial...',
'oci.saved': 'Credencial guardada exitosamente!',
'oci.updated': 'Credencial actualizada exitosamente!',
'oci.deleting': 'Eliminando credencial...',
'oci.deleted': 'Credencial eliminada exitosamente!',
'oci.fillField': 'Complete',
'oci.fillOcidTenancy': 'Complete OCID Tenencia',
'oci.fillUserAndFP': 'Complete OCID Usuario y Fingerprint',
'oci.pasteToken': 'Pegue el Token de Sesion',
'oci.selectKey': 'Seleccione la clave privada (.pem)',
'oci.selectSessionKey': 'Seleccione la clave de sesion (.pem)',
'oci.changeHint': 'Complete para cambiar. Actual:',
'oci.keepKey': 'Deje vacio para mantener la clave actual',
'oci.typeCannotChange': '(el tipo no se puede cambiar)',
'oci.sshNotConfigured': 'no configurado',
// ── GenAI Config ──
'genai.title': 'Modelos GenAI',
'genai.subtitle': 'Configure modelos de inteligencia artificial generativa para el Chat Agent',
'genai.configured': 'Modelos Configurados',
'genai.model': 'modelo',
'genai.models': 'modelos',
'genai.noModels': 'No hay modelos configurados.',
'genai.newModel': 'Nuevo Modelo GenAI',
'genai.editModel': 'Editar Modelo',
'genai.configName': 'Nombre de Configuracion',
'genai.ociCredential': 'Credencial OCI',
'genai.modelSelect': 'Modelo',
'genai.genaiRegion': 'Region GenAI',
'genai.compartment': 'OCID Compartimiento',
'genai.modelOcid': 'OCID del Modelo',
'genai.modelOcidHint': 'Pegue el OCID del modelo desde el portal OCI (opcional)',
'genai.customOcid': 'Personalizado (usar OCID)',
'genai.default': 'Modelo Predeterminado',
'genai.setDefault': 'Establecer como Predeterminado',
'genai.saveModel': 'Guardar Modelo',
'genai.saveChanges': 'Guardar Cambios',
'genai.cancel': 'Cancelar',
'genai.saving': 'Guardando modelo...',
'genai.deleting': 'Eliminando modelo...',
'genai.addDesc': 'Agregue modelos personalizados o cree presets con credenciales especificas.',
'genai.fillName': 'Complete el nombre de configuracion',
'genai.selectOci': 'Seleccione una credencial OCI',
'genai.fillOcid': 'Ingrese el OCID del modelo para modelo personalizado',
// ── MCP Servers ──
'mcp.title': 'Servidores MCP',
'mcp.subtitle': 'Servidores MCP para integracion de herramientas y ejecucion de tareas',
'mcp.registered': 'Servidores Registrados',
'mcp.noServers': 'No hay servidores MCP registrados.',
'mcp.edit': 'Editar',
'mcp.activate': 'Activar',
'mcp.deactivate': 'Desactivar',
'mcp.active': 'Activo',
'mcp.inactive': 'Inactivo',
'mcp.delete': 'Eliminar',
'mcp.yes': 'Si',
'mcp.no': 'No',
'mcp.command': 'Comando:',
'mcp.url': 'URL:',
'mcp.module': 'Modulo:',
'mcp.adb': 'ADB:',
'mcp.noAdb': 'Ninguno',
'mcp.tools': 'Herramientas',
'mcp.noTools': 'Sin herramientas registradas',
'mcp.discoverTools': 'Descubrir Herramientas',
'mcp.discovering': 'Conectando al servidor MCP para descubrir herramientas...',
'mcp.newServer': 'Registrar Nuevo Servidor',
'mcp.editServer': 'Editar Servidor',
'mcp.name': 'Nombre',
'mcp.description': 'Descripcion',
'mcp.serverType': 'Tipo de Servidor',
'mcp.commandLabel': 'Comando',
'mcp.commandHint': 'Comando para iniciar el servidor',
'mcp.argsLabel': 'Argumentos (JSON)',
'mcp.argsHint': 'Arreglo de argumentos pasados al comando',
'mcp.urlLabel': 'URL del Servidor',
'mcp.urlHint': 'Endpoint SSE del servidor MCP',
'mcp.linkAdb': 'Vincular ADB Vector',
'mcp.linkAdbHint': 'Opcional — el servidor MCP puede usar esta base de datos como herramienta.',
'mcp.saveServer': 'Registrar Servidor',
'mcp.saveChanges': 'Guardar Cambios',
'mcp.cancel': 'Cancelar',
'mcp.saving': 'Registrando servidor...',
'mcp.updating': 'Actualizando servidor...',
'mcp.fillName': 'Complete el nombre del servidor',
'mcp.invalidArgs': 'Los argumentos deben ser un arreglo JSON valido',
'mcp.configDesc': 'Configure un nuevo servidor MCP. Los campos se ajustan segun el tipo seleccionado.',
// ── ADB Config ──
'adb.title': 'Conexiones ADB',
'adb.subtitle': 'Conexiones a Oracle Autonomous Database via Wallet (mTLS)',
'adb.registered': 'Conexiones Registradas',
'adb.connection': 'conexion',
'adb.connections': 'conexiones',
'adb.noConnections': 'No hay conexiones ADB registradas.',
'adb.edit': 'Editar',
'adb.test': 'Probar',
'adb.delete': 'Eliminar',
'adb.yes': 'Si',
'adb.no': 'No',
'adb.newConnection': 'Nueva Conexion',
'adb.editConnection': 'Editar Conexion',
'adb.connName': 'Nombre de Conexion',
'adb.walletZip': 'Wallet ZIP',
'adb.walletHint': 'Suba el wallet para extraer DSNs de tnsnames.ora',
'adb.walletEditHint': 'Suba un nuevo wallet o deje vacio para mantener el actual',
'adb.analyze': 'Analizar',
'adb.dsn': 'DSN (Nombre TNS)',
'adb.dsnHint': 'Seleccione o escriba el DSN. Ej. myatp_high',
'adb.username': 'Usuario',
'adb.password': 'Contrasena',
'adb.walletPassword': 'Contrasena del Wallet',
'adb.genaiConfig': 'Configuracion GenAI (Embeddings)',
'adb.genaiConfigHint': 'Credenciales OCI utilizadas para generar embeddings via GenAI.',
'adb.noRag': 'Ninguno (sin RAG)',
'adb.embeddingModel': 'Modelo de Embedding',
'adb.embeddingModelHint': 'Modelo OCI GenAI para generar vectores.',
'adb.saveConnection': 'Guardar Conexion',
'adb.saveChanges': 'Guardar Cambios',
'adb.cancel': 'Cancelar',
'adb.saving': 'Guardando conexion...',
'adb.deleting': 'Eliminando conexion...',
'adb.updateWallet': 'Actualizar Wallet',
'adb.adbConnection': 'Conexion ADB',
'adb.uploadWallet': 'Subir Wallet',
'adb.connDesc': 'Conexion via python-oracledb modo Thin con Wallet (mTLS) a Oracle Autonomous Database.',
'adb.selectWallet': 'Seleccione un archivo ZIP de wallet.',
'adb.selectConnection': 'Seleccione una conexion ADB.',
'adb.fillName': 'Complete el nombre de la conexion',
'adb.fillDsn': 'Complete el DSN',
'adb.fillUsername': 'Complete el usuario',
'adb.fillPassword': 'Complete la contrasena',
// ── Embeddings ──
'emb.title': 'Embeddings',
'emb.subtitle': 'Base de conocimiento vectorial — carga, importacion y gestion',
'emb.noAdb': 'No hay conexion ADB configurada.',
'emb.noAdbHint': 'Configure en ADB Vector.',
'emb.noOci': 'No hay credencial OCI configurada.',
'emb.noOciHint': 'Configure en Config → OCI.',
'emb.existing': 'Embeddings Existentes',
'emb.existingDesc': 'Consulte documentos almacenados en las tablas de embedding de ADB.',
'emb.adbConnection': 'Conexion ADB',
'emb.table': 'Tabla',
'emb.load': 'Cargar',
'emb.noEmbeddings': 'No se encontraron embeddings.',
'emb.total': 'Total: {0} documentos',
'emb.cisTitle': 'Recomendaciones CIS',
'emb.cisDesc': 'Suba la ultima version del PDF para mantener actualizadas las recomendaciones CIS de Oracle Cloud en la base de datos vectorial.',
'emb.pdfFile': 'Archivo PDF',
'emb.uploadCis': 'Subir Recomendaciones CIS',
'emb.kbTitle': 'Base de Conocimiento',
'emb.kbDesc': 'Suba documentos para alimentar la base de conocimiento del equipo. Los archivos seran vectorizados y estaran disponibles para consultas en el chat.',
'emb.fileLabel': 'Archivo (.txt, .pdf, .csv, .json, .md)',
'emb.uploadFile': 'Subir Archivo',
'emb.urlLabel': 'URL (pagina web o PDF)',
'emb.importUrl': 'Importar URL',
'emb.purgeTitle': 'Purgar y Re-embeber',
'emb.purgeDesc': 'Limpie los embeddings de una tabla para liberar espacio o preparar para re-ingestion. Esta accion es irreversible.',
'emb.tenancyOptional': 'Tenencia (opcional)',
'emb.purge': 'Purgar',
'emb.confirm': 'Confirmar',
'emb.selectTable': 'Seleccione una tabla.',
'emb.selectAdbAndFile': 'Seleccione la conexion ADB y el archivo.',
'emb.selectAdbAndUrl': 'Seleccione la conexion ADB e ingrese la URL.',
'emb.processing': 'Procesando PDF y generando embeddings...',
'emb.uploading': 'Subiendo y generando embeddings...',
'emb.fetchingUrl': 'Obteniendo contenido y generando embeddings...',
'emb.purging': 'Limpiando embeddings...',
// ── EmbConsult ──
'ec.title': 'Consultar Embeddings',
'ec.subtitle': 'Consulte datos almacenados en bases de datos vectoriales',
'ec.clear': 'Limpiar',
'ec.tableOptional': 'Tabla (opcional)',
'ec.allTables': 'Todas las tablas',
'ec.emptyTitle': 'Realice una consulta para buscar embeddings.',
'ec.emptyHint': 'Ej. "hallazgos para CIS 1.1", "recomendaciones de red", "como corregir MFA"',
'ec.searching': 'Consultando bases de datos vectoriales...',
'ec.placeholder': 'Escriba su consulta...',
'ec.submit': 'Consultar',
'ec.sources': 'Fuentes consultadas',
'ec.noAnswer': 'Sin respuesta del modelo.',
// ── Users ──
'usr.title': 'Usuarios',
'usr.activeCount': 'activo',
'usr.activesCount': 'activos',
'usr.inactiveCount': 'inactivo',
'usr.inactivesCount': 'inactivos',
'usr.total': 'total',
'usr.newUser': 'Nuevo Usuario',
'usr.cancel': 'Cancelar',
'usr.createUser': 'Crear Nuevo Usuario',
'usr.firstName': 'Nombre',
'usr.lastName': 'Apellido',
'usr.username': 'Usuario',
'usr.email': 'Correo Electronico',
'usr.password': 'Contrasena',
'usr.role': 'Rol',
'usr.creating': 'Creando...',
'usr.create': 'Crear Usuario',
'usr.noUsers': 'No hay usuarios registrados',
'usr.noUsersHint': 'Haga clic en "Nuevo Usuario" para crear el primer usuario.',
'usr.name': 'Nombre',
'usr.mfa': 'MFA',
'usr.status': 'Estado',
'usr.lastLogin': 'Ultimo Ingreso',
'usr.actions': 'Acciones',
'usr.active': 'Activo',
'usr.inactive': 'Inactivo',
'usr.never': 'Nunca',
'usr.confirm': 'Confirmar',
'usr.deactivate': 'Desactivar usuario',
'usr.created': 'Usuario creado exitosamente!',
'usr.roleUpdated': 'Rol actualizado',
'usr.deactivated': 'Usuario desactivado',
'usr.requiredName': 'Nombre y apellido son requeridos',
'usr.requiredUsername': 'El usuario es requerido',
'usr.requiredPassword': 'La contrasena es requerida',
// ── MFA ──
'mfa.title': 'Autenticacion MFA (TOTP)',
'mfa.subtitle': 'Proteja su cuenta con autenticacion de dos factores via Google Authenticator o Authy.',
'mfa.enabled': 'MFA esta habilitado',
'mfa.disabled': 'MFA esta deshabilitado',
'mfa.enabledDesc': 'Su cuenta esta protegida con autenticacion de dos factores.',
'mfa.disabledDesc': 'Habilite MFA para agregar una capa adicional de seguridad.',
'mfa.disableHint': 'Para deshabilitar MFA, haga clic en el boton de abajo. Esto reducira la seguridad de su cuenta.',
'mfa.disableBtn': 'Deshabilitar MFA',
'mfa.confirmDisable': 'Esta seguro de que desea deshabilitar MFA para su cuenta?',
'mfa.scanQr': 'Escanee el Codigo QR o copie el secreto',
'mfa.openApp': 'Abra su aplicacion de autenticacion (Google Authenticator, Authy, etc.)',
'mfa.enterCode': 'Ingrese el codigo de su aplicacion',
'mfa.activateMfa': 'Activar MFA',
'mfa.generateSecret': 'Generar Secreto',
'mfa.generateHint': 'Haga clic en el boton de abajo para generar un secreto TOTP. Necesitara una aplicacion de autenticacion para escanear el Codigo QR.',
'mfa.activated': 'MFA activado exitosamente!',
'mfa.deactivated': 'MFA desactivado.',
'mfa.invalidCode': 'Ingrese un codigo de 6 digitos',
'mfa.configure': 'Configurar',
// ── Audit ──
'audit.title': 'Registro de Auditoria',
'audit.subtitle': 'Monitoreo de actividad del sistema, configuraciones y sesiones de chat.',
'audit.tabAudit': 'Registro de Auditoria',
'audit.tabConfig': 'Logs de Configuracion',
'audit.tabChat': 'Logs de Chat',
'audit.records': 'registros',
'audit.refresh': 'Actualizar',
'audit.all': 'Todos',
'audit.errors': 'Errores',
'audit.success': 'Exitoso',
'audit.info': 'Info',
'audit.loadingLogs': 'Cargando logs...',
'audit.noAudit': 'Sin registros de auditoria.',
'audit.noConfig': 'Sin logs de configuracion registrados.',
'audit.noChat': 'Sin logs de chat registrados.',
'audit.date': 'Fecha',
'audit.user': 'Usuario',
'audit.action': 'Accion',
'audit.resource': 'Recurso',
'audit.details': 'Detalles',
'audit.ip': 'IP',
'audit.config': 'Configuracion',
'audit.status': 'Estado',
'audit.message': 'Mensaje',
'audit.session': 'Sesion',
'audit.source': 'Fuente',
// ── Hardcoded fixes ──
'common.errorPrefix': 'Error: ',
'common.errorSave': 'Error al guardar',
'common.errorDelete': 'Error al eliminar',
'common.errorUnknown': 'Error desconocido',
'common.errorLoad': 'Error al cargar',
'oci.thTenancy': 'Tenencia',
'oci.thType': 'Tipo',
'oci.thRegion': 'Region',
'oci.thDetails': 'Detalles',
'oci.thActions': 'Acciones',
'genai.thConfig': 'Configuracion',
'genai.thModel': 'Modelo',
'genai.thOciConfig': 'Configuracion OCI',
'genai.thRegion': 'Region',
'genai.thActions': 'Acciones',
'genai.savedSuccess': 'Modelo guardado exitosamente!',
'genai.updatedSuccess': 'Modelo actualizado exitosamente!',
'genai.deletedSuccess': 'Modelo eliminado exitosamente!',
'genai.editingLabel': 'Editando:',
'genai.regionFilled': 'Region y Compartimiento completados desde <strong>{0}</strong>',
'mcp.savedSuccess': 'Servidor registrado exitosamente!',
'mcp.updatedSuccess': 'Servidor actualizado exitosamente!',
'mcp.deletingServer': 'Eliminando servidor...',
'mcp.deletedSuccess': 'Servidor eliminado exitosamente!',
'mcp.discoveredTools': '{0} herramienta(s) descubierta(s), {1} total',
'mcp.errorDiscoverTools': 'Error al descubrir herramientas',
'mcp.removeToolConfirm': 'Eliminar herramienta "{0}"?',
'mcp.editingLabel': 'Editando:',
'adb.thName': 'Nombre',
'adb.thDsn': 'DSN',
'adb.thUser': 'Usuario',
'adb.thTables': 'Tablas',
'adb.thMtls': 'mTLS',
'adb.thWallet': 'Wallet',
'adb.thEmbed': 'Embed',
'adb.thActions': 'Acciones',
'adb.thTable': 'Tabla',
'adb.thDescription': 'Descripcion',
'adb.thActive': 'Activo',
'adb.thTableActions': 'Acciones',
'adb.walletParsed': 'Wallet analizado! {0} DSN(s): <strong>{1}</strong>. Archivos: {2}',
'adb.savedSuccess': 'Conexion guardada!',
'adb.updatedSuccess': 'Conexion actualizada!',
'adb.walletIncluded': ' Wallet incluido.',
'adb.deletedSuccess': 'Conexion eliminada exitosamente!',
'adb.walletSending': 'Enviando wallet...',
'adb.walletSent': 'Wallet subido! Archivos: {0}',
'adb.enterTableName': 'Ingrese el nombre de la tabla.',
'adb.tableRegistered': 'Tabla {0} registrada!',
'adb.confirmRemoveTable': 'Eliminar esta tabla del registro?',
'adb.tableRemoved': 'Tabla eliminada.',
'adb.editingLabel': 'Editando:',
'emb.errorLoadEmb': 'Error al cargar embeddings',
'emb.confirmDeleteEmb': 'Eliminar este embedding?',
'emb.totalDocs': 'Total: {0} documentos',
'ec.errorPrefix': 'Error: ',
'ec.errorUnknown': 'Error desconocido',
'ec.docs': 'documentos',
'usr.errorLoadUsers': 'Error al cargar usuarios',
'usr.errorCreateUser': 'Error al crear usuario',
'usr.errorUpdateRole': 'Error al actualizar rol',
'usr.errorDeactivate': 'Error al desactivar usuario',
'usr.mfaSelfOnly': 'MFA solo puede ser activado por el propio usuario.',
'usr.myTimezone': 'Mi Zona Horaria',
'ws.typeDestroyLabel': 'Escriba <strong>DESTROY</strong> para confirmar:',
'exp.tempAccess': 'Acceso temporal - ',
'exp.optional': 'Opcional',
'exp.ipPrompt': 'IP para permitir acceso (CIDR):',
'exp.nsgType': 'Tipo',
// ── Common ──
'common.save': 'Guardar',
'common.cancel': 'Cancelar',
'common.delete': 'Eliminar',
'common.edit': 'Editar',
'common.create': 'Crear',
'common.update': 'Actualizar',
'common.close': 'Cerrar',
'common.confirm': 'Confirmar',
'common.back': 'Volver',
'common.search': 'Buscar',
'common.refresh': 'Actualizar',
'common.download': 'Descargar',
'common.upload': 'Subir',
'common.copy': 'Copiar',
'common.retry': 'Reintentar',
'common.send': 'Enviar',
'common.clear': 'Limpiar',
'common.test': 'Probar',
'common.yes': 'Si',
'common.no': 'No',
'common.loading': 'Cargando...',
'common.saving': 'Guardando...',
'common.select': 'Seleccione...',
'common.noData': 'No se encontraron datos',
'common.error': 'Error',
'common.success': 'Exitoso',
// ── My Settings ──
'settings.title': 'Mi Configuracion',
'settings.timezone': 'Zona Horaria',
'settings.tzDesc': 'Establezca su zona horaria para la visualizacion de fecha y hora.',
'settings.tzChanged': 'Zona horaria cambiada exitosamente',
'settings.language': 'Idioma',
'settings.langDesc': 'Seleccione el idioma de la interfaz.',
'settings.changePassword': 'Cambiar Contrasena',
'settings.currentPw': 'Contrasena Actual',
'settings.newPw': 'Nueva Contrasena',
'settings.confirmPw': 'Confirmar Nueva Contrasena',
'settings.savePw': 'Cambiar Contrasena',
'settings.saving': 'Guardando...',
'settings.pwChanged': 'Contrasena cambiada exitosamente',
'settings.pwRequired': 'Complete todos los campos',
'settings.pwMismatch': 'Las contrasenas no coinciden',
'settings.pwMinLength': 'La nueva contrasena debe tener al menos 8 caracteres',
'settings.federatedInfo': 'La contrasena y MFA son administrados por Oracle IAM Identity Domain. Configure directamente en el portal OCI.',
'settings.mfa': 'Autenticacion Multi-Factor (MFA)',
// ── Terminal ──
'term.title': 'OCI CLI Terminal',
'term.subtitle': 'Ejecute comandos OCI CLI directamente en el navegador',
'term.config': 'Configuración',
'term.selectConfig': 'Seleccione una configuración OCI',
'term.welcome': 'Escriba comandos OCI CLI. Ej: oci iam user list --compartment-id <ocid>',
'term.clearHint': 'para limpiar',
'term.historyHint': 'para navegar en el historial',
'term.executing': 'Ejecutando...',
'term.noOutput': 'Comando ejecutado sin output',
// ── Oracle IAM ──
'iam.title': 'Oracle IAM Identity Domains',
'iam.subtitle': 'Integracion OIDC para autenticacion corporativa',
'iam.authMode': 'Modo de Autenticacion',
'iam.authModeDesc': 'Define como los usuarios se autentican en el sistema.',
'iam.modeLocal': 'Local',
'iam.modeLocalDesc': 'Usuarios locales con contrasena',
'iam.modeOidc': 'Oracle IAM',
'iam.modeOidcDesc': 'Solo SSO',
'iam.modeBoth': 'Ambos',
'iam.modeBothDesc': 'Local + Oracle IAM',
'iam.oidcConfig': 'Configuracion OIDC',
'iam.testConnection': 'Probar Conexion',
'iam.issuerUrl': 'URL del Emisor (Identity Domain)',
'iam.clientId': 'Client ID',
'iam.clientSecret': 'Client Secret',
'iam.redirectUri': 'URI de Redireccion',
'iam.groupMapping': 'Mapeo de Grupos',
'iam.groupMappingDesc': 'Mapee los grupos de OCI Identity Domain a roles del sistema.',
'iam.save': 'Guardar Configuracion',
'iam.saving': 'Guardando...',
'iam.saved': 'Configuracion guardada exitosamente',
'iam.issuerRequired': 'La URL del emisor es requerida',
'iam.testOk': 'Conexion OK',
'iam.testInvalid': 'Respuesta invalida del endpoint de descubrimiento',
'iam.testFail': 'Fallo en la conexion',
};
export default es;

View File

@@ -1,15 +1,19 @@
import { create } from 'zustand'; import { create } from 'zustand';
import pt from './pt'; import pt from './pt';
import en from './en'; import en from './en';
import es from './es';
type Lang = 'pt' | 'en'; type Lang = 'pt' | 'en' | 'es';
const LANGS: Record<Lang, Record<string, string>> = { pt, en }; const LANGS: Record<Lang, Record<string, string>> = { pt, en, es };
function detectLang(): Lang { function detectLang(): Lang {
const saved = localStorage.getItem('lang') as Lang | null; const saved = localStorage.getItem('lang') as Lang | null;
if (saved && LANGS[saved]) return saved; if (saved && LANGS[saved]) return saved;
return navigator.language.startsWith('pt') ? 'pt' : 'en'; const nav = navigator.language.toLowerCase();
if (nav.startsWith('pt')) return 'pt';
if (nav.startsWith('es')) return 'es';
return 'en';
} }
interface I18nState { interface I18nState {

View File

@@ -20,6 +20,7 @@ const pt: Record<string, string> = {
'nav.adb': 'ADB Vector', 'nav.adb': 'ADB Vector',
'nav.embeddings': 'Embeddings', 'nav.embeddings': 'Embeddings',
'nav.embConsult': 'Consultar Embeddings', 'nav.embConsult': 'Consultar Embeddings',
'nav.terminal': 'Terminal',
'nav.userMgmt': 'Gestão de Usuários', 'nav.userMgmt': 'Gestão de Usuários',
'nav.users': 'Usuários', 'nav.users': 'Usuários',
'nav.mySettings': 'Minhas Configurações', 'nav.mySettings': 'Minhas Configurações',
@@ -756,6 +757,8 @@ const pt: Record<string, string> = {
'settings.timezone': 'Timezone', 'settings.timezone': 'Timezone',
'settings.tzDesc': 'Define o fuso horário para exibição de datas e horários.', 'settings.tzDesc': 'Define o fuso horário para exibição de datas e horários.',
'settings.tzChanged': 'Timezone alterado com sucesso', 'settings.tzChanged': 'Timezone alterado com sucesso',
'settings.language': 'Idioma',
'settings.langDesc': 'Selecione o idioma da interface.',
'settings.changePassword': 'Alterar Senha', 'settings.changePassword': 'Alterar Senha',
'settings.currentPw': 'Senha Atual', 'settings.currentPw': 'Senha Atual',
'settings.newPw': 'Nova Senha', 'settings.newPw': 'Nova Senha',
@@ -768,6 +771,16 @@ const pt: Record<string, string> = {
'settings.pwMinLength': 'A nova senha deve ter pelo menos 8 caracteres', 'settings.pwMinLength': 'A nova senha deve ter pelo menos 8 caracteres',
'settings.federatedInfo': 'Senha e MFA são gerenciados pelo Oracle IAM Identity Domain. Configure diretamente no portal OCI.', 'settings.federatedInfo': 'Senha e MFA são gerenciados pelo Oracle IAM Identity Domain. Configure diretamente no portal OCI.',
'settings.mfa': 'Autenticação Multi-Fator (MFA)', 'settings.mfa': 'Autenticação Multi-Fator (MFA)',
// ── Terminal ──
'term.title': 'OCI CLI Terminal',
'term.subtitle': 'Execute comandos OCI CLI diretamente no navegador',
'term.config': 'Configuração',
'term.selectConfig': 'Selecione uma configuração OCI',
'term.welcome': 'Digite comandos OCI CLI. Ex: oci iam user list --compartment-id <ocid>',
'term.clearHint': 'para limpar',
'term.historyHint': 'para navegar no histórico',
'term.executing': 'Executando...',
'term.noOutput': 'Comando executado sem output',
// ── Oracle IAM ── // ── Oracle IAM ──
'iam.title': 'Oracle IAM Identity Domains', 'iam.title': 'Oracle IAM Identity Domains',
'iam.subtitle': 'Integração OIDC para autenticação corporativa', 'iam.subtitle': 'Integração OIDC para autenticação corporativa',

View File

@@ -0,0 +1,339 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Terminal, Loader2, ChevronDown } from 'lucide-react';
import client from '@/api/client';
import { useI18n } from '@/i18n';
interface TermLine {
type: 'input' | 'output' | 'error' | 'info';
text: string;
}
interface OciCfg {
id: string;
tenancy_name: string;
}
export default function TerminalPage() {
const { t } = useI18n();
const [configs, setConfigs] = useState<OciCfg[]>([]);
const [selectedCfg, setSelectedCfg] = useState('');
const [command, setCommand] = useState('');
const [lines, setLines] = useState<TermLine[]>([]);
const [running, setRunning] = useState(false);
const [cmdHistory, setCmdHistory] = useState<string[]>([]);
const [histIdx, setHistIdx] = useState(-1);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [tabCount, setTabCount] = useState(0);
const outputRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
client.get('/oci/configs').then((data: any) => {
setConfigs(data);
if (data.length > 0 && !selectedCfg) setSelectedCfg(data[0].id);
}).catch(() => {});
}, []);
useEffect(() => {
if (outputRef.current) outputRef.current.scrollTop = outputRef.current.scrollHeight;
}, [lines]);
useEffect(() => {
if (selectedCfg) {
client.get(`/terminal/history?oci_config_id=${selectedCfg}&limit=100`).then((data: any) => {
setCmdHistory(data.map((h: any) => h.command).reverse());
}).catch(() => {});
}
}, [selectedCfg]);
const addLine = useCallback((type: TermLine['type'], text: string) => {
setLines(prev => [...prev, { type, text }]);
}, []);
const execute = useCallback(async () => {
const cmd = command.trim();
if (!cmd) return;
if (!selectedCfg) { addLine('error', t('term.selectConfig')); return; }
// Built-in commands
if (cmd === 'clear') { setLines([]); setCommand(''); return; }
if (cmd === 'history') {
addLine('input', `$ ${cmd}`);
cmdHistory.forEach((h, i) => addLine('output', ` ${i + 1} ${h}`));
setCommand('');
return;
}
if (cmd === 'help') {
addLine('input', `$ ${cmd}`);
addLine('info', 'OCI CLI Terminal — Comandos disponíveis:');
addLine('info', ' oci <service> <resource> <action> [options] Executar comando OCI CLI');
addLine('info', ' Tab Autocomplete');
addLine('info', ' ↑ / ↓ Navegar histórico');
addLine('info', ' clear Limpar terminal');
addLine('info', ' history Ver histórico');
addLine('info', ' help Este menu');
setCommand('');
return;
}
addLine('input', `$ ${cmd}`);
setCommand('');
setCmdHistory(prev => [...prev, cmd]);
setHistIdx(-1);
setSuggestions([]);
setRunning(true);
try {
const resp = await client.post('/terminal/execute', {
oci_config_id: selectedCfg,
command: cmd,
}) as any;
if (resp.output) addLine('output', resp.output);
if (resp.error) addLine('error', resp.error);
if (!resp.output && !resp.error) addLine('info', '(no output)');
} catch (err: any) {
addLine('error', err?.message || err?.detail || String(err));
} finally {
setRunning(false);
inputRef.current?.focus();
}
}, [command, selectedCfg, addLine, cmdHistory, t]);
const handleTab = useCallback(async () => {
if (!command.trim() || running) return;
try {
const resp = await client.get(`/terminal/completions?prefix=${encodeURIComponent(command)}`) as any;
const sugs: string[] = resp.suggestions || [];
if (sugs.length === 0) return;
if (sugs.length === 1) {
// Single match — auto-complete
const parts = command.trimEnd().split(' ');
if (command.endsWith(' ')) {
setCommand(command + sugs[0] + ' ');
} else {
parts[parts.length - 1] = sugs[0];
setCommand(parts.join(' ') + ' ');
}
setSuggestions([]);
setTabCount(0);
} else {
// Multiple matches — show them
if (tabCount > 0) {
// Second Tab — display all
addLine('input', `$ ${command}`);
addLine('info', sugs.join(' '));
}
setSuggestions(sugs);
setTabCount(prev => prev + 1);
// Find common prefix
let common = sugs[0];
for (const s of sugs) {
while (!s.startsWith(common)) common = common.slice(0, -1);
}
if (common) {
const parts = command.trimEnd().split(' ');
const last = parts[parts.length - 1];
if (command.endsWith(' ')) {
if (common.length > 0) setCommand(command + common);
} else if (common.length > last.length) {
parts[parts.length - 1] = common;
setCommand(parts.join(' '));
}
}
}
} catch {
// ignore
}
}, [command, running, tabCount, addLine]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Tab') {
e.preventDefault();
handleTab();
return;
}
setTabCount(0);
setSuggestions([]);
if (e.key === 'Enter' && !running) {
execute();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (cmdHistory.length > 0) {
const newIdx = histIdx < cmdHistory.length - 1 ? histIdx + 1 : histIdx;
setHistIdx(newIdx);
setCommand(cmdHistory[cmdHistory.length - 1 - newIdx] || '');
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (histIdx > 0) {
setHistIdx(histIdx - 1);
setCommand(cmdHistory[cmdHistory.length - histIdx] || '');
} else {
setHistIdx(-1);
setCommand('');
}
} else if (e.key === 'l' && e.ctrlKey) {
e.preventDefault();
setLines([]);
} else if (e.key === 'c' && e.ctrlKey && running) {
addLine('error', '^C');
}
};
const cfgName = configs.find(c => c.id === selectedCfg)?.tenancy_name || '';
const prompt = `agent@${cfgName || 'oci'}:~$ `;
return (
<div className="page" style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 8 }}>
{/* Header bar */}
<div className="flex items-center gap-3" style={{ flexShrink: 0 }}>
<div className="flex items-center gap-2 px-3 py-2 rounded-lg" style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}>
<Terminal size={16} style={{ color: 'var(--ac)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--t1)' }}>OCI CLI</span>
</div>
<div className="relative">
<select
value={selectedCfg}
onChange={e => { setSelectedCfg(e.target.value); setLines([]); }}
className="px-3 py-2 pr-8 rounded-lg text-sm font-medium outline-none cursor-pointer appearance-none"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 200 }}
>
<option value="">-- {t('term.selectConfig')} --</option>
{configs.map(c => (
<option key={c.id} value={c.id}>{c.tenancy_name}</option>
))}
</select>
<ChevronDown size={14} className="absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none" style={{ color: 'var(--t3)' }} />
</div>
<div className="spacer" />
<span className="text-[.65rem]" style={{ color: 'var(--t4)', fontFamily: 'monospace' }}>
Tab: autocomplete | : history | Ctrl+L: clear
</span>
</div>
{/* Terminal window */}
<div
className="flex-1 flex flex-col rounded-lg overflow-hidden"
style={{
background: '#0d1117',
border: '1px solid #30363d',
minHeight: 400,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Consolas', monospace",
fontSize: '0.8rem',
lineHeight: 1.5,
}}
onClick={() => inputRef.current?.focus()}
>
{/* Title bar */}
<div className="flex items-center gap-2 px-3 py-1.5" style={{ background: '#161b22', borderBottom: '1px solid #30363d' }}>
<div className="flex gap-1.5">
<div style={{ width: 10, height: 10, borderRadius: '50%', background: '#f85149' }} />
<div style={{ width: 10, height: 10, borderRadius: '50%', background: '#d29922' }} />
<div style={{ width: 10, height: 10, borderRadius: '50%', background: '#3fb950' }} />
</div>
<span style={{ color: '#8b949e', fontSize: '0.7rem', flex: 1, textAlign: 'center' }}>
agent@oci-cis-agent {cfgName || 'terminal'}
</span>
</div>
{/* Output */}
<div ref={outputRef} className="flex-1 overflow-y-auto px-3 py-2" style={{ scrollbarWidth: 'thin', scrollbarColor: '#30363d #0d1117' }}>
{/* MOTD */}
{lines.length === 0 && (
<div style={{ color: '#3fb950' }}>
<pre style={{ margin: 0, fontSize: '0.7rem', color: '#238636' }}>{`
___ ____ ___ ____ _ ___
/ _ \\ / ___|_ _| / ___| | |_ _|
| | | | | | | | | | | | |
| |_| | |___ | | | |___| |___ | |
\\___/ \\____|___| \\____|_____|___|
`}</pre>
<span style={{ color: '#8b949e' }}>Oracle Cloud Infrastructure CLI v3.x</span>
<br />
<span style={{ color: '#484f58' }}>Type <span style={{ color: '#58a6ff' }}>help</span> for available commands, <span style={{ color: '#58a6ff' }}>Tab</span> for autocomplete</span>
<br /><br />
</div>
)}
{lines.map((line, i) => (
<div key={i} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{line.type === 'input' && (
<span><span style={{ color: '#3fb950' }}>{prompt.split(':')[0]}:</span><span style={{ color: '#58a6ff' }}>~</span><span style={{ color: '#e6edf3' }}>$ </span><span style={{ color: '#e6edf3' }}>{line.text.replace(/^\$ /, '')}</span></span>
)}
{line.type === 'output' && (
<span style={{ color: '#c9d1d9' }}>{line.text}</span>
)}
{line.type === 'error' && (
<span style={{ color: '#f85149' }}>{line.text}</span>
)}
{line.type === 'info' && (
<span style={{ color: '#8b949e' }}>{line.text}</span>
)}
</div>
))}
{running && (
<div className="flex items-center gap-2" style={{ color: '#58a6ff' }}>
<Loader2 size={12} className="animate-spin" />
<span style={{ fontSize: '0.75rem' }}>{t('term.executing')}</span>
</div>
)}
{/* Autocomplete suggestions inline */}
{suggestions.length > 1 && !running && (
<div style={{ color: '#8b949e', margin: '2px 0' }}>
{suggestions.join(' ')}
</div>
)}
</div>
{/* Input line */}
<div className="flex items-center px-3 py-1.5" style={{ borderTop: '1px solid #21262d', background: '#0d1117' }}>
<span style={{ color: '#3fb950', whiteSpace: 'nowrap' }}>{prompt.split(':')[0]}:</span>
<span style={{ color: '#58a6ff' }}>~</span>
<span style={{ color: '#e6edf3', marginRight: 4 }}>$ </span>
<input
ref={inputRef}
type="text"
value={command}
onChange={e => { setCommand(e.target.value); setTabCount(0); setSuggestions([]); }}
onKeyDown={handleKeyDown}
disabled={running}
placeholder={running ? '' : ''}
autoFocus
spellCheck={false}
autoComplete="off"
autoCapitalize="off"
className="flex-1 outline-none"
style={{
background: 'transparent',
border: 'none',
color: '#e6edf3',
fontSize: 'inherit',
fontFamily: 'inherit',
lineHeight: 'inherit',
caretColor: '#e6edf3',
padding: 0,
}}
/>
{/* Blinking cursor when empty and not running */}
{!command && !running && (
<span style={{
display: 'inline-block', width: 7, height: 14,
background: '#e6edf3', marginLeft: -2,
animation: 'blink 1s step-end infinite',
}} />
)}
</div>
</div>
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import {
Settings, Clock, ShieldCheck, ShieldOff, KeyRound, Lock, Loader2, Settings, Clock, ShieldCheck, ShieldOff, KeyRound, Lock, Loader2,
CheckCircle, AlertTriangle, Eye, EyeOff, Save, CheckCircle, AlertTriangle, Eye, EyeOff, Save, Languages,
} from 'lucide-react'; } from 'lucide-react';
import client from '@/api/client'; import client from '@/api/client';
import { adminApi } from '@/api/endpoints/admin'; import { adminApi } from '@/api/endpoints/admin';
@@ -9,7 +9,7 @@ import { useAuthStore } from '@/stores/auth';
import { useI18n } from '@/i18n'; import { useI18n } from '@/i18n';
export default function MySettingsPage() { export default function MySettingsPage() {
const { t } = useI18n(); const { t, lang, setLang } = useI18n();
const { user: currentUser, checkAuth } = useAuthStore(); const { user: currentUser, checkAuth } = useAuthStore();
const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null); const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
@@ -177,6 +177,33 @@ export default function MySettingsPage() {
</select> </select>
</div> </div>
{/* Language */}
<div className="card">
<div className="card-header">
<div className="icon" style={{ background: 'var(--acl)' }}>
<Languages size={16} style={{ color: 'var(--ac)' }} />
</div>
<h2>{t('settings.language') || 'Idioma'}</h2>
</div>
<p className="text-xs mb-3" style={{ color: 'var(--t3)' }}>
{t('settings.langDesc') || 'Selecione o idioma da interface.'}
</p>
<div className="flex gap-2">
{([['pt', 'Português', '🇧🇷'], ['en', 'English', '🇺🇸'], ['es', 'Español', '🇪🇸']] as const).map(([code, label, flag]) => (
<button key={code} onClick={() => setLang(code as any)}
className="flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-all"
style={{
background: lang === code ? 'var(--acl)' : 'var(--bg2)',
border: `1.5px solid ${lang === code ? 'var(--ac)' : 'var(--bd)'}`,
color: lang === code ? 'var(--ac)' : 'var(--t3)',
}}>
<span className="text-base">{flag}</span>
{label}
</button>
))}
</div>
</div>
{/* Password Change — only for local users */} {/* Password Change — only for local users */}
{isLocal && ( {isLocal && (
<div className="card"> <div className="card">