diff --git a/README.md b/README.md
index 00204b5..a15a7eb 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-
+
@@ -33,11 +33,12 @@ The platform combines security compliance scanning, AI-powered chat with **RAG (
- **OCI Generative AI** integration via official SDK (`oci.generative_ai_inference`)
- **RAG (Retrieval-Augmented Generation)**: automatically queries ADB vector store for relevant context before generating responses
- **MCP Tool Use (Function Calling)**: GenAI models can call tools from registered MCP servers during chat — supports both Cohere and Generic (OpenAI-style) function calling formats with automatic tool execution loop (max 5 iterations)
-- **Chat Memory Compaction**: automatic summarization of older messages when conversation exceeds ~8000 tokens — keeps 6 recent messages intact and generates an LLM-based summary of older context, similar to Claude Code's context compression
+- **Chat Memory Compaction**: automatic summarization of older messages when conversation exceeds ~6000 tokens — keeps 20 recent messages (10 interactions) intact and generates an LLM-based summary of older context, similar to Claude Code's context compression
+- **Chat Sidebar**: collapsible right-side panel with model parameters (temperature, max_tokens, top_p, top_k, penalties) for quick adjustment during conversation
- **Multimodal Chat**: upload images (PNG/JPG/GIF/WebP), PDFs, and text files directly in the chat for AI analysis — supports up to 5 files per message via OCI GenAI `ImageContent` and `DocumentContent`
- **Async Background Processing**: chat requests return immediately, GenAI + MCP tools process in background via dedicated thread pool (16 threads), frontend polls for results — eliminates 504 timeouts on long-running scans
- **Thinking Indicator**: button disables and shows spinner + "Pensando..." while waiting for GenAI response, with message timestamps
-- 69 chat models + 11 embedding models across 6 providers: **Cohere**, **Meta**, **Google**, **OpenAI** (GPT-5.3/5.2/5.1/5/4.1/4o, Codex, Image, Audio, o1/o3/o4-mini, GPT-oss), **xAI** (Grok 4.1/4/3), **ProtectAI**
+- 15 chat models + 3 embedding models across 5 providers: **Meta** (Llama 4), **Google** (Gemini 2.5), **OpenAI** (GPT-5.2/5.1/5 Mini/4.1/4o, o3/o4-mini), **xAI** (Grok 4/3)
- OCID-based model resolution: catalog maps model IDs to OCI resource IDs per region for reliable API calls
- 16 OCI regions supported with auto-generated endpoints
- Full parameter control: temperature, max_tokens, top_p, top_k, frequency/presence penalty
@@ -45,9 +46,32 @@ The platform combines security compliance scanning, AI-powered chat with **RAG (
- Conversation history with session management
- On-Demand and Dedicated serving modes
+### 🏗️ Terraform Agent (IaC)
+- **AI-powered Terraform code generation** for OCI infrastructure provisioning
+- Dedicated chat interface with OCI Terraform provider context
+- **Workspace management**: create, plan, apply, destroy Terraform workspaces
+- **Compartment-aware**: browse and select target compartment for provisioning, with existing OCI resources injected as context
+- **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
+- Terraform CLI installed in container (v1.7.5)
+- Dedicated system prompt optimized for OCI Terraform provider best practices
+
### 🔍 OCI Account Explorer
-- Browse tenancy resources directly from the UI
-- Explore: Compartments, Regions, VCNs, Compute Instances, Autonomous Databases, Object Storage Buckets
+- **Tree-view navigation** with resizable side panel and drag handle
+- **40+ resource types** across 8 categories:
+ - **IAM**: Users, Groups, Policies, Dynamic Groups
+ - **Compute**: Instances, Instance Pools, Container Instances
+ - **Networking**: VCNs, Subnets, Security Lists, NSGs, Route Tables, NAT/Internet/Service Gateways, Public IPs, Network Firewalls, Firewall Policies
+ - **Storage**: Block Volumes, Boot Volumes, Object Storage Buckets, File Systems, Mount Targets
+ - **Database**: Autonomous Databases, DB Systems, MySQL DB Systems
+ - **Kubernetes**: OKE Clusters
+ - **Observability**: Alarms, Log Groups, Events Rules, Notification Topics
+ - **Security**: Vaults, DNS Zones, API Gateways
+- **Multi-region support**: select and filter by multiple OCI regions with checkbox selection
+- **Compartment tree**: hierarchical compartment browser with full OCID visibility
+- **Security list detail**: view ingress/egress rules directly in the explorer
- Select which OCI connection to explore
- Real-time API calls via OCI Python SDK
@@ -78,7 +102,9 @@ The platform combines security compliance scanning, AI-powered chat with **RAG (
- **Per-section data collection**: each scan tool collects only the OCI data needed for that section, avoiding unnecessary API calls
- **Region-specific scanning**: all scan tools accept optional `regions` parameter to target specific OCI regions (e.g., `["us-ashburn-1"]`) instead of scanning the entire tenancy
- **Session caching**: collected data is cached per config+regions scope (2-hour TTL), so subsequent scans on different sections reuse shared prerequisites (compartments, identity domains)
-- **Parallelized data collection**: base collectors and regional collectors run in parallel thread pools (up to 8 workers), with 5-minute timeout per tool call
+- **Compartment filtering**: scan results can be filtered by specific compartment, showing only relevant findings
+- **Collection error tracking**: errors during data collection are captured per section and reported to the caller
+- **Parallelized data collection**: base collectors and regional collectors run in parallel thread pools (up to 8 workers), with 30-minute timeout per tool call and 1 automatic retry on failure
- Based on Oracle's official `cis_reports.py` (6660 lines, 48 CIS + 11 OBP checks)
### 🔌 MCP Server Registry + Tool Discovery
@@ -110,8 +136,9 @@ The platform combines security compliance scanning, AI-powered chat with **RAG (
- Browse, inspect and delete individual embeddings from the ADB vector store
- 11 embedding models supported including Cohere Embed v4.0 and OpenAI Text Embedding 3
-### 📜 Configuration Logs
+### 📜 Configuration & Chat Logs
- **Persistent activity log** per configuration tab (OCI, GenAI, ADB, MCP)
+- **Chat audit logs**: all chat interactions (user messages, AI responses, tool calls, errors) logged with session/message ID, severity, and source
- Logs all test, save, upload, report, and ingest operations with severity (success/error/info)
- **Inline log panel** at the bottom of each config tab with severity filter
- Auto-cleanup of logs older than 30 days
@@ -147,6 +174,9 @@ The platform combines security compliance scanning, AI-powered chat with **RAG (
│ │ ├─────────────┤ (discover │ │
│ │ │ │ +execute) │ │
│ │ ├─────────────┤ │ │
+│ │ │ Terraform │──▶ Plan / │ │
+│ │ │ │ Apply │ │
+│ │ ├─────────────┤ │ │
│ │ │ oracledb │──▶ ADB │ │
│ │ ├─────────────┤ │ │
│ │ │ RAG Pipeline │──▶ Embed + │ │
@@ -316,13 +346,13 @@ Allow group to read buckets in compartment
```
oci-cis-agent/
├── backend/
-│ ├── app.py # FastAPI application (~2600 lines)
+│ ├── app.py # FastAPI application (~3800 lines)
│ ├── cis_reports.py # Oracle CIS Benchmark checker (6660 lines, report engine)
-│ ├── mcp_cis_server.py # MCP server with 12 granular CIS tools
-│ ├── Dockerfile # Python 3.12 + OCI CLI
+│ ├── 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 (~900 lines)
+│ └── index.html # SPA with Oracle Cloud theme (~1780 lines)
├── nginx/
│ └── default.conf # Reverse proxy config
├── docker-compose.yml # Orchestration
@@ -360,11 +390,44 @@ oci-cis-agent/
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/oci/explore/{id}/compartments` | List compartments |
+| GET | `/api/oci/explore/{id}/compartment-tree` | Hierarchical compartment tree |
| GET | `/api/oci/explore/{id}/regions` | List subscribed regions |
| GET | `/api/oci/explore/{id}/vcns` | List VCNs |
-| GET | `/api/oci/explore/{id}/instances` | List compute instances |
+| GET | `/api/oci/explore/{id}/subnets` | List Subnets |
+| GET | `/api/oci/explore/{id}/security_lists` | List Security Lists (with rules) |
+| GET | `/api/oci/explore/{id}/nsgs` | List Network Security Groups |
+| GET | `/api/oci/explore/{id}/route_tables` | List Route Tables (with rules) |
+| GET | `/api/oci/explore/{id}/nat_gateways` | List NAT Gateways |
+| GET | `/api/oci/explore/{id}/internet_gateways` | List Internet Gateways |
+| GET | `/api/oci/explore/{id}/service_gateways` | List Service Gateways |
+| GET | `/api/oci/explore/{id}/network_firewalls` | List Network Firewalls |
+| GET | `/api/oci/explore/{id}/network_firewall_policies` | List Firewall Policies |
+| GET | `/api/oci/explore/{id}/public_ips` | List Public IPs |
+| GET | `/api/oci/explore/{id}/instances` | List Compute Instances |
+| GET | `/api/oci/explore/{id}/instance_pools` | List Instance Pools |
+| GET | `/api/oci/explore/{id}/container_instances` | List Container Instances |
| GET | `/api/oci/explore/{id}/databases` | List Autonomous Databases |
-| GET | `/api/oci/explore/{id}/buckets` | List Object Storage buckets |
+| GET | `/api/oci/explore/{id}/db_systems` | List DB Systems |
+| GET | `/api/oci/explore/{id}/mysql_db_systems` | List MySQL DB Systems |
+| GET | `/api/oci/explore/{id}/buckets` | List Object Storage Buckets |
+| GET | `/api/oci/explore/{id}/block_volumes` | List Block Volumes |
+| GET | `/api/oci/explore/{id}/boot_volumes` | List Boot Volumes |
+| GET | `/api/oci/explore/{id}/file_systems` | List File Systems |
+| GET | `/api/oci/explore/{id}/mount_targets` | List Mount Targets |
+| GET | `/api/oci/explore/{id}/oke_clusters` | List OKE Clusters |
+| GET | `/api/oci/explore/{id}/load_balancers` | List Load Balancers |
+| GET | `/api/oci/explore/{id}/functions` | List Functions |
+| GET | `/api/oci/explore/{id}/iam_users` | List IAM Users |
+| GET | `/api/oci/explore/{id}/iam_groups` | List IAM Groups |
+| GET | `/api/oci/explore/{id}/iam_policies` | List IAM Policies |
+| GET | `/api/oci/explore/{id}/iam_dynamic_groups` | List Dynamic Groups |
+| GET | `/api/oci/explore/{id}/vaults` | List Vaults |
+| GET | `/api/oci/explore/{id}/dns_zones` | List DNS Zones |
+| GET | `/api/oci/explore/{id}/api_gateways` | List API Gateways |
+| GET | `/api/oci/explore/{id}/alarms` | List Alarms |
+| GET | `/api/oci/explore/{id}/log_groups` | List Log Groups |
+| GET | `/api/oci/explore/{id}/notification_topics` | List Notification Topics |
+| GET | `/api/oci/explore/{id}/events_rules` | List Events Rules |
### Generative AI
@@ -417,6 +480,24 @@ oci-cis-agent/
| GET | `/api/embeddings/{vid}/list` | List embeddings in ADB (paginated, accepts `table_name` query param) |
| DELETE | `/api/embeddings/{vid}/{doc_id}` | Delete individual embedding (accepts `table_name` query param) |
+### Terraform Agent
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| POST | `/api/terraform/chat` | Send message to Terraform Agent |
+| GET | `/api/terraform/resources` | List OCI resources in compartment (for context) |
+| POST | `/api/terraform/workspaces` | Create Terraform workspace |
+| GET | `/api/terraform/workspaces` | List user's workspaces |
+| GET | `/api/terraform/workspaces/{wid}` | Get workspace details |
+| PUT | `/api/terraform/workspaces/{wid}/code` | Update workspace Terraform code |
+| POST | `/api/terraform/workspaces/{wid}/plan` | Run `terraform plan` |
+| POST | `/api/terraform/workspaces/{wid}/apply` | Run `terraform apply` |
+| POST | `/api/terraform/workspaces/{wid}/destroy` | Run `terraform destroy` |
+| GET | `/api/terraform/workspaces/{wid}/status` | Poll workspace execution status |
+| GET | `/api/terraform/workspaces/{wid}/download` | Download workspace `.tf` files |
+| POST | `/api/terraform/workspaces/{wid}/cancel` | Cancel running Terraform operation |
+| DELETE | `/api/terraform/workspaces/{wid}` | Delete workspace |
+
### Chat & Reports
| Method | Endpoint | Description |
@@ -458,7 +539,7 @@ oci-cis-agent/
---
-## Supported GenAI Models (69 Chat + 11 Embedding)
+## Supported GenAI Models (15 Chat + 3 Embedding)
All models include OCID mapping for `us-ashburn-1`. For other regions, use the "Personalizado (usar OCID)" option.
@@ -466,75 +547,21 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the "
| Provider | Model | Model ID | API Format |
|----------|-------|----------|------------|
-| Cohere | Command A Reasoning | `cohere.command-a-reasoning` | COHERE |
-| Cohere | Command A Vision | `cohere.command-a-vision` | COHERE |
-| Cohere | Command A | `cohere.command-a-03-2025` | COHERE |
-| Cohere | Command R+ (08-2024) | `cohere.command-r-plus-08-2024` | COHERE |
-| Cohere | Command R (08-2024) | `cohere.command-r-08-2024` | COHERE |
| Meta | Llama 4 Maverick | `meta.llama-4-maverick-17b-128e-instruct-fp8` | GENERIC |
| Meta | Llama 4 Scout | `meta.llama-4-scout-17b-16e-instruct` | GENERIC |
-| Meta | Llama Guard 4 (12B) | `meta.llama-guard-4-12b` | GENERIC |
| Google | Gemini 2.5 Pro | `google.gemini-2.5-pro` | GENERIC |
| Google | Gemini 2.5 Flash | `google.gemini-2.5-flash` | GENERIC |
-| Google | Gemini 2.5 Flash-Lite | `google.gemini-2.5-flash-lite` | GENERIC |
-| OpenAI | GPT-5.3 Codex | `openai.gpt-5.3-codex` | GENERIC |
-| OpenAI | GPT-5.2 Codex | `openai.gpt-5.2-codex` | GENERIC |
-| OpenAI | GPT-5.2 Pro | `openai.gpt-5.2-pro` | GENERIC |
-| OpenAI | GPT-5.2 Pro (2025-12-11) | `openai.gpt-5.2-pro-2025-12-11` | GENERIC |
| OpenAI | GPT-5.2 | `openai.gpt-5.2` | GENERIC |
-| OpenAI | GPT-5.2 (2025-12-11) | `openai.gpt-5.2-2025-12-11` | GENERIC |
-| OpenAI | GPT-5.2 Chat Latest | `openai.gpt-5.2-chat-latest` | GENERIC |
-| OpenAI | GPT-5.1 Codex Max | `openai.gpt-5.1-codex-max` | GENERIC |
-| OpenAI | GPT-5.1 Codex | `openai.gpt-5.1-codex` | GENERIC |
-| OpenAI | GPT-5.1 Codex Mini | `openai.gpt-5.1-codex-mini` | GENERIC |
| OpenAI | GPT-5.1 | `openai.gpt-5.1` | GENERIC |
-| OpenAI | GPT-5.1 (2025-11-13) | `openai.gpt-5.1-2025-11-13` | GENERIC |
-| OpenAI | GPT-5.1 Chat Latest | `openai.gpt-5.1-chat-latest` | GENERIC |
-| OpenAI | GPT-5 Codex | `openai.gpt-5-codex` | GENERIC |
-| OpenAI | GPT-5 | `openai.gpt-5` | GENERIC |
-| OpenAI | GPT-5 (2025-08-07) | `openai.gpt-5-2025-08-07` | GENERIC |
| OpenAI | GPT-5 Mini | `openai.gpt-5-mini` | GENERIC |
-| OpenAI | GPT-5 Mini (2025-08-07) | `openai.gpt-5-mini-2025-08-07` | GENERIC |
-| OpenAI | GPT-5 Nano | `openai.gpt-5-nano` | GENERIC |
-| OpenAI | GPT-5 Nano (2025-08-07) | `openai.gpt-5-nano-2025-08-07` | GENERIC |
-| OpenAI | GPT-4.1 | `openai.gpt-4.1` | GENERIC |
-| OpenAI | GPT-4.1 (2025-04-14) | `openai.gpt-4.1-2025-04-14` | GENERIC |
+| OpenAI | **GPT-4.1 (Default)** | `openai.gpt-4.1` | GENERIC |
| OpenAI | GPT-4.1 Mini | `openai.gpt-4.1-mini` | GENERIC |
-| OpenAI | GPT-4.1 Mini (2025-04-14) | `openai.gpt-4.1-mini-2025-04-14` | GENERIC |
-| OpenAI | GPT-4.1 Nano | `openai.gpt-4.1-nano` | GENERIC |
-| OpenAI | GPT-4.1 Nano (2025-04-14) | `openai.gpt-4.1-nano-2025-04-14` | GENERIC |
| OpenAI | GPT-4o | `openai.gpt-4o` | GENERIC |
-| OpenAI | GPT-4o (2024-08-06) | `openai.gpt-4o-2024-08-06` | GENERIC |
-| OpenAI | GPT-4o (2024-11-20) | `openai.gpt-4o-2024-11-20` | GENERIC |
-| OpenAI | GPT-4o Mini | `openai.gpt-4o-mini` | GENERIC |
-| OpenAI | GPT-4o Mini Search Preview | `openai.gpt-4o-mini-search-preview` | GENERIC |
-| OpenAI | GPT-4o Mini Search (2025-03-11) | `openai.gpt-4o-mini-search-preview-2025-03-11` | GENERIC |
-| OpenAI | GPT-4o Search Preview | `openai.gpt-4o-search-preview` | GENERIC |
-| OpenAI | GPT-4o Search (2025-03-11) | `openai.gpt-4o-search-preview-2025-03-11` | GENERIC |
-| OpenAI | GPT Image 1.5 | `openai.gpt-image-1.5` | GENERIC |
-| OpenAI | GPT Image 1 | `openai.gpt-image-1` | GENERIC |
-| OpenAI | GPT Audio | `openai.gpt-audio` | GENERIC |
| OpenAI | o4-mini | `openai.o4-mini` | GENERIC |
-| OpenAI | o4-mini (2025-04-16) | `openai.o4-mini-2025-04-16` | GENERIC |
| OpenAI | o3 | `openai.o3` | GENERIC |
-| OpenAI | o3 (2025-04-16) | `openai.o3-2025-04-16` | GENERIC |
-| OpenAI | o3-mini | `openai.o3-mini` | GENERIC |
-| OpenAI | o3-mini (2025-01-31) | `openai.o3-mini-2025-01-31` | GENERIC |
-| OpenAI | o1 | `openai.o1` | GENERIC |
-| OpenAI | o1 (2024-12-17) | `openai.o1-2024-12-17` | GENERIC |
-| OpenAI | GPT-oss (120B) | `openai.gpt-oss-120b` | GENERIC |
-| OpenAI | GPT-oss (20B) | `openai.gpt-oss-20b` | GENERIC |
| xAI | Grok 4 | `xai.grok-4` | GENERIC |
-| xAI | Grok 4.1 Fast Reasoning | `xai.grok-4-1-fast-reasoning` | GENERIC |
-| xAI | Grok 4.1 Fast Non-Reasoning | `xai.grok-4-1-fast-non-reasoning` | GENERIC |
-| xAI | Grok 4 Fast Reasoning | `xai.grok-4-fast-reasoning` | GENERIC |
-| xAI | Grok 4 Fast Non-Reasoning | `xai.grok-4-fast-non-reasoning` | GENERIC |
| xAI | Grok 3 | `xai.grok-3` | GENERIC |
-| xAI | Grok 3 Mini | `xai.grok-3-mini` | GENERIC |
-| xAI | Grok 3 Fast | `xai.grok-3-fast` | GENERIC |
| xAI | Grok 3 Mini Fast | `xai.grok-3-mini-fast` | GENERIC |
-| xAI | Grok Code Fast 1 | `xai.grok-code-fast-1` | GENERIC |
-| ProtectAI | DeBERTa Prompt Injection v2 | `protectai.deberta-v3-base-prompt-injection-v2` | GENERIC |
> **Custom OCID**: You can also use any model available in your region by selecting "Personalizado (usar OCID)" and providing the full model OCID.
@@ -545,14 +572,6 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the "
| Cohere | Embed v4.0 (Multimodal) | `cohere.embed-v4.0` | 1536 |
| OpenAI | Text Embedding 3 Large | `openai.text-embedding-3-large` | 3072 |
| OpenAI | Text Embedding 3 Small | `openai.text-embedding-3-small` | 1536 |
-| Cohere | Embed English v3.0 | `cohere.embed-english-v3.0` | 1024 |
-| Cohere | Embed Multilingual v3.0 | `cohere.embed-multilingual-v3.0` | 1024 |
-| Cohere | Embed English Light v3.0 | `cohere.embed-english-light-v3.0` | 384 |
-| Cohere | Embed Multilingual Light v3.0 | `cohere.embed-multilingual-light-v3.0` | 384 |
-| Cohere | Embed English Image v3.0 | `cohere.embed-english-image-v3.0` | 1024 |
-| Cohere | Embed Multilingual Image v3.0 | `cohere.embed-multilingual-image-v3.0` | 1024 |
-| Cohere | Embed English Light Image v3.0 | `cohere.embed-english-light-image-v3.0` | 384 |
-| Cohere | Embed Multilingual Light Image v3.0 | `cohere.embed-multilingual-light-image-v3.0` | 384 |
### GenAI Regions
@@ -573,6 +592,7 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the "
| GenAI | `oci.generative_ai_inference` |
| Container | Docker Compose, Nginx reverse proxy |
| MCP | Model Context Protocol SDK (stdio/SSE) with tool discovery + execution |
+| Terraform | Terraform CLI 1.7.5, OCI Provider |
| CIS Scanner | Oracle CIS Foundations Benchmark 3.0 checker (`cis_reports.py`) |
---
@@ -581,6 +601,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.0** | 2026-03 | Async background chat processing (no more 504 timeouts), frontend polling with timestamps, 8 uvicorn workers + 16-thread chat executor for ~12 simultaneous chats, parallelized MCP data collection (5-thread base + 8-thread regional), 2-hour MCP session cache, 5-min tool timeout, full dead code cleanup across backend/frontend/MCP |
| **v1.9** | 2026-03 | Multimodal chat (image/PDF/text file upload with OCI GenAI ImageContent/DocumentContent), region-specific MCP scanning (`regions` param on all scan tools), orphaned report auto-detection on progress poll, nginx timeout increased to 15min, improved API error handling for non-JSON responses |
| **v1.8** | 2026-03 | CIS Engine auto-update from Oracle GitHub with automatic patch reapplication, version check UI card (admin), new `/api/cis-engine/*` endpoints, report file listing and individual download endpoints, reorganized Reports tab (execution history + status) and Downloads tab (file browser only with expandable cards per report), CIS Level description tooltip, persistent log expand during report generation |
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 5ddbc4f..39a488b 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -2,9 +2,12 @@ FROM python:3.12-slim
WORKDIR /app
-# Install system deps
+# Install system deps + Terraform CLI
RUN apt-get update && apt-get install -y --no-install-recommends \
- curl gcc libffi-dev && \
+ curl gcc libffi-dev unzip && \
+ ARCH=$(dpkg --print-architecture) && \
+ curl -fsSL "https://releases.hashicorp.com/terraform/1.7.5/terraform_1.7.5_linux_${ARCH}.zip" -o /tmp/tf.zip && \
+ unzip /tmp/tf.zip -d /usr/local/bin/ && rm /tmp/tf.zip && \
rm -rf /var/lib/apt/lists/*
# Install Python deps + OCI CLI
diff --git a/backend/app.py b/backend/app.py
index 094e657..bafdda5 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -38,13 +38,16 @@ for d in [DATA, OCI_DIR, REPORTS, MCP_DIR, WALLET_DIR]:
d.mkdir(parents=True, exist_ok=True)
_running_reports: dict[str, asyncio.subprocess.Process] = {} # rid → subprocess
+_running_terraform: dict[str, asyncio.subprocess.Process] = {} # wid → subprocess
+TERRAFORM_DIR = DATA / "terraform"
+TERRAFORM_DIR.mkdir(parents=True, exist_ok=True)
_chat_executor = concurrent.futures.ThreadPoolExecutor(max_workers=16, thread_name_prefix="chat")
# ── Chat Memory Compaction Settings ──
-COMPACT_TOKEN_THRESHOLD = 8000 # estimated tokens before triggering compaction
-COMPACT_KEEP_RECENT = 6 # recent messages to keep uncompacted
-COMPACT_SUMMARY_MAX_TOKENS = 1000
-COMPACT_MIN_MESSAGES = 6
+COMPACT_TOKEN_THRESHOLD = 6000 # estimated tokens before triggering compaction
+COMPACT_KEEP_RECENT = 20 # recent messages to keep uncompacted (10 interactions = 20 msgs)
+COMPACT_SUMMARY_MAX_TOKENS = 1500
+COMPACT_MIN_MESSAGES = 8
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("agent")
@@ -300,8 +303,33 @@ def init_db():
is_active INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
+ CREATE TABLE IF NOT EXISTS chat_logs (
+ id TEXT PRIMARY KEY,
+ session_id TEXT,
+ message_id TEXT,
+ user_id TEXT,
+ severity TEXT NOT NULL,
+ source TEXT NOT NULL,
+ action TEXT NOT NULL,
+ message TEXT NOT NULL,
+ created_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,
+ oci_config_id TEXT NOT NULL,
+ compartment_id TEXT,
+ name TEXT DEFAULT 'workspace',
+ tf_code TEXT,
+ status TEXT DEFAULT 'draft',
+ plan_output TEXT, apply_output TEXT, destroy_output TEXT,
+ error TEXT,
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now'))
+ );
""")
c.execute("DELETE FROM config_logs WHERE created_at < datetime('now', '-30 days')")
+ c.execute("DELETE FROM chat_logs WHERE created_at < datetime('now', '-30 days')")
# ── Migrations ──
for col in ["system_prompt TEXT DEFAULT ''"]:
try:
@@ -314,7 +342,7 @@ def init_db():
except sqlite3.OperationalError:
pass
for col in ["progress TEXT DEFAULT ''", "level INTEGER DEFAULT 2", "obp_checks INTEGER DEFAULT 0",
- "raw_data INTEGER DEFAULT 0", "redact_output INTEGER DEFAULT 0"]:
+ "raw_data INTEGER DEFAULT 0", "redact_output INTEGER DEFAULT 0", "worker_pid INTEGER"]:
try:
c.execute(f"ALTER TABLE reports ADD COLUMN {col}")
except sqlite3.OperationalError:
@@ -336,6 +364,9 @@ def init_db():
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 (?,?,?,?,?)",
(str(uuid.uuid4()), "OCI CIS RAG Agent", "chat", RAG_DEFAULT_SYSTEM_PROMPT, 1))
+ if not c.execute("SELECT 1 FROM system_prompts WHERE agent='terraform'").fetchone():
+ c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active) VALUES (?,?,?,?,?)",
+ (str(uuid.uuid4()), "OCI Terraform Agent", "terraform", TF_DEFAULT_SYSTEM_PROMPT, 1))
adm = c.execute("SELECT id FROM users WHERE username='admin'").fetchone()
if not adm:
c.execute(
@@ -407,6 +438,11 @@ def _config_log(config_type, config_id, config_name, severity, action, message,
c.execute("INSERT INTO config_logs (id,config_type,config_id,config_name,severity,action,message,user_id,username) VALUES (?,?,?,?,?,?,?,?,?)",
(str(uuid.uuid4()), config_type, config_id, config_name or "", severity, action, str(message)[:2000], uid, uname))
+def _chat_log(sid, mid, uid, severity, source, action, message):
+ with db() as c:
+ c.execute("INSERT INTO chat_logs (id,session_id,message_id,user_id,severity,source,action,message) VALUES (?,?,?,?,?,?,?,?)",
+ (str(uuid.uuid4()), sid, mid, uid, severity, source, action, str(message)[:2000]))
+
# ── Models ────────────────────────────────────────────────────────────────────
class LoginReq(BaseModel):
username: str; password: str; totp_code: Optional[str] = None
@@ -716,43 +752,92 @@ async def explore_regions(cid: str, u=Depends(current_user)):
except Exception as e:
return {"error": str(e)[:500]}
-@app.get("/api/oci/explore/{cid}/vcns")
-async def explore_vcns(cid: str, compartment_id: str = Query(None), u=Depends(current_user)):
+def _explore_comp(cid):
+ """Helper: resolve compartment from config."""
+ with db() as c:
+ cfg = c.execute("SELECT tenancy_ocid,compartment_id FROM oci_configs WHERE id=?", (cid,)).fetchone()
+ return cfg, _safe_dec(cfg["compartment_id"]) or _safe_dec(cfg["tenancy_ocid"])
+
+@app.get("/api/oci/explore/{cid}/compartment-tree")
+async def explore_compartment_tree(cid: str, u=Depends(current_user)):
try:
import oci
config = _get_oci_config(cid)
+ identity = oci.identity.IdentityClient(config)
+ cfg, default_comp = _explore_comp(cid)
+ tenancy = _safe_dec(cfg["tenancy_ocid"]) or default_comp
+ user_comp = _safe_dec(cfg["compartment_id"])
+ # Use tenancy as root for the tree
+ tree = [{"id": tenancy, "name": "root (tenancy)", "parent_id": None}]
+ try:
+ comps = identity.list_compartments(tenancy, compartment_id_in_subtree=True).data
+ seen = {tenancy}
+ for cp in comps:
+ if cp.lifecycle_state == "ACTIVE":
+ tree.append({"id":cp.id, "name":cp.name, "parent_id":cp.compartment_id,
+ "lifecycle_state":cp.lifecycle_state})
+ seen.add(cp.id)
+ # If user's configured compartment is not in the tree, add it explicitly
+ if user_comp and user_comp != tenancy and user_comp not in seen:
+ try:
+ cp = identity.get_compartment(user_comp).data
+ tree.append({"id":cp.id, "name":cp.name, "parent_id":cp.compartment_id,
+ "lifecycle_state":cp.lifecycle_state})
+ except Exception:
+ tree.append({"id":user_comp, "name":"Meu Compartment", "parent_id":tenancy,
+ "lifecycle_state":"ACTIVE"})
+ except Exception:
+ # If listing from tenancy fails (no permission), list from user's compartment
+ if user_comp and user_comp != tenancy:
+ try:
+ cp = identity.get_compartment(user_comp).data
+ tree = [{"id":cp.id, "name":cp.name, "parent_id": None, "lifecycle_state":cp.lifecycle_state}]
+ sub = identity.list_compartments(user_comp, compartment_id_in_subtree=True).data
+ tree += [{"id":s.id, "name":s.name, "parent_id":s.compartment_id,
+ "lifecycle_state":s.lifecycle_state} for s in sub if s.lifecycle_state == "ACTIVE"]
+ except Exception:
+ tree = [{"id":user_comp, "name":"Meu Compartment", "parent_id": None, "lifecycle_state":"ACTIVE"}]
+ return tree
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/vcns")
+async def explore_vcns(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
vn = oci.core.VirtualNetworkClient(config)
- with db() as c:
- cfg = c.execute("SELECT tenancy_ocid,compartment_id FROM oci_configs WHERE id=?", (cid,)).fetchone()
- comp = compartment_id or _safe_dec(cfg["compartment_id"]) or _safe_dec(cfg["tenancy_ocid"])
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
vcns = vn.list_vcns(comp).data
return [{"id":v.id,"display_name":v.display_name,"cidr_blocks":v.cidr_blocks,"lifecycle_state":v.lifecycle_state} for v in vcns]
except Exception as e:
return {"error": str(e)[:500]}
@app.get("/api/oci/explore/{cid}/instances")
-async def explore_instances(cid: str, compartment_id: str = Query(None), u=Depends(current_user)):
+async def explore_instances(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
try:
import oci
config = _get_oci_config(cid)
+ if region: config["region"] = region
compute = oci.core.ComputeClient(config)
- with db() as c:
- cfg = c.execute("SELECT tenancy_ocid,compartment_id FROM oci_configs WHERE id=?", (cid,)).fetchone()
- comp = compartment_id or _safe_dec(cfg["compartment_id"]) or _safe_dec(cfg["tenancy_ocid"])
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
insts = compute.list_instances(comp).data
return [{"id":i.id,"display_name":i.display_name,"shape":i.shape,"lifecycle_state":i.lifecycle_state,"region":i.region,"time_created":str(i.time_created)} for i in insts]
except Exception as e:
return {"error": str(e)[:500]}
@app.get("/api/oci/explore/{cid}/databases")
-async def explore_databases(cid: str, compartment_id: str = Query(None), u=Depends(current_user)):
+async def explore_databases(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
try:
import oci
config = _get_oci_config(cid)
+ if region: config["region"] = region
db_client = oci.database.DatabaseClient(config)
- with db() as c:
- cfg = c.execute("SELECT tenancy_ocid,compartment_id FROM oci_configs WHERE id=?", (cid,)).fetchone()
- comp = compartment_id or _safe_dec(cfg["compartment_id"]) or _safe_dec(cfg["tenancy_ocid"])
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
adbs = db_client.list_autonomous_databases(comp).data
return [{"id":a.id,"display_name":a.display_name,"db_name":a.db_name,"lifecycle_state":a.lifecycle_state,
"cpu_core_count":a.cpu_core_count,"data_storage_size_in_tbs":a.data_storage_size_in_tbs,
@@ -761,20 +846,562 @@ async def explore_databases(cid: str, compartment_id: str = Query(None), u=Depen
return {"error": str(e)[:500]}
@app.get("/api/oci/explore/{cid}/buckets")
-async def explore_buckets(cid: str, compartment_id: str = Query(None), u=Depends(current_user)):
+async def explore_buckets(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
try:
import oci
config = _get_oci_config(cid)
+ if region: config["region"] = region
os_client = oci.object_storage.ObjectStorageClient(config)
namespace = os_client.get_namespace().data
- with db() as c:
- cfg = c.execute("SELECT tenancy_ocid,compartment_id FROM oci_configs WHERE id=?", (cid,)).fetchone()
- comp = compartment_id or _safe_dec(cfg["compartment_id"]) or _safe_dec(cfg["tenancy_ocid"])
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
buckets = os_client.list_buckets(namespace, comp).data
return [{"name":b.name,"namespace":b.namespace,"time_created":str(b.time_created)} for b in buckets]
except Exception as e:
return {"error": str(e)[:500]}
+@app.get("/api/oci/explore/{cid}/subnets")
+async def explore_subnets(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ vn = oci.core.VirtualNetworkClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ subs = vn.list_subnets(comp).data
+ return [{"id":s.id,"display_name":s.display_name,"cidr_block":s.cidr_block,"vcn_id":s.vcn_id,
+ "lifecycle_state":s.lifecycle_state,"prohibit_public_ip_on_vnic":s.prohibit_public_ip_on_vnic} for s in subs]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/security_lists")
+async def explore_security_lists(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ vn = oci.core.VirtualNetworkClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ sls = vn.list_security_lists(comp).data
+ def _fmt_rule(r, direction):
+ proto_map = {"6":"TCP","17":"UDP","1":"ICMP","all":"ALL"}
+ proto = proto_map.get(r.protocol, r.protocol)
+ src_dst = r.source if direction == "ingress" else r.destination
+ ports = ""
+ if r.tcp_options:
+ dp = r.tcp_options.destination_port_range
+ if dp: ports = f"{dp.min}-{dp.max}" if dp.min != dp.max else str(dp.min)
+ elif r.udp_options:
+ dp = r.udp_options.destination_port_range
+ if dp: ports = f"{dp.min}-{dp.max}" if dp.min != dp.max else str(dp.min)
+ return {"direction":direction,"protocol":proto,"source_dest":src_dst or "","ports":ports,
+ "stateless":r.is_stateless,"description":getattr(r,'description','') or ""}
+ result = []
+ for s in sls:
+ ingress = [_fmt_rule(r,"ingress") for r in (s.ingress_security_rules or [])]
+ egress = [_fmt_rule(r,"egress") for r in (s.egress_security_rules or [])]
+ result.append({"id":s.id,"display_name":s.display_name,"vcn_id":s.vcn_id,"lifecycle_state":s.lifecycle_state,
+ "ingress_count":len(ingress),"egress_count":len(egress),"rules":ingress+egress})
+ return result
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/block_volumes")
+async def explore_block_volumes(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ bs = oci.core.BlockstorageClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ vols = bs.list_volumes(compartment_id=comp).data
+ return [{"id":v.id,"display_name":v.display_name,"size_in_gbs":v.size_in_gbs,
+ "lifecycle_state":v.lifecycle_state,"vpus_per_gb":v.vpus_per_gb} for v in vols]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/load_balancers")
+async def explore_load_balancers(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ lb = oci.load_balancer.LoadBalancerClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ lbs = lb.list_load_balancers(comp).data
+ return [{"id":l.id,"display_name":l.display_name,"lifecycle_state":l.lifecycle_state,
+ "shape_name":l.shape_name,"ip_addresses":[ip.ip_address for ip in (l.ip_addresses or [])]} for l in lbs]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/functions")
+async def explore_functions(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ fn = oci.functions.FunctionsManagementClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ apps = fn.list_applications(comp).data
+ result = []
+ for a in apps:
+ fns = fn.list_functions(a.id).data
+ for f in fns:
+ result.append({"id":f.id,"display_name":f.display_name,"application":a.display_name,
+ "lifecycle_state":f.lifecycle_state,"memory_in_mbs":f.memory_in_mbs,
+ "timeout_in_seconds":f.timeout_in_seconds,"image":f.image})
+ return result
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/container_instances")
+async def explore_container_instances(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ ci = oci.container_instances.ContainerInstanceClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ cis = ci.list_container_instances(comp).data
+ return [{"id":c.id,"display_name":c.display_name,"lifecycle_state":c.lifecycle_state,
+ "container_count":c.container_count,"shape":c.shape,
+ "time_created":str(c.time_created)} for c in cis]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/nsgs")
+async def explore_nsgs(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ vn = oci.core.VirtualNetworkClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ nsgs = vn.list_network_security_groups(compartment_id=comp).data
+ return [{"id":n.id,"display_name":n.display_name,"vcn_id":n.vcn_id,"lifecycle_state":n.lifecycle_state,
+ "time_created":str(n.time_created)} for n in nsgs]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/route_tables")
+async def explore_route_tables(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ vn = oci.core.VirtualNetworkClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ rts = vn.list_route_tables(comp).data
+ result = []
+ for rt in rts:
+ routes = []
+ for r in (rt.route_rules or []):
+ target_type = r.network_entity_id.split(".")[1] if r.network_entity_id and "." in r.network_entity_id else "unknown"
+ routes.append({"destination":r.destination or "","destination_type":r.destination_type or "",
+ "target_id":r.network_entity_id or "","target_type":target_type,
+ "description":getattr(r,'description','') or ""})
+ result.append({"id":rt.id,"display_name":rt.display_name,"vcn_id":rt.vcn_id,"lifecycle_state":rt.lifecycle_state,
+ "route_count":len(routes),"routes":routes})
+ return result
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/nat_gateways")
+async def explore_nat_gateways(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ vn = oci.core.VirtualNetworkClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ ngs = vn.list_nat_gateways(comp).data
+ return [{"id":n.id,"display_name":n.display_name,"vcn_id":n.vcn_id,"lifecycle_state":n.lifecycle_state,
+ "nat_ip":n.nat_ip,"block_traffic":n.block_traffic} for n in ngs]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/internet_gateways")
+async def explore_internet_gateways(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ vn = oci.core.VirtualNetworkClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ igs = vn.list_internet_gateways(comp).data
+ return [{"id":g.id,"display_name":g.display_name,"vcn_id":g.vcn_id,"lifecycle_state":g.lifecycle_state,
+ "is_enabled":g.is_enabled} for g in igs]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/service_gateways")
+async def explore_service_gateways(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ vn = oci.core.VirtualNetworkClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ sgs = vn.list_service_gateways(comp).data
+ return [{"id":g.id,"display_name":g.display_name,"vcn_id":g.vcn_id,"lifecycle_state":g.lifecycle_state,
+ "services":[s.service_name for s in (g.services or [])]} for g in sgs]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/public_ips")
+async def explore_public_ips(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ vn = oci.core.VirtualNetworkClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ ips = vn.list_public_ips(scope="REGION", compartment_id=comp).data
+ return [{"id":ip.id,"display_name":ip.display_name or "—","ip_address":ip.ip_address,
+ "lifecycle_state":ip.lifecycle_state,"lifetime":ip.lifetime,
+ "assigned_entity_type":ip.assigned_entity_type} for ip in ips]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/boot_volumes")
+async def explore_boot_volumes(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ identity = oci.identity.IdentityClient(config)
+ bs = oci.core.BlockstorageClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ ads = identity.list_availability_domains(comp).data
+ result = []
+ for ad in ads:
+ bvs = bs.list_boot_volumes(availability_domain=ad.name, compartment_id=comp).data
+ for v in bvs:
+ result.append({"id":v.id,"display_name":v.display_name,"size_in_gbs":v.size_in_gbs,
+ "lifecycle_state":v.lifecycle_state,"availability_domain":ad.name,
+ "vpus_per_gb":v.vpus_per_gb})
+ return result
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/instance_pools")
+async def explore_instance_pools(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ cm = oci.core.ComputeManagementClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ pools = cm.list_instance_pools(comp).data
+ return [{"id":p.id,"display_name":p.display_name,"lifecycle_state":p.lifecycle_state,
+ "size":p.size,"time_created":str(p.time_created)} for p in pools]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/oke_clusters")
+async def explore_oke_clusters(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ ce = oci.container_engine.ContainerEngineClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ clusters = ce.list_clusters(comp).data
+ return [{"id":c.id,"name":c.name,"lifecycle_state":c.lifecycle_state,
+ "kubernetes_version":c.kubernetes_version,"vcn_id":c.vcn_id,
+ "endpoint_config":c.endpoint_config.is_public_ip_enabled if c.endpoint_config else None} for c in clusters]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/file_systems")
+async def explore_file_systems(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ identity = oci.identity.IdentityClient(config)
+ fss = oci.file_storage.FileStorageClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ ads = identity.list_availability_domains(comp).data
+ result = []
+ for ad in ads:
+ fs_list = fss.list_file_systems(comp, ad.name).data
+ for f in fs_list:
+ result.append({"id":f.id,"display_name":f.display_name,"lifecycle_state":f.lifecycle_state,
+ "availability_domain":ad.name,"metered_bytes":f.metered_bytes,
+ "time_created":str(f.time_created)})
+ return result
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/mount_targets")
+async def explore_mount_targets(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ identity = oci.identity.IdentityClient(config)
+ fss = oci.file_storage.FileStorageClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ ads = identity.list_availability_domains(comp).data
+ result = []
+ for ad in ads:
+ mts = fss.list_mount_targets(comp, ad.name).data
+ for m in mts:
+ result.append({"id":m.id,"display_name":m.display_name,"lifecycle_state":m.lifecycle_state,
+ "availability_domain":ad.name,"subnet_id":m.subnet_id,
+ "private_ip_ids":m.private_ip_ids})
+ return result
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/db_systems")
+async def explore_db_systems(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ db_client = oci.database.DatabaseClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ dbs = db_client.list_db_systems(comp).data
+ return [{"id":d.id,"display_name":d.display_name,"lifecycle_state":d.lifecycle_state,
+ "shape":d.shape,"database_edition":d.database_edition,
+ "cpu_core_count":d.cpu_core_count,"node_count":d.node_count} for d in dbs]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/mysql_db_systems")
+async def explore_mysql_db_systems(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ mysql = oci.mysql.DbSystemClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ dbs = mysql.list_db_systems(comp).data
+ return [{"id":d.id,"display_name":d.display_name,"lifecycle_state":d.lifecycle_state,
+ "shape_name":d.shape_name,"mysql_version":d.mysql_version,
+ "is_highly_available":d.is_highly_available} for d in dbs]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/alarms")
+async def explore_alarms(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ mon = oci.monitoring.MonitoringClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ alarms = mon.list_alarms(comp).data
+ return [{"id":a.id,"display_name":a.display_name,"lifecycle_state":a.lifecycle_state,
+ "severity":a.severity,"namespace":a.namespace,"is_enabled":a.is_enabled} for a in alarms]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/log_groups")
+async def explore_log_groups(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ logging_client = oci.logging.LoggingManagementClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ lgs = logging_client.list_log_groups(comp).data
+ return [{"id":g.id,"display_name":g.display_name,"time_created":str(g.time_created),
+ "description":g.description or ""} for g in lgs]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/iam_users")
+async def explore_iam_users(cid: str, u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ identity = oci.identity.IdentityClient(config)
+ cfg, _ = _explore_comp(cid)
+ tenancy = _safe_dec(cfg["tenancy_ocid"])
+ users = identity.list_users(tenancy).data
+ return [{"id":u2.id,"name":u2.name,"email":u2.email or "","lifecycle_state":u2.lifecycle_state,
+ "is_mfa_activated":u2.is_mfa_activated,"time_created":str(u2.time_created),
+ "last_successful_login_time":str(u2.last_successful_login_time) if u2.last_successful_login_time else None} for u2 in users]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/iam_groups")
+async def explore_iam_groups(cid: str, u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ identity = oci.identity.IdentityClient(config)
+ cfg, _ = _explore_comp(cid)
+ tenancy = _safe_dec(cfg["tenancy_ocid"])
+ groups = identity.list_groups(tenancy).data
+ return [{"id":g.id,"name":g.name,"description":g.description or "","lifecycle_state":g.lifecycle_state,
+ "time_created":str(g.time_created)} for g in groups]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/iam_policies")
+async def explore_iam_policies(cid: str, compartment_id: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ identity = oci.identity.IdentityClient(config)
+ cfg, default_comp = _explore_comp(cid)
+ comp = compartment_id or _safe_dec(cfg["tenancy_ocid"]) or default_comp
+ policies = identity.list_policies(comp).data
+ return [{"id":p.id,"name":p.name,"description":p.description or "","lifecycle_state":p.lifecycle_state,
+ "statements":p.statements,"time_created":str(p.time_created)} for p in policies]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/iam_dynamic_groups")
+async def explore_iam_dynamic_groups(cid: str, u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ identity = oci.identity.IdentityClient(config)
+ cfg, _ = _explore_comp(cid)
+ tenancy = _safe_dec(cfg["tenancy_ocid"])
+ dgs = identity.list_dynamic_groups(tenancy).data
+ return [{"id":g.id,"name":g.name,"description":g.description or "","lifecycle_state":g.lifecycle_state,
+ "matching_rule":g.matching_rule,"time_created":str(g.time_created)} for g in dgs]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/vaults")
+async def explore_vaults(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ kms = oci.key_management.KmsVaultClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ vaults = kms.list_vaults(comp).data
+ return [{"id":v.id,"display_name":v.display_name,"lifecycle_state":v.lifecycle_state,
+ "vault_type":v.vault_type,"crypto_endpoint":v.crypto_endpoint,
+ "time_created":str(v.time_created)} for v in vaults]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/dns_zones")
+async def explore_dns_zones(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ dns = oci.dns.DnsClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ zones = dns.list_zones(comp).data
+ return [{"id":z.id,"name":z.name,"lifecycle_state":z.lifecycle_state,
+ "zone_type":z.zone_type,"serial":z.serial,
+ "time_created":str(z.time_created)} for z in zones]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/api_gateways")
+async def explore_api_gateways(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ apigw = oci.apigateway.GatewayClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ gws = apigw.list_gateways(comp).data
+ return [{"id":g.id,"display_name":g.display_name,"lifecycle_state":g.lifecycle_state,
+ "endpoint_type":g.endpoint_type,"hostname":g.hostname,
+ "subnet_id":g.subnet_id,"time_created":str(g.time_created)} for g in gws]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/notification_topics")
+async def explore_notification_topics(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ ons = oci.ons.NotificationControlPlaneClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ topics = ons.list_topics(comp).data
+ return [{"id":t.topic_id,"name":t.name,"lifecycle_state":t.lifecycle_state,
+ "description":t.description or "","api_endpoint":t.api_endpoint,
+ "time_created":str(t.time_created)} for t in topics]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/events_rules")
+async def explore_events_rules(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ events = oci.events.EventsClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ rules = events.list_rules(comp).data
+ return [{"id":r.id,"display_name":r.display_name,"lifecycle_state":r.lifecycle_state,
+ "condition":r.condition,"is_enabled":r.is_enabled,
+ "description":r.description or "","time_created":str(r.time_created)} for r in rules]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/network_firewalls")
+async def explore_network_firewalls(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ fw = oci.network_firewall.NetworkFirewallClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ firewalls = fw.list_network_firewalls(compartment_id=comp).data
+ return [{"id":f.id,"display_name":f.display_name,"lifecycle_state":f.lifecycle_state,
+ "subnet_id":f.subnet_id,"network_firewall_policy_id":f.network_firewall_policy_id,
+ "time_created":str(f.time_created)} for f in firewalls]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
+@app.get("/api/oci/explore/{cid}/network_firewall_policies")
+async def explore_network_firewall_policies(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
+ try:
+ import oci
+ config = _get_oci_config(cid)
+ if region: config["region"] = region
+ fw = oci.network_firewall.NetworkFirewallClient(config)
+ _, default_comp = _explore_comp(cid)
+ comp = compartment_id or default_comp
+ policies = fw.list_network_firewall_policies(compartment_id=comp).data
+ return [{"id":p.id,"display_name":p.display_name,"lifecycle_state":p.lifecycle_state,
+ "time_created":str(p.time_created)} for p in policies]
+ except Exception as e:
+ return {"error": str(e)[:500]}
+
# ── OCI GenAI Config & Chat ───────────────────────────────────────────────────
@app.get("/api/genai/models")
async def list_genai_models(u=Depends(current_user)):
@@ -871,12 +1498,14 @@ def _generate_summary(genai_cfg: dict, messages_to_summarize: list) -> str:
conversation_text += f"{role_label}: {content}\n\n"
summary_prompt = (
- "Resuma a conversa abaixo em um parágrafo conciso que capture:\n"
- "- Tópicos principais discutidos\n"
- "- Decisões tomadas e conclusões\n"
- "- Resultados de ferramentas/tools executadas\n"
- "- Contexto OCI/CIS relevante para perguntas futuras\n\n"
- "Seja conciso mas preserve toda informação acionável.\n\n"
+ "Resuma a conversa abaixo de forma compacta (máximo 2-3 parágrafos curtos) que capture:\n"
+ "- Tópicos discutidos e perguntas do usuário\n"
+ "- Decisões tomadas, conclusões e resultados importantes\n"
+ "- Resultados de ferramentas/tools executadas (checks PASS/FAIL, scores, recursos encontrados)\n"
+ "- Contexto OCI/CIS relevante (compartments, regiões, config_ids usados)\n"
+ "- Dados numéricos importantes (scores, contagens, OCIDs mencionados)\n\n"
+ "Seja conciso mas preserve TODA informação acionável e dados concretos. "
+ "Não inclua saudações ou formalidades.\n\n"
"CONVERSA:\n" + conversation_text
)
@@ -1220,6 +1849,57 @@ RAG_DEFAULT_SYSTEM_PROMPT = """Você é um assistente RAG especializado em Oracl
- CIS_REPORT:
- CIS_RECOMMENDATIONS: (se usado)"""
+TF_DEFAULT_SYSTEM_PROMPT = """Você é um agente especializado EXCLUSIVAMENTE em gerar código Terraform para **Oracle Cloud Infrastructure (OCI)**.
+
+### RESTRIÇÃO FUNDAMENTAL
+- Você SOMENTE gera código Terraform para o provider OCI (oracle/oci).
+- Se o usuário pedir recursos de AWS, Azure, GCP ou qualquer outro cloud provider, recuse educadamente e explique que este agente é exclusivo para Oracle Cloud Infrastructure.
+- Não gere código para providers que não sejam `oracle/oci`.
+
+### 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.
+
+### 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.
+
+### Recursos existentes
+- Se o contexto incluir uma seção "RECURSOS OCI EXISTENTES NO COMPARTMENT", analise-a ANTES de gerar código.
+- REUTILIZE recursos existentes quando possível (ex: use uma VCN já existente em vez de criar outra, referencie subnets existentes).
+- Use `data` sources para referenciar recursos existentes (ex: `data "oci_core_vcns" "existing"`).
+- Informe ao usuário quais recursos existentes serão reutilizados e quais serão criados.
+
+### Formato de resposta
+1. SEMPRE gere código Terraform em blocos de código com tag `hcl`:
+ ```hcl
+ // código aqui
+ ```
+
+2. Após CADA bloco 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)
+ + oci_core_internet_gateway.igw (Internet Gateway)
+ ```
+ Use `+` para recursos a criar, `~` para recursos existentes que serão referenciados. Cada linha: `+ resource_type.name (descrição humana)`.
+
+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).
+
+### Diretrizes
+- Use variables para valores configuráveis (CIDRs, display names, shapes, etc.)
+- Inclua defaults sensatos nas definições de variáveis.
+- Adicione tags/freeform_tags para organização.
+- Siga boas práticas e naming conventions OCI.
+- 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):
"""Create an oracledb connection from an adb_vector_configs row."""
import oracledb
@@ -1995,6 +2675,8 @@ async def _exec_report(rid, cfg, regions, level=2, obp=False, raw=False, redact_
if redact_output: cmd.append("--redact-output")
proc = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
_running_reports[rid] = proc
+ with db() as c:
+ c.execute("UPDATE reports SET worker_pid=? WHERE id=?", (proc.pid, rid))
progress_lines = []
try:
while True:
@@ -2060,18 +2742,12 @@ async def get_report_progress(rid: str, u=Depends(current_user)):
with db() as c:
r = c.execute("SELECT status,progress,created_at,completed_at,error_msg FROM reports WHERE id=?", (rid,)).fetchone()
if not r: raise HTTPException(404)
- # Detect orphaned "running" reports: status is running but no live process
- if r["status"] == "running" and rid not in _running_reports:
- with db() as c:
- c.execute("UPDATE reports SET status='failed', error_msg='Interrompido: processo não encontrado', completed_at=datetime('now') WHERE id=? AND status='running'", (rid,))
- log.warning(f"Report {rid} marked as failed: no live process found")
- return {**dict(r), "status": "failed", "error_msg": "Interrompido: processo não encontrado", "completed_at": datetime.utcnow().isoformat()}
return dict(r)
@app.post("/api/reports/{rid}/cancel")
async def cancel_report(rid: str, u=Depends(require("admin", "user"))):
with db() as c:
- r = c.execute("SELECT status FROM reports WHERE id=?", (rid,)).fetchone()
+ r = c.execute("SELECT status, worker_pid FROM reports WHERE id=?", (rid,)).fetchone()
if not r: raise HTTPException(404)
if r["status"] != "running":
raise HTTPException(400, "Report is not running")
@@ -2086,6 +2762,12 @@ async def cancel_report(rid: str, u=Depends(require("admin", "user"))):
except ProcessLookupError:
pass
_running_reports.pop(rid, None)
+ elif r["worker_pid"]:
+ import signal
+ try:
+ os.kill(r["worker_pid"], signal.SIGTERM)
+ except (ProcessLookupError, OSError):
+ pass
with db() as c:
c.execute("UPDATE reports SET status='cancelled',error_msg='Cancelled by user',completed_at=datetime('now') WHERE id=?", (rid,))
_audit(u["id"], u["username"], "cancel_report", rid)
@@ -2139,7 +2821,7 @@ async def report_dl(rid, fmt: str = Query("json"), u=Depends(current_user)):
# ── Chat Agent ────────────────────────────────────────────────────────────────
# (endpoints defined below _chat_core, after _agent_respond)
-def _chat_start(msg: ChatMsg, u, attachments: list = None):
+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."""
sid = msg.session_id or str(uuid.uuid4())
@@ -2201,7 +2883,7 @@ def _chat_start(msg: ChatMsg, u, attachments: list = None):
return sid, mid, genai_cfg
-async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_cfg: dict, attachments: list = None):
+async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_cfg: dict, attachments: list = None, agent_type: str = "chat"):
"""Background worker — processes GenAI chat, updates DB when done."""
log.info(f"Chat background started: mid={mid}, sid={sid}")
try:
@@ -2235,15 +2917,17 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
log.info(f"RAG: Retrieved {len(documents)} docs from {tbl['table_name']}")
except Exception as te:
log.warning(f"RAG search failed for table {tbl['table_name']}: {te}")
+ _chat_log(sid, mid, user["id"], "warning", "rag", "search_failed", f"{tbl['table_name']}: {te}")
except Exception as e:
log.warning(f"RAG retrieval failed for {adb_cfg.get('config_name','?')} (non-fatal): {e}")
+ _chat_log(sid, mid, user["id"], "warning", "rag", "retrieval_failed", f"{adb_cfg.get('config_name','?')}: {e}")
if all_documents:
all_documents.sort(key=lambda d: d["distance"])
rag_context = _build_rag_context(all_documents[:10])
cfg_dict = dict(genai_cfg)
with db() as c:
- sp_row = c.execute("SELECT content FROM system_prompts WHERE agent='chat' AND is_active=1 LIMIT 1").fetchone()
+ sp_row = c.execute("SELECT content FROM system_prompts WHERE agent=? AND is_active=1 LIMIT 1", (agent_type,)).fetchone()
global_prompt = sp_row["content"] if sp_row and sp_row["content"] else ""
if rag_context:
augmented_message = RAG_CONTEXT_TEMPLATE.format(context=rag_context, question=msg.message)
@@ -2253,6 +2937,76 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
if global_prompt:
cfg_dict["system_prompt"] = global_prompt
+ # ── Inject all config context into system prompt so model auto-resolves IDs ──
+ ctx_parts = []
+ active_oci_id = cfg_dict.get("oci_config_id") or (msg.oci_config_id if msg.oci_config_id else None)
+ with db() as c:
+ oci_cfgs = c.execute("SELECT id,tenancy_name,region,compartment_id FROM oci_configs WHERE user_id=?", (user["id"],)).fetchall()
+ genai_cfgs = c.execute("SELECT id,name,model_id,genai_region,compartment_id,oci_config_id FROM genai_configs WHERE user_id=?", (user["id"],)).fetchall()
+ adb_cfgs = c.execute("SELECT id,config_name,dsn,table_name,is_active,genai_config_id,embedding_model_id FROM adb_vector_configs WHERE user_id=? AND is_active=1", (user["id"],)).fetchall()
+ mcp_srvs = c.execute("SELECT id,name,description,is_active FROM mcp_servers WHERE is_active=1 AND (user_id=? OR EXISTS (SELECT 1 FROM users WHERE id=? AND role='admin'))", (user["id"], user["id"])).fetchall()
+ # ── Active OCI config (selected by user in this session) ──
+ if active_oci_id:
+ for oc in oci_cfgs:
+ if oc["id"] == active_oci_id:
+ comp = _safe_dec(oc["compartment_id"]) if oc["compartment_id"] else "N/A"
+ ctx_parts.append(
+ f"⚡ CONFIG OCI ATIVA (usar como config_id em TODAS as tools que precisarem): "
+ f"config_id=\"{oc['id']}\" tenancy=\"{oc['tenancy_name']}\" region=\"{oc['region']}\" compartment_id=\"{comp}\""
+ )
+ break
+ if oci_cfgs:
+ ctx_parts.append("\nTodas as configurações OCI disponíveis (use 'id' como config_id):")
+ for oc in oci_cfgs:
+ comp = _safe_dec(oc["compartment_id"]) if oc["compartment_id"] else "N/A"
+ active_tag = " ← ATIVA" if oc["id"] == active_oci_id else ""
+ ctx_parts.append(f" - id=\"{oc['id']}\" tenancy=\"{oc['tenancy_name']}\" region=\"{oc['region']}\" compartment_id=\"{comp}\"{active_tag}")
+ if genai_cfgs:
+ ctx_parts.append("\nConfigurações GenAI disponíveis (use 'id' como genai_config_id):")
+ for gc in genai_cfgs:
+ comp = _safe_dec(gc["compartment_id"]) if gc["compartment_id"] else "N/A"
+ ctx_parts.append(f" - id=\"{gc['id']}\" name=\"{gc['name']}\" model=\"{gc['model_id']}\" region=\"{gc['genai_region']}\" compartment_id=\"{comp}\"")
+ if adb_cfgs:
+ ctx_parts.append("\nConfigurações ADB Vector Store disponíveis (use 'id' como adb_config_id):")
+ for ac in adb_cfgs:
+ emb = ac["embedding_model_id"] if ac["embedding_model_id"] else "N/A"
+ ctx_parts.append(f" - id=\"{ac['id']}\" name=\"{ac['config_name']}\" table=\"{ac['table_name']}\" embedding_model=\"{emb}\"")
+ if mcp_srvs:
+ ctx_parts.append("\nMCP Servers ativos (tools disponíveis para uso):")
+ for ms in mcp_srvs:
+ desc = ms["description"] or ""
+ ctx_parts.append(f" - id=\"{ms['id']}\" name=\"{ms['name']}\" desc=\"{desc}\"")
+ if ctx_parts:
+ ctx_parts.append("\nIMPORTANTE: Use automaticamente a config OCI ATIVA como config_id em todas as tools. NUNCA peça config_id, tenancy ou IDs ao usuário — já estão definidos acima.")
+ config_hint = "\n".join(ctx_parts)
+ base_prompt = cfg_dict.get("system_prompt", "")
+ cfg_dict["system_prompt"] = f"{base_prompt}\n\n{config_hint}" if base_prompt else config_hint
+
+ # ── Inject existing OCI resources for terraform agent ──
+ if agent_type == "terraform" and active_oci_id:
+ try:
+ # Determine compartment: from msg or from OCI config
+ tf_compartment = getattr(msg, 'compartment_id', None) or None
+ if not tf_compartment:
+ for oc in oci_cfgs:
+ if oc["id"] == active_oci_id:
+ tf_compartment = _safe_dec(oc["compartment_id"]) if oc["compartment_id"] else None
+ break
+ if tf_compartment:
+ tf_region = None
+ for oc in oci_cfgs:
+ if oc["id"] == active_oci_id:
+ tf_region = oc["region"]
+ break
+ loop = asyncio.get_event_loop()
+ resources = await loop.run_in_executor(
+ _chat_executor, partial(_fetch_compartment_resources, active_oci_id, tf_compartment, tf_region))
+ resource_ctx = _build_resource_context(resources)
+ cfg_dict["system_prompt"] = cfg_dict.get("system_prompt", "") + "\n\n" + resource_ctx
+ log.info(f"Terraform: injected resource context for compartment {tf_compartment[:20]}...")
+ except Exception as e:
+ log.warning(f"Failed to inject terraform resource context: {e}")
+
mcp_tools = []
tool_defs = None
if msg.use_tools:
@@ -2286,11 +3040,14 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
try:
result = await _execute_mcp_tool(mcp_match["server"], tc["name"], tc["arguments"])
log.info(f"Tool {tc['name']} executed successfully ({len(result)} chars)")
+ _chat_log(sid, mid, user["id"], "info", "tool", "tool_success", f"{tc['name']} ({len(result)} chars)")
except Exception as te:
result = f"Erro ao executar tool {tc['name']}: {str(te)[:300]}"
log.warning(f"Tool {tc['name']} failed: {te}")
+ _chat_log(sid, mid, user["id"], "error", "tool", "tool_error", f"{tc['name']}: {te}")
else:
result = f"Tool {tc['name']} não encontrada nos MCP servers ativos"
+ _chat_log(sid, mid, user["id"], "error", "tool", "tool_not_found", tc["name"])
iteration_results.append({"tool_call_id": tc["id"], "name": tc["name"], "content": result})
all_tool_results.extend(iteration_results)
@@ -2334,6 +3091,7 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
log.info(f"Chat {mid} completed successfully")
except Exception as e:
log.error(f"Chat {mid} failed: {e}")
+ _chat_log(sid, mid, user["id"], "error", "genai", "genai_failed", str(e))
with db() as c:
c.execute("UPDATE chat_messages SET content=?, status='failed' WHERE id=?",
(f"❌ Erro GenAI: {str(e)[:400]}", mid))
@@ -2465,6 +3223,353 @@ 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"]))
return {"ok": True}
+
+# ── Terraform Agent ───────────────────────────────────────────────────────────
+
+def _fetch_compartment_resources(oci_config_id: str, compartment_id: str, region: str = None) -> dict:
+ """Fetch existing OCI resources in a compartment for terraform context."""
+ import oci
+ config = _get_oci_config(oci_config_id)
+ if region:
+ config["region"] = region
+ resources = {}
+ try:
+ vn = oci.core.VirtualNetworkClient(config)
+ vcns = vn.list_vcns(compartment_id).data
+ resources["vcns"] = [{"id": v.id, "display_name": v.display_name, "cidr_blocks": v.cidr_blocks, "state": v.lifecycle_state} for v in vcns if v.lifecycle_state == "AVAILABLE"]
+ subs = vn.list_subnets(compartment_id).data
+ resources["subnets"] = [{"id": s.id, "display_name": s.display_name, "cidr_block": s.cidr_block, "vcn_id": s.vcn_id, "public": not s.prohibit_public_ip_on_vnic, "state": s.lifecycle_state} for s in subs if s.lifecycle_state == "AVAILABLE"]
+ igws = vn.list_internet_gateways(compartment_id).data
+ resources["internet_gateways"] = [{"id": g.id, "display_name": g.display_name, "vcn_id": g.vcn_id, "enabled": g.is_enabled} for g in igws if g.lifecycle_state == "AVAILABLE"]
+ ngws = vn.list_nat_gateways(compartment_id).data
+ resources["nat_gateways"] = [{"id": g.id, "display_name": g.display_name, "vcn_id": g.vcn_id} for g in ngws if g.lifecycle_state == "AVAILABLE"]
+ rts = vn.list_route_tables(compartment_id).data
+ resources["route_tables"] = [{"id": r.id, "display_name": r.display_name, "vcn_id": r.vcn_id} for r in rts if r.lifecycle_state == "AVAILABLE"]
+ sls = vn.list_security_lists(compartment_id).data
+ resources["security_lists"] = [{"id": s.id, "display_name": s.display_name, "vcn_id": s.vcn_id} for s in sls if s.lifecycle_state == "AVAILABLE"]
+ except Exception as e:
+ log.warning(f"Failed to fetch networking resources: {e}")
+ 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"]
+ 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"]
+ except Exception as e:
+ log.warning(f"Failed to fetch database resources: {e}")
+ try:
+ bs = oci.core.BlockstorageClient(config)
+ vols = bs.list_volumes(compartment_id).data
+ resources["block_volumes"] = [{"id": v.id, "display_name": v.display_name, "size_in_gbs": v.size_in_gbs, "state": v.lifecycle_state} for v in vols if v.lifecycle_state == "AVAILABLE"]
+ except Exception as e:
+ log.warning(f"Failed to fetch block storage resources: {e}")
+ try:
+ lb_client = oci.load_balancer.LoadBalancerClient(config)
+ lbs = lb_client.list_load_balancers(compartment_id).data
+ resources["load_balancers"] = [{"id": l.id, "display_name": l.display_name, "shape_name": l.shape_name, "state": l.lifecycle_state} for l in lbs if l.lifecycle_state == "ACTIVE"]
+ except Exception as e:
+ log.warning(f"Failed to fetch load balancer resources: {e}")
+ return resources
+
+
+def _build_resource_context(resources: dict) -> str:
+ """Build a text summary of existing resources for injection into system prompt."""
+ lines = ["## RECURSOS OCI EXISTENTES NO COMPARTMENT"]
+ total = 0
+ resource_labels = {
+ "vcns": ("VCNs", lambda r: f" - {r['display_name']} (CIDR: {','.join(r.get('cidr_blocks') or [])}) [OCID: {r['id'][:30]}...]"),
+ "subnets": ("Subnets", lambda r: f" - {r['display_name']} (CIDR: {r['cidr_block']}, {'pública' if r.get('public') else 'privada'}, VCN: {r['vcn_id'][:30]}...)"),
+ "internet_gateways": ("Internet Gateways", lambda r: f" - {r['display_name']} (VCN: {r['vcn_id'][:30]}...)"),
+ "nat_gateways": ("NAT Gateways", lambda r: f" - {r['display_name']} (VCN: {r['vcn_id'][:30]}...)"),
+ "route_tables": ("Route Tables", lambda r: f" - {r['display_name']} (VCN: {r['vcn_id'][:30]}...)"),
+ "security_lists": ("Security Lists", lambda r: f" - {r['display_name']} (VCN: {r['vcn_id'][:30]}...)"),
+ "instances": ("Compute Instances", lambda r: f" - {r['display_name']} (Shape: {r['shape']}, Estado: {r['state']})"),
+ "autonomous_databases": ("Autonomous Databases", lambda r: f" - {r['display_name']} (DB: {r['db_name']}, Free Tier: {r.get('is_free_tier', False)})"),
+ "block_volumes": ("Block Volumes", lambda r: f" - {r['display_name']} ({r.get('size_in_gbs', '?')} GB)"),
+ "load_balancers": ("Load Balancers", lambda r: f" - {r['display_name']} (Shape: {r.get('shape_name', '?')})"),
+ }
+ for key, (label, formatter) in resource_labels.items():
+ items = resources.get(key, [])
+ if items:
+ total += len(items)
+ lines.append(f"\n**{label}** ({len(items)}):")
+ for item in items[:15]: # limit to 15 per category
+ lines.append(formatter(item))
+ if len(items) > 15:
+ lines.append(f" ... e mais {len(items) - 15}")
+ if total == 0:
+ lines.append("\nNenhum recurso encontrado neste compartment. Todos os recursos serão novos.")
+ else:
+ lines.append(f"\n**Total: {total} recursos existentes.** REUTILIZE-OS sempre que possível usando data sources.")
+ return "\n".join(lines)
+
+
+@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."""
+ try:
+ loop = asyncio.get_event_loop()
+ resources = await loop.run_in_executor(
+ _chat_executor, partial(_fetch_compartment_resources, oci_config_id, compartment_id, region))
+ return resources
+ except Exception as e:
+ raise HTTPException(500, str(e)[:500])
+
+
+@app.post("/api/terraform/chat")
+async def terraform_chat(msg: ChatMsg, bg: BackgroundTasks, u=Depends(current_user)):
+ sid, mid_or_result, genai_cfg = _chat_start(msg, u, agent_type="terraform")
+ if genai_cfg is None:
+ return mid_or_result
+ bg.add_task(_chat_background, mid_or_result, sid, msg, dict(u), genai_cfg, None, "terraform")
+ return {"message_id": mid_or_result, "session_id": sid, "status": "processing"}
+
+
+class TfWorkspaceReq(BaseModel):
+ session_id: str
+ oci_config_id: str
+ compartment_id: Optional[str] = None
+ name: str = "workspace"
+ tf_code: str
+
+
+@app.post("/api/terraform/workspaces")
+async def tf_create_workspace(req: TfWorkspaceReq, u=Depends(current_user)):
+ wid = str(uuid.uuid4())
+ with db() as c:
+ c.execute(
+ "INSERT INTO terraform_workspaces (id,user_id,session_id,oci_config_id,compartment_id,name,tf_code,status) VALUES (?,?,?,?,?,?,?,?)",
+ (wid, u["id"], req.session_id, req.oci_config_id, req.compartment_id, req.name, req.tf_code, "draft"))
+ return {"id": wid, "status": "draft"}
+
+
+@app.get("/api/terraform/workspaces")
+async def tf_list_workspaces(u=Depends(current_user)):
+ with db() as c:
+ rows = c.execute(
+ "SELECT id,name,session_id,oci_config_id,status,created_at,updated_at FROM terraform_workspaces WHERE user_id=? ORDER BY created_at DESC",
+ (u["id"],)).fetchall()
+ return [dict(r) for r in rows]
+
+
+@app.get("/api/terraform/workspaces/{wid}")
+async def tf_get_workspace(wid: str, u=Depends(current_user)):
+ with db() as c:
+ r = c.execute("SELECT * FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone()
+ if not r: raise HTTPException(404)
+ return dict(r)
+
+
+@app.put("/api/terraform/workspaces/{wid}/code")
+async def tf_update_code(wid: str, req: dict, u=Depends(current_user)):
+ code = req.get("tf_code", "")
+ with db() as c:
+ r = c.execute("SELECT id FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone()
+ if not r: raise HTTPException(404)
+ c.execute("UPDATE terraform_workspaces SET tf_code=?, status='draft', updated_at=datetime('now') WHERE id=?", (code, wid))
+ return {"ok": True}
+
+
+@app.post("/api/terraform/workspaces/{wid}/plan")
+async def tf_run_plan(wid: str, bg: BackgroundTasks, u=Depends(current_user)):
+ with db() as c:
+ ws = c.execute("SELECT * FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone()
+ if not ws: raise HTTPException(404)
+ if ws["status"] in ("planning", "applying", "destroying"):
+ raise HTTPException(400, f"Workspace is {ws['status']}")
+ with db() as c:
+ c.execute("UPDATE terraform_workspaces SET status='planning', plan_output='', error=NULL, updated_at=datetime('now') WHERE id=?", (wid,))
+ bg.add_task(_terraform_exec, wid, "plan", dict(u))
+ return {"status": "planning"}
+
+
+@app.post("/api/terraform/workspaces/{wid}/apply")
+async def tf_run_apply(wid: str, bg: BackgroundTasks, u=Depends(current_user)):
+ with db() as c:
+ ws = c.execute("SELECT * FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone()
+ if not ws: raise HTTPException(404)
+ if ws["status"] not in ("planned", "applied", "destroyed", "failed"):
+ raise HTTPException(400, f"Cannot apply in status {ws['status']}. Run plan first.")
+ with db() as c:
+ c.execute("UPDATE terraform_workspaces SET status='applying', apply_output='', error=NULL, updated_at=datetime('now') WHERE id=?", (wid,))
+ bg.add_task(_terraform_exec, wid, "apply", dict(u))
+ return {"status": "applying"}
+
+
+@app.post("/api/terraform/workspaces/{wid}/destroy")
+async def tf_run_destroy(wid: str, req: dict, bg: BackgroundTasks, u=Depends(current_user)):
+ if req.get("confirm") != "DESTROY":
+ raise HTTPException(400, "Confirmação obrigatória: envie {\"confirm\": \"DESTROY\"}")
+ with db() as c:
+ ws = c.execute("SELECT * FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone()
+ if not ws: raise HTTPException(404)
+ if ws["status"] in ("planning", "applying", "destroying"):
+ raise HTTPException(400, f"Workspace is {ws['status']}")
+ with db() as c:
+ c.execute("UPDATE terraform_workspaces SET status='destroying', destroy_output='', error=NULL, updated_at=datetime('now') WHERE id=?", (wid,))
+ bg.add_task(_terraform_exec, wid, "destroy", dict(u))
+ return {"status": "destroying"}
+
+
+@app.get("/api/terraform/workspaces/{wid}/status")
+async def tf_workspace_status(wid: str, u=Depends(current_user)):
+ with db() as c:
+ r = c.execute("SELECT status, plan_output, apply_output, destroy_output, error, updated_at FROM terraform_workspaces WHERE id=? AND user_id=?",
+ (wid, u["id"])).fetchone()
+ if not r: raise HTTPException(404)
+ return dict(r)
+
+
+@app.get("/api/terraform/workspaces/{wid}/download")
+async def tf_download(wid: str, u=Depends(current_user)):
+ with db() as c:
+ 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
+ return Response(content=r["tf_code"], media_type="text/plain",
+ headers={"Content-Disposition": f'attachment; filename="main.tf"'})
+
+
+@app.post("/api/terraform/workspaces/{wid}/cancel")
+async def tf_cancel(wid: str, u=Depends(current_user)):
+ proc = _running_terraform.get(wid)
+ if proc:
+ proc.terminate()
+ try:
+ await asyncio.wait_for(proc.wait(), timeout=5)
+ except asyncio.TimeoutError:
+ proc.kill()
+ with db() as c:
+ c.execute("UPDATE terraform_workspaces SET status='failed', error='Cancelado pelo usuário', updated_at=datetime('now') WHERE id=?", (wid,))
+ _running_terraform.pop(wid, None)
+ return {"ok": True}
+
+
+@app.delete("/api/terraform/workspaces/{wid}")
+async def tf_delete_workspace(wid: str, u=Depends(current_user)):
+ with db() as c:
+ c.execute("DELETE FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"]))
+ wdir = TERRAFORM_DIR / wid
+ if wdir.exists():
+ import shutil as _sh
+ _sh.rmtree(wdir, ignore_errors=True)
+ return {"ok": True}
+
+
+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}")
+ status_col = f"{action}_output"
+ final_ok = {"plan": "planned", "apply": "applied", "destroy": "destroyed"}[action]
+
+ try:
+ with db() as c:
+ ws = c.execute("SELECT * FROM terraform_workspaces WHERE id=?", (wid,)).fetchone()
+ if not ws:
+ return
+
+ wdir = TERRAFORM_DIR / wid
+ wdir.mkdir(parents=True, exist_ok=True)
+
+ # Write main.tf
+ (wdir / "main.tf").write_text(ws["tf_code"] or "")
+
+ # Auto-generate provider.tf from OCI config
+ oci_cfg = _get_oci_config(ws["oci_config_id"])
+ provider_tf = f'''terraform {{
+ required_providers {{
+ oci = {{
+ source = "oracle/oci"
+ }}
+ }}
+}}
+
+provider "oci" {{
+ tenancy_ocid = "{oci_cfg.get('tenancy', '')}"
+ user_ocid = "{oci_cfg.get('user', '')}"
+ fingerprint = "{oci_cfg.get('fingerprint', '')}"
+ private_key_path = "{oci_cfg.get('key_file', '')}"
+ region = "{oci_cfg.get('region', '')}"
+}}
+'''
+ (wdir / "provider.tf").write_text(provider_tf)
+
+ # Auto-generate terraform.tfvars with compartment and region
+ with db() as c:
+ oci_row = c.execute("SELECT compartment_id, region FROM oci_configs WHERE id=?", (ws["oci_config_id"],)).fetchone()
+ # Use workspace-selected compartment, fallback to OCI config default
+ comp_id = ws["compartment_id"] if ws["compartment_id"] else (_safe_dec(oci_row["compartment_id"]) if oci_row and oci_row["compartment_id"] else "")
+ region = oci_row["region"] if oci_row else oci_cfg.get("region", "")
+ tfvars = f'compartment_id = "{comp_id}"\nregion = "{region}"\n'
+ (wdir / "terraform.tfvars").write_text(tfvars)
+
+ output_lines = []
+
+ def _update_output(text):
+ output_lines.append(text)
+ with db() as c:
+ c.execute(f"UPDATE terraform_workspaces SET {status_col}=?, updated_at=datetime('now') WHERE id=?",
+ ("\n".join(output_lines[-200:]), wid))
+
+ # terraform init
+ _update_output("$ terraform init ...")
+ proc_init = await asyncio.create_subprocess_exec(
+ "terraform", f"-chdir={wdir}", "init", "-no-color", "-input=false",
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
+ while True:
+ line = await proc_init.stdout.readline()
+ if not line:
+ break
+ _update_output(line.decode(errors="replace").rstrip())
+ await proc_init.wait()
+ if proc_init.returncode != 0:
+ _update_output(f"\n❌ terraform init failed (exit {proc_init.returncode})")
+ with db() as c:
+ c.execute("UPDATE terraform_workspaces SET status='failed', error=?, updated_at=datetime('now') WHERE id=?",
+ ("terraform init failed", wid))
+ return
+
+ # terraform action
+ _update_output(f"\n$ terraform {action} ...")
+ cmd = ["terraform", f"-chdir={wdir}", action, "-no-color", "-input=false"]
+ if action in ("apply", "destroy"):
+ cmd.append("-auto-approve")
+
+ proc = await asyncio.create_subprocess_exec(
+ *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
+ _running_terraform[wid] = proc
+
+ while True:
+ line = await proc.stdout.readline()
+ if not line:
+ break
+ _update_output(line.decode(errors="replace").rstrip())
+ await proc.wait()
+ _running_terraform.pop(wid, None)
+
+ if proc.returncode == 0:
+ _update_output(f"\n✅ terraform {action} completed successfully")
+ with db() as c:
+ c.execute("UPDATE terraform_workspaces SET status=?, updated_at=datetime('now') WHERE id=?",
+ (final_ok, wid))
+ _audit(user["id"], user["username"], f"terraform_{action}", wid, f"status={final_ok}")
+ else:
+ _update_output(f"\n❌ terraform {action} failed (exit {proc.returncode})")
+ with db() as c:
+ c.execute("UPDATE terraform_workspaces SET status='failed', error=?, updated_at=datetime('now') WHERE id=?",
+ (f"terraform {action} failed (exit {proc.returncode})", wid))
+
+ except Exception as e:
+ log.error(f"Terraform exec error: {e}")
+ with db() as c:
+ c.execute("UPDATE terraform_workspaces SET status='failed', error=?, updated_at=datetime('now') WHERE id=?",
+ (str(e)[:500], wid))
+
+
# ── Audit ─────────────────────────────────────────────────────────────────────
@app.get("/api/audit-log")
async def audit_log(limit:int=Query(100,le=500), u=Depends(require("admin"))):
@@ -2489,6 +3594,22 @@ async def get_config_logs(
with db() as c: rows = c.execute(query, params).fetchall()
return [dict(r) for r in rows]
+# ── Chat Logs ─────────────────────────────────────────────────────────────────
+@app.get("/api/chat-logs")
+async def get_chat_logs(
+ severity: str = Query(None), session_id: str = Query(None),
+ limit: int = Query(50, le=200), u=Depends(current_user)
+):
+ query = "SELECT * FROM chat_logs WHERE 1=1"
+ params = []
+ if severity: query += " AND severity=?"; params.append(severity)
+ if session_id: query += " AND session_id=?"; params.append(session_id)
+ if u["role"] != "admin": query += " AND user_id=?"; params.append(u["id"])
+ query += " ORDER BY created_at DESC LIMIT ?"
+ params.append(limit)
+ with db() as c: rows = c.execute(query, params).fetchall()
+ return [dict(r) for r in rows]
+
# ── App Settings ──────────────────────────────────────────────────────────────
@app.get("/api/settings/{key}")
async def get_setting(key: str, u=Depends(current_user)):
diff --git a/backend/mcp_cis_server.py b/backend/mcp_cis_server.py
index 66432de..9327c93 100644
--- a/backend/mcp_cis_server.py
+++ b/backend/mcp_cis_server.py
@@ -25,7 +25,8 @@ server = Server("cis-compliance")
# session_key -> {checker, sections_collected: set, sections_analyzed: set, ...}
_sessions: dict = {}
SESSION_TTL = 7200 # 2 hours — reduce cold starts
-TOOL_TIMEOUT = 300 # 5 min max per tool call
+TOOL_TIMEOUT = 1800 # 30 min max per tool call
+TOOL_RETRIES = 1 # 1 automatic retry on failure
def _session_key(config_id: str, regions: list[str] | None = None) -> str:
@@ -233,10 +234,15 @@ def _collect_section(session, section: str):
if not deps:
return
+ if "collection_errors" not in session:
+ session["collection_errors"] = {}
+ errors = []
+
def _run_method(fn_name):
try:
getattr(checker, fn_name)()
except Exception as e:
+ errors.append({"collector": fn_name, "error": str(e)[:200]})
print(f"Warning: {fn_name} failed: {e}")
# Home region collectors (parallel)
@@ -249,6 +255,9 @@ def _collect_section(session, section: str):
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
list(ex.map(_run_method, deps["regional"]))
+ if errors:
+ session["collection_errors"][section] = errors
+
session["sections_collected"].add(section)
@@ -312,6 +321,36 @@ def _section_summary(checker, section_key: str) -> dict:
"passed": passed, "failed": failed, "checks": checks}
+def _finding_in_compartment(finding, compartment_id):
+ if isinstance(finding, dict):
+ fcomp = finding.get("compartment_id")
+ if fcomp == compartment_id:
+ return True
+ if fcomp is None:
+ return True # recurso sem compartment (IAM tenancy-level) → incluir
+ return False
+
+
+def _filter_by_compartment(result, compartment_id):
+ passed = failed = 0
+ for check in result["checks"]:
+ original = check["findings"]
+ filtered = [f for f in original if _finding_in_compartment(f, compartment_id)]
+ check["findings"] = filtered
+ check["findings_count"] = len(filtered)
+ if check["status"] == "FAIL" and len(filtered) == 0:
+ check["status"] = "PASS"
+ if check["status"] == "PASS":
+ passed += 1
+ elif check["status"] == "FAIL":
+ failed += 1
+ total = passed + failed
+ result["passed"] = passed
+ result["failed"] = failed
+ result["compliance_score"] = round(passed / total * 100, 1) if total > 0 else 0
+ result["compartment_filter"] = compartment_id
+
+
# ──────────────────────────────────────────────────────────────────────
# Remediation Data
# ──────────────────────────────────────────────────────────────────────
@@ -451,36 +490,42 @@ TOOLS = [
inputSchema={"type": "object", "properties": {
"config_id": {"type": "string", "description": "ID da config OCI"},
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões específicas (ex: ['us-ashburn-1']). Vazio = todas."},
+ "compartment_id": {"type": "string", "description": "OCID de compartment para filtrar findings. Vazio = todos os compartments."},
}, "required": ["config_id"]}),
Tool(name="cis_scan_networking",
description="Coleta dados de rede e executa checks CIS 2.1-2.8: security lists, NSGs, VCNs, subnets, OIC, OAC, ADB.",
inputSchema={"type": "object", "properties": {
"config_id": {"type": "string", "description": "ID da config OCI"},
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões específicas (ex: ['us-ashburn-1']). Vazio = todas."},
+ "compartment_id": {"type": "string", "description": "OCID de compartment para filtrar findings. Vazio = todos os compartments."},
}, "required": ["config_id"]}),
Tool(name="cis_scan_compute",
description="Coleta instâncias Compute e executa checks CIS 3.1-3.3: metadata v2, secure boot, in-transit encryption. Rápido.",
inputSchema={"type": "object", "properties": {
"config_id": {"type": "string", "description": "ID da config OCI"},
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões específicas (ex: ['us-ashburn-1']). Vazio = todas."},
+ "compartment_id": {"type": "string", "description": "OCID de compartment para filtrar findings. Vazio = todos os compartments."},
}, "required": ["config_id"]}),
Tool(name="cis_scan_logging_monitoring",
description="Coleta dados de logging/monitoring e executa checks CIS 4.1-4.18: tags, notificações, events, Cloud Guard, KMS, logs.",
inputSchema={"type": "object", "properties": {
"config_id": {"type": "string", "description": "ID da config OCI"},
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões específicas (ex: ['us-ashburn-1']). Vazio = todas."},
+ "compartment_id": {"type": "string", "description": "OCID de compartment para filtrar findings. Vazio = todos os compartments."},
}, "required": ["config_id"]}),
Tool(name="cis_scan_storage",
description="Coleta dados de storage e executa checks CIS 5.1.1-5.3.1: buckets, block/boot volumes, file storage, CMK, versioning.",
inputSchema={"type": "object", "properties": {
"config_id": {"type": "string", "description": "ID da config OCI"},
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões específicas (ex: ['us-ashburn-1']). Vazio = todas."},
+ "compartment_id": {"type": "string", "description": "OCID de compartment para filtrar findings. Vazio = todos os compartments."},
}, "required": ["config_id"]}),
Tool(name="cis_scan_asset_management",
description="Coleta dados de assets e executa checks CIS 6.1-6.2: compartments, recursos no root.",
inputSchema={"type": "object", "properties": {
"config_id": {"type": "string", "description": "ID da config OCI"},
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões específicas (ex: ['us-ashburn-1']). Vazio = todas."},
+ "compartment_id": {"type": "string", "description": "OCID de compartment para filtrar findings. Vazio = todos os compartments."},
}, "required": ["config_id"]}),
Tool(name="cis_get_check",
description="Retorna findings detalhados de um check CIS individual por ID (ex: '1.7', '2.1'). Requer scan da seção correspondente.",
@@ -529,11 +574,18 @@ def _handle_cis_list_checks(args):
def _handle_scan_section(args, section: str):
config_id = args["config_id"]
regions = args.get("regions") or None
+ compartment_id = args.get("compartment_id") or None
session = _get_or_create_checker(config_id, regions=regions)
_analyze_section(session, section)
result = _section_summary(session["checker"], section)
if regions:
result["regions_filter"] = regions
+ if compartment_id:
+ _filter_by_compartment(result, compartment_id)
+ col_errors = session.get("collection_errors", {}).get(section, [])
+ if col_errors:
+ result["partial_errors"] = col_errors
+ result["warning"] = f"{len(col_errors)} coletores falharam — resultado pode estar incompleto"
return result
def _handle_cis_get_check(args):
@@ -620,17 +672,22 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
handler = TOOL_HANDLERS.get(name)
if not handler:
return [TextContent(type="text", text=json.dumps({"error": f"Tool '{name}' not found"}))]
- try:
- loop = asyncio.get_event_loop()
- result = await asyncio.wait_for(
- loop.run_in_executor(None, partial(handler, arguments)),
- timeout=TOOL_TIMEOUT
- )
- return [TextContent(type="text", text=json.dumps(result, default=str, ensure_ascii=False))]
- except asyncio.TimeoutError:
- return [TextContent(type="text", text=json.dumps({"error": f"Tool '{name}' timed out after {TOOL_TIMEOUT}s"}))]
- except Exception as e:
- return [TextContent(type="text", text=json.dumps({"error": str(e), "traceback": traceback.format_exc()}))]
+ last_error = None
+ loop = asyncio.get_event_loop()
+ for attempt in range(1 + TOOL_RETRIES):
+ try:
+ result = await asyncio.wait_for(
+ loop.run_in_executor(None, partial(handler, arguments)),
+ timeout=TOOL_TIMEOUT
+ )
+ return [TextContent(type="text", text=json.dumps(result, default=str, ensure_ascii=False))]
+ except asyncio.TimeoutError:
+ last_error = f"Tool '{name}' timed out after {TOOL_TIMEOUT}s"
+ except Exception as e:
+ last_error = f"{e}"
+ if attempt < TOOL_RETRIES:
+ print(f"[Retry] Tool '{name}' failed ({last_error}), retrying...")
+ return [TextContent(type="text", text=json.dumps({"error": last_error}))]
async def main():
async with stdio_server() as (read_stream, write_stream):
diff --git a/frontend/index.html b/frontend/index.html
index 3257d62..688b573 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -115,10 +115,22 @@ tbody tr:last-child td{border-bottom:none}
.g3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:.75rem}
/* ── Chat ── */
-.ch-c{display:flex;flex-direction:column;height:calc(100vh - 56px - 3rem);overflow:hidden}
-.ch-t{display:flex;gap:.5rem;align-items:center;padding:.6rem .85rem;background:var(--bg1);border:1px solid var(--bd);
- border-radius:var(--rl) var(--rl) 0 0;font-size:.74rem;flex-wrap:wrap;box-shadow:var(--sh1)}
+.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}
+.ch-side.open{width:300px;border-left-width:1px}
+.ch-side-inner{width:300px;padding:.85rem;overflow-y:auto;height:100%;box-sizing:border-box}
+.ch-side-title{font-weight:700;font-size:.82rem;padding-bottom:.5rem;border-bottom:1px solid var(--bd);margin-bottom:.65rem;display:flex;align-items:center;gap:.4rem}
+.ch-side-section{margin-bottom:.85rem}
+.ch-side-section label{font-weight:600;color:var(--t3);font-size:.68rem;display:block;margin-bottom:3px}
+.ch-side-section input[type="number"],.ch-side-section select{width:100%;padding:.32rem .5rem;font-size:.74rem;border:1px solid var(--bd);border-radius:6px;background:var(--bg2);box-sizing:border-box}
+.ch-side .g2{display:grid;grid-template-columns:1fr 1fr;gap:.5rem}
+.ch-main{flex:1;display:flex;flex-direction:column;min-width:0;order:1}
+.ch-t{display:flex;gap:.5rem;align-items:center;padding:.55rem .85rem;background:var(--bg1);
+ border-bottom:1px solid var(--bd);font-size:.74rem;flex-wrap:wrap}
.ch-t select{max-width:260px;font-size:.74rem;padding:.35rem .5rem}
+.ch-icon-btn{width:32px;height:32px;display:flex;align-items:center;justify-content:center;border-radius:8px;cursor:pointer;border:1px solid var(--bd);background:var(--bg1);font-size:.82rem;transition:all .2s;flex-shrink:0}
+.ch-icon-btn:hover{border-color:var(--ac);color:var(--ac)}
+.ch-icon-btn.active{background:var(--pl);border-color:var(--ac);color:var(--ac)}
.mdrop{position:relative;display:inline-block}
.mdrop-btn{background:var(--bg2);border:1px solid var(--bd);border-radius:8px;padding:.35rem .6rem;font-size:.74rem;cursor:pointer;max-width:320px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:.3rem;min-width:180px}
.mdrop-btn:hover{border-color:var(--ac)}
@@ -131,14 +143,8 @@ tbody tr:last-child td{border-bottom:none}
.mdrop-list .itm{padding:.3rem .7rem;font-size:.74rem;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.mdrop-list .itm:hover{background:var(--bg3)}
.mdrop-list .itm.sel{background:var(--pl);color:var(--p);font-weight:600}
-.ch-params{padding:.55rem .85rem;background:var(--bg1);border-left:1px solid var(--bd);border-right:1px solid var(--bd);border-bottom:1px solid var(--bg3);font-size:.72rem}
-.ch-params .g3{display:grid;grid-template-columns:repeat(3,1fr);gap:.5rem;margin-bottom:.3rem}
-.ch-params label{font-weight:600;color:var(--t3);font-size:.66rem;margin-bottom:2px;display:block}
-.ch-params input{width:100%;padding:.25rem .4rem;font-size:.72rem;border:1px solid var(--bd);border-radius:6px;background:var(--bg2)}
-.gear-btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:8px;cursor:pointer;border:1px solid var(--bd);background:var(--bg1);font-size:.78rem;transition:all .2s;flex-shrink:0}
-.gear-btn:hover{border-color:var(--ac);color:var(--ac)}
.ch-t label{font-weight:600;color:var(--t3);font-size:.72rem}
-.ch-m{flex:1;overflow-y:auto;padding:1rem;background:var(--bg2);border-left:1px solid var(--bd);border-right:1px solid var(--bd)}
+.ch-m{flex:1;overflow-y:auto;padding:1rem;background:var(--bg2)}
.cm{margin-bottom:.85rem;display:flex;gap:.6rem;animation:msgIn .3s var(--trans)}.cm-user{justify-content:flex-end}
@keyframes msgIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
.cm-user .cb{background:linear-gradient(145deg,var(--ac),var(--ach));color:#fff;border-radius:var(--rl) var(--rl) 6px var(--rl);border-color:transparent;box-shadow:0 2px 8px rgba(199,70,52,.15)}
@@ -149,8 +155,7 @@ tbody tr:last-child td{border-bottom:none}
.cm-ts{font-size:.65rem;color:#999;margin-top:3px;opacity:.7}
.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:1px solid var(--bd);border-top:none;
- border-radius:0 0 var(--rl) var(--rl);box-shadow:0 -2px 8px rgba(0,0,0,.02)}
+.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 .btn{border-radius:10px}
@@ -185,11 +190,36 @@ tbody tr:last-child td{border-bottom:none}
.emp p{line-height:1.55}
/* ── Explorer ── */
-.exp-r{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:.65rem;margin-top:.65rem}
-.exp-c{background:var(--bg2);border:1px solid var(--bd);border-radius:var(--r);padding:.85rem;font-size:.78rem;transition:all .2s var(--trans)}
+.exp-wrap{display:flex;height:calc(100vh - 56px - 3rem);border:1px solid var(--bd);border-radius:var(--rl);overflow:hidden;box-shadow:var(--sh2)}
+.exp-tree{width:240px;flex-shrink:0;background:var(--bg1);border-right:none;overflow-y:auto;padding:.5rem 0;min-width:140px;max-width:50%}
+.exp-resize{width:5px;flex-shrink:0;cursor:col-resize;background:var(--bd);transition:background .15s;position:relative}
+.exp-resize:hover,.exp-resize.dragging{background:var(--ac);opacity:.6}
+.exp-resize::after{content:'';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:3px;height:28px;border-left:1px solid var(--t4);border-right:1px solid var(--t4);opacity:.4}
+.exp-resize:hover::after,.exp-resize.dragging::after{border-color:var(--ac);opacity:.8}
+.exp-main{flex:1;display:flex;flex-direction:column;min-width:0;overflow:hidden}
+.exp-toolbar{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-bottom:1px solid var(--bd);background:var(--bg);flex-wrap:wrap}
+.exp-toolbar select{font-size:.74rem;padding:.3rem .5rem;border-radius:8px;border:1px solid var(--bd);background:var(--bg2);color:var(--t1)}
+.exp-nav{display:flex;border-bottom:1px solid var(--bd);background:var(--bg);flex-shrink:0}
+.exp-cats{display:flex;overflow-x:auto;gap:0;flex:1}
+.exp-cat{padding:.45rem .7rem;font-size:.66rem;font-weight:600;cursor:pointer;color:var(--t4);white-space:nowrap;border-bottom:2px solid transparent;transition:all .15s;text-transform:uppercase;letter-spacing:.3px}
+.exp-cat:hover{color:var(--t2);background:var(--bg2)}
+.exp-cat.active{color:var(--ac);border-bottom-color:var(--ac);background:var(--acl)}
+.exp-tabs{display:flex;gap:3px;padding:.3rem .75rem;border-bottom:1px solid var(--bd);background:var(--bg2);overflow-x:auto;flex-shrink:0}
+.exp-tab{padding:.3rem .6rem;font-size:.7rem;border-radius:8px;cursor:pointer;color:var(--t3);white-space:nowrap;transition:all .15s;border:1px solid transparent}
+.exp-tab:hover{background:var(--bg1);color:var(--t1);border-color:var(--bd)}
+.exp-tab.active{background:var(--bg1);color:var(--ac);font-weight:600;border-color:var(--ac);box-shadow:0 1px 3px rgba(199,70,52,.1)}
+.exp-content{flex:1;overflow-y:auto;padding:.75rem}
+.exp-r{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:.65rem}
+.exp-c{background:var(--bg2);border:1px solid var(--bd);border-radius:var(--r);padding:.85rem;font-size:.78rem;transition:all .2s var(--trans);overflow:hidden;word-wrap:break-word;overflow-wrap:break-word;min-width:0;box-sizing:border-box}
.exp-c:hover{border-color:var(--bdf);box-shadow:var(--sh1);transform:translateY(-1px)}
-.exp-c strong{color:var(--t1);font-size:.84rem;font-weight:600}
-.exp-c .em{font-family:var(--fm);font-size:.65rem;color:var(--t4);word-break:break-all}
+.exp-c strong{color:var(--t1);font-size:.84rem;font-weight:600;display:block;overflow:hidden;text-overflow:ellipsis}
+.exp-c .em{font-family:var(--fm);font-size:.65rem;color:var(--t4);word-break:break-all;overflow-wrap:break-word;display:inline-block;max-width:100%}
+.exp-node{padding:.15rem 0;font-size:.74rem;color:var(--t2)}
+.exp-node-row{display:flex;align-items:center;gap:.25rem;padding:.25rem .5rem;border-radius:6px;cursor:pointer;transition:all .15s}
+.exp-node-row:hover{background:var(--bg2)}
+.exp-node-row.selected{background:var(--acl);color:var(--ac);font-weight:600;border-left:2px solid var(--ac)}
+.exp-node-toggle{width:16px;text-align:center;font-size:.55rem;color:var(--t4);flex-shrink:0;cursor:pointer;transition:transform .15s}
+.exp-node-children{padding-left:.85rem;border-left:1px solid var(--bg4);margin-left:.65rem}
/* ── Animations ── */
@keyframes fi{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
@@ -206,7 +236,70 @@ tbody tr:last-child td{border-bottom:none}
::-webkit-scrollbar-thumb:hover{background:var(--bdf)}
/* ── Responsive ── */
-@media(max-width:768px){.sb{transform:translateX(-100%)}.sb.open{transform:translateX(0)}.mc{margin-left:0}.g2,.g3{grid-template-columns:1fr}.pc{padding:1rem}}
+@media(max-width:768px){.sb{transform:translateX(-100%)}.sb.open{transform:translateX(0)}.mc{margin-left:0}.g2,.g3{grid-template-columns:1fr}.pc{padding:1rem}
+.exp-wrap{flex-direction:column;height:auto;min-height:calc(100vh - 56px - 3rem)}
+.exp-tree{width:100%;max-height:180px;border-right:none;border-bottom:1px solid var(--bd)}
+.exp-r{grid-template-columns:1fr}
+.exp-cats{flex-wrap:wrap}
+.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-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-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-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-file:hover{border-color:#7b42bc}
+.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}
+.tf-file-name{font-size:.76rem;font-weight:600;font-family:var(--fm);color:var(--t1)}
+.tf-file-meta{font-size:.64rem;color:var(--t3)}
+.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-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-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}
+.tf-plan-item{display:flex;align-items:baseline;gap:.5rem;padding:.25rem 0;font-size:.74rem}
+.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)}
+.tf-badge-run{background:var(--bll);color:var(--bl)}
+.tf-badge-err{background:var(--rdl);color:var(--rd)}
+.tf-badge-dest{background:var(--t4);color:var(--bg)}
+.tf-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:100}
+.tf-modal{background:var(--bg);border-radius:var(--r);padding:1.5rem;max-width:420px;width:90%;box-shadow:0 8px 30px rgba(0,0,0,.25)}
+.tf-modal h3{margin:0 0 .5rem;color:var(--rd)}
+.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}
+}
@@ -216,13 +309,16 @@ const V='1.1';
const LOGO_W=` `;
const LOGO_R=` `;
-const S={user:null,token:null,tab:'chat',msgs:[],sid:null,reports:[],ociCfg:[],genaiCfg:[],adbCfg:[],mcpSvr:[],users:[],models:{},regions:[],embModels:{},expData:null,editing:null,
+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:'',
- chatParams:{temperature:1,max_tokens:6000,top_p:0.95,top_k:1,frequency_penalty:0,presence_penalty:0},chatParamsOpen:false,chatUseTools:true,
+ 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:'',
rptLevel:2,rptObp:false,rptRaw:false,rptRedact:false,reportFiles:{},dlExpandedRid:null,dlTenancyFilter:'',
- cisVer:null,cisCheckResult:null,cisUpdating:false,rptHistFilter:'',chatFiles:[]};
+ 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};
const API='/api';
async function $api(p,o={}){const h={...(o.headers||{})};if(S.token)h['Authorization']='Bearer '+S.token;
@@ -241,11 +337,12 @@ async function loadData(){try{
try{const m=await $api('/genai/models');S.models=m.models;S.regions=m.regions;S.embModels=m.embedding_models||{};S.ociRegions=m.oci_regions||{}}catch(e){}
try{S.adbCfg=await $api('/adb/configs')}catch(e){S.adbCfg=[]}
try{S.chatPrompts=await $api('/prompts/chat')}catch(e){S.chatPrompts=[]}
+ 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){}
}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;S.expData=null;R();if(t==='audit'&&S.user.role==='admin')loadAudit();if(t==='downloads')refreshDl();
+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()}
const tm={'oci-config':'oci','genai':'genai','adb':'adb','mcp':'mcp'};if(tm[t])setTimeout(()=>refreshCLogs(tm[t]),100)}
/* ── Login ── */
@@ -264,8 +361,9 @@ function rLogin(){return`
/* ── App Shell ── */
function rApp(){return`
`}
+const TF_IC='
';
function rSb(){
- const tabs=[['chat','💬','Chat Agent'],['explorer','🔍','OCI Explorer'],['report','📊','Reports'],['downloads','📁','Downloads']];
+ const tabs=[['chat','💬','Chat Agent'],['terraform',TF_IC,'Terraform'],['explorer','🔍','OCI Explorer'],['report','📊','Reports'],['downloads','📁','Downloads']];
const ctabs=[['oci-config','☁️','Credenciais OCI'],['genai','🧠','GenAI Config'],['mcp','🔌','MCP Servers'],['adb','🗄️','ADB Vector'],['embeddings','🧬','Embeddings']];
const atabs=[['users','👥','Usuários'],['mfa','🔐','MFA'],['audit','📋','Audit Log']];
const i=(S.user?.first_name||S.user?.username||'?')[0].toUpperCase();
@@ -280,57 +378,70 @@ ${atabs.map(t=>`
${i}
${S.user?.first_name?S.user.first_name+' '+S.user.last_name:S.user?.username}
${S.user?.role}
⏻
`}
-function rTb(){const t={'chat':'💬 AI Agent Chat','explorer':'🔍 OCI Account Explorer','report':'📊 Compliance Reports','downloads':'📁 Downloads','oci-config':'☁️ Credenciais OCI','genai':'🧠 OCI Generative AI','mcp':'🔌 MCP Servers','adb':'🗄️ Autonomous DB Vector','embeddings':'🧬 Embeddings','users':'👥 Gerenciar Usuários','mfa':'🔐 Autenticação MFA','audit':'📋 Audit Log'};
+function rTb(){const t={'chat':'💬 AI Agent Chat','terraform':TF_IC+' Terraform Agent','explorer':'🔍 OCI Account Explorer','report':'📊 Compliance Reports','downloads':'📁 Downloads','oci-config':'☁️ Credenciais OCI','genai':'🧠 OCI Generative AI','mcp':'🔌 MCP Servers','adb':'🗄️ Autonomous DB Vector','embeddings':'🧬 Embeddings','users':'👥 Gerenciar Usuários','mfa':'🔐 Autenticação MFA','audit':'📋 Audit Log'};
return`
`}
-function rPg(){switch(S.tab){case'chat':return rChat();case'explorer':return rExplorer();case'report':return rReport();case'downloads':return rDl();case'oci-config':return rOci();case'genai':return rGenAI();case'mcp':return rMCP();case'adb':return rADB();case'embeddings':return rEmbeddings();case'users':return rUsers();case'mfa':return rMfa();case'audit':return rAudit();default:return''}}
+function rPg(){switch(S.tab){case'chat':return rChat();case'terraform':return rTerraform();case'explorer':return rExplorer();case'report':return rReport();case'downloads':return rDl();case'oci-config':return rOci();case'genai':return rGenAI();case'mcp':return rMCP();case'adb':return rADB();case'embeddings':return rEmbeddings();case'users':return rUsers();case'mfa':return rMfa();case'audit':return rAudit();default:return''}}
/* ── Chat ── */
function rChat(){
const ms=S.msgs.length===0?'
🤖
Inicie uma conversa com o agente.
Selecione um modelo para começar.
'
:S.msgs.map(m=>`
${fm(m.c)}
${m.t?`
${m.t}
`:''}
`).join('');
- // Build custom dropdown items
- // Skip non-chat models: Codex (completions only), Image, Audio, Guard, ProtectAI, GPT-oss, Pro (Responses API only)
- const skip=new Set(['openai.gpt-image-1','openai.gpt-image-1.5','openai.gpt-audio','meta.llama-guard-4-12b','protectai.deberta-v3-base-prompt-injection-v2',
- 'openai.gpt-5.3-codex','openai.gpt-5.2-codex','openai.gpt-5.1-codex','openai.gpt-5.1-codex-max','openai.gpt-5.1-codex-mini','openai.gpt-5-codex',
- 'openai.gpt-oss-120b','openai.gpt-oss-20b']);
- const provs={};for(const[mid,info]of Object.entries(S.models)){if(skip.has(mid))continue;const p=info.provider||'other';if(!provs[p])provs[p]=[];provs[p].push({id:mid,name:info.name})}
+ const provs={};for(const[mid,info]of Object.entries(S.models)){const p=info.provider||'other';if(!provs[p])provs[p]=[];provs[p].push({id:mid,name:info.name})}
const provOrder=['openai','google','meta','xai'];
let ddItems='';
if(S.genaiCfg.length){ddItems+=`
Configs Salvas
`;S.genaiCfg.forEach(g=>{const mi=S.models[g.model_id];ddItems+=`
${g.name||mi?.name||g.model_id} (${g.genai_region})
`})}
provOrder.filter(p=>provs[p]).forEach(p=>{ddItems+=`
${p.charAt(0).toUpperCase()+p.slice(1)}
`;provs[p].forEach(m=>{ddItems+=`
${m.name}
`})});
- // Current label
let curLabel='Selecione um modelo...';
if(S.chatModel.startsWith('cfg:')){const g=S.genaiCfg.find(x=>x.id===S.chatModel.slice(4));const mi=g?S.models[g.model_id]:null;curLabel=g?(g.name||mi?.name||g.model_id)+' ('+g.genai_region+')':'Config...'}
else if(S.chatModel){const mi=S.models[S.chatModel];curLabel=mi?mi.name:S.chatModel}
const isDirect=S.chatModel&&!S.chatModel.startsWith('cfg:');
- const hasModel=!!S.chatModel;
- // OCI selector for direct mode
const ociSel=isDirect?`
Credencial OCI... ${S.ociCfg.map(c=>`${c.tenancy_name} (${c.region}) `).join('')}`:'';
- const gearBtn=hasModel?`
${S.chatParamsOpen?'✕':'⚙️'}
`:'';
const ragBadge=S.adbCfg.some(c=>c.genai_config_id&&c.is_active)?'
RAG ':'';
- // Params panel - available for any model (direct or preset)
- const pp=S.chatParams;
- const paramsPanel=hasModel&&S.chatParamsOpen?`
`:'';
+ const toolCount=S.mcpSvr.filter(m=>m.is_active&&Array.isArray(m.tools)&&m.tools.length).reduce((a,m)=>a+m.tools.length,0);
+ let sideContent='';
+ if(S.chatPanel==='config')sideContent=rChatConfig(isDirect,toolCount);
+ else if(S.chatPanel==='logs')sideContent=rChatLogs();
return`
+
+
🧠 Modelo:
-${ociSel}${gearBtn}${ragBadge}
Limpar
-${paramsPanel}
${ms}
-${S.chatFiles.length?`
${S.chatFiles.map((f,i)=>f.type==='image'?`
× `:`
📄 ${f.name} ×
`).join('')}
`:''}
+${ociSel}${ragBadge}
+
⚙️
+
📋
+
🗑️
+
${ms}
+${S.chatFiles.length?`
${S.chatFiles.map((f,i)=>f.type==='image'?`
× `:`
📄 ${f.name} ×
`).join('')}
`:''}
📎
-Enviar →
`}
+
Enviar → `}
+function rChatConfig(isDirect,toolCount){
+ const pp=S.chatParams;
+ return`⚙️ Configurações
+
+
+ 🔌 MCP Tools
+${toolCount?`${toolCount} tools disponíveis `:''}
+
+${isDirect?`
+Credencial OCI
+
+Selecionar... ${S.ociCfg.map(c=>`${c.tenancy_name} (${c.region}) `).join('')}
`:''}`}
+function rChatLogs(){
+ return`📋 Chat Logs
+
+
+Todos Erros Avisos Info
+↻
+`}
function toggleModelDrop(){const dd=document.getElementById('mdd');dd.classList.toggle('open');if(dd.classList.contains('open')){const inp=document.getElementById('mds');inp.value='';inp.focus();filterModels('')}}
function pickModel(v){S.chatModel=v;document.getElementById('mdd').classList.remove('open');
if(v&&!v.startsWith('cfg:')&&!S.chatOci&&S.ociCfg.length){S.chatOci=S.ociCfg[0].id;const c=S.ociCfg[0];S.chatRegion=c.region;S.chatCompartment=c.compartment_id||''}
@@ -341,9 +452,9 @@ function filterModels(q){const list=document.getElementById('mdl');const items=l
else{const show=!q||el.textContent.toLowerCase().includes(ql);el.style.display=show?'':'none';if(show)grpHasVisible=true}});
if(lastGrp)lastGrp.style.display=grpHasVisible?'':'none'}
document.addEventListener('click',e=>{const dd=document.getElementById('mdd');if(dd&&dd.classList.contains('open')&&!e.target.closest('.mdrop'))dd.classList.remove('open');
- if(!e.target.closest('.dd')){let changed=false;if(S.rptOciOpen){S.rptOciOpen=false;changed=true}if(S.rptRselOpen){S.rptRselOpen=false;changed=true}if(S.ociFormRegOpen){S.ociFormRegOpen=false;changed=true}if(S.rptRegionsOpen){S.rptRegionsOpen=false;changed=true}if(changed)R()}})
+ if(!e.target.closest('.dd')){let changed=false;if(S.rptOciOpen){S.rptOciOpen=false;changed=true}if(S.rptRselOpen){S.rptRselOpen=false;changed=true}if(S.ociFormRegOpen){S.ociFormRegOpen=false;changed=true}if(S.rptRegionsOpen){S.rptRegionsOpen=false;changed=true}if(S.expRegDdOpen){S.expRegDdOpen=false;changed=true}if(changed)R()}})
function chatOciChanged(){S.chatOci=document.getElementById('coci').value;const c=S.ociCfg.find(x=>x.id===S.chatOci);if(c){S.chatRegion=c.region;S.chatCompartment=c.compartment_id||''}}
-function toggleChatParams(){S.chatParamsOpen=!S.chatParamsOpen;R()}
+function togglePanel(p){S.chatPanel=S.chatPanel===p?'':p;R();if(S.chatPanel==='logs')loadChatLogs()}
function editPrompt(id){S.editingPrompt=id;R()}
async function savePrompt(){const name=document.getElementById('spname').value;const content=document.getElementById('gsysp').value;
if(!name||!content)return sm('spm','Preencha nome e conteúdo do prompt.','e');
@@ -419,27 +530,549 @@ 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()}
+async function loadChatLogs(){
+ const sev=document.getElementById('cl-sev-chat')?.value||'';
+ const qs='limit=50'+(sev?'&severity='+sev:'')+(S.sid?'&session_id='+S.sid:'');
+ const el=document.getElementById('chat-logs-body');if(!el)return;
+ try{const logs=await $api('/chat-logs?'+qs);
+ if(!logs.length){el.innerHTML='';return}
+ el.innerHTML='Data Fonte Ação Status Mensagem '+
+ logs.map(l=>{const sc=l.severity==='error'?'err':l.severity==='warning'?'w':'ok';
+ return''+l.created_at+' '+
+ ''+l.source+' '+
+ ''+l.action+' '+
+ ''+l.severity+' '+
+ ''+(l.message?.slice(0,150)||'—')+' '}).join('')+
+ '
'}
+ catch(e){el.innerHTML=''+e.message+'
'}}
+
+/* ── Terraform Agent ── */
+const TF_ICON=` `;
+function escHtml(s){return s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"')}
+
+function fmTf(t){
+ // Extract ALL hcl blocks and build files list
+ 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 ''+
+ '
'+escHtml(trimmed)+' ';
+ });
+ 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);
+ return ''+
+ S.tfPlan.map(x=>'
+ '+escHtml(x.type)+' '+escHtml(x.desc)+'
').join('')+'
';
+ });
+ // Update files list
+ 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
+ }
+ r=r.replace(/\*\*(.*?)\*\*/g,'$1 ').replace(/`(.*?)`/g,'$1').replace(/\n/g,' ');
+ return r;
+}
+
+function rTerraform(){
+ const ms=S.tfMsgs.length===0
+ ?`${TF_ICON}
Descreva a infraestrutura OCI desejada
Ex: "Crie uma VCN com subnets pública e privada, internet gateway e um compute instance"
`
+ :S.tfMsgs.map(m=>`${m.r==='assistant'?fmTf(m.c):fm(m.c)}
`).join('');
+
+ // Model dropdown
+ const provs={};for(const[mid,info]of Object.entries(S.models)){const p=info.provider||'other';if(!provs[p])provs[p]=[];provs[p].push({id:mid,name:info.name})}
+ const provOrder=['openai','google','meta','xai'];
+ let ddItems='';
+ if(S.genaiCfg.length){ddItems+='Configs Salvas
';S.genaiCfg.forEach(g=>{const mi=S.models[g.model_id];ddItems+=`${g.name||mi?.name||g.model_id}
`})}
+ provOrder.filter(p=>provs[p]).forEach(p=>{ddItems+=`${p[0].toUpperCase()+p.slice(1)}
`;provs[p].forEach(m=>{ddItems+=`${m.name}
`})});
+ let curLabel='Selecione modelo...';
+ if(S.tfModel.startsWith('cfg:')){const g=S.genaiCfg.find(x=>x.id===S.tfModel.slice(4));curLabel=g?(g.name||g.model_id):'Config...'}
+ else if(S.tfModel){const mi=S.models[S.tfModel];curLabel=mi?mi.name:S.tfModel}
+
+ const isDirect=S.tfModel&&!S.tfModel.startsWith('cfg:');
+ const ociSel=isDirect?`OCI Config... ${S.ociCfg.map(c=>`${c.tenancy_name} (${c.region}) `).join('')} `:'';
+ const compSel=(S.tfComps.length||S.tfCompLoading)?`${S.tfCompLoading?'Carregando... ':'Compartment... '+tfFlatComps(S.tfComps,0).map(c=>`${c.name} `).join('')} `:'';
+
+ // Status badge
+ const stBadge=_tfBadge(S.tfStatus);
+
+ return `
+
+
+
${ms}
+
+
+ Gerar
+
+
+
+
+
+
+
+ Files${S.tfFiles.length?' ('+S.tfFiles.length+')':''}
+
+
+
+ Plan${S.tfPlan.length?' ('+S.tfPlan.length+')':''}
+
+
+
+ Resources${S.tfResources?(' ('+Object.values(S.tfResources).reduce((a,b)=>a+(Array.isArray(b)?b.length:0),0)+')'):''}
+
+
+
+ ${S.tfFiles.length?'Download All ':''}
+
+
+
${rTfBottomContent()}
+
+ ${S.tfConfirmDest?rTfConfirmDestroy():''}
+
`;
+}
+
+function _tfBadge(st){
+ const m={draft:['draft','Draft'],planning:['run','Planning...'],planned:['ok','Plan OK'],applying:['run','Applying...'],applied:['ok','Applied'],destroying:['run','Destroying...'],destroyed:['dest','Destroyed'],failed:['err','Failed']};
+ const[cls,label]=m[st]||['draft',st];
+ return`${label} `;
+}
+
+function rTfActions(){
+ if(S.tfRunning)return'Cancel ';
+ const st=S.tfStatus;let btns='';
+ if(S.tfCode&&(st==='draft'||st==='failed'))btns+=`${TF_ICON} Plan `;
+ if(st==='planned'||st==='failed')btns+=`${TF_ICON} Re-plan `;
+ if(st==='planned')btns+='Apply ';
+ if(st==='applied')btns+=`${TF_ICON} Re-plan `;
+ if(st==='applied'||st==='planned')btns+='Destroy ';
+ return btns;
+}
+
+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 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);
+ if(oc){
+ const comp=S.tfCompartment||oc.compartment_id||'';
+ 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)=>{
+ 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`
+
+
${f.name}
${sizeStr}${f.auto?' · auto-generated':''}
+
+
`}).join('');
+}
+
+function rTfPlanPanel(){
+ if(!S.tfPlan.length)return`${TF_ICON}
Nenhum recurso no plano
`;
+ return S.tfPlan.map(r=>`
+
+
+
${escHtml(r.type)}
+
${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``;
+ return out;
+}
+
+function rTfConfirmDestroy(){
+ return`
+
Confirmar Destroy
+
Isso irá destruir todos os recursos provisionados. Esta ação é irreversível.
+
Digite DESTROY para confirmar:
+
+
+ Cancelar
+ Destruir
+
+
`;
+}
+
+function tfStartResize(e){
+ e.preventDefault();const el=document.getElementById('tfBottom');if(!el)return;
+ const startY=e.clientY;const startH=el.offsetHeight;
+ const bar=e.target;bar.classList.add('dragging');
+ function onMove(ev){const dh=startY-ev.clientY;el.style.height=Math.max(100,Math.min(startH+dh,window.innerHeight-200))+'px'}
+ function onUp(){bar.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp)}
+ document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);
+}
+
+function tfCopyFile(i){
+ const files=[...S.tfFiles];
+ const oc=S.ociCfg.find(x=>x.id===S.tfOci);
+ if(oc){const comp=S.tfCompartment||oc.compartment_id||'';if(!files.find(f=>f.name==='terraform.tfvars'))files.push({name:'terraform.tfvars',content:`compartment_id = "${comp}"\nregion = "${oc.region}"`,auto:true})}
+ if(files[i])navigator.clipboard.writeText(files[i].content).then(()=>alert('Copiado!'));
+}
+function tfDlFile(i){
+ const files=[...S.tfFiles];
+ const oc=S.ociCfg.find(x=>x.id===S.tfOci);
+ if(oc){const comp=S.tfCompartment||oc.compartment_id||'';if(!files.find(f=>f.name==='terraform.tfvars'))files.push({name:'terraform.tfvars',content:`compartment_id = "${comp}"\nregion = "${oc.region}"`,auto:true})}
+ const f=files[i];if(!f)return;
+ 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);
+}
+function tfDlAll(){
+ // Download each file
+ const files=[...S.tfFiles];
+ const oc=S.ociCfg.find(x=>x.id===S.tfOci);
+ if(oc){const comp=S.tfCompartment||oc.compartment_id||'';if(!files.find(f=>f.name==='terraform.tfvars'))files.push({name:'terraform.tfvars',content:`compartment_id = "${comp}"\nregion = "${oc.region}"`,auto:true})}
+ files.forEach((f,i)=>{setTimeout(()=>{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)},i*200)});
+}
+
+function tfPickModel(v){S.tfModel=v;S.tfMdOpen=false;if(v&&!v.startsWith('cfg:')&&!S.tfOci&&S.ociCfg.length){S.tfOci=S.ociCfg[0].id;S.tfRegion=S.ociCfg[0].region;S.tfCompartment=S.ociCfg[0].compartment_id||'';tfLoadComps()}else if(v&&v.startsWith('cfg:')){const g=S.genaiCfg.find(x=>x.id===v.slice(4));if(g&&g.oci_config_id&&!S.tfOci){S.tfOci=g.oci_config_id;const c=S.ociCfg.find(x=>x.id===g.oci_config_id);if(c){S.tfRegion=c.region;S.tfCompartment=c.compartment_id||''}tfLoadComps()}}R()}
+async function tfLoadComps(){
+ const oid=S.tfOci||(S.tfModel.startsWith('cfg:')?((S.genaiCfg.find(x=>x.id===S.tfModel.slice(4))||{}).oci_config_id||''):'');
+ if(!oid){S.tfComps=[];R();return}
+ S.tfCompLoading=true;R();
+ try{const d=await $api('/oci/explore/'+oid+'/compartment-tree');S.tfComps=d&&!d.error?d:[];
+ if(S.tfComps.length&&!S.tfCompartment){S.tfCompartment=S.tfComps[0].id;tfLoadResources()}
+ }catch(e){S.tfComps=[]}
+ S.tfCompLoading=false;R();
+}
+function tfFlatComps(comps,depth){
+ let r=[];for(const c of comps){r.push({id:c.id,name:' '.repeat(depth)+c.name,depth});if(c.children&&c.children.length)r=r.concat(tfFlatComps(c.children,depth+1))}return r;
+}
+async function tfLoadResources(){
+ const oid=S.tfOci||(S.tfModel.startsWith('cfg:')?((S.genaiCfg.find(x=>x.id===S.tfModel.slice(4))||{}).oci_config_id||''):'');
+ const comp=S.tfCompartment;
+ if(!oid||!comp){S.tfResources=null;R();return}
+ S.tfResLoading=true;R();
+ try{const d=await $api('/terraform/resources?oci_config_id='+encodeURIComponent(oid)+'&compartment_id='+encodeURIComponent(comp)+(S.tfRegion?'®ion='+encodeURIComponent(S.tfRegion):''));
+ S.tfResources=d&&!d.error?d:null;
+ }catch(e){S.tfResources=null}
+ S.tfResLoading=false;R();
+}
+function rTfResourcesPanel(){
+ if(S.tfResLoading)return``;
+ if(!S.tfCompartment)return`Selecione um compartment para ver os recursos existentes
`;
+ if(!S.tfResources)return`Nenhum recurso carregado
Carregar Recursos `;
+ const res=S.tfResources;
+ 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:'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:'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'}) `}
+ ];
+ let total=0;
+ let html='';
+ html+='
Recursos existentes no compartment Atualizar
';
+ for(const cat of cats){
+ const items=res[cat.key]||[];
+ if(!items.length)continue;
+ total+=items.length;
+ html+=`
${cat.icon} ${cat.label} (${items.length})
`;
+ for(const r of items.slice(0,10)){
+ html+=`
${cat.fmt(r)}
`;
+ }
+ if(items.length>10)html+=`
... e mais ${items.length-10}
`;
+ html+='
';
+ }
+ if(!total)html+='
Nenhum recurso encontrado neste compartment
';
+ else html+=`
Total: ${total} recursos. O agente irá reutilizá-los automaticamente quando possível.
`;
+ html+='
';
+ return html;
+}
+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()}
+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)}
+
+async function tfSend(){
+ const el=document.getElementById('tfi');const m=el.value.trim();if(!m)return;
+ if(!S.tfModel){S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione um modelo 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{
+ const body={message:m,session_id:S.tfSid,use_tools:false};
+ if(S.tfModel.startsWith('cfg:'))body.genai_config_id=S.tfModel.slice(4);
+ else{
+ if(!S.tfOci){S.tfMsgs.pop();S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione uma credencial OCI.'});R();return}
+ body.model_id=S.tfModel;body.oci_config_id=S.tfOci;body.genai_region=S.tfRegion;body.compartment_id=S.tfCompartment;
+ }
+ const d=await $api('/terraform/chat',{method:'POST',body});
+ S.tfSid=d.session_id;
+ if(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()}
+}
+
+async function tfPollResult(mid){
+ for(let i=0;i<600;i++){
+ await new Promise(r=>setTimeout(r,i<10?1000:3000));
+ try{const r=await $api('/chat/'+mid+'/status');
+ if(r.status==='done'){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:r.content});R();tfScroll();
+ if(r.content&&r.content.includes('```plan')){S.tfPanel='plan';R()}return}
+ if(r.status==='failed'){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'❌ '+r.content});R();tfScroll();return}
+ }catch(e){}
+ }
+ S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'⏰ Timeout.'});R()
+}
+
+async function tfSaveAndPlan(){
+ if(!S.tfCode){alert('Gere código Terraform primeiro.');return}
+ // Resolve OCI config
+ let ociId=S.tfOci;
+ if(!ociId&&S.tfModel.startsWith('cfg:')){const g=S.genaiCfg.find(x=>x.id===S.tfModel.slice(4));if(g)ociId=g.oci_config_id}
+ if(!ociId&&S.ociCfg.length)ociId=S.ociCfg[0].id;
+ if(!ociId){alert('Selecione uma credencial OCI.');return}
+ try{
+ // Create or update workspace
+ if(!S.tfWs){
+ const d=await $api('/terraform/workspaces',{method:'POST',body:{session_id:S.tfSid||'none',oci_config_id:ociId,compartment_id:S.tfCompartment||null,name:'workspace',tf_code:S.tfCode}});
+ S.tfWs=d.id;
+ }else{
+ await $api('/terraform/workspaces/'+S.tfWs+'/code',{method:'PUT',body:{tf_code:S.tfCode}});
+ }
+ // Run plan
+ await $api('/terraform/workspaces/'+S.tfWs+'/plan',{method:'POST'});
+ S.tfStatus='planning';S.tfRunning=true;S.tfPlanOut='';S.tfPanel='output';R();
+ await tfPollExec();
+ }catch(e){alert('Erro: '+e.message)}
+}
+
+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();
+ await tfPollExec();
+ }catch(e){alert('Erro: '+e.message)}
+}
+
+async function tfConfirmDestroy(){
+ const inp=document.getElementById('tfDestInput');
+ if(!inp||inp.value!=='DESTROY'){alert('Digite DESTROY para confirmar.');return}
+ S.tfConfirmDest=false;R();
+ 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();
+ await tfPollExec();
+ }catch(e){alert('Erro: '+e.message)}
+}
+
+async function tfCancel(){
+ if(!S.tfWs)return;
+ try{await $api('/terraform/workspaces/'+S.tfWs+'/cancel',{method:'POST'})}catch(e){}
+ S.tfRunning=false;S.tfStatus='failed';R();
+}
+
+async function tfPollExec(){
+ for(let i=0;i<600;i++){
+ await new Promise(r=>setTimeout(r,2000));
+ try{
+ const r=await $api('/terraform/workspaces/'+S.tfWs+'/status');
+ S.tfPlanOut=r.plan_output||S.tfPlanOut;
+ S.tfApplyOut=r.apply_output||S.tfApplyOut;
+ S.tfDestroyOut=r.destroy_output||S.tfDestroyOut;
+ S.tfStatus=r.status;
+ 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);
+ }catch(e){}
+ }
+ S.tfRunning=false;R();
+}
+
/* ── OCI Explorer ── */
+const EXP_GROUPS=[
+ {name:'Compute',tabs:[
+ {key:'instances',label:'Instances'},{key:'boot_volumes',label:'Boot Vol'},{key:'instance_pools',label:'Pools'}]},
+ {name:'Rede',tabs:[
+ {key:'vcns',label:'VCN'},{key:'subnets',label:'Subnets'},{key:'security_lists',label:'Sec Lists'},
+ {key:'nsgs',label:'NSGs'},{key:'route_tables',label:'Routes'},{key:'nat_gateways',label:'NAT GW'},
+ {key:'internet_gateways',label:'IGW'},{key:'service_gateways',label:'SGW'},{key:'public_ips',label:'IPs Públicos'},
+ {key:'load_balancers',label:'Load Balancer'}]},
+ {name:'Storage',tabs:[
+ {key:'buckets',label:'Buckets'},{key:'block_volumes',label:'Block Vol'},
+ {key:'file_systems',label:'File Storage'},{key:'mount_targets',label:'Mount Targets'}]},
+ {name:'Database',tabs:[
+ {key:'databases',label:'ADB'},{key:'db_systems',label:'DB Systems'},{key:'mysql_db_systems',label:'MySQL'}]},
+ {name:'Containers',tabs:[
+ {key:'container_instances',label:'Containers'},{key:'oke_clusters',label:'OKE'}]},
+ {name:'Serverless',tabs:[
+ {key:'functions',label:'Functions'},{key:'api_gateways',label:'API Gateway'}]},
+ {name:'Observabilidade',tabs:[
+ {key:'alarms',label:'Alarmes'},{key:'log_groups',label:'Log Groups'},
+ {key:'notification_topics',label:'Notificações'},{key:'events_rules',label:'Events'}]},
+ {name:'Segurança',tabs:[
+ {key:'vaults',label:'Vaults'},{key:'dns_zones',label:'DNS'},
+ {key:'network_firewalls',label:'Firewalls'},{key:'network_firewall_policies',label:'FW Policies'}]},
+ {name:'IAM',tabs:[
+ {key:'iam_users',label:'Usuários'},{key:'iam_groups',label:'Grupos'},
+ {key:'iam_policies',label:'Policies'},{key:'iam_dynamic_groups',label:'Dynamic Groups'}]}
+];
+const EXP_TABS=EXP_GROUPS.flatMap(g=>g.tabs);
function rExplorer(){
- const cfgSel=S.ociCfg.map(c=>`${c.tenancy_name} (${c.region}) `).join('');
if(!S.ociCfg.length)return`☁️
Nenhuma credencial OCI configurada.
Configure em Credenciais OCI .
`;
- return`🔍 Explorar Conta OCI
-
Conexão OCI ${cfgSel}
-
Recurso Compartments Regions Compute Instances VCNs Autonomous DBs Object Storage Buckets
-
🔍 Explorar
-Resultados
${S.expData?renderExpData(S.expData):'
Selecione uma conexão e recurso.
'}
`}
-async function doExplore(){const cid=document.getElementById('exCfg').value;const res=document.getElementById('exRes').value;
- document.getElementById('exOut').innerHTML=' Consultando OCI...
';
- try{const d=await $api('/oci/explore/'+cid+'/'+res);S.expData=d;
- document.getElementById('exTitle').textContent='Resultados — '+res;
- document.getElementById('exOut').innerHTML=renderExpData(d)}catch(e){document.getElementById('exOut').innerHTML=''+e.message+'
'}}
-function renderExpData(d){if(d.error)return`${d.error}
`;if(!Array.isArray(d)||!d.length)return'Nenhum resultado encontrado.
';
+ if(!S.expCfg&&S.ociCfg.length)S.expCfg=S.ociCfg[0].id;
+ const cfgOpts=S.ociCfg.map(c=>`${c.tenancy_name} (${c.region}) `).join('');
+ const regLabel=S.expSelRegions.length?S.expSelRegions.length+' região(ões)':'Todas as regiões';
+ const regChecks=S.expRegions.map(r=>` ${r.name}${r.is_home?' (Home) ':''} `).join('');
+ const catIcons={Compute:'💻',Rede:'🌐',Storage:'💾',Database:'🗄️',Containers:'📦',Serverless:'⚡',Observabilidade:'📊',Segurança:'🔒',IAM:'👤'};
+ const cats=EXP_GROUPS.map(g=>`${catIcons[g.name]||''} ${g.name}
`).join('');
+ const activeGrp=EXP_GROUPS.find(g=>g.name===S.expCat)||EXP_GROUPS[0];
+ const tabs=activeGrp.tabs.map(t=>{const cnt=S.expCounts[t.key];return`${t.label}${cnt!=null?' ('+cnt+') ':''}
`}).join('');
+ return`
+
📁 Compartments
${S.expTree.length?rExpTree(null,0):'
Carregando...
'}
+
+
+
+
+
${tabs}
+
${S.expLoading?'
Consultando OCI...
':renderExpData(S.expData)}
+
`}
+function rExpTree(parentId,depth){
+ const children=S.expTree.filter(n=>n.parent_id===parentId);
+ if(!children.length)return'';
+ return children.map(n=>{
+ const hasKids=S.expTree.some(c=>c.parent_id===n.id);
+ const isOpen=S.expTreeOpen[n.id];
+ const isSel=S.expSelComp===n.id;
+ return`
+
+${hasKids?(isOpen?'▼':'▶'):' '}
+${n.name}
+${hasKids&&isOpen?`
${rExpTree(n.id,depth+1)}
`:''}
`}).join('')}
+async function expSetCfg(cid){S.expCfg=cid;S.expTree=[];S.expSelComp='';S.expData=null;S.expSelRegions=[];S.expRegions=[];S.expCounts={};S.expRegDdOpen=false;R();await expLoadTree();await expLoadRegions()}
+function expToggleRegion(r){const i=S.expSelRegions.indexOf(r);if(i>=0)S.expSelRegions.splice(i,1);else S.expSelRegions.push(r);R()}
+function expRemoveRegion(r){S.expSelRegions=S.expSelRegions.filter(x=>x!==r);if(S.expSelComp)expLoadRes()}
+async function expApplyRegions(){if(S.expSelComp)await expLoadRes()}
+function expSetCat(c){S.expCat=c;const grp=EXP_GROUPS.find(g=>g.name===c);if(grp&&grp.tabs.length){S.expResType=grp.tabs[0].key;S.expData=null;R();if(S.expSelComp)expLoadRes()}else R()}
+function expSetTab(t){S.expResType=t;S.expData=null;R();if(S.expSelComp)expLoadRes()}
+function expToggleNode(id){S.expTreeOpen[id]=!S.expTreeOpen[id];R()}
+async function expSelectComp(id){S.expSelComp=id;S.expCounts={};await expLoadRes()}
+function expStartResize(e){e.preventDefault();const tree=document.getElementById('exp-tree');const handle=e.target;if(!tree)return;handle.classList.add('dragging');const startX=e.clientX;const startW=tree.offsetWidth;
+ const onMove=ev=>{const w=Math.max(140,Math.min(startW+(ev.clientX-startX),window.innerWidth*0.5));tree.style.width=w+'px'};
+ const onUp=()=>{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);document.body.style.cursor='';document.body.style.userSelect=''};
+ document.body.style.cursor='col-resize';document.body.style.userSelect='none';document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp)}
+async function expRefresh(){if(S.expCfg){S.expTree=[];S.expCounts={};R();await expLoadTree();if(S.expSelComp)await expLoadRes()}}
+async function expLoadTree(){
+ if(!S.expCfg)return;
+ try{const d=await $api('/oci/explore/'+S.expCfg+'/compartment-tree');
+ S.expTree=d.error?[]:(d||[]);
+ if(S.expTree.length&&!S.expSelComp){S.expSelComp=S.expTree[0].id;S.expTreeOpen[S.expTree[0].id]=true}
+ R();if(S.expSelComp)await expLoadRes()}catch(e){S.expTree=[];R()}}
+async function expLoadRegions(){
+ if(!S.expCfg)return;
+ try{const d=await $api('/oci/explore/'+S.expCfg+'/regions');S.expRegions=d.error?[]:(d||[]);R()}catch(e){S.expRegions=[];R()}}
+async function expLoadRes(){
+ if(!S.expCfg||!S.expSelComp)return;
+ S.expLoading=true;S._expExpanded={};R();
+ const regions=S.expSelRegions.length?S.expSelRegions:[null];
+ let all=[];let errors=[];
+ const BATCH=3;const DELAY=800;
+ for(let i=0;i{
+ const q=new URLSearchParams({compartment_id:S.expSelComp});
+ if(reg)q.set('region',reg);
+ try{const d=await $api('/oci/explore/'+S.expCfg+'/'+S.expResType+'?'+q);
+ if(d.error){errors.push(reg+': '+d.error)}
+ else if(Array.isArray(d)){d.forEach(item=>{if(reg&&!item.region)item._region=reg});all=all.concat(d)}}
+ catch(e){errors.push((reg||'default')+': '+e.message)}});
+ await Promise.all(fetches);
+ if(i+BATCHsetTimeout(r,DELAY))}
+ if(all.length)S.expData=all;
+ else if(errors.length)S.expData={error:errors.join('\n')};
+ else S.expData=[];
+ S.expCounts[S.expResType]=all.length;
+ S.expLoading=false;R()}
+function renderExpData(d){if(!d)return'Selecione um compartment na árvore.
';
+ if(d.error)return`${d.error}
`;if(!Array.isArray(d)||!d.length)return'Nenhum recurso encontrado neste compartment.
';
+ if(S.expResType==='security_lists')return rExpSecLists(d);
+ if(S.expResType==='route_tables')return rExpRouteTables(d);
return`${d.length} item(ns) encontrado(s)
${d.map(i=>{
- const keys=Object.keys(i).filter(k=>k!=='id');
- const name=i.display_name||i.name||i.db_name||'—';
+ const keys=Object.keys(i);
+ const name=i.display_name||i.name||i.db_name||i.application||'—';
+ const skip=['display_name','name','db_name','lifecycle_state','application','rules','routes'];
return`
${name} ${i.lifecycle_state?` ${i.lifecycle_state} `:''}
-${keys.filter(k=>!['display_name','name','db_name','lifecycle_state'].includes(k)).map(k=>`${k}: ${typeof i[k]==='object'?JSON.stringify(i[k]):i[k]} `).join(' ')}
`}).join('')}
`}
+${keys.filter(k=>!skip.includes(k)).map(k=>`${k}: ${typeof i[k]==='object'?JSON.stringify(i[k]):i[k]} `).join(' ')}`}).join('')}`}
+function rExpSecLists(d){
+ return`${d.length} security list(s)
${d.map((sl,idx)=>{
+ const rules=sl.rules||[];
+ const ingress=rules.filter(r=>r.direction==='ingress');
+ const egress=rules.filter(r=>r.direction==='egress');
+ const expanded=S._expExpanded&&S._expExpanded[idx];
+ return`
+
${sl.display_name} ${sl.lifecycle_state}
+
${expanded?'▼':'▶'} ${ingress.length} ingress · ${egress.length} egress
+
id: ${sl.id}
+${expanded?`
${ingress.length?`
↓ Ingress (${ingress.length})
+
Protocolo Origem Portas Stateless Descrição
+${ingress.map(r=>`${r.protocol} ${r.source_dest} ${r.ports||'ALL'} ${r.stateless?'Sim':'Não'} ${r.description||'—'} `).join('')}
+
`:''}
+${egress.length?`
↑ Egress (${egress.length})
+
Protocolo Destino Portas Stateless Descrição
+${egress.map(r=>`${r.protocol} ${r.source_dest} ${r.ports||'ALL'} ${r.stateless?'Sim':'Não'} ${r.description||'—'} `).join('')}
+
`:''}
`:''}
`}).join('')}`}
+function rExpRouteTables(d){
+ return`${d.length} route table(s)
${d.map((rt,idx)=>{
+ const routes=rt.routes||[];
+ const expanded=S._expExpanded&&S._expExpanded['rt'+idx];
+ return`
+
${rt.display_name} ${rt.lifecycle_state}
+
${expanded?'▼':'▶'} ${routes.length} rota(s)
+
id: ${rt.id}
+${expanded&&routes.length?`
+
Destino Tipo Target Descrição
+${routes.map(r=>`${r.destination} ${r.destination_type} ${r.target_type}${r.target_id} ${r.description||'—'} `).join('')}
+
`:expanded?'
Nenhuma rota configurada
':''}
`}).join('')}`}
/* ── Report ── */
function rReport(){const comp=S.reports.filter(r=>r.status==='completed');