feat: session token auth, workspace submenu, retry buttons, terraform fixes, app rename

- Rename app to "AI Agent - Infrastructure & Security Engineer"
- OCI session token authentication (SecurityTokenSigner, conditional form, type tags)
- Terraform Workspaces sidebar sub-tab (date-grouped accordion, INNER JOIN filter)
- Retry button on failed messages (Chat, Terraform, Prompt Generator)
- Fix f2 undefined in dedup, oci_core_services false validation, Re-plan tfCode sync
- Auto-fix button no longer requires pre-selected model
- DRG attachment rules added to Terraform system prompt
- Stuck workspace recovery on container restart
This commit is contained in:
nogueiraguh
2026-03-12 18:39:35 -03:00
parent 18fabec805
commit baacc1ba21
3 changed files with 548 additions and 119 deletions

View File

@@ -1,15 +1,15 @@
<p align="center">
<img src="logo.svg" alt="OCI CIS AI Agent" width="96" height="96">
<img src="logo.svg" alt="AI Agent - Infrastructure & Security Engineer" width="96" height="96">
</p>
<h1 align="center">OCI CIS AI Agent</h1>
<h1 align="center">AI Agent — Infrastructure & Security Engineer</h1>
<p align="center">
<strong>Oracle Cloud Infrastructure — CIS Foundations Benchmark 3.0 — AI-Powered Compliance Platform</strong>
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-2.5-C74634?style=flat-square" alt="Version">
<img src="https://img.shields.io/badge/version-2.6-C74634?style=flat-square" alt="Version">
<img src="https://img.shields.io/badge/python-3.12-3776AB?style=flat-square" alt="Python">
<img src="https://img.shields.io/badge/FastAPI-0.115-009688?style=flat-square" alt="FastAPI">
<img src="https://img.shields.io/badge/OCI-GenAI-C74634?style=flat-square" alt="OCI">
@@ -21,7 +21,7 @@
## Overview
OCI CIS AI Agent is a self-hosted web application that automates **CIS Oracle Cloud Infrastructure Foundations Benchmark 3.0** compliance checks, powered by **OCI Generative AI** for intelligent analysis and an **MCP (Model Context Protocol)** server architecture for extensible task execution.
AI Agent — Infrastructure & Security Engineer is a self-hosted web application that automates **CIS Oracle Cloud Infrastructure Foundations Benchmark 3.0** compliance checks, powered by **OCI Generative AI** for intelligent analysis and an **MCP (Model Context Protocol)** server architecture for extensible task execution.
The platform combines security compliance scanning, AI-powered chat with **RAG (Retrieval-Augmented Generation)**, infrastructure exploration, and vector-based knowledge storage into a single, containerized solution with an **Oracle Dark Premium** theme (light/dark modes), **KPI dashboard** with compliance gauge, and **Chart.js** visualizations.
@@ -697,6 +697,7 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the "
| Version | Date | Changes |
|---------|------|---------|
| **v2.6** | 2026-03 | **Rename**: app renamed to "AI Agent — Infrastructure & Security Engineer". **Session Token Auth**: OCI session token authentication support (type selector API Key/Session Token, conditional form fields, collapsible credential form, type tags in credentials table, `SecurityTokenSigner` backend). **Terraform Workspaces submenu**: sidebar sub-tab listing all workspaces grouped by date with accordion UI, filtered by existing chat sessions (INNER JOIN), actions (plan/apply/destroy/delete/open). **Retry on failure**: "Reenviar" button on failed messages across Chat Agent, Terraform Agent, and Prompt Generator. **Terraform fixes**: `f2` undefined variable in dedup logic, `oci_core_services` false positive validation (data sources excluded), `S.tfCode` reconstruction from `S.tfFiles` for Re-plan, auto-fix button visible without pre-selected model, DRG attachment rules in system prompt. **Resilience**: stuck workspace recovery on container restart (planning/applying/destroying → reset + lock file cleanup). |
| **v2.5** | 2026-03 | **Explorer Expansion**: KPI stats bar with per-category resource counts (cached in SQLite `explorer_counts_cache`, background refresh on compartment access), enhanced views for VCNs/Subnets/Load Balancers/Buckets with expandable cards, start/stop for DB Systems + MySQL + Container Instances (5 resource types total), dead state filtering (`TERMINATED`/`DELETED`/`DELETING` hidden across all 36 endpoints), wider compartment tree (280px), IAM excluded from compartment totals. **Prompt Generator Intelligence**: same knowledge pipeline as Terraform Agent — auto-detects resource types from user message, fetches official docs (Example Usage + Arguments) from GitHub cached in SQLite, uses compact categorized resource reference. **Network Firewall fix**: `NetworkFirewallPolicySummaryCollection` not iterable — added `.items` accessor for collection objects. |
| **v2.4** | 2026-03 | **Terraform Safety & UX**: region safety guard (plan blocked if model omits `variable "region"` — prevents wrong-region provisioning), rollback button on apply failure (manual `terraform destroy` with "ROLLBACK" confirmation), SSH public key field in OCI configs (auto-injected into `terraform.tfvars` for compute instances), reasoning_effort uppercase fix for OCI SDK, split-panel layout (terminal right 40%, files/plan/resources below chat 60%), plan button persists after destroy. **Terraform tfvars**: expanded `oci_var_map` with `ssh_public_key`/`ssh_authorized_keys` auto-mapping from OCI config |
| **v2.3** | 2026-03 | **Consult Embeddings**: dedicated sub-menu with chat-like interface for natural language Q&A against vector store — queries all active tables via cosine similarity, returns contextual answers with source citations (works with or without GenAI config). **Knowledge Base & RAG Enhancements**: Knowledge Base (Base de Conhecimento) with file upload + URL import to `engineerknowledgebase` vector table, CIS Recommendations upload to `cisrecom` table, URL content extraction (HTML tag/script stripping, remote PDF parsing), auto-resolve embedding config from OCI credentials (no separate GenAI config required), `report_date` metadata tracking for embedding freshness, purge & re-embed flow for updated reports. **ADB Vector Improvements**: case-insensitive table matching with quoted identifiers for Oracle ADB, table validation endpoint against `user_tables`, tables section moved inside edit connection card, case-preserving table registration (removed forced uppercase). **Vector search fix**: FLOAT32 compatibility for VECTOR_DISTANCE queries, base64-decoded compartment_id for auto-resolved OCI configs. **Embeddings tab cleanup**: removed delete buttons, simplified to read-only view, updated descriptions. **Dependencies**: added PyPDF2 for PDF text extraction |

View File

