From 18fabec805306cef2c0d63d0f2b34333bb37232d Mon Sep 17 00:00:00 2001 From: nogueiraguh Date: Thu, 12 Mar 2026 11:24:38 -0300 Subject: [PATCH] feat: Explorer KPI stats, enhanced views, start/stop expansion, Prompt Generator docs injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Explorer: KPI stats bar with cached counts (SQLite), background refresh, enhanced views for VCNs/Subnets/LBs/Buckets, start/stop for DB Systems, MySQL, Container Instances, dead state filtering across all 36 endpoints - Prompt Generator: same knowledge pipeline as Terraform Agent — resource type detection + official docs from GitHub + compact resource reference - Fix: NetworkFirewallPolicySummaryCollection not iterable (.items accessor) --- README.md | 39 ++++-- backend/app.py | 282 ++++++++++++++++++++++++++++++++++++++------ frontend/index.html | 151 ++++++++++++++++++++++-- 3 files changed, 416 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 7c3f7cc..0d764e1 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- Version + Version Python FastAPI OCI @@ -80,7 +80,8 @@ The platform combines security compliance scanning, AI-powered chat with **RAG ( - **AI-powered prompt generation** for OCI infrastructure — generates structured, optimized prompts to feed into the Terraform Agent - Dedicated chat interface (sub-menu under Terraform) matching Terraform Agent layout - **Curated model selection**: only 7 recommended models (GPT-4.1, o3, o4-mini, GPT-5.1, GPT-5.2, Gemini 2.5 Pro, Gemini 2.5 Flash), grouped by provider -- **OCI Terraform Resource Reference**: prompt generation includes the full OCI provider resource catalog as context, ensuring accurate resource names and attributes +- **Same knowledge pipeline as Terraform Agent**: compact resource reference + auto-detected resource types + official docs from GitHub (cached in SQLite) +- **Official Terraform docs injection**: detects resource types from user message (keywords + explicit `oci_*` mentions), fetches Example Usage + Argument Reference from GitHub, cached in `tf_resource_docs` table - **Conversation history**: persistent sessions (agent_type `tf-prompt`) with sidebar history panel, rename/delete, session restore - **Contextual follow-up**: conversation history sent to GenAI for iterative prompt refinement - **Copy & Send**: action buttons on assistant messages to copy the prompt or send directly to the Terraform Agent input @@ -90,6 +91,7 @@ The platform combines security compliance scanning, AI-powered chat with **RAG ( ### ⚡ OCI Resource Actions - **Start/Stop Compute Instances** directly from OCI Account Explorer with one click - **Start/Stop Autonomous Databases** from Explorer with confirmation prompt +- **Start/Stop DB Systems, MySQL, Container Instances** — 5 resource types with operational control - **Update Network Access**: add your IP to ADB ACL (whitelisted_ips) directly from Explorer - **ACL display**: view current whitelisted IPs for Autonomous Databases in Explorer cards - **Auto-refresh**: resource state updates automatically after Start/Stop actions (polling) @@ -97,18 +99,23 @@ The platform combines security compliance scanning, AI-powered chat with **RAG ( - Actions audited in the audit log ### 🔍 OCI Account Explorer -- **Tree-view navigation** with resizable side panel and drag handle -- **40+ resource types** across 8 categories: - - **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 +- **KPI stats bar**: real-time resource counts per category with tooltip breakdown, cached in SQLite for instant loading +- **Tree-view navigation** with resizable side panel (280px default) and drag handle +- **36 resource types** across 9 categories: + - **Compute**: Instances, Boot Volumes, Instance Pools + - **Networking**: VCNs, Subnets, Security Lists, NSGs, Route Tables, NAT/Internet/Service Gateways, Public IPs, Load Balancers + - **Storage**: Buckets, Block Volumes, 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 + - **Containers**: Container Instances, OKE Clusters + - **Serverless**: Functions, API Gateways + - **Observability**: Alarms, Log Groups, Notification Topics, Events Rules + - **Security**: Vaults, DNS Zones, Network Firewalls, Firewall Policies + - **IAM**: Users, Groups, Policies, Dynamic Groups +- **Enhanced views**: expandable cards for VCNs (CIDRs, DNS), Subnets (AD, route table), Load Balancers (listeners, backends), Buckets (size, object count, visibility) - **Multi-region support**: select and filter by multiple OCI regions with checkbox selection - **Compartment tree**: hierarchical compartment browser with full OCID visibility +- **Dead state filtering**: automatically hides TERMINATED/DELETED/DELETING resources +- **Background count refresh**: counts cached in SQLite, refreshed on compartment access without blocking UI - **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 @@ -411,14 +418,14 @@ Allow group to read buckets in compartment ``` oci-cis-agent/ ├── backend/ -│ ├── app.py # FastAPI application (~5500 lines) +│ ├── app.py # FastAPI application (~5900 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 (~2950 lines) +│ └── index.html # SPA with Oracle Dark Premium theme (~3200 lines) ├── nginx/ │ └── default.conf # Reverse proxy config ├── docker-compose.yml # Orchestration @@ -494,6 +501,8 @@ oci-cis-agent/ | 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 | +| GET | `/api/oci/explore/{id}/counts` | Get cached resource counts (instant from SQLite) | +| POST | `/api/oci/explore/{id}/counts/refresh` | Refresh all resource counts in background | ### OCI Resource Actions @@ -501,6 +510,9 @@ oci-cis-agent/ |--------|----------|-------------| | POST | `/api/oci/instances/{id}/action` | Start/Stop compute instance | | POST | `/api/oci/autonomous-databases/{id}/action` | Start/Stop Autonomous Database | +| POST | `/api/oci/db-systems/{id}/action` | Start/Stop DB System | +| POST | `/api/oci/mysql-db-systems/{id}/action` | Start/Stop MySQL DB System | +| POST | `/api/oci/container-instances/{id}/action` | Start/Stop Container Instance | ### Generative AI @@ -685,6 +697,7 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the " | Version | Date | Changes | |---------|------|---------| +| **v2.5** | 2026-03 | **Explorer Expansion**: KPI stats bar with per-category resource counts (cached in SQLite `explorer_counts_cache`, background refresh on compartment access), enhanced views for VCNs/Subnets/Load Balancers/Buckets with expandable cards, start/stop for DB Systems + MySQL + Container Instances (5 resource types total), dead state filtering (`TERMINATED`/`DELETED`/`DELETING` hidden across all 36 endpoints), wider compartment tree (280px), IAM excluded from compartment totals. **Prompt Generator Intelligence**: same knowledge pipeline as Terraform Agent — auto-detects resource types from user message, fetches official docs (Example Usage + Arguments) from GitHub cached in SQLite, uses compact categorized resource reference. **Network Firewall fix**: `NetworkFirewallPolicySummaryCollection` not iterable — added `.items` accessor for collection objects. | | **v2.4** | 2026-03 | **Terraform Safety & UX**: region safety guard (plan blocked if model omits `variable "region"` — prevents wrong-region provisioning), rollback button on apply failure (manual `terraform destroy` with "ROLLBACK" confirmation), SSH public key field in OCI configs (auto-injected into `terraform.tfvars` for compute instances), reasoning_effort uppercase fix for OCI SDK, split-panel layout (terminal right 40%, files/plan/resources below chat 60%), plan button persists after destroy. **Terraform tfvars**: expanded `oci_var_map` with `ssh_public_key`/`ssh_authorized_keys` auto-mapping from OCI config | | **v2.3** | 2026-03 | **Consult Embeddings**: dedicated sub-menu with chat-like interface for natural language Q&A against vector store — queries all active tables via cosine similarity, returns contextual answers with source citations (works with or without GenAI config). **Knowledge Base & RAG Enhancements**: Knowledge Base (Base de Conhecimento) with file upload + URL import to `engineerknowledgebase` vector table, CIS Recommendations upload to `cisrecom` table, URL content extraction (HTML tag/script stripping, remote PDF parsing), auto-resolve embedding config from OCI credentials (no separate GenAI config required), `report_date` metadata tracking for embedding freshness, purge & re-embed flow for updated reports. **ADB Vector Improvements**: case-insensitive table matching with quoted identifiers for Oracle ADB, table validation endpoint against `user_tables`, tables section moved inside edit connection card, case-preserving table registration (removed forced uppercase). **Vector search fix**: FLOAT32 compatibility for VECTOR_DISTANCE queries, base64-decoded compartment_id for auto-resolved OCI configs. **Embeddings tab cleanup**: removed delete buttons, simplified to read-only view, updated descriptions. **Dependencies**: added PyPDF2 for PDF text extraction | | **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 | diff --git a/backend/app.py b/backend/app.py index 98e47d1..92e3860 100644 --- a/backend/app.py +++ b/backend/app.py @@ -336,6 +336,16 @@ def init_db(): created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS explorer_counts_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + oci_config_id TEXT NOT NULL, + compartment_id TEXT NOT NULL, + resource_type TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + regions TEXT DEFAULT '', + updated_at TEXT DEFAULT (datetime('now')), + UNIQUE(oci_config_id, compartment_id, resource_type, regions) + ); CREATE TABLE IF NOT EXISTS tf_valid_types ( name TEXT PRIMARY KEY, kind TEXT NOT NULL DEFAULT 'resource', @@ -825,12 +835,89 @@ async def explore_regions(cid: str, u=Depends(current_user)): except Exception as e: return {"error": str(e)[:500]} +_DEAD_STATES = {'TERMINATED','TERMINATING','DELETED','DELETING','PENDING_DELETION','FAULTY'} + +_EXP_RESOURCE_TYPES = [ + 'instances','boot_volumes','instance_pools', + 'vcns','subnets','security_lists','nsgs','route_tables','nat_gateways', + 'internet_gateways','service_gateways','public_ips','load_balancers', + 'buckets','block_volumes','file_systems','mount_targets', + 'databases','db_systems','mysql_db_systems', + 'container_instances','oke_clusters', + 'functions','api_gateways', + 'alarms','log_groups','notification_topics','events_rules', + 'vaults','dns_zones','network_firewalls','network_firewall_policies', + 'iam_users','iam_groups','iam_policies','iam_dynamic_groups', +] + def _explore_comp(cid): """Helper: resolve compartment from config.""" with db() as c: cfg = c.execute("SELECT tenancy_ocid,compartment_id FROM oci_configs WHERE id=?", (cid,)).fetchone() return cfg, _safe_dec(cfg["compartment_id"]) or _safe_dec(cfg["tenancy_ocid"]) +# ── Explorer Counts Cache ───────────────────────────────────────────────────── +@app.get("/api/oci/explore/{cid}/counts") +async def explore_counts_cached(cid: str, compartment_id: str = Query(...), regions: str = Query(""), u=Depends(current_user)): + """Return cached resource counts from DB (instant).""" + with db() as c: + rows = c.execute( + "SELECT resource_type, count, updated_at FROM explorer_counts_cache WHERE oci_config_id=? AND compartment_id=? AND regions=?", + (cid, compartment_id, regions)).fetchall() + return {r["resource_type"]: {"count": r["count"], "updated_at": r["updated_at"]} for r in rows} + +@app.post("/api/oci/explore/{cid}/counts/refresh") +async def explore_counts_refresh(cid: str, req: dict, u=Depends(current_user)): + """Refresh all resource counts in background thread and save to DB.""" + compartment_id = req.get("compartment_id") + regions_list = req.get("regions", []) + if not compartment_id: + raise HTTPException(400, "compartment_id required") + regions_key = ",".join(sorted(regions_list)) if regions_list else "" + import threading + def _do_refresh(): + import httpx, asyncio + _exp_fn_map = {} # will call explore endpoints via internal dispatch + results = {} + for rt in _EXP_RESOURCE_TYPES: + fn_name = f"explore_{rt}" + fn = globals().get(fn_name) + if not fn: + results[rt] = 0 + continue + try: + loop = asyncio.new_event_loop() + regs = regions_list if regions_list else [None] + total = 0 + for reg in regs: + # IAM endpoints don't take compartment_id/region + if rt.startswith('iam_') and rt != 'iam_policies': + data = loop.run_until_complete(fn(cid, u=u)) + elif rt == 'iam_policies': + data = loop.run_until_complete(fn(cid, compartment_id=compartment_id, u=u)) + else: + data = loop.run_until_complete(fn(cid, compartment_id=compartment_id, region=reg, u=u)) + if isinstance(data, list): + total += len(data) + elif isinstance(data, dict) and not data.get("error"): + total += 0 + results[rt] = total + loop.close() + except Exception as e: + log.warning(f"Explorer count refresh error for {rt}: {e}") + results[rt] = 0 + # Save to DB + with db() as c: + for rt, cnt in results.items(): + c.execute("""INSERT INTO explorer_counts_cache (oci_config_id, compartment_id, resource_type, count, regions, updated_at) + VALUES (?,?,?,?,?,datetime('now')) + ON CONFLICT(oci_config_id, compartment_id, resource_type, regions) + DO UPDATE SET count=excluded.count, updated_at=datetime('now')""", + (cid, compartment_id, rt, cnt, regions_key)) + log.info(f"Explorer counts refreshed for config={cid} comp={compartment_id[:20]}... total_types={len(results)}") + threading.Thread(target=_do_refresh, daemon=True).start() + return {"status": "refreshing", "types": len(_EXP_RESOURCE_TYPES)} + @app.get("/api/oci/explore/{cid}/compartment-tree") async def explore_compartment_tree(cid: str, u=Depends(current_user)): try: @@ -884,7 +971,10 @@ async def explore_vcns(cid: str, compartment_id: str = Query(None), region: str _, default_comp = _explore_comp(cid) comp = compartment_id or default_comp vcns = vn.list_vcns(comp).data - return [{"id":v.id,"display_name":v.display_name,"cidr_blocks":v.cidr_blocks,"lifecycle_state":v.lifecycle_state} for v in vcns] + return [{"id":v.id,"display_name":v.display_name,"cidr_blocks":v.cidr_blocks,"lifecycle_state":v.lifecycle_state, + "dns_label":v.dns_label,"vcn_domain_name":v.vcn_domain_name, + "default_route_table_id":v.default_route_table_id,"default_security_list_id":v.default_security_list_id, + "time_created":str(v.time_created)} for v in vcns if v.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -898,7 +988,7 @@ async def explore_instances(cid: str, compartment_id: str = Query(None), region: _, default_comp = _explore_comp(cid) comp = compartment_id or default_comp insts = compute.list_instances(comp).data - return [{"id":i.id,"display_name":i.display_name,"shape":i.shape,"lifecycle_state":i.lifecycle_state,"region":i.region,"time_created":str(i.time_created)} for i in insts] + return [{"id":i.id,"display_name":i.display_name,"shape":i.shape,"lifecycle_state":i.lifecycle_state,"region":i.region,"time_created":str(i.time_created)} for i in insts if i.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -914,7 +1004,7 @@ async def explore_databases(cid: str, compartment_id: str = Query(None), region: adbs = db_client.list_autonomous_databases(comp).data return [{"id":a.id,"display_name":a.display_name,"db_name":a.db_name,"lifecycle_state":a.lifecycle_state, "cpu_core_count":a.cpu_core_count,"data_storage_size_in_tbs":a.data_storage_size_in_tbs, - "is_free_tier":a.is_free_tier,"whitelisted_ips":a.whitelisted_ips or []} for a in adbs] + "is_free_tier":a.is_free_tier,"whitelisted_ips":a.whitelisted_ips or []} for a in adbs if a.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -928,8 +1018,11 @@ async def explore_buckets(cid: str, compartment_id: str = Query(None), region: s namespace = os_client.get_namespace().data _, default_comp = _explore_comp(cid) comp = compartment_id or default_comp - buckets = os_client.list_buckets(namespace, comp).data - return [{"name":b.name,"namespace":b.namespace,"time_created":str(b.time_created)} for b in buckets] + buckets = os_client.list_buckets(namespace, comp, fields=["approximateSize","approximateCount"]).data + return [{"name":b.name,"namespace":b.namespace,"time_created":str(b.time_created), + "approximate_size":b.approximate_size,"approximate_count":b.approximate_count, + "public_access_type":b.public_access_type or "NoPublicAccess", + "storage_tier":b.storage_tier,"versioning":b.versioning} for b in buckets] except Exception as e: return {"error": str(e)[:500]} @@ -944,7 +1037,10 @@ async def explore_subnets(cid: str, compartment_id: str = Query(None), region: s comp = compartment_id or default_comp subs = vn.list_subnets(comp).data return [{"id":s.id,"display_name":s.display_name,"cidr_block":s.cidr_block,"vcn_id":s.vcn_id, - "lifecycle_state":s.lifecycle_state,"prohibit_public_ip_on_vnic":s.prohibit_public_ip_on_vnic} for s in subs] + "lifecycle_state":s.lifecycle_state,"prohibit_public_ip_on_vnic":s.prohibit_public_ip_on_vnic, + "availability_domain":s.availability_domain or "Regional","dns_label":s.dns_label, + "route_table_id":s.route_table_id,"security_list_ids":s.security_list_ids, + "subnet_domain_name":s.subnet_domain_name,"time_created":str(s.time_created)} for s in subs if s.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -973,6 +1069,7 @@ async def explore_security_lists(cid: str, compartment_id: str = Query(None), re "stateless":r.is_stateless,"description":getattr(r,'description','') or "","rule_index":idx} result = [] for s in sls: + if s.lifecycle_state in _DEAD_STATES: continue ingress = [_fmt_rule(r,"ingress",i) for i,r in enumerate(s.ingress_security_rules or [])] egress = [_fmt_rule(r,"egress",i) for i,r in enumerate(s.egress_security_rules or [])] result.append({"id":s.id,"display_name":s.display_name,"vcn_id":s.vcn_id,"lifecycle_state":s.lifecycle_state, @@ -1073,7 +1170,7 @@ async def explore_block_volumes(cid: str, compartment_id: str = Query(None), reg comp = compartment_id or default_comp vols = bs.list_volumes(compartment_id=comp).data return [{"id":v.id,"display_name":v.display_name,"size_in_gbs":v.size_in_gbs, - "lifecycle_state":v.lifecycle_state,"vpus_per_gb":v.vpus_per_gb} for v in vols] + "lifecycle_state":v.lifecycle_state,"vpus_per_gb":v.vpus_per_gb} for v in vols if v.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1087,8 +1184,21 @@ async def explore_load_balancers(cid: str, compartment_id: str = Query(None), re _, default_comp = _explore_comp(cid) comp = compartment_id or default_comp lbs = lb.list_load_balancers(comp).data - return [{"id":l.id,"display_name":l.display_name,"lifecycle_state":l.lifecycle_state, - "shape_name":l.shape_name,"ip_addresses":[ip.ip_address for ip in (l.ip_addresses or [])]} for l in lbs] + result = [] + for l in lbs: + if l.lifecycle_state in _DEAD_STATES: continue + listeners = [] + for name, lis in (l.listeners or {}).items(): + listeners.append({"name": name, "port": lis.port, "protocol": lis.protocol, + "backend_set": lis.default_backend_set_name}) + backend_sets = [] + for name, bs in (l.backend_sets or {}).items(): + backends = [{"ip": b.ip_address, "port": b.port, "weight": b.weight} for b in (bs.backends or [])] + backend_sets.append({"name": name, "backends": backends, "policy": bs.policy}) + result.append({"id":l.id,"display_name":l.display_name,"lifecycle_state":l.lifecycle_state, + "shape_name":l.shape_name,"ip_addresses":[ip.ip_address for ip in (l.ip_addresses or [])], + "is_private":l.is_private,"listeners":listeners,"backend_sets":backend_sets}) + return result except Exception as e: return {"error": str(e)[:500]} @@ -1104,8 +1214,10 @@ async def explore_functions(cid: str, compartment_id: str = Query(None), region: apps = fn.list_applications(comp).data result = [] for a in apps: + if a.lifecycle_state in _DEAD_STATES: continue fns = fn.list_functions(a.id).data for f in fns: + if f.lifecycle_state in _DEAD_STATES: continue result.append({"id":f.id,"display_name":f.display_name,"application":a.display_name, "lifecycle_state":f.lifecycle_state,"memory_in_mbs":f.memory_in_mbs, "timeout_in_seconds":f.timeout_in_seconds,"image":f.image}) @@ -1125,7 +1237,7 @@ async def explore_container_instances(cid: str, compartment_id: str = Query(None cis = ci.list_container_instances(comp).data return [{"id":c.id,"display_name":c.display_name,"lifecycle_state":c.lifecycle_state, "container_count":c.container_count,"shape":c.shape, - "time_created":str(c.time_created)} for c in cis] + "time_created":str(c.time_created)} for c in cis if c.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1142,6 +1254,7 @@ async def explore_nsgs(cid: str, compartment_id: str = Query(None), region: str result = [] proto_map = {"1": "ICMP", "6": "TCP", "17": "UDP", "all": "ALL"} for n in nsgs: + if n.lifecycle_state in _DEAD_STATES: continue rules_data = oci.pagination.list_call_get_all_results( vn.list_network_security_group_security_rules, n.id).data rules = [] @@ -1242,6 +1355,7 @@ async def explore_route_tables(cid: str, compartment_id: str = Query(None), regi rts = vn.list_route_tables(comp).data result = [] for rt in rts: + if rt.lifecycle_state in _DEAD_STATES: continue routes = [] for r in (rt.route_rules or []): target_type = r.network_entity_id.split(".")[1] if r.network_entity_id and "." in r.network_entity_id else "unknown" @@ -1265,7 +1379,7 @@ async def explore_nat_gateways(cid: str, compartment_id: str = Query(None), regi comp = compartment_id or default_comp ngs = vn.list_nat_gateways(comp).data return [{"id":n.id,"display_name":n.display_name,"vcn_id":n.vcn_id,"lifecycle_state":n.lifecycle_state, - "nat_ip":n.nat_ip,"block_traffic":n.block_traffic} for n in ngs] + "nat_ip":n.nat_ip,"block_traffic":n.block_traffic} for n in ngs if n.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1280,7 +1394,7 @@ async def explore_internet_gateways(cid: str, compartment_id: str = Query(None), comp = compartment_id or default_comp igs = vn.list_internet_gateways(comp).data return [{"id":g.id,"display_name":g.display_name,"vcn_id":g.vcn_id,"lifecycle_state":g.lifecycle_state, - "is_enabled":g.is_enabled} for g in igs] + "is_enabled":g.is_enabled} for g in igs if g.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1295,7 +1409,7 @@ async def explore_service_gateways(cid: str, compartment_id: str = Query(None), comp = compartment_id or default_comp sgs = vn.list_service_gateways(comp).data return [{"id":g.id,"display_name":g.display_name,"vcn_id":g.vcn_id,"lifecycle_state":g.lifecycle_state, - "services":[s.service_name for s in (g.services or [])]} for g in sgs] + "services":[s.service_name for s in (g.services or [])]} for g in sgs if g.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1311,7 +1425,7 @@ async def explore_public_ips(cid: str, compartment_id: str = Query(None), region ips = vn.list_public_ips(scope="REGION", compartment_id=comp).data return [{"id":ip.id,"display_name":ip.display_name or "—","ip_address":ip.ip_address, "lifecycle_state":ip.lifecycle_state,"lifetime":ip.lifetime, - "assigned_entity_type":ip.assigned_entity_type} for ip in ips] + "assigned_entity_type":ip.assigned_entity_type} for ip in ips if ip.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1330,6 +1444,7 @@ async def explore_boot_volumes(cid: str, compartment_id: str = Query(None), regi for ad in ads: bvs = bs.list_boot_volumes(availability_domain=ad.name, compartment_id=comp).data for v in bvs: + if v.lifecycle_state in _DEAD_STATES: continue result.append({"id":v.id,"display_name":v.display_name,"size_in_gbs":v.size_in_gbs, "lifecycle_state":v.lifecycle_state,"availability_domain":ad.name, "vpus_per_gb":v.vpus_per_gb}) @@ -1348,7 +1463,7 @@ async def explore_instance_pools(cid: str, compartment_id: str = Query(None), re comp = compartment_id or default_comp pools = cm.list_instance_pools(comp).data return [{"id":p.id,"display_name":p.display_name,"lifecycle_state":p.lifecycle_state, - "size":p.size,"time_created":str(p.time_created)} for p in pools] + "size":p.size,"time_created":str(p.time_created)} for p in pools if p.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1364,7 +1479,7 @@ async def explore_oke_clusters(cid: str, compartment_id: str = Query(None), regi clusters = ce.list_clusters(comp).data return [{"id":c.id,"name":c.name,"lifecycle_state":c.lifecycle_state, "kubernetes_version":c.kubernetes_version,"vcn_id":c.vcn_id, - "endpoint_config":c.endpoint_config.is_public_ip_enabled if c.endpoint_config else None} for c in clusters] + "endpoint_config":c.endpoint_config.is_public_ip_enabled if c.endpoint_config else None} for c in clusters if c.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1383,6 +1498,7 @@ async def explore_file_systems(cid: str, compartment_id: str = Query(None), regi for ad in ads: fs_list = fss.list_file_systems(comp, ad.name).data for f in fs_list: + if f.lifecycle_state in _DEAD_STATES: continue result.append({"id":f.id,"display_name":f.display_name,"lifecycle_state":f.lifecycle_state, "availability_domain":ad.name,"metered_bytes":f.metered_bytes, "time_created":str(f.time_created)}) @@ -1405,6 +1521,7 @@ async def explore_mount_targets(cid: str, compartment_id: str = Query(None), reg for ad in ads: mts = fss.list_mount_targets(comp, ad.name).data for m in mts: + if m.lifecycle_state in _DEAD_STATES: continue result.append({"id":m.id,"display_name":m.display_name,"lifecycle_state":m.lifecycle_state, "availability_domain":ad.name,"subnet_id":m.subnet_id, "private_ip_ids":m.private_ip_ids}) @@ -1424,7 +1541,7 @@ async def explore_db_systems(cid: str, compartment_id: str = Query(None), region dbs = db_client.list_db_systems(comp).data return [{"id":d.id,"display_name":d.display_name,"lifecycle_state":d.lifecycle_state, "shape":d.shape,"database_edition":d.database_edition, - "cpu_core_count":d.cpu_core_count,"node_count":d.node_count} for d in dbs] + "cpu_core_count":d.cpu_core_count,"node_count":d.node_count} for d in dbs if d.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1440,7 +1557,7 @@ async def explore_mysql_db_systems(cid: str, compartment_id: str = Query(None), dbs = mysql.list_db_systems(comp).data return [{"id":d.id,"display_name":d.display_name,"lifecycle_state":d.lifecycle_state, "shape_name":d.shape_name,"mysql_version":d.mysql_version, - "is_highly_available":d.is_highly_available} for d in dbs] + "is_highly_available":d.is_highly_available} for d in dbs if d.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1455,7 +1572,7 @@ async def explore_alarms(cid: str, compartment_id: str = Query(None), region: st comp = compartment_id or default_comp alarms = mon.list_alarms(comp).data return [{"id":a.id,"display_name":a.display_name,"lifecycle_state":a.lifecycle_state, - "severity":a.severity,"namespace":a.namespace,"is_enabled":a.is_enabled} for a in alarms] + "severity":a.severity,"namespace":a.namespace,"is_enabled":a.is_enabled} for a in alarms if a.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1485,7 +1602,7 @@ async def explore_iam_users(cid: str, u=Depends(current_user)): users = identity.list_users(tenancy).data return [{"id":u2.id,"name":u2.name,"email":u2.email or "","lifecycle_state":u2.lifecycle_state, "is_mfa_activated":u2.is_mfa_activated,"time_created":str(u2.time_created), - "last_successful_login_time":str(u2.last_successful_login_time) if u2.last_successful_login_time else None} for u2 in users] + "last_successful_login_time":str(u2.last_successful_login_time) if u2.last_successful_login_time else None} for u2 in users if u2.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1499,7 +1616,7 @@ async def explore_iam_groups(cid: str, u=Depends(current_user)): tenancy = _safe_dec(cfg["tenancy_ocid"]) groups = identity.list_groups(tenancy).data return [{"id":g.id,"name":g.name,"description":g.description or "","lifecycle_state":g.lifecycle_state, - "time_created":str(g.time_created)} for g in groups] + "time_created":str(g.time_created)} for g in groups if g.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1513,7 +1630,7 @@ async def explore_iam_policies(cid: str, compartment_id: str = Query(None), u=De comp = compartment_id or _safe_dec(cfg["tenancy_ocid"]) or default_comp policies = identity.list_policies(comp).data return [{"id":p.id,"name":p.name,"description":p.description or "","lifecycle_state":p.lifecycle_state, - "statements":p.statements,"time_created":str(p.time_created)} for p in policies] + "statements":p.statements,"time_created":str(p.time_created)} for p in policies if p.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1527,7 +1644,7 @@ async def explore_iam_dynamic_groups(cid: str, u=Depends(current_user)): tenancy = _safe_dec(cfg["tenancy_ocid"]) dgs = identity.list_dynamic_groups(tenancy).data return [{"id":g.id,"name":g.name,"description":g.description or "","lifecycle_state":g.lifecycle_state, - "matching_rule":g.matching_rule,"time_created":str(g.time_created)} for g in dgs] + "matching_rule":g.matching_rule,"time_created":str(g.time_created)} for g in dgs if g.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1543,7 +1660,7 @@ async def explore_vaults(cid: str, compartment_id: str = Query(None), region: st vaults = kms.list_vaults(comp).data return [{"id":v.id,"display_name":v.display_name,"lifecycle_state":v.lifecycle_state, "vault_type":v.vault_type,"crypto_endpoint":v.crypto_endpoint, - "time_created":str(v.time_created)} for v in vaults] + "time_created":str(v.time_created)} for v in vaults if v.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1559,7 +1676,7 @@ async def explore_dns_zones(cid: str, compartment_id: str = Query(None), region: zones = dns.list_zones(comp).data return [{"id":z.id,"name":z.name,"lifecycle_state":z.lifecycle_state, "zone_type":z.zone_type,"serial":z.serial, - "time_created":str(z.time_created)} for z in zones] + "time_created":str(z.time_created)} for z in zones if z.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1575,7 +1692,7 @@ async def explore_api_gateways(cid: str, compartment_id: str = Query(None), regi gws = apigw.list_gateways(comp).data return [{"id":g.id,"display_name":g.display_name,"lifecycle_state":g.lifecycle_state, "endpoint_type":g.endpoint_type,"hostname":g.hostname, - "subnet_id":g.subnet_id,"time_created":str(g.time_created)} for g in gws] + "subnet_id":g.subnet_id,"time_created":str(g.time_created)} for g in gws if g.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1591,7 +1708,7 @@ async def explore_notification_topics(cid: str, compartment_id: str = Query(None topics = ons.list_topics(comp).data return [{"id":t.topic_id,"name":t.name,"lifecycle_state":t.lifecycle_state, "description":t.description or "","api_endpoint":t.api_endpoint, - "time_created":str(t.time_created)} for t in topics] + "time_created":str(t.time_created)} for t in topics if t.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1607,7 +1724,7 @@ async def explore_events_rules(cid: str, compartment_id: str = Query(None), regi rules = events.list_rules(comp).data return [{"id":r.id,"display_name":r.display_name,"lifecycle_state":r.lifecycle_state, "condition":r.condition,"is_enabled":r.is_enabled, - "description":r.description or "","time_created":str(r.time_created)} for r in rules] + "description":r.description or "","time_created":str(r.time_created)} for r in rules if r.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1620,10 +1737,11 @@ async def explore_network_firewalls(cid: str, compartment_id: str = Query(None), fw = oci.network_firewall.NetworkFirewallClient(config) _, default_comp = _explore_comp(cid) comp = compartment_id or default_comp - firewalls = fw.list_network_firewalls(compartment_id=comp).data + resp = fw.list_network_firewalls(compartment_id=comp).data + firewalls = resp.items if hasattr(resp, 'items') else (resp if isinstance(resp, list) else []) return [{"id":f.id,"display_name":f.display_name,"lifecycle_state":f.lifecycle_state, "subnet_id":f.subnet_id,"network_firewall_policy_id":f.network_firewall_policy_id, - "time_created":str(f.time_created)} for f in firewalls] + "time_created":str(f.time_created)} for f in firewalls if f.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -1636,9 +1754,10 @@ async def explore_network_firewall_policies(cid: str, compartment_id: str = Quer fw = oci.network_firewall.NetworkFirewallClient(config) _, default_comp = _explore_comp(cid) comp = compartment_id or default_comp - policies = fw.list_network_firewall_policies(compartment_id=comp).data + resp = fw.list_network_firewall_policies(compartment_id=comp).data + policies = resp.items if hasattr(resp, 'items') else (resp if isinstance(resp, list) else []) return [{"id":p.id,"display_name":p.display_name,"lifecycle_state":p.lifecycle_state, - "time_created":str(p.time_created)} for p in policies] + "time_created":str(p.time_created)} for p in policies if p.lifecycle_state not in _DEAD_STATES] except Exception as e: return {"error": str(e)[:500]} @@ -4574,6 +4693,79 @@ async def oci_adb_update_network(adb_id: str, req: dict, u=Depends(current_user) raise HTTPException(500, str(e)[:500]) +@app.post("/api/oci/db-systems/{db_system_id}/action") +async def oci_db_system_action(db_system_id: str, req: dict, u=Depends(current_user)): + action = req.get("action", "").upper() + oci_config_id = req.get("oci_config_id", "") + region = req.get("region") + if action not in ("START", "STOP"): + raise HTTPException(400, "Ação inválida. Use START ou STOP.") + if not oci_config_id: + raise HTTPException(400, "oci_config_id obrigatório") + try: + import oci + config = _get_oci_config(oci_config_id) + if region: config["region"] = region + db_client = oci.database.DatabaseClient(config) + if action == "START": + db_client.start_db_system(db_system_id) + else: + db_client.stop_db_system(db_system_id) + _audit(u["id"], u["username"], f"db_system_{action.lower()}", db_system_id) + return {"ok": True, "action": action, "db_system_id": db_system_id} + except Exception as e: + raise HTTPException(500, str(e)[:500]) + + +@app.post("/api/oci/mysql-db-systems/{db_system_id}/action") +async def oci_mysql_action(db_system_id: str, req: dict, u=Depends(current_user)): + action = req.get("action", "").lower() + oci_config_id = req.get("oci_config_id", "") + region = req.get("region") + if action not in ("start", "stop"): + raise HTTPException(400, "Ação inválida. Use start ou stop.") + if not oci_config_id: + raise HTTPException(400, "oci_config_id obrigatório") + try: + import oci + config = _get_oci_config(oci_config_id) + if region: config["region"] = region + mysql = oci.mysql.DbSystemClient(config) + if action == "start": + mysql.start_db_system(db_system_id) + else: + shutdown_type = req.get("shutdown_type", "SLOW") + mysql.stop_db_system(db_system_id, oci.mysql.models.StopDbSystemDetails(shutdown_type=shutdown_type)) + _audit(u["id"], u["username"], f"mysql_{action}", db_system_id) + return {"ok": True, "action": action, "db_system_id": db_system_id} + except Exception as e: + raise HTTPException(500, str(e)[:500]) + + +@app.post("/api/oci/container-instances/{ci_id}/action") +async def oci_container_instance_action(ci_id: str, req: dict, u=Depends(current_user)): + action = req.get("action", "").lower() + oci_config_id = req.get("oci_config_id", "") + region = req.get("region") + if action not in ("start", "stop"): + raise HTTPException(400, "Ação inválida. Use start ou stop.") + if not oci_config_id: + raise HTTPException(400, "oci_config_id obrigatório") + try: + import oci + config = _get_oci_config(oci_config_id) + if region: config["region"] = region + ci = oci.container_instances.ContainerInstanceClient(config) + if action == "start": + ci.start_container_instance(ci_id) + else: + ci.stop_container_instance(ci_id) + _audit(u["id"], u["username"], f"container_instance_{action}", ci_id) + return {"ok": True, "action": action, "container_instance_id": ci_id} + except Exception as e: + raise HTTPException(500, str(e)[:500]) + + @app.get("/api/oci/my-ip") async def get_my_ip(request: Request): """Return the caller's public IP address.""" @@ -4684,11 +4876,33 @@ async def tf_generate_prompt(req: TfPromptReq, u=Depends(current_user)): (sid, u["id"], "tf-prompt", title)) else: c.execute("UPDATE chat_sessions SET updated_at=datetime('now') WHERE id=?", (sid,)) - # Load TF resource reference for context + # Load TF resource reference (compact — same as Terraform Agent) tf_ref = _load_tf_resource_reference() system_with_ref = TFP_SYSTEM_PROMPT if tf_ref: - system_with_ref += f"\n\n### Referência de Recursos OCI Terraform Disponíveis\nUse esta referência para garantir que os recursos mencionados no prompt existam no provider OCI:\n\n{tf_ref}" + 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: + system_with_ref += "\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 + + # Detect resource types from user message + history and fetch official docs + detect_text = req.message + if req.history: + for h in req.history[-3:]: + detect_text += "\n" + (h.get("content", "") or "") + resource_types = _detect_tf_resource_types(detect_text) + if resource_types: + docs_ctx = _get_tf_docs_for_resources(resource_types) + if docs_ctx: + system_with_ref += "\n\n" + docs_ctx + log.info(f"TF Prompt Generator: injected {len(resource_types)} resource docs ({len(docs_ctx)} chars)") + gc["system_prompt"] = system_with_ref gc["temperature"] = 0.7 gc["max_tokens"] = 4000 diff --git a/frontend/index.html b/frontend/index.html index 5f93530..b374e73 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -244,7 +244,7 @@ tbody tr:last-child td{border-bottom:none} /* ── Explorer ── */ .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-tree{width:280px;flex-shrink:0;background:var(--bg1);border-right:none;overflow-y:auto;padding:0;min-width:180px;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} @@ -268,7 +268,7 @@ tbody tr:last-child td{border-bottom:none} .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{display:flex;align-items:center;gap:.3rem;padding:.35rem .6rem;border-radius:6px;cursor:pointer;transition:all .15s;font-size:.76rem} .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} @@ -531,7 +531,7 @@ const IC={ const LOGO_W=``; const LOGO_R=``; -const S={user:null,token:null,tab:'chat',msgs:[],sid:null,reports:[],ociCfg:[],genaiCfg:[],adbCfg:[],mcpSvr:[],users:[],models:{},regions:[],embModels:{},expData:null,expCfg:'',expSelRegions:[],expRegDdOpen:false,expTree:[],expSelComp:'',expCat:'Compute',expResType:'instances',expTreeOpen:{},expLoading:false,expRegions:[],expCounts:{},expTreeW: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:{},expCountsRefreshing:false,expTreeW:null,editing:null, chatModel:'',chatOci:'',chatRegion:'',chatCompartment:'',chatHistOpen:false,chatHistory:[],tfHistOpen:false,tfHistory:[], chatParams:{temperature:1,max_tokens:6000,top_p:0.95,top_k:1,frequency_penalty:0,presence_penalty:0,reasoning_effort:'medium'},chatPanel:'',chatUseTools:true, chatPrompts:[],editingPrompt:null,trackingReportId:null,ociRegions:{},rptSelRegions:[],rptRegionsOpen:false,rptRegionFilter:'', @@ -1615,6 +1615,21 @@ async function ociAdbAction(id,action){ if(!confirm(label+' este Autonomous Database?'))return; try{await $api('/oci/autonomous-databases/'+id+'/action',{method:'POST',body:{action,oci_config_id:S.expCfg,region:null}});expPollState(action==='start'?'AVAILABLE':'STOPPED')}catch(e){alert('Erro: '+e.message)} } +async function ociDbSystemAction(id,action){ + const label=action==='START'?'Iniciar':'Parar'; + if(!confirm(label+' este DB System?'))return; + try{await $api('/oci/db-systems/'+id+'/action',{method:'POST',body:{action,oci_config_id:S.expCfg,region:null}});expPollState(action==='START'?'AVAILABLE':'STOPPED')}catch(e){alert('Erro: '+e.message)} +} +async function ociMysqlAction(id,action){ + const label=action==='start'?'Iniciar':'Parar'; + if(!confirm(label+' este MySQL DB System?'))return; + try{await $api('/oci/mysql-db-systems/'+id+'/action',{method:'POST',body:{action,oci_config_id:S.expCfg,region:null}});expPollState(action==='start'?'ACTIVE':'INACTIVE')}catch(e){alert('Erro: '+e.message)} +} +async function ociContainerAction(id,action){ + const label=action==='start'?'Iniciar':'Parar'; + if(!confirm(label+' esta Container Instance?'))return; + try{await $api('/oci/container-instances/'+id+'/action',{method:'POST',body:{action,oci_config_id:S.expCfg,region:null}});expPollState(action==='start'?'ACTIVE':'INACTIVE')}catch(e){alert('Erro: '+e.message)} +} async function ociAdbUpdateNet(id){ try{ const ipResp=await $api('/oci/my-ip'); @@ -1844,8 +1859,19 @@ function rExplorer(){ const cats=EXP_GROUPS.map(g=>`

${catIcons[g.name]||''} ${g.name}
`).join(''); const activeGrp=EXP_GROUPS.find(g=>g.name===S.expCat)||EXP_GROUPS[0]; const tabs=activeGrp.tabs.map(t=>{const cnt=S.expCounts[t.key];return`
${t.label}${cnt!=null?' ('+cnt+')':''}
`}).join(''); + // KPI stats from counts + const _iamKeys=new Set(['iam_users','iam_groups','iam_policies','iam_dynamic_groups']); + const totalRes=Object.entries(S.expCounts).reduce((a,[k,v])=>a+(_iamKeys.has(k)?0:(v||0)),0); + const kpiCats=EXP_GROUPS.map(g=>{const cnt=g.tabs.reduce((a,t)=>a+(S.expCounts[t.key]||0),0);const detail=g.tabs.filter(t=>(S.expCounts[t.key]||0)>0).map(t=>t.label+': '+S.expCounts[t.key]).join('\n');return{name:g.name,icon:catIcons[g.name]||'',count:cnt,detail:detail}}).filter(k=>k.count>0); + const selCompName=S.expTree.find(n=>n.id===S.expSelComp); + const compLabel=selCompName?selCompName.name:'—'; + const kpiHtml=S.expSelComp?`
+
${IC.folder} Compartment
${compLabel}
${S.expSelRegions.length||'Todas'} região(ões)
+
${IC.activity} Total
${totalRes}
${S.expCountsRefreshing?' atualizando...':'recursos'}
+${kpiCats.slice(0,6).map(k=>`
${k.icon}
${k.count}
${k.name}
`).join('')} +
`:''; return`
-
${IC.folder} Compartments
${S.expTree.length?rExpTree(null,0):'
Carregando...
'}
+
${IC.folder} Compartments
${S.expTree.length?rExpTree(null,0):'
Carregando...
'}
@@ -1857,6 +1883,7 @@ ${S.expRegDdOpen?`
${S.expSelRegions.map(r=>`${r}×`).join('')}
`:''}
+${kpiHtml}
${cats}
${tabs}
${S.expLoading?'
Consultando OCI...
':renderExpData(S.expData)}
@@ -1873,19 +1900,19 @@ function rExpTree(parentId,depth){ ${hasKids?(isOpen?'▼':'▶'):' '} ${n.name}
${hasKids&&isOpen?`
${rExpTree(n.id,depth+1)}
`:''}
`}).join('')} -async function expSetCfg(cid){S.expCfg=cid;S.expTree=[];S.expSelComp='';S.expData=null;S.expSelRegions=[];S.expRegions=[];S.expCounts={};S.expRegDdOpen=false;R();await expLoadTree();await expLoadRegions()} +async function expSetCfg(cid){S.expCfg=cid;S.expTree=[];S.expSelComp='';S.expData=null;S.expSelRegions=[];S.expRegions=[];S.expCounts={};S.expRegDdOpen=false;_expCountAbort++;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()} +async function expApplyRegions(){if(S.expSelComp){S.expCounts={};_expCountAbort++;await expLoadRes();expLoadCachedCounts();expRefreshCounts()}} 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()} +async function expSelectComp(id){S.expSelComp=id;S.expCounts={};_expCountAbort++;expLoadCachedCounts();await expLoadRes();expRefreshCounts()} 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';S.expTreeW=w}; 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 expRefresh(){if(S.expCfg){S.expTree=[];S.expCounts={};_expCountAbort++;R();await expLoadTree();if(S.expSelComp){await expLoadRes();expRefreshCounts()}}} async function expLoadTree(){ if(!S.expCfg)return; try{const d=await $api('/oci/explore/'+S.expCfg+'/compartment-tree'); @@ -1917,25 +1944,131 @@ async function expLoadRes(){ else S.expData=[]; S.expCounts[S.expResType]=all.length; S.expLoading=false;R()} +let _expCountAbort=0; +async function expLoadCachedCounts(){ + const comp=S.expSelComp;const cfg=S.expCfg;if(!comp||!cfg)return; + const regKey=S.expSelRegions.length?S.expSelRegions.slice().sort().join(','):''; + try{ + const q=new URLSearchParams({compartment_id:comp,regions:regKey}); + const d=await $api('/oci/explore/'+cfg+'/counts?'+q); + if(d&&!d.error){for(const[k,v]of Object.entries(d)){S.expCounts[k]=v.count}R()} + }catch(e){console.warn('Cache counts error:',e)} +} +async function expRefreshCounts(){ + const comp=S.expSelComp;const cfg=S.expCfg;if(!comp||!cfg)return; + const myRun=++_expCountAbort; + S.expCountsRefreshing=true;R(); + try{ + await $api('/oci/explore/'+cfg+'/counts/refresh',{method:'POST',body:{compartment_id:comp,regions:S.expSelRegions}}); + const poll=async(attempts)=>{ + if(_expCountAbort!==myRun||S.expSelComp!==comp){S.expCountsRefreshing=false;R();return} + if(attempts<=0){S.expCountsRefreshing=false;R();return} + await new Promise(r=>setTimeout(r,3000)); + if(_expCountAbort!==myRun||S.expSelComp!==comp){S.expCountsRefreshing=false;R();return} + await expLoadCachedCounts(); + poll(attempts-1); + }; + poll(12); + }catch(e){S.expCountsRefreshing=false;R();console.warn('Refresh counts error:',e)} +} function renderExpData(d){if(!d)return'

Selecione um compartment na árvore.

'; if(d.error)return`
${d.error}
`;if(!Array.isArray(d)||!d.length)return'

Nenhum recurso encontrado neste compartment.

'; if(S.expResType==='security_lists')return rExpSecLists(d); if(S.expResType==='nsgs')return rExpNsgs(d); if(S.expResType==='route_tables')return rExpRouteTables(d); + if(S.expResType==='vcns')return rExpVcns(d); + if(S.expResType==='subnets')return rExpSubnets(d); + if(S.expResType==='load_balancers')return rExpLoadBalancers(d); + if(S.expResType==='buckets')return rExpBuckets(d); const isInst=S.expResType==='instances'; const isAdb=S.expResType==='databases'; + const isDbSys=S.expResType==='db_systems'; + const isMysql=S.expResType==='mysql_db_systems'; + const isCI=S.expResType==='container_instances'; return`
${d.length} item(ns) encontrado(s)
${d.map(i=>{ 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','whitelisted_ips']; + const skip=['display_name','name','db_name','lifecycle_state','application','rules','routes','whitelisted_ips','listeners','backend_sets']; const aclHtml=isAdb&&i.whitelisted_ips&&i.whitelisted_ips.length?'
ACL: '+i.whitelisted_ips.join(', ')+'':''; let actionBtn=''; if(isInst&&i.lifecycle_state==='RUNNING')actionBtn=' '; else if(isInst&&i.lifecycle_state==='STOPPED')actionBtn=' '; else if(isAdb&&i.lifecycle_state==='AVAILABLE')actionBtn=' '; else if(isAdb&&i.lifecycle_state==='STOPPED')actionBtn=' '; + else if(isDbSys&&i.lifecycle_state==='AVAILABLE')actionBtn=' '; + else if(isDbSys&&i.lifecycle_state==='STOPPED')actionBtn=' '; + else if(isMysql&&i.lifecycle_state==='ACTIVE')actionBtn=' '; + else if(isMysql&&i.lifecycle_state==='INACTIVE')actionBtn=' '; + else if(isCI&&i.lifecycle_state==='ACTIVE')actionBtn=' '; + else if(isCI&&i.lifecycle_state==='INACTIVE')actionBtn=' '; return`
${name}${i.lifecycle_state?` ${i.lifecycle_state}`:''}${actionBtn}${aclHtml}
${keys.filter(k=>!skip.includes(k)).map(k=>`${k}: ${typeof i[k]==='object'?JSON.stringify(i[k]):i[k]}`).join('
')}
`}).join('')}
`} +function _fmtBytes(b){if(!b&&b!==0)return'—';if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';if(b<1073741824)return(b/1048576).toFixed(1)+' MB';if(b<1099511627776)return(b/1073741824).toFixed(1)+' GB';return(b/1099511627776).toFixed(2)+' TB'} +function rExpVcns(d){ + return`
${d.length} VCN(s)
${d.map((v,idx)=>{ + const expanded=S._expExpanded&&S._expExpanded['vcn'+idx]; + return`
+
${v.display_name} ${v.lifecycle_state}
+
${expanded?'▼':'▶'} ${(v.cidr_blocks||[]).length} CIDR(s)
+
id: ${v.id}
+${expanded?`
+ + + + + + + +
CIDR Blocks${(v.cidr_blocks||[]).join(', ')}
DNS Label${v.dns_label||'—'}
VCN Domain${v.vcn_domain_name||'—'}
Default Route Table${v.default_route_table_id||'—'}
Default Security List${v.default_security_list_id||'—'}
Criado em${v.time_created||'—'}
`:''}
`}).join('')}`} +function rExpSubnets(d){ + return`
${d.length} subnet(s)
${d.map((s,idx)=>{ + const expanded=S._expExpanded&&S._expExpanded['sub'+idx]; + return`
+
${s.display_name} ${s.lifecycle_state} ${s.cidr_block}
+
${expanded?'▼':'▶'} ${s.availability_domain||'Regional'}
+
id: ${s.id}
+${expanded?`
+ + + + + + + + + + +
CIDR${s.cidr_block}
Availability Domain${s.availability_domain||'Regional'}
DNS Label${s.dns_label||'—'}
Subnet Domain${s.subnet_domain_name||'—'}
IP Público${s.prohibit_public_ip_on_vnic?'Proibido (privada)':'Permitido (pública)'}
Route Table${s.route_table_id||'—'}
Security Lists${(s.security_list_ids||[]).join('
')||'—'}
VCN${s.vcn_id||'—'}
Criado em${s.time_created||'—'}
`:''}
`}).join('')}`} +function rExpLoadBalancers(d){ + return`
${d.length} load balancer(s)
${d.map((lb,idx)=>{ + const expanded=S._expExpanded&&S._expExpanded['lb'+idx]; + const ips=(lb.ip_addresses||[]).join(', '); + return`
+
${lb.display_name} ${lb.lifecycle_state} ${lb.is_private?'Privado':'Público'}
+
${expanded?'▼':'▶'} ${lb.shape_name} · ${(lb.listeners||[]).length} listener(s)
+
IPs: ${ips||'—'}
+${expanded?`
+${(lb.listeners||[]).length?`
Listeners (${lb.listeners.length})
+ +${lb.listeners.map(l=>``).join('')} +
NomePortaProtocoloBackend Set
${l.name}${l.port}${l.protocol}${l.backend_set||'—'}
`:''} +${(lb.backend_sets||[]).length?`
Backend Sets (${lb.backend_sets.length})
+${lb.backend_sets.map(bs=>`
${bs.name} policy: ${bs.policy||'—'} +${(bs.backends||[]).length?` +${bs.backends.map(b=>``).join('')} +
IPPortaPeso
${b.ip}${b.port}${b.weight}
`:'
Nenhum backend
'}
`).join('')}`:''} +
`:''}
`}).join('')}`} +function rExpBuckets(d){ + return`
${d.length} bucket(s)
${d.map(b=>{ + const vis=b.public_access_type==='NoPublicAccess'?'Privado':'Público ('+b.public_access_type+')'; + const visBadge=b.public_access_type==='NoPublicAccess'?'b-pass':'b-pend'; + return`
${b.name} ${vis} +
Objetos: ${b.approximate_count!=null?b.approximate_count.toLocaleString():'—'} +Tamanho: ${_fmtBytes(b.approximate_size)} +
Storage Tier: ${b.storage_tier||'—'} +Versionamento: ${b.versioning||'—'} +
Namespace: ${b.namespace} +Criado: ${b.time_created||'—'}
`}).join('')}
`} function rExpSecLists(d){ return`
${d.length} security list(s)
${d.map((sl,idx)=>{ const rules=sl.rules||[];