feat: Terraform resource reference from provider schema, provider alias fixes, and timestamps
- Add gen_tf_reference.py to generate OCI Terraform provider resource catalog (~937 resources) from `terraform providers schema -json` - Auto-inject categorized resource reference into Terraform Agent system prompt to prevent invalid resource type names - Build-time reference generation in Dockerfile, refreshable via POST /api/terraform/refresh-reference - Provider alias detection with brace-matching parser for multi-region deployments - Clean old .tf files before writing new ones on Re-plan - Message timestamps in terraform chat and history - Partial DOM rendering to eliminate screen flickering - Layout overflow fix (no scroll beyond viewport)
This commit is contained in:
10
README.md
10
README.md
@@ -60,6 +60,7 @@ The platform combines security compliance scanning, AI-powered chat with **RAG (
|
||||
- **Chat history**: ChatGPT-style session history with rename/delete, shared between Chat Agent and Terraform Agent
|
||||
- **Resizable split view**: chat on top (50%), files/plan/resources + terminal on bottom (50%) with drag-to-resize
|
||||
- Terraform CLI installed in container (v1.7.5)
|
||||
- **Terraform Resource Reference (RAG-lite)**: auto-generated OCI provider resource catalog (~937 resources) injected into system prompt to prevent invalid resource type names — built from `terraform providers schema -json` at container build time, refreshable via API endpoint
|
||||
- Dedicated system prompt optimized for OCI Terraform provider best practices
|
||||
|
||||
### ⚡ OCI Resource Actions
|
||||
@@ -356,13 +357,14 @@ Allow group <group-name> to read buckets in compartment <compartment-name>
|
||||
```
|
||||
oci-cis-agent/
|
||||
├── backend/
|
||||
│ ├── app.py # FastAPI application (~4000 lines)
|
||||
│ ├── app.py # FastAPI application (~4200 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 Cloud theme (~1950 lines)
|
||||
│ └── index.html # SPA with Oracle Cloud theme (~1980 lines)
|
||||
├── nginx/
|
||||
│ └── default.conf # Reverse proxy config
|
||||
├── docker-compose.yml # Orchestration
|
||||
@@ -514,6 +516,8 @@ oci-cis-agent/
|
||||
| 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 |
|
||||
| POST | `/api/terraform/refresh-reference` | Regenerate OCI Terraform resource reference |
|
||||
| GET | `/api/terraform/reference-status` | Check resource reference availability |
|
||||
|
||||
### Chat & Reports
|
||||
|
||||
@@ -622,7 +626,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, inline file editor, split-panel terminal, multi-file generation via `// filename:` markers), ChatGPT-style chat history for both Chat Agent and Terraform Agent (rename/delete sessions), OCI Resource Actions (start/stop instances and Autonomous DBs from Resources panel), OCI Explorer expanded to 40+ resource types across 8 categories with tree-view navigation and resizable panels, compartment filtering for MCP CIS scans, chat sidebar with model parameters, chat audit logs, collection error tracking in MCP server, tool timeout increased to 30min with auto-retry, memory compaction tuned (6K threshold, 20 recent messages), model catalog trimmed from 69 to 15 curated models, improved GenAI response extraction with fallback for empty responses, fixed layout overflow (no page scroll) |
|
||||
| **v2.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), **Terraform Resource Reference** (auto-generated OCI provider resource catalog from `terraform providers schema -json`, injected into system prompt to prevent invalid resource types, refreshable via API), ChatGPT-style chat history for both Chat Agent and Terraform Agent (rename/delete sessions), OCI Resource Actions (start/stop instances and Autonomous DBs from Resources panel), OCI Explorer expanded to 40+ resource types across 8 categories with tree-view navigation and resizable panels, compartment filtering for MCP CIS scans, chat sidebar with model parameters, chat audit logs, collection error tracking in MCP server, tool timeout increased to 30min with auto-retry, memory compaction tuned (6K threshold, 20 recent messages), model catalog trimmed from 69 to 15 curated models, improved GenAI response extraction with fallback for empty responses, fixed layout overflow (no page scroll), 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 |
|
||||
| **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 |
|
||||
|
||||
@@ -19,10 +19,14 @@ RUN pip install --no-cache-dir -r requirements.txt && \
|
||||
COPY app.py .
|
||||
COPY cis_reports.py .
|
||||
COPY mcp_cis_server.py .
|
||||
COPY gen_tf_reference.py .
|
||||
|
||||
# Data volume
|
||||
RUN mkdir -p /data
|
||||
|
||||
# Generate OCI Terraform resource reference at build time
|
||||
RUN python gen_tf_reference.py || echo "WARN: tf reference generation skipped (terraform init may need network)"
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "8"]
|
||||
|
||||
197
backend/app.py
197
backend/app.py
@@ -1955,6 +1955,14 @@ TF_DEFAULT_SYSTEM_PROMPT = """Você é um agente especializado EXCLUSIVAMENTE em
|
||||
- Cada bloco `hcl` vira um arquivo separado no sistema.
|
||||
- Se o usuário NÃO especificar, gere tudo em um único bloco.
|
||||
|
||||
### REGRA CRÍTICA: Unicidade de Recursos
|
||||
- **NUNCA** declare o mesmo recurso (mesmo tipo + mesmo nome) em mais de um arquivo.
|
||||
- Cada `resource "tipo" "nome"` deve existir em EXATAMENTE UM arquivo.
|
||||
- Se um recurso precisa de atributos definidos em outro arquivo (ex: route_table_id, security_list_ids), defina TODOS os atributos na declaração ÚNICA do recurso, referenciando recursos de outros arquivos.
|
||||
- Exemplo ERRADO: declarar `resource "oci_core_subnet" "app"` em `networking.tf` e redeclarar em `routing.tf`.
|
||||
- Exemplo CORRETO: declarar a subnet UMA VEZ em `networking.tf` com `route_table_id = oci_core_route_table.app_rt.id` referenciando a route table de `routing.tf`.
|
||||
- Antes de gerar o código, planeje quais recursos vão em cada arquivo para evitar duplicações.
|
||||
|
||||
### Contexto OCI
|
||||
- Use as informações de tenancy, região e compartment fornecidas no contexto.
|
||||
- Use `var.compartment_id` como default para compartment_id nos recursos.
|
||||
@@ -2000,8 +2008,68 @@ TF_DEFAULT_SYSTEM_PROMPT = """Você é um agente especializado EXCLUSIVAMENTE em
|
||||
- Se o pedido for ambíguo, faça perguntas de esclarecimento.
|
||||
- NÃO sugira rodar comandos terraform — o sistema cuida disso.
|
||||
- Inclua outputs úteis (IPs, OCIDs dos recursos criados).
|
||||
- SEMPRE declare `variable "region"` no variables.tf (o sistema injeta via terraform.tfvars).
|
||||
|
||||
### Nomes CORRETOS de recursos OCI (ERROS COMUNS)
|
||||
Abaixo estão os nomes corretos de resource types do provider oracle/oci. O modelo DEVE usar exatamente estes nomes:
|
||||
|
||||
- **Network Firewall**:
|
||||
- `oci_network_firewall_network_firewall` (NÃO `oci_network_firewall`)
|
||||
- `oci_network_firewall_network_firewall_policy` (NÃO `oci_network_firewall_policy`)
|
||||
- **DRG Attachment Management**:
|
||||
- `oci_core_drg_attachment_management` (NÃO `oci_core_vcn_drg_attachment_management`)
|
||||
- `oci_core_drg_attachment` — para criar attachments normais
|
||||
- **DRG Route Distribution Statement** (`oci_core_drg_route_distribution_statement`):
|
||||
- NÃO use `attachment_id` diretamente. Use o bloco `match_criteria`:
|
||||
```hcl
|
||||
match_criteria {
|
||||
match_type = "DRG_ATTACHMENT_ID"
|
||||
drg_attachment_id = oci_core_drg_attachment.example.id
|
||||
}
|
||||
```
|
||||
- **Remote Peering Connection**: `oci_core_remote_peering_connection`
|
||||
- **DRG Route Table**: `oci_core_drg_route_table`
|
||||
- **DRG Route Distribution**: `oci_core_drg_route_distribution`
|
||||
- **DRG Route Table Route Rule**: `oci_core_drg_route_table_route_rule`
|
||||
"""
|
||||
|
||||
# ── Terraform OCI Resource Reference (generated at build time, updatable at runtime) ──
|
||||
_TF_RESOURCE_REF_PATH = "/data/oci_tf_resource_reference.txt"
|
||||
_tf_resource_ref_cache: dict = {"content": None, "mtime": 0}
|
||||
|
||||
def _load_tf_resource_reference() -> str:
|
||||
"""Load the OCI Terraform resource reference file, cached by mtime."""
|
||||
try:
|
||||
p = Path(_TF_RESOURCE_REF_PATH)
|
||||
if not p.exists():
|
||||
return ""
|
||||
mtime = p.stat().st_mtime
|
||||
if _tf_resource_ref_cache["content"] and _tf_resource_ref_cache["mtime"] == mtime:
|
||||
return _tf_resource_ref_cache["content"]
|
||||
content = p.read_text()
|
||||
_tf_resource_ref_cache["content"] = content
|
||||
_tf_resource_ref_cache["mtime"] = mtime
|
||||
log.info(f"Loaded TF resource reference: {len(content)} chars, {content.count(chr(10))} lines")
|
||||
return content
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to load TF resource reference: {e}")
|
||||
return ""
|
||||
|
||||
def _regenerate_tf_reference() -> dict:
|
||||
"""Regenerate the OCI Terraform resource reference file."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["python", "/app/gen_tf_reference.py"],
|
||||
capture_output=True, text=True, timeout=300
|
||||
)
|
||||
_tf_resource_ref_cache["content"] = None # invalidate cache
|
||||
if result.returncode == 0:
|
||||
ref = _load_tf_resource_reference()
|
||||
return {"ok": True, "output": result.stdout.strip(), "lines": ref.count('\n')}
|
||||
return {"ok": False, "error": result.stderr.strip()}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
def _get_adb_connection(cfg: dict):
|
||||
"""Create an oracledb connection from an adb_vector_configs row."""
|
||||
import oracledb
|
||||
@@ -3120,6 +3188,25 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to inject terraform resource context: {e}")
|
||||
|
||||
# ── Inject OCI Terraform resource reference for correct resource names ──
|
||||
if agent_type == "terraform":
|
||||
tf_ref = _load_tf_resource_reference()
|
||||
if tf_ref:
|
||||
# Extract only the categorized sections (not the full 900+ resource list)
|
||||
# to keep prompt size manageable
|
||||
sections = []
|
||||
for line in tf_ref.split('\n'):
|
||||
if line.startswith('## All Resource Types'):
|
||||
break
|
||||
sections.append(line)
|
||||
ref_compact = '\n'.join(sections).strip()
|
||||
if ref_compact:
|
||||
cfg_dict["system_prompt"] = cfg_dict.get("system_prompt", "") + \
|
||||
"\n\n### Referência de Recursos OCI Terraform (gerado do provider schema)\n" + \
|
||||
"Use EXATAMENTE estes nomes de resource types. Se o recurso não estiver nesta lista, ele NÃO EXISTE no provider.\n\n" + \
|
||||
ref_compact
|
||||
log.info(f"Terraform: injected resource reference ({len(ref_compact)} chars)")
|
||||
|
||||
mcp_tools = []
|
||||
tool_defs = None
|
||||
if msg.use_tools:
|
||||
@@ -3676,6 +3763,26 @@ async def tf_delete_workspace(wid: str, u=Depends(current_user)):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/terraform/refresh-reference")
|
||||
async def tf_refresh_reference(u=Depends(current_user)):
|
||||
"""Regenerate the OCI Terraform resource reference from provider schema."""
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(_chat_executor, _regenerate_tf_reference)
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/terraform/reference-status")
|
||||
async def tf_reference_status(u=Depends(current_user)):
|
||||
"""Check if OCI Terraform resource reference is available."""
|
||||
p = Path(_TF_RESOURCE_REF_PATH)
|
||||
if p.exists():
|
||||
content = _load_tf_resource_reference()
|
||||
lines = content.count('\n') if content else 0
|
||||
mtime = datetime.fromtimestamp(p.stat().st_mtime).isoformat()
|
||||
return {"available": True, "lines": lines, "updated_at": mtime}
|
||||
return {"available": False}
|
||||
|
||||
|
||||
def _write_tf_files(wdir: Path, tf_code: str):
|
||||
"""Parse tf_code for '// filename: xxx.tf' markers and write separate files."""
|
||||
import re as _re
|
||||
@@ -3709,11 +3816,81 @@ async def _terraform_exec(wid: str, action: str, user: dict):
|
||||
wdir = TERRAFORM_DIR / wid
|
||||
wdir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Clean old .tf files (keep .terraform/, state, lock)
|
||||
for old_tf in wdir.glob("*.tf"):
|
||||
old_tf.unlink()
|
||||
|
||||
# Write .tf files — parse // filename: comments to split into separate files
|
||||
_write_tf_files(wdir, ws["tf_code"] or "")
|
||||
|
||||
# Auto-generate provider.tf from OCI config
|
||||
# Detect provider aliases declared by the model in generated files
|
||||
import re as _re2
|
||||
oci_cfg = _get_oci_config(ws["oci_config_id"])
|
||||
alias_blocks = [] # list of (alias_name, region_ref)
|
||||
|
||||
# Scan all .tf files for provider "oci" { alias = "..." ... region = ... }
|
||||
provider_block_re = _re2.compile(r'provider\s+"oci"\s*\{', _re2.MULTILINE)
|
||||
for tf_file in sorted(wdir.glob("*.tf")):
|
||||
if tf_file.name == "provider.tf":
|
||||
continue
|
||||
content = tf_file.read_text()
|
||||
# Find each provider "oci" block and extract alias + region
|
||||
blocks_to_remove = []
|
||||
for m in provider_block_re.finditer(content):
|
||||
start = m.start()
|
||||
# Find matching closing brace
|
||||
depth = 0
|
||||
end = start
|
||||
for ci in range(m.end() - 1, len(content)):
|
||||
if content[ci] == '{':
|
||||
depth += 1
|
||||
elif content[ci] == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = ci + 1
|
||||
break
|
||||
block = content[start:end]
|
||||
alias_m = _re2.search(r'alias\s*=\s*"([^"]+)"', block)
|
||||
region_m = _re2.search(r'region\s*=\s*(.+)', block)
|
||||
if alias_m:
|
||||
alias_name = alias_m.group(1)
|
||||
region_ref = region_m.group(1).strip().rstrip("}").strip() if region_m else '"unknown"'
|
||||
alias_blocks.append((alias_name, region_ref))
|
||||
blocks_to_remove.append((start, end))
|
||||
# Remove model-generated provider blocks (we centralize in provider.tf)
|
||||
if blocks_to_remove:
|
||||
new_content = content
|
||||
for s, e in reversed(blocks_to_remove):
|
||||
new_content = new_content[:s] + new_content[e:]
|
||||
# Also remove leading comments right before removed blocks
|
||||
new_content = _re2.sub(r'\n(//[^\n]*\n){1,5}\s*\n', '\n\n', new_content)
|
||||
new_content = new_content.strip()
|
||||
if new_content:
|
||||
tf_file.write_text(new_content + "\n")
|
||||
else:
|
||||
tf_file.unlink() # Remove empty file
|
||||
|
||||
# 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":
|
||||
continue
|
||||
content = tf_file.read_text()
|
||||
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"'))
|
||||
|
||||
cred_block = f''' tenancy_ocid = "{oci_cfg.get('tenancy', '')}"
|
||||
user_ocid = "{oci_cfg.get('user', '')}"
|
||||
fingerprint = "{oci_cfg.get('fingerprint', '')}"
|
||||
private_key_path = "{oci_cfg.get('key_file', '')}"'''
|
||||
|
||||
# Use region from OCI config row (may differ from oci sdk 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", "")
|
||||
|
||||
provider_tf = f'''terraform {{
|
||||
required_providers {{
|
||||
oci = {{
|
||||
@@ -3723,11 +3900,21 @@ async def _terraform_exec(wid: str, action: str, user: dict):
|
||||
}}
|
||||
|
||||
provider "oci" {{
|
||||
tenancy_ocid = "{oci_cfg.get('tenancy', '')}"
|
||||
user_ocid = "{oci_cfg.get('user', '')}"
|
||||
fingerprint = "{oci_cfg.get('fingerprint', '')}"
|
||||
private_key_path = "{oci_cfg.get('key_file', '')}"
|
||||
region = "{oci_cfg.get('region', '')}"
|
||||
{cred_block}
|
||||
region = "{primary_region}"
|
||||
}}
|
||||
'''
|
||||
# Add alias providers with same credentials
|
||||
seen_aliases = set()
|
||||
for alias_name, region_ref in alias_blocks:
|
||||
if alias_name in seen_aliases:
|
||||
continue
|
||||
seen_aliases.add(alias_name)
|
||||
provider_tf += f'''
|
||||
provider "oci" {{
|
||||
alias = "{alias_name}"
|
||||
{cred_block}
|
||||
region = {region_ref}
|
||||
}}
|
||||
'''
|
||||
(wdir / "provider.tf").write_text(provider_tf)
|
||||
|
||||
70
backend/gen_tf_reference.py
Normal file
70
backend/gen_tf_reference.py
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate OCI Terraform provider resource reference from provider schema."""
|
||||
import json, sys, subprocess, os
|
||||
|
||||
def main():
|
||||
schema_dir = "/tmp/tf_schema"
|
||||
os.makedirs(schema_dir, exist_ok=True)
|
||||
|
||||
# Write minimal tf config
|
||||
with open(f"{schema_dir}/main.tf", "w") as f:
|
||||
f.write('terraform {\n required_providers {\n oci = {\n source = "oracle/oci"\n }\n }\n}\n')
|
||||
|
||||
# Init
|
||||
subprocess.run(["terraform", f"-chdir={schema_dir}", "init", "-input=false", "-no-color"],
|
||||
capture_output=True, timeout=120)
|
||||
|
||||
# Get schema
|
||||
result = subprocess.run(["terraform", f"-chdir={schema_dir}", "providers", "schema", "-json"],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
data = json.loads(result.stdout)
|
||||
|
||||
oci = data["provider_schemas"].get("registry.terraform.io/oracle/oci", {})
|
||||
res = oci.get("resource_schemas", {})
|
||||
ds = oci.get("data_source_schemas", {})
|
||||
|
||||
important_prefixes = [
|
||||
"oci_core_", "oci_network_firewall_", "oci_network_load_balancer_",
|
||||
"oci_load_balancer_", "oci_database_", "oci_identity_",
|
||||
"oci_objectstorage_", "oci_containerengine_", "oci_functions_",
|
||||
"oci_dns_", "oci_file_storage_", "oci_kms_", "oci_vault_",
|
||||
"oci_bastion_", "oci_waf_", "oci_certificates_",
|
||||
]
|
||||
|
||||
lines = []
|
||||
lines.append("# OCI Terraform Provider — Resource Reference")
|
||||
lines.append(f"# Total: {len(res)} resources, {len(ds)} data sources")
|
||||
lines.append("")
|
||||
|
||||
for prefix in important_prefixes:
|
||||
cat_resources = {k: v for k, v in sorted(res.items()) if k.startswith(prefix)}
|
||||
if not cat_resources:
|
||||
continue
|
||||
cat_name = prefix.rstrip("_").replace("oci_", "").replace("_", " ").title()
|
||||
lines.append(f"## {cat_name}")
|
||||
for rname, rschema in cat_resources.items():
|
||||
attrs = rschema.get("block", {}).get("attributes", {})
|
||||
required = [a for a, v in attrs.items() if v.get("required")]
|
||||
block_types = list(rschema.get("block", {}).get("block_types", {}).keys())
|
||||
parts = [f"- **{rname}**"]
|
||||
if required:
|
||||
parts.append(f" required: {', '.join(required)}")
|
||||
if block_types:
|
||||
parts.append(f" blocks: {', '.join(block_types)}")
|
||||
lines.append("".join(parts))
|
||||
lines.append("")
|
||||
|
||||
# Also generate a flat list of ALL resource names for quick lookup
|
||||
lines.append("## All Resource Types (full list)")
|
||||
for rname in sorted(res.keys()):
|
||||
lines.append(f"- {rname}")
|
||||
|
||||
output = "/data/oci_tf_resource_reference.txt"
|
||||
with open(output, "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
print(f"Generated {len(lines)} lines -> {output}")
|
||||
print(f"Resources: {len(res)}, Data Sources: {len(ds)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -129,6 +129,7 @@ tbody tr:last-child td{border-bottom:none}
|
||||
.ch-hp-ttl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.ch-hp-del{opacity:0;transition:opacity .15s;cursor:pointer;color:var(--t4);flex-shrink:0;display:flex;align-items:center;padding:2px}
|
||||
.ch-hp-del:hover{color:var(--rd)}
|
||||
.ch-hp-time{font-size:.58rem;color:var(--t4);flex-shrink:0;font-variant-numeric:tabular-nums}
|
||||
.ch-hp-item:hover .ch-hp-del{opacity:1}
|
||||
|
||||
/* ── Chat ── */
|
||||
@@ -370,13 +371,34 @@ async function loadData(){try{
|
||||
loadHistory('chat');
|
||||
}catch(e){console.error(e)}}
|
||||
function R(){try{
|
||||
// Preserve textarea values before re-render
|
||||
// Preserve inputs
|
||||
const chiEl=document.getElementById('chi');if(chiEl)S._chiVal=chiEl.value;
|
||||
const tfiEl=document.getElementById('tfi');if(tfiEl)S._tfiVal=tfiEl.value;
|
||||
document.getElementById('app').innerHTML=S.user?rApp():rLogin();
|
||||
// Restore textarea values after re-render
|
||||
const edEl=document.getElementById('tfEdTa');if(edEl)S._tfEdVal=edEl.value;
|
||||
// Preserve scroll positions
|
||||
const scrollIds=['chm','tfchm','tfBottom','tfTermOut','pg'];
|
||||
const scrollMap={};
|
||||
scrollIds.forEach(id=>{const el=document.getElementById(id);if(el)scrollMap[id]=el.scrollTop});
|
||||
const bcLeft=document.querySelector('.tf-bc-left');if(bcLeft)scrollMap._bcLeft=bcLeft.scrollTop;
|
||||
|
||||
const appEl=document.getElementById('app');
|
||||
if(!S.user){appEl.innerHTML=rLogin();return}
|
||||
// Partial update: only update page content + sidebar
|
||||
const pgEl=document.getElementById('pg');
|
||||
if(pgEl){
|
||||
pgEl.innerHTML=rPg();
|
||||
const sbEl=document.querySelector('.nav');if(sbEl){const tmp=document.createElement('div');tmp.innerHTML=rSb();const nn=tmp.querySelector('.nav');if(nn)sbEl.innerHTML=nn.innerHTML}
|
||||
}else{
|
||||
appEl.innerHTML=rApp();
|
||||
}
|
||||
// Restore inputs
|
||||
const chi2=document.getElementById('chi');if(chi2&&S._chiVal){chi2.value=S._chiVal;autoGrow(chi2)}
|
||||
const tfi2=document.getElementById('tfi');if(tfi2&&S._tfiVal){tfi2.value=S._tfiVal;autoGrow(tfi2)}
|
||||
const ed2=document.getElementById('tfEdTa');if(ed2&&S._tfEdVal)ed2.value=S._tfEdVal;
|
||||
// Restore scroll positions
|
||||
requestAnimationFrame(()=>{
|
||||
Object.entries(scrollMap).forEach(([id,top])=>{if(id==='_bcLeft'){const el=document.querySelector('.tf-bc-left');if(el)el.scrollTop=top}else{const el=document.getElementById(id);if(el)el.scrollTop=top}});
|
||||
});
|
||||
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()}
|
||||
@@ -396,7 +418,7 @@ function rLogin(){return`<div class="lp"><div class="lc fi">
|
||||
<div style="text-align:center;margin-top:.85rem;font-size:.66rem;color:var(--t4)">Padrão: admin / admin123</div></div></div></div>`}
|
||||
|
||||
/* ── App Shell ── */
|
||||
function rApp(){return`<div class="app">${rSb()}<div class="mc">${rTb()}<div class="pc fi" id="pg">${rPg()}</div></div></div>`}
|
||||
function rApp(){return`<div class="app">${rSb()}<div class="mc">${rTb()}<div class="pc" 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(){
|
||||
@@ -589,7 +611,7 @@ async function loadSession(sid,type){
|
||||
if(type==='chat'){
|
||||
S.sid=sid;S.msgs=d.messages.map(m=>({r:m.role,c:m.content,t:m.created_at?.slice(11,16)||''}));R();scCh();
|
||||
}else{
|
||||
S.tfSid=sid;S.tfMsgs=d.messages.map(m=>({r:m.role,c:m.content}));
|
||||
S.tfSid=sid;S.tfMsgs=d.messages.map(m=>({r:m.role,c:m.content,t:m.created_at?.slice(11,16)||''}));
|
||||
S.tfFiles=[];S.tfCode='';S.tfPlan=[];
|
||||
const last=[...d.messages].reverse().find(m=>m.role==='assistant'&&m.content&&m.content.includes('```'));
|
||||
if(last)fmTf(last.content);
|
||||
@@ -628,8 +650,10 @@ function rHistList(type,hist,curSid){
|
||||
if(g!==lastGroup){lastGroup=g;html+=`<div class="ch-hp-grp">${g}</div>`}
|
||||
const active=s.id===curSid?' active':'';
|
||||
const title=s.title||'Nova conversa';
|
||||
const ts=s.updated_at?new Date(s.updated_at+'Z').toLocaleTimeString('pt-BR',{hour:'2-digit',minute:'2-digit'}):'';
|
||||
html+=`<div class="ch-hp-item${active}" onclick="loadSession('${s.id}','${type}')">
|
||||
<span class="ch-hp-ttl" title="${title.replace(/"/g,'"')}">${escHtml(title)}</span>
|
||||
${ts?`<span class="ch-hp-time">${ts}</span>`:''}
|
||||
<span class="ch-hp-del" onclick="event.stopPropagation();delSession('${s.id}','${type}')" title="Excluir">
|
||||
<svg viewBox="0 0 16 16" width="11" height="11" fill="currentColor"><path d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"/><path fill-rule="evenodd" d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118z"/></svg>
|
||||
</span>
|
||||
@@ -699,7 +723,7 @@ function tfCopyBlock(i){const b=S.tfFiles[i];if(b)navigator.clipboard.writeText(
|
||||
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('');
|
||||
:S.tfMsgs.map(m=>`<div class="cm cm-${m.r}"><div class="cb">${m.r==='assistant'?fmTf(m.c):fm(m.c)}</div>${m.t?`<div class="cm-ts">${m.t}</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})}
|
||||
@@ -728,8 +752,8 @@ function rTerraform(){
|
||||
<div class="tf-toolbar">
|
||||
<div class="ch-icon-btn${S.tfHistOpen?' active':''}" onclick="S.tfHistOpen=!S.tfHistOpen;if(S.tfHistOpen)loadHistory('terraform');R()" title="Histórico" style="width:24px;height:24px;font-size:.85rem">☰</div>
|
||||
${TF_ICON}
|
||||
<div class="mdrop"><div class="mdrop-btn" onclick="S.tfMdOpen=!S.tfMdOpen;R()">${curLabel} <span class="arr">▼</span></div>
|
||||
${S.tfMdOpen?`<div class="mdrop-dd open"><input type="text" id="tfmds" placeholder="Buscar..." oninput="tfFilterModels(this.value)"><div class="mdrop-list" id="tfmdl">${ddItems}</div></div>`:''}
|
||||
<div class="mdrop"><div class="mdrop-btn" onclick="tfToggleModelDrop()">${curLabel} <span class="arr">▼</span></div>
|
||||
<div class="mdrop-dd" id="tfmdd"><input type="text" id="tfmds" placeholder="Buscar..." oninput="tfFilterModels(this.value)"><div class="mdrop-list" id="tfmdl">${ddItems}</div></div>
|
||||
</div>
|
||||
${ociSel}${compSel}
|
||||
${stBadge}
|
||||
@@ -746,15 +770,15 @@ function rTerraform(){
|
||||
<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()">
|
||||
<div class="tf-btab${S.tfBtab==='files'?' active':''}" onclick="tfSwitchBtab('files')">
|
||||
<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()">
|
||||
<div class="tf-btab${S.tfBtab==='plan'?' active':''}" onclick="tfSwitchBtab('plan')">
|
||||
<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()">
|
||||
<div class="tf-btab${S.tfBtab==='resources'?' active':''}" onclick="tfSwitchBtab('resources')">
|
||||
<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>
|
||||
@@ -964,6 +988,16 @@ async function ociStartInstance(id){if(!confirm('Iniciar esta instância?'))retu
|
||||
async function ociStopInstance(id){if(!confirm('Parar esta instância?'))return;try{await $api('/oci/instances/'+id+'/action',{method:'POST',body:{action:'STOP',oci_config_id:_ociResOid(),region:S.tfRegion||null}});alert('Instância parando...');tfLoadResources()}catch(e){alert('Erro: '+e.message)}}
|
||||
async function ociStartAdb(id){if(!confirm('Iniciar este Autonomous Database?'))return;try{await $api('/oci/autonomous-databases/'+id+'/action',{method:'POST',body:{action:'start',oci_config_id:_ociResOid(),region:S.tfRegion||null}});alert('Autonomous DB iniciando...');tfLoadResources()}catch(e){alert('Erro: '+e.message)}}
|
||||
async function ociStopAdb(id){if(!confirm('Parar este Autonomous Database?'))return;try{await $api('/oci/autonomous-databases/'+id+'/action',{method:'POST',body:{action:'stop',oci_config_id:_ociResOid(),region:S.tfRegion||null}});alert('Autonomous DB parando...');tfLoadResources()}catch(e){alert('Erro: '+e.message)}}
|
||||
function tfSwitchBtab(tab){
|
||||
S.tfBtab=tab;S.tfEditIdx=-1;
|
||||
if(tab==='resources'&&!S.tfResources&&S.tfCompartment)tfLoadResources();
|
||||
// Update only bottom panel content + tab active states
|
||||
const left=document.querySelector('.tf-bc-left');if(left)left.innerHTML=rTfBottomContent();
|
||||
const tabs=document.querySelectorAll('.tf-btab');tabs.forEach(t=>{const isActive=t.textContent.trim().toLowerCase().startsWith(tab);t.classList.toggle('active',isActive)});
|
||||
// Update actions
|
||||
const acts=document.querySelector('.tf-bottom-actions');if(acts){acts.innerHTML=(S.tfFiles.length?'<button class="btn bs bsm" onclick="tfDlAll()" style="font-size:.66rem;padding:2px 8px">Download All</button>':'')+rTfActions()}
|
||||
}
|
||||
function tfToggleModelDrop(){const dd=document.getElementById('tfmdd');dd.classList.toggle('open');S.tfMdOpen=dd.classList.contains('open');if(S.tfMdOpen){const inp=document.getElementById('tfmds');if(inp){inp.value='';inp.focus();tfFilterModels('')}}}
|
||||
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.tfFiles=[];S.tfWs=null;S.tfPlanOut='';S.tfApplyOut='';S.tfDestroyOut='';S.tfStatus='draft';S.tfRunning=false;S.tfConfirmDest=false;S.tfComps=[];S.tfCompartment='';S.tfResources=null;S.tfResLoading=false;R()}
|
||||
@@ -973,35 +1007,35 @@ function dlTfCode(){if(!S.tfFiles.length)return;if(S.tfFiles.length===1){const f
|
||||
|
||||
async function tfSend(){
|
||||
const el=document.getElementById('tfi');const m=el.value.trim();if(!m)return;el.style.height='auto';S._tfiVal='';
|
||||
if(!S.tfModel){S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione um modelo antes de enviar.'});R();return}
|
||||
if(!S.tfCompartment){S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione um compartment antes de enviar.'});R();return}
|
||||
el.value='';S.tfMsgs.push({r:'user',c:m});R();tfScroll();
|
||||
if(!S.tfModel){S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione um modelo antes de enviar.',t:tstamp()});R();return}
|
||||
if(!S.tfCompartment){S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione um compartment antes de enviar.',t:tstamp()});R();return}
|
||||
el.value='';S.tfMsgs.push({r:'user',c:m,t:tstamp()});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}
|
||||
if(!S.tfOci){S.tfMsgs.pop();S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione uma credencial OCI.',t:tstamp()});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(S.tfHistOpen)loadHistory('terraform');
|
||||
if(d.status==='processing'&&d.message_id){await tfPollResult(d.message_id)}
|
||||
else{S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:d.response||d.content||'Sem resposta'});R();tfScroll()}
|
||||
}catch(e){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'❌ Erro: '+e.message});R()}
|
||||
else{S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:d.response||d.content||'Sem resposta',t:tstamp()});R();tfScroll()}
|
||||
}catch(e){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'❌ Erro: '+e.message,t:tstamp()});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.status==='done'){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:r.content,t:tstamp()});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}
|
||||
if(r.status==='failed'){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'❌ '+r.content,t:tstamp()});R();tfScroll();return}
|
||||
}catch(e){}
|
||||
}
|
||||
S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'⏰ Timeout.'});R()
|
||||
S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'⏰ Timeout.',t:tstamp()});R()
|
||||
}
|
||||
|
||||
async function tfSaveAndPlan(){
|
||||
|
||||
Reference in New Issue
Block a user