From 8bccf9367e37e62d0c062a8d7a96b5d3f32c9c0d Mon Sep 17 00:00:00 2001 From: nogueiraguh Date: Sun, 8 Mar 2026 00:15:10 -0300 Subject: [PATCH] feat: chat history, inline file editor, split terminal, OCI resource actions, and layout fixes - Add ChatGPT-style chat history for Chat Agent and Terraform Agent (sessions, rename, delete) - Add inline .tf file editor with Tab support and auto-sync to workspace code - Add split-panel terminal showing Plan/Apply/Destroy output alongside files/plan/resources - Add OCI resource actions: start/stop Compute instances and Autonomous DBs from Resources panel - Add multi-file Terraform generation via // filename: markers - Fix GenAI empty response handling (GPT-5.2 fallback message) - Fix compartment select visibility, Terraform code block light theme - Fix page layout overflow (no more scroll beyond viewport) - Add DB migration for terraform_workspaces.compartment_id column - Update README with new features, API endpoints, and version notes --- README.md | 29 ++++- backend/app.py | 260 +++++++++++++++++++++++++++++++++++-- frontend/index.html | 305 +++++++++++++++++++++++++++++++++----------- 3 files changed, 500 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index a15a7eb..f399d62 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,20 @@ The platform combines security compliance scanning, AI-powered chat with **RAG ( - **Plan/Apply/Destroy lifecycle**: full Terraform workflow with real-time output tracking - **File management**: view generated `.tf` files, copy, download individually or as bundle - **Confirmation modal**: type "DESTROY" to confirm resource destruction -- **Resizable split view**: chat on top, files/plan/resources/output tabs on bottom with drag-to-resize +- **Inline file editor**: click any generated `.tf` file to edit in-place with monospace editor, Tab support, and save — auto-syncs with workspace code +- **Split-panel terminal**: real-time Plan/Apply/Destroy output in a dark terminal panel alongside files/plan/resources (always visible) +- **Multi-file generation**: AI generates separate `.tf` files via `// filename:` markers (main.tf, variables.tf, outputs.tf, etc.) +- **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) - Dedicated system prompt optimized for OCI Terraform provider best practices +### ⚡ OCI Resource Actions +- **Start/Stop Compute Instances** directly from the Resources panel with one click +- **Start/Stop Autonomous Databases** with confirmation prompt +- Lifecycle state badges (RUNNING/STOPPED/AVAILABLE) with color indicators +- Actions audited in the audit log + ### 🔍 OCI Account Explorer - **Tree-view navigation** with resizable side panel and drag handle - **40+ resource types** across 8 categories: @@ -346,13 +356,13 @@ Allow group to read buckets in compartment ``` oci-cis-agent/ ├── backend/ -│ ├── app.py # FastAPI application (~3800 lines) +│ ├── app.py # FastAPI application (~4000 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) │ ├── Dockerfile # Python 3.12 + OCI CLI + Terraform CLI │ └── requirements.txt # Dependencies ├── frontend/ -│ └── index.html # SPA with Oracle Cloud theme (~1780 lines) +│ └── index.html # SPA with Oracle Cloud theme (~1950 lines) ├── nginx/ │ └── default.conf # Reverse proxy config ├── docker-compose.yml # Orchestration @@ -429,6 +439,13 @@ oci-cis-agent/ | GET | `/api/oci/explore/{id}/notification_topics` | List Notification Topics | | GET | `/api/oci/explore/{id}/events_rules` | List Events Rules | +### OCI Resource Actions + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/oci/instances/{id}/action` | Start/Stop compute instance | +| POST | `/api/oci/autonomous-databases/{id}/action` | Start/Stop Autonomous Database | + ### Generative AI | Method | Endpoint | Description | @@ -504,6 +521,10 @@ oci-cis-agent/ |--------|----------|-------------| | POST | `/api/chat` | Send message (with RAG + MCP tool use, accepts `use_tools` flag) | | POST | `/api/chat/upload` | Send message with file attachments (multipart, images/PDFs/text) | +| GET | `/api/chat/sessions` | List chat sessions (history) with agent type filter | +| GET | `/api/chat/sessions/{sid}/messages` | Get messages for a session | +| PUT | `/api/chat/sessions/{sid}/title` | Rename a chat session | +| DELETE | `/api/chat/{sid}` | Delete chat session and messages | | POST | `/api/reports/run` | Execute CIS report | | GET | `/api/reports` | List reports | | GET | `/api/reports/{id}/html` | View HTML report | @@ -601,7 +622,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), 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 | +| **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.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/app.py b/backend/app.py index bafdda5..d213044 100644 --- a/backend/app.py +++ b/backend/app.py @@ -14,7 +14,7 @@ from functools import partial from fastapi import ( FastAPI, HTTPException, Depends, Request, UploadFile, File, Form, - Query, BackgroundTasks + Query, Body, BackgroundTasks ) from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, FileResponse @@ -314,6 +314,14 @@ def init_db(): message TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS chat_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + agent_type TEXT NOT NULL DEFAULT 'chat', + title TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); CREATE TABLE IF NOT EXISTS terraform_workspaces ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, session_id TEXT NOT NULL, @@ -352,6 +360,11 @@ def init_db(): c.execute(f"ALTER TABLE chat_messages ADD COLUMN {col}") except sqlite3.OperationalError: pass + for col in ["compartment_id TEXT"]: + try: + c.execute(f"ALTER TABLE terraform_workspaces ADD COLUMN {col}") + except sqlite3.OperationalError: + pass # Migrate legacy table_name from adb_vector_configs into adb_vector_tables try: for cfg_row in c.execute("SELECT id, table_name FROM adb_vector_configs WHERE table_name IS NOT NULL AND table_name != ''").fetchall(): @@ -360,6 +373,19 @@ def init_db(): (str(uuid.uuid4()), cfg_row["id"], cfg_row["table_name"], "Migrado automaticamente")) except Exception: pass + # Backfill chat_sessions from existing chat_messages + try: + c.execute("""INSERT OR IGNORE INTO chat_sessions (id, user_id, agent_type, title, created_at, updated_at) + SELECT cm.session_id, cm.user_id, + CASE WHEN tw.id IS NOT NULL THEN 'terraform' ELSE 'chat' END, + SUBSTR((SELECT content FROM chat_messages cm2 WHERE cm2.session_id=cm.session_id AND cm2.role='user' ORDER BY cm2.created_at ASC LIMIT 1), 1, 80), + MIN(cm.created_at), MAX(cm.created_at) + FROM chat_messages cm + LEFT JOIN terraform_workspaces tw ON tw.session_id = cm.session_id + WHERE cm.session_id NOT IN (SELECT id FROM chat_sessions) + GROUP BY cm.session_id""") + except Exception: + pass # Seed default system prompt if none exists for chat agent if not c.execute("SELECT 1 FROM system_prompts WHERE agent='chat'").fetchone(): c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active) VALUES (?,?,?,?,?)", @@ -1798,11 +1824,61 @@ def _call_genai(gc: dict, message: str, history: list = None, tools: list = None }) # Preserve raw tool_calls for building assistant message in next iteration tool_calls_raw = choice.message.tool_calls - # Extract text + # Extract text from content if hasattr(choice.message, 'content') and choice.message.content: contents = choice.message.content - if contents and len(contents) > 0: - text = contents[0].text if hasattr(contents[0], 'text') else "" + if isinstance(contents, str): + text = contents + elif isinstance(contents, list): + text_parts = [] + for c in contents: + if isinstance(c, str): + text_parts.append(c) + elif hasattr(c, 'text') and c.text: + text_parts.append(c.text) + elif isinstance(c, dict) and c.get('text'): + text_parts.append(c['text']) + text = "\n".join(text_parts) + else: + text = str(contents) + # Fallback: check reasoning_content (some models like GPT-5.2 use this) + if not text and hasattr(resp, 'choices') and resp.choices: + choice = resp.choices[0] + if hasattr(choice, 'message') and choice.message: + msg_obj = choice.message + if hasattr(msg_obj, 'reasoning_content') and msg_obj.reasoning_content: + rc = msg_obj.reasoning_content + if isinstance(rc, str): + text = rc + elif isinstance(rc, list): + text_parts = [] + for c in rc: + if isinstance(c, str): + text_parts.append(c) + elif hasattr(c, 'text') and c.text: + text_parts.append(c.text) + elif isinstance(c, dict) and c.get('text'): + text_parts.append(c['text']) + text = "\n".join(text_parts) + else: + text = str(rc) + if text: + log.info(f"GenAI: extracted text from reasoning_content ({len(text)} chars)") + # Last resort: try choice.text + if not text: + if hasattr(choice, 'text') and choice.text: + text = choice.text + if not text and not tool_calls: + raw_content = getattr(getattr(resp.choices[0], 'message', None), 'content', None) if hasattr(resp, 'choices') and resp.choices else None + # Also dump message attrs for debugging + msg_attrs = [] + if hasattr(resp, 'choices') and resp.choices and hasattr(resp.choices[0], 'message') and resp.choices[0].message: + msg_attrs = [a for a in dir(resp.choices[0].message) if not a.startswith('_')] + reasoning = getattr(resp.choices[0].message, 'reasoning_content', None) if hasattr(resp, 'choices') and resp.choices and hasattr(resp.choices[0], 'message') else None + refusal = getattr(resp.choices[0].message, 'refusal', None) if hasattr(resp, 'choices') and resp.choices and hasattr(resp.choices[0], 'message') else None + finish_reason = getattr(resp.choices[0], 'finish_reason', None) if hasattr(resp, 'choices') and resp.choices else None + choice_attrs = [a for a in dir(resp.choices[0]) if not a.startswith('_')] if hasattr(resp, 'choices') and resp.choices else [] + log.warning(f"GenAI returned empty response. model={gc.get('model_id')}, finish_reason={finish_reason}, content_repr: {repr(raw_content)[:500]}, reasoning: {repr(reasoning)[:500] if reasoning else None}, refusal: {repr(refusal)[:200] if refusal else None}, choice_attrs: {choice_attrs}") return (text or "", tool_calls, tool_calls_raw) # ── RAG Helpers ─────────────────────────────────────────────────────────────── @@ -1859,12 +1935,30 @@ TF_DEFAULT_SYSTEM_PROMPT = """Você é um agente especializado EXCLUSIVAMENTE em ### Seu papel - Gerar código Terraform HCL production-ready usando o provider OCI (oracle/oci). - Sempre usar a sintaxe mais recente do OCI Terraform provider. -- NÃO incluir bloco provider/required_providers — ele é gerado automaticamente pelo sistema. + +### Multi-Região +- Quando o usuário solicitar recursos em MÚLTIPLAS regiões OCI, use **provider aliases**. +- NÃO inclua o bloco `terraform { required_providers }` nem o `provider "oci"` principal — estes são gerados automaticamente pelo sistema. +- Inclua APENAS os providers com alias para regiões adicionais: + ```hcl + provider "oci" { + alias = "secondary" + region = var.region_secondary + } + ``` +- Referencie o provider alias nos recursos: `provider = oci.secondary` +- NUNCA diga que não pode criar recursos em outra região. Use provider aliases para qualquer região OCI. + +### Múltiplos Arquivos +- Quando o usuário pedir "um arquivo por recurso" ou "arquivos separados", gere MÚLTIPLOS blocos `hcl` separados. +- Nomeie cada bloco com um comentário na primeira linha: `// filename: vcn_hub.tf`, `// filename: firewall.tf`, etc. +- Cada bloco `hcl` vira um arquivo separado no sistema. +- Se o usuário NÃO especificar, gere tudo em um único bloco. ### 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. -- Use `var.region` para região. +- Use `var.region` para região primária. ### Recursos existentes - Se o contexto incluir uma seção "RECURSOS OCI EXISTENTES NO COMPARTMENT", analise-a ANTES de gerar código. @@ -1878,7 +1972,7 @@ TF_DEFAULT_SYSTEM_PROMPT = """Você é um agente especializado EXCLUSIVAMENTE em // código aqui ``` -2. Após CADA bloco de código, forneça uma seção **Resource Plan** neste formato exato: +2. Após TODOS os blocos de código, forneça uma seção **Resource Plan** neste formato exato: ```plan + oci_core_vcn.main (Virtual Cloud Network) + oci_core_subnet.public (Subnet Pública) @@ -1889,6 +1983,15 @@ TF_DEFAULT_SYSTEM_PROMPT = """Você é um agente especializado EXCLUSIVAMENTE em 3. Explique o que cada recurso faz e como se conectam. 4. Quando o usuário pedir modificação, mostre o código COMPLETO atualizado (não apenas diffs). +### Validação +- Após gerar o código, faça uma validação automática verificando: + - Referências cruzadas entre recursos estão corretas + - Todos os CIDRs estão consistentes e não se sobrepõem indevidamente + - Security lists, route tables e gateways necessários estão presentes + - Dependências entre recursos estão na ordem correta + - Não há gaps de segurança óbvios (subnets sem security list, rotas faltando, etc.) +- Inclua uma seção "✅ Validação" no final da resposta com os itens verificados. + ### Diretrizes - Use variables para valores configuráveis (CIDRs, display names, shapes, etc.) - Inclua defaults sensatos nas definições de variáveis. @@ -1897,7 +2000,6 @@ 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). -- Gere tudo em um único bloco de código (variables + resources + outputs juntos). """ def _get_adb_connection(cfg: dict): @@ -2824,10 +2926,17 @@ async def report_dl(rid, fmt: str = Query("json"), u=Depends(current_user)): def _chat_start(msg: ChatMsg, u, attachments: list = None, agent_type: str = "chat"): """Start a chat: save user msg, resolve config, return (sid, mid, genai_cfg) or immediate response. If genai_cfg is None, returns immediate fallback response in mid field as dict.""" + is_new = not msg.session_id sid = msg.session_id or str(uuid.uuid4()) with db() as c: c.execute("INSERT INTO chat_messages (id,session_id,user_id,role,content,model_id,status) VALUES (?,?,?,?,?,?,?)", (str(uuid.uuid4()), sid, u["id"], "user", msg.message, None, "done")) + if is_new: + title = (msg.message or "Nova conversa")[:80].strip() + c.execute("INSERT OR IGNORE INTO chat_sessions (id,user_id,agent_type,title) VALUES (?,?,?,?)", + (sid, u["id"], agent_type, title)) + else: + c.execute("UPDATE chat_sessions SET updated_at=datetime('now') WHERE id=?", (sid,)) genai_cfg = None if msg.genai_config_id: @@ -2982,6 +3091,10 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c base_prompt = cfg_dict.get("system_prompt", "") cfg_dict["system_prompt"] = f"{base_prompt}\n\n{config_hint}" if base_prompt else config_hint + # ── Terraform agent: boost max_tokens for complex infra code ── + if agent_type == "terraform": + cfg_dict["max_tokens"] = max(int(cfg_dict.get("max_tokens", 6000)), 16000) + # ── Inject existing OCI resources for terraform agent ── if agent_type == "terraform" and active_oci_id: try: @@ -3086,6 +3199,11 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c tools_info = '\n\n🔧 **Tools utilizadas:** ' + ', '.join(tr["name"] for tr in all_tool_results) resp += tools_info + if not resp or not resp.strip(): + model_id = genai_cfg.get("model_id", "unknown") + resp = f"⚠️ O modelo **{model_id}** retornou uma resposta vazia. Isso pode ocorrer com alguns modelos. Tente novamente ou selecione outro modelo (ex: Gemini, Llama)." + log.warning(f"Chat {mid}: empty response from model {model_id}, returning fallback message") + with db() as c: c.execute("UPDATE chat_messages SET content=?, status='done' WHERE id=?", (resp, mid)) log.info(f"Chat {mid} completed successfully") @@ -3218,9 +3336,43 @@ def _agent_respond(msg, user): return ("Sou o **OCI CIS AI Agent v" + VERSION + "**. Sem modelo GenAI configurado, uso respostas locais.\n\n" "Para chat com IA:\n1. Configure **OCI Credentials**\n2. Configure **GenAI** com modelo e região\n3. Selecione o modelo no chat\n\nDigite **ajuda** para ver os comandos.") +@app.get("/api/chat/sessions") +async def list_chat_sessions(agent_type: str = "chat", limit: int = 50, u=Depends(current_user)): + with db() as c: + rows = c.execute( + """SELECT cs.id, cs.title, cs.agent_type, cs.created_at, cs.updated_at, + (SELECT COUNT(*) FROM chat_messages cm WHERE cm.session_id=cs.id) as message_count + FROM chat_sessions cs WHERE cs.user_id=? AND cs.agent_type=? + ORDER BY cs.updated_at DESC LIMIT ?""", + (u["id"], agent_type, limit)).fetchall() + return [dict(r) for r in rows] + + +@app.get("/api/chat/sessions/{sid}/messages") +async def get_session_messages(sid: str, u=Depends(current_user)): + with db() as c: + session = c.execute("SELECT * FROM chat_sessions WHERE id=? AND user_id=?", (sid, u["id"])).fetchone() + if not session: + raise HTTPException(404, "Session not found") + msgs = c.execute( + "SELECT id, role, content, model_id, status, created_at FROM chat_messages " + "WHERE session_id=? AND status='done' ORDER BY created_at ASC", (sid,)).fetchall() + return {"session": dict(session), "messages": [dict(m) for m in msgs]} + + +@app.put("/api/chat/sessions/{sid}/title") +async def rename_session(sid: str, req: dict = Body(...), u=Depends(current_user)): + with db() as c: + c.execute("UPDATE chat_sessions SET title=? WHERE id=? AND user_id=?", + (req.get("title", "")[:120], sid, u["id"])) + return {"ok": True} + + @app.delete("/api/chat/{sid}") async def clear_chat(sid, u=Depends(current_user)): - with db() as c: c.execute("DELETE FROM chat_messages WHERE session_id=? AND user_id=?", (sid, u["id"])) + with db() as c: + c.execute("DELETE FROM chat_messages WHERE session_id=? AND user_id=?", (sid, u["id"])) + c.execute("DELETE FROM chat_sessions WHERE id=? AND user_id=?", (sid, u["id"])) return {"ok": True} @@ -3252,13 +3404,13 @@ def _fetch_compartment_resources(oci_config_id: str, compartment_id: str, region try: compute = oci.core.ComputeClient(config) insts = compute.list_instances(compartment_id).data - resources["instances"] = [{"id": i.id, "display_name": i.display_name, "shape": i.shape, "state": i.lifecycle_state} for i in insts if i.lifecycle_state == "RUNNING"] + resources["instances"] = [{"id": i.id, "display_name": i.display_name, "shape": i.shape, "state": i.lifecycle_state} for i in insts if i.lifecycle_state in ("RUNNING", "STOPPED")] except Exception as e: log.warning(f"Failed to fetch compute resources: {e}") try: db_client = oci.database.DatabaseClient(config) adbs = db_client.list_autonomous_databases(compartment_id).data - resources["autonomous_databases"] = [{"id": a.id, "display_name": a.display_name, "db_name": a.db_name, "state": a.lifecycle_state, "is_free_tier": a.is_free_tier} for a in adbs if a.lifecycle_state == "AVAILABLE"] + resources["autonomous_databases"] = [{"id": a.id, "display_name": a.display_name, "db_name": a.db_name, "state": a.lifecycle_state, "is_free_tier": a.is_free_tier} for a in adbs if a.lifecycle_state in ("AVAILABLE", "STOPPED")] except Exception as e: log.warning(f"Failed to fetch database resources: {e}") try: @@ -3308,6 +3460,53 @@ def _build_resource_context(resources: dict) -> str: return "\n".join(lines) +@app.post("/api/oci/instances/{instance_id}/action") +async def oci_instance_action(instance_id: str, req: dict, u=Depends(current_user)): + action = req.get("action", "").upper() + oci_config_id = req.get("oci_config_id", "") + region = req.get("region") + if action not in ("START", "STOP"): + raise HTTPException(400, "Ação inválida. Use START ou STOP.") + if not oci_config_id: + raise HTTPException(400, "oci_config_id obrigatório") + try: + import oci + config = _get_oci_config(oci_config_id) + if region: + config["region"] = region + compute = oci.core.ComputeClient(config) + compute.instance_action(instance_id, action) + _audit(u["id"], u["username"], f"instance_{action.lower()}", instance_id) + return {"ok": True, "action": action, "instance_id": instance_id} + except Exception as e: + raise HTTPException(500, str(e)[:500]) + + +@app.post("/api/oci/autonomous-databases/{adb_id}/action") +async def oci_adb_action(adb_id: str, req: dict, u=Depends(current_user)): + action = req.get("action", "").lower() + oci_config_id = req.get("oci_config_id", "") + region = req.get("region") + if action not in ("start", "stop"): + raise HTTPException(400, "Ação inválida. Use start ou stop.") + if not oci_config_id: + raise HTTPException(400, "oci_config_id obrigatório") + try: + import oci + config = _get_oci_config(oci_config_id) + if region: + config["region"] = region + db_client = oci.database.DatabaseClient(config) + if action == "start": + db_client.start_autonomous_database(adb_id) + else: + db_client.stop_autonomous_database(adb_id) + _audit(u["id"], u["username"], f"adb_{action}", adb_id) + return {"ok": True, "action": action, "adb_id": adb_id} + except Exception as e: + raise HTTPException(500, str(e)[:500]) + + @app.get("/api/terraform/resources") async def tf_list_resources(oci_config_id: str = Query(...), compartment_id: str = Query(...), region: str = Query(None), u=Depends(current_user)): """List existing OCI resources in a compartment for terraform context.""" @@ -3430,6 +3629,23 @@ async def tf_download(wid: str, u=Depends(current_user)): r = c.execute("SELECT tf_code, name FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone() if not r or not r["tf_code"]: raise HTTPException(404, "No code found") from starlette.responses import Response + import re as _re + parts = _re.split(r'^//\s*filename:\s*(\S+)\s*$', r["tf_code"], flags=_re.MULTILINE) + if len(parts) >= 3: + import io, zipfile + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: + if parts[0].strip(): + zf.writestr("main.tf", parts[0].strip()) + for i in range(1, len(parts), 2): + fname = parts[i].strip().replace("/", "_").replace("..", "_") + content = parts[i + 1].strip() if i + 1 < len(parts) else "" + if fname and content: + zf.writestr(fname, content) + buf.seek(0) + ws_name = r["name"] or "terraform" + return Response(content=buf.read(), media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{ws_name}.zip"'}) return Response(content=r["tf_code"], media_type="text/plain", headers={"Content-Disposition": f'attachment; filename="main.tf"'}) @@ -3460,6 +3676,24 @@ async def tf_delete_workspace(wid: str, u=Depends(current_user)): return {"ok": True} +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 + parts = _re.split(r'^//\s*filename:\s*(\S+)\s*$', tf_code, flags=_re.MULTILINE) + if len(parts) >= 3: + # parts = [preamble, filename1, content1, filename2, content2, ...] + if parts[0].strip(): + (wdir / "main.tf").write_text(parts[0].strip()) + for i in range(1, len(parts), 2): + fname = parts[i].strip() + content = parts[i + 1].strip() if i + 1 < len(parts) else "" + if fname and content: + safe_name = fname.replace("/", "_").replace("..", "_") + (wdir / safe_name).write_text(content) + else: + (wdir / "main.tf").write_text(tf_code) + + async def _terraform_exec(wid: str, action: str, user: dict): """Background: run terraform init + plan/apply/destroy in workspace dir.""" log.info(f"Terraform {action} started: wid={wid}") @@ -3475,8 +3709,8 @@ async def _terraform_exec(wid: str, action: str, user: dict): wdir = TERRAFORM_DIR / wid wdir.mkdir(parents=True, exist_ok=True) - # Write main.tf - (wdir / "main.tf").write_text(ws["tf_code"] or "") + # 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 oci_cfg = _get_oci_config(ws["oci_config_id"]) diff --git a/frontend/index.html b/frontend/index.html index 7871fb6..8bf08e0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -27,11 +27,11 @@ --trans:cubic-bezier(.22,1,.36,1) } *{margin:0;padding:0;box-sizing:border-box} -body{font-family:var(--fs);background:var(--bg);color:var(--t1);min-height:100vh;-webkit-font-smoothing:antialiased} +body{font-family:var(--fs);background:var(--bg);color:var(--t1);height:100vh;overflow:hidden;-webkit-font-smoothing:antialiased} ::selection{background:rgba(199,70,52,.15);color:var(--t1)} /* ── Sidebar ── */ -.app{display:flex;min-height:100vh} +.app{display:flex;height:100vh;overflow:hidden} .sb{width:260px;background:var(--bg1);display:flex;flex-direction:column;position:fixed;top:0;left:0;bottom:0;z-index:100; border-right:1px solid var(--bd);transition:transform .4s var(--trans)} .sb-h{padding:1.25rem 1.15rem;background:linear-gradient(145deg,#c74634 0%,#a83428 50%,#8b2a20 100%);position:relative;overflow:hidden} @@ -57,12 +57,12 @@ body{font-family:var(--fs);background:var(--bg);color:var(--t1);min-height:100vh .lo-btn:hover{border-color:var(--rd);color:var(--rd);background:var(--rdl)} /* ── Main Content ── */ -.mc{flex:1;margin-left:260px;min-height:100vh} +.mc{flex:1;margin-left:260px;height:100vh;overflow:hidden;display:flex;flex-direction:column} .tb{height:56px;background:rgba(255,255,255,.8);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px); border-bottom:1px solid var(--bd);display:flex;align-items:center;justify-content:space-between;padding:0 1.5rem;position:sticky;top:0;z-index:50} .tb-t{font-weight:700;font-size:.92rem;color:var(--t1);letter-spacing:-.01em} .tb-a{display:flex;align-items:center;gap:.6rem} -.pc{padding:1.5rem;max-width:1400px} +.pc{padding:1.5rem;max-width:1400px;flex:1;overflow-y:auto;min-height:0} /* ── Cards ── */ .cd{background:var(--bg1);border:1px solid var(--bd);border-radius:var(--rl);padding:1.35rem;margin-bottom:.85rem; @@ -114,6 +114,23 @@ tbody tr:last-child td{border-bottom:none} .g2{display:grid;grid-template-columns:1fr 1fr;gap:.75rem} .g3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:.75rem} +/* ── Chat History Panel (inside chat/terraform) ── */ +.ch-hp{width:0;overflow:hidden;transition:width .25s ease;background:var(--bg1);border-right:0 solid var(--bd);flex-shrink:0;order:0} +.ch-hp.open{width:260px;border-right-width:1px} +.ch-hp-inner{width:260px;display:flex;flex-direction:column;height:100%;box-sizing:border-box} +.ch-hp-hdr{display:flex;align-items:center;justify-content:space-between;padding:.55rem .7rem;border-bottom:1px solid var(--bd);font-size:.74rem;font-weight:700;color:var(--t2)} +.ch-hp-new{font-size:.72rem;font-weight:600;color:var(--ac);cursor:pointer;padding:.35rem .7rem;border:1px dashed var(--ac);border-radius:6px;transition:background .15s} +.ch-hp-new:hover{background:var(--acl)} +.ch-hp-list{overflow-y:auto;padding:.3rem .45rem;flex:1} +.ch-hp-grp{font-size:.58rem;font-weight:700;color:var(--t4);text-transform:uppercase;letter-spacing:.06em;padding:.5rem .25rem .15rem} +.ch-hp-item{display:flex;align-items:center;gap:.3rem;padding:.4rem .5rem;border-radius:8px;cursor:pointer;font-size:.72rem;color:var(--t2);transition:background .15s;margin-bottom:1px} +.ch-hp-item:hover{background:var(--bg3)} +.ch-hp-item.active{background:var(--acl2);color:var(--ac);font-weight:600} +.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-item:hover .ch-hp-del{opacity:1} + /* ── Chat ── */ .ch-c{display:flex;height:calc(100vh - 56px - 3rem);overflow:hidden;border:1px solid var(--bd);border-radius:var(--rl);box-shadow:var(--sh2)} .ch-side{width:0;overflow:hidden;transition:width .3s cubic-bezier(.4,0,.2,1);background:var(--bg1);border-left:0 solid var(--bd);flex-shrink:0;order:2} @@ -156,7 +173,7 @@ tbody tr:last-child td{border-bottom:none} .cb code{font-family:var(--fm);font-size:.73rem;background:var(--bg3);padding:.12rem .3rem;border-radius:5px} .cb strong{color:var(--ac)} .ch-i{display:flex;gap:.45rem;padding:.7rem .85rem;background:var(--bg1);border-top:1px solid var(--bd);box-shadow:0 -2px 8px rgba(0,0,0,.02)} -.ch-i input{flex:1;background:var(--bg2);border-radius:10px} +.ch-i textarea,.ch-i input{flex:1;background:var(--bg2);border-radius:10px;font-family:inherit;font-size:inherit;line-height:1.4;padding:.45rem .65rem;border:1.5px solid var(--bd);min-height:36px;max-height:200px} .ch-i .btn{border-radius:10px} /* ── Report ── */ @@ -244,26 +261,37 @@ tbody tr:last-child td{border-bottom:none} .exp-cat{padding:.3rem .5rem;font-size:.6rem} } /* ── Terraform Agent ── */ -.tf-wrap{display:flex;flex-direction:column;height:calc(100vh - 56px)} -.tf-chat{flex:1;display:flex;flex-direction:column;overflow:hidden;min-height:200px} +.tf-wrap{display:flex;flex-direction:column;height:calc(100vh - 56px - 3rem);overflow:hidden} +.tf-chat{flex:1;display:flex;flex-direction:column;overflow:hidden;min-height:0} .tf-toolbar{display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-bottom:1px solid var(--bd);flex-wrap:wrap;background:var(--bg)} .tf-msgs{flex:1;overflow-y:auto;padding:.7rem} .tf-input{display:flex;gap:.5rem;padding:.5rem .7rem;border-top:1px solid var(--bd)} -.tf-input input{flex:1} +.tf-input textarea,.tf-input input{flex:1;font-family:inherit;font-size:inherit;line-height:1.4;padding:.45rem .65rem;border:1.5px solid var(--bd);border-radius:10px;background:var(--bg2);min-height:36px;max-height:200px} .tf-resize{height:5px;cursor:row-resize;background:var(--bd);transition:background .15s;position:relative;flex-shrink:0} .tf-resize:hover,.tf-resize.dragging{background:#7b42bc;opacity:.7} .tf-resize::after{content:'';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:28px;height:3px;border-top:1px solid var(--t4);border-bottom:1px solid var(--t4);opacity:.4} .tf-resize:hover::after{border-color:#7b42bc;opacity:.8} -.tf-bottom{height:280px;display:flex;flex-direction:column;overflow:hidden;border-top:1px solid var(--bd);background:var(--bg1)} +.tf-bottom{flex:1;display:flex;flex-direction:column;overflow:hidden;border-top:1px solid var(--bd);background:var(--bg1);min-height:0} .tf-bottom-tabs{display:flex;align-items:center;gap:0;border-bottom:1px solid var(--bd);background:var(--bg);padding:0 .5rem} .tf-btab{padding:.4rem .75rem;font-size:.72rem;font-weight:600;cursor:pointer;border-bottom:2px solid transparent;color:var(--t3);display:flex;align-items:center;gap:.3rem;transition:all .15s} .tf-btab:hover{color:var(--t1)} .tf-btab.active{color:#7b42bc;border-bottom-color:#7b42bc} .tf-btab svg{width:14px;height:14px} .tf-bottom-actions{margin-left:auto;display:flex;align-items:center;gap:.4rem;padding-right:.3rem} -.tf-bottom-content{flex:1;overflow:auto;padding:.5rem .7rem} -.tf-file{display:flex;align-items:center;gap:.5rem;padding:.45rem .6rem;border-radius:8px;border:1px solid var(--bd);margin-bottom:.4rem;background:var(--bg);transition:border-color .15s} +.tf-bottom-content{flex:1;overflow:hidden;display:flex;flex-direction:row} +.tf-bc-left{flex:1;overflow:auto;padding:.5rem .7rem;min-width:0} +.tf-bc-right{width:50%;overflow:auto;border-left:1px solid var(--bd);flex-shrink:0;display:flex;flex-direction:column} +.tf-bc-right .tf-term-hdr{padding:.3rem .6rem;font-size:.68rem;font-weight:700;color:#cdd6f4;background:#12122a;border-bottom:1px solid #2a2a4a;display:flex;align-items:center;gap:.4rem} +.tf-bc-right .tf-term-hdr svg{width:12px;height:12px;fill:#cdd6f4} +.tf-bc-right .tf-term-body{flex:1;overflow:auto;background:#1a1a2e;padding:.5rem;font-family:var(--fm);font-size:.68rem;line-height:1.5;color:#cdd6f4;white-space:pre-wrap;word-break:break-word} +.tf-file{display:flex;align-items:center;gap:.5rem;padding:.45rem .6rem;border-radius:8px;border:1px solid var(--bd);margin-bottom:.4rem;background:var(--bg);transition:border-color .15s;cursor:pointer} .tf-file:hover{border-color:#7b42bc} +.tf-file.editing{border-color:#7b42bc;background:#7b42bc08} +.tf-editor{border:1px solid #e2ddf5;border-radius:var(--r);overflow:hidden;margin-bottom:.5rem;background:#f8f6ff} +.tf-editor-hdr{display:flex;align-items:center;gap:.4rem;padding:.35rem .6rem;background:#efe9fc;border-bottom:1px solid #e2ddf5;font-size:.7rem;font-weight:600;color:#4a3875} +.tf-editor-hdr .tf-ed-name{flex:1;font-family:var(--fm)} +.tf-editor-hdr .btn{font-size:.62rem;padding:2px 8px} +.tf-editor-ta{width:100%;min-height:120px;max-height:400px;border:none;outline:none;resize:vertical;font-family:var(--fm);font-size:.72rem;line-height:1.6;color:#3b2e58;background:#f8f6ff;padding:.6rem .7rem;box-sizing:border-box;tab-size:2} .tf-file-icon{width:32px;height:32px;border-radius:6px;background:linear-gradient(135deg,#7b42bc22,#5c4ee522);display:flex;align-items:center;justify-content:center;flex-shrink:0} .tf-file-icon svg{width:16px;height:16px;fill:#7b42bc} .tf-file-info{flex:1;min-width:0} @@ -272,12 +300,12 @@ tbody tr:last-child td{border-bottom:none} .tf-file-actions{display:flex;gap:.3rem} .tf-file-btn{width:26px;height:26px;border-radius:6px;border:1px solid var(--bd);background:var(--bg);display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:.7rem;transition:all .15s} .tf-file-btn:hover{border-color:#7b42bc;background:#7b42bc11} -.tf-code-block{background:#1e1e2e;border-radius:var(--r);overflow:hidden;margin:.6rem 0} -.tf-code-header{display:flex;align-items:center;gap:.4rem;padding:.4rem .7rem;background:#2a2a3e;font-size:.72rem;color:#cdd6f4;border-bottom:1px solid #45475a} +.tf-code-block{background:#f8f6ff;border:1px solid #e2ddf5;border-radius:var(--r);overflow:hidden;margin:.6rem 0} +.tf-code-header{display:flex;align-items:center;gap:.4rem;padding:.4rem .7rem;background:#efe9fc;font-size:.72rem;color:#4a3875;border-bottom:1px solid #e2ddf5} .tf-code-header span{flex:1;font-weight:600} -.tf-code-header .btn{color:#cdd6f4;border-color:#45475a;background:transparent;font-size:.66rem;padding:2px 8px} -.tf-code-header .btn:hover{background:#45475a} -.tf-pre{padding:.85rem;overflow-x:auto;font-size:.74rem;line-height:1.6;color:#cdd6f4;font-family:var(--fm);margin:0;white-space:pre} +.tf-code-header .btn{color:#7b42bc;border-color:#d4c8ef;background:transparent;font-size:.66rem;padding:2px 8px} +.tf-code-header .btn:hover{background:#e2ddf5} +.tf-pre{padding:.85rem;overflow-x:auto;font-size:.74rem;line-height:1.6;color:#3b2e58;font-family:var(--fm);margin:0;white-space:pre} .tf-plan-block{background:var(--gnl);border:1px solid rgba(22,163,74,.15);border-radius:var(--r);margin:.6rem 0;overflow:hidden} .tf-plan-header{padding:.45rem .7rem;font-weight:700;font-size:.76rem;color:var(--gn);border-bottom:1px solid rgba(22,163,74,.12)} .tf-plan-list{padding:.4rem .7rem} @@ -285,7 +313,6 @@ tbody tr:last-child td{border-bottom:none} .tf-plan-add{color:var(--gn);font-weight:700;font-family:var(--fm)} .tf-plan-type{font-family:var(--fm);font-weight:600;color:var(--t1)} .tf-plan-desc{color:var(--t3);font-size:.68rem} -.tf-output{background:#1a1a2e;color:#cdd6f4;font-family:var(--fm);font-size:.7rem;line-height:1.5;padding:.6rem;overflow:auto;white-space:pre-wrap;word-break:break-word;border-radius:var(--r);height:100%} .tf-badge{font-size:.58rem;padding:1px 6px;border-radius:10px;font-weight:700;display:inline-flex;align-items:center;gap:3px} .tf-badge-draft{background:#7b42bc18;color:#7b42bc} .tf-badge-ok{background:var(--gnl);color:var(--gn)} @@ -298,7 +325,7 @@ tbody tr:last-child td{border-bottom:none} .tf-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:var(--t3);font-size:.76rem;gap:.3rem} .tf-empty svg{width:40px;height:40px;fill:#7b42bc;opacity:.3} @media(max-width:768px){ -.tf-bottom{height:200px} +.tf-bottom{min-height:0} } @@ -310,7 +337,7 @@ const LOGO_W=``; const S={user:null,token:null,tab:'chat',msgs:[],sid:null,reports:[],ociCfg:[],genaiCfg:[],adbCfg:[],mcpSvr:[],users:[],models:{},regions:[],embModels:{},expData:null,expCfg:'',expSelRegions:[],expRegDdOpen:false,expTree:[],expSelComp:'',expCat:'Compute',expResType:'instances',expTreeOpen:{},expLoading:false,expRegions:[],expCounts:{},editing:null, - chatModel:'',chatOci:'',chatRegion:'',chatCompartment:'', + chatModel:'',chatOci:'',chatRegion:'',chatCompartment:'',chatHistOpen:false,chatHistory:[],tfHistOpen:false,tfHistory:[], chatParams:{temperature:1,max_tokens:6000,top_p:0.95,top_k:1,frequency_penalty:0,presence_penalty:0},chatPanel:'',chatUseTools:true, chatPrompts:[],editingPrompt:null,trackingReportId:null,ociRegions:{},rptSelRegions:[],rptRegionsOpen:false,rptRegionFilter:'', rptOciOpen:false,rptOciFilter:'',rptOciVal:'',rptRselOpen:false,rptRselFilter:'',rptRselVal:'',ociFormRegOpen:false,ociFormRegFilter:'',ociFormRegVal:'', @@ -318,7 +345,7 @@ const S={user:null,token:null,tab:'chat',msgs:[],sid:null,reports:[],ociCfg:[],g cisVer:null,cisCheckResult:null,cisUpdating:false,rptHistFilter:'',chatFiles:[], tfMsgs:[],tfSid:null,tfModel:'',tfOci:'',tfRegion:'',tfCompartment:'',tfPlan:[],tfCode:'',tfPanel:'', tfWs:null,tfWsList:[],tfPlanOut:'',tfApplyOut:'',tfDestroyOut:'',tfStatus:'draft',tfRunning:false,tfConfirmDest:false,tfMdOpen:false, - tfComps:[],tfCompLoading:false,tfFiles:[],tfBtab:'files',tfResources:null,tfResLoading:false}; + tfComps:[],tfCompLoading:false,tfFiles:[],tfBtab:'files',tfEditIdx:-1,tfResources:null,tfResLoading:false}; const API='/api'; async function $api(p,o={}){const h={...(o.headers||{})};if(S.token)h['Authorization']='Bearer '+S.token; @@ -340,9 +367,19 @@ async function loadData(){try{ if(!S.chatModel){const dc=S.genaiCfg.find(g=>g.is_default);if(dc)S.chatModel='cfg:'+dc.id;else if(S.models['openai.gpt-4.1'])S.chatModel='openai.gpt-4.1'} if(S.user.role==='admin'){try{S.users=await $api('/users')}catch(e){}} try{S.cisVer=await $api('/cis-engine/version')}catch(e){} + loadHistory('chat'); }catch(e){console.error(e)}} -function R(){document.getElementById('app').innerHTML=S.user?rApp():rLogin();if(S.editing?.type==='mcp')setTimeout(mcpTypeFields,0)} -function switchTab(t){S.tab=t;R();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()} +function R(){try{ + // Preserve textarea values before re-render + 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 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)} + 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()} const tm={'oci-config':'oci','genai':'genai','adb':'adb','mcp':'mcp'};if(tm[t])setTimeout(()=>refreshCLogs(tm[t]),100)} /* ── Login ── */ @@ -403,18 +440,24 @@ function rChat(){ if(S.chatPanel==='config')sideContent=rChatConfig(isDirect,toolCount); else if(S.chatPanel==='logs')sideContent=rChatLogs(); return`
+
+
🕘 Histórico
+ Novo Chat
+
${rHistList('chat',S.chatHistory,S.sid)}
+
${sideContent}
-
${curLabel}
${ddItems}
+
+
+
${curLabel}
${ddItems}
${ociSel}${ragBadge}
⚙️
📋
-
🗑️
+
${ms}
${S.chatFiles.length?`
${S.chatFiles.map((f,i)=>f.type==='image'?`
×
`:`
📄 ${f.name}×
`).join('')}
`:''}
- +
`} function rChatConfig(isDirect,toolCount){ const pp=S.chatParams; @@ -464,13 +507,14 @@ async function savePrompt(){const name=document.getElementById('spname').value;c async function activatePrompt(id){try{await $api('/prompts/'+id,{method:'PUT',body:{is_active:true}});S.chatPrompts=await $api('/prompts/chat');R()}catch(e){sm('spm',e.message,'e')}} async function delPrompt(id){if(!confirm('Excluir prompt?'))return;try{await $api('/prompts/'+id,{method:'DELETE'});S.editingPrompt=null;S.chatPrompts=await $api('/prompts/chat');R()}catch(e){sm('spm',e.message,'e')}} function fm(t){return t.replace(/\*\*(.*?)\*\*/g,'$1').replace(/`(.*?)`/g,'$1').replace(/\n/g,'
')} +function autoGrow(el){el.style.height='auto';el.style.height=Math.min(el.scrollHeight,200)+'px'} function addChatFiles(input){for(const f of input.files){if(S.chatFiles.length>=5){alert('Máximo 5 arquivos');break} const entry={file:f,name:f.name,type:f.type.startsWith('image/')?'image':'file',preview:null}; if(entry.type==='image'){const r=new FileReader();r.onload=e=>{entry.preview=e.target.result;R()};r.readAsDataURL(f)} S.chatFiles.push(entry)}input.value='';R()} function rmChatFile(i){S.chatFiles.splice(i,1);R()} function tstamp(){const d=new Date();return d.toLocaleTimeString('pt-BR',{hour:'2-digit',minute:'2-digit'})} -async function sChat(){const el=document.getElementById('chi');const m=el.value.trim(); +async function sChat(){const el=document.getElementById('chi');const m=el.value.trim();el.style.height='auto';S._chiVal=''; if(!m&&!S.chatFiles.length)return; if(!S.chatModel){S.msgs.push({r:'assistant',c:'⚠️ Selecione um modelo antes de enviar uma mensagem.'});R();return} el.value=''; @@ -511,6 +555,7 @@ async function sChat(){const el=document.getElementById('chi');const m=el.value. body.top_k=S.chatParams.top_k;body.frequency_penalty=S.chatParams.frequency_penalty;body.presence_penalty=S.chatParams.presence_penalty} d=await $api('/chat',{method:'POST',body})} S.sid=d.session_id; + if(S.chatHistOpen)loadHistory('chat'); if(d.status==='processing'&&d.message_id){ await pollChatResult(d.message_id) }else{ @@ -530,6 +575,69 @@ async function pollChatResult(mid){ S.msgs=S.msgs.filter(x=>!x.thinking);S.msgs.push({r:'assistant',c:'⏰ Timeout: a resposta está demorando muito. Tente novamente.',t:tstamp()});R()} function scCh(){setTimeout(()=>{const e=document.getElementById('chm');if(e)e.scrollTop=e.scrollHeight},50)} async function clrChat(){if(S.sid)try{await $api('/chat/'+S.sid,{method:'DELETE'})}catch(e){}S.msgs=[];S.sid=null;R()} +function newChat(){S.msgs=[];S.sid=null;S.chatFiles=[];R()} +function 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.tfConfirmDest=false;S.tfEditIdx=-1;R()} + +async function loadHistory(type){ + try{const h=await $api('/chat/sessions?agent_type='+type+'&limit=50'); + if(type==='chat')S.chatHistory=h;else S.tfHistory=h} + catch(e){if(type==='chat')S.chatHistory=[];else S.tfHistory=[]} + R(); +} +async function loadSession(sid,type){ + try{const d=await $api('/chat/sessions/'+sid+'/messages'); + 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.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); + try{const wsList=await $api('/terraform/workspaces');const ws=wsList.find(w=>w.session_id===sid); + if(ws){S.tfWs=ws.id;S.tfStatus=ws.status}else{S.tfWs=null;S.tfStatus='draft'} + }catch(e){S.tfWs=null;S.tfStatus='draft'} + R(); + } + }catch(e){alert('Erro ao carregar sessão: '+e.message)} +} +async function delSession(sid,type){ + if(!confirm('Excluir esta conversa?'))return; + try{await $api('/chat/'+sid,{method:'DELETE'})}catch(e){} + if(type==='chat'){if(S.sid===sid)newChat();S.chatHistory=S.chatHistory.filter(s=>s.id!==sid)} + else{if(S.tfSid===sid)newTfChat();S.tfHistory=S.tfHistory.filter(s=>s.id!==sid)} + R(); +} +async function renameSession(sid,type){ + const cur=(type==='chat'?S.chatHistory:S.tfHistory).find(s=>s.id===sid); + const t=prompt('Renomear conversa:',cur?.title||''); + if(t===null)return; + try{await $api('/chat/sessions/'+sid+'/title',{method:'PUT',body:{title:t}}); + if(cur)cur.title=t;R()}catch(e){} +} +function _histDateGroup(dateStr){ + if(!dateStr)return'Outras'; + const d=new Date(dateStr+'Z'),now=new Date(),diff=(now-d)/86400000; + if(diff<1)return'Hoje';if(diff<2)return'Ontem';if(diff<7)return'Últimos 7 dias';if(diff<30)return'Últimos 30 dias';return'Anteriores'; +} + +function rHistList(type,hist,curSid){ + if(!hist.length)return'
Nenhuma conversa
'; + let html='';let lastGroup=''; + hist.forEach(s=>{ + const g=_histDateGroup(s.updated_at||s.created_at); + if(g!==lastGroup){lastGroup=g;html+=`
${g}
`} + const active=s.id===curSid?' active':''; + const title=s.title||'Nova conversa'; + html+=`
+ ${escHtml(title)} + + + +
`; + }); + return html; +} + async function loadChatLogs(){ const sev=document.getElementById('cl-sev-chat')?.value||''; const qs='limit=50'+(sev?'&severity='+sev:'')+(S.sid?'&session_id='+S.sid:''); @@ -551,30 +659,42 @@ const TF_ICON=`{ const trimmed=code.trim(); - codeBlocks.push(trimmed); - S.tfCode=trimmed; - return '
'+TF_ICON+' main.tf'+ - '
'+ - '
'+escHtml(trimmed)+'
'; + // Check for // filename: comment on first line + const fnMatch=trimmed.match(/^\/\/\s*filename:\s*(\S+)/); + const fileName=fnMatch?fnMatch[1]:'main.tf'; + // Content without the filename comment line + const content=fnMatch?trimmed.replace(/^\/\/\s*filename:\s*\S+\s*\n?/,'').trim():trimmed; + codeBlocks.push({name:fileName,content:content}); + S.tfCode=content; + return '
'+TF_ICON+' '+escHtml(fileName)+''+ + '
'+ + '
'+escHtml(content)+'
'; }); r=r.replace(/```plan\s*\n([\s\S]*?)```/g,(m,plan)=>{ const lines=plan.trim().split('\n'); - S.tfPlan=lines.map(l=>{const m2=l.match(/^\+\s+(\S+)\s+\((.+)\)$/);return m2?{type:m2[1],desc:m2[2]}:{type:l.replace(/^\+\s*/,'').trim(),desc:''}}).filter(x=>x.type); + S.tfPlan=lines.map(l=>{const m2=l.match(/^[+~]\s+(\S+)\s+\((.+)\)$/);return m2?{type:m2[1],desc:m2[2]}:{type:l.replace(/^[+~]\s*/,'').trim(),desc:''}}).filter(x=>x.type); return '
'+TF_ICON+' Resource Plan — '+S.tfPlan.length+' resource(s)
'+ S.tfPlan.map(x=>'
+'+escHtml(x.type)+''+escHtml(x.desc)+'
').join('')+'
'; }); - // Update files list + // Build files list — merge blocks with same filename, keep separate files if(codeBlocks.length){ - S.tfFiles=[{name:'main.tf',content:codeBlocks.join('\n\n'),size:codeBlocks.join('\n\n').length,type:'hcl'}]; - // Detect if the code has variables -> also create variables.tf reference + const fileMap={}; + codeBlocks.forEach(b=>{ + if(fileMap[b.name])fileMap[b.name]+='\n\n'+b.content; + else fileMap[b.name]=b.content; + }); + S.tfFiles=Object.entries(fileMap).map(([name,content])=>({name,content,size:content.length,type:'hcl'})); + // Set tfCode to combined content for backward compat + S.tfCode=S.tfFiles.map(f=>'// filename: '+f.name+'\n'+f.content).join('\n\n'); } r=r.replace(/\*\*(.*?)\*\*/g,'$1').replace(/`(.*?)`/g,'$1').replace(/\n/g,'
'); return r; } +function tfCopyBlock(i){const b=S.tfFiles[i];if(b)navigator.clipboard.writeText(b.content).then(()=>alert('Copiado!'))} function rTerraform(){ const ms=S.tfMsgs.length===0 @@ -593,14 +713,20 @@ function rTerraform(){ const isDirect=S.tfModel&&!S.tfModel.startsWith('cfg:'); const ociSel=isDirect?``:''; - const compSel=(S.tfComps.length||S.tfCompLoading)?``:''; + const compSel=(S.tfComps.length||S.tfCompLoading||S.tfOci)?``:''; // Status badge const stBadge=_tfBadge(S.tfStatus); return `
+
+
+
🕘 Histórico
+ Novo Chat
+
${rHistList('terraform',S.tfHistory,S.tfSid)}
+
+
${TF_ICON}
${curLabel}
${S.tfMdOpen?`
${ddItems}
`:''} @@ -608,15 +734,15 @@ function rTerraform(){ ${ociSel}${compSel} ${stBadge}
- ${rTfActions()} -
+
${ms}
- +
+
@@ -632,15 +758,15 @@ function rTerraform(){ Resources${S.tfResources?(' ('+Object.values(S.tfResources).reduce((a,b)=>a+(Array.isArray(b)?b.length:0),0)+')'):''}
-
- - Output -
${S.tfFiles.length?'':''} + ${rTfActions()}
-
${rTfBottomContent()}
+
+
${rTfBottomContent()}
+ ${rTfTerminal()} +
${S.tfConfirmDest?rTfConfirmDestroy():''}
`; @@ -653,13 +779,13 @@ function _tfBadge(st){ } function rTfActions(){ - if(S.tfRunning)return''; + if(S.tfRunning)return''; const st=S.tfStatus;let btns=''; - if(S.tfCode&&(st==='draft'||st==='failed'))btns+=``; - if(st==='planned'||st==='failed')btns+=``; - if(st==='planned')btns+=''; - if(st==='applied')btns+=``; - if(st==='applied'||st==='planned')btns+=''; + if(S.tfCode&&(st==='draft'||st==='failed'))btns+=``; + if(st==='planned'||st==='failed')btns+=``; + if(st==='planned')btns+=''; + if(st==='applied')btns+=``; + if(st==='applied'||st==='planned')btns+=''; return btns; } @@ -667,13 +793,27 @@ function rTfBottomContent(){ if(S.tfBtab==='files')return rTfFiles(); if(S.tfBtab==='plan')return rTfPlanPanel(); if(S.tfBtab==='resources')return rTfResourcesPanel(); - if(S.tfBtab==='output')return rTfOutputPanel(); return''; } +function rTfTerminal(){ + const out=S.tfPlanOut||S.tfApplyOut||S.tfDestroyOut; + let label='Terminal',color='#cdd6f4',content=''; + if(out){ + if(S.tfPlanOut)content=S.tfPlanOut; + if(S.tfApplyOut)content=S.tfApplyOut; + if(S.tfDestroyOut)content=S.tfDestroyOut; + label=S.tfDestroyOut?'Destroy Output':S.tfApplyOut?'Apply Output':'Plan Output'; + color=S.tfDestroyOut?'#f38ba8':S.tfApplyOut?'#a6e3a1':'#cba6f7'; + } + return`
+
${label}${S.tfRunning?'● Executando...':''}
+
${out?escHtml(content):'Aguardando execução...'}
+
`; +} + function rTfFiles(){ if(!S.tfFiles.length)return`
${TF_ICON}

Nenhum arquivo gerado ainda

Descreva a infraestrutura desejada no chat acima

`; - // Auto-add provider.tf and terraform.tfvars as virtual files const files=[...S.tfFiles]; if(S.tfWs||S.tfOci){ const oc=S.ociCfg.find(x=>x.id===S.tfOci); @@ -682,18 +822,29 @@ function rTfFiles(){ if(!files.find(f=>f.name==='terraform.tfvars'))files.push({name:'terraform.tfvars',content:`compartment_id = "${comp}"\nregion = "${oc.region}"`,size:0,type:'tfvars',auto:true}); } } - return files.map((f,i)=>{ + let html=''; + for(let i=0;i1024?(sz/1024).toFixed(1)+' KB':sz+' B'; const iconFill=f.auto?'#888':'#7b42bc'; - return`
+ html+=`
${f.name}
${sizeStr}${f.auto?' · auto-generated':''}
-
-
+ ${!f.auto?`
`:''} +
+
-
`}).join(''); +
`; + if(isEdit){ + html+=`
+
${f.name}${f.auto?'somente leitura':''}
+ +
`; + } + } + return html; } function rTfPlanPanel(){ @@ -704,15 +855,6 @@ function rTfPlanPanel(){
${escHtml(r.desc)}
`).join(''); } -function rTfOutputPanel(){ - let out=''; - if(S.tfPlanOut)out+=`
${TF_ICON} Plan Output
${escHtml(S.tfPlanOut)}
`; - if(S.tfApplyOut)out+=`
Apply Output
${escHtml(S.tfApplyOut)}
`; - if(S.tfDestroyOut)out+=`
Destroy Output
${escHtml(S.tfDestroyOut)}
`; - if(!out)return`

Nenhuma execução ainda

`; - return out; -} - function rTfConfirmDestroy(){ return`

Confirmar Destroy

@@ -735,6 +877,8 @@ function tfStartResize(e){ document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp); } +function tfOpenFile(i){S.tfEditIdx=S.tfEditIdx===i?-1:i;R();if(S.tfEditIdx>=0)setTimeout(()=>{const ta=document.getElementById('tfEdTa');if(ta){ta.style.height='auto';ta.style.height=Math.min(Math.max(ta.scrollHeight,120),400)+'px'}},20)} +function tfSaveFile(i){const ta=document.getElementById('tfEdTa');if(!ta||i<0||i>=S.tfFiles.length)return;S.tfFiles[i].content=ta.value;S.tfFiles[i].size=ta.value.length;S.tfCode=S.tfFiles.map(f=>'// filename: '+f.name+'\n'+f.content).join('\n\n');S.tfStatus='draft';R()} function tfCopyFile(i){ const files=[...S.tfFiles]; const oc=S.ociCfg.find(x=>x.id===S.tfOci); @@ -787,12 +931,12 @@ function rTfResourcesPanel(){ const cats=[ {key:'vcns',label:'VCNs',icon:'',fmt:r=>`${r.display_name} (${(r.cidr_blocks||[]).join(', ')})`}, {key:'subnets',label:'Subnets',icon:'',fmt:r=>`${r.display_name} (${r.cidr_block}, ${r.public?'pública':'privada'})`}, - {key:'instances',label:'Compute',icon:'',fmt:r=>`${r.display_name} (${r.shape})`}, + {key:'instances',label:'Compute',icon:'',fmt:r=>{const stopped=r.state==='STOPPED';const running=r.state==='RUNNING';const stC=stopped?'var(--rd)':running?'var(--gn)':'var(--t3)';return`${r.display_name} (${r.shape}) ${r.state}${stopped?` `:running?` `:''}`}}, {key:'internet_gateways',label:'Internet GWs',icon:'',fmt:r=>r.display_name}, {key:'nat_gateways',label:'NAT GWs',icon:'',fmt:r=>r.display_name}, {key:'route_tables',label:'Route Tables',icon:'',fmt:r=>r.display_name}, {key:'security_lists',label:'Security Lists',icon:'',fmt:r=>r.display_name}, - {key:'autonomous_databases',label:'Databases',icon:'',fmt:r=>`${r.display_name} (${r.db_name}${r.is_free_tier?' Free':''})`}, + {key:'autonomous_databases',label:'Databases',icon:'',fmt:r=>{const stopped=r.state==='STOPPED';const avail=r.state==='AVAILABLE';const stC=stopped?'var(--rd)':avail?'var(--gn)':'var(--t3)';return`${r.display_name} (${r.db_name}${r.is_free_tier?' Free':''}) ${r.state}${stopped?` `:avail?` `:''}`}}, {key:'block_volumes',label:'Block Volumes',icon:'',fmt:r=>`${r.display_name} (${r.size_in_gbs||'?'} GB)`}, {key:'load_balancers',label:'Load Balancers',icon:'',fmt:r=>`${r.display_name} (${r.shape_name||'flexible'})`} ]; @@ -815,16 +959,22 @@ function rTfResourcesPanel(){ html+='
'; return html; } +function _ociResOid(){return S.tfOci||(S.tfModel.startsWith('cfg:')?((S.genaiCfg.find(x=>x.id===S.tfModel.slice(4))||{}).oci_config_id||''):'')} +async function ociStartInstance(id){if(!confirm('Iniciar esta instância?'))return;try{await $api('/oci/instances/'+id+'/action',{method:'POST',body:{action:'START',oci_config_id:_ociResOid(),region:S.tfRegion||null}});alert('Instância iniciando...');tfLoadResources()}catch(e){alert('Erro: '+e.message)}} +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 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.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()} +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()} function tfScroll(){setTimeout(()=>{const e=document.getElementById('tfchm');if(e)e.scrollTop=e.scrollHeight},50)} function copyTfCode(){navigator.clipboard.writeText(S.tfCode).then(()=>alert('Código copiado!'))} -function dlTfCode(){if(!S.tfCode)return;const b=new Blob([S.tfCode],{type:'text/plain'});const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='main.tf';a.click();URL.revokeObjectURL(a.href)} +function dlTfCode(){if(!S.tfFiles.length)return;if(S.tfFiles.length===1){const f=S.tfFiles[0];const b=new Blob([f.content],{type:'text/plain'});const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download=f.name;a.click();URL.revokeObjectURL(a.href)}else{tfDlAll()}} async function tfSend(){ - const el=document.getElementById('tfi');const m=el.value.trim();if(!m)return; + 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(); S.tfMsgs.push({r:'assistant',c:'⏳ _Gerando Terraform..._',thinking:true});R();tfScroll(); try{ @@ -836,6 +986,7 @@ async function tfSend(){ } 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()} @@ -870,7 +1021,7 @@ async function tfSaveAndPlan(){ } // Run plan await $api('/terraform/workspaces/'+S.tfWs+'/plan',{method:'POST'}); - S.tfStatus='planning';S.tfRunning=true;S.tfPlanOut='';S.tfPanel='output';R(); + S.tfStatus='planning';S.tfRunning=true;S.tfPlanOut='';S.tfBtab='plan';R(); await tfPollExec(); }catch(e){alert('Erro: '+e.message)} } @@ -879,7 +1030,7 @@ async function tfApply(){ if(!S.tfWs){alert('Execute Plan primeiro.');return} try{ await $api('/terraform/workspaces/'+S.tfWs+'/apply',{method:'POST'}); - S.tfStatus='applying';S.tfRunning=true;S.tfApplyOut='';S.tfPanel='output';R(); + S.tfStatus='applying';S.tfRunning=true;S.tfApplyOut='';S.tfBtab='plan';R(); await tfPollExec(); }catch(e){alert('Erro: '+e.message)} } @@ -891,7 +1042,7 @@ async function tfConfirmDestroy(){ if(!S.tfWs)return; try{ await $api('/terraform/workspaces/'+S.tfWs+'/destroy',{method:'POST',body:{confirm:'DESTROY'}}); - S.tfStatus='destroying';S.tfRunning=true;S.tfDestroyOut='';S.tfPanel='output';R(); + S.tfStatus='destroying';S.tfRunning=true;S.tfDestroyOut='';S.tfBtab='plan';R(); await tfPollExec(); }catch(e){alert('Erro: '+e.message)} } @@ -914,7 +1065,7 @@ async function tfPollExec(){ if(!['planning','applying','destroying'].includes(r.status)){S.tfRunning=false;R();return} R(); // Auto-scroll output - const outs=document.querySelectorAll('.tf-output');outs.forEach(o=>o.scrollTop=o.scrollHeight); + const t=document.getElementById('tfTermOut');if(t)t.scrollTop=t.scrollHeight; }catch(e){} } S.tfRunning=false;R(); @@ -1235,7 +1386,7 @@ function _dlFmtSize(b){if(b>=1048576)return(b/1048576).toFixed(1)+' MB';if(b>=10 function _dlFileIcon(name){ const ext=name.split('.').pop().toLowerCase(); const colors={html:'#e44d26',htm:'#e44d26',csv:'#217346',json:'#f5a623',xlsx:'#217346',xls:'#217346',txt:'#6b7280',pdf:'#dc2626',png:'#8b5cf6',svg:'#8b5cf6'}; - const c=colors[ext]||'var(--ac)'; + const c=colors[ext]||'#c74634'; return`
${ext}
`; } function rDl(){