@@ -1,5 +1,5 @@
"""
OCI CIS AI Agent - Backend API v1.1
AI Agent - Infrastructure & Security Engineer - Backend API v1.1
FastAPI with JWT auth, TOTP MFA, RBAC, OCI GenAI (exact SDK pattern),
OCI Account Explorer, MCP Server registry with VectorDB tool integration,
Autonomous DB vector storage, CIS reports, chat agent, audit log.
@@ -52,7 +52,7 @@ COMPACT_MIN_MESSAGES = 8
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("agent")
app = FastAPI(title="OCI CIS AI Agent", version=VERSION)
app = FastAPI(title="AI Agent - Infrastructure & Security Engineer", version=VERSION)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
allow_methods=["*"], allow_headers=["*"])
security = HTTPBearer()
@@ -390,7 +390,7 @@ def init_db():
c.execute(f"ALTER TABLE terraform_workspaces ADD COLUMN {col}")
except sqlite3.OperationalError:
pass
for col in ["ssh_public_key TEXT"]:
for col in ["ssh_public_key TEXT", "auth_type TEXT DEFAULT 'api_key'"]:
try:
c.execute(f"ALTER TABLE oci_configs ADD COLUMN {col}")
except sqlite3.OperationalError:
@@ -427,6 +427,16 @@ def init_db():
else:
c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active) VALUES (?,?,?,?,?)",
(str(uuid.uuid4()), "OCI Terraform Agent", "terraform", TF_DEFAULT_SYSTEM_PROMPT, 1))
# Recover stuck terraform workspaces (interrupted by container restart)
stuck = c.execute("SELECT id, status FROM terraform_workspaces WHERE status IN ('planning','applying','destroying')").fetchall()
for ws in stuck:
prev = 'applied' if ws["status"] == 'destroying' else 'failed'
c.execute("UPDATE terraform_workspaces SET status=?, error='Interrompido por restart do container', updated_at=datetime('now') WHERE id=?", (prev, ws["id"]))
# Remove stale lock files
lock_file = TERRAFORM_DIR / ws["id"] / ".terraform.tfstate.lock.info"
if lock_file.exists():
lock_file.unlink()
log.info(f"Recovered stuck workspace {ws['id']}: {ws['status']} -> {prev}")
adm = c.execute("SELECT id FROM users WHERE username='admin'").fetchone()
if not adm:
c.execute(
@@ -446,7 +456,7 @@ def _totp_verify(secret,code,window=1):
c=str((struct.unpack(">I",h[o:o+4])[0]&0x7FFFFFFF)%1_000_000).zfill(6)
if hmac.compare_digest(c,code): return True
return False
def _totp_uri(secret,user): return f"otpauth://totp/OCI-CIS-Agent:{user}?secret={secret}&issuer=OCI-CIS-Agent"
def _totp_uri(secret,user): return f"otpauth://totp/AI-Agent:{user}?secret={secret}&issuer=AI-Agent"
def _make_token(uid,role,sid):
return pyjwt.encode({"sub":uid,"role":role,"sid":sid,"exp":datetime.utcnow()+timedelta(hours=JWT_EXP_H),"iat":datetime.utcnow()},APP_SECRET,algorithm=JWT_ALG)
def _enc(v): return base64.b64encode(v.encode()).decode()
@@ -469,7 +479,30 @@ def _safe_dec(v):
def _get_oci_config(oci_config_id: str) -> dict:
import oci
config_path = str(OCI_DIR / oci_config_id / "config")
return oci.config.from_file(config_path, "DEFAULT")
config = oci.config.from_file(config_path, "DEFAULT")
return config
def _get_oci_signer(oci_config_id: str):
"""Return (config, signer) tuple. For session_token auth, returns SecurityTokenSigner; for api_key returns None (use default)."""
import oci
config = _get_oci_config(oci_config_id)
if config.get("security_token_file"):
from oci.auth.signers import SecurityTokenSigner
token_path = config["security_token_file"]
with open(token_path) as f:
token = f.read().strip()
signer = SecurityTokenSigner(token, config["key_file"])
return config, signer
return config, None
def _oci_client(client_class, oci_config_id: str, **kwargs):
"""Create an OCI client with correct auth (api_key or session_token)."""
config, signer = _get_oci_signer(oci_config_id)
if signer:
return client_class(config=config, signer=signer, **kwargs)
return client_class(config, **kwargs)
# ── Auth deps ─────────────────────────────────────────────────────────────────
async def current_user(cred: HTTPAuthorizationCredentials = Depends(security)):
@@ -653,43 +686,79 @@ async def del_user(uid: str, adm=Depends(require("admin"))):
@app.post("/api/oci/config")
async def save_oci(
tenancy_name: str = Form(...), tenancy_ocid: str = Form(...),
user_ocid: str = Form(...), fingerprint: str = Form(...),
user_ocid: str = Form(""), fingerprint: str = Form(""),
region: str = Form(...), compartment_id: str = Form(""),
key_passphrase: str = Form(""),
ssh_public_key: str = Form(""),
private_key: UploadFile = File(...), public_key: Optional[UploadFile] = File(None),
auth_type: str = Form("api_key"),
security_token: str = Form(""),
private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None),
u = Depends(require("admin","user"))
):
if auth_type not in ("api_key", "session_token"):
raise HTTPException(400, "auth_type deve ser 'api_key' ou 'session_token'")
cid = str(uuid.uuid4()); cdir = OCI_DIR / cid; cdir.mkdir(parents=True)
kp = cdir / "oci_api_key.pem"
key_bytes = await private_key.read()
kp.write_bytes(key_bytes); kp.chmod(0o600)
# Detect encrypted keys that require passphrase
key_text = key_bytes.decode("utf-8", errors="ignore")
key_is_encrypted = "ENCRYPTED" in key_text or "Proc-Type: 4,ENCRYPTED" in key_text
if key_is_encrypted and not key_passphrase:
# Clean up and warn
shutil.rmtree(cdir, ignore_errors=True)
raise HTTPException(400, "A chave privada está criptografada (ENCRYPTED). Informe a Key Passphrase.")
if public_key: (cdir / "oci_api_key_public.pem").write_bytes(await public_key.read())
cfg_file = cdir / "config"
cfg_content = (f"[DEFAULT]\nuser={user_ocid}\nfingerprint={fingerprint}\n"
f"tenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n")
if key_passphrase:
cfg_content += f"pass_phrase={key_passphrase}\n"
cfg_file.write_text(cfg_content)
cfg_file.chmod(0o600)
if auth_type == "session_token":
# Session token auth: requires tenancy_ocid, region, token, and session key
if not security_token.strip():
shutil.rmtree(cdir, ignore_errors=True)
raise HTTPException(400, "Session Token é obrigatório para autenticação por token.")
if not private_key or not private_key.filename:
shutil.rmtree(cdir, ignore_errors=True)
raise HTTPException(400, "A chave de sessão (.pem) é obrigatória para autenticação por token.")
key_bytes = await private_key.read()
kp.write_bytes(key_bytes); kp.chmod(0o600)
# Save security token file
token_file = cdir / "token"
token_file.write_text(security_token.strip())
token_file.chmod(0o600)
# Generate config
cfg_content = (f"[DEFAULT]\ntenancy={tenancy_ocid}\nregion={region}\n"
f"key_file={kp}\nsecurity_token_file={token_file}\n")
if user_ocid:
cfg_content = f"[DEFAULT]\nuser={user_ocid}\n" + cfg_content.replace("[DEFAULT]\n", "")
cfg_file = cdir / "config"
cfg_file.write_text(cfg_content); cfg_file.chmod(0o600)
# For session_token, fingerprint/user may not be provided — use placeholders
user_ocid = user_ocid or "session_token_user"
fingerprint = fingerprint or "session_token"
else:
# API Key auth (original flow)
if not user_ocid or not fingerprint:
shutil.rmtree(cdir, ignore_errors=True)
raise HTTPException(400, "OCID User e Fingerprint são obrigatórios para API Key.")
if not private_key or not private_key.filename:
shutil.rmtree(cdir, ignore_errors=True)
raise HTTPException(400, "Selecione a chave privada (.pem).")
key_bytes = await private_key.read()
kp.write_bytes(key_bytes); kp.chmod(0o600)
key_text = key_bytes.decode("utf-8", errors="ignore")
key_is_encrypted = "ENCRYPTED" in key_text or "Proc-Type: 4,ENCRYPTED" in key_text
if key_is_encrypted and not key_passphrase:
shutil.rmtree(cdir, ignore_errors=True)
raise HTTPException(400, "A chave privada está criptografada (ENCRYPTED). Informe a Key Passphrase.")
if public_key and public_key.filename:
(cdir / "oci_api_key_public.pem").write_bytes(await public_key.read())
cfg_file = cdir / "config"
cfg_content = (f"[DEFAULT]\nuser={user_ocid}\nfingerprint={fingerprint}\n"
f"tenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n")
if key_passphrase:
cfg_content += f"pass_phrase={key_passphrase}\n"
cfg_file.write_text(cfg_content); cfg_file.chmod(0o600)
with db() as c:
c.execute("INSERT INTO oci_configs (id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,key_file_path,compartment_id,ssh_public_key) VALUES (?,?,?,?,?,?,?,?,?,?)",
(cid, u["id"], tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, str(kp), _enc(compartment_id) if compartment_id else None, ssh_public_key.strip() if ssh_public_key.strip() else None))
_audit(u["id"], u["username"], "save_oci_config", cid, f"tenancy={tenancy_name}")
_config_log("oci", cid, tenancy_name, "success", "save", f"Credencial salva: {tenancy_name} ({region})", u["id"], u["username"])
c.execute("INSERT INTO oci_configs (id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,key_file_path,compartment_id,ssh_public_key,auth_type) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(cid, u["id"], tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, str(kp), _enc(compartment_id) if compartment_id else None, ssh_public_key.strip() if ssh_public_key.strip() else None, auth_type))
_audit(u["id"], u["username"], "save_oci_config", cid, f"tenancy={tenancy_name} auth={auth_type}")
_config_log("oci", cid, tenancy_name, "success", "save", f"Credencial salva: {tenancy_name} ({region}) [{auth_type}]", u["id"], u["username"])
return {"id": cid, "tenancy_name": tenancy_name, "region": region}
@app.get("/api/oci/configs")
async def list_oci(u=Depends(current_user)):
with db() as c:
cols = "id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,compartment_id,ssh_public_key,created_at"
cols = "id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,compartment_id,ssh_public_key,auth_type,created_at"
if u["role"]=="admin": rows=c.execute(f"SELECT {cols} FROM oci_configs").fetchall()
else: rows=c.execute(f"SELECT {cols} FROM oci_configs WHERE user_id=?",(u["id"],)).fetchall()
result = []
@@ -736,6 +805,8 @@ async def update_oci(
region: str = Form(...), compartment_id: str = Form(""),
key_passphrase: str = Form(""),
ssh_public_key: str = Form(""),
auth_type: str = Form(""),
security_token: str = Form(""),
private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None),
u = Depends(require("admin","user"))
):
@@ -748,6 +819,11 @@ async def update_oci(
user_ocid = user_ocid or _safe_dec(existing["user_ocid"])
fingerprint = fingerprint or _safe_dec(existing["fingerprint"])
compartment_id = compartment_id or _safe_dec(existing["compartment_id"]) or ""
try:
existing_auth = existing["auth_type"] or "api_key"
except (IndexError, KeyError):
existing_auth = "api_key"
auth_type = auth_type or existing_auth
try:
existing_ssh = existing["ssh_public_key"] or ""
except (IndexError, KeyError):
@@ -759,21 +835,32 @@ async def update_oci(
key_bytes = await private_key.read()
kp.write_bytes(key_bytes); kp.chmod(0o600)
key_text = key_bytes.decode("utf-8", errors="ignore")
if ("ENCRYPTED" in key_text or "Proc-Type: 4,ENCRYPTED" in key_text) and not key_passphrase:
if auth_type == "api_key" and ("ENCRYPTED" in key_text or "Proc-Type: 4,ENCRYPTED" in key_text) and not key_passphrase:
raise HTTPException(400, "A chave privada está criptografada (ENCRYPTED). Informe a Key Passphrase.")
if public_key and public_key.filename:
(cdir / "oci_api_key_public.pem").write_bytes(await public_key.read())
# Update session token file if provided
if security_token.strip():
token_file = cdir / "token"
token_file.write_text(security_token.strip()); token_file.chmod(0o600)
cfg_file = cdir / "config"
cfg_content = (f"[DEFAULT]\nuser={user_ocid}\nfingerprint={fingerprint}\n"
f"tenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n")
if key_passphrase:
cfg_content += f"pass_phrase={key_passphrase}\n"
if auth_type == "session_token":
token_path = cdir / "token"
cfg_content = f"[DEFAULT]\ntenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n"
if user_ocid and user_ocid != "session_token_user":
cfg_content = f"[DEFAULT]\nuser={user_ocid}\ntenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n"
cfg_content += f"security_token_file={token_path}\n"
else:
cfg_content = (f"[DEFAULT]\nuser={user_ocid}\nfingerprint={fingerprint}\n"
f"tenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n")
if key_passphrase:
cfg_content += f"pass_phrase={key_passphrase}\n"
cfg_file.write_text(cfg_content); cfg_file.chmod(0o600)
with db() as c:
c.execute("UPDATE oci_configs SET tenancy_name=?,tenancy_ocid=?,user_ocid=?,fingerprint=?,region=?,compartment_id=?,ssh_public_key=? WHERE id=?",
(tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, _enc(compartment_id) if compartment_id else None, ssh_public_key or None, cid))
_audit(u["id"], u["username"], "update_oci_config", cid, f"tenancy={tenancy_name}")
_config_log("oci", cid, tenancy_name, "success", "save", f"Credencial atualizada: {tenancy_name} ({region})", u["id"], u["username"])
c.execute("UPDATE oci_configs SET tenancy_name=?,tenancy_ocid=?,user_ocid=?,fingerprint=?,region=?,compartment_id=?,ssh_public_key=?,auth_type=? WHERE id=?",
(tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, _enc(compartment_id) if compartment_id else None, ssh_public_key or None, auth_type, cid))
_audit(u["id"], u["username"], "update_oci_config", cid, f"tenancy={tenancy_name} auth={auth_type}")
_config_log("oci", cid, tenancy_name, "success", "save", f"Credencial atualizada: {tenancy_name} ({region}) [{auth_type}]", u["id"], u["username"])
return {"id": cid, "tenancy_name": tenancy_name, "region": region}
@app.post("/api/oci/test/{cid}")
@@ -2354,6 +2441,7 @@ Se pedirem AWS, Azure, GCP ou outro provider, recuse educadamente.
- Remote peering: APENAS UM LADO do par define `peer_id` e `peer_region_name`. O outro lado é criado SEM `peer_id`. Nunca gere dois RPCs apontando um para o outro (causa Cycle error). Exemplo: RPC_mad1 (sem peer_id) e RPC_mad3 (com peer_id = RPC_mad1.id, peer_region_name = var.region).
- Associação de route table com subnet deve ser via `route_table_id` na subnet ou `oci_core_route_table_attachment`.
- `oci_network_firewall_network_firewall` exige `network_firewall_policy_id`.
- DRG attachments: use `oci_core_drg_attachment` para VCN attachments (tipo padrão). Use `oci_core_drg_attachment_management` SOMENTE para REMOTE_PEERING_CONNECTION (único tipo suportado para management). Nunca duplique resource type+name entre arquivos (ex: dois `oci_core_drg_attachment.vcn_app_x`). Sempre associe `drg_route_table_id` nos VCN attachments para garantir roteamento correto.
### Proibições absolutas — módulos e variáveis
- NUNCA use `module` blocks. Este workspace é flat (um único diretório). Não existem subdiretórios `modules/`. Toda a infraestrutura deve ser definida diretamente com `resource` e `data` blocks.
@@ -2426,9 +2514,9 @@ def _validate_tf_resource_types(tf_code: str) -> list:
Returns list of dicts with invalid types and suggestions."""
import re as _re
from difflib import get_close_matches
# Extract all resource and data type declarations
# Extract only resource type declarations (data sources are read-only and may not be in the schema)
used_types = set()
for m in _re.finditer(r'(?:resource|data)\s+"(\w+)"', tf_code):
for m in _re.finditer(r'resource\s+"(\w+)"', tf_code):
used_types.add(m.group(1))
if not used_types:
return []
@@ -3549,7 +3637,7 @@ async def embed_upload_url(
if not url.startswith(("http://", "https://")):
raise HTTPException(400, "URL inválida — deve começar com http:// ou https://")
try:
resp = req.get(url, timeout=30, headers={"User-Agent": "Mozilla/5.0 OCI-CIS-Agent/1.0"})
resp = req.get(url, timeout=30, headers={"User-Agent": "Mozilla/5.0 AI-Agent/1.0"})
resp.raise_for_status()
except Exception as e:
raise HTTPException(400, f"Erro ao acessar URL: {str(e)[:300]}")
@@ -4483,7 +4571,7 @@ def _agent_respond(msg, user):
return "\n".join(lines)
if any(k in m for k in ["cis","benchmark","checks","verificar"]):
return ("🔒 **CIS OCI Foundations Benchmark 3.0**\n\n54 controles em 8 domínios:\n• IAM: 17 controles\n• Networking: 8 controles\n• Compute: 3 controles\n• Logging & Monitoring: 18 controles\n• Storage: 6 controles\n• Asset Management: 2 controles\n\nConfigure OCI e execute um relatório na aba **Report**.")
return ("Sou o **OCI CIS AI Agent v" + VERSION + "**. Sem modelo GenAI configurado, uso respostas locais.\n\n"
return ("Sou o **AI Agent v" + VERSION + "** — Infrastructure & Security Engineer. Sem modelo GenAI configurado, uso respostas locais.\n\n"
"Para chat com IA:\n1. Configure **OCI Credentials**\n2. Configure **GenAI** com modelo e região\n3. Selecione o modelo no chat\n\nDigite **ajuda** para ver os comandos.")
@app.get("/api/chat/sessions")
@@ -4835,7 +4923,7 @@ class TfPromptReq(BaseModel):
session_id: Optional[str] = None
@app.post("/api/terraform/generate-prompt")
async def tf_generate_prompt(req: TfPromptReq, u=Depends(current_user)):
async def tf_generate_prompt(req: TfPromptReq, bg: BackgroundTasks, u=Depends(current_user)):
gc = None
if req.genai_config:
if req.genai_config.get("genai_config_id"):
@@ -4911,16 +4999,23 @@ async def tf_generate_prompt(req: TfPromptReq, u=Depends(current_user)):
if req.history:
hist = [{"role": "user" if m.get("role","").upper() in ("USER","user") else "assistant",
"content": m.get("content", "")} for m in req.history]
try:
answer, _, _ = _call_genai(gc, req.message, history=hist)
# Save assistant response
with db() as c:
c.execute("INSERT INTO chat_messages (id,session_id,user_id,role,content,model_id,status) VALUES (?,?,?,?,?,?,?)",
(str(uuid.uuid4()), sid, u["id"], "assistant", answer, gc.get("model_id"), "done"))
return {"prompt": answer, "session_id": sid}
except Exception as e:
log.error(f"tf_generate_prompt error: {e}")
raise HTTPException(500, str(e)[:500])
# Async background processing — same pattern as Chat/Terraform agents
mid = str(uuid.uuid4())
with db() as c:
c.execute("INSERT INTO chat_messages (id,session_id,user_id,role,content,model_id,status) VALUES (?,?,?,?,?,?,?)",
(mid, sid, u["id"], "assistant", "", gc.get("model_id"), "processing"))
def _tfp_background():
try:
answer, _, _ = _call_genai(gc, req.message, history=hist)
with db() as c:
c.execute("UPDATE chat_messages SET content=?, status='done' WHERE id=?", (answer, mid))
log.info(f"TF Prompt Generator completed: mid={mid} len={len(answer)}")
except Exception as e:
log.error(f"tf_generate_prompt error: {e}")
with db() as c:
c.execute("UPDATE chat_messages SET content=?, status='failed' WHERE id=?", (str(e)[:500], mid))
bg.add_task(_tfp_background)
return {"message_id": mid, "session_id": sid, "status": "processing"}
@app.post("/api/terraform/chat")
async def terraform_chat(msg: ChatMsg, bg: BackgroundTasks, u=Depends(current_user)):
@@ -4953,7 +5048,12 @@ async def tf_create_workspace(req: TfWorkspaceReq, u=Depends(current_user)):
async def tf_list_workspaces(u=Depends(current_user)):
with db() as c:
rows = c.execute(
"SELECT id,name,session_id,oci_config_id,status,created_at,updated_at FROM terraform_workspaces WHERE user_id=? ORDER BY created_at DESC",
"""SELECT tw.id,tw.name,tw.session_id,tw.oci_config_id,tw.compartment_id,tw.status,
tw.plan_output,tw.apply_output,tw.destroy_output,tw.error,tw.created_at,tw.updated_at,
cs.title as session_title
FROM terraform_workspaces tw
INNER JOIN chat_sessions cs ON cs.id = tw.session_id AND cs.user_id = tw.user_id
WHERE tw.user_id=? ORDER BY tw.updated_at DESC""",
(u["id"],)).fetchall()
return [dict(r) for r in rows]
@@ -5253,7 +5353,7 @@ def _write_tf_files(wdir: Path, tf_code: str):
# Remove monolithic files that are fully duplicated
for fname in dupes_in:
if all(
any(f2 != fname and f2 in [fl for fl in resource_locations.get(key, []) if fl != fname])
any(f2 != fname for f2 in resource_locations.get(key, []))
for key, flist in resource_locations.items()
if fname in flist
) and len(files) - 1 >= 2:
@@ -5881,7 +5981,7 @@ async def startup():
except Exception as e:
log.warning(f"Could not populate tf_valid_types: {e}")
log.info(f"OCI CIS AI Agent v{VERSION} API started")
log.info(f"AI Agent v{VERSION} API started")
if __name__ == "__main__":
import uvicorn

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>OCI CIS AI Agent</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">
<style>
:root,html.light {
@@ -374,6 +374,8 @@ html .spinner,html [class*="kpi-bar-fill"],html canvas{transition:none!important
.exp-cats{flex-wrap:wrap}
.exp-cat{padding:.3rem .5rem;font-size:.6rem}
}
.retry-btn{display:inline-flex;align-items:center;gap:.25rem;margin-top:.35rem;padding:.25rem .65rem;font-size:.64rem;font-family:inherit;border-radius:6px;border:1px solid var(--rd);background:transparent;color:var(--rd);cursor:pointer;transition:all .15s}
.retry-btn:hover{background:var(--rd);color:#fff}
/* ── Terraform Agent ── */
.tf-wrap{display:flex;flex-direction:row;height:calc(100vh - 56px - 3rem);overflow:hidden}
.tf-left{flex:6;display:flex;flex-direction:column;overflow:hidden;min-width:0}
@@ -454,6 +456,33 @@ html .spinner,html [class*="kpi-bar-fill"],html canvas{transition:none!important
.tf-prompt-saved{font-size:.62rem;color:var(--t3);transition:opacity .3s}
.tf-prompt-name{background:var(--bg2);color:var(--t1);border:1px solid var(--bd);border-radius:4px;padding:.25rem .4rem;font-size:.72rem;width:100%;margin-bottom:.5rem;box-sizing:border-box}
.tf-modal h3{margin:0 0 .5rem;color:var(--rd)}
/* ── Terraform Workspaces ── */
.tws-page{padding:1rem;height:calc(100vh - 56px - 3rem);overflow-y:auto}
.tws-hdr{display:flex;align-items:center;gap:.7rem;margin-bottom:1rem}
.tws-hdr h3{margin:0;font-size:.88rem;color:var(--t1)}
.tws-filter{display:flex;gap:.3rem}
.tws-filter-btn{padding:.2rem .55rem;font-size:.64rem;border-radius:12px;border:1px solid var(--bd);background:var(--bg);color:var(--t3);cursor:pointer;font-family:inherit;transition:all .15s}
.tws-filter-btn.on{background:var(--pp);color:#fff;border-color:var(--pp)}
.tws-group{margin-bottom:.7rem}
.tws-group-hdr{display:flex;align-items:center;gap:.5rem;padding:.4rem .6rem;background:var(--bg2);border:1px solid var(--bd);border-radius:8px;cursor:pointer;user-select:none;transition:background .12s}
.tws-group-hdr:hover{background:var(--bg3)}
.tws-group-arrow{width:14px;height:14px;transition:transform .2s;fill:var(--t3)}
.tws-group-hdr.open .tws-group-arrow{transform:rotate(90deg)}
.tws-group-date{font-size:.72rem;font-weight:700;color:var(--t1)}
.tws-group-cnt{font-size:.6rem;color:var(--t3);margin-left:auto}
.tws-group-body{padding:.3rem 0 0 0}
.tws-row{display:flex;align-items:center;gap:.6rem;padding:.5rem .7rem;border:1px solid var(--bd);border-radius:8px;margin-bottom:.35rem;background:var(--bg);transition:border-color .15s}
.tws-row:hover{border-color:var(--pp)}
.tws-row-info{flex:1;min-width:0;display:flex;flex-direction:column;gap:.15rem}
.tws-row-title{font-size:.74rem;font-weight:600;color:var(--t1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tws-row-sub{font-size:.6rem;color:var(--t3);display:flex;gap:.6rem;flex-wrap:wrap}
.tws-row-sub span{display:inline-flex;align-items:center;gap:.15rem}
.tws-row-acts{display:flex;gap:.3rem;align-items:center;flex-shrink:0}
.tws-row-acts .btn{font-size:.6rem;padding:2px 8px}
.tws-row-detail{margin:.3rem 0 .4rem .7rem;background:var(--bg2);border:1px solid var(--bd);border-radius:6px;padding:.5rem;max-height:200px;overflow:auto}
.tws-row-detail pre{font-size:.62rem;line-height:1.4;color:var(--t2);font-family:var(--fm);margin:0;white-space:pre-wrap;word-break:break-word}
.tws-empty{text-align:center;padding:3rem 1rem;color:var(--t3)}
.tws-empty p{margin:.3rem 0}
.tf-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:var(--t3);font-size:.76rem;gap:.3rem}
.tf-empty svg{width:40px;height:40px;fill:#7b42bc;opacity:.3}
@media(max-width:768px){
@@ -542,7 +571,9 @@ const S={user:null,token:null,tab:'chat',msgs:[],sid:null,reports:[],ociCfg:[],g
tfWs:null,tfWsList:[],tfPlanOut:'',tfApplyOut:'',tfDestroyOut:'',tfStatus:'draft',tfRunning:false,tfHadApply:false,tfConfirmDest:false,tfConfirmRollback:false,tfMdOpen:false,
tfComps:[],tfCompLoading:false,tfFiles:[],tfBtab:'files',tfEditIdx:-1,tfResources:null,tfResLoading:false,tfRefStatus:'',tfBottomH:null,
tfpMsgs:[],tfpLoading:false,tfpCopied:false,tfpModel:'',tfpMdOpen:false,tfpOci:'',tfpRegion:'',tfpCompartment:'',
tfpHistOpen:false,tfpHistory:[],tfpSid:null};
tfpHistOpen:false,tfpHistory:[],tfpSid:null,
twsList:[],twsLoading:false,twsExpanded:null,twsFilter:'all',twsConfirmDel:null,twsConfirmDest:null,twsClosedGroups:null,
ociFormOpen:false,ociAuthType:'api_key'};
const API='/api';
async function $api(p,o={}){const h={...(o.headers||{})};if(S.token)h['Authorization']='Bearer '+S.token;
@@ -603,13 +634,13 @@ function R(){try{
}catch(e){console.error('Render error:',e);document.getElementById('app').innerHTML='<div style="padding:2rem;color:red"><h3>Render Error</h3><pre>'+e.message+'</pre></div>'}}
function switchTab(t){S.tab=t;R();
const pg=document.getElementById('pg');if(pg){const pc=pg.querySelector('.pc');if(pc){pc.classList.add('tab-enter');setTimeout(()=>pc.classList.remove('tab-enter'),500)}}
if(t==='chat')loadHistory('chat');if(t==='terraform')loadHistory('terraform');if(t==='audit'&&S.user.role==='admin')loadAudit();if(t==='downloads')refreshDl();if(t==='explorer'&&S.ociCfg.length&&!S.expTree.length){if(!S.expCfg)S.expCfg=S.ociCfg[0].id;expLoadTree();expLoadRegions()}
if(t==='chat')loadHistory('chat');if(t==='terraform')loadHistory('terraform');if(t==='tf-workspaces')tfWsLoadList();if(t==='audit'&&S.user.role==='admin')loadAudit();if(t==='downloads')refreshDl();if(t==='explorer'&&S.ociCfg.length&&!S.expTree.length){if(!S.expCfg)S.expCfg=S.ociCfg[0].id;expLoadTree();expLoadRegions()}
const tm={'oci-config':'oci','genai':'genai','adb':'adb','mcp':'mcp'};if(tm[t])setTimeout(()=>refreshCLogs(tm[t]),100)}
/* ── Login ── */
function rLogin(){return`<div class="lp"><div class="lc fi">
<div style="text-align:center;margin-bottom:1.5rem">${LOGO_R}
<h2>OCI CIS Agent</h2><div class="st">Oracle Cloud Infrastructure · Security</div></div>
<h2>AI Agent</h2><div class="st">Infrastructure & Security Engineer</div></div>
<div id="le"></div>
<div id="mfa-s" style="display:none"><div class="al al-i">Código MFA obrigatório</div>
<div class="ig"><label>Código TOTP</label><input type="text" id="mfa-c" placeholder="000000" maxlength="6" style="text-align:center;font-size:1.1rem;letter-spacing:.3em;font-weight:600"></div>
@@ -624,12 +655,12 @@ function rApp(){return`<div class="app">${rSb()}<div class="mc">${rTb()}<div cla
const TF_IC='<svg viewBox="0 0 16 16" width="14" height="14" style="vertical-align:-2px;margin-right:2px"><path d="M5.6 1v4.2l3.6 2.1V3.1L5.6 1zm4.4 2.1v4.2l3.6-2.1V1L10 3.1zM1.4 3.5v4.2l3.6 2.1V5.6L1.4 3.5zM5.6 8.1v4.2L9.2 14.4V10.2L5.6 8.1z" fill="#7b42bc"/></svg>';
function rSb(){
const tabs=[['chat',IC.chat,'Chat Agent'],['terraform',TF_IC,'Terraform'],['tf-prompt',IC.edit,'Prompt Generator','sub'],['explorer',IC.search,'OCI Explorer'],['report',IC.chart,'Reports'],['downloads',IC.folder,'Downloads']];
const tabs=[['chat',IC.chat,'Chat Agent'],['terraform',TF_IC,'Terraform'],['tf-prompt',IC.edit,'Prompt Generator','sub'],['tf-workspaces',IC.folder,'Workspaces','sub'],['explorer',IC.search,'OCI Explorer'],['report',IC.chart,'Reports'],['downloads',IC.folder,'Downloads']];
const ctabs=[['oci-config',IC.cloud,'Credenciais OCI'],['genai',IC.brain,'GenAI Config'],['mcp',IC.plug,'MCP Servers'],['adb',IC.db,'ADB Vector'],['embeddings',IC.dna,'Embeddings'],['emb-consult',IC.search,'Consultar Embeddings','sub']];
const atabs=[['users',IC.users,'Usuários'],['mfa',IC.lock,'MFA'],['audit',IC.log,'Audit Log']];
const i=(S.user?.first_name||S.user?.username||'?')[0].toUpperCase();
return`<div class="sb">
<div class="sb-h"><h1>${LOGO_W} OCI CIS Agent</h1><div class="st">Infrastructure & Security · v${V}</div></div>
<div class="sb-h"><h1>${LOGO_W} AI Agent</h1><div class="st">Infrastructure & Security Engineer · v${V}</div></div>
<div class="nav"><div class="nl">Principal</div>
${tabs.map(t=>`<div class="ni ${S.tab===t[0]?'on':''}" onclick="switchTab('${t[0]}')" ${t[3]==='sub'?'style="padding-left:2.2rem;font-size:.78rem;opacity:.9"':''}><span class="ic">${t[1]}</span>${t[2]}</div>`).join('')}
<div class="nl">Configuração</div>
@@ -640,15 +671,15 @@ ${atabs.map(t=>`<div class="ni ${S.tab===t[0]?'on':''}" onclick="switchTab('${t[
<div class="lo-btn" onclick="toggleTheme()" title="Alternar tema">${document.documentElement.classList.contains('dark')?IC.sun:IC.moon}</div>
<div class="lo-btn" onclick="doLogout()" title="Sair">⏻</div></div></div></div>`}
function rTb(){const t={'chat':IC.chat+' AI Agent Chat','terraform':TF_IC+' Terraform Agent','tf-prompt':IC.edit+' Prompt Generator','explorer':IC.search+' OCI Account Explorer','report':IC.chart+' Compliance Reports','downloads':IC.folder+' Downloads','oci-config':IC.cloud+' Credenciais OCI','genai':IC.brain+' OCI Generative AI','mcp':IC.plug+' MCP Servers','adb':IC.db+' Autonomous DB Vector','embeddings':IC.dna+' Embeddings','emb-consult':IC.search+' Consultar Embeddings','users':IC.users+' Gerenciar Usuários','mfa':IC.lock+' Autenticação MFA','audit':IC.log+' Audit Log'};
function rTb(){const t={'chat':IC.chat+' AI Agent Chat','terraform':TF_IC+' Terraform Agent','tf-prompt':IC.edit+' Prompt Generator','tf-workspaces':IC.folder+' Terraform Workspaces','explorer':IC.search+' OCI Account Explorer','report':IC.chart+' Compliance Reports','downloads':IC.folder+' Downloads','oci-config':IC.cloud+' Credenciais OCI','genai':IC.brain+' OCI Generative AI','mcp':IC.plug+' MCP Servers','adb':IC.db+' Autonomous DB Vector','embeddings':IC.dna+' Embeddings','emb-consult':IC.search+' Consultar Embeddings','users':IC.users+' Gerenciar Usuários','mfa':IC.lock+' Autenticação MFA','audit':IC.log+' Audit Log'};
return`<div class="tb"><div class="tb-t">${t[S.tab]||''}</div><div class="tb-a"><span class="tag">v${V}</span></div></div>`}
function rPg(){switch(S.tab){case'chat':return rChat();case'terraform':return rTerraform();case'tf-prompt':return rTfPrompt();case'explorer':return rExplorer();case'report':return rReport();case'downloads':return rDl();case'oci-config':return rOci();case'genai':return rGenAI();case'mcp':return rMCP();case'adb':return rADB();case'embeddings':return rEmbeddings();case'emb-consult':return rEmbConsultPage();case'users':return rUsers();case'mfa':return rMfa();case'audit':return rAudit();default:return''}}
function rPg(){switch(S.tab){case'chat':return rChat();case'terraform':return rTerraform();case'tf-prompt':return rTfPrompt();case'tf-workspaces':return rTfWorkspaces();case'explorer':return rExplorer();case'report':return rReport();case'downloads':return rDl();case'oci-config':return rOci();case'genai':return rGenAI();case'mcp':return rMCP();case'adb':return rADB();case'embeddings':return rEmbeddings();case'emb-consult':return rEmbConsultPage();case'users':return rUsers();case'mfa':return rMfa();case'audit':return rAudit();default:return''}}
/* ── Chat ── */
function rChat(){
const ms=S.msgs.length===0?`<div class="emp"><div class="eic">${IC.bot}</div><p>Inicie uma conversa com o agente.</p><p style="font-size:.74rem;margin-top:.35rem">Selecione um modelo para começar.</p></div>`
:S.msgs.map(m=>`<div class="cm cm-${m.r}"><div class="cb">${fm(m.c)}</div>${m.t?`<div class="cm-ts">${m.t}</div>`:''}</div>`).join('');
:S.msgs.map((m,i)=>`<div class="cm cm-${m.r}"><div class="cb">${fm(m.c)}</div>${m._failed?`<button class="retry-btn" onclick="chatRetry(${i})">↻ Reenviar</button>`:''}${m.t?`<div class="cm-ts">${m.t}</div>`:''}</div>`).join('');
const provs={};for(const[mid,info]of Object.entries(S.models)){const p=info.provider||'other';if(!provs[p])provs[p]=[];provs[p].push({id:mid,name:info.name})}
const provOrder=['openai','google','meta','xai'];
let ddItems='';
@@ -802,19 +833,38 @@ async function sChat(){const el=document.getElementById('chi');const m=el.value.
let resp=d.response;
if(d.tools_used&&d.tools_used.length){resp+='\n\n'+IC.wrench+' **Tools utilizadas:** '+d.tools_used.map(t=>t.name).join(', ')}
S.msgs.push({r:'assistant',c:resp,t:tstamp()});R();scCh()}}
catch(e){S.msgs=S.msgs.filter(x=>!x.thinking);S.msgs.push({r:'assistant',c:IC.err+' Erro: '+e.message,t:tstamp()});R()}
catch(e){S.msgs=S.msgs.filter(x=>!x.thinking);S.msgs.push({r:'assistant',c:IC.err+' Erro: '+e.message,t:tstamp(),_failed:true});R()}
finally{el.disabled=false;el.focus();btns.forEach(b=>{b.disabled=false});if(sendBtn)sendBtn.textContent=sendBtn.dataset.origText||'Enviar →'}}
async function pollChatResult(mid){
for(let i=0;i<1200;i++){
await new Promise(r=>setTimeout(r,i<10?1000:3000));
try{const r=await $api('/chat/'+mid+'/status');
if(r.status==='done'){S.msgs=S.msgs.filter(x=>!x.thinking);S.msgs.push({r:'assistant',c:r.content,t:tstamp()});R();scCh();return}
if(r.status==='failed'){S.msgs=S.msgs.filter(x=>!x.thinking);S.msgs.push({r:'assistant',c:IC.err+' '+r.content,t:tstamp()});R();scCh();return}
if(r.status==='failed'){S.msgs=S.msgs.filter(x=>!x.thinking);S.msgs.push({r:'assistant',c:IC.err+' '+r.content,t:tstamp(),_failed:true});R();scCh();return}
}catch(e){}}
S.msgs=S.msgs.filter(x=>!x.thinking);S.msgs.push({r:'assistant',c:IC.timeout+' Timeout: a resposta está demorando muito. Tente novamente.',t:tstamp()});R()}
S.msgs=S.msgs.filter(x=>!x.thinking);S.msgs.push({r:'assistant',c:IC.timeout+' Timeout: a resposta está demorando muito. Tente novamente.',t:tstamp(),_failed:true});R()}
function scCh(){setTimeout(()=>{const e=document.getElementById('chm');if(e)e.scrollTop=e.scrollHeight},50)}
async function clrChat(){if(S.sid)try{await $api('/chat/'+S.sid,{method:'DELETE'})}catch(e){}S.msgs=[];S.sid=null;R()}
function newChat(){S.msgs=[];S.sid=null;S.chatFiles=[];R()}
function chatRetry(idx){
// Find the last user message before this failed assistant message
let userMsg='';
for(let i=idx-1;i>=0;i--){if(S.msgs[i].r==='user'){userMsg=S.msgs[i].c;break}}
if(!userMsg)return;
// Remove the failed message
S.msgs.splice(idx,1);R();
// Re-send
const el=document.getElementById('chi');if(el)el.value=userMsg;
sChat();
}
function tfRetry(idx){
let userMsg='';
for(let i=idx-1;i>=0;i--){if(S.tfMsgs[i].r==='user'){userMsg=S.tfMsgs[i].c;break}}
if(!userMsg)return;
S.tfMsgs.splice(idx,1);R();
const el=document.getElementById('tfi');if(el)el.value=userMsg;
tfSend();
}
function newTfChat(){S.tfMsgs=[];S.tfSid=null;S.tfPlan=[];S.tfCode='';S.tfFiles=[];S.tfWs=null;S.tfPlanOut='';S.tfApplyOut='';S.tfDestroyOut='';S.tfStatus='draft';S.tfRunning=false;S.tfHadApply=false;S.tfConfirmDest=false;S.tfConfirmRollback=false;S.tfEditIdx=-1;R()}
function tfToggleMenu(){
@@ -1207,7 +1257,7 @@ function rTfPrompt(){
<div style="display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.75rem;justify-content:center">${TFP_EXAMPLES.map(e=>`<button class="btn bs bsm" onclick="document.getElementById('tfpi').value='${e.replace(/'/g,"\\'")}';document.getElementById('tfpi').focus()" style="font-size:.66rem;white-space:normal;text-align:left;max-width:280px;line-height:1.35">${e}</button>`).join('')}</div></div>`
:msgs.map((m,i)=>`<div class="cm cm-${m.r}"><div class="cb">${fm(m.c)}${m._raw?`
<div class="tfp-acts"><button class="tfp-act" onclick="tfpCopy(${i})">${m._copied?IC.ok+' Copiado':IC.clip+' Copiar'}</button>
<button class="tfp-act tfp-act-tf" onclick="tfpSendToTf(${i})">${TF_IC} Terraform</button></div>`:''}</div></div>`).join('');
<button class="tfp-act tfp-act-tf" onclick="tfpSendToTf(${i})">${TF_IC} Terraform</button></div>`:''}</div>${m._failed?`<button class="retry-btn" onclick="tfpRetry(${i})">↻ Reenviar</button>`:''}</div>`).join('');
// Model dropdown — only recommended models, grouped by provider
const tfpAllowed=['openai.gpt-4.1','openai.o3','openai.o4-mini','openai.gpt-5.1','openai.gpt-5.2','google.gemini-2.5-pro','google.gemini-2.5-flash'];
const tfpProvs={};tfpAllowed.forEach(mid=>{const mi=S.models[mid];if(mi){const p=mi.provider||'other';if(!tfpProvs[p])tfpProvs[p]=[];tfpProvs[p].push({id:mid,name:mi.name})}});
@@ -1257,10 +1307,26 @@ async function tfpSend(){
if(S.tfpSid)body.session_id=S.tfpSid;
const d=await $api('/terraform/generate-prompt',{method:'POST',body});
if(d.session_id)S.tfpSid=d.session_id;
S.tfpMsgs.push({r:'assistant',c:d.prompt,_raw:d.prompt});
}catch(e){S.tfpMsgs.push({r:'assistant',c:IC.err+' Erro: '+e.message})}
S.tfpLoading=false;R();
const mc2=document.getElementById('tfpchm');if(mc2)mc2.scrollTop=mc2.scrollHeight}
if(d.status==='processing'&&d.message_id){await tfpPollResult(d.message_id)}
else if(d.prompt){S.tfpMsgs.push({r:'assistant',c:d.prompt,_raw:d.prompt});S.tfpLoading=false;R()}
}catch(e){S.tfpMsgs.push({r:'assistant',c:IC.err+' Erro: '+e.message,_failed:true});S.tfpLoading=false;R()}}
async function tfpPollResult(mid){
for(let i=0;i<600;i++){
await new Promise(r=>setTimeout(r,i<10?1000:3000));
try{const r=await $api('/chat/'+mid+'/status');
if(r.status==='done'){S.tfpMsgs.push({r:'assistant',c:r.content,_raw:r.content});S.tfpLoading=false;R();
const mc=document.getElementById('tfpchm');if(mc)mc.scrollTop=mc.scrollHeight;return}
if(r.status==='failed'){S.tfpMsgs.push({r:'assistant',c:IC.err+' '+r.content,_failed:true});S.tfpLoading=false;R();return}
}catch(e){}}
S.tfpMsgs.push({r:'assistant',c:IC.timeout+' Timeout: a geração está demorando muito.',_failed:true});S.tfpLoading=false;R()}
function tfpRetry(idx){
let userMsg='';
for(let i=idx-1;i>=0;i--){if(S.tfpMsgs[i].r==='user'){userMsg=S.tfpMsgs[i].c;break}}
if(!userMsg)return;
S.tfpMsgs.splice(idx,1);R();
const el=document.getElementById('tfpi');if(el)el.value=userMsg;
tfpSend();
}
function tfpPickModel(v){S.tfpModel=v;S.tfpMdOpen=false;
if(v&&!v.startsWith('cfg:')&&!S.tfpOci&&S.ociCfg.length){S.tfpOci=S.ociCfg[0].id;S.tfpRegion=S.ociCfg[0].region;S.tfpCompartment=S.ociCfg[0].compartment_id||''}
else if(v&&v.startsWith('cfg:')){const g=S.genaiCfg.find(x=>x.id===v.slice(4));if(g&&g.oci_config_id&&!S.tfpOci){S.tfpOci=g.oci_config_id;const c=S.ociCfg.find(x=>x.id===g.oci_config_id);if(c){S.tfpRegion=c.region;S.tfpCompartment=c.compartment_id||''}}}
@@ -1292,7 +1358,7 @@ function tfpSendToTf(idx){
function rTerraform(){
const ms=S.tfMsgs.length===0
?`<div class="tf-empty">${TF_ICON}<p>Descreva a infraestrutura OCI desejada</p><p style="font-size:.68rem;opacity:.6">Ex: "Crie uma VCN com subnets pública e privada, internet gateway e um compute instance"</p></div>`
:S.tfMsgs.map(m=>`<div class="cm cm-${m.r}"><div class="cb">${m.r==='assistant'?fmTf(m.c):fm(m.c)}</div>${m.t?`<div class="cm-ts">${m.t}</div>`:''}</div>`).join('');
:S.tfMsgs.map((m,i)=>`<div class="cm cm-${m.r}"><div class="cb">${m.r==='assistant'?fmTf(m.c):fm(m.c)}</div>${m._failed?`<button class="retry-btn" onclick="tfRetry(${i})">↻ Reenviar</button>`:''}${m.t?`<div class="cm-ts">${m.t}</div>`:''}</div>`).join('');
// Model dropdown — same filtered models as Prompt Generator, grouped by provider
const tfAllowed=['openai.gpt-4.1','openai.o3','openai.o4-mini','openai.gpt-5.1','openai.gpt-5.2','google.gemini-2.5-pro','google.gemini-2.5-flash'];
@@ -1405,7 +1471,7 @@ function rTfActions(){
const canReplan=S.tfCode&&['planned','failed','applied'].includes(st);
if(canPlan&&!canReplan)btns+=`<button class="btn bsm" style="background:var(--pp);color:#fff;border-color:var(--pp);font-size:.72rem;padding:4px 12px;gap:4px;display:inline-flex;align-items:center" onclick="tfSaveAndPlan()">${TF_ICON} Plan</button>`;
if(canReplan)btns+=`<button class="btn bsm" style="background:var(--pp);color:#fff;border-color:var(--pp);font-size:.72rem;padding:4px 12px;gap:4px;display:inline-flex;align-items:center" onclick="tfSaveAndPlan()">${TF_ICON} Re-plan</button>`;
if(st==='failed'&&S.tfPlanOut&&S.tfFiles.length&&S.tfModel)btns+=`<button class="btn bsm" style="background:#e67e22;color:#fff;border-color:#e67e22;font-size:.72rem;padding:4px 12px;gap:4px;display:inline-flex;align-items:center" onclick="tfAutoFix()">${IC.fix} Auto-corrigir</button>`;
if(st==='failed'&&S.tfPlanOut&&S.tfFiles.length)btns+=`<button class="btn bsm" style="background:#e67e22;color:#fff;border-color:#e67e22;font-size:.72rem;padding:4px 12px;gap:4px;display:inline-flex;align-items:center" onclick="tfAutoFix()">${IC.fix} Auto-corrigir</button>`;
if(st==='failed'&&(S.tfApplyOut||S.tfHadApply))btns+='<button class="btn bsm" style="background:var(--rd);color:#fff;border-color:var(--rd);font-size:.72rem;padding:4px 12px;gap:4px;display:inline-flex;align-items:center" onclick="S.tfConfirmRollback=true;R()">↩ Rollback</button>';
if(st==='planned')btns+='<button class="btn bsm" style="background:var(--gn);color:#fff;border-color:var(--gn);font-size:.72rem;padding:4px 12px" onclick="tfApply()">✓ Apply</button>';
if(st==='applied'||st==='planned')btns+='<button class="btn bsm" style="background:var(--rd);color:#fff;border-color:var(--rd);font-size:.72rem;padding:4px 12px" onclick="S.tfConfirmDest=true;R()">✕ Destroy</button>';
@@ -1699,7 +1765,7 @@ async function tfSend(){
if(S.tfHistOpen)loadHistory('terraform');
if(d.status==='processing'&&d.message_id){await tfPollResult(d.message_id)}
else{S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:d.response||d.content||'Sem resposta',t:tstamp()});R();tfScroll()}
}catch(e){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:IC.err+' Erro: '+e.message,t:tstamp()});R()}
}catch(e){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:IC.err+' Erro: '+e.message,t:tstamp(),_failed:true});R()}
}
async function tfPollResult(mid){
@@ -1708,13 +1774,17 @@ async function tfPollResult(mid){
try{const r=await $api('/chat/'+mid+'/status');
if(r.status==='done'){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:r.content,t:tstamp()});R();tfScroll();
if(r.content&&r.content.includes('```plan')){S.tfPanel='plan';R()}return}
if(r.status==='failed'){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:IC.err+' '+r.content,t:tstamp()});R();tfScroll();return}
if(r.status==='failed'){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:IC.err+' '+r.content,t:tstamp(),_failed:true});R();tfScroll();return}
}catch(e){}
}
S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:IC.timeout+' Timeout.',t:tstamp()});R()
S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:IC.timeout+' Timeout.',t:tstamp(),_failed:true});R()
}
async function tfSaveAndPlan(){
// Build tfCode from files if empty but files exist
if(!S.tfCode&&S.tfFiles.length){
S.tfCode=S.tfFiles.map(f=>'// filename: '+f.name+'\n'+f.content).join('\n\n');
}
if(!S.tfCode){alert('Gere código Terraform primeiro.');return}
// Resolve OCI config
let ociId=S.tfOci;
@@ -1733,7 +1803,7 @@ async function tfSaveAndPlan(){
await $api('/terraform/workspaces/'+S.tfWs+'/plan',{method:'POST'});
S.tfStatus='planning';S.tfRunning=true;S.tfPlanOut='';S.tfApplyOut='';S.tfDestroyOut='';S.tfBtab='plan';R();
await tfPollExec();
}catch(e){alert('Erro: '+e.message)}
}catch(e){alert('Erro ao executar plan: '+e.message)}
}
async function tfApply(){
@@ -1764,7 +1834,8 @@ async function tfCancel(){
}
async function tfAutoFix(){
if(!S.tfPlanOut||!S.tfFiles.length||!S.tfModel)return;
if(!S.tfPlanOut||!S.tfFiles.length)return;
if(!S.tfModel){S.tfMsgs.push({r:'assistant',c:IC.warn+' Selecione um modelo antes de usar Auto-corrigir.',t:tstamp()});R();return}
// Extract errors from plan output
const errLines=S.tfPlanOut.split('\n').filter(l=>/^Error:|^\s+on\s+\S+\.tf\s+line|^\s+\d+:|has not been declared|is not expected|Missing required|Unsupported|Invalid|Duplicate|undeclared/i.test(l));
const errCtx=errLines.length?errLines.join('\n'):S.tfPlanOut.slice(-2000);
@@ -1820,6 +1891,219 @@ async function tfPollExec(){
}
/* ── Terraform Workspaces ── */
async function tfWsLoadList(){
S.twsLoading=true;R();
try{S.twsList=await $api('/terraform/workspaces')}catch(e){S.twsList=[]}
S.twsLoading=false;R();
}
function rTfWorkspaces(){
const f=S.twsFilter;
let items=S.twsList||[];
if(f==='active')items=items.filter(w=>['applied','planned','planning','applying'].includes(w.status));
else if(f==='destroyed')items=items.filter(w=>w.status==='destroyed');
else if(f==='failed')items=items.filter(w=>w.status==='failed');
else if(f==='draft')items=items.filter(w=>w.status==='draft');
const counts={all:(S.twsList||[]).length,active:(S.twsList||[]).filter(w=>['applied','planned','planning','applying'].includes(w.status)).length,destroyed:(S.twsList||[]).filter(w=>w.status==='destroyed').length,failed:(S.twsList||[]).filter(w=>w.status==='failed').length,draft:(S.twsList||[]).filter(w=>w.status==='draft').length};
const fb=(k,l)=>`<button class="tws-filter-btn${f===k?' on':''}" onclick="S.twsFilter='${k}';R()">${l} (${counts[k]})</button>`;
// Group by date
const groups={};
for(const w of items){
const dt=(w.updated_at||w.created_at||'').slice(0,10);
const label=_twsDateLabel(dt);
if(!groups[label])groups[label]={date:dt,items:[]};
groups[label].items.push(w);
}
const groupKeys=Object.keys(groups);
let body='';
if(groupKeys.length){
body=groupKeys.map(label=>{
const g=groups[label];
const open=S.twsClosedGroups?!S.twsClosedGroups.has(label):true;
return`<div class="tws-group">
<div class="tws-group-hdr${open?' open':''}" onclick="twsToggleGroup('${label.replace(/'/g,"\\'")}')">
<svg class="tws-group-arrow" viewBox="0 0 16 16"><path d="M6 3l5 5-5 5z"/></svg>
<span class="tws-group-date">${label}</span>
<span class="tws-group-cnt">${g.items.length} workspace${g.items.length>1?'s':''}</span>
</div>
${open?`<div class="tws-group-body">${g.items.map(w=>_rTwsRow(w)).join('')}</div>`:''}
</div>`;
}).join('');
}else{
body=S.twsLoading?'<div class="tws-empty"><p>Carregando workspaces...</p></div>'
:'<div class="tws-empty"><div style="font-size:2rem;margin-bottom:.5rem">${TF_IC}</div><p>Nenhum workspace encontrado</p><p style="font-size:.7rem">Crie workspaces pelo Terraform Agent</p></div>';
}
return`<div class="tws-page">
<div class="tws-hdr">
<h3>${TF_IC} Terraform Workspaces</h3>
<div style="flex:1"></div>
<div class="tws-filter">${fb('all','Todos')}${fb('active','Ativos')}${fb('draft','Draft')}${fb('failed','Falha')}${fb('destroyed','Destruídos')}</div>
<button class="btn bs bsm" onclick="tfWsLoadList()" style="font-size:.66rem;padding:3px 10px">${S.twsLoading?'Carregando...':'Atualizar'}</button>
</div>
${body}
${S.twsConfirmDest?_rTwsConfirmDestroy():''}
${S.twsConfirmDel?_rTwsConfirmDelete():''}
</div>`;
}
function _twsDateLabel(dt){
if(!dt)return'Sem data';
const today=new Date().toISOString().slice(0,10);
const yesterday=new Date(Date.now()-86400000).toISOString().slice(0,10);
if(dt===today)return'Hoje';
if(dt===yesterday)return'Ontem';
const d=new Date(dt+'T00:00:00');
const diff=Math.floor((new Date()-d)/86400000);
if(diff<7)return d.toLocaleDateString('pt-BR',{weekday:'long'});
return d.toLocaleDateString('pt-BR',{day:'2-digit',month:'short',year:'numeric'});
}
function twsToggleGroup(label){
if(!S.twsClosedGroups)S.twsClosedGroups=new Set();
if(S.twsClosedGroups.has(label))S.twsClosedGroups.delete(label);else S.twsClosedGroups.add(label);
R();
}
function _rTwsRow(w){
const badge=_tfBadge(w.status);
const oc=S.ociCfg.find(x=>x.id===w.oci_config_id);
const ocName=oc?(oc.name||oc.region||w.oci_config_id):'—';
const exp=S.twsExpanded===w.id;
const ago=_twsTimeAgo(w.updated_at||w.created_at);
const hasOutput=w.plan_output||w.apply_output||w.destroy_output||w.error;
const title=w.session_title||w.name||'workspace';
let acts='';
const running=['planning','applying','destroying'].includes(w.status);
if(running){acts+=`<button class="btn bsm" style="background:var(--rd);color:#fff;border-color:var(--rd)" onclick="event.stopPropagation();twsCancel('${w.id}')">Cancelar</button>`}
else{
acts+=`<button class="btn bs bsm" onclick="event.stopPropagation();twsOpen('${w.id}')" title="Abrir no Terraform Agent">Abrir</button>`;
if(['draft','failed','destroyed','planned','applied'].includes(w.status))acts+=`<button class="btn bsm" style="background:var(--pp);color:#fff;border-color:var(--pp)" onclick="event.stopPropagation();twsPlan('${w.id}')">Plan</button>`;
if(w.status==='planned')acts+=`<button class="btn bsm" style="background:var(--gn);color:#fff;border-color:var(--gn)" onclick="event.stopPropagation();twsApply('${w.id}')">Apply</button>`;
if(w.status==='applied'||w.status==='planned')acts+=`<button class="btn bsm" style="background:var(--rd);color:#fff;border-color:var(--rd)" onclick="event.stopPropagation();S.twsConfirmDest='${w.id}';R()">Destroy</button>`;
acts+=`<button class="btn bsm" style="color:var(--rd);border-color:var(--rd);background:transparent" onclick="event.stopPropagation();S.twsConfirmDel='${w.id}';R()" title="Excluir">✕</button>`;
}
let detail='';
if(exp&&hasOutput){
const out=w.error?'ERROR: '+w.error:(w.destroy_output||w.apply_output||w.plan_output||'');
detail=`<div class="tws-row-detail"><pre>${escHtml(out)}</pre></div>`;
}
return`<div>
<div class="tws-row" onclick="${hasOutput&&!running?`S.twsExpanded=S.twsExpanded==='${w.id}'?null:'${w.id}';R()`:''}">
${badge}
<div class="tws-row-info">
<div class="tws-row-title" title="${escHtml(title)}">${escHtml(title)}</div>
<div class="tws-row-sub">
<span>${IC.cloud} ${escHtml(ocName)}</span>
${w.compartment_id?`<span title="${w.compartment_id}">...${w.compartment_id.slice(-8)}</span>`:''}
<span>${ago}</span>
${hasOutput&&!running?`<span style="color:var(--pp);cursor:pointer">${exp?'▾ ocultar':'▸ ver output'}</span>`:''}
</div>
</div>
<div class="tws-row-acts">${acts}</div>
</div>
${detail}
</div>`;
}
function _twsTimeAgo(ts){
if(!ts)return'—';
const d=new Date(ts+'Z');const now=new Date();const s=Math.floor((now-d)/1000);
if(s<60)return'agora';if(s<3600)return Math.floor(s/60)+'min';if(s<86400)return Math.floor(s/3600)+'h';return Math.floor(s/86400)+'d';
}
function _rTwsConfirmDestroy(){
return`<div class="tf-modal-overlay" onclick="S.twsConfirmDest=null;R()"><div class="tf-modal" onclick="event.stopPropagation()">
<h3>Confirmar Destroy</h3>
<p style="font-size:.76rem;color:var(--t2);margin:.5rem 0">Esta ação irá destruir todos os recursos provisionados por este workspace. Digite <b>DESTROY</b> para confirmar:</p>
<input type="text" id="twsDestInput" placeholder="DESTROY" style="width:100%;padding:.4rem;font-size:.8rem;text-align:center;border:1px solid var(--rd);border-radius:6px;background:var(--bg2);color:var(--t1);box-sizing:border-box">
<div style="display:flex;gap:.5rem;justify-content:flex-end;margin-top:.7rem">
<button class="btn bs" onclick="S.twsConfirmDest=null;R()">Cancelar</button>
<button class="btn" style="background:var(--rd);color:#fff;border-color:var(--rd)" onclick="twsDoDestroy()">Destroy</button>
</div>
</div></div>`;
}
function _rTwsConfirmDelete(){
return`<div class="tf-modal-overlay" onclick="S.twsConfirmDel=null;R()"><div class="tf-modal" onclick="event.stopPropagation()">
<h3>Excluir Workspace</h3>
<p style="font-size:.76rem;color:var(--t2);margin:.5rem 0">Deseja excluir este workspace permanentemente? Arquivos locais serão removidos.</p>
<div style="display:flex;gap:.5rem;justify-content:flex-end;margin-top:.7rem">
<button class="btn bs" onclick="S.twsConfirmDel=null;R()">Cancelar</button>
<button class="btn" style="background:var(--rd);color:#fff;border-color:var(--rd)" onclick="twsDoDelete()">Excluir</button>
</div>
</div></div>`;
}
async function twsOpen(wid){
const ws=S.twsList.find(w=>w.id===wid);if(!ws)return;
S.tab='terraform';
S.tfSid=ws.session_id;S.tfWs=wid;S.tfStatus=ws.status;
try{
const detail=await $api('/terraform/workspaces/'+wid);
if(detail.tf_code)S.tfCode=detail.tf_code;
if(detail.plan_output)S.tfPlanOut=detail.plan_output;
if(detail.apply_output)S.tfApplyOut=detail.apply_output;
if(detail.destroy_output)S.tfDestroyOut=detail.destroy_output;
S.tfOci=detail.oci_config_id||'';S.tfCompartment=detail.compartment_id||'';
if(detail.oci_config_id){
const oc=S.ociCfg.find(x=>x.id===detail.oci_config_id);
if(oc)S.tfRegion=oc.region;
}
if(detail.tf_code){
S.tfCode=detail.tf_code;
const parts=detail.tf_code.split(/^\/\/\s*filename:\s*/m).filter(Boolean);
if(parts.length>1){S.tfFiles=parts.map(p=>{const nl=p.indexOf('\n');const fn=p.substring(0,nl).trim();const code=p.substring(nl+1).trim();return{name:fn,content:code,size:code.length}})}
else{const split=_splitTfMonolith(detail.tf_code);S.tfFiles=Object.entries(split).map(([fn,code])=>({name:fn,content:code,size:code.length}))}
}
if(detail.status==='applied')S.tfHadApply=true;
if(S.tfPlanOut)S.tfBtab='plan';
else if(S.tfFiles.length)S.tfBtab='files';
if(S.tfOci){tfLoadComps();if(S.tfCompartment)tfLoadResources()}
}catch(e){}
try{const msgs=await $api('/chat/sessions/'+ws.session_id+'/messages');S.tfMsgs=msgs.map(m=>({r:m.role,c:m.content,t:m.created_at?new Date(m.created_at+'Z').toLocaleTimeString():''}))}catch(e){S.tfMsgs=[]}
R();
}
async function twsPlan(wid){
try{await $api('/terraform/workspaces/'+wid+'/plan',{method:'POST'})}catch(e){alert('Erro: '+e.message);return}
tfWsLoadList();_twsPollWs(wid);
}
async function twsApply(wid){
try{await $api('/terraform/workspaces/'+wid+'/apply',{method:'POST'})}catch(e){alert('Erro: '+e.message);return}
tfWsLoadList();_twsPollWs(wid);
}
async function twsDoDestroy(){
const wid=S.twsConfirmDest;if(!wid)return;
const v=document.getElementById('twsDestInput');if(!v||v.value!=='DESTROY'){v.style.borderColor='var(--rd)';return}
S.twsConfirmDest=null;
try{await $api('/terraform/workspaces/'+wid+'/destroy',{method:'POST',body:{confirm:'DESTROY'}})}catch(e){alert('Erro: '+e.message);R();return}
tfWsLoadList();_twsPollWs(wid);
}
async function twsDoDelete(){
const wid=S.twsConfirmDel;if(!wid)return;
S.twsConfirmDel=null;
try{await $api('/terraform/workspaces/'+wid,{method:'DELETE'})}catch(e){alert('Erro: '+e.message)}
tfWsLoadList();
}
async function twsCancel(wid){
try{await $api('/terraform/workspaces/'+wid+'/cancel',{method:'POST'})}catch(e){}
tfWsLoadList();
}
async function _twsPollWs(wid){
for(let i=0;i<300;i++){
await new Promise(r=>setTimeout(r,3000));
try{
const r=await $api('/terraform/workspaces/'+wid+'/status');
const done=!['planning','applying','destroying'].includes(r.status);
const idx=S.twsList.findIndex(w=>w.id===wid);
if(idx>=0){
S.twsList[idx].status=r.status;
S.twsList[idx].plan_output=r.plan_output||S.twsList[idx].plan_output;
S.twsList[idx].apply_output=r.apply_output||S.twsList[idx].apply_output;
S.twsList[idx].destroy_output=r.destroy_output||S.twsList[idx].destroy_output;
S.twsList[idx].error=r.error||'';
if(S.tab==='tf-workspaces'){
const el=document.getElementById('tws-badge-'+wid);
if(el)el.outerHTML=_tfBadge(r.status);
}
}
if(done){tfWsLoadList();return}
}catch(e){}
}
}
/* ── OCI Explorer ── */
const EXP_GROUPS=[
{name:'Compute',tabs:[
@@ -1858,17 +2142,17 @@ function rExplorer(){
const catIcons={Compute:IC.computer,Rede:IC.globe,Storage:IC.hdd,Database:IC.db,Containers:IC.container,Serverless:IC.zap,Observabilidade:IC.activity,Segurança:IC.shield,IAM:IC.user};
const cats=EXP_GROUPS.map(g=>`<div class="exp-cat${S.expCat===g.name?' active':''}" onclick="expSetCat('${g.name}')">${catIcons[g.name]||''} ${g.name}</div>`).join('');
const activeGrp=EXP_GROUPS.find(g=>g.name===S.expCat)||EXP_GROUPS[0];
const tabs=activeGrp.tabs.map(t=>{const cnt=S.expCounts[t.key];return`<div class="exp-tab${S.expResType===t.key?' active':''}" onclick="expSetTab('${t.key}')">${t.label}${cnt!=null?' <span style="font-size:.58rem;opacity:.7">('+cnt+')</span>':''}</div>`}).join('');
const tabs=activeGrp.tabs.map(t=>{const cnt=S.expCounts[t.key];return`<div class="exp-tab${S.expResType===t.key?' active':''}" onclick="expSetTab('${t.key}')">${t.label} <span id="exp-tab-cnt-${t.key}" style="font-size:.58rem;opacity:.7">${cnt!=null?'('+cnt+')':''}</span></div>`}).join('');
// KPI stats from counts
const _iamKeys=new Set(['iam_users','iam_groups','iam_policies','iam_dynamic_groups']);
const totalRes=Object.entries(S.expCounts).reduce((a,[k,v])=>a+(_iamKeys.has(k)?0:(v||0)),0);
const kpiCats=EXP_GROUPS.map(g=>{const cnt=g.tabs.reduce((a,t)=>a+(S.expCounts[t.key]||0),0);const detail=g.tabs.filter(t=>(S.expCounts[t.key]||0)>0).map(t=>t.label+': '+S.expCounts[t.key]).join('\n');return{name:g.name,icon:catIcons[g.name]||'',count:cnt,detail:detail}}).filter(k=>k.count>0);
const selCompName=S.expTree.find(n=>n.id===S.expSelComp);
const compLabel=selCompName?selCompName.name:'—';
const kpiHtml=S.expSelComp?`<div style="display:flex;gap:.5rem;padding:.5rem .75rem;border-bottom:1px solid var(--bd);background:var(--bg);overflow-x:auto;align-items:stretch">
const kpiHtml=S.expSelComp?`<div id="exp-kpi" style="display:flex;gap:.5rem;padding:.5rem .75rem;border-bottom:1px solid var(--bd);background:var(--bg);overflow-x:auto;align-items:stretch">
<div class="kpi" style="padding:.5rem .7rem;min-width:120px;flex:1"><div class="kpi-label">${IC.folder} Compartment</div><div style="font-size:.82rem;font-weight:700;color:var(--t1);margin-top:.15rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${compLabel}">${compLabel}</div><div class="kpi-sub">${S.expSelRegions.length||'Todas'} região(ões)</div></div>
<div class="kpi kpi-info" style="padding:.5rem .7rem;min-width:80px;text-align:center;flex:0 0 auto"><div class="kpi-label">${IC.activity} Total</div><div class="kpi-val v-info" style="font-size:1.3rem">${totalRes}</div><div class="kpi-sub">${S.expCountsRefreshing?'<span class="spinner" style="width:10px;height:10px;display:inline-block;vertical-align:-1px"></span> atualizando...':'recursos'}</div></div>
${kpiCats.slice(0,6).map(k=>`<div class="kpi" style="padding:.5rem .7rem;min-width:75px;text-align:center;flex:0 0 auto;cursor:default" title="${k.detail}"><div class="kpi-label" style="justify-content:center">${k.icon}</div><div class="kpi-val" style="font-size:1.2rem">${k.count}</div><div class="kpi-sub">${k.name}</div></div>`).join('')}
<div class="kpi kpi-info" style="padding:.5rem .7rem;min-width:80px;text-align:center;flex:0 0 auto"><div class="kpi-label">${IC.activity} Total</div><div id="exp-kpi-total" class="kpi-val v-info" style="font-size:1.3rem">${totalRes}</div><div id="exp-kpi-sub" class="kpi-sub">${S.expCountsRefreshing?'<span class="spinner" style="width:10px;height:10px;display:inline-block;vertical-align:-1px"></span> atualizando...':'recursos'}</div></div>
<span id="exp-kpi-cats">${kpiCats.slice(0,6).map(k=>`<div class="kpi" style="padding:.5rem .7rem;min-width:75px;text-align:center;flex:0 0 auto;cursor:default" title="${k.detail}"><div class="kpi-label" style="justify-content:center">${k.icon}</div><div class="kpi-val" style="font-size:1.2rem">${k.count}</div><div class="kpi-sub">${k.name}</div></div>`).join('')}</span>
</div>`:'';
return`<div class="exp-wrap">
<div class="exp-tree" id="exp-tree"><div style="padding:.6rem .7rem .5rem;font-size:.78rem;font-weight:700;color:var(--t1);border-bottom:1px solid var(--bd);margin-bottom:.3rem;display:flex;align-items:center;gap:.4rem;background:var(--bg2)">${IC.folder} Compartments</div>${S.expTree.length?rExpTree(null,0):'<div class="ld"><span class="spinner"></span> Carregando...</div>'}</div>
@@ -1945,31 +2229,44 @@ async function expLoadRes(){
S.expCounts[S.expResType]=all.length;
S.expLoading=false;R()}
let _expCountAbort=0;
async function expLoadCachedCounts(){
async function expLoadCachedCounts(soft){
const comp=S.expSelComp;const cfg=S.expCfg;if(!comp||!cfg)return;
const regKey=S.expSelRegions.length?S.expSelRegions.slice().sort().join(','):'';
try{
const q=new URLSearchParams({compartment_id:comp,regions:regKey});
const d=await $api('/oci/explore/'+cfg+'/counts?'+q);
if(d&&!d.error){for(const[k,v]of Object.entries(d)){S.expCounts[k]=v.count}R()}
if(d&&!d.error){let changed=false;for(const[k,v]of Object.entries(d)){if(S.expCounts[k]!==v.count){S.expCounts[k]=v.count;changed=true}}if(changed){if(soft)expUpdateKpi();else R()}}
}catch(e){console.warn('Cache counts error:',e)}
}
function expUpdateKpi(){
const el=document.getElementById('exp-kpi');if(!el)return;
const catIcons={Compute:IC.computer,Rede:IC.globe,Storage:IC.hdd,Database:IC.db,Containers:IC.container,Serverless:IC.zap,Observabilidade:IC.activity,'Segurança':IC.shield,IAM:IC.user};
const _iamKeys=new Set(['iam_users','iam_groups','iam_policies','iam_dynamic_groups']);
const totalRes=Object.entries(S.expCounts).reduce((a,[k,v])=>a+(_iamKeys.has(k)?0:(v||0)),0);
const kpiCats=EXP_GROUPS.map(g=>{const cnt=g.tabs.reduce((a,t)=>a+(S.expCounts[t.key]||0),0);const detail=g.tabs.filter(t=>(S.expCounts[t.key]||0)>0).map(t=>t.label+': '+S.expCounts[t.key]).join('\n');return{name:g.name,icon:catIcons[g.name]||'',count:cnt,detail}}).filter(k=>k.count>0);
const totalEl=document.getElementById('exp-kpi-total');if(totalEl)totalEl.textContent=totalRes;
const subEl=document.getElementById('exp-kpi-sub');if(subEl)subEl.innerHTML=S.expCountsRefreshing?'<span class="spinner" style="width:10px;height:10px;display:inline-block;vertical-align:-1px"></span> atualizando...':'recursos';
const catsEl=document.getElementById('exp-kpi-cats');if(catsEl)catsEl.innerHTML=kpiCats.slice(0,6).map(k=>`<div class="kpi" style="padding:.5rem .7rem;min-width:75px;text-align:center;flex:0 0 auto;cursor:default" title="${k.detail}"><div class="kpi-label" style="justify-content:center">${k.icon}</div><div class="kpi-val" style="font-size:1.2rem">${k.count}</div><div class="kpi-sub">${k.name}</div></div>`).join('');
// Update tab counts
const activeGrp=EXP_GROUPS.find(g=>g.name===S.expCat)||EXP_GROUPS[0];
activeGrp.tabs.forEach(t=>{const te=document.getElementById('exp-tab-cnt-'+t.key);if(te)te.textContent=S.expCounts[t.key]!=null?'('+S.expCounts[t.key]+')':''});
}
async function expRefreshCounts(){
const comp=S.expSelComp;const cfg=S.expCfg;if(!comp||!cfg)return;
const myRun=++_expCountAbort;
S.expCountsRefreshing=true;R();
S.expCountsRefreshing=true;expUpdateKpi();
try{
await $api('/oci/explore/'+cfg+'/counts/refresh',{method:'POST',body:{compartment_id:comp,regions:S.expSelRegions}});
const poll=async(attempts)=>{
if(_expCountAbort!==myRun||S.expSelComp!==comp){S.expCountsRefreshing=false;R();return}
if(attempts<=0){S.expCountsRefreshing=false;R();return}
if(_expCountAbort!==myRun||S.expSelComp!==comp){S.expCountsRefreshing=false;expUpdateKpi();return}
if(attempts<=0){S.expCountsRefreshing=false;expUpdateKpi();return}
await new Promise(r=>setTimeout(r,3000));
if(_expCountAbort!==myRun||S.expSelComp!==comp){S.expCountsRefreshing=false;R();return}
await expLoadCachedCounts();
if(_expCountAbort!==myRun||S.expSelComp!==comp){S.expCountsRefreshing=false;expUpdateKpi();return}
await expLoadCachedCounts(true);
poll(attempts-1);
};
poll(12);
}catch(e){S.expCountsRefreshing=false;R();console.warn('Refresh counts error:',e)}
}catch(e){S.expCountsRefreshing=false;expUpdateKpi();console.warn('Refresh counts error:',e)}
}
function renderExpData(d){if(!d)return'<div class="emp"><p>Selecione um compartment na árvore.</p></div>';
if(d.error)return`<div class="al al-e">${d.error}</div>`;if(!Array.isArray(d)||!d.length)return'<div class="emp"><p>Nenhum recurso encontrado neste compartment.</p></div>';
@@ -2547,9 +2844,9 @@ function dlRpt(id,f){window.open(API+'/reports/'+id+'/download?fmt='+f+'&token='
async function refreshDl(){S.reports=await $api('/reports');S.reportFiles={};S.dlExpandedRid=null;R()}
/* ── Edit helpers ── */
function editCfg(type,id){S.editing={type,id};if(type==='oci'){const c=S.ociCfg.find(x=>x.id===id);S.ociFormRegVal=c?c.region:'';S.ociFormRegFilter=''}R();
function editCfg(type,id){S.editing={type,id};if(type==='oci'){const c=S.ociCfg.find(x=>x.id===id);S.ociFormRegVal=c?c.region:'';S.ociFormRegFilter='';S.ociFormOpen=true;S.ociAuthType=c?.auth_type||'api_key'}R();
const form=document.querySelector('.cd:has(#obtn), .cd:has(#gbtn), .cd:has(#abtn), .cd:has(#mcbtn)');if(form)form.scrollIntoView({behavior:'smooth',block:'start'})}
function cancelEdit(){S.editing=null;S.ociFormRegVal='';S.ociFormRegFilter='';R()}
function cancelEdit(){S.editing=null;S.ociFormRegVal='';S.ociFormRegFilter='';S.ociFormOpen=false;S.ociAuthType='api_key';R()}
/* ── OCI Config ── */
function rOciRegionDd(eo){
@@ -2569,6 +2866,10 @@ ${ddItems||'<div style="padding:.5rem;color:var(--t4);font-size:.72rem">Nenhuma
</div>`:''}</div>`}
function ociFormPickReg(r){S.ociFormRegVal=r;S.ociFormRegOpen=false;S.ociFormRegFilter='';R()}
function rOci(){const eo=S.editing?.type==='oci'?S.ociCfg.find(c=>c.id===S.editing.id):null;
const formOpen=S.ociFormOpen||!!eo;
const at=eo?(eo.auth_type||'api_key'):S.ociAuthType;
const isToken=at==='session_token';
const authTag=t=>(t||'api_key')==='session_token'?'<span class="tag" style="background:#e67e2222;color:#e67e22;font-size:.58rem">Session Token</span>':'<span class="tag" style="background:var(--gnl);color:var(--gn);font-size:.58rem">API Key</span>';
return`<div class="cd" style="display:flex;align-items:center;justify-content:space-between;padding:.6rem 1rem">
<div style="font-size:.78rem;font-weight:600;color:var(--t2)">${IC.settings} Configuração do Sistema</div>
<div style="display:flex;gap:.4rem;align-items:center">
@@ -2578,44 +2879,71 @@ function rOci(){const eo=S.editing?.type==='oci'?S.ociCfg.find(c=>c.id===S.editi
</div></div>
<div id="cfgMsg"></div>
<div class="cd"><div class="ct">${IC.cloud} Credenciais Registradas</div><div id="ocm"></div>
<table><thead><tr><th>Tenancy</th><th>Region</th><th>Detalhes</th><th>Ações</th></tr></thead><tbody>
${S.ociCfg.map(c=>`<tr${eo&&eo.id===c.id?' style="background:var(--bg3)"':''}><td><strong>${c.tenancy_name}</strong><br><span style="font-size:.66rem;color:var(--t4)">${c.created_at}</span></td><td><span class="tag">${c.region}</span></td>
<td style="font-family:var(--fm);font-size:.66rem;color:var(--t4);line-height:1.6"><span title="Fingerprint">${IC.key} ••••••••••••••••••••</span><br><span title="Tenancy OCID">${IC.building} ${c.tenancy_ocid}</span><br><span title="User OCID">${IC.user} ${c.user_ocid}</span><br><span title="Compartment">${IC.pkg} ${c.compartment_id||'—'}</span><br><span title="SSH Public Key">${IC.key} SSH: ${c.ssh_public_key_preview||'<em>não configurada</em>'}</span></td>
<table><thead><tr><th>Tenancy</th><th>Tipo</th><th>Region</th><th>Detalhes</th><th>Ações</th></tr></thead><tbody>
${S.ociCfg.map(c=>`<tr${eo&&eo.id===c.id?' style="background:var(--bg3)"':''}><td><strong>${c.tenancy_name}</strong><br><span style="font-size:.66rem;color:var(--t4)">${c.created_at}</span></td><td>${authTag(c.auth_type)}</td><td><span class="tag">${c.region}</span></td>
<td style="font-family:var(--fm);font-size:.66rem;color:var(--t4);line-height:1.6">${(c.auth_type||'api_key')==='session_token'?`<span title="Tenancy OCID">${IC.building} ${c.tenancy_ocid}</span><br><span title="Auth">${IC.lock} Session Token</span>`:`<span title="Fingerprint">${IC.key} ••••••••••••••••••••</span><br><span title="Tenancy OCID">${IC.building} ${c.tenancy_ocid}</span><br><span title="User OCID">${IC.user} ${c.user_ocid}</span>`}<br><span title="Compartment">${IC.pkg} ${c.compartment_id||'—'}</span><br><span title="SSH Public Key">${IC.key} SSH: ${c.ssh_public_key_preview||'<em>não configurada</em>'}</span></td>
<td><button class="btn bs bsm" onclick="editCfg('oci','${c.id}')">Editar</button> <button class="btn bs bsm" onclick="tOci('${c.id}')">Testar</button> <button class="btn bd bsm" onclick="dOci('${c.id}')">Excluir</button></td></tr>`).join('')}</tbody></table>
${!S.ociCfg.length?'<div class="emp" style="padding:.85rem"><p>Nenhuma credencial registrada.</p></div>':''}</div>
<div class="cd"><div class="ct">${eo?IC.edit+' Editar Credencial':IC.plus+' Nova Credencial'}</div>
<div class="cdesc">${eo?'Editando: <strong>'+eo.tenancy_name+'</strong>':'Adicione as credenciais da sua conta OCI para executar reports e acessar serviços.'}</div><div id="om"></div>
<div class="cd">
<div style="display:flex;align-items:center;cursor:pointer;gap:.5rem" onclick="${eo?'':`S.ociFormOpen=!S.ociFormOpen;R()`}">
<svg viewBox="0 0 16 16" width="14" height="14" fill="var(--t3)" style="transition:transform .2s;transform:rotate(${formOpen?'90':'0'}deg)"><path d="M6 3l5 5-5 5z"/></svg>
<div class="ct" style="margin:0">${eo?IC.edit+' Editar Credencial':IC.plus+' Nova Credencial'}</div>
</div>
${formOpen?`
<div class="cdesc" style="margin-top:.5rem">${eo?'Editando: <strong>'+eo.tenancy_name+'</strong>':'Adicione as credenciais da sua conta OCI para executar reports e acessar serviços.'}</div><div id="om"></div>
<div style="display:flex;gap:.5rem;margin-bottom:.7rem">
<button class="tws-filter-btn${!isToken?' on':''}" onclick="S.ociAuthType='api_key';R()" ${eo?'disabled':''}>${IC.key} API Key</button>
<button class="tws-filter-btn${isToken?' on':''}" onclick="S.ociAuthType='session_token';R()" ${eo?'disabled':''}>${IC.lock} Session Token</button>
${eo?'<span style="font-size:.62rem;color:var(--t4);align-self:center">(tipo não pode ser alterado)</span>':''}
</div>
<div class="g2">
<div class="ig"><label>Tenancy Name</label><input type="text" id="otn" placeholder="minha-empresa" value="${eo?eo.tenancy_name:''}"></div>
<div class="ig"><label>OCID User</label><input type="password" id="ouo" placeholder="ocid1.user.oc1.." value="${eo?'':''}" autocomplete="off">${eo?'<div class="ht">Preencha para alterar. Atual: <code>'+eo.user_ocid+'</code></div>':''}</div>
<div class="ig"><label>Fingerprint</label><input type="password" id="ofp" placeholder="aa:bb:cc:dd:..." value="${eo?'':''}" autocomplete="off">${eo?'<div class="ht">Preencha para alterar. Atual: <code>'+eo.fingerprint+'</code></div>':''}</div>
<div class="ig"><label>OCID Tenancy</label><input type="password" id="oto" placeholder="ocid1.tenancy.oc1.." value="${eo?'':''}" autocomplete="off">${eo?'<div class="ht">Preencha para alterar. Atual: <code>'+eo.tenancy_ocid+'</code></div>':''}</div>
<div class="ig"><label>Region</label>${rOciRegionDd(eo)}</div>
<div class="ig"><label>Compartment OCID</label><input type="password" id="ocp" placeholder="ocid1.compartment.oc1.." value="${eo?'':''}" autocomplete="off">${eo?'<div class="ht">Preencha para alterar. Atual: <code>'+(eo.compartment_id||'—')+'</code></div>':''}</div></div>
<div class="g2"><div class="ig"><label>Private Key (.pem)</label>${eo?'<div class="ht">Deixe vazio para manter a chave atual</div>':''}<input type="file" id="osk" accept=".pem"></div>
<div class="ig"><label>Public Key (.pem)</label><input type="file" id="opk" accept=".pem"></div></div>
<div class="g2"><div class="ig"><label>Key Passphrase</label><div class="ht">Apenas se a chave privada for protegida por senha</div><input type="password" id="okp" placeholder="(opcional)"></div>
${!isToken?`<div class="g2">
<div class="ig"><label>OCID User</label><input type="password" id="ouo" placeholder="ocid1.user.oc1.." value="" autocomplete="off">${eo?'<div class="ht">Preencha para alterar. Atual: <code>'+eo.user_ocid+'</code></div>':''}</div>
<div class="ig"><label>Fingerprint</label><input type="password" id="ofp" placeholder="aa:bb:cc:dd:..." value="" autocomplete="off">${eo?'<div class="ht">Preencha para alterar. Atual: <code>'+eo.fingerprint+'</code></div>':''}</div></div>`
:`<div class="g2">
<div class="ig"><label>Session Token</label><div class="ht">Cole o conteúdo do arquivo token gerado por <code>oci session authenticate</code></div><textarea id="ostk" rows="3" placeholder="Conteúdo do arquivo ~/.oci/sessions/DEFAULT/token" style="font-size:.68rem;font-family:var(--fm);resize:vertical"></textarea></div>
<div class="ig"><label>OCID User <span style="font-size:.6rem;color:var(--t4)">(opcional)</span></label><input type="password" id="ouo" placeholder="ocid1.user.oc1.. (preenchido automaticamente)" value="" autocomplete="off"><div class="ht">O SDK pode resolver o user a partir do token. Deixe vazio se preferir.</div></div></div>`}
<div class="g2"><div class="ig"><label>${isToken?'Chave de Sessão (.pem)':'Private Key (.pem)'}</label>${eo?'<div class="ht">Deixe vazio para manter a chave atual</div>':''}<div class="ht">${isToken?'Arquivo <code>oci_api_key.pem</code> da pasta de sessão':'Chave privada da API Key OCI'}</div><input type="file" id="osk" accept=".pem"></div>
${!isToken?'<div class="ig"><label>Public Key (.pem)</label><input type="file" id="opk" accept=".pem"></div>':'<div class="ig"></div>'}</div>
<div class="g2">${!isToken?'<div class="ig"><label>Key Passphrase</label><div class="ht">Apenas se a chave privada for protegida por senha</div><input type="password" id="okp" placeholder="(opcional)"></div>':'<div class="ig"></div>'}
<div class="ig"><label>SSH Public Key</label><div class="ht">Chave SSH para acesso a instâncias compute (ex: ssh-rsa AAAA...)</div><textarea id="osshk" rows="2" placeholder="ssh-rsa AAAAB3NzaC1yc2E... (opcional)" style="font-size:.72rem;font-family:var(--fm);resize:vertical">${eo&&eo.ssh_public_key?eo.ssh_public_key:''}</textarea>${eo&&eo.ssh_public_key_preview?'<div class="ht">Atual: <code>'+eo.ssh_public_key_preview+'</code></div>':''}</div></div>
<div style="display:flex;gap:8px"><button class="btn bp" id="obtn" onclick="sOci()">${eo?'Salvar Alterações':'Salvar Credencial'}</button>${eo?'<button class="btn bs" onclick="cancelEdit()">Cancelar</button>':''}</div></div>
<div style="display:flex;gap:8px"><button class="btn bp" id="obtn" onclick="sOci()">${eo?'Salvar Alterações':'Salvar Credencial'}</button>${eo?'<button class="btn bs" onclick="cancelEdit()">Cancelar</button>':''}</div>
`:''}
</div>
`+rConfigLogs('oci')}
async function sOci(){const tn=document.getElementById('otn').value;
if(!tn)return sm('om','Preencha o Tenancy Name','e');
const fd=new FormData();fd.append('tenancy_name',tn);fd.append('tenancy_ocid',document.getElementById('oto').value);
fd.append('user_ocid',document.getElementById('ouo').value);fd.append('fingerprint',document.getElementById('ofp').value);
fd.append('region',S.ociFormRegVal||'');fd.append('compartment_id',document.getElementById('ocp').value);
fd.append('key_passphrase',document.getElementById('okp').value||'');
fd.append('ssh_public_key',document.getElementById('osshk').value||'');
const sk=document.getElementById('osk').files[0];
const isEdit=S.editing?.type==='oci';
const eo=isEdit?S.ociCfg.find(c=>c.id===S.editing.id):null;
const at=eo?(eo.auth_type||'api_key'):S.ociAuthType;
const isToken=at==='session_token';
const fd=new FormData();fd.append('tenancy_name',tn);fd.append('tenancy_ocid',document.getElementById('oto').value);
fd.append('user_ocid',(document.getElementById('ouo')||{}).value||'');
fd.append('fingerprint',(document.getElementById('ofp')||{}).value||'');
fd.append('region',S.ociFormRegVal||'');fd.append('compartment_id',document.getElementById('ocp').value);
fd.append('key_passphrase',(document.getElementById('okp')||{}).value||'');
fd.append('ssh_public_key',document.getElementById('osshk').value||'');
fd.append('auth_type',at);
fd.append('security_token',(document.getElementById('ostk')||{}).value||'');
const sk=document.getElementById('osk').files[0];
if(!isEdit){
if(!document.getElementById('oto').value||!document.getElementById('ouo').value||!document.getElementById('ofp').value)return sm('om','Preencha OCID Tenancy, OCID User e Fingerprint','e');
if(!sk)return sm('om','Selecione a chave privada','e');
if(!document.getElementById('oto').value)return sm('om','Preencha o OCID Tenancy','e');
if(!isToken){
if(!document.getElementById('ouo').value||!document.getElementById('ofp').value)return sm('om','Preencha OCID User e Fingerprint','e');
}else{
if(!(document.getElementById('ostk')||{}).value)return sm('om','Cole o Session Token','e');
}
if(!sk)return sm('om',isToken?'Selecione a chave de sessão (.pem)':'Selecione a chave privada (.pem)','e');
}
if(sk)fd.append('private_key',sk);
const pk=document.getElementById('opk').files[0];if(pk)fd.append('public_key',pk);
const pk=(document.getElementById('opk')||{}).files?.[0];if(pk)fd.append('public_key',pk);
const btn=document.getElementById('obtn');btn.disabled=true;btn.textContent='Salvando...';sm('om','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Salvando credencial...','i');
try{const url=isEdit?'/oci/configs/'+S.editing.id:'/oci/config';const method=isEdit?'PUT':'POST';
await $api(url,{method,body:fd,headers:{}});S.editing=null;S.ociFormRegVal='';S.ociFormRegFilter='';S.ociCfg=await $api('/oci/configs');sm('om',IC.ok+' Credencial '+(isEdit?'atualizada':'salva')+' com sucesso!','s');R()}catch(e){sm('om',e.message,'e');btn.disabled=false;btn.textContent=isEdit?'Salvar Alterações':'Salvar Credencial'}}
await $api(url,{method,body:fd,headers:{}});S.editing=null;S.ociFormRegVal='';S.ociFormRegFilter='';S.ociFormOpen=false;S.ociAuthType='api_key';S.ociCfg=await $api('/oci/configs');sm('om',IC.ok+' Credencial '+(isEdit?'atualizada':'salva')+' com sucesso!','s');R()}catch(e){sm('om',e.message,'e');btn.disabled=false;btn.textContent=isEdit?'Salvar Alterações':'Salvar Credencial'}}
async function cfgExport(){
try{
const resp=await fetch(API+'/config/export',{headers:S.token?{'Authorization':'Bearer '+S.token}:{}});