diff --git a/README.md b/README.md index f399d62..79d6661 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ The platform combines security compliance scanning, AI-powered chat with **RAG ( - **Chat history**: ChatGPT-style session history with rename/delete, shared between Chat Agent and Terraform Agent - **Resizable split view**: chat on top (50%), files/plan/resources + terminal on bottom (50%) with drag-to-resize - Terraform CLI installed in container (v1.7.5) +- **Terraform Resource Reference (RAG-lite)**: auto-generated OCI provider resource catalog (~937 resources) injected into system prompt to prevent invalid resource type names — built from `terraform providers schema -json` at container build time, refreshable via API endpoint - Dedicated system prompt optimized for OCI Terraform provider best practices ### ⚡ OCI Resource Actions @@ -356,13 +357,14 @@ Allow group to read buckets in compartment ``` oci-cis-agent/ ├── backend/ -│ ├── app.py # FastAPI application (~4000 lines) +│ ├── app.py # FastAPI application (~4200 lines) │ ├── cis_reports.py # Oracle CIS Benchmark checker (6660 lines, report engine) │ ├── mcp_cis_server.py # MCP server with 12 granular CIS tools (~700 lines) +│ ├── gen_tf_reference.py # OCI Terraform provider resource catalog generator │ ├── Dockerfile # Python 3.12 + OCI CLI + Terraform CLI │ └── requirements.txt # Dependencies ├── frontend/ -│ └── index.html # SPA with Oracle Cloud theme (~1950 lines) +│ └── index.html # SPA with Oracle Cloud theme (~1980 lines) ├── nginx/ │ └── default.conf # Reverse proxy config ├── docker-compose.yml # Orchestration @@ -514,6 +516,8 @@ oci-cis-agent/ | GET | `/api/terraform/workspaces/{wid}/download` | Download workspace `.tf` files | | POST | `/api/terraform/workspaces/{wid}/cancel` | Cancel running Terraform operation | | DELETE | `/api/terraform/workspaces/{wid}` | Delete workspace | +| POST | `/api/terraform/refresh-reference` | Regenerate OCI Terraform resource reference | +| GET | `/api/terraform/reference-status` | Check resource reference availability | ### Chat & Reports @@ -622,7 +626,7 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the " | Version | Date | Changes | |---------|------|---------| -| **v2.1** | 2026-03 | Terraform Agent (AI-powered IaC generation with plan/apply/destroy lifecycle, workspace management, Terraform CLI in container, inline file editor, split-panel terminal, multi-file generation via `// filename:` markers), ChatGPT-style chat history for both Chat Agent and Terraform Agent (rename/delete sessions), OCI Resource Actions (start/stop instances and Autonomous DBs from Resources panel), OCI Explorer expanded to 40+ resource types across 8 categories with tree-view navigation and resizable panels, compartment filtering for MCP CIS scans, chat sidebar with model parameters, chat audit logs, collection error tracking in MCP server, tool timeout increased to 30min with auto-retry, memory compaction tuned (6K threshold, 20 recent messages), model catalog trimmed from 69 to 15 curated models, improved GenAI response extraction with fallback for empty responses, fixed layout overflow (no page scroll) | +| **v2.1** | 2026-03 | Terraform Agent (AI-powered IaC generation with plan/apply/destroy lifecycle, workspace management, Terraform CLI in container, inline file editor, split-panel terminal, multi-file generation via `// filename:` markers), **Terraform Resource Reference** (auto-generated OCI provider resource catalog from `terraform providers schema -json`, injected into system prompt to prevent invalid resource types, refreshable via API), ChatGPT-style chat history for both Chat Agent and Terraform Agent (rename/delete sessions), OCI Resource Actions (start/stop instances and Autonomous DBs from Resources panel), OCI Explorer expanded to 40+ resource types across 8 categories with tree-view navigation and resizable panels, compartment filtering for MCP CIS scans, chat sidebar with model parameters, chat audit logs, collection error tracking in MCP server, tool timeout increased to 30min with auto-retry, memory compaction tuned (6K threshold, 20 recent messages), model catalog trimmed from 69 to 15 curated models, improved GenAI response extraction with fallback for empty responses, fixed layout overflow (no page scroll), provider alias detection with brace-matching parser, multi-region Terraform support | | **v2.0** | 2026-03 | Async background chat processing (no more 504 timeouts), frontend polling with timestamps, 8 uvicorn workers + 16-thread chat executor for ~12 simultaneous chats, parallelized MCP data collection (5-thread base + 8-thread regional), 2-hour MCP session cache, 5-min tool timeout, full dead code cleanup across backend/frontend/MCP | | **v1.9** | 2026-03 | Multimodal chat (image/PDF/text file upload with OCI GenAI ImageContent/DocumentContent), region-specific MCP scanning (`regions` param on all scan tools), orphaned report auto-detection on progress poll, nginx timeout increased to 15min, improved API error handling for non-JSON responses | | **v1.8** | 2026-03 | CIS Engine auto-update from Oracle GitHub with automatic patch reapplication, version check UI card (admin), new `/api/cis-engine/*` endpoints, report file listing and individual download endpoints, reorganized Reports tab (execution history + status) and Downloads tab (file browser only with expandable cards per report), CIS Level description tooltip, persistent log expand during report generation | diff --git a/backend/Dockerfile b/backend/Dockerfile index 39a488b..150b8d8 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -19,10 +19,14 @@ RUN pip install --no-cache-dir -r requirements.txt && \ COPY app.py . COPY cis_reports.py . COPY mcp_cis_server.py . +COPY gen_tf_reference.py . # Data volume RUN mkdir -p /data +# Generate OCI Terraform resource reference at build time +RUN python gen_tf_reference.py || echo "WARN: tf reference generation skipped (terraform init may need network)" + EXPOSE 8000 CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "8"] diff --git a/backend/app.py b/backend/app.py index d213044..4ecb88e 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1955,6 +1955,14 @@ TF_DEFAULT_SYSTEM_PROMPT = """Você é um agente especializado EXCLUSIVAMENTE em - Cada bloco `hcl` vira um arquivo separado no sistema. - Se o usuário NÃO especificar, gere tudo em um único bloco. +### REGRA CRÍTICA: Unicidade de Recursos +- **NUNCA** declare o mesmo recurso (mesmo tipo + mesmo nome) em mais de um arquivo. +- Cada `resource "tipo" "nome"` deve existir em EXATAMENTE UM arquivo. +- Se um recurso precisa de atributos definidos em outro arquivo (ex: route_table_id, security_list_ids), defina TODOS os atributos na declaração ÚNICA do recurso, referenciando recursos de outros arquivos. +- Exemplo ERRADO: declarar `resource "oci_core_subnet" "app"` em `networking.tf` e redeclarar em `routing.tf`. +- Exemplo CORRETO: declarar a subnet UMA VEZ em `networking.tf` com `route_table_id = oci_core_route_table.app_rt.id` referenciando a route table de `routing.tf`. +- Antes de gerar o código, planeje quais recursos vão em cada arquivo para evitar duplicações. + ### Contexto OCI - Use as informações de tenancy, região e compartment fornecidas no contexto. - Use `var.compartment_id` como default para compartment_id nos recursos. @@ -2000,8 +2008,68 @@ TF_DEFAULT_SYSTEM_PROMPT = """Você é um agente especializado EXCLUSIVAMENTE em - Se o pedido for ambíguo, faça perguntas de esclarecimento. - NÃO sugira rodar comandos terraform — o sistema cuida disso. - Inclua outputs úteis (IPs, OCIDs dos recursos criados). +- SEMPRE declare `variable "region"` no variables.tf (o sistema injeta via terraform.tfvars). + +### Nomes CORRETOS de recursos OCI (ERROS COMUNS) +Abaixo estão os nomes corretos de resource types do provider oracle/oci. O modelo DEVE usar exatamente estes nomes: + +- **Network Firewall**: + - `oci_network_firewall_network_firewall` (NÃO `oci_network_firewall`) + - `oci_network_firewall_network_firewall_policy` (NÃO `oci_network_firewall_policy`) +- **DRG Attachment Management**: + - `oci_core_drg_attachment_management` (NÃO `oci_core_vcn_drg_attachment_management`) + - `oci_core_drg_attachment` — para criar attachments normais +- **DRG Route Distribution Statement** (`oci_core_drg_route_distribution_statement`): + - NÃO use `attachment_id` diretamente. Use o bloco `match_criteria`: + ```hcl + match_criteria { + match_type = "DRG_ATTACHMENT_ID" + drg_attachment_id = oci_core_drg_attachment.example.id + } + ``` +- **Remote Peering Connection**: `oci_core_remote_peering_connection` +- **DRG Route Table**: `oci_core_drg_route_table` +- **DRG Route Distribution**: `oci_core_drg_route_distribution` +- **DRG Route Table Route Rule**: `oci_core_drg_route_table_route_rule` """ +# ── Terraform OCI Resource Reference (generated at build time, updatable at runtime) ── +_TF_RESOURCE_REF_PATH = "/data/oci_tf_resource_reference.txt" +_tf_resource_ref_cache: dict = {"content": None, "mtime": 0} + +def _load_tf_resource_reference() -> str: + """Load the OCI Terraform resource reference file, cached by mtime.""" + try: + p = Path(_TF_RESOURCE_REF_PATH) + if not p.exists(): + return "" + mtime = p.stat().st_mtime + if _tf_resource_ref_cache["content"] and _tf_resource_ref_cache["mtime"] == mtime: + return _tf_resource_ref_cache["content"] + content = p.read_text() + _tf_resource_ref_cache["content"] = content + _tf_resource_ref_cache["mtime"] = mtime + log.info(f"Loaded TF resource reference: {len(content)} chars, {content.count(chr(10))} lines") + return content + except Exception as e: + log.warning(f"Failed to load TF resource reference: {e}") + return "" + +def _regenerate_tf_reference() -> dict: + """Regenerate the OCI Terraform resource reference file.""" + try: + result = subprocess.run( + ["python", "/app/gen_tf_reference.py"], + capture_output=True, text=True, timeout=300 + ) + _tf_resource_ref_cache["content"] = None # invalidate cache + if result.returncode == 0: + ref = _load_tf_resource_reference() + return {"ok": True, "output": result.stdout.strip(), "lines": ref.count('\n')} + return {"ok": False, "error": result.stderr.strip()} + except Exception as e: + return {"ok": False, "error": str(e)} + def _get_adb_connection(cfg: dict): """Create an oracledb connection from an adb_vector_configs row.""" import oracledb @@ -3120,6 +3188,25 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c except Exception as e: log.warning(f"Failed to inject terraform resource context: {e}") + # ── Inject OCI Terraform resource reference for correct resource names ── + if agent_type == "terraform": + tf_ref = _load_tf_resource_reference() + if tf_ref: + # Extract only the categorized sections (not the full 900+ resource list) + # to keep prompt size manageable + sections = [] + for line in tf_ref.split('\n'): + if line.startswith('## All Resource Types'): + break + sections.append(line) + ref_compact = '\n'.join(sections).strip() + if ref_compact: + cfg_dict["system_prompt"] = cfg_dict.get("system_prompt", "") + \ + "\n\n### Referência de Recursos OCI Terraform (gerado do provider schema)\n" + \ + "Use EXATAMENTE estes nomes de resource types. Se o recurso não estiver nesta lista, ele NÃO EXISTE no provider.\n\n" + \ + ref_compact + log.info(f"Terraform: injected resource reference ({len(ref_compact)} chars)") + mcp_tools = [] tool_defs = None if msg.use_tools: @@ -3676,6 +3763,26 @@ async def tf_delete_workspace(wid: str, u=Depends(current_user)): return {"ok": True} +@app.post("/api/terraform/refresh-reference") +async def tf_refresh_reference(u=Depends(current_user)): + """Regenerate the OCI Terraform resource reference from provider schema.""" + loop = asyncio.get_event_loop() + result = await loop.run_in_executor(_chat_executor, _regenerate_tf_reference) + return result + + +@app.get("/api/terraform/reference-status") +async def tf_reference_status(u=Depends(current_user)): + """Check if OCI Terraform resource reference is available.""" + p = Path(_TF_RESOURCE_REF_PATH) + if p.exists(): + content = _load_tf_resource_reference() + lines = content.count('\n') if content else 0 + mtime = datetime.fromtimestamp(p.stat().st_mtime).isoformat() + return {"available": True, "lines": lines, "updated_at": mtime} + return {"available": False} + + def _write_tf_files(wdir: Path, tf_code: str): """Parse tf_code for '// filename: xxx.tf' markers and write separate files.""" import re as _re @@ -3709,11 +3816,81 @@ async def _terraform_exec(wid: str, action: str, user: dict): wdir = TERRAFORM_DIR / wid wdir.mkdir(parents=True, exist_ok=True) + # Clean old .tf files (keep .terraform/, state, lock) + for old_tf in wdir.glob("*.tf"): + old_tf.unlink() + # Write .tf files — parse // filename: comments to split into separate files _write_tf_files(wdir, ws["tf_code"] or "") # Auto-generate provider.tf from OCI config + # Detect provider aliases declared by the model in generated files + import re as _re2 oci_cfg = _get_oci_config(ws["oci_config_id"]) + alias_blocks = [] # list of (alias_name, region_ref) + + # Scan all .tf files for provider "oci" { alias = "..." ... region = ... } + provider_block_re = _re2.compile(r'provider\s+"oci"\s*\{', _re2.MULTILINE) + for tf_file in sorted(wdir.glob("*.tf")): + if tf_file.name == "provider.tf": + continue + content = tf_file.read_text() + # Find each provider "oci" block and extract alias + region + blocks_to_remove = [] + for m in provider_block_re.finditer(content): + start = m.start() + # Find matching closing brace + depth = 0 + end = start + for ci in range(m.end() - 1, len(content)): + if content[ci] == '{': + depth += 1 + elif content[ci] == '}': + depth -= 1 + if depth == 0: + end = ci + 1 + break + block = content[start:end] + alias_m = _re2.search(r'alias\s*=\s*"([^"]+)"', block) + region_m = _re2.search(r'region\s*=\s*(.+)', block) + if alias_m: + alias_name = alias_m.group(1) + region_ref = region_m.group(1).strip().rstrip("}").strip() if region_m else '"unknown"' + alias_blocks.append((alias_name, region_ref)) + blocks_to_remove.append((start, end)) + # Remove model-generated provider blocks (we centralize in provider.tf) + if blocks_to_remove: + new_content = content + for s, e in reversed(blocks_to_remove): + new_content = new_content[:s] + new_content[e:] + # Also remove leading comments right before removed blocks + new_content = _re2.sub(r'\n(//[^\n]*\n){1,5}\s*\n', '\n\n', new_content) + new_content = new_content.strip() + if new_content: + tf_file.write_text(new_content + "\n") + else: + tf_file.unlink() # Remove empty file + + # Also scan for provider refs in resource blocks (provider = oci.xxx) + for tf_file in sorted(wdir.glob("*.tf")): + if tf_file.name == "provider.tf": + continue + content = tf_file.read_text() + for ref_m in _re2.finditer(r'provider\s*=\s*oci\.(\w+)', content): + ref_alias = ref_m.group(1) + if ref_alias not in [a[0] for a in alias_blocks]: + alias_blocks.append((ref_alias, '"unknown"')) + + cred_block = f''' tenancy_ocid = "{oci_cfg.get('tenancy', '')}" + user_ocid = "{oci_cfg.get('user', '')}" + fingerprint = "{oci_cfg.get('fingerprint', '')}" + private_key_path = "{oci_cfg.get('key_file', '')}"''' + + # Use region from OCI config row (may differ from oci sdk config) + with db() as c: + oci_row = c.execute("SELECT region FROM oci_configs WHERE id=?", (ws["oci_config_id"],)).fetchone() + primary_region = oci_row["region"] if oci_row else oci_cfg.get("region", "") + provider_tf = f'''terraform {{ required_providers {{ oci = {{ @@ -3723,11 +3900,21 @@ async def _terraform_exec(wid: str, action: str, user: dict): }} provider "oci" {{ - tenancy_ocid = "{oci_cfg.get('tenancy', '')}" - user_ocid = "{oci_cfg.get('user', '')}" - fingerprint = "{oci_cfg.get('fingerprint', '')}" - private_key_path = "{oci_cfg.get('key_file', '')}" - region = "{oci_cfg.get('region', '')}" +{cred_block} + region = "{primary_region}" +}} +''' + # Add alias providers with same credentials + seen_aliases = set() + for alias_name, region_ref in alias_blocks: + if alias_name in seen_aliases: + continue + seen_aliases.add(alias_name) + provider_tf += f''' +provider "oci" {{ + alias = "{alias_name}" +{cred_block} + region = {region_ref} }} ''' (wdir / "provider.tf").write_text(provider_tf) diff --git a/backend/gen_tf_reference.py b/backend/gen_tf_reference.py new file mode 100644 index 0000000..38f622d --- /dev/null +++ b/backend/gen_tf_reference.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Generate OCI Terraform provider resource reference from provider schema.""" +import json, sys, subprocess, os + +def main(): + schema_dir = "/tmp/tf_schema" + os.makedirs(schema_dir, exist_ok=True) + + # Write minimal tf config + with open(f"{schema_dir}/main.tf", "w") as f: + f.write('terraform {\n required_providers {\n oci = {\n source = "oracle/oci"\n }\n }\n}\n') + + # Init + subprocess.run(["terraform", f"-chdir={schema_dir}", "init", "-input=false", "-no-color"], + capture_output=True, timeout=120) + + # Get schema + result = subprocess.run(["terraform", f"-chdir={schema_dir}", "providers", "schema", "-json"], + capture_output=True, text=True, timeout=60) + data = json.loads(result.stdout) + + oci = data["provider_schemas"].get("registry.terraform.io/oracle/oci", {}) + res = oci.get("resource_schemas", {}) + ds = oci.get("data_source_schemas", {}) + + important_prefixes = [ + "oci_core_", "oci_network_firewall_", "oci_network_load_balancer_", + "oci_load_balancer_", "oci_database_", "oci_identity_", + "oci_objectstorage_", "oci_containerengine_", "oci_functions_", + "oci_dns_", "oci_file_storage_", "oci_kms_", "oci_vault_", + "oci_bastion_", "oci_waf_", "oci_certificates_", + ] + + lines = [] + lines.append("# OCI Terraform Provider — Resource Reference") + lines.append(f"# Total: {len(res)} resources, {len(ds)} data sources") + lines.append("") + + for prefix in important_prefixes: + cat_resources = {k: v for k, v in sorted(res.items()) if k.startswith(prefix)} + if not cat_resources: + continue + cat_name = prefix.rstrip("_").replace("oci_", "").replace("_", " ").title() + lines.append(f"## {cat_name}") + for rname, rschema in cat_resources.items(): + attrs = rschema.get("block", {}).get("attributes", {}) + required = [a for a, v in attrs.items() if v.get("required")] + block_types = list(rschema.get("block", {}).get("block_types", {}).keys()) + parts = [f"- **{rname}**"] + if required: + parts.append(f" required: {', '.join(required)}") + if block_types: + parts.append(f" blocks: {', '.join(block_types)}") + lines.append("".join(parts)) + lines.append("") + + # Also generate a flat list of ALL resource names for quick lookup + lines.append("## All Resource Types (full list)") + for rname in sorted(res.keys()): + lines.append(f"- {rname}") + + output = "/data/oci_tf_resource_reference.txt" + with open(output, "w") as f: + f.write("\n".join(lines)) + + print(f"Generated {len(lines)} lines -> {output}") + print(f"Resources: {len(res)}, Data Sources: {len(ds)}") + +if __name__ == "__main__": + main() diff --git a/frontend/index.html b/frontend/index.html index 8bf08e0..d782531 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -129,6 +129,7 @@ tbody tr:last-child td{border-bottom:none} .ch-hp-ttl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .ch-hp-del{opacity:0;transition:opacity .15s;cursor:pointer;color:var(--t4);flex-shrink:0;display:flex;align-items:center;padding:2px} .ch-hp-del:hover{color:var(--rd)} +.ch-hp-time{font-size:.58rem;color:var(--t4);flex-shrink:0;font-variant-numeric:tabular-nums} .ch-hp-item:hover .ch-hp-del{opacity:1} /* ── Chat ── */ @@ -370,13 +371,34 @@ async function loadData(){try{ loadHistory('chat'); }catch(e){console.error(e)}} function R(){try{ - // Preserve textarea values before re-render + // Preserve inputs const chiEl=document.getElementById('chi');if(chiEl)S._chiVal=chiEl.value; const tfiEl=document.getElementById('tfi');if(tfiEl)S._tfiVal=tfiEl.value; - document.getElementById('app').innerHTML=S.user?rApp():rLogin(); - // Restore textarea values after re-render + const edEl=document.getElementById('tfEdTa');if(edEl)S._tfEdVal=edEl.value; + // Preserve scroll positions + const scrollIds=['chm','tfchm','tfBottom','tfTermOut','pg']; + const scrollMap={}; + scrollIds.forEach(id=>{const el=document.getElementById(id);if(el)scrollMap[id]=el.scrollTop}); + const bcLeft=document.querySelector('.tf-bc-left');if(bcLeft)scrollMap._bcLeft=bcLeft.scrollTop; + + const appEl=document.getElementById('app'); + if(!S.user){appEl.innerHTML=rLogin();return} + // Partial update: only update page content + sidebar + const pgEl=document.getElementById('pg'); + if(pgEl){ + pgEl.innerHTML=rPg(); + const sbEl=document.querySelector('.nav');if(sbEl){const tmp=document.createElement('div');tmp.innerHTML=rSb();const nn=tmp.querySelector('.nav');if(nn)sbEl.innerHTML=nn.innerHTML} + }else{ + appEl.innerHTML=rApp(); + } + // Restore inputs const chi2=document.getElementById('chi');if(chi2&&S._chiVal){chi2.value=S._chiVal;autoGrow(chi2)} const tfi2=document.getElementById('tfi');if(tfi2&&S._tfiVal){tfi2.value=S._tfiVal;autoGrow(tfi2)} + const ed2=document.getElementById('tfEdTa');if(ed2&&S._tfEdVal)ed2.value=S._tfEdVal; + // Restore scroll positions + requestAnimationFrame(()=>{ + Object.entries(scrollMap).forEach(([id,top])=>{if(id==='_bcLeft'){const el=document.querySelector('.tf-bc-left');if(el)el.scrollTop=top}else{const el=document.getElementById(id);if(el)el.scrollTop=top}}); + }); if(S.editing?.type==='mcp')setTimeout(mcpTypeFields,0) }catch(e){console.error('Render error:',e);document.getElementById('app').innerHTML='

Render Error

'+e.message+'
'}} function switchTab(t){S.tab=t;R();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()} @@ -396,7 +418,7 @@ function rLogin(){return`
Padrão: admin / admin123
`} /* ── App Shell ── */ -function rApp(){return`
${rSb()}
${rTb()}
${rPg()}
`} +function rApp(){return`
${rSb()}
${rTb()}
${rPg()}
`} const TF_IC=''; function rSb(){ @@ -589,7 +611,7 @@ async function loadSession(sid,type){ if(type==='chat'){ S.sid=sid;S.msgs=d.messages.map(m=>({r:m.role,c:m.content,t:m.created_at?.slice(11,16)||''}));R();scCh(); }else{ - S.tfSid=sid;S.tfMsgs=d.messages.map(m=>({r:m.role,c:m.content})); + S.tfSid=sid;S.tfMsgs=d.messages.map(m=>({r:m.role,c:m.content,t:m.created_at?.slice(11,16)||''})); S.tfFiles=[];S.tfCode='';S.tfPlan=[]; const last=[...d.messages].reverse().find(m=>m.role==='assistant'&&m.content&&m.content.includes('```')); if(last)fmTf(last.content); @@ -628,8 +650,10 @@ function rHistList(type,hist,curSid){ if(g!==lastGroup){lastGroup=g;html+=`
${g}
`} const active=s.id===curSid?' active':''; const title=s.title||'Nova conversa'; + const ts=s.updated_at?new Date(s.updated_at+'Z').toLocaleTimeString('pt-BR',{hour:'2-digit',minute:'2-digit'}):''; html+=`
${escHtml(title)} + ${ts?`${ts}`:''} @@ -699,7 +723,7 @@ function tfCopyBlock(i){const b=S.tfFiles[i];if(b)navigator.clipboard.writeText( function rTerraform(){ const ms=S.tfMsgs.length===0 ?`
${TF_ICON}

Descreva a infraestrutura OCI desejada

Ex: "Crie uma VCN com subnets pública e privada, internet gateway e um compute instance"

` - :S.tfMsgs.map(m=>`
${m.r==='assistant'?fmTf(m.c):fm(m.c)}
`).join(''); + :S.tfMsgs.map(m=>`
${m.r==='assistant'?fmTf(m.c):fm(m.c)}
${m.t?`
${m.t}
`:''}
`).join(''); // Model dropdown 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})} @@ -728,8 +752,8 @@ function rTerraform(){
${TF_ICON} -
${curLabel}
- ${S.tfMdOpen?`
${ddItems}
`:''} +
${curLabel}
+
${ddItems}
${ociSel}${compSel} ${stBadge} @@ -746,15 +770,15 @@ function rTerraform(){
-
+
Files${S.tfFiles.length?' ('+S.tfFiles.length+')':''}
-
+
Plan${S.tfPlan.length?' ('+S.tfPlan.length+')':''}
-
+
Resources${S.tfResources?(' ('+Object.values(S.tfResources).reduce((a,b)=>a+(Array.isArray(b)?b.length:0),0)+')'):''}
@@ -964,6 +988,16 @@ async function ociStartInstance(id){if(!confirm('Iniciar esta instância?'))retu async function ociStopInstance(id){if(!confirm('Parar esta instância?'))return;try{await $api('/oci/instances/'+id+'/action',{method:'POST',body:{action:'STOP',oci_config_id:_ociResOid(),region:S.tfRegion||null}});alert('Instância parando...');tfLoadResources()}catch(e){alert('Erro: '+e.message)}} async function ociStartAdb(id){if(!confirm('Iniciar este Autonomous Database?'))return;try{await $api('/oci/autonomous-databases/'+id+'/action',{method:'POST',body:{action:'start',oci_config_id:_ociResOid(),region:S.tfRegion||null}});alert('Autonomous DB iniciando...');tfLoadResources()}catch(e){alert('Erro: '+e.message)}} async function ociStopAdb(id){if(!confirm('Parar este Autonomous Database?'))return;try{await $api('/oci/autonomous-databases/'+id+'/action',{method:'POST',body:{action:'stop',oci_config_id:_ociResOid(),region:S.tfRegion||null}});alert('Autonomous DB parando...');tfLoadResources()}catch(e){alert('Erro: '+e.message)}} +function tfSwitchBtab(tab){ + S.tfBtab=tab;S.tfEditIdx=-1; + if(tab==='resources'&&!S.tfResources&&S.tfCompartment)tfLoadResources(); + // Update only bottom panel content + tab active states + const left=document.querySelector('.tf-bc-left');if(left)left.innerHTML=rTfBottomContent(); + const tabs=document.querySelectorAll('.tf-btab');tabs.forEach(t=>{const isActive=t.textContent.trim().toLowerCase().startsWith(tab);t.classList.toggle('active',isActive)}); + // Update actions + const acts=document.querySelector('.tf-bottom-actions');if(acts){acts.innerHTML=(S.tfFiles.length?'':'')+rTfActions()} +} +function tfToggleModelDrop(){const dd=document.getElementById('tfmdd');dd.classList.toggle('open');S.tfMdOpen=dd.classList.contains('open');if(S.tfMdOpen){const inp=document.getElementById('tfmds');if(inp){inp.value='';inp.focus();tfFilterModels('')}}} function tfFilterModels(q){const list=document.getElementById('tfmdl');if(!list)return;const items=list.querySelectorAll('.itm,.grp');const ql=q.toLowerCase();let lastGrp=null,vis=false;items.forEach(el=>{if(el.classList.contains('grp')){if(lastGrp)lastGrp.style.display=vis?'':'none';lastGrp=el;vis=false}else{const show=!q||el.textContent.toLowerCase().includes(ql);el.style.display=show?'':'none';if(show)vis=true}});if(lastGrp)lastGrp.style.display=vis?'':'none'} function tfTogglePanel(p){S.tfPanel=S.tfPanel===p?'':p;R()} async function tfClear(){if(S.tfSid)try{await $api('/chat/'+S.tfSid,{method:'DELETE'})}catch(e){}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.tfConfirmDest=false;S.tfComps=[];S.tfCompartment='';S.tfResources=null;S.tfResLoading=false;R()} @@ -973,35 +1007,35 @@ function dlTfCode(){if(!S.tfFiles.length)return;if(S.tfFiles.length===1){const f async function tfSend(){ const el=document.getElementById('tfi');const m=el.value.trim();if(!m)return;el.style.height='auto';S._tfiVal=''; - if(!S.tfModel){S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione um modelo antes de enviar.'});R();return} - if(!S.tfCompartment){S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione um compartment antes de enviar.'});R();return} - el.value='';S.tfMsgs.push({r:'user',c:m});R();tfScroll(); + if(!S.tfModel){S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione um modelo antes de enviar.',t:tstamp()});R();return} + if(!S.tfCompartment){S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione um compartment antes de enviar.',t:tstamp()});R();return} + el.value='';S.tfMsgs.push({r:'user',c:m,t:tstamp()});R();tfScroll(); S.tfMsgs.push({r:'assistant',c:'⏳ _Gerando Terraform..._',thinking:true});R();tfScroll(); try{ const body={message:m,session_id:S.tfSid,use_tools:false}; if(S.tfModel.startsWith('cfg:'))body.genai_config_id=S.tfModel.slice(4); else{ - if(!S.tfOci){S.tfMsgs.pop();S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione uma credencial OCI.'});R();return} + if(!S.tfOci){S.tfMsgs.pop();S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione uma credencial OCI.',t:tstamp()});R();return} body.model_id=S.tfModel;body.oci_config_id=S.tfOci;body.genai_region=S.tfRegion;body.compartment_id=S.tfCompartment; } const d=await $api('/terraform/chat',{method:'POST',body}); S.tfSid=d.session_id; if(S.tfHistOpen)loadHistory('terraform'); if(d.status==='processing'&&d.message_id){await tfPollResult(d.message_id)} - else{S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:d.response||d.content||'Sem resposta'});R();tfScroll()} - }catch(e){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'❌ Erro: '+e.message});R()} + 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:'❌ Erro: '+e.message,t:tstamp()});R()} } async function tfPollResult(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.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:r.content});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.status==='failed'){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'❌ '+r.content});R();tfScroll();return} + if(r.status==='failed'){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'❌ '+r.content,t:tstamp()});R();tfScroll();return} }catch(e){} } - S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'⏰ Timeout.'});R() + S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'⏰ Timeout.',t:tstamp()});R() } async function tfSaveAndPlan(){