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
This commit is contained in:
29
README.md
29
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 <group-name> to read buckets in compartment <compartment-name>
|
||||
```
|
||||
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 |
|
||||
|
||||
260
backend/app.py
260
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"])
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -310,7 +337,7 @@ const LOGO_W=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width=
|
||||
const LOGO_R=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="52" height="52"><rect x="2" y="5" width="32" height="26" rx="13" ry="13" fill="rgba(199,70,52,0.08)" stroke="#C74634" stroke-width="1.8"/><rect x="11" y="11" width="14" height="14" rx="4" fill="#fff" stroke="#C74634" stroke-width="0.4"/><circle cx="15" cy="17" r="2" fill="#C74634"/><circle cx="21" cy="17" r="2" fill="#C74634"/><circle cx="15" cy="16.7" r="0.7" fill="#fff"/><circle cx="21" cy="16.7" r="0.7" fill="#fff"/><rect x="14" y="21" width="8" height="1.5" rx="0.75" fill="#C74634" opacity="0.6"/><line x1="18" y1="11" x2="18" y2="6" stroke="#C74634" stroke-width="1.2" stroke-linecap="round"/><circle cx="18" cy="5.5" r="1.5" fill="#C74634" opacity="0.25" stroke="#C74634" stroke-width="0.7"/><line x1="11" y1="18" x2="6" y2="18" stroke="#C74634" stroke-width="1" stroke-linecap="round" opacity="0.4"/><line x1="25" y1="18" x2="30" y2="18" stroke="#C74634" stroke-width="1" stroke-linecap="round" opacity="0.4"/><circle cx="5.5" cy="18" r="1" fill="#C74634" opacity="0.35"/><circle cx="30.5" cy="18" r="1" fill="#C74634" opacity="0.35"/></svg>`;
|
||||
|
||||
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='<div style="padding:2rem;color:red"><h3>Render Error</h3><pre>'+e.message+'</pre></div>'}}
|
||||
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`<div class="ch-c">
|
||||
<div class="ch-hp${S.chatHistOpen?' open':''}"><div class="ch-hp-inner">
|
||||
<div class="ch-hp-hdr"><span>🕘 Histórico</span><div class="ch-hp-new" onclick="newChat()">+ Novo Chat</div></div>
|
||||
<div class="ch-hp-list">${rHistList('chat',S.chatHistory,S.sid)}</div>
|
||||
</div></div>
|
||||
<div class="ch-side${S.chatPanel?' open':''}"><div class="ch-side-inner">${sideContent}</div></div>
|
||||
<div class="ch-main">
|
||||
<div class="ch-t"><label>🧠 Modelo:</label><div class="mdrop"><div class="mdrop-btn" onclick="toggleModelDrop()">${curLabel} <span class="arr">▼</span></div><div class="mdrop-dd" id="mdd"><input type="text" id="mds" placeholder="Buscar modelo..." oninput="filterModels(this.value)"><div class="mdrop-list" id="mdl">${ddItems}</div></div></div>
|
||||
<div class="ch-t">
|
||||
<div class="ch-icon-btn${S.chatHistOpen?' active':''}" onclick="S.chatHistOpen=!S.chatHistOpen;if(S.chatHistOpen)loadHistory('chat');R()" title="Histórico">☰</div>
|
||||
<label>🧠 Modelo:</label><div class="mdrop"><div class="mdrop-btn" onclick="toggleModelDrop()">${curLabel} <span class="arr">▼</span></div><div class="mdrop-dd" id="mdd"><input type="text" id="mds" placeholder="Buscar modelo..." oninput="filterModels(this.value)"><div class="mdrop-list" id="mdl">${ddItems}</div></div></div>
|
||||
${ociSel}${ragBadge}<div style="flex:1"></div>
|
||||
<div class="ch-icon-btn${S.chatPanel==='config'?' active':''}" onclick="togglePanel('config')" title="Configurações">⚙️</div>
|
||||
<div class="ch-icon-btn${S.chatPanel==='logs'?' active':''}" onclick="togglePanel('logs')" title="Chat Logs">📋</div>
|
||||
<div class="ch-icon-btn" onclick="clrChat()" title="Limpar conversa">🗑️</div></div>
|
||||
<div class="ch-icon-btn" onclick="newChat()" title="Nova conversa">✨</div></div>
|
||||
<div class="ch-m" id="chm">${ms}</div>
|
||||
${S.chatFiles.length?`<div style="display:flex;gap:.4rem;flex-wrap:wrap;padding:.4rem .85rem;background:var(--bg1);border-top:1px solid var(--bd)">${S.chatFiles.map((f,i)=>f.type==='image'?`<div style="position:relative;width:48px;height:48px"><img src="${f.preview}" style="width:48px;height:48px;object-fit:cover;border-radius:6px;border:1px solid var(--bd)"><span onclick="rmChatFile(${i})" style="position:absolute;top:-4px;right:-4px;background:var(--err);color:#fff;width:16px;height:16px;border-radius:50%;font-size:.6rem;display:flex;align-items:center;justify-content:center;cursor:pointer">×</span></div>`:`<div style="position:relative;display:flex;align-items:center;gap:.25rem;background:var(--bg2);border:1px solid var(--bd);border-radius:6px;padding:.25rem .5rem;font-size:.68rem"><span>📄 ${f.name}</span><span onclick="rmChatFile(${i})" style="color:var(--err);cursor:pointer;font-weight:700">×</span></div>`).join('')}</div>`:''}
|
||||
<div class="ch-i"><input type="file" id="chf" multiple accept="image/*,.pdf,.txt,.csv,.json,.log,.xml,.yaml,.yml,.md,.py,.js,.html,.css,.sql" style="display:none" onchange="addChatFiles(this)">
|
||||
<button class="btn bs" onclick="document.getElementById('chf').click()" title="Anexar arquivo" style="font-size:1rem;padding:.3rem .5rem">📎</button>
|
||||
<input type="text" id="chi" placeholder="Digite sua mensagem..." onkeydown="if(event.key==='Enter')sChat()">
|
||||
<textarea id="chi" placeholder="Digite sua mensagem... (Shift+Enter para nova linha)" rows="1" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sChat()}" oninput="autoGrow(this)" onpaste="setTimeout(()=>autoGrow(this),0)" style="resize:none;overflow-y:auto"></textarea>
|
||||
<button class="btn bp" onclick="sChat()">Enviar →</button></div></div></div>`}
|
||||
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,'<strong>$1</strong>').replace(/`(.*?)`/g,'<code>$1</code>').replace(/\n/g,'<br>')}
|
||||
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'<div style="text-align:center;padding:1rem .5rem;font-size:.7rem;color:var(--t4)">Nenhuma conversa</div>';
|
||||
let html='';let lastGroup='';
|
||||
hist.forEach(s=>{
|
||||
const g=_histDateGroup(s.updated_at||s.created_at);
|
||||
if(g!==lastGroup){lastGroup=g;html+=`<div class="ch-hp-grp">${g}</div>`}
|
||||
const active=s.id===curSid?' active':'';
|
||||
const title=s.title||'Nova conversa';
|
||||
html+=`<div class="ch-hp-item${active}" onclick="loadSession('${s.id}','${type}')">
|
||||
<span class="ch-hp-ttl" title="${title.replace(/"/g,'"')}">${escHtml(title)}</span>
|
||||
<span class="ch-hp-del" onclick="event.stopPropagation();delSession('${s.id}','${type}')" title="Excluir">
|
||||
<svg viewBox="0 0 16 16" width="11" height="11" fill="currentColor"><path d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"/><path fill-rule="evenodd" d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118z"/></svg>
|
||||
</span>
|
||||
</div>`;
|
||||
});
|
||||
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=`<svg viewBox="0 0 16 16" width="16" height="16"><path d="M5.6 1v4
|
||||
function escHtml(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||
|
||||
function fmTf(t){
|
||||
// Extract ALL hcl blocks and build files list
|
||||
// Extract ALL hcl blocks, parse // filename: comments, build separate files
|
||||
const codeBlocks=[];
|
||||
let r=t.replace(/```(?:hcl|terraform)\s*\n([\s\S]*?)```/g,(m,code)=>{
|
||||
const trimmed=code.trim();
|
||||
codeBlocks.push(trimmed);
|
||||
S.tfCode=trimmed;
|
||||
return '<div class="tf-code-block"><div class="tf-code-header"><span>'+TF_ICON+' main.tf</span>'+
|
||||
'<button class="btn" onclick="copyTfCode()">Copy</button></div>'+
|
||||
'<pre class="tf-pre"><code>'+escHtml(trimmed)+'</code></pre></div>';
|
||||
// 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 '<div class="tf-code-block"><div class="tf-code-header"><span>'+TF_ICON+' '+escHtml(fileName)+'</span>'+
|
||||
'<button class="btn" onclick="tfCopyBlock('+( codeBlocks.length-1)+')">Copy</button></div>'+
|
||||
'<pre class="tf-pre"><code>'+escHtml(content)+'</code></pre></div>';
|
||||
});
|
||||
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 '<div class="tf-plan-block"><div class="tf-plan-header">'+TF_ICON+' Resource Plan — '+S.tfPlan.length+' resource(s)</div><div class="tf-plan-list">'+
|
||||
S.tfPlan.map(x=>'<div class="tf-plan-item"><span class="tf-plan-add">+</span><span class="tf-plan-type">'+escHtml(x.type)+'</span><span class="tf-plan-desc">'+escHtml(x.desc)+'</span></div>').join('')+'</div></div>';
|
||||
});
|
||||
// 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,'<strong>$1</strong>').replace(/`(.*?)`/g,'<code>$1</code>').replace(/\n/g,'<br>');
|
||||
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?`<select id="tfoci" onchange="S.tfOci=this.value;const c=S.ociCfg.find(x=>x.id===this.value);if(c){S.tfRegion=c.region;S.tfCompartment=c.compartment_id||''}tfLoadComps()" style="max-width:180px"><option value="">OCI Config...</option>${S.ociCfg.map(c=>`<option value="${c.id}"${S.tfOci===c.id?' selected':''}>${c.tenancy_name} (${c.region})</option>`).join('')}</select>`:'';
|
||||
const compSel=(S.tfComps.length||S.tfCompLoading)?`<select onchange="S.tfCompartment=this.value;tfLoadResources()" style="max-width:220px">${S.tfCompLoading?'<option>Carregando...</option>':'<option value="">Compartment...</option>'+tfFlatComps(S.tfComps,0).map(c=>`<option value="${c.id}"${S.tfCompartment===c.id?' selected':''}>${c.name}</option>`).join('')}</select>`:'';
|
||||
const compSel=(S.tfComps.length||S.tfCompLoading||S.tfOci)?`<select onchange="S.tfCompartment=this.value;tfLoadResources()" style="max-width:220px">${S.tfCompLoading?'<option>Carregando...</option>':S.tfComps.length?'<option value="">Compartment...</option>'+tfFlatComps(S.tfComps,0).map(c=>`<option value="${c.id}"${S.tfCompartment===c.id?' selected':''}>${c.name}</option>`).join(''):'<option value="">Selecione Compartment...</option>'}</select>`:'';
|
||||
|
||||
// Status badge
|
||||
const stBadge=_tfBadge(S.tfStatus);
|
||||
|
||||
return `<div class="tf-wrap">
|
||||
<div style="display:flex;flex:1;min-height:0;overflow:hidden">
|
||||
<div class="ch-hp${S.tfHistOpen?' open':''}"><div class="ch-hp-inner">
|
||||
<div class="ch-hp-hdr"><span>🕘 Histórico</span><div class="ch-hp-new" onclick="newTfChat()">+ Novo Chat</div></div>
|
||||
<div class="ch-hp-list">${rHistList('terraform',S.tfHistory,S.tfSid)}</div>
|
||||
</div></div>
|
||||
<div class="tf-chat">
|
||||
<div class="tf-toolbar">
|
||||
<div class="ch-icon-btn${S.tfHistOpen?' active':''}" onclick="S.tfHistOpen=!S.tfHistOpen;if(S.tfHistOpen)loadHistory('terraform');R()" title="Histórico" style="width:24px;height:24px;font-size:.85rem">☰</div>
|
||||
${TF_ICON}
|
||||
<div class="mdrop"><div class="mdrop-btn" onclick="S.tfMdOpen=!S.tfMdOpen;R()">${curLabel} <span class="arr">▼</span></div>
|
||||
${S.tfMdOpen?`<div class="mdrop-dd open"><input type="text" id="tfmds" placeholder="Buscar..." oninput="tfFilterModels(this.value)"><div class="mdrop-list" id="tfmdl">${ddItems}</div></div>`:''}
|
||||
@@ -608,15 +734,15 @@ function rTerraform(){
|
||||
${ociSel}${compSel}
|
||||
${stBadge}
|
||||
<div style="flex:1"></div>
|
||||
${rTfActions()}
|
||||
<div class="tf-file-btn" onclick="tfClear()" title="Nova conversa" style="width:24px;height:24px"><svg viewBox="0 0 16 16" width="12" height="12" fill="var(--t3)"><path d="M2 3h12v1H2zm1.5-1h9l-.5-1h-8zM3 4v9.5a1.5 1.5 0 001.5 1.5h7a1.5 1.5 0 001.5-1.5V4z"/></svg></div>
|
||||
<div class="tf-file-btn" onclick="newTfChat()" title="Nova conversa" style="width:24px;height:24px"><svg viewBox="0 0 16 16" width="12" height="12" fill="var(--t3)"><path d="M8 2v12M2 8h12" stroke="var(--t3)" stroke-width="2" stroke-linecap="round"/></svg></div>
|
||||
</div>
|
||||
<div class="tf-msgs" id="tfchm">${ms}</div>
|
||||
<div class="tf-input">
|
||||
<input type="text" id="tfi" placeholder="Descreva a infraestrutura desejada..." onkeydown="if(event.key==='Enter')tfSend()">
|
||||
<textarea id="tfi" placeholder="Descreva a infraestrutura desejada... (Shift+Enter para nova linha)" rows="1" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();tfSend()}" oninput="autoGrow(this)" onpaste="setTimeout(()=>autoGrow(this),0)" style="resize:none;overflow-y:auto"></textarea>
|
||||
<button class="btn bp" onclick="tfSend()" style="background:#7b42bc;border-color:#7b42bc">Gerar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tf-resize" onmousedown="tfStartResize(event)"></div>
|
||||
<div class="tf-bottom" id="tfBottom">
|
||||
<div class="tf-bottom-tabs">
|
||||
@@ -632,15 +758,15 @@ function rTerraform(){
|
||||
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 1L1 5l7 4 7-4zm0 6L1 11l7 4 7-4z"/></svg>
|
||||
Resources${S.tfResources?(' ('+Object.values(S.tfResources).reduce((a,b)=>a+(Array.isArray(b)?b.length:0),0)+')'):''}
|
||||
</div>
|
||||
<div class="tf-btab${S.tfBtab==='output'?' active':''}" onclick="S.tfBtab='output';R()">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M2 1h12a1 1 0 011 1v12a1 1 0 01-1 1H2a1 1 0 01-1-1V2a1 1 0 011-1zm1 3v9h10V4zm2 1l3 2.5L5 10z"/></svg>
|
||||
Output
|
||||
</div>
|
||||
<div class="tf-bottom-actions">
|
||||
${S.tfFiles.length?'<button class="btn bs bsm" onclick="tfDlAll()" style="font-size:.66rem;padding:2px 8px">Download All</button>':''}
|
||||
${rTfActions()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tf-bottom-content">${rTfBottomContent()}</div>
|
||||
<div class="tf-bottom-content">
|
||||
<div class="tf-bc-left">${rTfBottomContent()}</div>
|
||||
${rTfTerminal()}
|
||||
</div>
|
||||
</div>
|
||||
${S.tfConfirmDest?rTfConfirmDestroy():''}
|
||||
</div>`;
|
||||
@@ -653,13 +779,13 @@ function _tfBadge(st){
|
||||
}
|
||||
|
||||
function rTfActions(){
|
||||
if(S.tfRunning)return'<button class="btn bsm" style="background:var(--rd);color:#fff;border-color:var(--rd);font-size:.68rem;padding:2px 10px" onclick="tfCancel()">Cancel</button>';
|
||||
if(S.tfRunning)return'<button class="btn bsm" style="background:var(--rd);color:#fff;border-color:var(--rd);font-size:.72rem;padding:4px 12px" onclick="tfCancel()">Cancel</button>';
|
||||
const st=S.tfStatus;let btns='';
|
||||
if(S.tfCode&&(st==='draft'||st==='failed'))btns+=`<button class="btn bsm" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.68rem;padding:2px 10px" onclick="tfSaveAndPlan()">${TF_ICON} Plan</button>`;
|
||||
if(st==='planned'||st==='failed')btns+=`<button class="btn bsm" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.68rem;padding:2px 10px" onclick="tfSaveAndPlan()">${TF_ICON} Re-plan</button>`;
|
||||
if(st==='planned')btns+='<button class="btn bsm" style="background:var(--gn);color:#fff;border-color:var(--gn);font-size:.68rem;padding:2px 10px" onclick="tfApply()">Apply</button>';
|
||||
if(st==='applied')btns+=`<button class="btn bsm" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.68rem;padding:2px 10px" onclick="tfSaveAndPlan()">${TF_ICON} Re-plan</button>`;
|
||||
if(st==='applied'||st==='planned')btns+='<button class="btn bsm" style="background:var(--rd);color:#fff;border-color:var(--rd);font-size:.68rem;padding:2px 10px" onclick="S.tfConfirmDest=true;R()">Destroy</button>';
|
||||
if(S.tfCode&&(st==='draft'||st==='failed'))btns+=`<button class="btn bsm" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.72rem;padding:4px 12px;gap:4px;display:inline-flex;align-items:center" onclick="tfSaveAndPlan()">${TF_ICON} Plan</button>`;
|
||||
if(st==='planned'||st==='failed')btns+=`<button class="btn bsm" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.72rem;padding:4px 12px;gap:4px;display:inline-flex;align-items:center" onclick="tfSaveAndPlan()">${TF_ICON} Re-plan</button>`;
|
||||
if(st==='planned')btns+='<button class="btn bsm" style="background:var(--gn);color:#fff;border-color:var(--gn);font-size:.72rem;padding:4px 12px" onclick="tfApply()">✓ Apply</button>';
|
||||
if(st==='applied')btns+=`<button class="btn bsm" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.72rem;padding:4px 12px;gap:4px;display:inline-flex;align-items:center" onclick="tfSaveAndPlan()">${TF_ICON} Re-plan</button>`;
|
||||
if(st==='applied'||st==='planned')btns+='<button class="btn bsm" style="background:var(--rd);color:#fff;border-color:var(--rd);font-size:.72rem;padding:4px 12px" onclick="S.tfConfirmDest=true;R()">✕ Destroy</button>';
|
||||
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`<div class="tf-bc-right">
|
||||
<div class="tf-term-hdr"><svg viewBox="0 0 16 16"><path d="M2 1h12a1 1 0 011 1v12a1 1 0 01-1 1H2a1 1 0 01-1-1V2a1 1 0 011-1zm1 3v9h10V4zm2 1l3 2.5L5 10z"/></svg><span style="color:${color}">${label}</span>${S.tfRunning?'<span style="margin-left:auto;font-size:.6rem;color:#f9e2af">● Executando...</span>':''}</div>
|
||||
<div class="tf-term-body" id="tfTermOut">${out?escHtml(content):'<span style="opacity:.4">Aguardando execução...</span>'}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function rTfFiles(){
|
||||
if(!S.tfFiles.length)return`<div class="tf-empty">${TF_ICON}<p>Nenhum arquivo gerado ainda</p><p style="font-size:.66rem;opacity:.5">Descreva a infraestrutura desejada no chat acima</p></div>`;
|
||||
// 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;i<files.length;i++){
|
||||
const f=files[i];const isEdit=S.tfEditIdx===i;
|
||||
const sz=f.content?f.content.length:f.size;
|
||||
const sizeStr=sz>1024?(sz/1024).toFixed(1)+' KB':sz+' B';
|
||||
const iconFill=f.auto?'#888':'#7b42bc';
|
||||
return`<div class="tf-file">
|
||||
html+=`<div class="tf-file${isEdit?' editing':''}" onclick="tfOpenFile(${i})">
|
||||
<div class="tf-file-icon"><svg viewBox="0 0 16 16" fill="${iconFill}"><path d="M4 1a1 1 0 00-1 1v12a1 1 0 001 1h8a1 1 0 001-1V4.5L9.5 1H4zm5 0v3.5h3.5"/></svg></div>
|
||||
<div class="tf-file-info"><div class="tf-file-name">${f.name}</div><div class="tf-file-meta">${sizeStr}${f.auto?' · auto-generated':''}</div></div>
|
||||
<div class="tf-file-actions">
|
||||
<div class="tf-file-btn" onclick="tfCopyFile(${i})" title="Copiar"><svg viewBox="0 0 16 16" width="12" height="12" fill="var(--t3)"><path d="M4 2a2 2 0 012-2h4.586L14 3.414V10a2 2 0 01-2 2H6a2 2 0 01-2-2zm6-1.414L13.414 4H10zM2 6a2 2 0 00-2 2v6a2 2 0 002 2h4a2 2 0 002-2v-1H6a3 3 0 01-3-3V6z"/></svg></div>
|
||||
<div class="tf-file-btn" onclick="tfDlFile(${i})" title="Download"><svg viewBox="0 0 16 16" width="12" height="12" fill="var(--t3)"><path d="M8 1v9m0 0L5 7m3 3l3-3M2 12v1.5A1.5 1.5 0 003.5 15h9a1.5 1.5 0 001.5-1.5V12" stroke="var(--t3)" stroke-width="1.5" fill="none" stroke-linecap="round"/></svg></div>
|
||||
${!f.auto?`<div class="tf-file-btn" onclick="event.stopPropagation();tfOpenFile(${isEdit?-1:i})" title="${isEdit?'Fechar':'Editar'}"><svg viewBox="0 0 16 16" width="12" height="12" fill="var(--t3)"><path d="M12.146.854a.5.5 0 01.708 0l2.292 2.292a.5.5 0 010 .708L5.854 13.146a.5.5 0 01-.22.128l-3.5 1.166a.5.5 0 01-.632-.632l1.166-3.5a.5.5 0 01.128-.22z"/></svg></div>`:''}
|
||||
<div class="tf-file-btn" onclick="event.stopPropagation();tfCopyFile(${i})" title="Copiar"><svg viewBox="0 0 16 16" width="12" height="12" fill="var(--t3)"><path d="M4 2a2 2 0 012-2h4.586L14 3.414V10a2 2 0 01-2 2H6a2 2 0 01-2-2zm6-1.414L13.414 4H10zM2 6a2 2 0 00-2 2v6a2 2 0 002 2h4a2 2 0 002-2v-1H6a3 3 0 01-3-3V6z"/></svg></div>
|
||||
<div class="tf-file-btn" onclick="event.stopPropagation();tfDlFile(${i})" title="Download"><svg viewBox="0 0 16 16" width="12" height="12" fill="var(--t3)"><path d="M8 1v9m0 0L5 7m3 3l3-3M2 12v1.5A1.5 1.5 0 003.5 15h9a1.5 1.5 0 001.5-1.5V12" stroke="var(--t3)" stroke-width="1.5" fill="none" stroke-linecap="round"/></svg></div>
|
||||
</div>
|
||||
</div>`}).join('');
|
||||
</div>`;
|
||||
if(isEdit){
|
||||
html+=`<div class="tf-editor">
|
||||
<div class="tf-editor-hdr"><svg viewBox="0 0 16 16" width="12" height="12" fill="#7b42bc"><path d="M12.146.854a.5.5 0 01.708 0l2.292 2.292a.5.5 0 010 .708L5.854 13.146a.5.5 0 01-.22.128l-3.5 1.166a.5.5 0 01-.632-.632l1.166-3.5a.5.5 0 01.128-.22z"/></svg><span class="tf-ed-name">${f.name}</span>${f.auto?'<span style="font-size:.58rem;color:var(--t3);font-weight:400">somente leitura</span>':'<button class="btn bs" onclick="tfSaveFile(${i})" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.6rem;padding:1px 8px">Salvar</button>'}<button class="btn bs" onclick="S.tfEditIdx=-1;R()" style="font-size:.6rem;padding:1px 8px">Fechar</button></div>
|
||||
<textarea class="tf-editor-ta" id="tfEdTa" ${f.auto?'readonly':''}onkeydown="if(event.key==='Tab'){event.preventDefault();const s=this.selectionStart,e=this.selectionEnd;this.value=this.value.substring(0,s)+' '+this.value.substring(e);this.selectionStart=this.selectionEnd=s+2}">${escHtml(f.content||'')}</textarea>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function rTfPlanPanel(){
|
||||
@@ -704,15 +855,6 @@ function rTfPlanPanel(){
|
||||
<div style="font-size:.64rem;color:var(--t3)">${escHtml(r.desc)}</div></div></div>`).join('');
|
||||
}
|
||||
|
||||
function rTfOutputPanel(){
|
||||
let out='';
|
||||
if(S.tfPlanOut)out+=`<div style="font-size:.7rem;font-weight:600;color:#7b42bc;margin-bottom:.3rem">${TF_ICON} Plan Output</div><div class="tf-output">${escHtml(S.tfPlanOut)}</div>`;
|
||||
if(S.tfApplyOut)out+=`<div style="font-size:.7rem;font-weight:600;color:var(--gn);margin-top:.5rem;margin-bottom:.3rem">Apply Output</div><div class="tf-output">${escHtml(S.tfApplyOut)}</div>`;
|
||||
if(S.tfDestroyOut)out+=`<div style="font-size:.7rem;font-weight:600;color:var(--rd);margin-top:.5rem;margin-bottom:.3rem">Destroy Output</div><div class="tf-output">${escHtml(S.tfDestroyOut)}</div>`;
|
||||
if(!out)return`<div class="tf-empty"><svg viewBox="0 0 16 16" width="36" height="36" fill="#7b42bc" opacity=".2"><path d="M2 1h12a1 1 0 011 1v12a1 1 0 01-1 1H2a1 1 0 01-1-1V2a1 1 0 011-1zm1 3v9h10V4zm2 1l3 2.5L5 10z"/></svg><p>Nenhuma execução ainda</p></div>`;
|
||||
return out;
|
||||
}
|
||||
|
||||
function rTfConfirmDestroy(){
|
||||
return`<div class="tf-modal-overlay" onclick="S.tfConfirmDest=false;R()"><div class="tf-modal" onclick="event.stopPropagation()">
|
||||
<h3 style="display:flex;align-items:center;gap:.4rem"><svg viewBox="0 0 16 16" width="18" height="18" fill="var(--rd)"><path d="M8 1a7 7 0 100 14A7 7 0 008 1zm0 2.5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 018 3.5zM8 10a1 1 0 100 2 1 1 0 000-2z"/></svg> Confirmar Destroy</h3>
|
||||
@@ -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:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M1 3h14v2H1zm2 4h10v2H3zm3 4h4v2H6z"/></svg>',fmt:r=>`${r.display_name} <span style="opacity:.6">(${(r.cidr_blocks||[]).join(', ')})</span>`},
|
||||
{key:'subnets',label:'Subnets',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M2 2h5v5H2zm7 0h5v5H9zm-7 7h5v5H2zm7 0h5v5H9z"/></svg>',fmt:r=>`${r.display_name} <span style="opacity:.6">(${r.cidr_block}, ${r.public?'pública':'privada'})</span>`},
|
||||
{key:'instances',label:'Compute',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M2 2h12v12H2z"/></svg>',fmt:r=>`${r.display_name} <span style="opacity:.6">(${r.shape})</span>`},
|
||||
{key:'instances',label:'Compute',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M2 2h12v12H2z"/></svg>',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} <span style="opacity:.6">(${r.shape})</span> <span style="color:${stC};font-weight:600;font-size:.62rem">${r.state}</span>${stopped?` <button class="btn bsm" style="font-size:.58rem;padding:1px 6px;background:var(--gn);color:#fff;border-color:var(--gn);margin-left:4px" onclick="event.stopPropagation();ociStartInstance('${r.id}')">▶ Start</button>`:running?` <button class="btn bsm" style="font-size:.58rem;padding:1px 6px;background:var(--rd);color:#fff;border-color:var(--rd);margin-left:4px" onclick="event.stopPropagation();ociStopInstance('${r.id}')">■ Stop</button>`:''}`}},
|
||||
{key:'internet_gateways',label:'Internet GWs',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><circle cx="8" cy="8" r="6" fill="none" stroke="#7b42bc" stroke-width="1.5"/><path d="M1 8h14M8 2c-2 2-2 10 0 12m0-12c2 2 2 10 0 12"/></svg>',fmt:r=>r.display_name},
|
||||
{key:'nat_gateways',label:'NAT GWs',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M3 3l5 5-5 5"/><path d="M8 8h6" stroke="#7b42bc" stroke-width="1.5"/></svg>',fmt:r=>r.display_name},
|
||||
{key:'route_tables',label:'Route Tables',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M2 3h12v2H2zm0 4h8v2H2zm0 4h10v2H2z"/></svg>',fmt:r=>r.display_name},
|
||||
{key:'security_lists',label:'Security Lists',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M8 1l6 3v4c0 3.5-2.5 6.5-6 7.5C4.5 14.5 2 11.5 2 8V4z"/></svg>',fmt:r=>r.display_name},
|
||||
{key:'autonomous_databases',label:'Databases',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><ellipse cx="8" cy="4" rx="6" ry="2.5"/><path d="M2 4v8c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5V4"/></svg>',fmt:r=>`${r.display_name} <span style="opacity:.6">(${r.db_name}${r.is_free_tier?' Free':''})</span>`},
|
||||
{key:'autonomous_databases',label:'Databases',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><ellipse cx="8" cy="4" rx="6" ry="2.5"/><path d="M2 4v8c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5V4"/></svg>',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} <span style="opacity:.6">(${r.db_name}${r.is_free_tier?' Free':''})</span> <span style="color:${stC};font-weight:600;font-size:.62rem">${r.state}</span>${stopped?` <button class="btn bsm" style="font-size:.58rem;padding:1px 6px;background:var(--gn);color:#fff;border-color:var(--gn);margin-left:4px" onclick="event.stopPropagation();ociStartAdb('${r.id}')">▶ Start</button>`:avail?` <button class="btn bsm" style="font-size:.58rem;padding:1px 6px;background:var(--rd);color:#fff;border-color:var(--rd);margin-left:4px" onclick="event.stopPropagation();ociStopAdb('${r.id}')">■ Stop</button>`:''}`}},
|
||||
{key:'block_volumes',label:'Block Volumes',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M3 2h10v12H3z"/></svg>',fmt:r=>`${r.display_name} <span style="opacity:.6">(${r.size_in_gbs||'?'} GB)</span>`},
|
||||
{key:'load_balancers',label:'Load Balancers',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M8 2v12M2 8h12M4 4l8 8M12 4l-8 8"/></svg>',fmt:r=>`${r.display_name} <span style="opacity:.6">(${r.shape_name||'flexible'})</span>`}
|
||||
];
|
||||
@@ -815,16 +959,22 @@ function rTfResourcesPanel(){
|
||||
html+='</div>';
|
||||
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`<div style="width:28px;height:28px;border-radius:6px;background:${c}14;display:flex;align-items:center;justify-content:center;flex-shrink:0"><span style="font-size:.58rem;font-weight:700;color:${c};text-transform:uppercase;letter-spacing:.3px">${ext}</span></div>`;
|
||||
}
|
||||
function rDl(){
|
||||
|
||||
Reference in New Issue
Block a user