feat: comprehensive security, performance, UX and deployment audit

Security:
- CORS restricted to explicit methods/headers, configurable via CORS_ORIGINS env
- Auth added to /reports/{rid}/html and /compliance-report endpoints
- Ownership check on report downloads
- Rate limiting on login (10 attempts/5min per IP with threading.Lock)
- Non-root container user (agent via gosu entrypoint)
- Nginx security headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy)
- .gitignore and .dockerignore for secret leak prevention
- .env.example with documentation

Performance:
- 16 SQLite indexes on foreign keys and frequently queried columns
- Pagination on chat messages (100), reports (50), audit log (100)
- subprocess.run wrapped in run_in_executor (3 async handlers)
- asyncio.wait_for timeouts on GenAI calls (300s) and Chromium PDF (120s)
- Thread pool reduced to 10 workers
- Code splitting with React.lazy (bundle: 1.3MB -> ~550KB initial)
- React.memo on TreeItem (recursive compartment tree)

Error handling:
- 13 bare except clauses replaced with logged Exception handlers
- Graceful shutdown handler (terminates subprocesses + executor)
- File upload validation (50MB max, extension whitelist per endpoint)
- Health check expanded (version, uptime, db_ok)
- Cache-Control on /api/genai/models (1h)
- Auto-cleanup audit_log > 30 days

Dead code removed:
- DownloadsPage.tsx, StubPage.tsx, MfaPage.tsx (moved to UsersPage)
- Legacy frontend/ directory
- 19 unused i18n keys, dist/ removed from git tracking

UX & i18n:
- 8 alert() calls replaced with styled error states (TerraformPage)
- 50+ hardcoded Portuguese strings localized to i18n (pt/en) across 11 files
- aria-label on all icon-only buttons (Chat, Explorer, Sidebar)
- focus-visible CSS for keyboard navigation
- Responsive grid fix (360px -> 280px for mobile)
- aria-hidden on decorative SVGs

Deployment:
- Docker resource limits (backend 4G, frontend 512M)
- Log rotation (json-file, 10MB x 3)
- Terraform version parameterized via ARG
This commit is contained in:
nogueiraguh
2026-03-20 13:48:30 -03:00
parent 61fbb3861d
commit fbcb1966d5
35 changed files with 1301 additions and 4903 deletions

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
# Generate with: openssl rand -hex 32
APP_SECRET=change-me-generate-a-secure-random-string
JWT_EXPIRY_HOURS=12
PORT=8080
CORS_ORIGINS=http://localhost:8080
OCI_CLI_SUPPRESS_FILE_PERMISSIONS_WARNING=True

10
backend/.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
.gitignore
__pycache__
*.pyc
.venv
.env*
*.db
*.sqlite
.DS_Store
.idea

View File

