feat: Terraform Agent, expanded OCI Explorer (40+ resources), and MCP improvements

Add Terraform Agent tab with AI-powered IaC generation (plan/apply/destroy lifecycle,
workspace management, compartment selection, Terraform CLI v1.7.5 in container).
Expand OCI Explorer from 6 to 40+ resource types across 8 categories with tree-view
navigation and resizable panels. Add compartment filtering and error tracking to MCP
CIS server, increase tool timeout to 30min with auto-retry. Add chat sidebar, chat
audit logs, and tune memory compaction (6K threshold, 20 recent messages). Trim model
catalog from 69 to 15 curated models. Update README for v2.1.
This commit is contained in:
nogueiraguh
2026-03-07 12:39:20 -03:00
parent 3e13cf4677
commit c7c4ca1f1b
5 changed files with 2032 additions and 197 deletions

175
README.md
View File

@@ -9,7 +9,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-2.0-C74634?style=flat-square" alt="Version">
<img src="https://img.shields.io/badge/version-2.1-C74634?style=flat-square" alt="Version">
<img src="https://img.shields.io/badge/python-3.12-3776AB?style=flat-square" alt="Python">
<img src="https://img.shields.io/badge/FastAPI-0.115-009688?style=flat-square" alt="FastAPI">
<img src="https://img.shields.io/badge/OCI-GenAI-C74634?style=flat-square" alt="OCI">
@@ -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 <group-name> to read buckets in compartment <compartment-name>
```
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 |

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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:
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:
return [TextContent(type="text", text=json.dumps({"error": f"Tool '{name}' timed out after {TOOL_TIMEOUT}s"}))]
last_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 = 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):

View File

@@ -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}
}
</style>
</head>
<body>
@@ -216,13 +309,16 @@ const V='1.1';
const LOGO_W=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="26" height="26" style="flex-shrink:0"><rect x="2" y="5" width="32" height="26" rx="13" ry="13" fill="rgba(255,255,255,0.12)" stroke="#fff" stroke-width="2"/><rect x="11" y="11" width="14" height="14" rx="4" fill="#fff" opacity="0.95"/><circle cx="15" cy="17" r="2" fill="#C74634"/><circle cx="21" cy="17" r="2" fill="#C74634"/><circle cx="15" cy="16.7" r="0.7" fill="#fff"/><circle cx="21" cy="16.7" r="0.7" fill="#fff"/><rect x="14" y="21" width="8" height="1.5" rx="0.75" fill="#C74634" opacity="0.6"/><line x1="18" y1="11" x2="18" y2="6" stroke="#fff" stroke-width="1.2" stroke-linecap="round"/><circle cx="18" cy="5.5" r="1.5" fill="#fff" opacity="0.9"/><line x1="11" y1="18" x2="6" y2="18" stroke="#fff" stroke-width="1" stroke-linecap="round" opacity="0.7"/><line x1="25" y1="18" x2="30" y2="18" stroke="#fff" stroke-width="1" stroke-linecap="round" opacity="0.7"/><circle cx="5.5" cy="18" r="1" fill="#fff" opacity="0.7"/><circle cx="30.5" cy="18" r="1" fill="#fff" opacity="0.7"/></svg>`;
const LOGO_R=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="52" height="52"><rect x="2" y="5" width="32" height="26" rx="13" ry="13" fill="rgba(199,70,52,0.08)" stroke="#C74634" stroke-width="1.8"/><rect x="11" y="11" width="14" height="14" rx="4" fill="#fff" stroke="#C74634" stroke-width="0.4"/><circle cx="15" cy="17" r="2" fill="#C74634"/><circle cx="21" cy="17" r="2" fill="#C74634"/><circle cx="15" cy="16.7" r="0.7" fill="#fff"/><circle cx="21" cy="16.7" r="0.7" fill="#fff"/><rect x="14" y="21" width="8" height="1.5" rx="0.75" fill="#C74634" opacity="0.6"/><line x1="18" y1="11" x2="18" y2="6" stroke="#C74634" stroke-width="1.2" stroke-linecap="round"/><circle cx="18" cy="5.5" r="1.5" fill="#C74634" opacity="0.25" stroke="#C74634" stroke-width="0.7"/><line x1="11" y1="18" x2="6" y2="18" stroke="#C74634" stroke-width="1" stroke-linecap="round" opacity="0.4"/><line x1="25" y1="18" x2="30" y2="18" stroke="#C74634" stroke-width="1" stroke-linecap="round" opacity="0.4"/><circle cx="5.5" cy="18" r="1" fill="#C74634" opacity="0.35"/><circle cx="30.5" cy="18" r="1" fill="#C74634" opacity="0.35"/></svg>`;
const S={user:null,token:null,tab:'chat',msgs:[],sid:null,reports:[],ociCfg:[],genaiCfg:[],adbCfg:[],mcpSvr:[],users:[],models:{},regions:[],embModels:{},expData:null,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`<div class="lp"><div class="lc fi">
/* ── App Shell ── */
function rApp(){return`<div class="app">${rSb()}<div class="mc">${rTb()}<div class="pc fi" id="pg">${rPg()}</div></div></div>`}
const TF_IC='<svg viewBox="0 0 16 16" width="14" height="14" style="vertical-align:-2px;margin-right:2px"><path d="M5.6 1v4.2l3.6 2.1V3.1L5.6 1zm4.4 2.1v4.2l3.6-2.1V1L10 3.1zM1.4 3.5v4.2l3.6 2.1V5.6L1.4 3.5zM5.6 8.1v4.2L9.2 14.4V10.2L5.6 8.1z" fill="#7b42bc"/></svg>';
function rSb(){
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=>`<div class="ni ${S.tab===t[0]?'on':''}" onclick="switchTab('${t[
<div class="sb-f"><div class="ui"><div class="ua">${i}</div><div><div class="un">${S.user?.first_name?S.user.first_name+' '+S.user.last_name:S.user?.username}</div><div class="ur">${S.user?.role}</div></div>
<div class="lo-btn" onclick="doLogout()" title="Sair">⏻</div></div></div></div>`}
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`<div class="tb"><div class="tb-t">${t[S.tab]||''}</div><div class="tb-a"><span class="tag">v${V}</span></div></div>`}
function rPg(){switch(S.tab){case'chat':return rChat();case'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?'<div class="emp"><div class="eic">🤖</div><p>Inicie uma conversa com o agente.</p><p style="font-size:.74rem;margin-top:.35rem">Selecione um modelo para começar.</p></div>'
:S.msgs.map(m=>`<div class="cm cm-${m.r}"><div class="cb">${fm(m.c)}</div>${m.t?`<div class="cm-ts">${m.t}</div>`:''}</div>`).join('');
// 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+=`<div class="grp">Configs Salvas</div>`;S.genaiCfg.forEach(g=>{const mi=S.models[g.model_id];ddItems+=`<div class="itm${S.chatModel==='cfg:'+g.id?' sel':''}" onclick="pickModel('cfg:${g.id}')">${g.name||mi?.name||g.model_id} (${g.genai_region})</div>`})}
provOrder.filter(p=>provs[p]).forEach(p=>{ddItems+=`<div class="grp">${p.charAt(0).toUpperCase()+p.slice(1)}</div>`;provs[p].forEach(m=>{ddItems+=`<div class="itm${S.chatModel===m.id?' sel':''}" onclick="pickModel('${m.id}')">${m.name}</div>`})});
// 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?`<select id="coci" onchange="chatOciChanged()" style="max-width:200px"><option value="">Credencial OCI...</option>${S.ociCfg.map(c=>`<option value="${c.id}" ${S.chatOci===c.id?'selected':''}>${c.tenancy_name} (${c.region})</option>`).join('')}</select>`:'';
const gearBtn=hasModel?`<div class="gear-btn" onclick="toggleChatParams()" title="Parâmetros">${S.chatParamsOpen?'✕':'⚙️'}</div>`:'';
const ragBadge=S.adbCfg.some(c=>c.genai_config_id&&c.is_active)?'<span class="tag" style="background:var(--gnl);color:var(--gn);font-size:.62rem">RAG</span>':'';
// Params panel - available for any model (direct or preset)
const pp=S.chatParams;
const paramsPanel=hasModel&&S.chatParamsOpen?`<div class="ch-params"><div class="g3">
<div><label>Temperature</label><input type="number" value="${pp.temperature}" step="0.1" min="0" max="2" onchange="S.chatParams.temperature=+this.value"></div>
<div><label>Max Tokens</label><input type="number" value="${pp.max_tokens}" min="1" max="128000" onchange="S.chatParams.max_tokens=+this.value"></div>
<div><label>Top P</label><input type="number" value="${pp.top_p}" step="0.05" min="0" max="1" onchange="S.chatParams.top_p=+this.value"></div>
</div><div class="g3">
<div><label>Top K</label><input type="number" value="${pp.top_k}" min="-1" max="500" onchange="S.chatParams.top_k=+this.value"></div>
<div><label>Freq Penalty</label><input type="number" value="${pp.frequency_penalty}" step="0.1" min="0" max="2" onchange="S.chatParams.frequency_penalty=+this.value"></div>
<div><label>Pres Penalty</label><input type="number" value="${pp.presence_penalty}" step="0.1" min="0" max="2" onchange="S.chatParams.presence_penalty=+this.value"></div>
</div><div style="margin-top:.5rem;display:flex;align-items:center;gap:.5rem;padding:.3rem 0">
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.76rem"><input type="checkbox" ${S.chatUseTools?'checked':''} onchange="S.chatUseTools=this.checked"> 🔌 Usar MCP Tools no Chat</label>
${S.mcpSvr.filter(m=>m.is_active&&Array.isArray(m.tools)&&m.tools.length).length?`<span style="font-size:.66rem;color:var(--t4)">(${S.mcpSvr.filter(m=>m.is_active&&Array.isArray(m.tools)&&m.tools.length).reduce((a,m)=>a+m.tools.length,0)} tools disponíveis)</span>`:''}</div></div>`:'';
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`<div class="ch-c">
<div class="ch-side${S.chatPanel?' open':''}"><div class="ch-side-inner">${sideContent}</div></div>
<div class="ch-main">
<div class="ch-t"><label>🧠 Modelo:</label><div class="mdrop"><div class="mdrop-btn" onclick="toggleModelDrop()">${curLabel} <span class="arr">▼</span></div><div class="mdrop-dd" id="mdd"><input type="text" id="mds" placeholder="Buscar modelo..." oninput="filterModels(this.value)"><div class="mdrop-list" id="mdl">${ddItems}</div></div></div>
${ociSel}${gearBtn}${ragBadge}<div style="flex:1"></div><button class="btn bd bsm" onclick="clrChat()">Limpar</button></div>
${paramsPanel}<div class="ch-m" id="chm">${ms}</div>
${S.chatFiles.length?`<div style="display:flex;gap:.4rem;flex-wrap:wrap;padding:.4rem .85rem;background:var(--bg1);border:1px solid var(--bd);border-bottom:none">${S.chatFiles.map((f,i)=>f.type==='image'?`<div style="position:relative;width:48px;height:48px"><img src="${f.preview}" style="width:48px;height:48px;object-fit:cover;border-radius:6px;border:1px solid var(--bd)"><span onclick="rmChatFile(${i})" style="position:absolute;top:-4px;right:-4px;background:var(--err);color:#fff;width:16px;height:16px;border-radius:50%;font-size:.6rem;display:flex;align-items:center;justify-content:center;cursor:pointer">&times;</span></div>`:`<div style="position:relative;display:flex;align-items:center;gap:.25rem;background:var(--bg2);border:1px solid var(--bd);border-radius:6px;padding:.25rem .5rem;font-size:.68rem"><span>📄 ${f.name}</span><span onclick="rmChatFile(${i})" style="color:var(--err);cursor:pointer;font-weight:700">&times;</span></div>`).join('')}</div>`:''}
${ociSel}${ragBadge}<div style="flex:1"></div>
<div class="ch-icon-btn${S.chatPanel==='config'?' active':''}" onclick="togglePanel('config')" title="Configurações">⚙️</div>
<div class="ch-icon-btn${S.chatPanel==='logs'?' active':''}" onclick="togglePanel('logs')" title="Chat Logs">📋</div>
<div class="ch-icon-btn" onclick="clrChat()" title="Limpar conversa">🗑️</div></div>
<div class="ch-m" id="chm">${ms}</div>
${S.chatFiles.length?`<div style="display:flex;gap:.4rem;flex-wrap:wrap;padding:.4rem .85rem;background:var(--bg1);border-top:1px solid var(--bd)">${S.chatFiles.map((f,i)=>f.type==='image'?`<div style="position:relative;width:48px;height:48px"><img src="${f.preview}" style="width:48px;height:48px;object-fit:cover;border-radius:6px;border:1px solid var(--bd)"><span onclick="rmChatFile(${i})" style="position:absolute;top:-4px;right:-4px;background:var(--err);color:#fff;width:16px;height:16px;border-radius:50%;font-size:.6rem;display:flex;align-items:center;justify-content:center;cursor:pointer">&times;</span></div>`:`<div style="position:relative;display:flex;align-items:center;gap:.25rem;background:var(--bg2);border:1px solid var(--bd);border-radius:6px;padding:.25rem .5rem;font-size:.68rem"><span>📄 ${f.name}</span><span onclick="rmChatFile(${i})" style="color:var(--err);cursor:pointer;font-weight:700">&times;</span></div>`).join('')}</div>`:''}
<div class="ch-i"><input type="file" id="chf" multiple accept="image/*,.pdf,.txt,.csv,.json,.log,.xml,.yaml,.yml,.md,.py,.js,.html,.css,.sql" style="display:none" onchange="addChatFiles(this)">
<button class="btn bs" onclick="document.getElementById('chf').click()" title="Anexar arquivo" style="font-size:1rem;padding:.3rem .5rem">📎</button>
<input type="text" id="chi" placeholder="Digite sua mensagem..." onkeydown="if(event.key==='Enter')sChat()">
<button class="btn bp" onclick="sChat()">Enviar →</button></div></div>`}
<button class="btn bp" onclick="sChat()">Enviar →</button></div></div></div>`}
function rChatConfig(isDirect,toolCount){
const pp=S.chatParams;
return`<div class="ch-side-title">⚙️ Configurações</div>
<div class="ch-side-section"><div class="g2">
<div><label>Temperature</label><input type="number" value="${pp.temperature}" step="0.1" min="0" max="2" onchange="S.chatParams.temperature=+this.value"></div>
<div><label>Max Tokens</label><input type="number" value="${pp.max_tokens}" min="1" max="128000" onchange="S.chatParams.max_tokens=+this.value"></div>
<div><label>Top P</label><input type="number" value="${pp.top_p}" step="0.05" min="0" max="1" onchange="S.chatParams.top_p=+this.value"></div>
<div><label>Top K</label><input type="number" value="${pp.top_k}" min="-1" max="500" onchange="S.chatParams.top_k=+this.value"></div>
<div><label>Freq Penalty</label><input type="number" value="${pp.frequency_penalty}" step="0.1" min="0" max="2" onchange="S.chatParams.frequency_penalty=+this.value"></div>
<div><label>Pres Penalty</label><input type="number" value="${pp.presence_penalty}" step="0.1" min="0" max="2" onchange="S.chatParams.presence_penalty=+this.value"></div>
</div></div>
<div class="ch-side-section" style="border-top:1px solid var(--bd);padding-top:.65rem">
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.76rem"><input type="checkbox" ${S.chatUseTools?'checked':''} onchange="S.chatUseTools=this.checked"> 🔌 MCP Tools</label>
${toolCount?`<span style="font-size:.66rem;color:var(--t4);margin-top:.2rem;display:block">${toolCount} tools disponíveis</span>`:''}
</div>
${isDirect?`<div class="ch-side-section" style="border-top:1px solid var(--bd);padding-top:.65rem">
<label>Credencial OCI</label>
<select onchange="S.chatOci=this.value;const c=S.ociCfg.find(x=>x.id===this.value);if(c){S.chatRegion=c.region;S.chatCompartment=c.compartment_id||''}" style="margin-top:3px">
<option value="">Selecionar...</option>${S.ociCfg.map(c=>`<option value="${c.id}" ${S.chatOci===c.id?'selected':''}>${c.tenancy_name} (${c.region})</option>`).join('')}</select></div>`:''}`}
function rChatLogs(){
return`<div class="ch-side-title">📋 Chat Logs</div>
<div style="display:flex;gap:.4rem;margin-bottom:.5rem">
<select id="cl-sev-chat" onchange="loadChatLogs()" style="flex:1;font-size:.7rem;padding:.25rem .4rem;border-radius:6px;border:1px solid var(--bd)">
<option value="">Todos</option><option value="error">Erros</option><option value="warning">Avisos</option><option value="info">Info</option></select>
<button class="btn bs bsm" onclick="loadChatLogs()" style="font-size:.68rem">↻</button></div>
<div id="chat-logs-body"><div style="padding:.5rem;font-size:.74rem;color:var(--t4)">Carregando...</div></div>`}
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='<div class="emp" style="padding:.5rem"><p>Nenhum log registrado.</p></div>';return}
el.innerHTML='<table style="font-size:.74rem"><thead><tr><th>Data</th><th>Fonte</th><th>Ação</th><th>Status</th><th>Mensagem</th></tr></thead><tbody>'+
logs.map(l=>{const sc=l.severity==='error'?'err':l.severity==='warning'?'w':'ok';
return'<tr><td style="font-size:.62rem;color:var(--t4);white-space:nowrap">'+l.created_at+'</td>'+
'<td><span class="tag">'+l.source+'</span></td>'+
'<td style="font-size:.72rem">'+l.action+'</td>'+
'<td><span style="color:var(--'+sc+');font-weight:600;font-size:.7rem">'+l.severity+'</span></td>'+
'<td style="font-size:.72rem;max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="'+(l.message||'').replace(/"/g,'&quot;')+'">'+(l.message?.slice(0,150)||'—')+'</td></tr>'}).join('')+
'</tbody></table>'}
catch(e){el.innerHTML='<div class="al al-e">'+e.message+'</div>'}}
/* ── Terraform Agent ── */
const TF_ICON=`<svg viewBox="0 0 16 16" width="16" height="16"><path d="M5.6 1v4.2l3.6 2.1V3.1L5.6 1zm4.4 2.1v4.2l3.6-2.1V1L10 3.1zM1.4 3.5v4.2l3.6 2.1V5.6L1.4 3.5zM5.6 8.1v4.2L9.2 14.4V10.2L5.6 8.1z" fill="#7b42bc"/></svg>`;
function escHtml(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
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 '<div class="tf-code-block"><div class="tf-code-header"><span>'+TF_ICON+' main.tf</span>'+
'<button class="btn" onclick="copyTfCode()">Copy</button></div>'+
'<pre class="tf-pre"><code>'+escHtml(trimmed)+'</code></pre></div>';
});
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 '<div class="tf-plan-block"><div class="tf-plan-header">'+TF_ICON+' Resource Plan — '+S.tfPlan.length+' resource(s)</div><div class="tf-plan-list">'+
S.tfPlan.map(x=>'<div class="tf-plan-item"><span class="tf-plan-add">+</span><span class="tf-plan-type">'+escHtml(x.type)+'</span><span class="tf-plan-desc">'+escHtml(x.desc)+'</span></div>').join('')+'</div></div>';
});
// Update files list
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,'<strong>$1</strong>').replace(/`(.*?)`/g,'<code>$1</code>').replace(/\n/g,'<br>');
return r;
}
function rTerraform(){
const ms=S.tfMsgs.length===0
?`<div class="tf-empty">${TF_ICON}<p>Descreva a infraestrutura OCI desejada</p><p style="font-size:.68rem;opacity:.6">Ex: "Crie uma VCN com subnets pública e privada, internet gateway e um compute instance"</p></div>`
:S.tfMsgs.map(m=>`<div class="cm cm-${m.r}"><div class="cb">${m.r==='assistant'?fmTf(m.c):fm(m.c)}</div></div>`).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+='<div class="grp">Configs Salvas</div>';S.genaiCfg.forEach(g=>{const mi=S.models[g.model_id];ddItems+=`<div class="itm${S.tfModel==='cfg:'+g.id?' sel':''}" onclick="tfPickModel('cfg:${g.id}')">${g.name||mi?.name||g.model_id}</div>`})}
provOrder.filter(p=>provs[p]).forEach(p=>{ddItems+=`<div class="grp">${p[0].toUpperCase()+p.slice(1)}</div>`;provs[p].forEach(m=>{ddItems+=`<div class="itm${S.tfModel===m.id?' sel':''}" onclick="tfPickModel('${m.id}')">${m.name}</div>`})});
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?`<select id="tfoci" onchange="S.tfOci=this.value;const c=S.ociCfg.find(x=>x.id===this.value);if(c){S.tfRegion=c.region;S.tfCompartment=c.compartment_id||''}tfLoadComps()" style="max-width:180px"><option value="">OCI Config...</option>${S.ociCfg.map(c=>`<option value="${c.id}"${S.tfOci===c.id?' selected':''}>${c.tenancy_name} (${c.region})</option>`).join('')}</select>`:'';
const compSel=(S.tfComps.length||S.tfCompLoading)?`<select onchange="S.tfCompartment=this.value;tfLoadResources()" style="max-width:220px">${S.tfCompLoading?'<option>Carregando...</option>':'<option value="">Compartment...</option>'+tfFlatComps(S.tfComps,0).map(c=>`<option value="${c.id}"${S.tfCompartment===c.id?' selected':''}>${c.name}</option>`).join('')}</select>`:'';
// Status badge
const stBadge=_tfBadge(S.tfStatus);
return `<div class="tf-wrap">
<div class="tf-chat">
<div class="tf-toolbar">
${TF_ICON}
<div class="mdrop"><div class="mdrop-btn" onclick="S.tfMdOpen=!S.tfMdOpen;R()">${curLabel} <span class="arr">▼</span></div>
${S.tfMdOpen?`<div class="mdrop-dd open"><input type="text" id="tfmds" placeholder="Buscar..." oninput="tfFilterModels(this.value)"><div class="mdrop-list" id="tfmdl">${ddItems}</div></div>`:''}
</div>
${ociSel}${compSel}
${stBadge}
<div style="flex:1"></div>
${rTfActions()}
<div class="tf-file-btn" onclick="tfClear()" title="Nova conversa" style="width:24px;height:24px"><svg viewBox="0 0 16 16" width="12" height="12" fill="var(--t3)"><path d="M2 3h12v1H2zm1.5-1h9l-.5-1h-8zM3 4v9.5a1.5 1.5 0 001.5 1.5h7a1.5 1.5 0 001.5-1.5V4z"/></svg></div>
</div>
<div class="tf-msgs" id="tfchm">${ms}</div>
<div class="tf-input">
<input type="text" id="tfi" placeholder="Descreva a infraestrutura desejada..." onkeydown="if(event.key==='Enter')tfSend()">
<button class="btn bp" onclick="tfSend()" style="background:#7b42bc;border-color:#7b42bc">Gerar</button>
</div>
</div>
<div class="tf-resize" onmousedown="tfStartResize(event)"></div>
<div class="tf-bottom" id="tfBottom">
<div class="tf-bottom-tabs">
<div class="tf-btab${S.tfBtab==='files'?' active':''}" onclick="S.tfBtab='files';R()">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M1 2.5A1.5 1.5 0 012.5 1h3.379a1.5 1.5 0 011.06.44l1.122 1.12A1.5 1.5 0 009.121 3H13.5A1.5 1.5 0 0115 4.5v8a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12.5z"/></svg>
Files${S.tfFiles.length?' ('+S.tfFiles.length+')':''}
</div>
<div class="tf-btab${S.tfBtab==='plan'?' active':''}" onclick="S.tfBtab='plan';R()">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M2 2h12v2H2zm0 4h8v2H2zm0 4h10v2H2z"/></svg>
Plan${S.tfPlan.length?' ('+S.tfPlan.length+')':''}
</div>
<div class="tf-btab${S.tfBtab==='resources'?' active':''}" onclick="S.tfBtab='resources';if(!S.tfResources&&S.tfCompartment)tfLoadResources();R()">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 1L1 5l7 4 7-4zm0 6L1 11l7 4 7-4z"/></svg>
Resources${S.tfResources?(' ('+Object.values(S.tfResources).reduce((a,b)=>a+(Array.isArray(b)?b.length:0),0)+')'):''}
</div>
<div class="tf-btab${S.tfBtab==='output'?' active':''}" onclick="S.tfBtab='output';R()">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M2 1h12a1 1 0 011 1v12a1 1 0 01-1 1H2a1 1 0 01-1-1V2a1 1 0 011-1zm1 3v9h10V4zm2 1l3 2.5L5 10z"/></svg>
Output
</div>
<div class="tf-bottom-actions">
${S.tfFiles.length?'<button class="btn bs bsm" onclick="tfDlAll()" style="font-size:.66rem;padding:2px 8px">Download All</button>':''}
</div>
</div>
<div class="tf-bottom-content">${rTfBottomContent()}</div>
</div>
${S.tfConfirmDest?rTfConfirmDestroy():''}
</div>`;
}
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`<span class="tf-badge tf-badge-${cls}">${label}</span>`;
}
function rTfActions(){
if(S.tfRunning)return'<button class="btn bsm" style="background:var(--rd);color:#fff;border-color:var(--rd);font-size:.68rem;padding:2px 10px" onclick="tfCancel()">Cancel</button>';
const st=S.tfStatus;let btns='';
if(S.tfCode&&(st==='draft'||st==='failed'))btns+=`<button class="btn bsm" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.68rem;padding:2px 10px" onclick="tfSaveAndPlan()">${TF_ICON} Plan</button>`;
if(st==='planned'||st==='failed')btns+=`<button class="btn bsm" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.68rem;padding:2px 10px" onclick="tfSaveAndPlan()">${TF_ICON} Re-plan</button>`;
if(st==='planned')btns+='<button class="btn bsm" style="background:var(--gn);color:#fff;border-color:var(--gn);font-size:.68rem;padding:2px 10px" onclick="tfApply()">Apply</button>';
if(st==='applied')btns+=`<button class="btn bsm" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.68rem;padding:2px 10px" onclick="tfSaveAndPlan()">${TF_ICON} Re-plan</button>`;
if(st==='applied'||st==='planned')btns+='<button class="btn bsm" style="background:var(--rd);color:#fff;border-color:var(--rd);font-size:.68rem;padding:2px 10px" onclick="S.tfConfirmDest=true;R()">Destroy</button>';
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`<div class="tf-empty">${TF_ICON}<p>Nenhum arquivo gerado ainda</p><p style="font-size:.66rem;opacity:.5">Descreva a infraestrutura desejada no chat acima</p></div>`;
// Auto-add provider.tf and terraform.tfvars as virtual files
const files=[...S.tfFiles];
if(S.tfWs||S.tfOci){
const oc=S.ociCfg.find(x=>x.id===S.tfOci);
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`<div class="tf-file">
<div class="tf-file-icon"><svg viewBox="0 0 16 16" fill="${iconFill}"><path d="M4 1a1 1 0 00-1 1v12a1 1 0 001 1h8a1 1 0 001-1V4.5L9.5 1H4zm5 0v3.5h3.5"/></svg></div>
<div class="tf-file-info"><div class="tf-file-name">${f.name}</div><div class="tf-file-meta">${sizeStr}${f.auto?' · auto-generated':''}</div></div>
<div class="tf-file-actions">
<div class="tf-file-btn" onclick="tfCopyFile(${i})" title="Copiar"><svg viewBox="0 0 16 16" width="12" height="12" fill="var(--t3)"><path d="M4 2a2 2 0 012-2h4.586L14 3.414V10a2 2 0 01-2 2H6a2 2 0 01-2-2zm6-1.414L13.414 4H10zM2 6a2 2 0 00-2 2v6a2 2 0 002 2h4a2 2 0 002-2v-1H6a3 3 0 01-3-3V6z"/></svg></div>
<div class="tf-file-btn" onclick="tfDlFile(${i})" title="Download"><svg viewBox="0 0 16 16" width="12" height="12" fill="var(--t3)"><path d="M8 1v9m0 0L5 7m3 3l3-3M2 12v1.5A1.5 1.5 0 003.5 15h9a1.5 1.5 0 001.5-1.5V12" stroke="var(--t3)" stroke-width="1.5" fill="none" stroke-linecap="round"/></svg></div>
</div>
</div>`}).join('');
}
function rTfPlanPanel(){
if(!S.tfPlan.length)return`<div class="tf-empty">${TF_ICON}<p>Nenhum recurso no plano</p></div>`;
return S.tfPlan.map(r=>`<div style="display:flex;align-items:center;gap:.5rem;padding:.4rem .5rem;border-radius:8px;background:var(--gnl);margin-bottom:.3rem">
<span style="color:var(--gn);font-weight:700;font-size:.82rem;font-family:var(--fm)">+</span>
<div><div style="font-size:.74rem;font-weight:600;font-family:var(--fm);color:var(--t1)">${escHtml(r.type)}</div>
<div style="font-size:.64rem;color:var(--t3)">${escHtml(r.desc)}</div></div></div>`).join('');
}
function rTfOutputPanel(){
let out='';
if(S.tfPlanOut)out+=`<div style="font-size:.7rem;font-weight:600;color:#7b42bc;margin-bottom:.3rem">${TF_ICON} Plan Output</div><div class="tf-output">${escHtml(S.tfPlanOut)}</div>`;
if(S.tfApplyOut)out+=`<div style="font-size:.7rem;font-weight:600;color:var(--gn);margin-top:.5rem;margin-bottom:.3rem">Apply Output</div><div class="tf-output">${escHtml(S.tfApplyOut)}</div>`;
if(S.tfDestroyOut)out+=`<div style="font-size:.7rem;font-weight:600;color:var(--rd);margin-top:.5rem;margin-bottom:.3rem">Destroy Output</div><div class="tf-output">${escHtml(S.tfDestroyOut)}</div>`;
if(!out)return`<div class="tf-empty"><svg viewBox="0 0 16 16" width="36" height="36" fill="#7b42bc" opacity=".2"><path d="M2 1h12a1 1 0 011 1v12a1 1 0 01-1 1H2a1 1 0 01-1-1V2a1 1 0 011-1zm1 3v9h10V4zm2 1l3 2.5L5 10z"/></svg><p>Nenhuma execução ainda</p></div>`;
return out;
}
function rTfConfirmDestroy(){
return`<div class="tf-modal-overlay" onclick="S.tfConfirmDest=false;R()"><div class="tf-modal" onclick="event.stopPropagation()">
<h3 style="display:flex;align-items:center;gap:.4rem"><svg viewBox="0 0 16 16" width="18" height="18" fill="var(--rd)"><path d="M8 1a7 7 0 100 14A7 7 0 008 1zm0 2.5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 018 3.5zM8 10a1 1 0 100 2 1 1 0 000-2z"/></svg> Confirmar Destroy</h3>
<p style="font-size:.8rem;margin:.5rem 0">Isso irá <strong>destruir todos os recursos</strong> provisionados. Esta ação é irreversível.</p>
<p style="font-size:.74rem;color:var(--t3);margin-bottom:.8rem">Digite <strong>DESTROY</strong> para confirmar:</p>
<input type="text" id="tfDestInput" placeholder="DESTROY" style="margin-bottom:.6rem">
<div style="display:flex;gap:.5rem;justify-content:flex-end">
<button class="btn bs" onclick="S.tfConfirmDest=false;R()">Cancelar</button>
<button class="btn bd" onclick="tfConfirmDestroy()">Destruir</button>
</div>
</div></div>`;
}
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?'&region='+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`<div class="tf-empty"><div class="spinner"></div><p>Carregando recursos...</p></div>`;
if(!S.tfCompartment)return`<div class="tf-empty"><svg viewBox="0 0 16 16" width="36" height="36" fill="#7b42bc" opacity=".2"><path d="M8 1L1 5l7 4 7-4zm0 6L1 11l7 4 7-4z"/></svg><p>Selecione um compartment para ver os recursos existentes</p></div>`;
if(!S.tfResources)return`<div class="tf-empty"><svg viewBox="0 0 16 16" width="36" height="36" fill="#7b42bc" opacity=".2"><path d="M8 1L1 5l7 4 7-4zm0 6L1 11l7 4 7-4z"/></svg><p>Nenhum recurso carregado</p><button class="btn bs bsm" onclick="tfLoadResources()" style="margin-top:.5rem">Carregar Recursos</button></div>`;
const res=S.tfResources;
const cats=[
{key:'vcns',label:'VCNs',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M1 3h14v2H1zm2 4h10v2H3zm3 4h4v2H6z"/></svg>',fmt:r=>`${r.display_name} <span style="opacity:.6">(${(r.cidr_blocks||[]).join(', ')})</span>`},
{key:'subnets',label:'Subnets',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M2 2h5v5H2zm7 0h5v5H9zm-7 7h5v5H2zm7 0h5v5H9z"/></svg>',fmt:r=>`${r.display_name} <span style="opacity:.6">(${r.cidr_block}, ${r.public?'pública':'privada'})</span>`},
{key:'instances',label:'Compute',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M2 2h12v12H2z"/></svg>',fmt:r=>`${r.display_name} <span style="opacity:.6">(${r.shape})</span>`},
{key:'internet_gateways',label:'Internet GWs',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><circle cx="8" cy="8" r="6" fill="none" stroke="#7b42bc" stroke-width="1.5"/><path d="M1 8h14M8 2c-2 2-2 10 0 12m0-12c2 2 2 10 0 12"/></svg>',fmt:r=>r.display_name},
{key:'nat_gateways',label:'NAT GWs',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M3 3l5 5-5 5"/><path d="M8 8h6" stroke="#7b42bc" stroke-width="1.5"/></svg>',fmt:r=>r.display_name},
{key:'route_tables',label:'Route Tables',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M2 3h12v2H2zm0 4h8v2H2zm0 4h10v2H2z"/></svg>',fmt:r=>r.display_name},
{key:'security_lists',label:'Security Lists',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M8 1l6 3v4c0 3.5-2.5 6.5-6 7.5C4.5 14.5 2 11.5 2 8V4z"/></svg>',fmt:r=>r.display_name},
{key:'autonomous_databases',label:'Databases',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><ellipse cx="8" cy="4" rx="6" ry="2.5"/><path d="M2 4v8c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5V4"/></svg>',fmt:r=>`${r.display_name} <span style="opacity:.6">(${r.db_name}${r.is_free_tier?' Free':''})</span>`},
{key:'block_volumes',label:'Block Volumes',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M3 2h10v12H3z"/></svg>',fmt:r=>`${r.display_name} <span style="opacity:.6">(${r.size_in_gbs||'?'} GB)</span>`},
{key:'load_balancers',label:'Load Balancers',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M8 2v12M2 8h12M4 4l8 8M12 4l-8 8"/></svg>',fmt:r=>`${r.display_name} <span style="opacity:.6">(${r.shape_name||'flexible'})</span>`}
];
let total=0;
let html='<div style="padding:.5rem">';
html+='<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.5rem"><span style="font-size:.72rem;font-weight:600;color:var(--t2)">Recursos existentes no compartment</span><button class="btn bs bsm" onclick="tfLoadResources()" style="font-size:.6rem;padding:1px 6px">Atualizar</button></div>';
for(const cat of cats){
const items=res[cat.key]||[];
if(!items.length)continue;
total+=items.length;
html+=`<div style="margin-bottom:.6rem"><div style="display:flex;align-items:center;gap:.3rem;font-size:.7rem;font-weight:600;color:#7b42bc;margin-bottom:.2rem">${cat.icon} ${cat.label} (${items.length})</div>`;
for(const r of items.slice(0,10)){
html+=`<div style="font-size:.68rem;padding:.15rem .4rem .15rem 1.2rem;color:var(--t2);border-left:2px solid #7b42bc22">${cat.fmt(r)}</div>`;
}
if(items.length>10)html+=`<div style="font-size:.62rem;padding:.1rem .4rem .1rem 1.2rem;color:var(--t3)">... e mais ${items.length-10}</div>`;
html+='</div>';
}
if(!total)html+='<div style="font-size:.72rem;color:var(--t3);text-align:center;padding:1rem">Nenhum recurso encontrado neste compartment</div>';
else html+=`<div style="font-size:.66rem;color:var(--t3);border-top:1px solid var(--bd);padding-top:.3rem;margin-top:.3rem">Total: <strong>${total}</strong> recursos. O agente irá reutilizá-los automaticamente quando possível.</div>`;
html+='</div>';
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=>`<option value="${c.id}">${c.tenancy_name} (${c.region})</option>`).join('');
if(!S.ociCfg.length)return`<div class="cd"><div class="emp"><div class="eic">☁️</div><p>Nenhuma credencial OCI configurada.</p><p style="font-size:.74rem;margin-top:.35rem">Configure em <strong>Credenciais OCI</strong>.</p></div></div>`;
return`<div class="cd"><div class="ct">🔍 Explorar Conta OCI</div>
<div class="g3"><div class="ig"><label>Conexão OCI</label><select id="exCfg">${cfgSel}</select></div>
<div class="ig"><label>Recurso</label><select id="exRes"><option value="compartments">Compartments</option><option value="regions">Regions</option><option value="instances">Compute Instances</option><option value="vcns">VCNs</option><option value="databases">Autonomous DBs</option><option value="buckets">Object Storage Buckets</option></select></div>
<div class="ig"><label>&nbsp;</label><button class="btn bp" onclick="doExplore()">🔍 Explorar</button></div></div></div>
<div class="cd"><div class="ct" id="exTitle">Resultados</div><div id="exOut">${S.expData?renderExpData(S.expData):'<div class="emp"><p>Selecione uma conexão e recurso.</p></div>'}</div></div>`}
async function doExplore(){const cid=document.getElementById('exCfg').value;const res=document.getElementById('exRes').value;
document.getElementById('exOut').innerHTML='<div class="ld"><span class="spinner"></span> Consultando OCI...</div>';
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='<div class="al al-e">'+e.message+'</div>'}}
function renderExpData(d){if(d.error)return`<div class="al al-e">${d.error}</div>`;if(!Array.isArray(d)||!d.length)return'<div class="emp"><p>Nenhum resultado encontrado.</p></div>';
if(!S.expCfg&&S.ociCfg.length)S.expCfg=S.ociCfg[0].id;
const cfgOpts=S.ociCfg.map(c=>`<option value="${c.id}"${c.id===S.expCfg?' selected':''}>${c.tenancy_name} (${c.region})</option>`).join('');
const regLabel=S.expSelRegions.length?S.expSelRegions.length+' região(ões)':'Todas as regiões';
const regChecks=S.expRegions.map(r=>`<label style="display:flex;align-items:center;gap:.35rem;padding:.25rem .5rem;font-size:.72rem;cursor:pointer;border-radius:4px;transition:background .1s" onmouseover="this.style.background='var(--bg2)'" onmouseout="this.style.background=''"><input type="checkbox" ${S.expSelRegions.includes(r.name)?'checked':''} onchange="expToggleRegion('${r.name}')" style="accent-color:var(--ac)"> ${r.name}${r.is_home?' <span style="font-size:.58rem;color:var(--ac)">(Home)</span>':''}</label>`).join('');
const catIcons={Compute:'💻',Rede:'🌐',Storage:'💾',Database:'🗄️',Containers:'📦',Serverless:'⚡',Observabilidade:'📊',Segurança:'🔒',IAM:'👤'};
const cats=EXP_GROUPS.map(g=>`<div class="exp-cat${S.expCat===g.name?' active':''}" onclick="expSetCat('${g.name}')">${catIcons[g.name]||''} ${g.name}</div>`).join('');
const activeGrp=EXP_GROUPS.find(g=>g.name===S.expCat)||EXP_GROUPS[0];
const tabs=activeGrp.tabs.map(t=>{const cnt=S.expCounts[t.key];return`<div class="exp-tab${S.expResType===t.key?' active':''}" onclick="expSetTab('${t.key}')">${t.label}${cnt!=null?' <span style="font-size:.58rem;opacity:.7">('+cnt+')</span>':''}</div>`}).join('');
return`<div class="exp-wrap">
<div class="exp-tree" id="exp-tree"><div style="padding:.5rem .65rem .4rem;font-size:.74rem;font-weight:700;color:var(--t2);border-bottom:1px solid var(--bd);margin-bottom:.3rem;display:flex;align-items:center;gap:.35rem">📁 Compartments</div>${S.expTree.length?rExpTree(null,0):'<div class="ld"><span class="spinner"></span> Carregando...</div>'}</div>
<div class="exp-resize" onmousedown="expStartResize(event)"></div>
<div class="exp-main">
<div class="exp-toolbar"><select onchange="expSetCfg(this.value)">${cfgOpts}</select>
${S.expRegions.length?`<div class="dd" style="position:relative" onclick="event.stopPropagation()"><div onclick="S.expRegDdOpen=!S.expRegDdOpen;R()" style="display:flex;align-items:center;gap:.3rem;cursor:pointer;padding:.3rem .6rem;background:var(--bg2);border:1px solid var(--bd);border-radius:8px;font-size:.72rem;white-space:nowrap;min-width:130px;justify-content:space-between"><span>🌍 ${regLabel}</span><span style="font-size:.55rem;color:var(--t4)">▼</span></div>
${S.expRegDdOpen?`<div style="position:absolute;top:100%;left:0;margin-top:4px;background:var(--bg);border:1px solid var(--bd);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.12);z-index:60;min-width:220px;overflow:hidden">
<div style="padding:.5rem .6rem;border-bottom:1px solid var(--bd);display:flex;justify-content:space-between;align-items:center"><span style="font-size:.7rem;font-weight:600;color:var(--t2)">Regiões</span><div style="display:flex;gap:.3rem"><button class="btn bs" style="font-size:.6rem;padding:.15rem .4rem" onclick="S.expSelRegions=S.expRegions.map(r=>r.name);R()">Todas</button><button class="btn bs" style="font-size:.6rem;padding:.15rem .4rem" onclick="S.expSelRegions=[];R()">Nenhuma</button></div></div>
<div style="max-height:240px;overflow-y:auto;padding:.3rem 0">${regChecks}</div>
<div style="padding:.4rem .6rem;border-top:1px solid var(--bd);text-align:right"><button class="btn bp" style="font-size:.68rem;padding:.25rem .7rem" onclick="S.expRegDdOpen=false;expApplyRegions()">Aplicar</button></div>
</div>`:''}</div>`:''}
${S.expSelRegions.length?`<div style="display:flex;gap:.25rem;flex-wrap:wrap;align-items:center">${S.expSelRegions.map(r=>`<span class="tag" style="font-size:.6rem;display:inline-flex;align-items:center;gap:.2rem;background:var(--acl);color:var(--ac);border:1px solid var(--ac);border-radius:6px;padding:.1rem .35rem">${r}<span onclick="expRemoveRegion('${r}')" style="cursor:pointer;font-weight:700;font-size:.7rem">&times;</span></span>`).join('')}</div>`:''}
<button class="btn" style="margin-left:auto;font-size:.7rem;padding:.25rem .6rem" onclick="expRefresh()">↻ Refresh</button></div>
<div class="exp-nav"><div class="exp-cats">${cats}</div></div>
<div class="exp-tabs">${tabs}</div>
<div class="exp-content">${S.expLoading?'<div class="ld"><span class="spinner"></span> Consultando OCI...</div>':renderExpData(S.expData)}</div>
</div></div>`}
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`<div class="exp-node" style="padding-left:${depth*0.6}rem">
<div class="exp-node-row${isSel?' selected':''}" onclick="expSelectComp('${n.id}')">
<span class="exp-node-toggle" onclick="event.stopPropagation();expToggleNode('${n.id}')">${hasKids?(isOpen?'▼':'▶'):' '}</span>
<span>${n.name}</span></div>
${hasKids&&isOpen?`<div class="exp-node-children">${rExpTree(n.id,depth+1)}</div>`:''}</div>`}).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<regions.length;i+=BATCH){
const batch=regions.slice(i,i+BATCH);
const fetches=batch.map(async reg=>{
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+BATCH<regions.length)await new Promise(r=>setTimeout(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'<div class="emp"><p>Selecione um compartment na árvore.</p></div>';
if(d.error)return`<div class="al al-e">${d.error}</div>`;if(!Array.isArray(d)||!d.length)return'<div class="emp"><p>Nenhum recurso encontrado neste compartment.</p></div>';
if(S.expResType==='security_lists')return rExpSecLists(d);
if(S.expResType==='route_tables')return rExpRouteTables(d);
return`<div style="font-size:.74rem;color:var(--t4);margin-bottom:.5rem">${d.length} item(ns) encontrado(s)</div><div class="exp-r">${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`<div class="exp-c"><strong>${name}</strong>${i.lifecycle_state?` <span class="badge ${i.lifecycle_state==='RUNNING'||i.lifecycle_state==='ACTIVE'||i.lifecycle_state==='AVAILABLE'?'b-pass':'b-pend'}">${i.lifecycle_state}</span>`:''}<br>
${keys.filter(k=>!['display_name','name','db_name','lifecycle_state'].includes(k)).map(k=>`<span style="color:var(--t4);font-size:.66rem">${k}:</span> <span class="em">${typeof i[k]==='object'?JSON.stringify(i[k]):i[k]}</span>`).join('<br>')}</div>`}).join('')}</div>`}
${keys.filter(k=>!skip.includes(k)).map(k=>`<span style="color:var(--t4);font-size:.66rem">${k}:</span> <span class="em">${typeof i[k]==='object'?JSON.stringify(i[k]):i[k]}</span>`).join('<br>')}</div>`}).join('')}</div>`}
function rExpSecLists(d){
return`<div style="font-size:.74rem;color:var(--t4);margin-bottom:.5rem">${d.length} security list(s)</div>${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`<div class="exp-c" style="max-width:100%"><div style="display:flex;justify-content:space-between;align-items:center;cursor:pointer" onclick="S._expExpanded=S._expExpanded||{};S._expExpanded[${idx}]=!S._expExpanded[${idx}];R()">
<div><strong style="display:inline">${sl.display_name}</strong> <span class="badge ${sl.lifecycle_state==='AVAILABLE'?'b-pass':'b-pend'}">${sl.lifecycle_state}</span></div>
<div style="font-size:.66rem;color:var(--t4)">${expanded?'▼':'▶'} ${ingress.length} ingress · ${egress.length} egress</div></div>
<div class="em" style="margin-top:.3rem">id: ${sl.id}</div>
${expanded?`<div style="margin-top:.5rem">${ingress.length?`<div style="font-size:.68rem;font-weight:600;color:var(--t2);margin:.4rem 0 .2rem">↓ Ingress (${ingress.length})</div>
<table style="width:100%;font-size:.66rem;border-collapse:collapse"><thead><tr style="background:var(--bg3);text-align:left"><th style="padding:.25rem .4rem">Protocolo</th><th style="padding:.25rem .4rem">Origem</th><th style="padding:.25rem .4rem">Portas</th><th style="padding:.25rem .4rem">Stateless</th><th style="padding:.25rem .4rem">Descrição</th></tr></thead><tbody>
${ingress.map(r=>`<tr style="border-top:1px solid var(--bd)"><td style="padding:.25rem .4rem;font-weight:600">${r.protocol}</td><td style="padding:.25rem .4rem;font-family:var(--fm);font-size:.62rem">${r.source_dest}</td><td style="padding:.25rem .4rem">${r.ports||'ALL'}</td><td style="padding:.25rem .4rem">${r.stateless?'Sim':'Não'}</td><td style="padding:.25rem .4rem;color:var(--t4);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${(r.description||'').replace(/"/g,'&quot;')}">${r.description||'—'}</td></tr>`).join('')}
</tbody></table>`:''}
${egress.length?`<div style="font-size:.68rem;font-weight:600;color:var(--t2);margin:.4rem 0 .2rem">↑ Egress (${egress.length})</div>
<table style="width:100%;font-size:.66rem;border-collapse:collapse"><thead><tr style="background:var(--bg3);text-align:left"><th style="padding:.25rem .4rem">Protocolo</th><th style="padding:.25rem .4rem">Destino</th><th style="padding:.25rem .4rem">Portas</th><th style="padding:.25rem .4rem">Stateless</th><th style="padding:.25rem .4rem">Descrição</th></tr></thead><tbody>
${egress.map(r=>`<tr style="border-top:1px solid var(--bd)"><td style="padding:.25rem .4rem;font-weight:600">${r.protocol}</td><td style="padding:.25rem .4rem;font-family:var(--fm);font-size:.62rem">${r.source_dest}</td><td style="padding:.25rem .4rem">${r.ports||'ALL'}</td><td style="padding:.25rem .4rem">${r.stateless?'Sim':'Não'}</td><td style="padding:.25rem .4rem;color:var(--t4);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${(r.description||'').replace(/"/g,'&quot;')}">${r.description||'—'}</td></tr>`).join('')}
</tbody></table>`:''}</div>`:''}</div>`}).join('')}`}
function rExpRouteTables(d){
return`<div style="font-size:.74rem;color:var(--t4);margin-bottom:.5rem">${d.length} route table(s)</div>${d.map((rt,idx)=>{
const routes=rt.routes||[];
const expanded=S._expExpanded&&S._expExpanded['rt'+idx];
return`<div class="exp-c" style="max-width:100%"><div style="display:flex;justify-content:space-between;align-items:center;cursor:pointer" onclick="S._expExpanded=S._expExpanded||{};S._expExpanded['rt'+${idx}]=!S._expExpanded['rt'+${idx}];R()">
<div><strong style="display:inline">${rt.display_name}</strong> <span class="badge ${rt.lifecycle_state==='AVAILABLE'?'b-pass':'b-pend'}">${rt.lifecycle_state}</span></div>
<div style="font-size:.66rem;color:var(--t4)">${expanded?'▼':'▶'} ${routes.length} rota(s)</div></div>
<div class="em" style="margin-top:.3rem">id: ${rt.id}</div>
${expanded&&routes.length?`<div style="margin-top:.5rem">
<table style="width:100%;font-size:.66rem;border-collapse:collapse"><thead><tr style="background:var(--bg3);text-align:left"><th style="padding:.25rem .4rem">Destino</th><th style="padding:.25rem .4rem">Tipo</th><th style="padding:.25rem .4rem">Target</th><th style="padding:.25rem .4rem">Descrição</th></tr></thead><tbody>
${routes.map(r=>`<tr style="border-top:1px solid var(--bd)"><td style="padding:.25rem .4rem;font-family:var(--fm);font-size:.62rem;font-weight:600">${r.destination}</td><td style="padding:.25rem .4rem">${r.destination_type}</td><td style="padding:.25rem .4rem;font-size:.6rem;color:var(--t3)">${r.target_type}<br><span class="em">${r.target_id}</span></td><td style="padding:.25rem .4rem;color:var(--t4);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${(r.description||'').replace(/"/g,'&quot;')}">${r.description||'—'}</td></tr>`).join('')}
</tbody></table></div>`:expanded?'<div style="font-size:.7rem;color:var(--t4);padding:.4rem">Nenhuma rota configurada</div>':''}</div>`}).join('')}`}
/* ── Report ── */
function rReport(){const comp=S.reports.filter(r=>r.status==='completed');