feat: Terraform intelligence, anti-flickering, and v2.2 release

- Official resource docs injection from registry.terraform.io (on-demand fetch + SQLite cache)
- Smart tfvars auto-generation from declared variables (tenancy_ocid, user_ocid, etc.)
- Multi-region provider: use var.region instead of hardcoded OCI config region
- RPC anti-cycle rule in system prompt
- System prompt auto-sync to DB on restart
- Fix UI flickering: animations scoped to tab switch only (tab-enter class)
- Terraform polling timeout increased to 60 min
- Terminal output updates without full page re-render
- Fix ${IC.bot} literal rendering in chat empty state
- Chat empty state icon scaled to 48px
- README updated to v2.2 with all changes
This commit is contained in:
nogueiraguh
2026-03-10 14:29:38 -03:00
parent 0877744cd1
commit 851def30b6
3 changed files with 247 additions and 45 deletions

View File

@@ -9,7 +9,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-2.1-C74634?style=flat-square" alt="Version">
<img src="https://img.shields.io/badge/version-2.2-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">
@@ -66,6 +66,10 @@ The platform combines security compliance scanning, AI-powered chat with **RAG (
- **Resizable split view**: chat on top (50%), files/plan/resources + terminal on bottom (50%) with drag-to-resize
- Terraform CLI installed in container (v1.7.5)
- **Terraform Resource Reference**: auto-generated OCI provider resource catalog (~937 resources) injected into system prompt — built from `terraform providers schema -json` at container startup, stored in SQLite, refreshable via menu button
- **Official Resource Docs Injection**: on-demand fetch of Example Usage + Argument Reference from the official OCI Terraform provider docs (registry.terraform.io) — cached in SQLite, injected into model context based on detected resource types
- **Smart variable resolution**: auto-generates `terraform.tfvars` by scanning declared variables and mapping them to OCI config values (`tenancy_ocid`, `user_ocid`, `fingerprint`, `compartment_id`) — region is never forced, respecting variable defaults
- **Multi-region provider management**: auto-detects provider aliases and region variables in generated code, generates `provider.tf` with credentials and correct region references (`var.region` instead of hardcoded values)
- **System prompt sync**: Terraform system prompt in code is automatically synced to DB on every restart — single source of truth
- Compact system prompt optimized for OCI Terraform provider best practices (100K max_tokens)
### ⚡ OCI Resource Actions
@@ -387,14 +391,14 @@ Allow group <group-name> to read buckets in compartment <compartment-name>
```
oci-cis-agent/
├── backend/
│ ├── app.py # FastAPI application (~4400 lines)
│ ├── app.py # FastAPI application (~4830 lines)
│ ├── cis_reports.py # Oracle CIS Benchmark checker (6660 lines, report engine)
│ ├── mcp_cis_server.py # MCP server with 12 granular CIS tools (~700 lines)
│ ├── gen_tf_reference.py # OCI Terraform provider resource catalog generator
│ ├── Dockerfile # Python 3.12 + OCI CLI + Terraform CLI
│ └── requirements.txt # Dependencies
├── frontend/
│ └── index.html # SPA with Oracle Dark Premium theme (~2470 lines)
│ └── index.html # SPA with Oracle Dark Premium theme (~2600 lines)
├── nginx/
│ └── default.conf # Reverse proxy config
├── docker-compose.yml # Orchestration
@@ -656,6 +660,7 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the "
| Version | Date | Changes |
|---------|------|---------|
| **v2.2** | 2026-03 | **UI Modernization (Phases 1B-5)**: Oracle Dark Premium theme, 45+ Lucide SVG icons replacing all emojis, KPI dashboard with compliance gauge + Chart.js donut/bar charts, report summary API endpoint, Phase 5 animations (staggered fade-in, smooth theme transitions, hover effects) — animations scoped to tab switch only (no flickering on re-render). **Terraform Intelligence**: official resource docs injection from registry.terraform.io (Example Usage + Argument Reference, on-demand fetch + SQLite cache), smart tfvars auto-generation from declared variables, multi-region provider management with `var.region` support, RPC anti-cycle rule, system prompt auto-sync to DB, 60-min polling timeout for long-running applies. **ADB fix**: wallet_password always passed for thin mode mTLS (fixes DPY-6005). CIS_EMBEDDINGS default removed from ADB config |
| **v2.1** | 2026-03 | Terraform Agent (AI-powered IaC generation with plan/apply/destroy lifecycle, workspace management, Terraform CLI in container, inline file editor, split-panel terminal, multi-file generation via `// filename:` markers, **smart file correction** with merge-based updates, **14-point validation checklist**, 100K max_tokens), **3-layer auto-split pipeline** (frontend + backend monolithic HCL splitting into categorized files + backend deduplication), **HCL syntax auto-fix** (single-line blocks expanded to multi-line), **SQLite-based resource type validation** (~937 types with `difflib` suggestions for invalid types), **Terraform Resource Reference** (auto-generated OCI provider catalog from `terraform providers schema -json`, generated at startup to survive volume mounts, stored in SQLite, refreshable via menu button), compact system prompt for Terraform agent, ChatGPT-style chat history for both Chat Agent and Terraform Agent (rename/delete sessions), OCI Resource Actions (start/stop instances and Autonomous DBs from **OCI Account Explorer**), OCI Explorer expanded to 40+ resource types across 8 categories with tree-view navigation and resizable panels, compartment filtering for MCP CIS scans, chat sidebar with model parameters, chat audit logs, collection error tracking in MCP server, tool timeout increased to 30min with auto-retry, memory compaction tuned (6K threshold, 20 recent messages), model catalog trimmed from 69 to 15 curated models, improved GenAI response extraction with fallback for empty responses, fixed layout overflow (no page scroll), provider alias detection with brace-matching parser, multi-region Terraform support |
| **v2.0** | 2026-03 | Async background chat processing (no more 504 timeouts), frontend polling with timestamps, 8 uvicorn workers + 16-thread chat executor for ~12 simultaneous chats, parallelized MCP data collection (5-thread base + 8-thread regional), 2-hour MCP session cache, 5-min tool timeout, full dead code cleanup across backend/frontend/MCP |
| **v1.9** | 2026-03 | Multimodal chat (image/PDF/text file upload with OCI GenAI ImageContent/DocumentContent), region-specific MCP scanning (`regions` param on all scan tools), orphaned report auto-detection on progress poll, nginx timeout increased to 15min, improved API error handling for non-JSON responses |

View File

@@ -342,6 +342,13 @@ def init_db():
required_args TEXT DEFAULT '',
blocks TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS tf_resource_docs (
slug TEXT PRIMARY KEY,
subcategory TEXT DEFAULT '',
example_usage TEXT DEFAULT '',
argument_ref TEXT DEFAULT '',
fetched_at TEXT DEFAULT (datetime('now'))
);
""")
c.execute("DELETE FROM config_logs WHERE created_at < datetime('now', '-30 days')")
c.execute("DELETE FROM chat_logs WHERE created_at < datetime('now', '-30 days')")
@@ -397,7 +404,11 @@ def init_db():
if not c.execute("SELECT 1 FROM system_prompts WHERE agent='chat'").fetchone():
c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active) VALUES (?,?,?,?,?)",
(str(uuid.uuid4()), "OCI CIS RAG Agent", "chat", RAG_DEFAULT_SYSTEM_PROMPT, 1))
if not c.execute("SELECT 1 FROM system_prompts WHERE agent='terraform'").fetchone():
tf_row = c.execute("SELECT id FROM system_prompts WHERE agent='terraform' AND is_active=1").fetchone()
if tf_row:
# Always sync DB prompt with code default
c.execute("UPDATE system_prompts SET content=? WHERE id=?", (TF_DEFAULT_SYSTEM_PROMPT, tf_row["id"]))
else:
c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active) VALUES (?,?,?,?,?)",
(str(uuid.uuid4()), "OCI Terraform Agent", "terraform", TF_DEFAULT_SYSTEM_PROMPT, 1))
adm = c.execute("SELECT id FROM users WHERE username='admin'").fetchone()
@@ -1936,7 +1947,7 @@ TF_DEFAULT_SYSTEM_PROMPT = """Você gera somente Terraform HCL para Oracle Cloud
Se pedirem AWS, Azure, GCP ou outro provider, recuse educadamente.
### Regras
- Gere código production-ready, com variáveis, defaults sensatos, tags e outputs úteis.
- Gere código production-ready, com variáveis, defaults sensatos, tags e outputs úteis. Evite ficar solicitando ao usuário para ficar interagindo a todo momento, entregue o que ele precisa e faça o mínimo de interações para entender o que o usuário precisa. Explique de forma clara o que foi gerado e o que foi arrumado para que fosse possível gerar o código do terraform.
- Use sempre a sintaxe mais recente do provider OCI.
- Não inclua `terraform { required_providers }` nem `provider "oci"` principal.
- Em multi-região, use provider aliases para regiões adicionais e `provider = oci.alias` nos recursos.
@@ -1945,7 +1956,7 @@ Se pedirem AWS, Azure, GCP ou outro provider, recuse educadamente.
- Nunca declare o mesmo recurso (tipo + nome) em mais de um arquivo.
- Use `var.compartment_id` nos recursos e declare sempre `variable "region"` em `variables.tf`.
- Se houver "RECURSOS OCI EXISTENTES NO COMPARTMENT", reutilize recursos com data sources sempre que possível.
- Se houver "ARQUIVOS TERRAFORM ATUAIS NO WORKSPACE", gere somente os arquivos corrigidos, com os mesmos nomes, sem renomear recursos.
- Se houver "ARQUIVOS TERRAFORM ATUAIS NO WORKSPACE", gere somente os arquivos corrigidos, com os mesmos nomes.
- Sempre responda com blocos `hcl`, depois **Resource Plan** (`+` criar, `~` reutilizar), uma breve explicação e uma seção `✅ Validação`.
- Valide: referências cruzadas, CIDRs, route tables, security lists, dependências, segurança e variáveis.
- Use apenas resource types válidos do OCI. Exemplos corretos:
@@ -1963,7 +1974,7 @@ Se pedirem AWS, Azure, GCP ou outro provider, recuse educadamente.
### Regras críticas
- `oci_core_drg_route_distribution_statement` deve usar `match_criteria`.
- Remote peering usa `peer_id` e `peer_region_name` no próprio `oci_core_remote_peering_connection`.
- Remote peering: APENAS UM LADO do par define `peer_id` e `peer_region_name`. O outro lado é criado SEM `peer_id`. Nunca gere dois RPCs apontando um para o outro (causa Cycle error). Exemplo: RPC_mad1 (sem peer_id) e RPC_mad3 (com peer_id = RPC_mad1.id, peer_region_name = var.region).
- Associação de route table com subnet deve ser via `route_table_id` na subnet ou `oci_core_route_table_attachment`.
- `oci_network_firewall_network_firewall` exige `network_firewall_policy_id`.
"""
@@ -2092,6 +2103,118 @@ def _regenerate_tf_reference() -> dict:
except Exception as e:
return {"ok": False, "error": str(e)}
# ── Terraform Resource Docs (on-demand from GitHub, cached in SQLite) ──
_TF_DOC_BASE_URL = "https://raw.githubusercontent.com/oracle/terraform-provider-oci/master/website/docs/r/"
def _fetch_tf_resource_doc(slug: str) -> dict | None:
"""Fetch a single resource doc from GitHub, parse Example Usage + Argument Reference, cache in SQLite."""
import re as _re
# Check cache first
with db() as c:
row = c.execute("SELECT example_usage, argument_ref FROM tf_resource_docs WHERE slug=?", (slug,)).fetchone()
if row and (row["example_usage"] or row["argument_ref"]):
return {"slug": slug, "example": row["example_usage"], "args": row["argument_ref"]}
# Fetch from GitHub
try:
import urllib.request
url = f"{_TF_DOC_BASE_URL}{slug}.html.markdown"
req = urllib.request.Request(url, headers={"User-Agent": "oci-cis-agent/2.1"})
with urllib.request.urlopen(req, timeout=15) as resp:
if resp.status != 200:
return None
md = resp.read().decode("utf-8")
# Extract Example Usage section
example = ""
m = _re.search(r'## Example Usage\s*\n(.*?)(?=\n## )', md, _re.DOTALL)
if m:
example = m.group(1).strip()
# Extract Argument Reference section
args = ""
m2 = _re.search(r'## Argument Reference\s*\n(.*?)(?=\n## )', md, _re.DOTALL)
if m2:
args = m2.group(1).strip()
# Cache in SQLite
subcategory = ""
m3 = _re.search(r'subcategory:\s*"([^"]+)"', md)
if m3:
subcategory = m3.group(1)
with db() as c:
c.execute(
"INSERT OR REPLACE INTO tf_resource_docs (slug, subcategory, example_usage, argument_ref) VALUES (?,?,?,?)",
(slug, subcategory, example, args))
log.info(f"Fetched TF doc: {slug} (example={len(example)} chars, args={len(args)} chars)")
return {"slug": slug, "example": example, "args": args}
except Exception as e:
log.warning(f"Failed to fetch TF doc for {slug}: {e}")
return None
def _get_tf_docs_for_resources(resource_types: list[str]) -> str:
"""Given a list of oci_xxx resource types, fetch their docs and build a context string."""
docs_parts = []
for rtype in resource_types[:10]: # limit to 10 to keep context manageable
# Convert oci_core_vcn -> core_vcn
slug = rtype.replace("oci_", "", 1) if rtype.startswith("oci_") else rtype
doc = _fetch_tf_resource_doc(slug)
if doc and (doc["example"] or doc["args"]):
part = f"#### {rtype}\n"
if doc["example"]:
part += f"**Example Usage:**\n{doc['example']}\n\n"
if doc["args"]:
part += f"**Arguments:**\n{doc['args']}\n"
docs_parts.append(part)
if not docs_parts:
return ""
return "### Documentação Oficial dos Recursos (registry.terraform.io)\n" + \
"Use exatamente os argumentos e estrutura mostrados nos exemplos abaixo.\n\n" + \
"\n---\n".join(docs_parts)
def _detect_tf_resource_types(text: str) -> list[str]:
"""Detect OCI resource types mentioned or implied in a user message + conversation."""
import re as _re
# Direct mentions of oci_ resource types
explicit = set(_re.findall(r'\boci_\w+', text))
# Keyword-to-resource mapping for common infra requests
keyword_map = {
'vcn': ['oci_core_vcn', 'oci_core_subnet', 'oci_core_internet_gateway', 'oci_core_nat_gateway',
'oci_core_route_table', 'oci_core_security_list'],
'subnet': ['oci_core_subnet'],
'instance': ['oci_core_instance', 'oci_core_instance_configuration'],
'compute': ['oci_core_instance'],
'load.?balancer': ['oci_load_balancer_load_balancer', 'oci_load_balancer_backend_set',
'oci_load_balancer_listener'],
'nlb': ['oci_network_load_balancer_network_load_balancer', 'oci_network_load_balancer_backend_set',
'oci_network_load_balancer_listener'],
'firewall': ['oci_network_firewall_network_firewall', 'oci_network_firewall_network_firewall_policy'],
'drg': ['oci_core_drg', 'oci_core_drg_attachment', 'oci_core_drg_route_table'],
'peering|rpc': ['oci_core_remote_peering_connection'],
'bucket|object.?storage': ['oci_objectstorage_bucket', 'oci_objectstorage_namespace_metadata'],
'autonomous|adb': ['oci_database_autonomous_database'],
'db.?system': ['oci_database_db_system'],
'mysql': ['oci_mysql_mysql_db_system'],
'oke|kubernetes|cluster': ['oci_containerengine_cluster', 'oci_containerengine_node_pool'],
'dns': ['oci_dns_zone', 'oci_dns_record'],
'vault|kms': ['oci_kms_vault', 'oci_kms_key'],
'waf': ['oci_waf_web_app_firewall', 'oci_waf_web_app_firewall_policy'],
'api.?gateway': ['oci_apigateway_gateway', 'oci_apigateway_deployment'],
'function|serverless': ['oci_functions_application', 'oci_functions_function'],
'nsg': ['oci_core_network_security_group', 'oci_core_network_security_group_security_rule'],
'bastion': ['oci_bastion_bastion', 'oci_bastion_session'],
'file.?storage': ['oci_file_storage_file_system', 'oci_file_storage_mount_target',
'oci_file_storage_export'],
'block.?volume': ['oci_core_volume', 'oci_core_volume_attachment'],
'policy|iam': ['oci_identity_policy', 'oci_identity_compartment', 'oci_identity_group',
'oci_identity_dynamic_group'],
}
text_lower = text.lower()
for pattern, resources in keyword_map.items():
if _re.search(pattern, text_lower):
explicit.update(resources)
return sorted(explicit)
def _get_adb_connection(cfg: dict):
"""Create an oracledb connection from an adb_vector_configs row."""
import oracledb
@@ -3279,6 +3402,26 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
ref_compact
log.info(f"Terraform: injected resource reference ({len(ref_compact)} chars)")
# ── Inject official Terraform resource docs (Example Usage + Arguments) ──
if agent_type == "terraform":
try:
# Detect resource types from user message + recent conversation history
detect_text = msg.message if hasattr(msg, 'message') else ""
if history:
# Include last 3 messages for context
for h in history[-3:]:
detect_text += "\n" + (h.get("content", "") or "")
resource_types = _detect_tf_resource_types(detect_text)
if resource_types:
loop = asyncio.get_event_loop()
docs_ctx = await loop.run_in_executor(
_chat_executor, partial(_get_tf_docs_for_resources, resource_types))
if docs_ctx:
cfg_dict["system_prompt"] = cfg_dict.get("system_prompt", "") + "\n\n" + docs_ctx
log.info(f"Terraform: injected {len(resource_types)} resource docs ({len(docs_ctx)} chars)")
except Exception as e:
log.warning(f"Failed to inject terraform resource docs: {e}")
# Log total prompt sizes for debugging
if agent_type == "terraform":
sp_len = len(cfg_dict.get("system_prompt", ""))
@@ -4160,6 +4303,20 @@ async def _terraform_exec(wid: str, action: str, user: dict):
else:
tf_file.unlink() # Remove empty file
# Scan all .tf files for variable declarations with region-like defaults
# so we can map alias providers to the correct variable reference
var_region_map = {} # variable_name -> default_value
var_re = _re2.compile(r'variable\s+"([^"]*region[^"]*)"\s*\{', _re2.IGNORECASE)
for tf_file in sorted(wdir.glob("*.tf")):
if tf_file.name in ("provider.tf", "terraform.tfvars"):
continue
content = tf_file.read_text()
for vm in var_re.finditer(content):
var_region_map[vm.group(1)] = f'var.{vm.group(1)}'
# Check if variable "region" is declared — use it for primary provider
has_region_var = "region" in var_region_map
# Also scan for provider refs in resource blocks (provider = oci.xxx)
for tf_file in sorted(wdir.glob("*.tf")):
if tf_file.name == "provider.tf":
@@ -4168,7 +4325,14 @@ async def _terraform_exec(wid: str, action: str, user: dict):
for ref_m in _re2.finditer(r'provider\s*=\s*oci\.(\w+)', content):
ref_alias = ref_m.group(1)
if ref_alias not in [a[0] for a in alias_blocks]:
alias_blocks.append((ref_alias, '"unknown"'))
# Try to find a matching region variable for this alias
# e.g. alias "mad_3" might match var.region_secondary
region_ref = 'var.region' # fallback to primary region var
for vname, vref in var_region_map.items():
if vname != "region": # prefer non-primary region vars for aliases
region_ref = vref
break
alias_blocks.append((ref_alias, region_ref))
passphrase = oci_cfg.get('pass_phrase', '')
cred_block = f''' tenancy_ocid = "{oci_cfg.get('tenancy', '')}"
@@ -4178,10 +4342,11 @@ async def _terraform_exec(wid: str, action: str, user: dict):
if passphrase:
cred_block += f'\n private_key_password = "{passphrase}"'
# Use region from OCI config row (may differ from oci sdk config)
# Primary region: use var.region if declared in .tf files, otherwise hardcode from OCI config
with db() as c:
oci_row = c.execute("SELECT region FROM oci_configs WHERE id=?", (ws["oci_config_id"],)).fetchone()
primary_region = oci_row["region"] if oci_row else oci_cfg.get("region", "")
region_value = 'var.region' if has_region_var else f'"{primary_region}"'
provider_tf = f'''terraform {{
required_providers {{
@@ -4193,7 +4358,7 @@ async def _terraform_exec(wid: str, action: str, user: dict):
provider "oci" {{
{cred_block}
region = "{primary_region}"
region = {region_value}
}}
'''
# Add alias providers with same credentials
@@ -4211,14 +4376,36 @@ provider "oci" {{
'''
(wdir / "provider.tf").write_text(provider_tf)
# Auto-generate terraform.tfvars with compartment and region
# Auto-generate terraform.tfvars — scan declared variables and provide values from OCI config
with db() as c:
oci_row = c.execute("SELECT compartment_id, region FROM oci_configs WHERE id=?", (ws["oci_config_id"],)).fetchone()
# Use workspace-selected compartment, fallback to OCI config default
comp_id = ws["compartment_id"] if ws["compartment_id"] else (_safe_dec(oci_row["compartment_id"]) if oci_row and oci_row["compartment_id"] else "")
region = oci_row["region"] if oci_row else oci_cfg.get("region", "")
tfvars = f'compartment_id = "{comp_id}"\nregion = "{region}"\n'
(wdir / "terraform.tfvars").write_text(tfvars)
# Scan all declared variables in .tf files
declared_vars = set()
for tf_file in sorted(wdir.glob("*.tf")):
if tf_file.name in ("provider.tf", "terraform.tfvars"):
continue
content = tf_file.read_text()
for vm in _re2.finditer(r'variable\s+"([^"]+)"', content):
declared_vars.add(vm.group(1))
# Map known variable names to OCI config values
var_values = {"compartment_id": comp_id}
oci_var_map = {
"tenancy_ocid": oci_cfg.get("tenancy", ""),
"tenancy_id": oci_cfg.get("tenancy", ""),
"user_ocid": oci_cfg.get("user", ""),
"fingerprint": oci_cfg.get("fingerprint", ""),
"private_key_path": oci_cfg.get("key_file", ""),
}
# NOTE: "region" is intentionally NOT included — Terraform uses the default from variable declarations
for vname, vval in oci_var_map.items():
if vname in declared_vars and vval:
var_values[vname] = vval
tfvars_lines = [f'{k} = "{v}"' for k, v in var_values.items()]
(wdir / "terraform.tfvars").write_text("\n".join(tfvars_lines) + "\n")
output_lines = []

View File

@@ -288,20 +288,20 @@ tbody tr:last-child td{border-bottom:none}
.ld{animation:pu 1.2s ease infinite;color:var(--t4);font-size:.82rem;text-align:center;padding:1.5rem}
.spinner{display:inline-block;width:16px;height:16px;border:2px solid var(--bd);border-top-color:var(--ac);border-radius:50%;animation:spin .6s linear infinite}
/* Page content fade-in */
.pc>.cd,.pc>.kpi-row,.pc>.exp-wrap,.pc>.ch-c,.pc>div{animation:fi .4s var(--trans) both}
.pc>.cd:nth-child(2),.pc>div:nth-child(2){animation-delay:.05s}
.pc>.cd:nth-child(3),.pc>div:nth-child(3){animation-delay:.1s}
.pc>.cd:nth-child(4),.pc>div:nth-child(4){animation-delay:.15s}
/* Page content fade-in — only on tab switch (class .tab-enter added by switchTab, removed after animation) */
.tab-enter>.cd,.tab-enter>.kpi-row,.tab-enter>.exp-wrap,.tab-enter>.ch-c,.tab-enter>div{animation:fi .4s var(--trans) both}
.tab-enter>.cd:nth-child(2),.tab-enter>div:nth-child(2){animation-delay:.05s}
.tab-enter>.cd:nth-child(3),.tab-enter>div:nth-child(3){animation-delay:.1s}
.tab-enter>.cd:nth-child(4),.tab-enter>div:nth-child(4){animation-delay:.15s}
/* Alert slide-in */
.al{padding:.65rem .85rem;border-radius:10px;font-size:.78rem;margin-bottom:.75rem;font-weight:500;animation:alertIn .3s var(--trans) both}
/* KPI card stagger */
.kpi{animation:fiScale .4s var(--trans) both}
.kpi:nth-child(2){animation-delay:.06s}
.kpi:nth-child(3){animation-delay:.12s}
.kpi:nth-child(4){animation-delay:.18s}
/* KPI card stagger — only on tab switch */
.tab-enter .kpi{animation:fiScale .4s var(--trans) both}
.tab-enter .kpi:nth-child(2){animation-delay:.06s}
.tab-enter .kpi:nth-child(3){animation-delay:.12s}
.tab-enter .kpi:nth-child(4){animation-delay:.18s}
/* Enhanced card hover */
.cd{transition:box-shadow .3s var(--trans),transform .3s var(--trans),border-color .3s var(--trans)}
@@ -319,20 +319,20 @@ tbody tr:last-child td{border-bottom:none}
.badge{transition:transform .15s var(--trans),box-shadow .15s var(--trans)}
.badge:hover{transform:scale(1.05)}
/* Table row slide-in */
tbody tr{animation:fi .3s var(--trans) both}
tbody tr:nth-child(2){animation-delay:.03s}
tbody tr:nth-child(3){animation-delay:.06s}
tbody tr:nth-child(4){animation-delay:.09s}
tbody tr:nth-child(5){animation-delay:.12s}
/* Table row slide-in — only on tab switch */
.tab-enter tbody tr{animation:fi .3s var(--trans) both}
.tab-enter tbody tr:nth-child(2){animation-delay:.03s}
.tab-enter tbody tr:nth-child(3){animation-delay:.06s}
.tab-enter tbody tr:nth-child(4){animation-delay:.09s}
.tab-enter tbody tr:nth-child(5){animation-delay:.12s}
/* Explorer card stagger */
.exp-c{animation:fiScale .3s var(--trans) both}
.exp-c:nth-child(2){animation-delay:.04s}
.exp-c:nth-child(3){animation-delay:.08s}
.exp-c:nth-child(4){animation-delay:.12s}
.exp-c:nth-child(5){animation-delay:.16s}
.exp-c:nth-child(6){animation-delay:.2s}
/* Explorer card stagger — only on tab switch */
.tab-enter .exp-c{animation:fiScale .3s var(--trans) both}
.tab-enter .exp-c:nth-child(2){animation-delay:.04s}
.tab-enter .exp-c:nth-child(3){animation-delay:.08s}
.tab-enter .exp-c:nth-child(4){animation-delay:.12s}
.tab-enter .exp-c:nth-child(5){animation-delay:.16s}
.tab-enter .exp-c:nth-child(6){animation-delay:.2s}
/* Nav item hover glow */
.ni:hover{background:var(--bg2);color:var(--t2);transform:translateX(2px)}
@@ -541,7 +541,9 @@ async function $api(p,o={}){const h={...(o.headers||{})};if(S.token)h['Authoriza
const d=await r.json();if(!r.ok)throw new Error(d.detail||d.message||'Erro');return d;}
async function doLogin(u,pw,totp){const b={username:u,password:pw};if(totp)b.totp_code=totp;
const d=await $api('/auth/login',{method:'POST',body:b});if(d.mfa_required)return d;
S.token=d.token;S.user=d.user;localStorage.setItem('t',d.token);await loadData();R();return d;}
S.token=d.token;S.user=d.user;localStorage.setItem('t',d.token);await loadData();R();
const _pg=document.getElementById('pg');if(_pg){const _pc=_pg.querySelector('.pc');if(_pc){_pc.classList.add('tab-enter');setTimeout(()=>_pc.classList.remove('tab-enter'),500)}}
return d;}
async function doLogout(){try{await $api('/auth/logout',{method:'POST'})}catch(e){}S.user=null;S.token=null;S.msgs=[];localStorage.removeItem('t');R()}
async function checkAuth(){const t=localStorage.getItem('t');if(!t)return;S.token=t;try{S.user=await $api('/users/me');await loadData()}catch(e){S.token=null;localStorage.removeItem('t')}}
async function loadData(){try{
@@ -587,7 +589,9 @@ function R(){try{
});
if(S.editing?.type==='mcp')setTimeout(mcpTypeFields,0)
}catch(e){console.error('Render error:',e);document.getElementById('app').innerHTML='<div style="padding:2rem;color:red"><h3>Render Error</h3><pre>'+e.message+'</pre></div>'}}
function switchTab(t){S.tab=t;R();if(t==='chat')loadHistory('chat');if(t==='terraform')loadHistory('terraform');if(t==='audit'&&S.user.role==='admin')loadAudit();if(t==='downloads')refreshDl();if(t==='explorer'&&S.ociCfg.length&&!S.expTree.length){if(!S.expCfg)S.expCfg=S.ociCfg[0].id;expLoadTree();expLoadRegions()}
function switchTab(t){S.tab=t;R();
const pg=document.getElementById('pg');if(pg){const pc=pg.querySelector('.pc');if(pc){pc.classList.add('tab-enter');setTimeout(()=>pc.classList.remove('tab-enter'),500)}}
if(t==='chat')loadHistory('chat');if(t==='terraform')loadHistory('terraform');if(t==='audit'&&S.user.role==='admin')loadAudit();if(t==='downloads')refreshDl();if(t==='explorer'&&S.ociCfg.length&&!S.expTree.length){if(!S.expCfg)S.expCfg=S.ociCfg[0].id;expLoadTree();expLoadRegions()}
const tm={'oci-config':'oci','genai':'genai','adb':'adb','mcp':'mcp'};if(tm[t])setTimeout(()=>refreshCLogs(tm[t]),100)}
/* ── Login ── */
@@ -1586,7 +1590,7 @@ async function tfCancel(){
}
async function tfPollExec(){
for(let i=0;i<600;i++){
for(let i=0;i<1800;i++){
await new Promise(r=>setTimeout(r,2000));
try{
const r=await $api('/terraform/workspaces/'+S.tfWs+'/status');
@@ -1594,10 +1598,16 @@ async function tfPollExec(){
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 t=document.getElementById('tfTermOut');if(t)t.scrollTop=t.scrollHeight;
const done=!['planning','applying','destroying'].includes(r.status);
if(done)S.tfRunning=false;
// Update only the terminal body without full re-render
const t=document.getElementById('tfTermOut');
if(t){
const content=S.tfDestroyOut||S.tfApplyOut||S.tfPlanOut||'';
t.innerHTML=content?escHtml(content):'<span style="opacity:.4">Aguardando execução...</span>';
t.scrollTop=t.scrollHeight;
}
if(done){R();return}
}catch(e){}
}
S.tfRunning=false;R();