@@ -2,12 +2,13 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
# Install system deps + Terraform CLI # Install system deps + Chromium + Terraform CLI
ARG TERRAFORM_VERSION=1.7.5
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
curl gcc libffi-dev unzip \ curl gcc libffi-dev unzip \
chromium && \ chromium && \
ARCH=$(dpkg --print-architecture) && \ ARCH=$(dpkg --print-architecture) && \
curl -fsSL "https://releases.hashicorp.com/terraform/1.7.5/terraform_1.7.5_linux_${ARCH}.zip" -o /tmp/tf.zip && \ curl -fsSL "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_${ARCH}.zip" -o /tmp/tf.zip && \
unzip /tmp/tf.zip -d /usr/local/bin/ && rm /tmp/tf.zip && \ unzip /tmp/tf.zip -d /usr/local/bin/ && rm /tmp/tf.zip && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
@@ -22,12 +23,21 @@ COPY cis_reports.py .
COPY mcp_cis_server.py . COPY mcp_cis_server.py .
COPY gen_tf_reference.py . COPY gen_tf_reference.py .
# Data volume # Non-root user setup
RUN mkdir -p /data RUN groupadd -r agent && useradd -r -g agent -d /app -s /bin/bash agent && \
mkdir -p /data && chown -R agent:agent /app /data
# Generate OCI Terraform resource reference at build time # Generate OCI Terraform resource reference at build time
RUN python gen_tf_reference.py || echo "WARN: tf reference generation skipped (terraform init may need network)" RUN python gen_tf_reference.py || echo "WARN: tf reference generation skipped (terraform init may need network)"
# Entrypoint: fix /data ownership then exec as agent user
RUN printf '#!/bin/bash\nchown -R agent:agent /data 2>/dev/null || true\nexec gosu agent "$@"\n' > /entrypoint.sh && \
chmod +x /entrypoint.sh && \
apt-get update && apt-get install -y --no-install-recommends gosu && rm -rf /var/lib/apt/lists/*
VOLUME /data
ENTRYPOINT ["/entrypoint.sh"]
EXPOSE 8000 EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "8"] CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "8"]

View File

@@ -5,7 +5,7 @@ OCI Account Explorer, MCP Server registry with VectorDB tool integration,
Autonomous DB vector storage, CIS reports, chat agent, audit log. Autonomous DB vector storage, CIS reports, chat agent, audit log.
""" """
import os, json, uuid, hashlib, hmac, time, base64, struct, secrets, subprocess import os, json, uuid, hashlib, hmac, time, base64, struct, secrets, subprocess
import shutil, asyncio, sqlite3, logging, re, concurrent.futures import shutil, asyncio, sqlite3, logging, re, concurrent.futures, threading
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
@@ -33,6 +33,8 @@ REPORTS = DATA / "reports"
MCP_DIR = DATA / "mcp_servers" MCP_DIR = DATA / "mcp_servers"
WALLET_DIR = DATA / "wallets" WALLET_DIR = DATA / "wallets"
VERSION = "1.1" VERSION = "1.1"
_START_TIME = time.time()
_MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # 50MB global limit for file uploads
for d in [DATA, OCI_DIR, REPORTS, MCP_DIR, WALLET_DIR]: for d in [DATA, OCI_DIR, REPORTS, MCP_DIR, WALLET_DIR]:
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
@@ -41,7 +43,7 @@ _running_reports: dict[str, asyncio.subprocess.Process] = {} # rid → subproce
_running_terraform: dict[str, asyncio.subprocess.Process] = {} # wid → subprocess _running_terraform: dict[str, asyncio.subprocess.Process] = {} # wid → subprocess
TERRAFORM_DIR = DATA / "terraform" TERRAFORM_DIR = DATA / "terraform"
TERRAFORM_DIR.mkdir(parents=True, exist_ok=True) TERRAFORM_DIR.mkdir(parents=True, exist_ok=True)
_chat_executor = concurrent.futures.ThreadPoolExecutor(max_workers=16, thread_name_prefix="chat") _chat_executor = concurrent.futures.ThreadPoolExecutor(max_workers=10, thread_name_prefix="chat")
# ── Chat Memory Compaction Settings ── # ── Chat Memory Compaction Settings ──
COMPACT_TOKEN_THRESHOLD = 6000 # estimated tokens before triggering compaction COMPACT_TOKEN_THRESHOLD = 6000 # estimated tokens before triggering compaction
@@ -52,9 +54,26 @@ COMPACT_MIN_MESSAGES = 8
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
log = logging.getLogger("agent") log = logging.getLogger("agent")
async def _validate_upload(file: "UploadFile", allowed_exts: set[str] | None = None, max_bytes: int = _MAX_UPLOAD_BYTES):
"""Validate uploaded file size and extension. Seeks back to 0 after size check."""
data = await file.read()
if len(data) > max_bytes:
raise HTTPException(400, f"Arquivo excede o limite de {max_bytes // (1024*1024)}MB")
if allowed_exts and file.filename:
ext = Path(file.filename).suffix.lower()
if ext not in allowed_exts:
raise HTTPException(400, f"Extensão '{ext}' não permitida. Aceitas: {', '.join(sorted(allowed_exts))}")
return data
app = FastAPI(title="AI Agent - Infrastructure & Security Engineer", version=VERSION) app = FastAPI(title="AI Agent - Infrastructure & Security Engineer", version=VERSION)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, _CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "").split(",") if os.environ.get("CORS_ORIGINS") else []
allow_methods=["*"], allow_headers=["*"]) app.add_middleware(CORSMiddleware,
allow_origins=_CORS_ORIGINS or ["http://localhost:8080", "http://127.0.0.1:8080"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization"])
security = HTTPBearer() security = HTTPBearer()
# ── OCI GenAI Models Catalog ────────────────────────────────────────────────── # ── OCI GenAI Models Catalog ──────────────────────────────────────────────────
@@ -363,6 +382,27 @@ def init_db():
""") """)
c.execute("DELETE FROM config_logs WHERE created_at < datetime('now', '-30 days')") c.execute("DELETE FROM config_logs WHERE created_at < datetime('now', '-30 days')")
c.execute("DELETE FROM chat_logs WHERE created_at < datetime('now', '-30 days')") c.execute("DELETE FROM chat_logs WHERE created_at < datetime('now', '-30 days')")
c.execute("DELETE FROM audit_log WHERE created_at < datetime('now', '-30 days')")
# ── Indexes for performance ──
for idx in [
"CREATE INDEX IF NOT EXISTS idx_chat_msg_session ON chat_messages(session_id)",
"CREATE INDEX IF NOT EXISTS idx_chat_msg_user ON chat_messages(user_id)",
"CREATE INDEX IF NOT EXISTS idx_chat_msg_status ON chat_messages(status)",
"CREATE INDEX IF NOT EXISTS idx_chat_sess_user ON chat_sessions(user_id)",
"CREATE INDEX IF NOT EXISTS idx_reports_user ON reports(user_id)",
"CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status)",
"CREATE INDEX IF NOT EXISTS idx_report_files_report ON report_files(report_id)",
"CREATE INDEX IF NOT EXISTS idx_oci_cfg_user ON oci_configs(user_id)",
"CREATE INDEX IF NOT EXISTS idx_genai_cfg_user ON genai_configs(user_id)",
"CREATE INDEX IF NOT EXISTS idx_mcp_srv_user ON mcp_servers(user_id)",
"CREATE INDEX IF NOT EXISTS idx_adb_cfg_user ON adb_vector_configs(user_id)",
"CREATE INDEX IF NOT EXISTS idx_adb_tables_cfg ON adb_vector_tables(adb_config_id)",
"CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)",
"CREATE INDEX IF NOT EXISTS idx_tf_ws_user ON terraform_workspaces(user_id)",
"CREATE INDEX IF NOT EXISTS idx_tf_ws_session ON terraform_workspaces(session_id)",
"CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id)",
]:
c.execute(idx)
# ── Migrations ── # ── Migrations ──
for col in ["system_prompt TEXT DEFAULT ''", "reasoning_effort TEXT"]: for col in ["system_prompt TEXT DEFAULT ''", "reasoning_effort TEXT"]:
try: try:
@@ -401,8 +441,8 @@ def init_db():
if not c.execute("SELECT 1 FROM adb_vector_tables WHERE adb_config_id=? AND table_name=?", (cfg_row["id"], cfg_row["table_name"])).fetchone(): if not c.execute("SELECT 1 FROM adb_vector_tables WHERE adb_config_id=? AND table_name=?", (cfg_row["id"], cfg_row["table_name"])).fetchone():
c.execute("INSERT INTO adb_vector_tables (id, adb_config_id, table_name, description) VALUES (?,?,?,?)", c.execute("INSERT INTO adb_vector_tables (id, adb_config_id, table_name, description) VALUES (?,?,?,?)",
(str(uuid.uuid4()), cfg_row["id"], cfg_row["table_name"], "Migrado automaticamente")) (str(uuid.uuid4()), cfg_row["id"], cfg_row["table_name"], "Migrado automaticamente"))
except Exception: except Exception as e:
pass log.warning(f"DB migration: adb_vector_tables backfill failed: {e}")
# Backfill chat_sessions from existing chat_messages # Backfill chat_sessions from existing chat_messages
try: try:
c.execute("""INSERT OR IGNORE INTO chat_sessions (id, user_id, agent_type, title, created_at, updated_at) c.execute("""INSERT OR IGNORE INTO chat_sessions (id, user_id, agent_type, title, created_at, updated_at)
@@ -414,8 +454,8 @@ def init_db():
LEFT JOIN terraform_workspaces tw ON tw.session_id = cm.session_id LEFT JOIN terraform_workspaces tw ON tw.session_id = cm.session_id
WHERE cm.session_id NOT IN (SELECT id FROM chat_sessions) WHERE cm.session_id NOT IN (SELECT id FROM chat_sessions)
GROUP BY cm.session_id""") GROUP BY cm.session_id""")
except Exception: except Exception as e:
pass log.warning(f"DB migration: chat_sessions backfill failed: {e}")
# Seed default system prompt if none exists for chat agent # Seed default system prompt if none exists for chat agent
if not c.execute("SELECT 1 FROM system_prompts WHERE agent='chat'").fetchone(): if not c.execute("SELECT 1 FROM system_prompts WHERE agent='chat'").fetchone():
c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active) VALUES (?,?,?,?,?)", c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active) VALUES (?,?,?,?,?)",
@@ -591,9 +631,28 @@ class MCPServerReq(BaseModel):
module_path: Optional[str] = None; tools: Optional[List[dict]] = None module_path: Optional[str] = None; tools: Optional[List[dict]] = None
linked_adb_id: Optional[str] = None linked_adb_id: Optional[str] = None
# ── Rate limiting (login brute-force protection) ─────────────────────────────
_login_attempts: dict = {} # {ip: [timestamps]}
_login_attempts_lock = threading.Lock()
_LOGIN_WINDOW = 300 # 5 minutes
_LOGIN_MAX = 10 # max attempts per window
def _check_rate_limit(ip: str):
"""Raise 429 if too many login attempts from this IP."""
import time
now = time.time()
with _login_attempts_lock:
attempts = _login_attempts.get(ip, [])
attempts = [t for t in attempts if now - t < _LOGIN_WINDOW]
if len(attempts) >= _LOGIN_MAX:
raise HTTPException(429, "Muitas tentativas de login. Aguarde 5 minutos.")
attempts.append(now)
_login_attempts[ip] = attempts
# ── Auth endpoints ──────────────────────────────────────────────────────────── # ── Auth endpoints ────────────────────────────────────────────────────────────
@app.post("/api/auth/login") @app.post("/api/auth/login")
async def login(req: LoginReq, request: Request): async def login(req: LoginReq, request: Request):
_check_rate_limit(request.client.host if request.client else "unknown")
with db() as c: with db() as c:
u = c.execute("SELECT * FROM users WHERE username=? AND is_active=1", (req.username,)).fetchone() u = c.execute("SELECT * FROM users WHERE username=? AND is_active=1", (req.username,)).fetchone()
if not u or not _verify_pw(req.password, u["password_hash"]): raise HTTPException(401, "Credenciais inválidas") if not u or not _verify_pw(req.password, u["password_hash"]): raise HTTPException(401, "Credenciais inválidas")
@@ -697,6 +756,13 @@ async def save_oci(
): ):
if auth_type not in ("api_key", "session_token"): if auth_type not in ("api_key", "session_token"):
raise HTTPException(400, "auth_type deve ser 'api_key' ou 'session_token'") raise HTTPException(400, "auth_type deve ser 'api_key' ou 'session_token'")
_PEM_EXTS = {".pem", ".key"}
if private_key and private_key.filename:
await _validate_upload(private_key, _PEM_EXTS)
private_key.file.seek(0)
if public_key and public_key.filename:
await _validate_upload(public_key, _PEM_EXTS)
public_key.file.seek(0)
cid = str(uuid.uuid4()); cdir = OCI_DIR / cid; cdir.mkdir(parents=True) cid = str(uuid.uuid4()); cdir = OCI_DIR / cid; cdir.mkdir(parents=True)
kp = cdir / "oci_api_key.pem" kp = cdir / "oci_api_key.pem"
@@ -810,6 +876,13 @@ async def update_oci(
private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None), private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None),
u = Depends(require("admin","user")) u = Depends(require("admin","user"))
): ):
_PEM_EXTS = {".pem", ".key"}
if private_key and private_key.filename:
await _validate_upload(private_key, _PEM_EXTS)
private_key.file.seek(0)
if public_key and public_key.filename:
await _validate_upload(public_key, _PEM_EXTS)
public_key.file.seek(0)
with db() as c: with db() as c:
existing = c.execute("SELECT * FROM oci_configs WHERE id=?", (cid,)).fetchone() existing = c.execute("SELECT * FROM oci_configs WHERE id=?", (cid,)).fetchone()
if not existing: raise HTTPException(404) if not existing: raise HTTPException(404)
@@ -874,8 +947,10 @@ async def test_oci(cid: str, u=Depends(require("admin","user"))):
_config_log("oci", cid, cname, "error", "test", "Config não encontrada", u["id"], u["username"]) _config_log("oci", cid, cname, "error", "test", "Config não encontrada", u["id"], u["username"])
return {"status":"error","message":"Config não encontrada"} return {"status":"error","message":"Config não encontrada"}
try: try:
r = subprocess.run(["oci","iam","region","list","--config-file",str(cp),"--output","json"], loop = asyncio.get_event_loop()
capture_output=True, text=True, timeout=30, stdin=subprocess.DEVNULL) r = await loop.run_in_executor(None, partial(
subprocess.run, ["oci","iam","region","list","--config-file",str(cp),"--output","json"],
capture_output=True, text=True, timeout=30, stdin=subprocess.DEVNULL))
if r.returncode == 0: if r.returncode == 0:
_config_log("oci", cid, cname, "success", "test", "Conexão OK", u["id"], u["username"]) _config_log("oci", cid, cname, "success", "test", "Conexão OK", u["id"], u["username"])
return {"status":"success","message":"Conexão OK","data":json.loads(r.stdout)} return {"status":"success","message":"Conexão OK","data":json.loads(r.stdout)}
@@ -1030,10 +1105,12 @@ async def explore_compartment_tree(cid: str, u=Depends(current_user)):
cp = identity.get_compartment(user_comp).data cp = identity.get_compartment(user_comp).data
tree.append({"id":cp.id, "name":cp.name, "parent_id":cp.compartment_id, tree.append({"id":cp.id, "name":cp.name, "parent_id":cp.compartment_id,
"lifecycle_state":cp.lifecycle_state}) "lifecycle_state":cp.lifecycle_state})
except Exception: except Exception as e:
log.warning(f"Failed to get compartment {user_comp}: {e}")
tree.append({"id":user_comp, "name":"Meu Compartment", "parent_id":tenancy, tree.append({"id":user_comp, "name":"Meu Compartment", "parent_id":tenancy,
"lifecycle_state":"ACTIVE"}) "lifecycle_state":"ACTIVE"})
except Exception: except Exception as e:
log.warning(f"Failed to list compartments from tenancy: {e}")
# If listing from tenancy fails (no permission), list from user's compartment # If listing from tenancy fails (no permission), list from user's compartment
if user_comp and user_comp != tenancy: if user_comp and user_comp != tenancy:
try: try:
@@ -1042,7 +1119,8 @@ async def explore_compartment_tree(cid: str, u=Depends(current_user)):
sub = identity.list_compartments(user_comp, compartment_id_in_subtree=True).data sub = identity.list_compartments(user_comp, compartment_id_in_subtree=True).data
tree += [{"id":s.id, "name":s.name, "parent_id":s.compartment_id, tree += [{"id":s.id, "name":s.name, "parent_id":s.compartment_id,
"lifecycle_state":s.lifecycle_state} for s in sub if s.lifecycle_state == "ACTIVE"] "lifecycle_state":s.lifecycle_state} for s in sub if s.lifecycle_state == "ACTIVE"]
except Exception: except Exception as e:
log.warning(f"Failed to list sub-compartments for {user_comp}: {e}")
tree = [{"id":user_comp, "name":"Meu Compartment", "parent_id": None, "lifecycle_state":"ACTIVE"}] tree = [{"id":user_comp, "name":"Meu Compartment", "parent_id": None, "lifecycle_state":"ACTIVE"}]
return tree return tree
except Exception as e: except Exception as e:
@@ -1851,7 +1929,10 @@ async def explore_network_firewall_policies(cid: str, compartment_id: str = Quer
# ── OCI GenAI Config & Chat ─────────────────────────────────────────────────── # ── OCI GenAI Config & Chat ───────────────────────────────────────────────────
@app.get("/api/genai/models") @app.get("/api/genai/models")
async def list_genai_models(u=Depends(current_user)): async def list_genai_models(u=Depends(current_user)):
return {"models": GENAI_MODELS, "regions": GENAI_REGIONS, "embedding_models": EMBEDDING_MODELS, "oci_regions": OCI_REGIONS} return JSONResponse(
content={"models": GENAI_MODELS, "regions": GENAI_REGIONS, "embedding_models": EMBEDDING_MODELS, "oci_regions": OCI_REGIONS},
headers={"Cache-Control": "max-age=3600"},
)
@app.post("/api/genai/config") @app.post("/api/genai/config")
async def save_genai(req: GenAIConfigReq, u=Depends(require("admin","user"))): async def save_genai(req: GenAIConfigReq, u=Depends(require("admin","user"))):
@@ -2281,7 +2362,7 @@ def _call_genai(gc: dict, message: str, history: list = None, tools: list = None
args = {} args = {}
if hasattr(tc, 'arguments') and tc.arguments: if hasattr(tc, 'arguments') and tc.arguments:
try: args = json.loads(tc.arguments) if isinstance(tc.arguments, str) else tc.arguments try: args = json.loads(tc.arguments) if isinstance(tc.arguments, str) else tc.arguments
except: args = {} except Exception as e: log.warning(f"Failed to parse tool call arguments: {e}"); args = {}
tool_calls.append({ tool_calls.append({
"id": tc.id if hasattr(tc, 'id') else tc.name, "id": tc.id if hasattr(tc, 'id') else tc.name,
"name": tc.name if hasattr(tc, 'name') else "", "name": tc.name if hasattr(tc, 'name') else "",
@@ -2762,7 +2843,8 @@ def _get_table_embedding_dim(cfg: dict, table_name: str) -> int:
row = cur.fetchone() row = cur.fetchone()
cur.close() cur.close()
return int(row[0]) if row and row[0] else 0 return int(row[0]) if row and row[0] else 0
except Exception: except Exception as e:
log.warning(f"Failed to get embedding dimension for table '{table_name}': {e}")
return 0 return 0
finally: finally:
conn.close() conn.close()
@@ -2818,7 +2900,8 @@ def _enrich_doc_content(doc: dict) -> str:
if isinstance(meta, str) and meta: if isinstance(meta, str) and meta:
try: try:
meta = json.loads(meta) meta = json.loads(meta)
except Exception: except Exception as e:
log.warning(f"Failed to parse document metadata JSON: {e}")
meta = {} meta = {}
if isinstance(meta, dict): if isinstance(meta, dict):
# If TEXT column is short/empty, use metadata.text instead # If TEXT column is short/empty, use metadata.text instead
@@ -2904,7 +2987,7 @@ async def list_mcp(u=Depends(current_user)):
for k in ("args","env_vars","tools"): for k in ("args","env_vars","tools"):
if d.get(k): if d.get(k):
try: d[k] = json.loads(d[k]) try: d[k] = json.loads(d[k])
except: pass except Exception as e: log.warning(f"Failed to parse MCP server field '{k}': {e}")
res.append(d) res.append(d)
return res return res
@@ -2940,6 +3023,8 @@ async def toggle_mcp(mid: str, u=Depends(require("admin","user"))):
@app.post("/api/mcp/servers/{mid}/upload") @app.post("/api/mcp/servers/{mid}/upload")
async def upload_mcp_file(mid: str, file: UploadFile = File(...), u=Depends(require("admin","user"))): async def upload_mcp_file(mid: str, file: UploadFile = File(...), u=Depends(require("admin","user"))):
await _validate_upload(file, {".py", ".js", ".ts", ".json", ".yaml", ".yml", ".toml", ".sh"})
file.file.seek(0)
sdir = MCP_DIR / mid; sdir.mkdir(parents=True, exist_ok=True) sdir = MCP_DIR / mid; sdir.mkdir(parents=True, exist_ok=True)
fp = sdir / file.filename fp = sdir / file.filename
fp.write_bytes(await file.read()) fp.write_bytes(await file.read())
@@ -2966,7 +3051,7 @@ async def discover_mcp_tools(mid: str, u=Depends(require("admin","user"))):
existing = [] existing = []
if mcp_srv.get("tools"): if mcp_srv.get("tools"):
try: existing = json.loads(mcp_srv["tools"]) if isinstance(mcp_srv["tools"], str) else mcp_srv["tools"] try: existing = json.loads(mcp_srv["tools"]) if isinstance(mcp_srv["tools"], str) else mcp_srv["tools"]
except: pass except Exception as e: log.warning(f"Failed to parse existing MCP tools JSON: {e}")
existing_names = {t["name"] for t in existing if isinstance(t, dict)} existing_names = {t["name"] for t in existing if isinstance(t, dict)}
for dt in discovered: for dt in discovered:
if dt["name"] not in existing_names: if dt["name"] not in existing_names:
@@ -3076,7 +3161,8 @@ def _get_active_mcp_tools(user_id: str) -> list[dict]:
continue continue
try: try:
tools = json.loads(tools_raw) if isinstance(tools_raw, str) else tools_raw tools = json.loads(tools_raw) if isinstance(tools_raw, str) else tools_raw
except: except Exception as e:
log.warning(f"Failed to parse tools JSON for MCP server '{srv.get('name', '?')}': {e}")
continue continue
for t in tools: for t in tools:
if isinstance(t, dict) and t.get("name"): if isinstance(t, dict) and t.get("name"):
@@ -3087,6 +3173,8 @@ def _get_active_mcp_tools(user_id: str) -> list[dict]:
@app.post("/api/adb/parse-wallet") @app.post("/api/adb/parse-wallet")
async def parse_wallet(wallet: UploadFile = File(...), u=Depends(require("admin","user"))): async def parse_wallet(wallet: UploadFile = File(...), u=Depends(require("admin","user"))):
"""Extract DSN names from tnsnames.ora inside wallet ZIP (temporary parse, no save).""" """Extract DSN names from tnsnames.ora inside wallet ZIP (temporary parse, no save)."""
await _validate_upload(wallet, {".zip"})
wallet.file.seek(0)
import zipfile, tempfile import zipfile, tempfile
try: try:
data = await wallet.read() data = await wallet.read()
@@ -3115,6 +3203,9 @@ async def save_adb(
wallet: Optional[UploadFile] = File(None), wallet: Optional[UploadFile] = File(None),
u=Depends(require("admin","user")) u=Depends(require("admin","user"))
): ):
if wallet and wallet.filename:
await _validate_upload(wallet, {".zip"})
wallet.file.seek(0)
vid = str(uuid.uuid4()) vid = str(uuid.uuid4())
use_mtls_bool = use_mtls.lower() in ("true", "1", "yes") use_mtls_bool = use_mtls.lower() in ("true", "1", "yes")
with db() as c: with db() as c:
@@ -3138,6 +3229,8 @@ async def save_adb(
@app.post("/api/adb/{vid}/upload-wallet") @app.post("/api/adb/{vid}/upload-wallet")
async def upload_wallet(vid: str, wallet: UploadFile = File(...), u=Depends(require("admin","user"))): async def upload_wallet(vid: str, wallet: UploadFile = File(...), u=Depends(require("admin","user"))):
await _validate_upload(wallet, {".zip"})
wallet.file.seek(0)
cname = None cname = None
with db() as c: with db() as c:
row = c.execute("SELECT config_name FROM adb_vector_configs WHERE id=?", (vid,)).fetchone() row = c.execute("SELECT config_name FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
@@ -3249,6 +3342,9 @@ async def update_adb(
wallet: Optional[UploadFile] = File(None), wallet: Optional[UploadFile] = File(None),
u=Depends(require("admin","user")) u=Depends(require("admin","user"))
): ):
if wallet and wallet.filename:
await _validate_upload(wallet, {".zip"})
wallet.file.seek(0)
with db() as c: with db() as c:
existing = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone() existing = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
if not existing: raise HTTPException(404) if not existing: raise HTTPException(404)
@@ -3631,6 +3727,8 @@ async def embed_upload(adb_config_id: str = Form(...), table_name: str = Form(""
allowed = ('.txt', '.pdf', '.csv', '.json', '.md') allowed = ('.txt', '.pdf', '.csv', '.json', '.md')
if not any(fname.endswith(ext) for ext in allowed): if not any(fname.endswith(ext) for ext in allowed):
raise HTTPException(400, f"Formatos aceitos: {', '.join(allowed)}") raise HTTPException(400, f"Formatos aceitos: {', '.join(allowed)}")
await _validate_upload(file, allowed)
file.file.seek(0)
raw = await file.read() raw = await file.read()
if fname.endswith('.pdf'): if fname.endswith('.pdf'):
content = _extract_pdf_text(raw) content = _extract_pdf_text(raw)
@@ -3965,11 +4063,11 @@ async def _exec_report(rid, cfg, regions, level=2, obp=False, raw=False, redact_
_config_log("oci", cfg["id"], cfg["tenancy_name"], "error", "report", str(e)[:2000]) _config_log("oci", cfg["id"], cfg["tenancy_name"], "error", "report", str(e)[:2000])
@app.get("/api/reports") @app.get("/api/reports")
async def list_reports(u=Depends(current_user)): async def list_reports(skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), u=Depends(current_user)):
with db() as c: with db() as c:
q = "SELECT id,user_id,tenancy_name,status,progress,level,obp_checks,raw_data,redact_output,created_at,completed_at,error_msg FROM reports" q = "SELECT id,user_id,tenancy_name,status,progress,level,obp_checks,raw_data,redact_output,created_at,completed_at,error_msg FROM reports"
rows = c.execute(q+" ORDER BY created_at DESC").fetchall() if u["role"]=="admin" \ rows = c.execute(q+" ORDER BY created_at DESC LIMIT ? OFFSET ?", (limit, skip)).fetchall() if u["role"]=="admin" \
else c.execute(q+" WHERE user_id=? ORDER BY created_at DESC",(u["id"],)).fetchall() else c.execute(q+" WHERE user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?",(u["id"], limit, skip)).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]
@app.get("/api/reports/{rid}/progress") @app.get("/api/reports/{rid}/progress")
@@ -4108,7 +4206,9 @@ async def download_report_file(rid: str, fid: str, token: str = Query(None), cre
return FileResponse(p, filename=f["file_name"]) return FileResponse(p, filename=f["file_name"])
@app.get("/api/reports/{rid}/html") @app.get("/api/reports/{rid}/html")
async def report_html(rid): async def report_html(rid, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
if not u: raise HTTPException(401, "Not authenticated")
with db() as c: r=c.execute("SELECT html_path FROM reports WHERE id=?",(rid,)).fetchone() with db() as c: r=c.execute("SELECT html_path FROM reports WHERE id=?",(rid,)).fetchone()
if not r or not r["html_path"] or not Path(r["html_path"]).exists(): raise HTTPException(404) if not r or not r["html_path"] or not Path(r["html_path"]).exists(): raise HTTPException(404)
return FileResponse(r["html_path"], media_type="text/html") return FileResponse(r["html_path"], media_type="text/html")
@@ -4255,14 +4355,14 @@ def _search_cis_remediation(title: str, rec_num: str, user_id: str) -> dict:
actual_dim = _get_table_embedding_dim(adb_cfg, tbl_name) actual_dim = _get_table_embedding_dim(adb_cfg, tbl_name)
if actual_dim and actual_dim in _DIM_TO_MODEL: if actual_dim and actual_dim in _DIM_TO_MODEL:
tbl_model = _DIM_TO_MODEL[actual_dim] tbl_model = _DIM_TO_MODEL[actual_dim]
except Exception: except Exception as e:
pass log.warning(f"Failed to detect embedding dimension for table '{tbl_name}': {e}")
query_embedding = _embed_text(query, emb_genai, tbl_model) query_embedding = _embed_text(query, emb_genai, tbl_model)
docs = _vector_search(adb_cfg, query_embedding, top_k=10, table_name=tbl_name) docs = _vector_search(adb_cfg, query_embedding, top_k=10, table_name=tbl_name)
if docs: if docs:
all_docs.extend(docs) all_docs.extend(docs)
except Exception: except Exception as e:
pass log.warning(f"Vector search failed for table '{tbl_name}': {e}")
if all_docs: if all_docs:
import re as _re import re as _re
@@ -4836,8 +4936,10 @@ def _generate_compliance_report(rid: str, user_id: str, force_rag: bool = True)
@app.get("/api/reports/{rid}/compliance-report") @app.get("/api/reports/{rid}/compliance-report")
async def compliance_report(rid: str, regen: int = Query(0)): async def compliance_report(rid: str, regen: int = Query(0), token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
"""Serve cached LAD A-Team CIS Compliance Report, or return 404 if not generated yet.""" """Serve cached LAD A-Team CIS Compliance Report, or return 404 if not generated yet."""
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
if not u: raise HTTPException(401, "Not authenticated")
rdir = REPORTS / rid rdir = REPORTS / rid
cache_path = rdir / "compliance_report.html" cache_path = rdir / "compliance_report.html"
@@ -4949,12 +5051,16 @@ async def compliance_report_download(rid: str, token: str = Query(None), cred: H
tmp_html = Path(tmpdir) / "report.html" tmp_html = Path(tmpdir) / "report.html"
tmp_pdf = Path(tmpdir) / "report.pdf" tmp_pdf = Path(tmpdir) / "report.pdf"
tmp_html.write_text(html, encoding="utf-8") tmp_html.write_text(html, encoding="utf-8")
result = subprocess.run([ loop = asyncio.get_event_loop()
"chromium", "--headless", "--no-sandbox", "--disable-gpu", result = await asyncio.wait_for(
"--disable-software-rasterizer", "--run-all-compositor-stages-before-draw", loop.run_in_executor(None, partial(
f"--print-to-pdf={tmp_pdf}", "--no-pdf-header-footer", subprocess.run, [
f"file://{tmp_html}" "chromium", "--headless", "--no-sandbox", "--disable-gpu",
], capture_output=True, timeout=120) "--disable-software-rasterizer", "--run-all-compositor-stages-before-draw",
f"--print-to-pdf={tmp_pdf}", "--no-pdf-header-footer",
f"file://{tmp_html}"
], capture_output=True, timeout=120)),
timeout=120)
if tmp_pdf.exists(): if tmp_pdf.exists():
pdf_bytes = tmp_pdf.read_bytes() pdf_bytes = tmp_pdf.read_bytes()
log.info(f"Chromium PDF generated: {len(pdf_bytes)} bytes") log.info(f"Chromium PDF generated: {len(pdf_bytes)} bytes")
@@ -4990,6 +5096,7 @@ async def report_dl(rid, fmt: str = Query("json"), token: str = Query(None), cre
if not u: raise HTTPException(401, "Not authenticated") if not u: raise HTTPException(401, "Not authenticated")
with db() as c: r=c.execute("SELECT * FROM reports WHERE id=?",(rid,)).fetchone() with db() as c: r=c.execute("SELECT * FROM reports WHERE id=?",(rid,)).fetchone()
if not r: raise HTTPException(404) if not r: raise HTTPException(404)
if u["role"] != "admin" and r["user_id"] != u["id"]: raise HTTPException(403)
p = r["json_path"] if fmt=="json" else r["html_path"] p = r["json_path"] if fmt=="json" else r["html_path"]
if not p or not Path(p).exists(): raise HTTPException(404) if not p or not Path(p).exists(): raise HTTPException(404)
return FileResponse(p, filename=f"cis_{r['tenancy_name']}_{rid[:8]}.{fmt}") return FileResponse(p, filename=f"cis_{r['tenancy_name']}_{rid[:8]}.{fmt}")
@@ -5274,9 +5381,15 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
hist = history[:-1] if len(history) > 1 else None hist = history[:-1] if len(history) > 1 else None
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
resp_text, tool_calls, tool_calls_raw = await loop.run_in_executor( try:
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist, resp_text, tool_calls, tool_calls_raw = await asyncio.wait_for(
tool_defs, None, None, attachments)) loop.run_in_executor(
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
tool_defs, None, None, attachments)),
timeout=300)
except asyncio.TimeoutError:
log.error(f"GenAI call timed out after 300s for session {sid}")
raise HTTPException(504, "O modelo de IA demorou demais para responder. Tente novamente.")
all_tool_results = [] all_tool_results = []
accumulated_msgs = [] accumulated_msgs = []
@@ -5314,9 +5427,15 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
tr_obj.call = tc_obj tr_obj.call = tc_obj
tr_obj.outputs = [{"result": tr["content"]}] tr_obj.outputs = [{"result": tr["content"]}]
cohere_results.append(tr_obj) cohere_results.append(tr_obj)
resp_text, tool_calls, tool_calls_raw = await loop.run_in_executor( try:
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist, resp_text, tool_calls, tool_calls_raw = await asyncio.wait_for(
tool_defs, cohere_results)) loop.run_in_executor(
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
tool_defs, cohere_results)),
timeout=300)
except asyncio.TimeoutError:
log.error(f"GenAI tool-use call timed out after 300s (Cohere, iteration {iterations})")
raise HTTPException(504, "O modelo de IA demorou demais para responder. Tente novamente.")
else: else:
import oci import oci
assistant_msg = oci.generative_ai_inference.models.AssistantMessage() assistant_msg = oci.generative_ai_inference.models.AssistantMessage()
@@ -5329,9 +5448,15 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
tool_content.text = tr["content"] tool_content.text = tr["content"]
tool_msg.content = [tool_content] tool_msg.content = [tool_content]
accumulated_msgs.append(tool_msg) accumulated_msgs.append(tool_msg)
resp_text, tool_calls, tool_calls_raw = await loop.run_in_executor( try:
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist, resp_text, tool_calls, tool_calls_raw = await asyncio.wait_for(
tool_defs, None, accumulated_msgs)) loop.run_in_executor(
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
tool_defs, None, accumulated_msgs)),
timeout=300)
except asyncio.TimeoutError:
log.error(f"GenAI tool-use call timed out after 300s (Generic, iteration {iterations})")
raise HTTPException(504, "O modelo de IA demorou demais para responder. Tente novamente.")
resp = resp_text resp = resp_text
if all_tool_results: if all_tool_results:
@@ -5416,7 +5541,8 @@ async def chat_with_files(
else: else:
try: try:
text = data.decode("utf-8", errors="replace") text = data.decode("utf-8", errors="replace")
except Exception: except Exception as e:
log.warning(f"UTF-8 decode failed for file attachment, falling back to latin-1: {e}")
text = data.decode("latin-1", errors="replace") text = data.decode("latin-1", errors="replace")
message = f"{message}\n\n--- Conteúdo de {f.filename} ---\n{text[:50000]}" message = f"{message}\n\n--- Conteúdo de {f.filename} ---\n{text[:50000]}"
file_names.append(f.filename) file_names.append(f.filename)
@@ -5507,15 +5633,18 @@ async def list_chat_sessions(agent_type: str = "chat", limit: int = 50, u=Depend
@app.get("/api/chat/sessions/{sid}/messages") @app.get("/api/chat/sessions/{sid}/messages")
async def get_session_messages(sid: str, u=Depends(current_user)): async def get_session_messages(sid: str, skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), u=Depends(current_user)):
with db() as c: with db() as c:
session = c.execute("SELECT * FROM chat_sessions WHERE id=? AND user_id=?", (sid, u["id"])).fetchone() session = c.execute("SELECT * FROM chat_sessions WHERE id=? AND user_id=?", (sid, u["id"])).fetchone()
if not session: if not session:
raise HTTPException(404, "Session not found") raise HTTPException(404, "Session not found")
total = c.execute(
"SELECT COUNT(*) as cnt FROM chat_messages "
"WHERE session_id=? AND status='done'", (sid,)).fetchone()["cnt"]
msgs = c.execute( msgs = c.execute(
"SELECT id, role, content, model_id, status, created_at FROM chat_messages " "SELECT id, role, content, model_id, status, created_at FROM chat_messages "
"WHERE session_id=? AND status='done' ORDER BY created_at ASC", (sid,)).fetchall() "WHERE session_id=? AND status='done' ORDER BY created_at ASC LIMIT ? OFFSET ?", (sid, limit, skip)).fetchall()
return {"session": dict(session), "messages": [dict(m) for m in msgs]} return {"session": dict(session), "messages": [dict(m) for m in msgs], "total": total}
@app.put("/api/chat/sessions/{sid}/title") @app.put("/api/chat/sessions/{sid}/title")
@@ -6105,7 +6234,13 @@ async def tf_delete_workspace(wid: str, u=Depends(current_user)):
async def tf_refresh_reference(u=Depends(current_user)): async def tf_refresh_reference(u=Depends(current_user)):
"""Regenerate the OCI Terraform resource reference from provider schema (internal, triggered by UI button).""" """Regenerate the OCI Terraform resource reference from provider schema (internal, triggered by UI button)."""
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
result = await loop.run_in_executor(_chat_executor, _regenerate_tf_reference) try:
result = await asyncio.wait_for(
loop.run_in_executor(_chat_executor, _regenerate_tf_reference),
timeout=300)
except asyncio.TimeoutError:
log.error("TF reference regeneration timed out after 300s")
raise HTTPException(504, "Geração de referência Terraform demorou demais. Tente novamente.")
# Add status info # Add status info
p = Path(_TF_RESOURCE_REF_PATH) p = Path(_TF_RESOURCE_REF_PATH)
if p.exists(): if p.exists():
@@ -6548,8 +6683,8 @@ provider "oci" {{
# ── Audit ───────────────────────────────────────────────────────────────────── # ── Audit ─────────────────────────────────────────────────────────────────────
@app.get("/api/audit-log") @app.get("/api/audit-log")
async def audit_log(limit:int=Query(100,le=500), u=Depends(require("admin"))): async def audit_log(skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), u=Depends(require("admin"))):
with db() as c: rows=c.execute("SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ?",(limit,)).fetchall() with db() as c: rows=c.execute("SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?",(limit, skip)).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]
# ── Config Logs ─────────────────────────────────────────────────────────────── # ── Config Logs ───────────────────────────────────────────────────────────────
@@ -6693,6 +6828,8 @@ async def export_config(u=Depends(require("admin"))):
@app.post("/api/config/import") @app.post("/api/config/import")
async def import_config(file: UploadFile = File(...), u=Depends(require("admin"))): async def import_config(file: UploadFile = File(...), u=Depends(require("admin"))):
"""Import configurations from exported JSON. Merges by ID (skip existing).""" """Import configurations from exported JSON. Merges by ID (skip existing)."""
await _validate_upload(file, {".json"})
file.file.seek(0)
content = await file.read() content = await file.read()
try: try:
data = json.loads(content) data = json.loads(content)
@@ -6843,7 +6980,40 @@ async def cis_engine_update(u=Depends(require("admin"))):
@app.get("/api/health") @app.get("/api/health")
async def health(): async def health():
return {"status":"ok","ts":datetime.utcnow().isoformat(),"version":VERSION} db_ok = False
try:
with db() as c:
c.execute("SELECT 1")
db_ok = True
except Exception:
pass
return {
"status": "ok",
"version": VERSION,
"uptime_seconds": int(time.time() - _START_TIME),
"db_ok": db_ok,
"ts": datetime.utcnow().isoformat(),
}
@app.on_event("shutdown")
async def shutdown():
"""Graceful shutdown: terminate running subprocesses and thread pool."""
for rid, proc in list(_running_reports.items()):
try:
proc.terminate()
log.info(f"Terminated report process {rid}")
except Exception as e:
log.warning(f"Failed to terminate report process {rid}: {e}")
_running_reports.clear()
for wid, proc in list(_running_terraform.items()):
try:
proc.terminate()
log.info(f"Terminated terraform process {wid}")
except Exception as e:
log.warning(f"Failed to terminate terraform process {wid}: {e}")
_running_terraform.clear()
_chat_executor.shutdown(wait=False)
log.info("Graceful shutdown completed")
@app.on_event("startup") @app.on_event("startup")
async def startup(): async def startup():
@@ -6885,7 +7055,9 @@ async def startup():
ref_path = Path(_TF_RESOURCE_REF_PATH) ref_path = Path(_TF_RESOURCE_REF_PATH)
if not ref_path.exists(): if not ref_path.exists():
try: try:
result = subprocess.run(["python", "/app/gen_tf_reference.py"], capture_output=True, text=True, timeout=300) loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, partial(
subprocess.run, ["python", "/app/gen_tf_reference.py"], capture_output=True, text=True, timeout=300))
if result.returncode == 0 and ref_path.exists(): if result.returncode == 0 and ref_path.exists():
log.info(f"Generated TF resource reference at startup: {ref_path.stat().st_size} bytes") log.info(f"Generated TF resource reference at startup: {ref_path.stat().st_size} bytes")
else: else:

View File

@@ -9,6 +9,7 @@ services:
- APP_SECRET=${APP_SECRET:-change-me-in-production-use-a-long-random-string} - APP_SECRET=${APP_SECRET:-change-me-in-production-use-a-long-random-string}
- JWT_EXPIRY_HOURS=${JWT_EXPIRY_HOURS:-12} - JWT_EXPIRY_HOURS=${JWT_EXPIRY_HOURS:-12}
- DATA_DIR=/data - DATA_DIR=/data
- CORS_ORIGINS=${CORS_ORIGINS:-}
- OCI_CLI_SUPPRESS_FILE_PERMISSIONS_WARNING=True - OCI_CLI_SUPPRESS_FILE_PERMISSIONS_WARNING=True
volumes: volumes:
- agent-data:/data - agent-data:/data
@@ -19,6 +20,15 @@ services:
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
memory: 4G
frontend: frontend:
image: nginx:alpine image: nginx:alpine
@@ -27,13 +37,21 @@ services:
volumes: volumes:
- ./frontend-react/dist:/usr/share/nginx/html/app:ro - ./frontend-react/dist:/usr/share/nginx/html/app:ro
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
# Note: nginx root points to /usr/share/nginx/html/app (React dist)
ports: ports:
- "${PORT:-8080}:80" - "${PORT:-8080}:80"
depends_on: depends_on:
- backend - backend
networks: networks:
- agent-net - agent-net
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "3"
deploy:
resources:
limits:
memory: 512M
volumes: volumes:
agent-data: agent-data:

View File

@@ -8,6 +8,7 @@ pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules node_modules
dist
dist-ssr dist-ssr
*.local *.local

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,22 +0,0 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/app/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Agent - Infrastructure & Security Engineer</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-Dtsz7Biu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ca4AYldT.css">
</head>
<body>
<div id="root"></div>
<script>
// Restore theme before React hydration to avoid flash
(function() {
var t = localStorage.getItem('theme') || 'light';
document.documentElement.className = t;
})();
</script>
</body>
</html>

View File

@@ -1,21 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="128" height="128">
<!-- Oracle-style rounded rectangle -->
<rect x="2" y="5" width="32" height="26" rx="13" ry="13" fill="rgba(199,70,52,0.08)" stroke="#C74634" stroke-width="2"/>
<!-- Robot head -->
<rect x="11" y="11" width="14" height="14" rx="4" fill="#fff" stroke="#C74634" stroke-width="0.5"/>
<!-- Eyes -->
<circle cx="15" cy="17" r="2" fill="#C74634"/>
<circle cx="21" cy="17" r="2" fill="#C74634"/>
<circle cx="15" cy="16.7" r="0.7" fill="#fff"/>
<circle cx="21" cy="16.7" r="0.7" fill="#fff"/>
<!-- Mouth -->
<rect x="14" y="21" width="8" height="1.5" rx="0.75" fill="#C74634" opacity="0.6"/>
<!-- Antenna -->
<line x1="18" y1="11" x2="18" y2="6" stroke="#C74634" stroke-width="1.2" stroke-linecap="round"/>
<circle cx="18" cy="5.5" r="1.5" fill="#C74634" opacity="0.3" stroke="#C74634" stroke-width="0.8"/>
<!-- Ears/signal -->
<line x1="11" y1="18" x2="6" y2="18" stroke="#C74634" stroke-width="1" stroke-linecap="round" opacity="0.5"/>
<line x1="25" y1="18" x2="30" y2="18" stroke="#C74634" stroke-width="1" stroke-linecap="round" opacity="0.5"/>
<circle cx="5.5" cy="18" r="1" fill="#C74634" opacity="0.4"/>
<circle cx="30.5" cy="18" r="1" fill="#C74634" opacity="0.4"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -2,7 +2,7 @@
<html lang="pt-BR"> <html lang="pt-BR">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/app/logo.svg" /> <link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Agent - Infrastructure & Security Engineer</title> <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"> <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">

View File

@@ -1,24 +1,34 @@
import { useEffect } from 'react'; import { useEffect, lazy, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useAppStore } from '@/stores/app'; import { useAppStore } from '@/stores/app';
import AppShell from '@/components/layout/AppShell'; import AppShell from '@/components/layout/AppShell';
import LoginPage from '@/pages/LoginPage'; import { Loader2 } from 'lucide-react';
import ChatPage from '@/pages/ChatPage';
import TerraformPage from '@/pages/TerraformPage'; // Lazy-loaded pages (code splitting)
import PromptGeneratorPage from '@/pages/PromptGeneratorPage'; const LoginPage = lazy(() => import('@/pages/LoginPage'));
import WorkspacesPage from '@/pages/WorkspacesPage'; const ChatPage = lazy(() => import('@/pages/ChatPage'));
import ExplorerPage from '@/pages/ExplorerPage'; const TerraformPage = lazy(() => import('@/pages/TerraformPage'));
import ReportsPage from '@/pages/ReportsPage'; const PromptGeneratorPage = lazy(() => import('@/pages/PromptGeneratorPage'));
import OciConfigPage from '@/pages/config/OciConfigPage'; const WorkspacesPage = lazy(() => import('@/pages/WorkspacesPage'));
import GenAiConfigPage from '@/pages/config/GenAiConfigPage'; const ExplorerPage = lazy(() => import('@/pages/ExplorerPage'));
import McpServersPage from '@/pages/config/McpServersPage'; const ReportsPage = lazy(() => import('@/pages/ReportsPage'));
import AdbConfigPage from '@/pages/config/AdbConfigPage'; const OciConfigPage = lazy(() => import('@/pages/config/OciConfigPage'));
import EmbeddingsPage from '@/pages/config/EmbeddingsPage'; const GenAiConfigPage = lazy(() => import('@/pages/config/GenAiConfigPage'));
import EmbConsultPage from '@/pages/config/EmbConsultPage'; const McpServersPage = lazy(() => import('@/pages/config/McpServersPage'));
import UsersPage from '@/pages/admin/UsersPage'; const AdbConfigPage = lazy(() => import('@/pages/config/AdbConfigPage'));
import MfaPage from '@/pages/admin/MfaPage'; const EmbeddingsPage = lazy(() => import('@/pages/config/EmbeddingsPage'));
import AuditPage from '@/pages/admin/AuditPage'; const EmbConsultPage = lazy(() => import('@/pages/config/EmbConsultPage'));
const UsersPage = lazy(() => import('@/pages/admin/UsersPage'));
const AuditPage = lazy(() => import('@/pages/admin/AuditPage'));
function PageLoader() {
return (
<div className="flex items-center justify-center h-full" style={{ color: 'var(--t4)' }}>
<Loader2 size={24} className="animate-spin" />
</div>
);
}
export default function App() { export default function App() {
const checkAuth = useAuthStore((s) => s.checkAuth); const checkAuth = useAuthStore((s) => s.checkAuth);
@@ -31,33 +41,33 @@ export default function App() {
}); });
}, [checkAuth, loadData]); }, [checkAuth, loadData]);
// Reload data when user changes (login)
useEffect(() => { useEffect(() => {
if (user) loadData(); if (user) loadData();
}, [user, loadData]); }, [user, loadData]);
return ( return (
<Routes> <Suspense fallback={<PageLoader />}>
<Route path="/login" element={<LoginPage />} /> <Routes>
<Route element={<AppShell />}> <Route path="/login" element={<LoginPage />} />
<Route index element={<Navigate to="/chat" replace />} /> <Route element={<AppShell />}>
<Route path="/chat" element={<ChatPage />} /> <Route index element={<Navigate to="/chat" replace />} />
<Route path="/terraform" element={<TerraformPage />} /> <Route path="/chat" element={<ChatPage />} />
<Route path="/terraform/prompt" element={<PromptGeneratorPage />} /> <Route path="/terraform" element={<TerraformPage />} />
<Route path="/terraform/workspaces" element={<WorkspacesPage />} /> <Route path="/terraform/prompt" element={<PromptGeneratorPage />} />
<Route path="/explorer" element={<ExplorerPage />} /> <Route path="/terraform/workspaces" element={<WorkspacesPage />} />
<Route path="/reports" element={<ReportsPage />} /> <Route path="/explorer" element={<ExplorerPage />} />
<Route path="/config/oci" element={<OciConfigPage />} /> <Route path="/reports" element={<ReportsPage />} />
<Route path="/config/genai" element={<GenAiConfigPage />} /> <Route path="/config/oci" element={<OciConfigPage />} />
<Route path="/config/mcp" element={<McpServersPage />} /> <Route path="/config/genai" element={<GenAiConfigPage />} />
<Route path="/config/adb" element={<AdbConfigPage />} /> <Route path="/config/mcp" element={<McpServersPage />} />
<Route path="/config/embeddings" element={<EmbeddingsPage />} /> <Route path="/config/adb" element={<AdbConfigPage />} />
<Route path="/config/embeddings/consult" element={<EmbConsultPage />} /> <Route path="/config/embeddings" element={<EmbeddingsPage />} />
<Route path="/admin/users" element={<UsersPage />} /> <Route path="/config/embeddings/consult" element={<EmbConsultPage />} />
<Route path="/admin/mfa" element={<MfaPage />} /> <Route path="/admin/users" element={<UsersPage />} />
<Route path="/admin/audit" element={<AuditPage />} /> <Route path="/admin/audit" element={<AuditPage />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/chat" replace />} /> <Route path="*" element={<Navigate to="/chat" replace />} />
</Routes> </Routes>
</Suspense>
); );
} }

View File

@@ -1,15 +1,16 @@
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { useState } from 'react';
import { import {
MessageSquare, Search, BarChart3, Cloud, MessageSquare, Search, BarChart3, Cloud,
Brain, Plug, Database, Dna, Users, Lock, FileText, Brain, Plug, Database, Dna, Users, FileText,
Sparkles, Sun, Moon, LogOut, FolderOpen, Languages Sparkles, Sun, Moon, LogOut, FolderOpen, Languages, Settings, ArrowLeft
} from 'lucide-react'; } from 'lucide-react';
import { useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useI18n } from '@/i18n'; import { useI18n } from '@/i18n';
const LOGO = ( const LOGO = (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width={30} height={30} style={{ flexShrink: 0 }}> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width={30} height={30} style={{ flexShrink: 0 }} aria-hidden="true">
<rect x="2" y="5" width="32" height="26" rx="13" ry="13" fill="rgba(255,255,255,0.12)" stroke="#fff" strokeWidth="2" /> <rect x="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" /> <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="15" cy="17" r="2" fill="#C74634" />
@@ -25,7 +26,7 @@ const LOGO = (
const SZ = 18; const SZ = 18;
const TerraformIcon = ({ size = 18 }: { size?: number }) => ( const TerraformIcon = ({ size = 18 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 64 64" fill="#7B42BC" style={{ flexShrink: 0 }}> <svg width={size} height={size} viewBox="0 0 64 64" fill="#7B42BC" style={{ flexShrink: 0 }} aria-hidden="true">
<polygon points="22.4,0 41.6,11.1 41.6,33.2 22.4,22.1" /> <polygon points="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="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="0.8,12.3 20,23.4 20,45.4 0.8,34.4" />
@@ -41,43 +42,40 @@ interface NavItem {
sub?: boolean; sub?: boolean;
} }
const NAV_SECTIONS: { titleKey: string; items: NavItem[] }[] = [ const NAV_ITEMS: NavItem[] = [
{ { to: '/chat', icon: <MessageSquare size={SZ} />, labelKey: 'nav.chat' },
titleKey: 'nav.principal', { to: '/terraform', icon: <TerraformIcon size={SZ + 2} />, labelKey: 'nav.terraform' },
items: [ { to: '/terraform/prompt', icon: <Sparkles size={SZ} />, labelKey: 'nav.promptGen', sub: true },
{ to: '/chat', icon: <MessageSquare size={SZ} />, labelKey: 'nav.chat' }, { to: '/terraform/workspaces', icon: <FolderOpen size={SZ} />, labelKey: 'nav.workspaces', sub: true },
{ to: '/terraform', icon: <TerraformIcon size={SZ + 2} />, labelKey: 'nav.terraform' }, { to: '/explorer', icon: <Search size={SZ} />, labelKey: 'nav.explorer' },
{ to: '/terraform/prompt', icon: <Sparkles size={SZ} />, labelKey: 'nav.promptGen', sub: true }, { to: '/reports', icon: <BarChart3 size={SZ} />, labelKey: 'nav.reports' },
{ to: '/terraform/workspaces', icon: <FolderOpen size={SZ} />, labelKey: 'nav.workspaces', sub: true }, { to: '/config/embeddings/consult', icon: <MessageSquare size={SZ} />, labelKey: 'nav.embConsult' },
{ to: '/explorer', icon: <Search size={SZ} />, labelKey: 'nav.explorer' }, ];
{ to: '/reports', icon: <BarChart3 size={SZ} />, labelKey: 'nav.reports' },
], const ADMIN_ITEMS: NavItem[] = [
}, { to: '/config/oci', icon: <Cloud size={SZ} />, labelKey: 'nav.ociCreds' },
{ { to: '/config/genai', icon: <Brain size={SZ} />, labelKey: 'nav.genai' },
titleKey: 'nav.config', { to: '/config/mcp', icon: <Plug size={SZ} />, labelKey: 'nav.mcp' },
items: [ { to: '/config/adb', icon: <Database size={SZ} />, labelKey: 'nav.adb' },
{ to: '/config/oci', icon: <Cloud size={SZ} />, labelKey: 'nav.ociCreds' }, { to: '/config/embeddings', icon: <Dna size={SZ} />, labelKey: 'nav.embeddings' },
{ to: '/config/genai', icon: <Brain size={SZ} />, labelKey: 'nav.genai' }, { to: '/admin/users', icon: <Users size={SZ} />, labelKey: 'nav.users', adminOnly: true },
{ to: '/config/mcp', icon: <Plug size={SZ} />, labelKey: 'nav.mcp' }, { to: '/admin/audit', icon: <FileText size={SZ} />, labelKey: 'nav.audit', adminOnly: true },
{ 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() { 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, lang, setLang } = useI18n();
const [showSettings, setShowSettings] = useState(false);
const linkCls = (sub?: boolean) =>
`flex items-center gap-3 rounded-xl font-medium transition-all duration-200 ${
sub ? 'pl-9 pr-3 py-2 text-[.78rem] opacity-90' : 'px-3 py-2.5 text-[.82rem]'
}`;
const linkStyle = (isActive: boolean) => isActive ? {
background: 'var(--acl2)', color: 'var(--ac)', boxShadow: 'inset 3px 0 0 var(--ac)',
} : { color: 'var(--t2)' };
return ( return (
<aside className="fixed top-0 left-0 bottom-0 w-[260px] flex flex-col z-50" <aside className="fixed top-0 left-0 bottom-0 w-[260px] flex flex-col z-50"
@@ -101,38 +99,54 @@ export default function Sidebar() {
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 overflow-y-auto px-2.5 py-3"> <nav className="flex-1 overflow-y-auto px-2.5 py-3">
{NAV_SECTIONS.map((section) => ( {showSettings ? (
<div key={section.titleKey} className="mb-1"> <>
<div className="text-[.6rem] uppercase tracking-[.14em] font-bold px-3 pt-4 pb-2" {/* Back button */}
<button
onClick={() => setShowSettings(false)}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-[.82rem] font-medium w-full cursor-pointer transition-all duration-200 mb-1"
style={{ color: 'var(--ac)', background: 'transparent', border: 'none' }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg2)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<ArrowLeft size={SZ} />
{t('sidebar.backToMenu')}
</button>
<div className="h-px mx-3 mb-2" style={{ background: 'var(--bd)' }} />
<div className="text-[.6rem] uppercase tracking-[.14em] font-bold px-3 pb-2"
style={{ color: 'var(--t4)' }}> style={{ color: 'var(--t4)' }}>
{t(section.titleKey)} {t('sidebar.settings')}
</div> </div>
{section.items {ADMIN_ITEMS
.filter((item) => !item.adminOnly || user?.role === 'admin') .filter((item) => !item.adminOnly || user?.role === 'admin')
.map((item) => ( .map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
end={item.to === '/terraform' || item.to === '/config/embeddings'} className={linkCls()}
className={`flex items-center gap-3 rounded-xl font-medium transition-all duration-200 ${ style={({ isActive }) => linkStyle(isActive)}
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} {item.icon}
{t(item.labelKey)} {t(item.labelKey)}
</NavLink> </NavLink>
))} ))}
</div> </>
))} ) : (
NAV_ITEMS
.filter((item) => !item.adminOnly || user?.role === 'admin')
.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/terraform' || item.to === '/config/embeddings'}
className={linkCls(item.sub)}
style={({ isActive }) => linkStyle(isActive)}
>
{item.icon}
{t(item.labelKey)}
</NavLink>
))
)}
</nav> </nav>
{/* Footer */} {/* Footer */}
@@ -142,6 +156,7 @@ export default function Sidebar() {
className="p-2.5 rounded-xl transition-all duration-200" className="p-2.5 rounded-xl transition-all duration-200"
style={{ color: 'var(--t3)' }} style={{ color: 'var(--t3)' }}
title={isDark ? t('sidebar.lightMode') : t('sidebar.darkMode')} title={isDark ? t('sidebar.lightMode') : t('sidebar.darkMode')}
aria-label={isDark ? t('sidebar.lightMode') : t('sidebar.darkMode')}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg3)'; e.currentTarget.style.color = 'var(--t1)'; }} 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)'; }} onMouseLeave={(e) => { e.currentTarget.style.background = ''; e.currentTarget.style.color = 'var(--t3)'; }}
> >
@@ -152,6 +167,7 @@ export default function Sidebar() {
className="p-2.5 rounded-xl transition-all duration-200 flex items-center gap-1" className="p-2.5 rounded-xl transition-all duration-200 flex items-center gap-1"
style={{ color: 'var(--t3)' }} style={{ color: 'var(--t3)' }}
title={lang === 'pt' ? 'English' : 'Português'} 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)'; }} 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)'; }} onMouseLeave={(e) => { e.currentTarget.style.background = ''; e.currentTarget.style.color = 'var(--t3)'; }}
> >
@@ -170,11 +186,23 @@ export default function Sidebar() {
</p> </p>
</div> </div>
</div> </div>
<button
onClick={() => setShowSettings(!showSettings)}
className="p-2.5 rounded-xl transition-all duration-200"
style={{ color: showSettings ? 'var(--ac)' : 'var(--t3)', background: showSettings ? 'var(--acl2)' : '' }}
title={t('sidebar.settings')}
aria-label={t('sidebar.settings')}
onMouseEnter={(e) => { if (!showSettings) { e.currentTarget.style.background = 'var(--bg3)'; e.currentTarget.style.color = 'var(--t1)'; } }}
onMouseLeave={(e) => { if (!showSettings) { e.currentTarget.style.background = ''; e.currentTarget.style.color = 'var(--t3)'; } }}
>
<Settings size={18} />
</button>
<button <button
onClick={logout} onClick={logout}
className="p-2.5 rounded-xl transition-all duration-200" className="p-2.5 rounded-xl transition-all duration-200"
style={{ color: 'var(--t3)' }} style={{ color: 'var(--t3)' }}
title={t('sidebar.logout')} title={t('sidebar.logout')}
aria-label={t('sidebar.logout')}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--rdl)'; e.currentTarget.style.color = 'var(--rd)'; }} 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)'; }} onMouseLeave={(e) => { e.currentTarget.style.background = ''; e.currentTarget.style.color = 'var(--t3)'; }}
> >

View File

@@ -1,68 +0,0 @@
import type { ReactNode } from 'react';
import { ArrowRight, Wrench } from 'lucide-react';
interface StubPageProps {
icon: ReactNode;
title: string;
description: string;
legacyTab: string;
color?: string;
features?: string[];
}
export default function StubPage({ icon, title, description, legacyTab, color = 'var(--ac)', features }: StubPageProps) {
return (
<div className="flex items-center justify-center h-full p-8">
<div className="text-center max-w-md w-full animate-[fadeIn_.4s_ease]">
{/* Icon with glow */}
<div className="relative inline-flex items-center justify-center mb-6">
<div className="absolute inset-0 rounded-full blur-2xl opacity-20"
style={{ background: color, transform: 'scale(2)' }} />
<div className="relative w-20 h-20 rounded-2xl flex items-center justify-center"
style={{ background: `color-mix(in srgb, ${color} 10%, transparent)`, border: `1px solid color-mix(in srgb, ${color} 20%, transparent)` }}>
<div style={{ color }}>{icon}</div>
</div>
</div>
{/* Title */}
<h2 className="text-xl font-bold mb-2" style={{ color: 'var(--t1)' }}>{title}</h2>
<p className="text-sm mb-6" style={{ color: 'var(--t3)' }}>{description}</p>
{/* Features preview */}
{features && (
<div className="mb-6 text-left rounded-xl p-4 mx-auto max-w-xs"
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)' }}>
<div className="flex items-center gap-2 mb-3">
<Wrench size={14} style={{ color: 'var(--t4)' }} />
<span className="text-[.7rem] font-semibold uppercase tracking-wider" style={{ color: 'var(--t4)' }}>
Em desenvolvimento
</span>
</div>
{features.map((f, i) => (
<div key={i} className="flex items-center gap-2 py-1.5">
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: color, opacity: 0.6 }} />
<span className="text-xs" style={{ color: 'var(--t2)' }}>{f}</span>
</div>
))}
</div>
)}
{/* CTA */}
<a
href={`/?tab=${legacyTab}`}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold transition-all duration-200"
style={{ background: color, color: '#fff' }}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = 'var(--sh2)'; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = ''; }}
>
Abrir versao atual
<ArrowRight size={16} />
</a>
<p className="text-[.65rem] mt-4" style={{ color: 'var(--t4)' }}>
Migrando para React em breve nesta interface
</p>
</div>
</div>
);
}

View File

@@ -1,7 +1,6 @@
const en: Record<string, string> = { const en: Record<string, string> = {
// ── Sidebar ── // ── Sidebar ──
'nav.principal': 'Main', 'nav.principal': 'Main',
'nav.config': 'Configuration',
'nav.admin': 'Administration', 'nav.admin': 'Administration',
'nav.chat': 'Chat Agent', 'nav.chat': 'Chat Agent',
'nav.terraform': 'Terraform', 'nav.terraform': 'Terraform',
@@ -16,11 +15,12 @@ const en: Record<string, string> = {
'nav.embeddings': 'Embeddings', 'nav.embeddings': 'Embeddings',
'nav.embConsult': 'Query Embeddings', 'nav.embConsult': 'Query Embeddings',
'nav.users': 'Users', 'nav.users': 'Users',
'nav.mfa': 'MFA',
'nav.audit': 'Audit Log', 'nav.audit': 'Audit Log',
'sidebar.lightMode': 'Light mode', 'sidebar.lightMode': 'Light mode',
'sidebar.darkMode': 'Dark mode', 'sidebar.darkMode': 'Dark mode',
'sidebar.logout': 'Logout', 'sidebar.logout': 'Logout',
'sidebar.settings': 'Settings',
'sidebar.backToMenu': 'Back to menu',
'sidebar.roleAdmin': 'Administrator', 'sidebar.roleAdmin': 'Administrator',
'sidebar.roleUser': 'User', 'sidebar.roleUser': 'User',
'sidebar.subtitle': 'Infrastructure & Security', 'sidebar.subtitle': 'Infrastructure & Security',
@@ -107,6 +107,14 @@ const en: Record<string, string> = {
'tf.copied': 'Copied', 'tf.copied': 'Copied',
'tf.noWs': 'No active workspace', 'tf.noWs': 'No active workspace',
'tf.deleteWs': 'Delete', 'tf.deleteWs': 'Delete',
'tf.noFilesToPlan': 'No files to plan',
'tf.selectOciConfig': 'Select OCI Config and Compartment',
'tf.running': 'Running...',
'tf.terminalTitle': 'Terraform output terminal',
'tf.terminalHint': 'Run Plan, Apply or Destroy to see results here',
'tf.autoFix': 'Auto-fix',
'tf.editSystemPrompt': 'Edit System Prompt',
'tf.refreshReference': 'Refresh Reference',
// ── Prompt Generator ── // ── Prompt Generator ──
'pg.history': 'History', 'pg.history': 'History',
@@ -308,24 +316,15 @@ const en: Record<string, string> = {
'rpt.errorEmbedding': 'Error generating embedding', 'rpt.errorEmbedding': 'Error generating embedding',
'rpt.cancelled': 'Cancelled', 'rpt.cancelled': 'Cancelled',
'rpt.completedAt': 'Completed:', 'rpt.completedAt': 'Completed:',
'rpt.complianceGenerating': 'Generating Compliance Report...',
// ── Downloads ── 'rpt.complianceFetchingKB': 'Fetching remediations from CIS Knowledge Base',
'dl.title': 'Downloads', 'rpt.complianceNotGenerated': 'Report not generated yet',
'dl.subtitle': 'Download generated CIS reports', 'rpt.complianceGenerate': 'Generate Compliance Report',
'dl.allTenancies': 'All Tenancies', 'rpt.complianceRegenerate': 'Regenerate',
'dl.allSections': 'All sections', 'rpt.complianceDownloadZip': 'Download ZIP',
'dl.reports': 'report(s)', 'rpt.complianceRemediation': '+ Remediation',
'dl.refresh': 'Refresh', 'rpt.complianceGeneratingShort': 'Generating...',
'dl.loadError': 'Error loading reports', 'rpt.complianceTitle': 'LAD A-Team CIS Compliance Report',
'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 Config ──
'oci.title': 'OCI Credentials', 'oci.title': 'OCI Credentials',
@@ -605,6 +604,7 @@ const en: Record<string, string> = {
'mfa.activated': 'MFA activated successfully!', 'mfa.activated': 'MFA activated successfully!',
'mfa.deactivated': 'MFA deactivated.', 'mfa.deactivated': 'MFA deactivated.',
'mfa.invalidCode': 'Enter a 6-digit code', 'mfa.invalidCode': 'Enter a 6-digit code',
'mfa.configure': 'Configure',
// ── Audit ── // ── Audit ──
'audit.title': 'Audit Log', 'audit.title': 'Audit Log',
@@ -634,6 +634,85 @@ const en: Record<string, string> = {
'audit.session': 'Session', 'audit.session': 'Session',
'audit.source': 'Source', 'audit.source': 'Source',
// ── Hardcoded fixes ──
'common.errorPrefix': 'Error: ',
'common.errorSave': 'Error saving',
'common.errorDelete': 'Error deleting',
'common.errorUnknown': 'Unknown error',
'common.errorLoad': 'Error loading',
'oci.thTenancy': 'Tenancy',
'oci.thType': 'Type',
'oci.thRegion': 'Region',
'oci.thDetails': 'Details',
'oci.thActions': 'Actions',
'genai.thConfig': 'Config',
'genai.thModel': 'Model',
'genai.thOciConfig': 'OCI Config',
'genai.thRegion': 'Region',
'genai.thActions': 'Actions',
'genai.savedSuccess': 'Model saved successfully!',
'genai.updatedSuccess': 'Model updated successfully!',
'genai.deletedSuccess': 'Model deleted successfully!',
'genai.editingLabel': 'Editing:',
'genai.regionFilled': 'Region and Compartment filled from <strong>{0}</strong>',
'mcp.savedSuccess': 'Server registered successfully!',
'mcp.updatedSuccess': 'Server updated successfully!',
'mcp.deletingServer': 'Deleting server...',
'mcp.deletedSuccess': 'Server deleted successfully!',
'mcp.discoveredTools': '{0} tool(s) discovered, {1} total',
'mcp.errorDiscoverTools': 'Error discovering tools',
'mcp.removeToolConfirm': 'Remove tool "{0}"?',
'mcp.editingLabel': 'Editing:',
'adb.thName': 'Name',
'adb.thDsn': 'DSN',
'adb.thUser': 'User',
'adb.thTables': 'Tables',
'adb.thMtls': 'mTLS',
'adb.thWallet': 'Wallet',
'adb.thEmbed': 'Embed',
'adb.thActions': 'Actions',
'adb.thTable': 'Table',
'adb.thDescription': 'Description',
'adb.thActive': 'Active',
'adb.thTableActions': 'Actions',
'adb.walletParsed': 'Wallet parsed! {0} DSN(s): <strong>{1}</strong>. Files: {2}',
'adb.savedSuccess': 'Connection saved!',
'adb.updatedSuccess': 'Connection updated!',
'adb.walletIncluded': ' Wallet included.',
'adb.deletedSuccess': 'Connection deleted successfully!',
'adb.walletSending': 'Sending wallet...',
'adb.walletSent': 'Wallet uploaded! Files: {0}',
'adb.enterTableName': 'Enter the table name.',
'adb.tableRegistered': 'Table {0} registered!',
'adb.confirmRemoveTable': 'Remove this table from registry?',
'adb.tableRemoved': 'Table removed.',
'adb.editingLabel': 'Editing:',
'emb.errorLoadEmb': 'Error loading embeddings',
'emb.confirmDeleteEmb': 'Delete this embedding?',
'emb.totalDocs': 'Total: {0} documents',
'ec.errorPrefix': 'Error: ',
'ec.errorUnknown': 'Unknown error',
'ec.docs': 'documents',
'usr.errorLoadUsers': 'Error loading users',
'usr.errorCreateUser': 'Error creating user',
'usr.errorUpdateRole': 'Error updating role',
'usr.errorDeactivate': 'Error deactivating user',
'usr.mfaSelfOnly': 'MFA can only be activated by the user themselves.',
'ws.typeDestroyLabel': 'Type <strong>DESTROY</strong> to confirm:',
'exp.tempAccess': 'Temporary access - ',
'exp.optional': 'Optional',
'exp.ipPrompt': 'IP to allow access (CIDR):',
'exp.nsgType': 'Type',
// ── Common ── // ── Common ──
'common.save': 'Save', 'common.save': 'Save',
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',

View File

@@ -1,7 +1,6 @@
const pt: Record<string, string> = { const pt: Record<string, string> = {
// ── Sidebar ── // ── Sidebar ──
'nav.principal': 'Principal', 'nav.principal': 'Principal',
'nav.config': 'Configuração',
'nav.admin': 'Administração', 'nav.admin': 'Administração',
'nav.chat': 'Chat Agent', 'nav.chat': 'Chat Agent',
'nav.terraform': 'Terraform', 'nav.terraform': 'Terraform',
@@ -16,11 +15,12 @@ const pt: Record<string, string> = {
'nav.embeddings': 'Embeddings', 'nav.embeddings': 'Embeddings',
'nav.embConsult': 'Consultar Embeddings', 'nav.embConsult': 'Consultar Embeddings',
'nav.users': 'Usuários', 'nav.users': 'Usuários',
'nav.mfa': 'MFA',
'nav.audit': 'Audit Log', 'nav.audit': 'Audit Log',
'sidebar.lightMode': 'Modo claro', 'sidebar.lightMode': 'Modo claro',
'sidebar.darkMode': 'Modo escuro', 'sidebar.darkMode': 'Modo escuro',
'sidebar.logout': 'Sair', 'sidebar.logout': 'Sair',
'sidebar.settings': 'Configurações',
'sidebar.backToMenu': 'Voltar ao menu',
'sidebar.roleAdmin': 'Administrador', 'sidebar.roleAdmin': 'Administrador',
'sidebar.roleUser': 'Usuário', 'sidebar.roleUser': 'Usuário',
'sidebar.subtitle': 'Infrastructure & Security', 'sidebar.subtitle': 'Infrastructure & Security',
@@ -107,6 +107,14 @@ const pt: Record<string, string> = {
'tf.copied': 'Copiado', 'tf.copied': 'Copiado',
'tf.noWs': 'Nenhum workspace ativo', 'tf.noWs': 'Nenhum workspace ativo',
'tf.deleteWs': 'Excluir', 'tf.deleteWs': 'Excluir',
'tf.noFilesToPlan': 'Nenhum arquivo para planejar',
'tf.selectOciConfig': 'Selecione OCI Config e Compartment',
'tf.running': 'Executando...',
'tf.terminalTitle': 'Terminal de saida do Terraform',
'tf.terminalHint': 'Execute Plan, Apply ou Destroy para ver os resultados aqui',
'tf.autoFix': 'Auto-corrigir',
'tf.editSystemPrompt': 'Editar System Prompt',
'tf.refreshReference': 'Refresh Reference',
// ── Prompt Generator ── // ── Prompt Generator ──
'pg.history': 'Historico', 'pg.history': 'Historico',
@@ -308,24 +316,15 @@ const pt: Record<string, string> = {
'rpt.errorEmbedding': 'Erro ao gerar embedding', 'rpt.errorEmbedding': 'Erro ao gerar embedding',
'rpt.cancelled': 'Cancelado', 'rpt.cancelled': 'Cancelado',
'rpt.completedAt': 'Concluido:', 'rpt.completedAt': 'Concluido:',
'rpt.complianceGenerating': 'Gerando Compliance Report...',
// ── Downloads ── 'rpt.complianceFetchingKB': 'Buscando remediações na Knowledge Base CIS',
'dl.title': 'Downloads', 'rpt.complianceNotGenerated': 'Report ainda não gerado',
'dl.subtitle': 'Baixe relatorios CIS gerados', 'rpt.complianceGenerate': 'Gerar Compliance Report',
'dl.allTenancies': 'Todas as Tenancies', 'rpt.complianceRegenerate': 'Regenerar',
'dl.allSections': 'Todas as secoes', 'rpt.complianceDownloadZip': 'Download ZIP',
'dl.reports': 'relatorio(s)', 'rpt.complianceRemediation': '+ Remediation',
'dl.refresh': 'Atualizar', 'rpt.complianceGeneratingShort': 'Gerando...',
'dl.loadError': 'Erro ao carregar relatorios', 'rpt.complianceTitle': 'LAD A-Team CIS Compliance Report',
'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 Config ──
'oci.title': 'Credenciais OCI', 'oci.title': 'Credenciais OCI',
@@ -605,6 +604,7 @@ const pt: Record<string, string> = {
'mfa.activated': 'MFA ativado com sucesso!', 'mfa.activated': 'MFA ativado com sucesso!',
'mfa.deactivated': 'MFA desativado.', 'mfa.deactivated': 'MFA desativado.',
'mfa.invalidCode': 'Informe um codigo de 6 digitos', 'mfa.invalidCode': 'Informe um codigo de 6 digitos',
'mfa.configure': 'Configurar',
// ── Audit ── // ── Audit ──
'audit.title': 'Log de Auditoria', 'audit.title': 'Log de Auditoria',
@@ -634,6 +634,86 @@ const pt: Record<string, string> = {
'audit.session': 'Sessao', 'audit.session': 'Sessao',
'audit.source': 'Origem', 'audit.source': 'Origem',
// ── Hardcoded fixes ──
'common.errorPrefix': 'Erro: ',
'common.errorSave': 'Erro ao salvar',
'common.errorDelete': 'Erro ao excluir',
'common.errorUnknown': 'Erro desconhecido',
'common.errorLoad': 'Erro ao carregar',
'oci.tableHeaders': 'Tenancy,Tipo,Region,Detalhes,Acoes',
'oci.thTenancy': 'Tenancy',
'oci.thType': 'Tipo',
'oci.thRegion': 'Region',
'oci.thDetails': 'Detalhes',
'oci.thActions': 'Acoes',
'genai.thConfig': 'Config',
'genai.thModel': 'Modelo',
'genai.thOciConfig': 'OCI Config',
'genai.thRegion': 'Regiao',
'genai.thActions': 'Acoes',
'genai.savedSuccess': 'Modelo salvo com sucesso!',
'genai.updatedSuccess': 'Modelo atualizado com sucesso!',
'genai.deletedSuccess': 'Modelo excluido com sucesso!',
'genai.editingLabel': 'Editando:',
'genai.regionFilled': 'Regiao e Compartment preenchidos de <strong>{0}</strong>',
'mcp.savedSuccess': 'Server registrado com sucesso!',
'mcp.updatedSuccess': 'Server atualizado com sucesso!',
'mcp.deletingServer': 'Excluindo server...',
'mcp.deletedSuccess': 'Server excluido com sucesso!',
'mcp.discoveredTools': '{0} tool(s) descoberta(s), {1} total',
'mcp.errorDiscoverTools': 'Erro ao descobrir tools',
'mcp.removeToolConfirm': 'Remover tool "{0}"?',
'mcp.editingLabel': 'Editando:',
'adb.thName': 'Nome',
'adb.thDsn': 'DSN',
'adb.thUser': 'User',
'adb.thTables': 'Tabelas',
'adb.thMtls': 'mTLS',
'adb.thWallet': 'Wallet',
'adb.thEmbed': 'Embed',
'adb.thActions': 'Acoes',
'adb.thTable': 'Tabela',
'adb.thDescription': 'Descricao',
'adb.thActive': 'Ativa',
'adb.thTableActions': 'Acoes',
'adb.walletParsed': 'Wallet analisado! {0} DSN(s): <strong>{1}</strong>. Arquivos: {2}',
'adb.savedSuccess': 'Conexao salva!',
'adb.updatedSuccess': 'Conexao atualizada!',
'adb.walletIncluded': ' Wallet incluido.',
'adb.deletedSuccess': 'Conexao excluida com sucesso!',
'adb.walletSending': 'Enviando wallet...',
'adb.walletSent': 'Wallet enviada! Arquivos: {0}',
'adb.enterTableName': 'Digite o nome da tabela.',
'adb.tableRegistered': 'Tabela {0} registrada!',
'adb.confirmRemoveTable': 'Excluir esta tabela do registro?',
'adb.tableRemoved': 'Tabela removida.',
'adb.editingLabel': 'Editando:',
'emb.errorLoadEmb': 'Erro ao carregar embeddings',
'emb.confirmDeleteEmb': 'Excluir este embedding?',
'emb.totalDocs': 'Total: {0} documentos',
'ec.errorPrefix': 'Erro: ',
'ec.errorUnknown': 'Erro desconhecido',
'ec.docs': 'documentos',
'usr.errorLoadUsers': 'Erro ao carregar usuarios',
'usr.errorCreateUser': 'Erro ao criar usuario',
'usr.errorUpdateRole': 'Erro ao atualizar role',
'usr.errorDeactivate': 'Erro ao desativar usuario',
'usr.mfaSelfOnly': 'MFA so pode ser ativado pelo proprio usuario.',
'ws.typeDestroyLabel': 'Digite <strong>DESTROY</strong> para confirmar:',
'exp.tempAccess': 'Acesso temporario - ',
'exp.optional': 'Opcional',
'exp.ipPrompt': 'IP para liberar acesso (CIDR):',
'exp.nsgType': 'Tipo',
// ── Common ── // ── Common ──
'common.save': 'Salvar', 'common.save': 'Salvar',
'common.cancel': 'Cancelar', 'common.cancel': 'Cancelar',

View File

@@ -137,6 +137,12 @@
} }
} }
/* ── Focus-visible for accessibility ── */
button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
outline: 2px solid var(--ac);
outline-offset: 2px;
}
/* ── Component styles — OUTSIDE layers so they override Tailwind utilities ── */ /* ── Component styles — OUTSIDE layers so they override Tailwind utilities ── */
/* Nav item hover */ /* Nav item hover */

View File

@@ -6,7 +6,7 @@ import {
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight'; import rehypeHighlight from 'rehype-highlight';
import { useAppStore } from '@/stores/app'; import { useAppStore, type ChatParams, DEFAULT_CHAT_PARAMS } from '@/stores/app';
import { useI18n } from '@/i18n'; import { useI18n } from '@/i18n';
import { import {
chatApi, chatApi,
@@ -31,29 +31,9 @@ interface AttachedFile {
preview?: string; preview?: string;
} }
interface ChatParams {
temperature: number;
max_tokens: number;
top_p: number;
top_k: number;
frequency_penalty: number;
presence_penalty: number;
reasoning_effort: string;
}
const ACCEPT = const ACCEPT =
'image/*,.pdf,.txt,.csv,.json,.log,.xml,.yaml,.yml,.md,.py,.js,.ts,.html,.css,.sql,.tf,.hcl'; 'image/*,.pdf,.txt,.csv,.json,.log,.xml,.yaml,.yml,.md,.py,.js,.ts,.html,.css,.sql,.tf,.hcl';
const DEFAULT_PARAMS: ChatParams = {
temperature: 1,
max_tokens: 6000,
top_p: 0.95,
top_k: 1,
frequency_penalty: 0,
presence_penalty: 0,
reasoning_effort: 'medium',
};
/* ── Helpers ── */ /* ── Helpers ── */
function nowHHmm(): string { function nowHHmm(): string {
@@ -95,23 +75,27 @@ function groupSessions(sessions: ChatSession[]): Record<string, ChatSession[]> {
export default function ChatPage() { export default function ChatPage() {
/* ── Store ── */ /* ── Store ── */
const { ociCfg, genaiCfg, models, mcpSvr, adbCfg } = useAppStore(); const { ociCfg, genaiCfg, models, mcpSvr, adbCfg,
chatModel: model, setChatModel: setModel,
chatOciConfig: ociConfig, setChatOciConfig: setOciConfig,
chatParams, setChatParams,
chatUseTools: useTools, setChatUseTools: setUseTools,
chatMessages, setChatMessages,
chatSessionId, setChatSessionId,
chatSessions, setChatSessions,
} = useAppStore();
const { t } = useI18n(); const { t } = useI18n();
// Aliases for store state (keep local names for minimal code changes)
const messages = chatMessages as UIMessage[];
const setMessages = setChatMessages as (v: UIMessage[] | ((prev: UIMessage[]) => UIMessage[])) => void;
const sessionId = chatSessionId;
const setSessionId = setChatSessionId;
const sessions = chatSessions as ChatSession[];
const setSessions = setChatSessions as (v: ChatSession[] | ((prev: ChatSession[]) => ChatSession[])) => void;
/* ── State ── */ /* ── State ── */
const [messages, setMessages] = useState<UIMessage[]>([]);
const [sessionId, setSessionId] = useState<string | null>(null);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [model, setModel] = useState(() => {
// Auto-select first genai config as default model
const store = useAppStore.getState();
if (store.genaiCfg.length > 0) return `cfg:${store.genaiCfg[0].id}`;
return '';
});
const [ociConfig, setOciConfig] = useState('');
const [files, setFiles] = useState<AttachedFile[]>([]); const [files, setFiles] = useState<AttachedFile[]>([]);
const [chatParams, setChatParams] = useState<ChatParams>({ ...DEFAULT_PARAMS });
const [useTools, setUseTools] = useState(true);
const [historyOpen, setHistoryOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
@@ -151,12 +135,15 @@ export default function ChatPage() {
[mcpSvr], [mcpSvr],
); );
// Auto-select default model when genaiCfg loads // Auto-select default model: prefer saved GenAI config, fallback to first direct model
useEffect(() => { useEffect(() => {
if (!model && genaiCfg.length > 0) { if (model) return;
setModel(`cfg:${genaiCfg[0].id}`); if (genaiCfg.length > 0) { setModel(`cfg:${genaiCfg[0].id}`); return; }
} const ids = Object.keys(models);
}, [genaiCfg]); // eslint-disable-line react-hooks/exhaustive-deps const preferred = ids.find((m) => m.includes('gpt-4.1') && !m.includes('mini'));
if (preferred) { setModel(preferred); return; }
if (ids.length > 0) setModel(ids[0]);
}, [genaiCfg, models, model, setModel]);
const hasRag = useMemo( const hasRag = useMemo(
() => adbCfg.some((c) => c.is_active) && (genaiCfg.length > 0 || ociCfg.length > 0), () => adbCfg.some((c) => c.is_active) && (genaiCfg.length > 0 || ociCfg.length > 0),
@@ -450,7 +437,7 @@ export default function ChatPage() {
} catch (err: any) { } catch (err: any) {
setMessages((prev) => [ setMessages((prev) => [
...prev.filter((m) => !m.thinking), ...prev.filter((m) => !m.thinking),
{ role: 'assistant', content: `Erro: ${err.message || t('chat.sendError')}`, timestamp: nowHHmm(), failed: true }, { role: 'assistant', content: `${t('common.errorPrefix')}${err.message || t('chat.sendError')}`, timestamp: nowHHmm(), failed: true },
]); ]);
} finally { } finally {
setSending(false); setSending(false);
@@ -618,6 +605,7 @@ export default function ChatPage() {
className="p-1.5 rounded cursor-pointer" className="p-1.5 rounded cursor-pointer"
style={{ background: historyOpen ? 'var(--bg3)' : 'transparent', color: 'var(--t2)' }} style={{ background: historyOpen ? 'var(--bg3)' : 'transparent', color: 'var(--t2)' }}
title={t('chat.history')} title={t('chat.history')}
aria-label={t('chat.history')}
> >
<Menu size={18} /> <Menu size={18} />
</button> </button>
@@ -738,6 +726,7 @@ export default function ChatPage() {
className="p-1.5 rounded cursor-pointer" className="p-1.5 rounded cursor-pointer"
style={{ background: settingsOpen ? 'var(--bg3)' : 'transparent', color: 'var(--t2)' }} style={{ background: settingsOpen ? 'var(--bg3)' : 'transparent', color: 'var(--t2)' }}
title={t('chat.settings')} title={t('chat.settings')}
aria-label={t('chat.settings')}
> >
<Settings size={18} /> <Settings size={18} />
</button> </button>
@@ -748,6 +737,7 @@ export default function ChatPage() {
className="p-1.5 rounded cursor-pointer" className="p-1.5 rounded cursor-pointer"
style={{ color: 'var(--t2)' }} style={{ color: 'var(--t2)' }}
title={t('chat.newChatTitle')} title={t('chat.newChatTitle')}
aria-label={t('chat.newChatTitle')}
> >
<Plus size={18} /> <Plus size={18} />
</button> </button>
@@ -898,6 +888,7 @@ export default function ChatPage() {
className="p-2 rounded cursor-pointer flex-shrink-0" className="p-2 rounded cursor-pointer flex-shrink-0"
style={{ color: 'var(--t3)', background: 'var(--bg2)', border: '1px solid var(--bd)' }} style={{ color: 'var(--t3)', background: 'var(--bg2)', border: '1px solid var(--bd)' }}
title={t('chat.attachFile')} title={t('chat.attachFile')}
aria-label={t('chat.attachFile')}
> >
<Paperclip size={18} /> <Paperclip size={18} />
</button> </button>
@@ -934,6 +925,7 @@ export default function ChatPage() {
opacity: sending ? 0.6 : 1, opacity: sending ? 0.6 : 1,
}} }}
title={t('chat.send')} title={t('chat.send')}
aria-label={t('chat.send')}
> >
{sending ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />} {sending ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button> </button>

View File

@@ -1,453 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import {
FolderDown, ChevronRight, RefreshCw, Download, FileText, Lock, Globe,
Server, Activity, Database, Package, BarChart3, AlertCircle, Loader2, Filter,
} from 'lucide-react';
import client from '@/api/client';
import { useI18n } from '@/i18n';
/* ── Types ── */
interface Report {
id: string;
tenancy_name: string;
status: string;
level: number | null;
created_at: string;
completed_at: string | null;
}
interface ReportFile {
id: string;
file_name: string;
file_type: string;
file_category: string;
file_size: number;
}
/* ── Helpers ── */
function extractSection(name: string): string {
const m = name.match(/^(?:cis|obp)_([A-Za-z_]+?)_\d/);
if (m) return m[1].replace(/_/g, ' ');
if (name.includes('summary')) return 'Summary';
if (name.includes('error')) return 'Error';
return 'Other';
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function fileExt(name: string): string {
return (name.split('.').pop() || '').toLowerCase();
}
const extColors: Record<string, string> = {
html: '#e44d26',
csv: '#217346',
json: '#f5a623',
xlsx: '#217346',
txt: '#6b7280',
pdf: '#dc2626',
};
const sectionIcons: Record<string, typeof Lock> = {
'Identity and Access Management': Lock,
'Networking': Globe,
'Compute': Server,
'Logging and Monitoring': Activity,
'Storage Object Storage': Database,
'Storage Block Volumes': Database,
'Storage File Storage Service': Database,
'Asset Management': Package,
'Summary': BarChart3,
'Error': AlertCircle,
'Other': FileText,
};
function sectionSort(a: string, b: string): number {
if (a === 'Summary') return -1;
if (b === 'Summary') return 1;
if (a === 'Other') return 1;
if (b === 'Other') return -1;
return a.localeCompare(b);
}
function groupBySection(files: ReportFile[]): Record<string, ReportFile[]> {
const map: Record<string, ReportFile[]> = {};
for (const f of files) {
const sec = extractSection(f.file_name);
(map[sec] ||= []).push(f);
}
return map;
}
/* ── Component ── */
export default function DownloadsPage() {
const { t } = useI18n();
const [reports, setReports] = useState<Report[]>([]);
const [expandedRid, setExpandedRid] = useState<string | null>(null);
const [filesCache, setFilesCache] = useState<Record<string, ReportFile[]>>({});
const [loadingFiles, setLoadingFiles] = useState<string | null>(null);
const [tenancyFilter, setTenancyFilter] = useState('');
const [sectionFilter, setSectionFilter] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const token = localStorage.getItem('t') || '';
const loadReports = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await client.get('/reports') as unknown as Report[];
setReports(data.filter((r) => r.status === 'completed'));
} catch (err) {
setError(err instanceof Error ? err.message : t('dl.loadError'));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadReports(); }, [loadReports]);
const handleRefresh = useCallback(() => {
setFilesCache({});
setExpandedRid(null);
setSectionFilter('');
loadReports();
}, [loadReports]);
const toggleExpand = useCallback(async (rid: string) => {
if (expandedRid === rid) {
setExpandedRid(null);
return;
}
setExpandedRid(rid);
if (!filesCache[rid]) {
setLoadingFiles(rid);
try {
const files = await client.get(`/reports/${rid}/files`) as unknown as ReportFile[];
setFilesCache((prev) => ({ ...prev, [rid]: files }));
} catch {
setFilesCache((prev) => ({ ...prev, [rid]: [] }));
} finally {
setLoadingFiles(null);
}
}
}, [expandedRid, filesCache]);
const handleDownload = useCallback((rid: string, fid: string) => {
window.open(`/api/reports/${rid}/files/${fid}/download?token=${token}`, '_blank');
}, [token]);
/* Derived data */
const tenancies = [...new Set(reports.map((r) => r.tenancy_name))].sort();
const filtered = tenancyFilter ? reports.filter((r) => r.tenancy_name === tenancyFilter) : reports;
/* Sections available in expanded report (for filter) */
const expandedFiles = expandedRid ? filesCache[expandedRid] : null;
const expandedSections = expandedFiles
? [...new Set(expandedFiles.map((f) => extractSection(f.file_name)))].sort(sectionSort)
: [];
return (
<div className="page">
{/* Header */}
<div className="page-header">
<div className="icon" style={{ background: 'var(--bg3)' }}>
<FolderDown size={24} style={{ color: 'var(--t3)' }} />
</div>
<div>
<h1>{t('dl.title')}</h1>
<div className="subtitle">{t('dl.subtitle')}</div>
</div>
<div className="spacer" />
<span className="count">{filtered.length} {t('dl.reports')}</span>
</div>
{/* Toolbar */}
<div className="card" style={{ padding: '12px 20px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div className="flex items-center gap-3 flex-wrap">
{/* Tenancy filter */}
<div className="flex items-center gap-1.5">
<Filter size={13} style={{ color: 'var(--t4)' }} />
<select
value={tenancyFilter}
onChange={(e) => { setTenancyFilter(e.target.value); setExpandedRid(null); setSectionFilter(''); }}
className="text-xs py-1.5 px-2.5 rounded-lg outline-none cursor-pointer"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 180 }}
>
<option value="">{t('dl.allTenancies')} ({reports.length})</option>
{tenancies.map((tn) => (
<option key={tn} value={tn}>
{tn} ({reports.filter((r) => r.tenancy_name === tn).length})
</option>
))}
</select>
</div>
{/* Section filter (only when expanded) */}
{expandedRid && expandedSections.length > 1 && (
<select
value={sectionFilter}
onChange={(e) => setSectionFilter(e.target.value)}
className="text-xs py-1.5 px-2.5 rounded-lg outline-none cursor-pointer"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 160 }}
>
<option value="">{t('dl.allSections')}</option>
{expandedSections.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
)}
<span className="text-[.7rem]" style={{ color: 'var(--t4)' }}>
{filtered.length} {t('dl.reports')}
</span>
</div>
<button
onClick={handleRefresh}
disabled={loading}
className="btn btn-secondary btn-sm"
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
{t('dl.refresh')}
</button>
</div>
{/* Error */}
{error && (
<div
className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg mb-4 text-xs font-medium"
style={{ background: 'var(--rdl)', color: 'var(--rd)' }}
>
<AlertCircle size={14} />
{error}
</div>
)}
{/* Loading */}
{loading && !reports.length && (
<div className="flex items-center justify-center gap-2 py-16" style={{ color: 'var(--t4)' }}>
<Loader2 size={18} className="animate-spin" />
<span className="text-sm">{t('dl.loading')}</span>
</div>
)}
{/* Empty state */}
{!loading && !filtered.length && (
<div className="card">
<div className="empty-state">
<FolderDown size={40} />
<h3>{t('dl.noReports')}</h3>
<p>{t('dl.noReportsHint')}</p>
</div>
</div>
)}
{/* Report list */}
<div className="flex flex-col gap-2.5">
{filtered.map((report) => {
const isExpanded = expandedRid === report.id;
const files = filesCache[report.id];
const isLoading = loadingFiles === report.id;
const fileCount = files ? files.length : null;
return (
<div
key={report.id}
className="card"
style={{
padding: 0,
overflow: 'hidden',
borderColor: isExpanded ? 'color-mix(in srgb, var(--ac) 30%, var(--bd))' : undefined,
boxShadow: isExpanded ? 'var(--sh2)' : undefined,
}}
>
{/* Report header */}
<button
onClick={() => toggleExpand(report.id)}
className="w-full flex items-center justify-between px-4 py-3 text-left transition-colors hover:bg-[var(--bg2)]"
>
<div className="flex items-center gap-3">
<ChevronRight
size={14}
className="transition-transform duration-200 flex-shrink-0"
style={{
color: 'var(--ac)',
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
}}
/>
<div>
<span className="text-[.82rem] font-semibold" style={{ color: 'var(--t1)' }}>
{report.tenancy_name}
</span>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>
Level {report.level || 2}
</span>
<span className="text-[.5rem]" style={{ color: 'var(--t4)' }}>|</span>
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>
{report.created_at}
</span>
{fileCount != null && (
<>
<span className="text-[.5rem]" style={{ color: 'var(--t4)' }}>|</span>
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>
{fileCount} {t('dl.files')}
</span>
</>
)}
</div>
</div>
</div>
<span className="text-[.68rem] flex-shrink-0" style={{ color: 'var(--t4)' }}>
{isExpanded ? t('dl.hide') : t('dl.viewFiles')}
</span>
</button>
{/* Expanded file list */}
{isExpanded && (
<div
className="px-4 py-4"
style={{ borderTop: '1px solid var(--bd)', background: 'var(--bg2)' }}
>
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-6" style={{ color: 'var(--t4)' }}>
<Loader2 size={16} className="animate-spin" />
<span className="text-xs">{t('dl.loadingFiles')}</span>
</div>
) : !files || !files.length ? (
<p className="text-xs text-center py-6" style={{ color: 'var(--t4)' }}>
{t('dl.noFiles')}
</p>
) : (
<FileListGrouped
files={files}
reportId={report.id}
sectionFilter={sectionFilter}
onDownload={handleDownload}
/>
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
/* ── FileListGrouped sub-component ── */
function FileListGrouped({
files,
reportId,
sectionFilter,
onDownload,
}: {
files: ReportFile[];
reportId: string;
sectionFilter: string;
onDownload: (rid: string, fid: string) => void;
}) {
const { t } = useI18n();
const grouped = groupBySection(files);
let sections = Object.keys(grouped).sort(sectionSort);
if (sectionFilter) sections = sections.filter((s) => s === sectionFilter);
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<span className="text-[.72rem]" style={{ color: 'var(--t3)' }}>
{files.length} {t('dl.filesInSections').replace('{0}', String(Object.keys(grouped).length))}
</span>
</div>
{sections.map((sec) => {
const secFiles = grouped[sec];
const Icon = sectionIcons[sec] || FileText;
return (
<div key={sec} className="mb-3">
{/* Section header */}
<div
className="flex items-center gap-2 mb-2 pb-1.5"
style={{ borderBottom: '1px solid var(--bd)' }}
>
<Icon size={13} style={{ color: 'var(--t3)', opacity: 0.7 }} />
<span className="text-[.72rem] font-bold" style={{ color: 'var(--t1)' }}>{sec}</span>
<span className="text-[.6rem]" style={{ color: 'var(--t4)' }}>({secFiles.length})</span>
</div>
{/* File grid */}
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(230px, 1fr))' }}>
{secFiles.map((f) => {
const ext = fileExt(f.file_name);
const color = extColors[ext] || 'var(--ac)';
return (
<button
key={f.id}
onClick={() => onDownload(reportId, f.id)}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-left transition-all group"
style={{
background: 'var(--bg)',
border: '1px solid var(--bd)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = color;
e.currentTarget.style.boxShadow = `0 2px 8px ${color}18`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--bd)';
e.currentTarget.style.boxShadow = 'none';
}}
>
{/* File type badge */}
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{ background: `${color}14` }}
>
<span
className="text-[.58rem] font-bold uppercase tracking-wide"
style={{ color }}
>
{ext}
</span>
</div>
{/* File info */}
<div className="flex-1 min-w-0 overflow-hidden">
<div
className="text-[.7rem] font-medium truncate"
style={{ color: 'var(--t1)' }}
title={f.file_name}
>
{f.file_name}
</div>
<div className="text-[.58rem] mt-0.5" style={{ color: 'var(--t4)' }}>
{formatSize(f.file_size)}
</div>
</div>
{/* Download icon */}
<Download
size={12}
className="flex-shrink-0 opacity-30 group-hover:opacity-70 transition-opacity"
style={{ color: 'var(--t3)' }}
/>
</button>
);
})}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { import {
Search, ChevronRight, ChevronDown, FolderTree, RefreshCw, Loader2, Search, ChevronRight, ChevronDown, FolderTree, RefreshCw, Loader2,
Server, HardDrive, Layers, Network, GitBranch, Shield, ShieldCheck, Server, HardDrive, Layers, Network, GitBranch, Shield, ShieldCheck,
@@ -186,7 +186,7 @@ function KV({ label, value, mono }: { label: string; value: React.ReactNode; mon
} }
/* ── Compartment Tree Node ────────────────────────────────────────────────── */ /* ── Compartment Tree Node ────────────────────────────────────────────────── */
function TreeItem({ const TreeItem = React.memo(function TreeItem({
node, selected, expanded, onSelect, onToggle, node, selected, expanded, onSelect, onToggle,
}: { }: {
node: TreeNode; node: TreeNode;
@@ -236,7 +236,7 @@ function TreeItem({
)} )}
</div> </div>
); );
} });
/* ── Region Multi-Select ──────────────────────────────────────────────────── */ /* ── Region Multi-Select ──────────────────────────────────────────────────── */
function RegionSelector({ function RegionSelector({
@@ -381,7 +381,7 @@ function AdbCard({ r, configId, regions, onReload }: { r: ResourceItem; configId
const resp = await fetch('https://api.ipify.org?format=json'); const resp = await fetch('https://api.ipify.org?format=json');
const data = await resp.json(); const data = await resp.json();
const myIp = data.ip + '/32'; const myIp = data.ip + '/32';
const ip = prompt('IP para liberar acesso (CIDR):', myIp); const ip = prompt(t('exp.ipPrompt'), myIp);
if (!ip) return; if (!ip) return;
setUpdating(true); setUpdating(true);
await explorerApi.adbUpdateNetwork(r.id, ip.trim(), configId, region); await explorerApi.adbUpdateNetwork(r.id, ip.trim(), configId, region);
@@ -599,7 +599,7 @@ function SecurityListCard({ r, configId, regions, onReload }: { r: ResourceItem;
const resp = await fetch('https://api.ipify.org?format=json'); const resp = await fetch('https://api.ipify.org?format=json');
const data = await resp.json(); const data = await resp.json();
const myIp = data.ip + '/32'; const myIp = data.ip + '/32';
setCidr(myIp); setDesc('Acesso temporário - ' + myIp); setCidr(myIp); setDesc(t('exp.tempAccess') + myIp);
setFormMsg({ type: 's', text: t('exp.ipDetected').replace('{0}', myIp) }); setFormMsg({ type: 's', text: t('exp.ipDetected').replace('{0}', myIp) });
} catch { setFormMsg({ type: 'e', text: t('exp.ipDetectFailed') }); } } catch { setFormMsg({ type: 'e', text: t('exp.ipDetectFailed') }); }
}; };
@@ -641,7 +641,7 @@ function SecurityListCard({ r, configId, regions, onReload }: { r: ResourceItem;
<RuleInput label={t('exp.portMax')} value={portMax} onChange={setPortMax} placeholder="22" /> <RuleInput label={t('exp.portMax')} value={portMax} onChange={setPortMax} placeholder="22" />
</div> </div>
)} )}
<RuleInput label={t('exp.description')} value={desc} onChange={setDesc} placeholder="Opcional" /> <RuleInput label={t('exp.description')} value={desc} onChange={setDesc} placeholder={t('exp.optional')} />
<label className="flex items-center gap-1.5 text-xs cursor-pointer" style={{ color: 'var(--t2)' }}> <label className="flex items-center gap-1.5 text-xs cursor-pointer" style={{ color: 'var(--t2)' }}>
<input type="checkbox" checked={stateless} onChange={(e) => setStateless(e.target.checked)} className="accent-current" style={{ accentColor: 'var(--ac)' }} /> <input type="checkbox" checked={stateless} onChange={(e) => setStateless(e.target.checked)} className="accent-current" style={{ accentColor: 'var(--ac)' }} />
Stateless Stateless
@@ -745,7 +745,7 @@ function NsgCard({ r, configId, regions, onReload }: { r: ResourceItem; configId
const resp = await fetch('https://api.ipify.org?format=json'); const resp = await fetch('https://api.ipify.org?format=json');
const data = await resp.json(); const data = await resp.json();
const myIp = data.ip + '/32'; const myIp = data.ip + '/32';
setCidr(myIp); setDesc('Acesso temporário - ' + myIp); setCidr(myIp); setDesc(t('exp.tempAccess') + myIp);
setFormMsg({ type: 's', text: t('exp.ipDetected').replace('{0}', myIp) }); setFormMsg({ type: 's', text: t('exp.ipDetected').replace('{0}', myIp) });
} catch { setFormMsg({ type: 'e', text: t('exp.ipDetectFailed') }); } } catch { setFormMsg({ type: 'e', text: t('exp.ipDetectFailed') }); }
}; };
@@ -787,7 +787,7 @@ function NsgCard({ r, configId, regions, onReload }: { r: ResourceItem; configId
<RuleInput label={t('exp.portMax')} value={portMax} onChange={setPortMax} placeholder="22" /> <RuleInput label={t('exp.portMax')} value={portMax} onChange={setPortMax} placeholder="22" />
</div> </div>
)} )}
<RuleInput label={t('exp.description')} value={desc} onChange={setDesc} placeholder="Opcional" /> <RuleInput label={t('exp.description')} value={desc} onChange={setDesc} placeholder={t('exp.optional')} />
<label className="flex items-center gap-1.5 text-xs cursor-pointer" style={{ color: 'var(--t2)' }}> <label className="flex items-center gap-1.5 text-xs cursor-pointer" style={{ color: 'var(--t2)' }}>
<input type="checkbox" checked={stateless} onChange={(e) => setStateless(e.target.checked)} className="accent-current" style={{ accentColor: 'var(--ac)' }} /> <input type="checkbox" checked={stateless} onChange={(e) => setStateless(e.target.checked)} className="accent-current" style={{ accentColor: 'var(--ac)' }} />
Stateless Stateless
@@ -809,7 +809,7 @@ function NsgCard({ r, configId, regions, onReload }: { r: ResourceItem; configId
<th className="text-left py-1 pr-2">{t('exp.direction')}</th> <th className="text-left py-1 pr-2">{t('exp.direction')}</th>
<th className="text-left py-1 pr-2">{t('exp.protocol')}</th> <th className="text-left py-1 pr-2">{t('exp.protocol')}</th>
<th className="text-left py-1 pr-2">{t('exp.originDest')}</th> <th className="text-left py-1 pr-2">{t('exp.originDest')}</th>
<th className="text-left py-1 pr-2">Tipo</th> <th className="text-left py-1 pr-2">{t('exp.nsgType')}</th>
<th className="text-left py-1 pr-2">{t('exp.ports')}</th> <th className="text-left py-1 pr-2">{t('exp.ports')}</th>
<th className="text-left py-1 pr-1">{t('exp.description')}</th> <th className="text-left py-1 pr-1">{t('exp.description')}</th>
<th className="w-6"></th> <th className="w-6"></th>
@@ -883,23 +883,30 @@ function RouteTableCard({ r }: { r: ResourceItem }) {
══════════════════════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════════════════════ */
export default function ExplorerPage() { export default function ExplorerPage() {
const { ociCfg } = useAppStore(); const store = useAppStore();
const { ociCfg } = store;
const { t } = useI18n(); const { t } = useI18n();
/* ── State ──────────────────────────────────────────────────────────────── */ /* ── State (from store for user selections) ──────────────────────────── */
const [selectedConfig, setSelectedConfig] = useState(''); const selectedConfig = store.expSelectedConfig;
const setSelectedConfig = store.setExpSelectedConfig;
const [compartmentTree, setCompartmentTree] = useState<TreeNode[]>([]); const [compartmentTree, setCompartmentTree] = useState<TreeNode[]>([]);
const [selectedCompartment, setSelectedCompartment] = useState(''); const selectedCompartment = store.expSelectedCompartment;
const [selectedRegions, setSelectedRegions] = useState<string[]>([]); const setSelectedCompartment = store.setExpSelectedCompartment;
const selectedRegions = store.expSelectedRegions;
const setSelectedRegions = store.setExpSelectedRegions;
const [availableRegions, setAvailableRegions] = useState<Region[]>([]); const [availableRegions, setAvailableRegions] = useState<Region[]>([]);
const [selectedCategory, setSelectedCategory] = useState('Compute'); const selectedCategory = store.expSelectedCategory;
const [selectedResourceType, setSelectedResourceType] = useState('instances'); const setSelectedCategory = store.setExpSelectedCategory;
const selectedResourceType = store.expSelectedResourceType;
const setSelectedResourceType = store.setExpSelectedResourceType;
const [resourceData, setResourceData] = useState<ResourceItem[] | null>(null); const [resourceData, setResourceData] = useState<ResourceItem[] | null>(null);
const [counts, setCounts] = useState<Record<string, number>>({}); const [counts, setCounts] = useState<Record<string, number>>({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [countsRefreshing, setCountsRefreshing] = useState(false); const [countsRefreshing, setCountsRefreshing] = useState(false);
const [treeOpen, setTreeOpen] = useState<Record<string, boolean>>({}); const [treeOpen, setTreeOpen] = useState<Record<string, boolean>>({});
const [treeWidth, setTreeWidth] = useState(280); const treeWidth = store.expTreeWidth;
const setTreeWidth = store.setExpTreeWidth;
const [treeLoading, setTreeLoading] = useState(false); const [treeLoading, setTreeLoading] = useState(false);
const [actionPending, setActionPending] = useState<Record<string, boolean>>({}); const [actionPending, setActionPending] = useState<Record<string, boolean>>({});
const [searchFilter, setSearchFilter] = useState(''); const [searchFilter, setSearchFilter] = useState('');
@@ -1239,6 +1246,8 @@ export default function ExplorerPage() {
className="font-bold text-[.7rem] cursor-pointer" className="font-bold text-[.7rem] cursor-pointer"
style={{ color: 'var(--ac)' }} style={{ color: 'var(--ac)' }}
onClick={() => setSelectedRegions((prev) => prev.filter((x) => x !== r))} onClick={() => setSelectedRegions((prev) => prev.filter((x) => x !== r))}
title={t('common.delete')}
aria-label={`${t('common.delete')} ${r}`}
> >
&times; &times;
</button> </button>
@@ -1425,7 +1434,7 @@ export default function ExplorerPage() {
)} )}
{!loading && filteredResources && filteredResources.length > 0 && ( {!loading && filteredResources && filteredResources.length > 0 && (
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))' }}> <div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))' }}>
{filteredResources.map((item, idx) => ( {filteredResources.map((item, idx) => (
<ResourceCard <ResourceCard
key={item.id || item.name || idx} key={item.id || item.name || idx}

View File

@@ -39,28 +39,36 @@ interface Msg {
/* ── Component ── */ /* ── Component ── */
export default function PromptGeneratorPage() { export default function PromptGeneratorPage() {
const { models, genaiCfg, ociCfg } = useAppStore(); const store = useAppStore();
const { models, genaiCfg, ociCfg } = store;
const { t } = useI18n(); const { t } = useI18n();
/* state */ /* state (from store) */
const [msgs, setMsgs] = useState<Msg[]>([]); const msgs = store.pgMessages as Msg[];
const setMsgs = store.setPgMessages;
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [sid, setSid] = useState<string | null>(null); const sid = store.pgSessionId;
const setSid = store.setPgSessionId;
/* model selection */ /* model selection (from store) */
const [selectedModel, setSelectedModel] = useState(''); const selectedModel = store.pgSelectedModel;
const setSelectedModel = store.setPgSelectedModel;
const [ddOpen, setDdOpen] = useState(false); const [ddOpen, setDdOpen] = useState(false);
const [ddSearch, setDdSearch] = useState(''); const [ddSearch, setDdSearch] = useState('');
/* OCI config (for direct models) */ /* OCI config (from store) */
const [ociId, setOciId] = useState(''); const ociId = store.pgOciId;
const [region, setRegion] = useState(''); const setOciId = store.setPgOciId;
const [compartment, setCompartment] = useState(''); const region = store.pgRegion;
const setRegion = store.setPgRegion;
const compartment = store.pgCompartment;
const setCompartment = store.setPgCompartment;
/* history sidebar */ /* history sidebar (from store) */
const [histOpen, setHistOpen] = useState(false); const [histOpen, setHistOpen] = useState(false);
const [history, setHistory] = useState<ChatSession[]>([]); const history = store.pgHistory as ChatSession[];
const setHistory = store.setPgHistory;
const chatRef = useRef<HTMLDivElement>(null); const chatRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -174,7 +182,7 @@ export default function PromptGeneratorPage() {
setLoading(false); setLoading(false);
} }
} catch (e: unknown) { } catch (e: unknown) {
setMsgs([...newMsgs, { role: 'assistant', content: 'Erro: ' + (e as Error).message, failed: true }]); setMsgs([...newMsgs, { role: 'assistant', content: t('common.errorPrefix') + (e as Error).message, failed: true }]);
setLoading(false); setLoading(false);
} }
}; };

View File

@@ -536,45 +536,59 @@ function CustomTooltip({ active, payload, label }: { active?: boolean; payload?:
export default function ReportsPage() { export default function ReportsPage() {
const { t } = useI18n(); const { t } = useI18n();
const ociCfg = useAppStore((s) => s.ociCfg); const store = useAppStore();
const ociRegions = useAppStore((s) => s.ociRegions); const ociCfg = store.ociCfg;
const adbCfg = useAppStore((s) => s.adbCfg); const ociRegions = store.ociRegions;
const adbCfg = store.adbCfg;
/* ── Reports list ── */ /* ── Reports list ── */
const [reports, setReports] = useState<Report[]>([]); const [reports, setReports] = useState<Report[]>([]);
const [reportsLoading, setReportsLoading] = useState(true); const [reportsLoading, setReportsLoading] = useState(true);
/* ── Form state ── */ /* ── Form state (from store) ── */
const [formOpen, setFormOpen] = useState(true); const formOpen = store.rptFormOpen;
const [ociVal, setOciVal] = useState(''); const setFormOpen = store.setRptFormOpen;
const [level, setLevel] = useState<1 | 2>(2); const ociVal = store.rptOciVal;
const [selectedRegions, setSelectedRegions] = useState<string[]>([]); const setOciVal = store.setRptOciVal;
const [obp, setObp] = useState(true); const level = store.rptLevel;
const [raw, setRaw] = useState(false); const setLevel = store.setRptLevel;
const [redact, setRedact] = useState(true); const selectedRegions = store.rptSelectedRegions;
const setSelectedRegions = store.setRptSelectedRegions;
const obp = store.rptObp;
const setObp = store.setRptObp;
const raw = store.rptRaw;
const setRaw = store.setRptRaw;
const redact = store.rptRedact;
const setRedact = store.setRptRedact;
/* ── Tracking / progress ── */ /* ── Tracking / progress (from store) ── */
const [trackingId, setTrackingId] = useState<string | null>(null); const trackingId = store.rptTrackingId;
const setTrackingId = store.setRptTrackingId;
const [progress, setProgress] = useState<ReportProgress | null>(null); const [progress, setProgress] = useState<ReportProgress | null>(null);
const [logExpanded, setLogExpanded] = useState(false); const [logExpanded, setLogExpanded] = useState(false);
/* ── Selected report + KPI ── */ /* ── Selected report + KPI (from store) ── */
const [selectedRid, setSelectedRid] = useState(''); const selectedRid = store.rptSelectedRid;
const setSelectedRid = store.setRptSelectedRid;
const [kpi, setKpi] = useState<ReportSummary | null>(null); const [kpi, setKpi] = useState<ReportSummary | null>(null);
const [kpiLoading, setKpiLoading] = useState(false); const [kpiLoading, setKpiLoading] = useState(false);
/* ── Report files ── */ /* ── Report files ── */
const [files, setFiles] = useState<ReportFile[]>([]); const [files, setFiles] = useState<ReportFile[]>([]);
/* ── Embedding state ── */ /* ── Embedding state (from store) ── */
const [embedAdb, setEmbedAdb] = useState(''); const embedAdb = store.rptEmbedAdb;
const [embedTable, setEmbedTable] = useState(''); const setEmbedAdb = store.setRptEmbedAdb;
const embedTable = store.rptEmbedTable;
const setEmbedTable = store.setRptEmbedTable;
const [embedLoading, setEmbedLoading] = useState(false); const [embedLoading, setEmbedLoading] = useState(false);
const [embedMsg, setEmbedMsg] = useState<{ type: 's' | 'e'; text: string } | null>(null); const [embedMsg, setEmbedMsg] = useState<{ type: 's' | 'e'; text: string } | null>(null);
/* ── HTML iframe ── */ /* ── HTML iframe (from store) ── */
const [showIframe, setShowIframe] = useState(false); const showIframe = store.rptShowIframe;
const [showCompliance, setShowCompliance] = useState(false); const setShowIframe = store.setRptShowIframe;
const showCompliance = store.rptShowCompliance;
const setShowCompliance = store.setRptShowCompliance;
const [complianceReady, setComplianceReady] = useState(false); const [complianceReady, setComplianceReady] = useState(false);
const [complianceGenerating, setComplianceGenerating] = useState(false); const [complianceGenerating, setComplianceGenerating] = useState(false);
@@ -1305,16 +1319,16 @@ export default function ReportsPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{showCompliance ? <Eye size={14} style={{ color: 'var(--gn)' }} /> : <EyeOff size={14} style={{ color: 'var(--t4)' }} />} {showCompliance ? <Eye size={14} style={{ color: 'var(--gn)' }} /> : <EyeOff size={14} style={{ color: 'var(--t4)' }} />}
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}> <span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
LAD A-Team CIS Compliance Report {t('rpt.complianceTitle')}
</span> </span>
{complianceReady && ( {complianceReady && (
<span className="text-[.6rem] px-2 py-0.5 rounded-full font-semibold" style={{ background: 'var(--gnl)', color: 'var(--gn)' }}> <span className="text-[.6rem] px-2 py-0.5 rounded-full font-semibold" style={{ background: 'var(--gnl)', color: 'var(--gn)' }}>
+ Remediation {t('rpt.complianceRemediation')}
</span> </span>
)} )}
{complianceGenerating && ( {complianceGenerating && (
<span className="text-[.6rem] px-2 py-0.5 rounded-full font-semibold flex items-center gap-1" style={{ background: 'var(--bll)', color: 'var(--bl)' }}> <span className="text-[.6rem] px-2 py-0.5 rounded-full font-semibold flex items-center gap-1" style={{ background: 'var(--bll)', color: 'var(--bl)' }}>
<Loader2 size={10} className="animate-spin" /> Gerando... <Loader2 size={10} className="animate-spin" /> {t('rpt.complianceGeneratingShort')}
</span> </span>
)} )}
</div> </div>
@@ -1338,7 +1352,7 @@ export default function ReportsPage() {
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer transition-colors no-underline" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer transition-colors no-underline"
style={{ background: 'var(--ac)', color: '#fff' }} style={{ background: 'var(--ac)', color: '#fff' }}
> >
<Download size={12} /> Download ZIP <Download size={12} /> {t('rpt.complianceDownloadZip')}
</a> </a>
<button <button
onClick={startComplianceGeneration} onClick={startComplianceGeneration}
@@ -1346,26 +1360,26 @@ export default function ReportsPage() {
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer transition-colors" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer transition-colors"
style={{ background: 'var(--bg3)', color: 'var(--t2)', border: '1px solid var(--bd)' }} style={{ background: 'var(--bg3)', color: 'var(--t2)', border: '1px solid var(--bd)' }}
> >
<RefreshCw size={12} /> Regenerar <RefreshCw size={12} /> {t('rpt.complianceRegenerate')}
</button> </button>
</div> </div>
</div> </div>
) : complianceGenerating ? ( ) : complianceGenerating ? (
<div className="flex flex-col items-center justify-center py-16 gap-3"> <div className="flex flex-col items-center justify-center py-16 gap-3">
<Loader2 size={32} className="animate-spin" style={{ color: 'var(--gn)' }} /> <Loader2 size={32} className="animate-spin" style={{ color: 'var(--gn)' }} />
<p className="text-[.82rem] font-medium" style={{ color: 'var(--t2)' }}>Gerando Compliance Report...</p> <p className="text-[.82rem] font-medium" style={{ color: 'var(--t2)' }}>{t('rpt.complianceGenerating')}</p>
<p className="text-[.7rem]" style={{ color: 'var(--t4)' }}>Buscando remediações na Knowledge Base CIS</p> <p className="text-[.7rem]" style={{ color: 'var(--t4)' }}>{t('rpt.complianceFetchingKB')}</p>
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center py-12 gap-3"> <div className="flex flex-col items-center justify-center py-12 gap-3">
<ShieldCheck size={32} style={{ color: 'var(--t4)', opacity: 0.5 }} /> <ShieldCheck size={32} style={{ color: 'var(--t4)', opacity: 0.5 }} />
<p className="text-[.78rem]" style={{ color: 'var(--t3)' }}>Report ainda não gerado</p> <p className="text-[.78rem]" style={{ color: 'var(--t3)' }}>{t('rpt.complianceNotGenerated')}</p>
<button <button
onClick={startComplianceGeneration} onClick={startComplianceGeneration}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-[.76rem] font-semibold cursor-pointer" className="flex items-center gap-2 px-4 py-2 rounded-lg text-[.76rem] font-semibold cursor-pointer"
style={{ background: 'var(--gn)', color: '#fff' }} style={{ background: 'var(--gn)', color: '#fff' }}
> >
<ShieldCheck size={14} /> Gerar Compliance Report <ShieldCheck size={14} /> {t('rpt.complianceGenerate')}
</button> </button>
</div> </div>
) )

View File

@@ -359,44 +359,64 @@ function Modal({ children, onClose }: { children: React.ReactNode; onClose: () =
export default function TerraformPage() { export default function TerraformPage() {
const { t } = useI18n(); const { t } = useI18n();
const { ociCfg, genaiCfg } = useAppStore(); const store = useAppStore();
const { ociCfg, genaiCfg } = store;
/* ── Chat state ── */ /* ── Chat state (from store) ── */
const [messages, setMessages] = useState<ChatMessage[]>([]); const messages = store.tfMessages as ChatMessage[];
const [sessionId, setSessionId] = useState<string | null>(null); const setMessages = store.setTfMessages;
const sessionId = store.tfSessionId;
const setSessionId = store.setTfSessionId;
const sessions = store.tfSessions as ChatSession[];
const setSessions = store.setTfSessions;
const [historyOpen, setHistoryOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [chatInput, setChatInput] = useState(''); const [chatInput, setChatInput] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null); const chatEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
/* ── Model state ── */ /* ── Model state (from store) ── */
const [model, setModel] = useState('openai.gpt-4.1'); const model = store.tfModel;
const [ociConfig, setOciConfig] = useState(''); const setModel = store.setTfModel;
const [region, setRegion] = useState(''); const ociConfig = store.tfOciConfig;
const [compartment, setCompartment] = useState(''); const setOciConfig = store.setTfOciConfig;
const [compartments, setCompartments] = useState<FlatCompartment[]>([]); const region = store.tfRegion;
const setRegion = store.setTfRegion;
const compartment = store.tfCompartment;
const setCompartment = store.setTfCompartment;
const compartments = store.tfCompartments as FlatCompartment[];
const setCompartments = store.setTfCompartments;
const tfTemperature = store.tfTemperature;
const setTfTemperature = store.setTfTemperature;
const [modelDropdownOpen, setModelDropdownOpen] = useState(false); const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
const [modelSearch, setModelSearch] = useState(''); const [modelSearch, setModelSearch] = useState('');
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [tfTemperature, setTfTemperature] = useState(0.3);
/* ── Workspace state ── */ /* ── Workspace state (from store) ── */
const [wsId, setWsId] = useState<string | null>(null); const wsId = store.tfWsId;
const [wsStatus, setWsStatus] = useState<WsStatus>('draft'); const setWsId = store.setTfWsId;
const [wsName, setWsName] = useState(''); const wsStatus = store.tfWsStatus as WsStatus;
const [files, setFiles] = useState<TfFile[]>([]); const setWsStatus = store.setTfWsStatus;
const [planOutput, setPlanOutput] = useState(''); const wsName = store.tfWsName;
const [applyOutput, setApplyOutput] = useState(''); const setWsName = store.setTfWsName;
const [destroyOutput, setDestroyOutput] = useState(''); const files = store.tfFiles as TfFile[];
const [wsError, setWsError] = useState(''); const setFiles = store.setTfFiles;
const planOutput = store.tfPlanOutput;
const setPlanOutput = store.setTfPlanOutput;
const applyOutput = store.tfApplyOutput;
const setApplyOutput = store.setTfApplyOutput;
const destroyOutput = store.tfDestroyOutput;
const setDestroyOutput = store.setTfDestroyOutput;
const wsError = store.tfWsError;
const setWsError = store.setTfWsError;
/* ── UI state ── */ /* ── UI state ── */
const [editingFileIdx, setEditingFileIdx] = useState<number | null>(null); const [editingFileIdx, setEditingFileIdx] = useState<number | null>(null);
const [bottomTab, setBottomTab] = useState<'files' | 'plan' | 'resources'>('files'); const bottomTab = store.tfBottomTab as 'files' | 'plan' | 'resources';
const setBottomTab = store.setTfBottomTab;
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [terminalView, setTerminalView] = useState<'plan' | 'apply' | 'destroy'>('plan'); const terminalView = store.tfTerminalView as 'plan' | 'apply' | 'destroy';
const setTerminalView = store.setTfTerminalView;
const terminalRef = useRef<HTMLPreElement>(null); const terminalRef = useRef<HTMLPreElement>(null);
/* ── Resources ── */ /* ── Resources ── */
@@ -506,7 +526,7 @@ export default function TerraformPage() {
pollWorkspaceStatus(ws.id); pollWorkspaceStatus(ws.id);
} }
} catch (e: unknown) { } catch (e: unknown) {
alert('Erro ao carregar workspace: ' + (e as Error).message); setWsError('Erro ao carregar workspace: ' + (e as Error).message);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -538,7 +558,7 @@ export default function TerraformPage() {
} }
} catch { /* no workspace */ } } catch { /* no workspace */ }
} catch (e: unknown) { } catch (e: unknown) {
alert('Erro: ' + (e as Error).message); setWsError('Erro: ' + (e as Error).message);
} }
}, [loadWorkspace]); }, [loadWorkspace]);
@@ -686,7 +706,7 @@ export default function TerraformPage() {
/* ── Save & Plan ── */ /* ── Save & Plan ── */
const doPlan = useCallback(async () => { const doPlan = useCallback(async () => {
if (files.length === 0) { alert('Nenhum arquivo para planejar'); return; } if (files.length === 0) { setWsError(t('tf.noFilesToPlan')); return; }
const code = joinFiles(files); const code = joinFiles(files);
try { try {
let wid = wsId; let wid = wsId;
@@ -712,7 +732,7 @@ export default function TerraformPage() {
setWsError(''); setWsError('');
pollWorkspaceStatus(wid); pollWorkspaceStatus(wid);
} catch (e: unknown) { } catch (e: unknown) {
alert('Erro: ' + (e as Error).message); setWsError('Erro: ' + (e as Error).message);
} }
}, [files, wsId, sessionId, ociConfig, compartment, wsName, pollWorkspaceStatus]); }, [files, wsId, sessionId, ociConfig, compartment, wsName, pollWorkspaceStatus]);
@@ -726,7 +746,7 @@ export default function TerraformPage() {
setApplyOutput(''); setApplyOutput('');
pollWorkspaceStatus(wsId); pollWorkspaceStatus(wsId);
} catch (e: unknown) { } catch (e: unknown) {
alert('Erro: ' + (e as Error).message); setWsError('Erro: ' + (e as Error).message);
} }
}, [wsId, pollWorkspaceStatus]); }, [wsId, pollWorkspaceStatus]);
@@ -742,7 +762,7 @@ export default function TerraformPage() {
setDestroyOutput(''); setDestroyOutput('');
pollWorkspaceStatus(wsId); pollWorkspaceStatus(wsId);
} catch (e: unknown) { } catch (e: unknown) {
alert('Erro: ' + (e as Error).message); setWsError('Erro: ' + (e as Error).message);
} }
}, [wsId, destroyInput, pollWorkspaceStatus]); }, [wsId, destroyInput, pollWorkspaceStatus]);
@@ -781,7 +801,7 @@ export default function TerraformPage() {
/* ── Load resources ── */ /* ── Load resources ── */
const loadResources = useCallback(async () => { const loadResources = useCallback(async () => {
if (!ociConfig || !compartment) { if (!ociConfig || !compartment) {
alert('Selecione OCI Config e Compartment'); setWsError(t('tf.selectOciConfig'));
return; return;
} }
setResourcesLoading(true); setResourcesLoading(true);
@@ -789,7 +809,7 @@ export default function TerraformPage() {
const r = await terraformApi.loadResources(ociConfig, compartment, region || undefined); const r = await terraformApi.loadResources(ociConfig, compartment, region || undefined);
setResources(r); setResources(r);
} catch (e: unknown) { } catch (e: unknown) {
alert('Erro: ' + (e as Error).message); setWsError('Erro: ' + (e as Error).message);
setResources({}); setResources({});
} }
setResourcesLoading(false); setResourcesLoading(false);
@@ -1095,13 +1115,13 @@ export default function TerraformPage() {
className="flex items-center gap-2 w-full px-3 py-2 text-left cursor-pointer transition-colors" className="flex items-center gap-2 w-full px-3 py-2 text-left cursor-pointer transition-colors"
style={{ color: 'var(--t2)' }}> style={{ color: 'var(--t2)' }}>
<Settings size={14} /> <Settings size={14} />
<span className="text-[.72rem]">Editar System Prompt</span> <span className="text-[.72rem]">{t('tf.editSystemPrompt')}</span>
</button> </button>
<button onClick={() => { setMenuOpen(false); /* TODO: refresh reference */ }} <button onClick={() => { setMenuOpen(false); /* TODO: refresh reference */ }}
className="flex items-center gap-2 w-full px-3 py-2 text-left cursor-pointer transition-colors" className="flex items-center gap-2 w-full px-3 py-2 text-left cursor-pointer transition-colors"
style={{ color: 'var(--t2)' }}> style={{ color: 'var(--t2)' }}>
<RefreshCw size={14} /> <RefreshCw size={14} />
<span className="text-[.72rem]">Refresh Reference</span> <span className="text-[.72rem]">{t('tf.refreshReference')}</span>
</button> </button>
</div> </div>
)} )}
@@ -1205,7 +1225,7 @@ export default function TerraformPage() {
{running && ( {running && (
<span className="flex items-center gap-1.5 text-[.68rem]" style={{ color: 'var(--yl)' }}> <span className="flex items-center gap-1.5 text-[.68rem]" style={{ color: 'var(--yl)' }}>
<Loader2 size={13} className="animate-spin" /> Executando... <Loader2 size={13} className="animate-spin" /> {t('tf.running')}
</span> </span>
)} )}
</div> </div>
@@ -1221,7 +1241,7 @@ export default function TerraformPage() {
}}> }}>
{!terminalOutput && !running && ( {!terminalOutput && !running && (
<span style={{ color: 'var(--t4)', opacity: 0.5 }}> <span style={{ color: 'var(--t4)', opacity: 0.5 }}>
{`# Terminal de saida do Terraform\n# Execute Plan, Apply ou Destroy para ver os resultados aqui`} {`# ${t('tf.terminalTitle')}\n# ${t('tf.terminalHint')}`}
</span> </span>
)} )}
{wsError && terminalView === 'plan' && ( {wsError && terminalView === 'plan' && (
@@ -1305,7 +1325,7 @@ export default function TerraformPage() {
<button onClick={doAutoFix} <button onClick={doAutoFix}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[.66rem] font-medium cursor-pointer transition-colors" className="flex items-center gap-1 px-2 py-1 rounded-md text-[.66rem] font-medium cursor-pointer transition-colors"
style={{ background: 'var(--yl)', color: '#000', border: '1px solid var(--yl)' }}> style={{ background: 'var(--yl)', color: '#000', border: '1px solid var(--yl)' }}>
<Wrench size={12} /> Auto-corrigir <Wrench size={12} /> {t('tf.autoFix')}
</button> </button>
)} )}
{wsStatus === 'applied' && !running && ( {wsStatus === 'applied' && !running && (

View File

@@ -130,25 +130,25 @@ export default function WorkspacesPage() {
/* ── actions ── */ /* ── actions ── */
const doPlan = async (wid: string) => { const doPlan = async (wid: string) => {
try { await terraformApi.plan(wid); } catch (e: unknown) { alert('Erro: ' + (e as Error).message); return; } try { await terraformApi.plan(wid); } catch (e: unknown) { alert(t('common.errorPrefix') + (e as Error).message); return; }
load(); pollWs(wid); load(); pollWs(wid);
}; };
const doApply = async (wid: string) => { const doApply = async (wid: string) => {
try { await terraformApi.apply(wid); } catch (e: unknown) { alert('Erro: ' + (e as Error).message); return; } try { await terraformApi.apply(wid); } catch (e: unknown) { alert(t('common.errorPrefix') + (e as Error).message); return; }
load(); pollWs(wid); load(); pollWs(wid);
}; };
const doDestroy = async () => { const doDestroy = async () => {
if (!confirmDestroy || destroyInput !== 'DESTROY') return; if (!confirmDestroy || destroyInput !== 'DESTROY') return;
const wid = confirmDestroy; const wid = confirmDestroy;
setConfirmDestroy(null); setDestroyInput(''); setConfirmDestroy(null); setDestroyInput('');
try { await terraformApi.destroy(wid); } catch (e: unknown) { alert('Erro: ' + (e as Error).message); load(); return; } try { await terraformApi.destroy(wid); } catch (e: unknown) { alert(t('common.errorPrefix') + (e as Error).message); load(); return; }
load(); pollWs(wid); load(); pollWs(wid);
}; };
const doDelete = async () => { const doDelete = async () => {
if (!confirmDelete) return; if (!confirmDelete) return;
const wid = confirmDelete; const wid = confirmDelete;
setConfirmDelete(null); setConfirmDelete(null);
try { await terraformApi.deleteWorkspace(wid); } catch (e: unknown) { alert('Erro: ' + (e as Error).message); } try { await terraformApi.deleteWorkspace(wid); } catch (e: unknown) { alert(t('common.errorPrefix') + (e as Error).message); }
load(); load();
}; };
const doCancel = async (wid: string) => { const doCancel = async (wid: string) => {
@@ -378,7 +378,7 @@ export default function WorkspacesPage() {
<p className="text-[.76rem] mb-3" style={{ color: 'var(--t2)' }}> <p className="text-[.76rem] mb-3" style={{ color: 'var(--t2)' }}>
{t('ws.confirmDestroyMsg')} {t('ws.confirmDestroyMsg')}
{' '} {' '}
Digite <strong>DESTROY</strong> para confirmar: <span dangerouslySetInnerHTML={{ __html: t('ws.typeDestroyLabel') }} />
</p> </p>
<input type="text" value={destroyInput} <input type="text" value={destroyInput}
onChange={(e) => setDestroyInput(e.target.value)} onChange={(e) => setDestroyInput(e.target.value)}

View File

@@ -1,281 +0,0 @@
import { useState, useCallback } from 'react';
import { Lock, ShieldCheck, ShieldOff, KeyRound, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
import { useAuthStore } from '@/stores/auth';
import client from '@/api/client';
import { useI18n } from '@/i18n';
interface MfaSetupResponse {
secret: string;
uri: string;
}
export default function MfaPage() {
const { user, checkAuth } = useAuthStore();
const { t } = useI18n();
const [secret, setSecret] = useState<string | null>(null);
const [totpUri, setTotpUri] = useState<string | null>(null);
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState<{ text: string; type: 's' | 'e' | 'i' } | null>(null);
const mfaEnabled = !!user?.mfa_enabled;
const handleSetup = useCallback(async () => {
setLoading(true);
setMsg(null);
try {
const data = await client.post('/mfa/setup') as unknown as MfaSetupResponse;
setSecret(data.secret);
setTotpUri(data.uri);
} catch (err) {
setMsg({ text: err instanceof Error ? err.message : 'Erro ao gerar secret', type: 'e' });
} finally {
setLoading(false);
}
}, []);
const handleVerify = useCallback(async () => {
if (!code || code.length !== 6) {
setMsg({ text: t('mfa.invalidCode'), type: 'e' });
return;
}
setLoading(true);
setMsg(null);
try {
await client.post('/mfa/verify', { totp_code: code });
setMsg({ text: t('mfa.activated'), type: 's' });
setSecret(null);
setTotpUri(null);
setCode('');
await checkAuth();
} catch (err) {
setMsg({ text: err instanceof Error ? err.message : 'Codigo invalido', type: 'e' });
} finally {
setLoading(false);
}
}, [code, checkAuth, t]);
const handleDisable = useCallback(async () => {
if (!user) return;
if (!window.confirm(t('mfa.confirmDisable'))) return;
setLoading(true);
setMsg(null);
try {
await client.post(`/mfa/disable/${user.id}`);
setMsg({ text: t('mfa.deactivated'), type: 'i' });
await checkAuth();
} catch (err) {
setMsg({ text: err instanceof Error ? err.message : 'Erro ao desativar MFA', type: 'e' });
} finally {
setLoading(false);
}
}, [user, checkAuth, t]);
const alertStyles: Record<string, { bg: string; color: string; icon: typeof CheckCircle2 }> = {
s: { bg: 'var(--gnl)', color: 'var(--gn)', icon: CheckCircle2 },
e: { bg: 'var(--rdl)', color: 'var(--rd)', icon: AlertCircle },
i: { bg: 'color-mix(in srgb, var(--bl) 12%, transparent)', color: 'var(--bl)', icon: AlertCircle },
};
return (
<div className="page" style={{ maxWidth: 580 }}>
{/* Header */}
<div className="page-header">
<div className="card-header icon" style={{ background: 'color-mix(in srgb, var(--ac) 10%, transparent)' }}>
<Lock size={20} style={{ color: 'var(--ac)' }} />
</div>
<div>
<h1>{t('mfa.title')}</h1>
<div className="subtitle">
{t('mfa.subtitle')}
</div>
</div>
</div>
{/* Card */}
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
{/* Status banner */}
<div
className="flex items-center gap-3 px-5 py-4"
style={{
borderBottom: '1px solid var(--bd)',
background: mfaEnabled
? 'color-mix(in srgb, var(--gn) 6%, transparent)'
: 'color-mix(in srgb, var(--yl) 6%, transparent)',
}}
>
{mfaEnabled ? (
<ShieldCheck size={22} style={{ color: 'var(--gn)' }} />
) : (
<ShieldOff size={22} style={{ color: 'var(--yl)' }} />
)}
<div>
<span className="text-sm font-semibold" style={{ color: 'var(--t1)' }}>
{mfaEnabled ? t('mfa.enabled') : t('mfa.disabled')}
</span>
<p className="text-xs mt-0.5" style={{ color: 'var(--t3)' }}>
{mfaEnabled
? t('mfa.enabledDesc')
: t('mfa.disabledDesc')}
</p>
</div>
</div>
{/* Content */}
<div className="p-5">
{/* Message */}
{msg && (() => {
const st = alertStyles[msg.type];
const Icon = st.icon;
return (
<div
className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg mb-4 text-xs font-medium"
style={{ background: st.bg, color: st.color }}
>
<Icon size={15} />
{msg.text}
</div>
);
})()}
{mfaEnabled ? (
/* Enabled state: show disable button */
<div className="flex flex-col items-center gap-4 py-4">
<div
className="w-16 h-16 rounded-full flex items-center justify-center"
style={{ background: 'color-mix(in srgb, var(--gn) 10%, transparent)' }}
>
<ShieldCheck size={32} style={{ color: 'var(--gn)' }} />
</div>
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>
{t('mfa.disableHint')}
</p>
<button
onClick={handleDisable}
disabled={loading}
className="btn btn-danger btn-sm"
>
{loading ? <Loader2 size={14} className="animate-spin" /> : <ShieldOff size={14} />}
{t('mfa.disableBtn')}
</button>
</div>
) : secret && totpUri ? (
/* Setup step 2: show secret + QR + verify */
<div className="flex flex-col gap-4">
{/* Instructions */}
<div className="flex items-start gap-3">
<div
className="w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 text-[.65rem] font-bold"
style={{ background: 'var(--ac)', color: '#fff' }}
>
1
</div>
<div>
<p className="text-xs font-semibold" style={{ color: 'var(--t1)' }}>
{t('mfa.scanQr')}
</p>
<p className="text-[.68rem] mt-0.5" style={{ color: 'var(--t4)' }}>
{t('mfa.openApp')}
</p>
</div>
</div>
{/* QR code */}
<div className="flex justify-center">
<div className="p-3 rounded-xl" style={{ background: '#fff' }}>
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(totpUri)}`}
alt="QR Code TOTP"
width={180}
height={180}
className="rounded"
/>
</div>
</div>
{/* Secret */}
<div
className="px-3.5 py-2.5 rounded-lg text-center"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}
>
<span className="text-[.62rem] block mb-1" style={{ color: 'var(--t4)' }}>SECRET</span>
<code
className="text-xs font-bold tracking-wider select-all cursor-pointer"
style={{ color: 'var(--ac)', fontFamily: 'var(--fm)' }}
title="Clique para selecionar"
>
{secret}
</code>
</div>
{/* Verify */}
<div className="flex items-start gap-3">
<div
className="w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 text-[.65rem] font-bold"
style={{ background: 'var(--ac)', color: '#fff' }}
>
2
</div>
<div className="flex-1">
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--t1)' }}>
{t('mfa.enterCode')}
</p>
<div className="flex gap-2">
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
maxLength={6}
className="flex-1 px-3 py-2 rounded-lg text-sm text-center tracking-[.3em] font-semibold outline-none transition-colors"
style={{
background: 'var(--bg2)',
border: '1px solid var(--bd)',
color: 'var(--t1)',
fontFamily: 'var(--fm)',
}}
onFocus={(e) => { e.target.style.borderColor = 'var(--ac)'; }}
onBlur={(e) => { e.target.style.borderColor = 'var(--bd)'; }}
onKeyDown={(e) => { if (e.key === 'Enter') handleVerify(); }}
autoFocus
/>
<button
onClick={handleVerify}
disabled={loading || code.length !== 6}
className="btn btn-success btn-sm"
>
{loading ? <Loader2 size={14} className="animate-spin" /> : <KeyRound size={14} />}
{t('mfa.activateMfa')}
</button>
</div>
</div>
</div>
</div>
) : (
/* Setup step 1: generate secret */
<div className="flex flex-col items-center gap-4 py-4">
<div
className="w-16 h-16 rounded-full flex items-center justify-center"
style={{ background: 'color-mix(in srgb, var(--yl) 10%, transparent)' }}
>
<KeyRound size={32} style={{ color: 'var(--yl)' }} />
</div>
<p className="text-xs text-center max-w-xs" style={{ color: 'var(--t3)' }}>
{t('mfa.generateHint')}
</p>
<button
onClick={handleSetup}
disabled={loading}
className="btn btn-primary"
>
{loading ? <Loader2 size={16} className="animate-spin" /> : <KeyRound size={16} />}
{t('mfa.generateSecret')}
</button>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Users, Plus, Shield, Eye, User as UserIcon, Trash2, X, AlertTriangle, CheckCircle, Loader2, UserPlus } from 'lucide-react'; import { Users, Plus, Shield, Eye, User as UserIcon, Trash2, X, AlertTriangle, CheckCircle, Loader2, UserPlus, Lock, ShieldCheck, ShieldOff, KeyRound } from 'lucide-react';
import client from '@/api/client'; import client from '@/api/client';
import { useAuthStore } from '@/stores/auth';
import { useI18n } from '@/i18n'; import { useI18n } from '@/i18n';
/* ── Types ── */ /* ── Types ── */
@@ -28,6 +29,7 @@ const ROLE_CONFIG: Record<Role, { label: string; color: string; bg: string; icon
/* ── Component ── */ /* ── Component ── */
export default function UsersPage() { export default function UsersPage() {
const { t } = useI18n(); const { t } = useI18n();
const { user: currentUser, checkAuth } = useAuthStore();
const [users, setUsers] = useState<AppUser[]>([]); const [users, setUsers] = useState<AppUser[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
@@ -35,6 +37,14 @@ export default function UsersPage() {
const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null); const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
// MFA modal state
const [mfaUserId, setMfaUserId] = useState<string | null>(null);
const [mfaSecret, setMfaSecret] = useState<string | null>(null);
const [mfaTotpUri, setMfaTotpUri] = useState<string | null>(null);
const [mfaCode, setMfaCode] = useState('');
const [mfaLoading, setMfaLoading] = useState(false);
const [mfaMsg, setMfaMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
// Form state // Form state
const [firstName, setFirstName] = useState(''); const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState(''); const [lastName, setLastName] = useState('');
@@ -48,7 +58,7 @@ export default function UsersPage() {
const data = await client.get('/users') as unknown as AppUser[]; const data = await client.get('/users') as unknown as AppUser[];
setUsers(data); setUsers(data);
} catch (err) { } catch (err) {
setMsg({ text: err instanceof Error ? err.message : 'Erro ao carregar usuarios', type: 'error' }); setMsg({ text: err instanceof Error ? err.message : t('usr.errorLoadUsers'), type: 'error' });
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -97,7 +107,7 @@ export default function UsersPage() {
setShowForm(false); setShowForm(false);
await fetchUsers(); await fetchUsers();
} catch (err) { } catch (err) {
setMsg({ text: err instanceof Error ? err.message : 'Erro ao criar usuario', type: 'error' }); setMsg({ text: err instanceof Error ? err.message : t('usr.errorCreateUser'), type: 'error' });
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -109,7 +119,7 @@ export default function UsersPage() {
setUsers(prev => prev.map(u => u.id === userId ? { ...u, role: newRole } : u)); setUsers(prev => prev.map(u => u.id === userId ? { ...u, role: newRole } : u));
setMsg({ text: t('usr.roleUpdated'), type: 'success' }); setMsg({ text: t('usr.roleUpdated'), type: 'success' });
} catch (err) { } catch (err) {
setMsg({ text: err instanceof Error ? err.message : 'Erro ao atualizar role', type: 'error' }); setMsg({ text: err instanceof Error ? err.message : t('usr.errorUpdateRole'), type: 'error' });
} }
}; };
@@ -120,7 +130,7 @@ export default function UsersPage() {
setMsg({ text: t('usr.deactivated'), type: 'success' }); setMsg({ text: t('usr.deactivated'), type: 'success' });
await fetchUsers(); await fetchUsers();
} catch (err) { } catch (err) {
setMsg({ text: err instanceof Error ? err.message : 'Erro ao desativar usuario', type: 'error' }); setMsg({ text: err instanceof Error ? err.message : t('usr.errorDeactivate'), type: 'error' });
} }
}; };
@@ -136,6 +146,63 @@ export default function UsersPage() {
} }
}; };
// MFA handlers
const openMfa = (userId: string) => {
setMfaUserId(userId);
setMfaSecret(null);
setMfaTotpUri(null);
setMfaCode('');
setMfaMsg(null);
};
const closeMfa = () => { setMfaUserId(null); setMfaSecret(null); setMfaTotpUri(null); setMfaCode(''); setMfaMsg(null); };
const handleMfaSetup = async () => {
setMfaLoading(true);
setMfaMsg(null);
try {
const data = await client.post('/mfa/setup') as unknown as { secret: string; uri: string };
setMfaSecret(data.secret);
setMfaTotpUri(data.uri);
} catch (err) {
setMfaMsg({ text: err instanceof Error ? err.message : 'Erro', type: 'error' });
} finally {
setMfaLoading(false);
}
};
const handleMfaVerify = async () => {
if (mfaCode.length !== 6) { setMfaMsg({ text: t('mfa.invalidCode'), type: 'error' }); return; }
setMfaLoading(true);
setMfaMsg(null);
try {
await client.post('/mfa/verify', { totp_code: mfaCode });
setMfaMsg({ text: t('mfa.activated'), type: 'success' });
setMfaSecret(null); setMfaTotpUri(null); setMfaCode('');
await fetchUsers();
await checkAuth();
} catch (err) {
setMfaMsg({ text: err instanceof Error ? err.message : 'Codigo invalido', type: 'error' });
} finally {
setMfaLoading(false);
}
};
const handleMfaDisable = async (userId: string) => {
if (!window.confirm(t('mfa.confirmDisable'))) return;
setMfaLoading(true);
setMfaMsg(null);
try {
await client.post(`/mfa/disable/${userId}`);
setMfaMsg({ text: t('mfa.deactivated'), type: 'success' });
await fetchUsers();
if (userId === currentUser?.id) await checkAuth();
} catch (err) {
setMfaMsg({ text: err instanceof Error ? err.message : 'Erro', type: 'error' });
} finally {
setMfaLoading(false);
}
};
const activeUsers = users.filter(u => u.is_active); const activeUsers = users.filter(u => u.is_active);
const inactiveUsers = users.filter(u => !u.is_active); const inactiveUsers = users.filter(u => !u.is_active);
@@ -365,14 +432,23 @@ export default function UsersPage() {
{/* MFA */} {/* MFA */}
<td className="px-4 py-3 text-center"> <td className="px-4 py-3 text-center">
{u.mfa_enabled ? ( {u.mfa_enabled ? (
<span <button
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.65rem] font-semibold" onClick={() => openMfa(u.id)}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.65rem] font-semibold cursor-pointer border-none"
style={{ background: 'var(--gnl)', color: 'var(--gn)' }} style={{ background: 'var(--gnl)', color: 'var(--gn)' }}
title={t('mfa.configure')}
> >
<CheckCircle size={11} /> {t('usr.active')} <ShieldCheck size={11} /> {t('usr.active')}
</span> </button>
) : ( ) : (
<span className="text-xs" style={{ color: 'var(--t4)' }}>---</span> <button
onClick={() => openMfa(u.id)}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.65rem] font-semibold cursor-pointer border-none"
style={{ background: 'var(--bg3)', color: 'var(--t4)' }}
title={t('mfa.configure')}
>
<Lock size={11} /> {t('mfa.configure')}
</button>
)} )}
</td> </td>
@@ -458,6 +534,106 @@ export default function UsersPage() {
</div> </div>
</div> </div>
)} )}
{/* MFA Modal */}
{mfaUserId && (() => {
const targetUser = users.find(u => u.id === mfaUserId);
if (!targetUser) return null;
const isSelf = mfaUserId === currentUser?.id;
const mfaOn = !!targetUser.mfa_enabled;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,.5)' }} onClick={closeMfa}>
<div className="rounded-xl overflow-hidden" style={{ background: 'var(--bg1)', border: '1px solid var(--bd)', width: 440, maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: '1px solid var(--bd)' }}>
<div className="flex items-center gap-2">
<Lock size={16} style={{ color: 'var(--ac)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--t1)' }}>
MFA {targetUser.first_name} {targetUser.last_name}
</span>
</div>
<button onClick={closeMfa} className="p-1 rounded cursor-pointer" style={{ color: 'var(--t4)' }}><X size={16} /></button>
</div>
<div className="p-5">
{/* Message */}
{mfaMsg && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg mb-4 text-xs font-medium"
style={{ background: mfaMsg.type === 'success' ? 'var(--gnl)' : 'var(--rdl)', color: mfaMsg.type === 'success' ? 'var(--gn)' : 'var(--rd)' }}>
{mfaMsg.type === 'success' ? <CheckCircle size={14} /> : <AlertTriangle size={14} />}
{mfaMsg.text}
</div>
)}
{mfaOn ? (
/* MFA enabled — show disable */
<div className="flex flex-col items-center gap-4 py-4">
<div className="w-14 h-14 rounded-full flex items-center justify-center" style={{ background: 'color-mix(in srgb, var(--gn) 10%, transparent)' }}>
<ShieldCheck size={28} style={{ color: 'var(--gn)' }} />
</div>
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>{t('mfa.enabledDesc')}</p>
<button onClick={() => handleMfaDisable(mfaUserId)} disabled={mfaLoading}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer"
style={{ background: 'var(--rdl)', color: 'var(--rd)', border: '1px solid color-mix(in srgb, var(--rd) 25%, transparent)' }}>
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <ShieldOff size={14} />}
{t('mfa.disableBtn')}
</button>
</div>
) : isSelf && mfaSecret && mfaTotpUri ? (
/* Setup step 2: QR + verify (only for self) */
<div className="flex flex-col gap-4">
<div className="flex justify-center">
<div className="p-3 rounded-xl" style={{ background: '#fff' }}>
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(mfaTotpUri)}`}
alt="QR Code" width={160} height={160} className="rounded" />
</div>
</div>
<div className="px-3 py-2 rounded-lg text-center" style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}>
<span className="text-[.6rem] block mb-1" style={{ color: 'var(--t4)' }}>SECRET</span>
<code className="text-xs font-bold tracking-wider select-all" style={{ color: 'var(--ac)', fontFamily: 'var(--fm)' }}>{mfaSecret}</code>
</div>
<div className="flex gap-2">
<input type="text" value={mfaCode} onChange={e => setMfaCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000" maxLength={6} autoFocus
className="flex-1 px-3 py-2 rounded-lg text-sm text-center tracking-[.3em] font-semibold outline-none"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', fontFamily: 'var(--fm)' }}
onKeyDown={e => { if (e.key === 'Enter') handleMfaVerify(); }} />
<button onClick={handleMfaVerify} disabled={mfaLoading || mfaCode.length !== 6}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer disabled:opacity-50"
style={{ background: 'var(--gn)', color: '#fff' }}>
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <KeyRound size={14} />}
{t('mfa.activateMfa')}
</button>
</div>
</div>
) : isSelf ? (
/* Setup step 1: generate (only for self) */
<div className="flex flex-col items-center gap-4 py-4">
<div className="w-14 h-14 rounded-full flex items-center justify-center" style={{ background: 'color-mix(in srgb, var(--yl) 10%, transparent)' }}>
<KeyRound size={28} style={{ color: 'var(--yl)' }} />
</div>
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>{t('mfa.generateHint')}</p>
<button onClick={handleMfaSetup} disabled={mfaLoading}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer"
style={{ background: 'var(--ac)', color: '#fff' }}>
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <KeyRound size={14} />}
{t('mfa.generateSecret')}
</button>
</div>
) : (
/* Not self — can only disable */
<div className="flex flex-col items-center gap-4 py-4">
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>
{t('usr.mfaSelfOnly')}
</p>
</div>
)}
</div>
</div>
</div>
);
})()}
</div> </div>
); );
} }

View File

@@ -115,9 +115,9 @@ export default function AdbConfigPage() {
const result = await adbApi.parseWallet(file); const result = await adbApi.parseWallet(file);
setDsnOptions(result.dsn_names); setDsnOptions(result.dsn_names);
if (result.dsn_names.length > 0) setDsn(result.dsn_names[0]); if (result.dsn_names.length > 0) setDsn(result.dsn_names[0]);
setFormMsg({ type: 's', text: `Wallet analisado! ${result.dsn_names.length} DSN(s): <strong>${result.dsn_names.join(', ')}</strong>. Arquivos: ${result.files.join(', ')}` }); setFormMsg({ type: 's', text: t('adb.walletParsed').replace('{0}', String(result.dsn_names.length)).replace('{1}', result.dsn_names.join(', ')).replace('{2}', result.files.join(', ')) });
} catch (err) { } catch (err) {
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao analisar wallet' }); setFormMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorLoad') });
} finally { } finally {
setParsingWallet(false); setParsingWallet(false);
} }
@@ -150,12 +150,12 @@ export default function AdbConfigPage() {
} else { } else {
await adbApi.create(fd); await adbApi.create(fd);
} }
setFormMsg({ type: 's', text: `Conexao ${editing ? 'atualizada' : 'salva'}!${walletFile ? ' Wallet incluido.' : ''}` }); setFormMsg({ type: 's', text: (editing ? t('adb.updatedSuccess') : t('adb.savedSuccess')) + (walletFile ? t('adb.walletIncluded') : '') });
await fetchConfigs(); await fetchConfigs();
useAppStore.getState().loadData(); useAppStore.getState().loadData();
setTimeout(resetForm, 1200); setTimeout(resetForm, 1200);
} catch (err) { } catch (err) {
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao salvar' }); setFormMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorSave') });
setSaving(false); setSaving(false);
} }
}; };
@@ -167,11 +167,11 @@ export default function AdbConfigPage() {
await adbApi.remove(id); await adbApi.remove(id);
await fetchConfigs(); await fetchConfigs();
useAppStore.getState().loadData(); useAppStore.getState().loadData();
setTableMsg({ type: 's', text: 'Conexao excluida com sucesso!' }); setTableMsg({ type: 's', text: t('adb.deletedSuccess') });
if (editing?.id === id) resetForm(); if (editing?.id === id) resetForm();
setTimeout(() => setTableMsg(null), 3000); setTimeout(() => setTableMsg(null), 3000);
} catch (err) { } catch (err) {
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao excluir' }); setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorDelete') });
} }
}; };
@@ -196,27 +196,27 @@ export default function AdbConfigPage() {
if (!file) { setWalletMsg({ type: 'e', text: t('adb.selectWallet') }); return; } if (!file) { setWalletMsg({ type: 'e', text: t('adb.selectWallet') }); return; }
if (!walletUploadId) { setWalletMsg({ type: 'e', text: t('adb.selectConnection') }); return; } if (!walletUploadId) { setWalletMsg({ type: 'e', text: t('adb.selectConnection') }); return; }
setUploadingWallet(true); setUploadingWallet(true);
setWalletMsg({ type: 'i', text: 'Enviando wallet...' }); setWalletMsg({ type: 'i', text: t('adb.walletSending') });
try { try {
const res = await adbApi.uploadWallet(walletUploadId, file); const res = await adbApi.uploadWallet(walletUploadId, file);
await fetchConfigs(); await fetchConfigs();
useAppStore.getState().loadData(); useAppStore.getState().loadData();
setWalletMsg({ type: 's', text: `Wallet enviada! Arquivos: ${res.files.join(', ')}` }); setWalletMsg({ type: 's', text: t('adb.walletSent').replace('{0}', res.files.join(', ')) });
if (walletUploadRef.current) walletUploadRef.current.value = ''; if (walletUploadRef.current) walletUploadRef.current.value = '';
} catch (err) { } catch (err) {
setWalletMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao enviar wallet' }); setWalletMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorSave') });
} finally { } finally {
setUploadingWallet(false); setUploadingWallet(false);
} }
}; };
const handleAddTable = async (vid: string) => { const handleAddTable = async (vid: string) => {
if (!newTableName.trim()) { setTableMsg({ type: 'e', text: 'Digite o nome da tabela.' }); return; } if (!newTableName.trim()) { setTableMsg({ type: 'e', text: t('adb.enterTableName') }); return; }
try { try {
await adbApi.addTable(vid, newTableName.trim(), newTableDesc.trim()); await adbApi.addTable(vid, newTableName.trim(), newTableDesc.trim());
await fetchConfigs(); await fetchConfigs();
useAppStore.getState().loadData(); useAppStore.getState().loadData();
setTableMsg({ type: 's', text: `Tabela ${newTableName.trim()} registrada!` }); setTableMsg({ type: 's', text: t('adb.tableRegistered').replace('{0}', newTableName.trim()) });
setNewTableName(''); setNewTableName('');
setNewTableDesc(''); setNewTableDesc('');
setTimeout(() => setTableMsg(null), 3000); setTimeout(() => setTableMsg(null), 3000);
@@ -236,12 +236,12 @@ export default function AdbConfigPage() {
}; };
const handleRemoveTable = async (vid: string, tid: string) => { const handleRemoveTable = async (vid: string, tid: string) => {
if (!confirm('Excluir esta tabela do registro?')) return; if (!confirm(t('adb.confirmRemoveTable'))) return;
try { try {
await adbApi.removeTable(vid, tid); await adbApi.removeTable(vid, tid);
await fetchConfigs(); await fetchConfigs();
useAppStore.getState().loadData(); useAppStore.getState().loadData();
setTableMsg({ type: 's', text: 'Tabela removida.' }); setTableMsg({ type: 's', text: t('adb.tableRemoved') });
setTimeout(() => setTableMsg(null), 3000); setTimeout(() => setTableMsg(null), 3000);
} catch (err) { } catch (err) {
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' }); setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
@@ -299,7 +299,7 @@ export default function AdbConfigPage() {
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
{['Nome', 'DSN', 'User', 'Tabelas', 'mTLS', 'Wallet', 'Embed', 'Acoes'].map((h) => ( {[t('adb.thName'), t('adb.thDsn'), t('adb.thUser'), t('adb.thTables'), t('adb.thMtls'), t('adb.thWallet'), t('adb.thEmbed'), t('adb.thActions')].map((h) => (
<th key={h}>{h}</th> <th key={h}>{h}</th>
))} ))}
</tr> </tr>
@@ -355,7 +355,7 @@ export default function AdbConfigPage() {
<table className="w-full text-[.68rem]" style={{ borderCollapse: 'collapse' }}> <table className="w-full text-[.68rem]" style={{ borderCollapse: 'collapse' }}>
<thead> <thead>
<tr> <tr>
{['Tabela', 'Descrição', 'Ativa', 'Ações'].map((h) => ( {[t('adb.thTable'), t('adb.thDescription'), t('adb.thActive'), t('adb.thTableActions')].map((h) => (
<th key={h} className="text-left px-2 py-1 text-[.6rem] font-semibold uppercase" style={{ color: 'var(--t4)', borderBottom: '1px solid var(--bd)' }}>{h}</th> <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> </tr>
@@ -480,7 +480,7 @@ export default function AdbConfigPage() {
<div className="px-5 pb-5 flex flex-col gap-4" style={{ borderTop: '1px solid var(--bd)' }}> <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)' }}> <p className="text-[.7rem] pt-3" style={{ color: 'var(--t4)' }}>
{editing {editing
? <>Editando: <strong style={{ color: 'var(--t2)' }}>{editing.config_name}</strong></> ? <>{t('adb.editingLabel')} <strong style={{ color: 'var(--t2)' }}>{editing.config_name}</strong></>
: <>{t('adb.connDesc')}</>} : <>{t('adb.connDesc')}</>}
</p> </p>

View File

@@ -114,7 +114,7 @@ export default function EmbConsultPage() {
} catch (err) { } catch (err) {
const assistantMsg: ChatMessage = { const assistantMsg: ChatMessage = {
role: 'assistant', role: 'assistant',
content: `Erro: ${err instanceof Error ? err.message : 'Erro desconhecido'}`, content: `${t('ec.errorPrefix')}${err instanceof Error ? err.message : t('ec.errorUnknown')}`,
}; };
setMessages((prev) => [...prev, assistantMsg]); setMessages((prev) => [...prev, assistantMsg]);
} finally { } finally {

View File

@@ -112,19 +112,19 @@ export default function EmbeddingsPage() {
setTotal(d.total); setTotal(d.total);
if (!d.documents.length) setListMsg({ type: 'i', text: t('emb.noEmbeddings') }); if (!d.documents.length) setListMsg({ type: 'i', text: t('emb.noEmbeddings') });
} catch (err) { } catch (err) {
setListMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao carregar embeddings' }); setListMsg({ type: 'e', text: err instanceof Error ? err.message : t('emb.errorLoadEmb') });
} finally { } finally {
setListLoading(false); setListLoading(false);
} }
}; };
const deleteEmb = async (docId: string) => { const deleteEmb = async (docId: string) => {
if (!confirm('Excluir este embedding?')) return; if (!confirm(t('emb.confirmDeleteEmb'))) return;
try { try {
await embeddingsApi.remove(selVid, docId, selTable || undefined); await embeddingsApi.remove(selVid, docId, selTable || undefined);
loadEmbs(); loadEmbs();
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : 'Erro ao excluir'); alert(err instanceof Error ? err.message : t('common.errorDelete'));
} }
}; };
@@ -311,7 +311,7 @@ export default function EmbeddingsPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
<p className="text-[.68rem]" style={{ color: 'var(--t4)' }}>Total: {total} documentos</p> <p className="text-[.68rem]" style={{ color: 'var(--t4)' }}>{t('emb.totalDocs').replace('{0}', String(total))}</p>
</> </>
)} )}
</div> </div>

View File

@@ -96,7 +96,7 @@ export default function GenAiConfigPage() {
if (!cfg) return; if (!cfg) return;
setGenaiRegion(cfg.region); setGenaiRegion(cfg.region);
if (cfg.compartment_id) setCompartmentId(cfg.compartment_id); if (cfg.compartment_id) setCompartmentId(cfg.compartment_id);
setFormMsg({ type: 'i', text: `Regiao e Compartment preenchidos de <strong>${cfg.tenancy_name}</strong>` }); setFormMsg({ type: 'i', text: t('genai.regionFilled').replace('{0}', cfg.tenancy_name) });
setTimeout(() => setFormMsg(null), 3000); setTimeout(() => setFormMsg(null), 3000);
}; };
@@ -128,12 +128,12 @@ export default function GenAiConfigPage() {
} else { } else {
await genaiApi.create(body); await genaiApi.create(body);
} }
setFormMsg({ type: 's', text: `Modelo ${editing ? 'atualizado' : 'salvo'} com sucesso!` }); setFormMsg({ type: 's', text: editing ? t('genai.updatedSuccess') : t('genai.savedSuccess') });
await fetchConfigs(); await fetchConfigs();
useAppStore.getState().loadData(); useAppStore.getState().loadData();
setTimeout(resetForm, 1200); setTimeout(resetForm, 1200);
} catch (err) { } catch (err) {
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao salvar' }); setFormMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorSave') });
setSaving(false); setSaving(false);
} }
}; };
@@ -145,11 +145,11 @@ export default function GenAiConfigPage() {
await genaiApi.remove(id); await genaiApi.remove(id);
await fetchConfigs(); await fetchConfigs();
useAppStore.getState().loadData(); useAppStore.getState().loadData();
setTableMsg({ type: 's', text: 'Modelo excluido com sucesso!' }); setTableMsg({ type: 's', text: t('genai.deletedSuccess') });
if (editing?.id === id) resetForm(); if (editing?.id === id) resetForm();
setTimeout(() => setTableMsg(null), 3000); setTimeout(() => setTableMsg(null), 3000);
} catch (err) { } catch (err) {
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao excluir' }); setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorDelete') });
} }
}; };
@@ -231,7 +231,7 @@ export default function GenAiConfigPage() {
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
{['Config', 'Modelo', 'OCI Config', 'Regiao', 'Acoes'].map((h) => ( {[t('genai.thConfig'), t('genai.thModel'), t('genai.thOciConfig'), t('genai.thRegion'), t('genai.thActions')].map((h) => (
<th key={h}>{h}</th> <th key={h}>{h}</th>
))} ))}
</tr> </tr>
@@ -345,7 +345,7 @@ export default function GenAiConfigPage() {
<div className="px-5 pb-5 flex flex-col gap-4" style={{ borderTop: '1px solid var(--bd)' }}> <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)' }}> <p className="text-[.7rem] pt-3" style={{ color: 'var(--t4)' }}>
{editing {editing
? <>Editando: <strong style={{ color: 'var(--t2)' }}>{editing.name}</strong></> ? <>{t('genai.editingLabel')} <strong style={{ color: 'var(--t2)' }}>{editing.name}</strong></>
: t('genai.addDesc')} : t('genai.addDesc')}
</p> </p>

View File

@@ -133,28 +133,28 @@ export default function McpServersPage() {
} else { } else {
await mcpApi.create(body); await mcpApi.create(body);
} }
setFormMsg({ type: 's', text: `Server ${editing ? 'atualizado' : 'registrado'} com sucesso!` }); setFormMsg({ type: 's', text: editing ? t('mcp.updatedSuccess') : t('mcp.savedSuccess') });
await fetchServers(); await fetchServers();
useAppStore.getState().loadData(); useAppStore.getState().loadData();
setTimeout(resetForm, 1200); setTimeout(resetForm, 1200);
} catch (err) { } catch (err) {
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao salvar' }); setFormMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorSave') });
setSaving(false); setSaving(false);
} }
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
setConfirmDelete(null); setConfirmDelete(null);
setTableMsg({ type: 'i', text: 'Excluindo server...' }); setTableMsg({ type: 'i', text: t('mcp.deletingServer') });
try { try {
await mcpApi.remove(id); await mcpApi.remove(id);
await fetchServers(); await fetchServers();
useAppStore.getState().loadData(); useAppStore.getState().loadData();
setTableMsg({ type: 's', text: 'Server excluido com sucesso!' }); setTableMsg({ type: 's', text: t('mcp.deletedSuccess') });
if (editing?.id === id) resetForm(); if (editing?.id === id) resetForm();
setTimeout(() => setTableMsg(null), 3000); setTimeout(() => setTableMsg(null), 3000);
} catch (err) { } catch (err) {
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao excluir' }); setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorDelete') });
} }
}; };
@@ -178,11 +178,11 @@ export default function McpServersPage() {
const res = await mcpApi.discoverTools(id); const res = await mcpApi.discoverTools(id);
await fetchServers(); await fetchServers();
useAppStore.getState().loadData(); useAppStore.getState().loadData();
setTableMsg({ type: 's', text: `${res.discovered} tool(s) descoberta(s), ${res.total} total` }); setTableMsg({ type: 's', text: t('mcp.discoveredTools').replace('{0}', String(res.discovered)).replace('{1}', String(res.total)) });
setExpandedTools((p) => ({ ...p, [id]: true })); setExpandedTools((p) => ({ ...p, [id]: true }));
setTimeout(() => setTableMsg(null), 4000); setTimeout(() => setTableMsg(null), 4000);
} catch (err) { } catch (err) {
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao descobrir tools' }); setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('mcp.errorDiscoverTools') });
} finally { } finally {
setDiscovering((p) => ({ ...p, [id]: false })); setDiscovering((p) => ({ ...p, [id]: false }));
} }
@@ -193,7 +193,7 @@ export default function McpServersPage() {
if (!srv?.tools) return; if (!srv?.tools) return;
const tool = srv.tools[idx]; const tool = srv.tools[idx];
const toolName = typeof tool === 'string' ? tool : (tool as McpToolDef).name; const toolName = typeof tool === 'string' ? tool : (tool as McpToolDef).name;
if (!confirm(`Remover tool "${toolName}"?`)) return; if (!confirm(t('mcp.removeToolConfirm').replace('{0}', toolName))) return;
const newTools = [...srv.tools]; const newTools = [...srv.tools];
newTools.splice(idx, 1); newTools.splice(idx, 1);
try { try {
@@ -422,7 +422,7 @@ export default function McpServersPage() {
<div className="px-5 pb-5 flex flex-col gap-4" style={{ borderTop: '1px solid var(--bd)' }}> <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)' }}> <p className="text-[.7rem] pt-3" style={{ color: 'var(--t4)' }}>
{editing {editing
? <>Editando: <strong style={{ color: 'var(--t2)' }}>{editing.name}</strong></> ? <>{t('mcp.editingLabel')} <strong style={{ color: 'var(--t2)' }}>{editing.name}</strong></>
: t('mcp.configDesc')} : t('mcp.configDesc')}
</p> </p>

View File

@@ -299,7 +299,7 @@ export default function OciConfigPage() {
useAppStore.getState().loadData(); useAppStore.getState().loadData();
setTimeout(resetForm, 1200); setTimeout(resetForm, 1200);
} catch (err) { } catch (err) {
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao salvar' }); setFormMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorSave') });
setSaving(false); setSaving(false);
} }
}; };
@@ -315,7 +315,7 @@ export default function OciConfigPage() {
if (editing?.id === id) resetForm(); if (editing?.id === id) resetForm();
setTimeout(() => setTableMsg(null), 3000); setTimeout(() => setTableMsg(null), 3000);
} catch (err) { } catch (err) {
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao excluir' }); setTableMsg({ type: 'e', text: err instanceof Error ? err.message : t('common.errorDelete') });
} }
}; };
@@ -387,7 +387,7 @@ export default function OciConfigPage() {
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
{['Tenancy', 'Tipo', 'Region', 'Detalhes', 'Acoes'].map((h) => ( {[t('oci.thTenancy'), t('oci.thType'), t('oci.thRegion'), t('oci.thDetails'), t('oci.thActions')].map((h) => (
<th key={h}>{h}</th> <th key={h}>{h}</th>
))} ))}
</tr> </tr>

View File

@@ -75,6 +75,34 @@ export interface AdbConfig {
created_at?: string; created_at?: string;
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ChatMessage = Record<string, any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ChatSessionInfo = Record<string, any>;
export interface ChatParams {
temperature: number;
max_tokens: number;
top_p: number;
top_k: number;
frequency_penalty: number;
presence_penalty: number;
reasoning_effort: string;
}
export const DEFAULT_CHAT_PARAMS: ChatParams = {
temperature: 1,
max_tokens: 6000,
top_p: 0.95,
top_k: 1,
frequency_penalty: 0,
presence_penalty: 0,
reasoning_effort: 'medium',
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Updater<T> = T | ((prev: T) => T);
interface AppState { interface AppState {
// Shared config data // Shared config data
ociCfg: OciConfig[]; ociCfg: OciConfig[];
@@ -86,6 +114,128 @@ interface AppState {
mcpSvr: McpServer[]; mcpSvr: McpServer[];
adbCfg: AdbConfig[]; adbCfg: AdbConfig[];
// Chat Agent persistent settings
chatModel: string;
chatOciConfig: string;
chatParams: ChatParams;
chatUseTools: boolean;
setChatModel: (v: string) => void;
setChatOciConfig: (v: string) => void;
setChatParams: (v: Updater<ChatParams>) => void;
setChatUseTools: (v: boolean) => void;
// Chat Agent session state (persists across tab switches)
chatMessages: ChatMessage[];
chatSessionId: string | null;
chatSessions: ChatSessionInfo[];
setChatMessages: (v: Updater<ChatMessage[]>) => void;
setChatSessionId: (v: string | null) => void;
setChatSessions: (v: Updater<ChatSessionInfo[]>) => void;
// ── Terraform Page state ──
tfMessages: ChatMessage[];
tfSessionId: string | null;
tfSessions: ChatSessionInfo[];
tfModel: string;
tfOciConfig: string;
tfRegion: string;
tfCompartment: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tfCompartments: any[];
tfTemperature: number;
tfWsId: string | null;
tfWsStatus: string;
tfWsName: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tfFiles: any[];
tfPlanOutput: string;
tfApplyOutput: string;
tfDestroyOutput: string;
tfWsError: string;
tfBottomTab: string;
tfTerminalView: string;
setTfMessages: (v: Updater<ChatMessage[]>) => void;
setTfSessionId: (v: string | null) => void;
setTfSessions: (v: Updater<ChatSessionInfo[]>) => void;
setTfModel: (v: string) => void;
setTfOciConfig: (v: string) => void;
setTfRegion: (v: string) => void;
setTfCompartment: (v: string) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setTfCompartments: (v: Updater<any[]>) => void;
setTfTemperature: (v: number) => void;
setTfWsId: (v: string | null) => void;
setTfWsStatus: (v: string) => void;
setTfWsName: (v: string) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setTfFiles: (v: Updater<any[]>) => void;
setTfPlanOutput: (v: string) => void;
setTfApplyOutput: (v: string) => void;
setTfDestroyOutput: (v: string) => void;
setTfWsError: (v: string) => void;
setTfBottomTab: (v: string) => void;
setTfTerminalView: (v: string) => void;
// ── Prompt Generator Page state ──
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pgMessages: any[];
pgSessionId: string | null;
pgSelectedModel: string;
pgOciId: string;
pgRegion: string;
pgCompartment: string;
pgHistory: ChatSessionInfo[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setPgMessages: (v: Updater<any[]>) => void;
setPgSessionId: (v: string | null) => void;
setPgSelectedModel: (v: string) => void;
setPgOciId: (v: string) => void;
setPgRegion: (v: string) => void;
setPgCompartment: (v: string) => void;
setPgHistory: (v: Updater<ChatSessionInfo[]>) => void;
// ── Explorer Page state ──
expSelectedConfig: string;
expSelectedCompartment: string;
expSelectedRegions: string[];
expSelectedCategory: string;
expSelectedResourceType: string;
expTreeWidth: number;
setExpSelectedConfig: (v: string) => void;
setExpSelectedCompartment: (v: string) => void;
setExpSelectedRegions: (v: Updater<string[]>) => void;
setExpSelectedCategory: (v: string) => void;
setExpSelectedResourceType: (v: string) => void;
setExpTreeWidth: (v: number) => void;
// ── Reports Page state ──
rptOciVal: string;
rptLevel: 1 | 2;
rptSelectedRegions: string[];
rptObp: boolean;
rptRaw: boolean;
rptRedact: boolean;
rptFormOpen: boolean;
rptSelectedRid: string;
rptTrackingId: string | null;
rptShowIframe: boolean;
rptShowCompliance: boolean;
rptEmbedAdb: string;
rptEmbedTable: string;
setRptOciVal: (v: string) => void;
setRptLevel: (v: 1 | 2) => void;
setRptSelectedRegions: (v: Updater<string[]>) => void;
setRptObp: (v: boolean) => void;
setRptRaw: (v: boolean) => void;
setRptRedact: (v: boolean) => void;
setRptFormOpen: (v: boolean) => void;
setRptSelectedRid: (v: string) => void;
setRptTrackingId: (v: string | null) => void;
setRptShowIframe: (v: boolean) => void;
setRptShowCompliance: (v: boolean) => void;
setRptEmbedAdb: (v: string) => void;
setRptEmbedTable: (v: string) => void;
// UI // UI
sidebarOpen: boolean; sidebarOpen: boolean;
toggleSidebar: () => void; toggleSidebar: () => void;
@@ -103,6 +253,120 @@ export const useAppStore = create<AppState>((set) => ({
embModels: {}, embModels: {},
mcpSvr: [], mcpSvr: [],
adbCfg: [], adbCfg: [],
chatModel: '',
chatOciConfig: '',
chatParams: { ...DEFAULT_CHAT_PARAMS },
chatUseTools: true,
setChatModel: (v) => set({ chatModel: v }),
setChatOciConfig: (v) => set({ chatOciConfig: v }),
setChatParams: (v) => set((s) => ({ chatParams: typeof v === 'function' ? v(s.chatParams) : v })),
setChatUseTools: (v) => set({ chatUseTools: v }),
chatMessages: [],
chatSessionId: null,
chatSessions: [],
setChatMessages: (v) => set((s) => ({ chatMessages: typeof v === 'function' ? v(s.chatMessages) : v })),
setChatSessionId: (v) => set({ chatSessionId: v }),
setChatSessions: (v) => set((s) => ({ chatSessions: typeof v === 'function' ? v(s.chatSessions) : v })),
// ── Terraform Page ──
tfMessages: [],
tfSessionId: null,
tfSessions: [],
tfModel: 'openai.gpt-4.1',
tfOciConfig: '',
tfRegion: '',
tfCompartment: '',
tfCompartments: [],
tfTemperature: 0.3,
tfWsId: null,
tfWsStatus: 'draft',
tfWsName: '',
tfFiles: [],
tfPlanOutput: '',
tfApplyOutput: '',
tfDestroyOutput: '',
tfWsError: '',
tfBottomTab: 'files',
tfTerminalView: 'plan',
setTfMessages: (v) => set((s) => ({ tfMessages: typeof v === 'function' ? v(s.tfMessages) : v })),
setTfSessionId: (v) => set({ tfSessionId: v }),
setTfSessions: (v) => set((s) => ({ tfSessions: typeof v === 'function' ? v(s.tfSessions) : v })),
setTfModel: (v) => set({ tfModel: v }),
setTfOciConfig: (v) => set({ tfOciConfig: v }),
setTfRegion: (v) => set({ tfRegion: v }),
setTfCompartment: (v) => set({ tfCompartment: v }),
setTfCompartments: (v) => set((s) => ({ tfCompartments: typeof v === 'function' ? v(s.tfCompartments) : v })),
setTfTemperature: (v) => set({ tfTemperature: v }),
setTfWsId: (v) => set({ tfWsId: v }),
setTfWsStatus: (v) => set({ tfWsStatus: v }),
setTfWsName: (v) => set({ tfWsName: v }),
setTfFiles: (v) => set((s) => ({ tfFiles: typeof v === 'function' ? v(s.tfFiles) : v })),
setTfPlanOutput: (v) => set({ tfPlanOutput: v }),
setTfApplyOutput: (v) => set({ tfApplyOutput: v }),
setTfDestroyOutput: (v) => set({ tfDestroyOutput: v }),
setTfWsError: (v) => set({ tfWsError: v }),
setTfBottomTab: (v) => set({ tfBottomTab: v }),
setTfTerminalView: (v) => set({ tfTerminalView: v }),
// ── Prompt Generator Page ──
pgMessages: [],
pgSessionId: null,
pgSelectedModel: '',
pgOciId: '',
pgRegion: '',
pgCompartment: '',
pgHistory: [],
setPgMessages: (v) => set((s) => ({ pgMessages: typeof v === 'function' ? v(s.pgMessages) : v })),
setPgSessionId: (v) => set({ pgSessionId: v }),
setPgSelectedModel: (v) => set({ pgSelectedModel: v }),
setPgOciId: (v) => set({ pgOciId: v }),
setPgRegion: (v) => set({ pgRegion: v }),
setPgCompartment: (v) => set({ pgCompartment: v }),
setPgHistory: (v) => set((s) => ({ pgHistory: typeof v === 'function' ? v(s.pgHistory) : v })),
// ── Explorer Page ──
expSelectedConfig: '',
expSelectedCompartment: '',
expSelectedRegions: [],
expSelectedCategory: 'Compute',
expSelectedResourceType: 'instances',
expTreeWidth: 280,
setExpSelectedConfig: (v) => set({ expSelectedConfig: v }),
setExpSelectedCompartment: (v) => set({ expSelectedCompartment: v }),
setExpSelectedRegions: (v) => set((s) => ({ expSelectedRegions: typeof v === 'function' ? v(s.expSelectedRegions) : v })),
setExpSelectedCategory: (v) => set({ expSelectedCategory: v }),
setExpSelectedResourceType: (v) => set({ expSelectedResourceType: v }),
setExpTreeWidth: (v) => set({ expTreeWidth: v }),
// ── Reports Page ──
rptOciVal: '',
rptLevel: 2,
rptSelectedRegions: [],
rptObp: true,
rptRaw: false,
rptRedact: true,
rptFormOpen: true,
rptSelectedRid: '',
rptTrackingId: null,
rptShowIframe: false,
rptShowCompliance: false,
rptEmbedAdb: '',
rptEmbedTable: '',
setRptOciVal: (v) => set({ rptOciVal: v }),
setRptLevel: (v) => set({ rptLevel: v }),
setRptSelectedRegions: (v) => set((s) => ({ rptSelectedRegions: typeof v === 'function' ? v(s.rptSelectedRegions) : v })),
setRptObp: (v) => set({ rptObp: v }),
setRptRaw: (v) => set({ rptRaw: v }),
setRptRedact: (v) => set({ rptRedact: v }),
setRptFormOpen: (v) => set({ rptFormOpen: v }),
setRptSelectedRid: (v) => set({ rptSelectedRid: v }),
setRptTrackingId: (v) => set({ rptTrackingId: v }),
setRptShowIframe: (v) => set({ rptShowIframe: v }),
setRptShowCompliance: (v) => set({ rptShowCompliance: v }),
setRptEmbedAdb: (v) => set({ rptEmbedAdb: v }),
setRptEmbedTable: (v) => set({ rptEmbedTable: v }),
sidebarOpen: true, sidebarOpen: true,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })), toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,7 @@
map $uri $empty {
default "";
}
server { server {
listen 80; listen 80;
server_name _; server_name _;
@@ -9,7 +13,12 @@ server {
location / { location / {
root /usr/share/nginx/html/app; root /usr/share/nginx/html/app;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
} }
# Backend API proxy # Backend API proxy
@@ -22,5 +31,7 @@ server {
proxy_read_timeout 900s; proxy_read_timeout 900s;
proxy_send_timeout 900s; proxy_send_timeout 900s;
proxy_connect_timeout 60s; proxy_connect_timeout 60s;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
} }
} }