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"> <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> </p>
<h1 align="center">OCI CIS AI Agent</h1> <h1 align="center">AI Agent — Infrastructure & Security Engineer</h1>
<p align="center"> <p align="center">
<strong>Oracle Cloud Infrastructure — CIS Foundations Benchmark 3.0 — AI-Powered Compliance Platform</strong> <strong>Oracle Cloud Infrastructure — CIS Foundations Benchmark 3.0 — AI-Powered Compliance Platform</strong>
</p> </p>
<p align="center"> <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/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/FastAPI-0.115-009688?style=flat-square" alt="FastAPI">
<img src="https://img.shields.io/badge/OCI-GenAI-C74634?style=flat-square" alt="OCI"> <img src="https://img.shields.io/badge/OCI-GenAI-C74634?style=flat-square" alt="OCI">
@@ -21,7 +21,7 @@
## Overview ## 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. 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 | | 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.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.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 | | **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), FastAPI with JWT auth, TOTP MFA, RBAC, OCI GenAI (exact SDK pattern),
OCI Account Explorer, MCP Server registry with VectorDB tool integration, 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.
@@ -52,7 +52,7 @@ COMPACT_MIN_MESSAGES = 8
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
log = logging.getLogger("agent") 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, app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
allow_methods=["*"], allow_headers=["*"]) allow_methods=["*"], allow_headers=["*"])
security = HTTPBearer() security = HTTPBearer()
@@ -390,7 +390,7 @@ def init_db():
c.execute(f"ALTER TABLE terraform_workspaces ADD COLUMN {col}") c.execute(f"ALTER TABLE terraform_workspaces ADD COLUMN {col}")
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
for col in ["ssh_public_key TEXT"]: for col in ["ssh_public_key TEXT", "auth_type TEXT DEFAULT 'api_key'"]:
try: try:
c.execute(f"ALTER TABLE oci_configs ADD COLUMN {col}") c.execute(f"ALTER TABLE oci_configs ADD COLUMN {col}")
except sqlite3.OperationalError: except sqlite3.OperationalError:
@@ -427,6 +427,16 @@ def init_db():
else: else:
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 (?,?,?,?,?)",
(str(uuid.uuid4()), "OCI Terraform Agent", "terraform", TF_DEFAULT_SYSTEM_PROMPT, 1)) (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() adm = c.execute("SELECT id FROM users WHERE username='admin'").fetchone()
if not adm: if not adm:
c.execute( 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) c=str((struct.unpack(">I",h[o:o+4])[0]&0x7FFFFFFF)%1_000_000).zfill(6)
if hmac.compare_digest(c,code): return True if hmac.compare_digest(c,code): return True
return False 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): 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) 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() 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: def _get_oci_config(oci_config_id: str) -> dict:
import oci import oci
config_path = str(OCI_DIR / oci_config_id / "config") 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 ───────────────────────────────────────────────────────────────── # ── Auth deps ─────────────────────────────────────────────────────────────────
async def current_user(cred: HTTPAuthorizationCredentials = Depends(security)): 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") @app.post("/api/oci/config")
async def save_oci( async def save_oci(
tenancy_name: str = Form(...), tenancy_ocid: str = Form(...), 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(""), region: str = Form(...), compartment_id: str = Form(""),
key_passphrase: str = Form(""), key_passphrase: str = Form(""),
ssh_public_key: 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")) 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) cid = str(uuid.uuid4()); cdir = OCI_DIR / cid; cdir.mkdir(parents=True)
kp = cdir / "oci_api_key.pem" kp = cdir / "oci_api_key.pem"
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() key_bytes = await private_key.read()
kp.write_bytes(key_bytes); kp.chmod(0o600) kp.write_bytes(key_bytes); kp.chmod(0o600)
# Detect encrypted keys that require passphrase
key_text = key_bytes.decode("utf-8", errors="ignore") key_text = key_bytes.decode("utf-8", errors="ignore")
key_is_encrypted = "ENCRYPTED" in key_text or "Proc-Type: 4,ENCRYPTED" in key_text key_is_encrypted = "ENCRYPTED" in key_text or "Proc-Type: 4,ENCRYPTED" in key_text
if key_is_encrypted and not key_passphrase: if key_is_encrypted and not key_passphrase:
# Clean up and warn
shutil.rmtree(cdir, ignore_errors=True) shutil.rmtree(cdir, ignore_errors=True)
raise HTTPException(400, "A chave privada está criptografada (ENCRYPTED). Informe a Key Passphrase.") 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()) if public_key and public_key.filename:
(cdir / "oci_api_key_public.pem").write_bytes(await public_key.read())
cfg_file = cdir / "config" cfg_file = cdir / "config"
cfg_content = (f"[DEFAULT]\nuser={user_ocid}\nfingerprint={fingerprint}\n" cfg_content = (f"[DEFAULT]\nuser={user_ocid}\nfingerprint={fingerprint}\n"
f"tenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n") f"tenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n")
if key_passphrase: if key_passphrase:
cfg_content += f"pass_phrase={key_passphrase}\n" cfg_content += f"pass_phrase={key_passphrase}\n"
cfg_file.write_text(cfg_content) cfg_file.write_text(cfg_content); cfg_file.chmod(0o600)
cfg_file.chmod(0o600)
with db() as c: 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 (?,?,?,?,?,?,?,?,?,?)", 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)) (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}") _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})", u["id"], u["username"]) _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} return {"id": cid, "tenancy_name": tenancy_name, "region": region}
@app.get("/api/oci/configs") @app.get("/api/oci/configs")
async def list_oci(u=Depends(current_user)): async def list_oci(u=Depends(current_user)):
with db() as c: 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() 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() else: rows=c.execute(f"SELECT {cols} FROM oci_configs WHERE user_id=?",(u["id"],)).fetchall()
result = [] result = []
@@ -736,6 +805,8 @@ async def update_oci(
region: str = Form(...), compartment_id: str = Form(""), region: str = Form(...), compartment_id: str = Form(""),
key_passphrase: str = Form(""), key_passphrase: str = Form(""),
ssh_public_key: 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), private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None),
u = Depends(require("admin","user")) u = Depends(require("admin","user"))
): ):
@@ -748,6 +819,11 @@ async def update_oci(
user_ocid = user_ocid or _safe_dec(existing["user_ocid"]) user_ocid = user_ocid or _safe_dec(existing["user_ocid"])
fingerprint = fingerprint or _safe_dec(existing["fingerprint"]) fingerprint = fingerprint or _safe_dec(existing["fingerprint"])
compartment_id = compartment_id or _safe_dec(existing["compartment_id"]) or "" 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: try:
existing_ssh = existing["ssh_public_key"] or "" existing_ssh = existing["ssh_public_key"] or ""
except (IndexError, KeyError): except (IndexError, KeyError):
@@ -759,21 +835,32 @@ async def update_oci(
key_bytes = await private_key.read() key_bytes = await private_key.read()
kp.write_bytes(key_bytes); kp.chmod(0o600) kp.write_bytes(key_bytes); kp.chmod(0o600)
key_text = key_bytes.decode("utf-8", errors="ignore") 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.") raise HTTPException(400, "A chave privada está criptografada (ENCRYPTED). Informe a Key Passphrase.")
if public_key and public_key.filename: if public_key and public_key.filename:
(cdir / "oci_api_key_public.pem").write_bytes(await public_key.read()) (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_file = cdir / "config"
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" cfg_content = (f"[DEFAULT]\nuser={user_ocid}\nfingerprint={fingerprint}\n"
f"tenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n") f"tenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n")
if key_passphrase: if key_passphrase:
cfg_content += f"pass_phrase={key_passphrase}\n" cfg_content += f"pass_phrase={key_passphrase}\n"
cfg_file.write_text(cfg_content); cfg_file.chmod(0o600) cfg_file.write_text(cfg_content); cfg_file.chmod(0o600)
with db() as c: 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=?", 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, cid)) (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}") _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})", u["id"], u["username"]) _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} return {"id": cid, "tenancy_name": tenancy_name, "region": region}
@app.post("/api/oci/test/{cid}") @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). - 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`. - 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`. - `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 ### 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. - 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.""" Returns list of dicts with invalid types and suggestions."""
import re as _re import re as _re
from difflib import get_close_matches 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() 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)) used_types.add(m.group(1))
if not used_types: if not used_types:
return [] return []
@@ -3549,7 +3637,7 @@ async def embed_upload_url(
if not url.startswith(("http://", "https://")): if not url.startswith(("http://", "https://")):
raise HTTPException(400, "URL inválida — deve começar com http:// ou https://") raise HTTPException(400, "URL inválida — deve começar com http:// ou https://")
try: 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() resp.raise_for_status()
except Exception as e: except Exception as e:
raise HTTPException(400, f"Erro ao acessar URL: {str(e)[:300]}") raise HTTPException(400, f"Erro ao acessar URL: {str(e)[:300]}")
@@ -4483,7 +4571,7 @@ def _agent_respond(msg, user):
return "\n".join(lines) return "\n".join(lines)
if any(k in m for k in ["cis","benchmark","checks","verificar"]): 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 ("🔒 **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.") "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") @app.get("/api/chat/sessions")
@@ -4835,7 +4923,7 @@ class TfPromptReq(BaseModel):
session_id: Optional[str] = None session_id: Optional[str] = None
@app.post("/api/terraform/generate-prompt") @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 gc = None
if req.genai_config: if req.genai_config:
if req.genai_config.get("genai_config_id"): 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: if req.history:
hist = [{"role": "user" if m.get("role","").upper() in ("USER","user") else "assistant", hist = [{"role": "user" if m.get("role","").upper() in ("USER","user") else "assistant",
"content": m.get("content", "")} for m in req.history] "content": m.get("content", "")} for m in req.history]
try: # Async background processing — same pattern as Chat/Terraform agents
answer, _, _ = _call_genai(gc, req.message, history=hist) mid = str(uuid.uuid4())
# Save assistant response
with db() as c: with db() as c:
c.execute("INSERT INTO chat_messages (id,session_id,user_id,role,content,model_id,status) VALUES (?,?,?,?,?,?,?)", 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")) (mid, sid, u["id"], "assistant", "", gc.get("model_id"), "processing"))
return {"prompt": answer, "session_id": sid} 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: except Exception as e:
log.error(f"tf_generate_prompt error: {e}") log.error(f"tf_generate_prompt error: {e}")
raise HTTPException(500, str(e)[:500]) 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") @app.post("/api/terraform/chat")
async def terraform_chat(msg: ChatMsg, bg: BackgroundTasks, u=Depends(current_user)): 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)): async def tf_list_workspaces(u=Depends(current_user)):
with db() as c: with db() as c:
rows = c.execute( 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() (u["id"],)).fetchall()
return [dict(r) for r in rows] 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 # Remove monolithic files that are fully duplicated
for fname in dupes_in: for fname in dupes_in:
if all( 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() for key, flist in resource_locations.items()
if fname in flist if fname in flist
) and len(files) - 1 >= 2: ) and len(files) - 1 >= 2:
@@ -5881,7 +5981,7 @@ async def startup():
except Exception as e: except Exception as e:
log.warning(f"Could not populate tf_valid_types: {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__": if __name__ == "__main__":
import uvicorn import uvicorn

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <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"> <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> <style>
:root,html.light { :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-cats{flex-wrap:wrap}
.exp-cat{padding:.3rem .5rem;font-size:.6rem} .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 ── */ /* ── Terraform Agent ── */
.tf-wrap{display:flex;flex-direction:row;height:calc(100vh - 56px - 3rem);overflow:hidden} .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} .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-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-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)} .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{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} .tf-empty svg{width:40px;height:40px;fill:#7b42bc;opacity:.3}
@media(max-width:768px){ @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, 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, 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:'', 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'; const API='/api';
async function $api(p,o={}){const h={...(o.headers||{})};if(S.token)h['Authorization']='Bearer '+S.token; 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>'}} }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(); 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)}} 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)} const tm={'oci-config':'oci','genai':'genai','adb':'adb','mcp':'mcp'};if(tm[t])setTimeout(()=>refreshCLogs(tm[t]),100)}
/* ── Login ── */ /* ── Login ── */
function rLogin(){return`<div class="lp"><div class="lc fi"> function rLogin(){return`<div class="lp"><div class="lc fi">
<div style="text-align:center;margin-bottom:1.5rem">${LOGO_R} <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="le"></div>
<div id="mfa-s" style="display:none"><div class="al al-i">Código MFA obrigatório</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> <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>'; 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(){ 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 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 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(); const i=(S.user?.first_name||S.user?.username||'?')[0].toUpperCase();
return`<div class="sb"> 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> <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('')} ${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> <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="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>`} <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>`} 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 ── */ /* ── Chat ── */
function rChat(){ 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>` 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 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']; const provOrder=['openai','google','meta','xai'];
let ddItems=''; let ddItems='';
@@ -802,19 +833,38 @@ async function sChat(){const el=document.getElementById('chi');const m=el.value.
let resp=d.response; 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(', ')} 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()}} 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 →'}} finally{el.disabled=false;el.focus();btns.forEach(b=>{b.disabled=false});if(sendBtn)sendBtn.textContent=sendBtn.dataset.origText||'Enviar →'}}
async function pollChatResult(mid){ async function pollChatResult(mid){
for(let i=0;i<1200;i++){ for(let i=0;i<1200;i++){
await new Promise(r=>setTimeout(r,i<10?1000:3000)); await new Promise(r=>setTimeout(r,i<10?1000:3000));
try{const r=await $api('/chat/'+mid+'/status'); 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==='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){}} }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)} 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()} 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 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 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(){ 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>` <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?` :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> <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 // 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 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})}}); 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; if(S.tfpSid)body.session_id=S.tfpSid;
const d=await $api('/terraform/generate-prompt',{method:'POST',body}); const d=await $api('/terraform/generate-prompt',{method:'POST',body});
if(d.session_id)S.tfpSid=d.session_id; if(d.session_id)S.tfpSid=d.session_id;
S.tfpMsgs.push({r:'assistant',c:d.prompt,_raw:d.prompt}); if(d.status==='processing'&&d.message_id){await tfpPollResult(d.message_id)}
}catch(e){S.tfpMsgs.push({r:'assistant',c:IC.err+' Erro: '+e.message})} else if(d.prompt){S.tfpMsgs.push({r:'assistant',c:d.prompt,_raw:d.prompt});S.tfpLoading=false;R()}
S.tfpLoading=false;R(); }catch(e){S.tfpMsgs.push({r:'assistant',c:IC.err+' Erro: '+e.message,_failed:true});S.tfpLoading=false;R()}}
const mc2=document.getElementById('tfpchm');if(mc2)mc2.scrollTop=mc2.scrollHeight} 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; 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||''} 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||''}}} 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(){ function rTerraform(){
const ms=S.tfMsgs.length===0 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>` ?`<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 // 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']; 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); 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(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(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==='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==='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>'; 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(S.tfHistOpen)loadHistory('terraform');
if(d.status==='processing'&&d.message_id){await tfPollResult(d.message_id)} 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()} 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){ async function tfPollResult(mid){
@@ -1708,13 +1774,17 @@ async function tfPollResult(mid){
try{const r=await $api('/chat/'+mid+'/status'); 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.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.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){} }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(){ 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} if(!S.tfCode){alert('Gere código Terraform primeiro.');return}
// Resolve OCI config // Resolve OCI config
let ociId=S.tfOci; let ociId=S.tfOci;
@@ -1733,7 +1803,7 @@ async function tfSaveAndPlan(){
await $api('/terraform/workspaces/'+S.tfWs+'/plan',{method:'POST'}); 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(); S.tfStatus='planning';S.tfRunning=true;S.tfPlanOut='';S.tfApplyOut='';S.tfDestroyOut='';S.tfBtab='plan';R();
await tfPollExec(); await tfPollExec();
}catch(e){alert('Erro: '+e.message)} }catch(e){alert('Erro ao executar plan: '+e.message)}
} }
async function tfApply(){ async function tfApply(){
@@ -1764,7 +1834,8 @@ async function tfCancel(){
} }
async function tfAutoFix(){ 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 // 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 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); 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 ── */ /* ── OCI Explorer ── */
const EXP_GROUPS=[ const EXP_GROUPS=[
{name:'Compute',tabs:[ {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 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 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 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 // KPI stats from counts
const _iamKeys=new Set(['iam_users','iam_groups','iam_policies','iam_dynamic_groups']); 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 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 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 selCompName=S.expTree.find(n=>n.id===S.expSelComp);
const compLabel=selCompName?selCompName.name:'—'; 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" 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> <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>
${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 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>`:''; </div>`:'';
return`<div class="exp-wrap"> 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> <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.expCounts[S.expResType]=all.length;
S.expLoading=false;R()} S.expLoading=false;R()}
let _expCountAbort=0; let _expCountAbort=0;
async function expLoadCachedCounts(){ async function expLoadCachedCounts(soft){
const comp=S.expSelComp;const cfg=S.expCfg;if(!comp||!cfg)return; const comp=S.expSelComp;const cfg=S.expCfg;if(!comp||!cfg)return;
const regKey=S.expSelRegions.length?S.expSelRegions.slice().sort().join(','):''; const regKey=S.expSelRegions.length?S.expSelRegions.slice().sort().join(','):'';
try{ try{
const q=new URLSearchParams({compartment_id:comp,regions:regKey}); const q=new URLSearchParams({compartment_id:comp,regions:regKey});
const d=await $api('/oci/explore/'+cfg+'/counts?'+q); 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)} }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(){ async function expRefreshCounts(){
const comp=S.expSelComp;const cfg=S.expCfg;if(!comp||!cfg)return; const comp=S.expSelComp;const cfg=S.expCfg;if(!comp||!cfg)return;
const myRun=++_expCountAbort; const myRun=++_expCountAbort;
S.expCountsRefreshing=true;R(); S.expCountsRefreshing=true;expUpdateKpi();
try{ try{
await $api('/oci/explore/'+cfg+'/counts/refresh',{method:'POST',body:{compartment_id:comp,regions:S.expSelRegions}}); await $api('/oci/explore/'+cfg+'/counts/refresh',{method:'POST',body:{compartment_id:comp,regions:S.expSelRegions}});
const poll=async(attempts)=>{ const poll=async(attempts)=>{
if(_expCountAbort!==myRun||S.expSelComp!==comp){S.expCountsRefreshing=false;R();return} if(_expCountAbort!==myRun||S.expSelComp!==comp){S.expCountsRefreshing=false;expUpdateKpi();return}
if(attempts<=0){S.expCountsRefreshing=false;R();return} if(attempts<=0){S.expCountsRefreshing=false;expUpdateKpi();return}
await new Promise(r=>setTimeout(r,3000)); await new Promise(r=>setTimeout(r,3000));
if(_expCountAbort!==myRun||S.expSelComp!==comp){S.expCountsRefreshing=false;R();return} if(_expCountAbort!==myRun||S.expSelComp!==comp){S.expCountsRefreshing=false;expUpdateKpi();return}
await expLoadCachedCounts(); await expLoadCachedCounts(true);
poll(attempts-1); poll(attempts-1);
}; };
poll(12); 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>'; 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>'; 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()} async function refreshDl(){S.reports=await $api('/reports');S.reportFiles={};S.dlExpandedRid=null;R()}
/* ── Edit helpers ── */ /* ── 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'})} 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 ── */ /* ── OCI Config ── */
function rOciRegionDd(eo){ function rOciRegionDd(eo){
@@ -2569,6 +2866,10 @@ ${ddItems||'<div style="padding:.5rem;color:var(--t4);font-size:.72rem">Nenhuma
</div>`:''}</div>`} </div>`:''}</div>`}
function ociFormPickReg(r){S.ociFormRegVal=r;S.ociFormRegOpen=false;S.ociFormRegFilter='';R()} 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; 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"> 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="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"> <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></div>
<div id="cfgMsg"></div> <div id="cfgMsg"></div>
<div class="cd"><div class="ct">${IC.cloud} Credenciais Registradas</div><div id="ocm"></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> <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><span class="tag">${c.region}</span></td> ${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"><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 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> <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> ${!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="cd">
<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 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="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>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>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>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="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> ${!isToken?`<div class="g2">
<div class="ig"><label>Public Key (.pem)</label><input type="file" id="opk" accept=".pem"></div></div> <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="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> <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 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')} `+rConfigLogs('oci')}
async function sOci(){const tn=document.getElementById('otn').value; async function sOci(){const tn=document.getElementById('otn').value;
if(!tn)return sm('om','Preencha o Tenancy Name','e'); 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 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(!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(!document.getElementById('oto').value)return sm('om','Preencha o OCID Tenancy','e');
if(!sk)return sm('om','Selecione a chave privada','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); 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'); 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'; 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(){ async function cfgExport(){
try{ try{
const resp=await fetch(API+'/config/export',{headers:S.token?{'Authorization':'Bearer '+S.token}:{}}); const resp=await fetch(API+'/config/export',{headers:S.token?{'Authorization':'Bearer '+S.token}:{}});