feat: knowledge base with URL import, ADB case-sensitivity fix, and RAG enhancements

- Add Knowledge Base (Base de Conhecimento) with file upload + URL import
- URL content extraction: HTML stripping, remote PDF parsing
- Auto-resolve embedding config from OCI credentials
- Case-insensitive ADB table matching with quoted identifiers
- Table validation endpoint, report_date metadata, purge & re-embed flow
- Embeddings tab cleanup, PyPDF2 dependency, README updated to v2.3
This commit is contained in:
nogueiraguh
2026-03-11 02:02:31 -03:00
parent 851def30b6
commit 4a802c8e64
4 changed files with 848 additions and 153 deletions

View File

@@ -9,7 +9,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-2.2-C74634?style=flat-square" alt="Version"> <img src="https://img.shields.io/badge/version-2.3-C74634?style=flat-square" alt="Version">
<img src="https://img.shields.io/badge/python-3.12-3776AB?style=flat-square" alt="Python"> <img src="https://img.shields.io/badge/python-3.12-3776AB?style=flat-square" alt="Python">
<img src="https://img.shields.io/badge/FastAPI-0.115-009688?style=flat-square" alt="FastAPI"> <img src="https://img.shields.io/badge/FastAPI-0.115-009688?style=flat-square" alt="FastAPI">
<img src="https://img.shields.io/badge/OCI-GenAI-C74634?style=flat-square" alt="OCI"> <img src="https://img.shields.io/badge/OCI-GenAI-C74634?style=flat-square" alt="OCI">
@@ -144,20 +144,23 @@ The platform combines security compliance scanning, AI-powered chat with **RAG (
- Oracle Autonomous Database connection with **mTLS Wallet** authentication - Oracle Autonomous Database connection with **mTLS Wallet** authentication
- `python-oracledb` Thin mode (no Oracle Client needed) - `python-oracledb` Thin mode (no Oracle Client needed)
- Wallet ZIP upload and automatic extraction - Wallet ZIP upload and automatic extraction
- Connection testing - Connection testing with **case-insensitive table validation** against ADB `user_tables`
- **Multiple vector tables per ADB**: register, edit, toggle active/inactive for each table - **Multiple vector tables per ADB**: register, edit, toggle active/inactive — tables managed inside the edit connection card
- **Multi-table RAG search**: queries all active tables across all ADB configs, merges results by cosine distance - **Multi-table RAG search**: queries all active tables across all ADB configs, merges results by cosine distance
- Link to GenAI config for embedding generation via OCI GenAI - **Auto-resolve embedding config**: automatically uses existing OCI credentials for GenAI embeddings — no separate GenAI config required
- **Case-insensitive table matching**: supports both uppercase and lowercase table names in Oracle ADB with quoted identifiers
### 🧬 Embeddings Management ### 🧬 Embeddings & Knowledge Base
- Dedicated tab for managing vector embeddings - Dedicated tab for managing vector embeddings and knowledge base
- **CIS Recommendations**: upload CIS PDF to populate the `cisrecom` vector table with Oracle Cloud security recommendations
- **Knowledge Base (Base de Conhecimento)**: upload documents (`.txt`, `.pdf`, `.csv`, `.json`, `.md`) or **import URLs** (web pages, PDF links) to populate the `engineerknowledgebase` vector table — content is automatically extracted, chunked, and vectorized
- **URL import**: fetches web pages, strips HTML tags/scripts, extracts readable text, and generates embeddings — supports HTML pages and remote PDFs
- **Embed CIS Reports**: automatically chunk reports by section with tenancy name, regions, and compartments enrichment, with `report_date` metadata for tracking data freshness
- **Purge & re-embed**: option to purge old embeddings by tenancy before re-embedding updated reports
- **Preview chunks before embedding**: review generated sections before creating embeddings - **Preview chunks before embedding**: review generated sections before creating embeddings
- **Embed CIS Reports**: automatically chunk reports by section with tenancy name, regions, and compartments enrichment - **OCI GenAI Embeddings**: uses Cohere Embed models (v4.0, multilingual) via OCI GenAI `embed_text` API
- **Upload text files**: upload `.txt` files, automatically chunked by paragraphs - Browse and inspect embeddings from the ADB vector store
- **Table selector**: choose which ADB vector table to store embeddings in - 3 embedding models supported: Cohere Embed v4.0, OpenAI Text Embedding 3 Large/Small
- **OCI GenAI Embeddings**: uses Cohere Embed models (v3.0/v4.0, multilingual, light) via OCI GenAI `embed_text` API
- Browse, inspect and delete individual embeddings from the ADB vector store
- 11 embedding models supported including Cohere Embed v4.0 and OpenAI Text Embedding 3
### 📜 Configuration & Chat Logs ### 📜 Configuration & Chat Logs
- **Persistent activity log** per configuration tab (OCI, GenAI, ADB, MCP) - **Persistent activity log** per configuration tab (OCI, GenAI, ADB, MCP)
@@ -350,20 +353,21 @@ For persistent vector storage and RAG-powered chat:
1. Add DSN (TNS name from tnsnames.ora, e.g., `myatp_high`) 1. Add DSN (TNS name from tnsnames.ora, e.g., `myatp_high`)
2. Set credentials (username/password) 2. Set credentials (username/password)
3. Select a **GenAI Config** (for embedding generation via OCI GenAI) 3. Select an **Embedding Model** (Cohere Embed v4.0 recommended)
4. Select an **Embedding Model** (Cohere Embed v4.0 recommended) 4. Upload Wallet ZIP (for mTLS)
5. Upload Wallet ZIP (for mTLS) 5. Test the connection
6. Test the connection 6. **Register vector tables**: add the names of existing tables in your ADB (e.g., `cisrecom`, `engineerknowledgebase`). Table names are case-insensitive and validated against ADB. Toggle tables active/inactive to control which are queried during RAG.
7. **Register vector tables**: add the names of existing tables in your ADB that contain vectorized data (e.g., `CIS_REPORT`, `CIS_RECOMMENDATIONS`). Toggle tables active/inactive to control which are queried during RAG.
> GenAI Config is optional — the app auto-resolves embedding credentials from your existing OCI config.
### Step 5 — Embeddings (Optional) ### Step 5 — Embeddings (Optional)
Navigate to the **Embeddings** tab to populate the vector store: Navigate to the **Embeddings** tab to populate the vector store:
1. **From CIS Reports**: Select a completed report, **preview chunks** (with tenancy/regions/compartments context), then generate embeddings 1. **CIS Recommendations**: Upload the CIS PDF to populate the `cisrecom` table with Oracle Cloud security recommendations
2. **From text files**: Upload `.txt` files for automatic chunking and embedding 2. **Knowledge Base**: Upload documents (`.txt`, `.pdf`, `.csv`, `.json`, `.md`) or paste a URL to import web pages — all content goes to the `engineerknowledgebase` table
3. **Select target table**: choose which ADB vector table to store embeddings in 3. **From CIS Reports** (Downloads tab): Embed completed reports with option to purge old data first
4. Browse and manage existing embeddings per table 4. Browse and inspect embeddings per table
Once embeddings exist, the **chat automatically uses RAG** — it queries all active vector tables across all ADB configs for relevant context before generating responses with the selected GenAI model. Once embeddings exist, the **chat automatically uses RAG** — it queries all active vector tables across all ADB configs for relevant context before generating responses with the selected GenAI model.
@@ -391,14 +395,14 @@ Allow group <group-name> to read buckets in compartment <compartment-name>
``` ```
oci-cis-agent/ oci-cis-agent/
├── backend/ ├── backend/
│ ├── app.py # FastAPI application (~4830 lines) │ ├── app.py # FastAPI application (~5300 lines)
│ ├── cis_reports.py # Oracle CIS Benchmark checker (6660 lines, report engine) │ ├── cis_reports.py # Oracle CIS Benchmark checker (6660 lines, report engine)
│ ├── mcp_cis_server.py # MCP server with 12 granular CIS tools (~700 lines) │ ├── mcp_cis_server.py # MCP server with 12 granular CIS tools (~700 lines)
│ ├── gen_tf_reference.py # OCI Terraform provider resource catalog generator │ ├── gen_tf_reference.py # OCI Terraform provider resource catalog generator
│ ├── Dockerfile # Python 3.12 + OCI CLI + Terraform CLI │ ├── Dockerfile # Python 3.12 + OCI CLI + Terraform CLI
│ └── requirements.txt # Dependencies │ └── requirements.txt # Dependencies
├── frontend/ ├── frontend/
│ └── index.html # SPA with Oracle Dark Premium theme (~2600 lines) │ └── index.html # SPA with Oracle Dark Premium theme (~2820 lines)
├── nginx/ ├── nginx/
│ └── default.conf # Reverse proxy config │ └── default.conf # Reverse proxy config
├── docker-compose.yml # Orchestration ├── docker-compose.yml # Orchestration
@@ -520,6 +524,7 @@ oci-cis-agent/
| GET | `/api/adb/{id}/tables` | List vector tables for ADB config | | GET | `/api/adb/{id}/tables` | List vector tables for ADB config |
| POST | `/api/adb/{id}/tables` | Add vector table | | POST | `/api/adb/{id}/tables` | Add vector table |
| PUT | `/api/adb/{id}/tables/{tid}` | Update vector table (name, description, active) | | PUT | `/api/adb/{id}/tables/{tid}` | Update vector table (name, description, active) |
| POST | `/api/adb/{id}/tables/check` | Validate registered tables against ADB (case-insensitive) |
| DELETE | `/api/adb/{id}/tables/{tid}` | Remove vector table | | DELETE | `/api/adb/{id}/tables/{tid}` | Remove vector table |
| DELETE | `/api/adb/configs/{id}` | Delete ADB config | | DELETE | `/api/adb/configs/{id}` | Delete ADB config |
@@ -528,8 +533,10 @@ oci-cis-agent/
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
| GET | `/api/embeddings/preview/{rid}` | Preview report chunks before embedding (with tenancy/regions/compartments) | | GET | `/api/embeddings/preview/{rid}` | Preview report chunks before embedding (with tenancy/regions/compartments) |
| POST | `/api/embeddings/report/{rid}` | Generate embeddings from CIS report (chunked by section, accepts `table_name`) | | POST | `/api/embeddings/report/{rid}` | Generate embeddings from CIS report (chunked by section, accepts `table_name`, `report_date`) |
| POST | `/api/embeddings/upload` | Upload .txt file and generate embeddings (accepts `table_name`) | | POST | `/api/embeddings/upload` | Upload file (.txt/.pdf/.csv/.json/.md) and generate embeddings |
| POST | `/api/embeddings/upload-url` | Import URL (web page or PDF), extract text, and generate embeddings |
| POST | `/api/embeddings/{vid}/purge` | Purge old embeddings by table + tenancy before re-embedding |
| GET | `/api/embeddings/{vid}/list` | List embeddings in ADB (paginated, accepts `table_name` query param) | | GET | `/api/embeddings/{vid}/list` | List embeddings in ADB (paginated, accepts `table_name` query param) |
| DELETE | `/api/embeddings/{vid}/{doc_id}` | Delete individual embedding (accepts `table_name` query param) | | DELETE | `/api/embeddings/{vid}/{doc_id}` | Delete individual embedding (accepts `table_name` query param) |
@@ -660,6 +667,7 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the "
| Version | Date | Changes | | Version | Date | Changes |
|---------|------|---------| |---------|------|---------|
| **v2.3** | 2026-03 | **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). **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 | | **v2.2** | 2026-03 | **UI Modernization (Phases 1B-5)**: Oracle Dark Premium theme, 45+ Lucide SVG icons replacing all emojis, KPI dashboard with compliance gauge + Chart.js donut/bar charts, report summary API endpoint, Phase 5 animations (staggered fade-in, smooth theme transitions, hover effects) — animations scoped to tab switch only (no flickering on re-render). **Terraform Intelligence**: official resource docs injection from registry.terraform.io (Example Usage + Argument Reference, on-demand fetch + SQLite cache), smart tfvars auto-generation from declared variables, multi-region provider management with `var.region` support, RPC anti-cycle rule, system prompt auto-sync to DB, 60-min polling timeout for long-running applies. **ADB fix**: wallet_password always passed for thin mode mTLS (fixes DPY-6005). CIS_EMBEDDINGS default removed from ADB config |
| **v2.1** | 2026-03 | Terraform Agent (AI-powered IaC generation with plan/apply/destroy lifecycle, workspace management, Terraform CLI in container, inline file editor, split-panel terminal, multi-file generation via `// filename:` markers, **smart file correction** with merge-based updates, **14-point validation checklist**, 100K max_tokens), **3-layer auto-split pipeline** (frontend + backend monolithic HCL splitting into categorized files + backend deduplication), **HCL syntax auto-fix** (single-line blocks expanded to multi-line), **SQLite-based resource type validation** (~937 types with `difflib` suggestions for invalid types), **Terraform Resource Reference** (auto-generated OCI provider catalog from `terraform providers schema -json`, generated at startup to survive volume mounts, stored in SQLite, refreshable via menu button), compact system prompt for Terraform agent, ChatGPT-style chat history for both Chat Agent and Terraform Agent (rename/delete sessions), OCI Resource Actions (start/stop instances and Autonomous DBs from **OCI Account Explorer**), OCI Explorer expanded to 40+ resource types across 8 categories with tree-view navigation and resizable panels, compartment filtering for MCP CIS scans, chat sidebar with model parameters, chat audit logs, collection error tracking in MCP server, tool timeout increased to 30min with auto-retry, memory compaction tuned (6K threshold, 20 recent messages), model catalog trimmed from 69 to 15 curated models, improved GenAI response extraction with fallback for empty responses, fixed layout overflow (no page scroll), provider alias detection with brace-matching parser, multi-region Terraform support | | **v2.1** | 2026-03 | Terraform Agent (AI-powered IaC generation with plan/apply/destroy lifecycle, workspace management, Terraform CLI in container, inline file editor, split-panel terminal, multi-file generation via `// filename:` markers, **smart file correction** with merge-based updates, **14-point validation checklist**, 100K max_tokens), **3-layer auto-split pipeline** (frontend + backend monolithic HCL splitting into categorized files + backend deduplication), **HCL syntax auto-fix** (single-line blocks expanded to multi-line), **SQLite-based resource type validation** (~937 types with `difflib` suggestions for invalid types), **Terraform Resource Reference** (auto-generated OCI provider catalog from `terraform providers schema -json`, generated at startup to survive volume mounts, stored in SQLite, refreshable via menu button), compact system prompt for Terraform agent, ChatGPT-style chat history for both Chat Agent and Terraform Agent (rename/delete sessions), OCI Resource Actions (start/stop instances and Autonomous DBs from **OCI Account Explorer**), OCI Explorer expanded to 40+ resource types across 8 categories with tree-view navigation and resizable panels, compartment filtering for MCP CIS scans, chat sidebar with model parameters, chat audit logs, collection error tracking in MCP server, tool timeout increased to 30min with auto-retry, memory compaction tuned (6K threshold, 20 recent messages), model catalog trimmed from 69 to 15 curated models, improved GenAI response extraction with fallback for empty responses, fixed layout overflow (no page scroll), provider alias detection with brace-matching parser, multi-region Terraform support |
| **v2.0** | 2026-03 | Async background chat processing (no more 504 timeouts), frontend polling with timestamps, 8 uvicorn workers + 16-thread chat executor for ~12 simultaneous chats, parallelized MCP data collection (5-thread base + 8-thread regional), 2-hour MCP session cache, 5-min tool timeout, full dead code cleanup across backend/frontend/MCP | | **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 |

View File

@@ -466,6 +466,16 @@ async def current_user(cred: HTTPAuthorizationCredentials = Depends(security)):
if not u or not s: raise HTTPException(401, "Sessão inválida") if not u or not s: raise HTTPException(401, "Sessão inválida")
return dict(u) return dict(u)
def _user_from_token(token: str):
"""Resolve user from a raw JWT token string (for query-param auth on downloads)."""
try: p = pyjwt.decode(token, APP_SECRET, algorithms=[JWT_ALG])
except Exception: raise HTTPException(401, "Token inválido")
with db() as c:
u = c.execute("SELECT * FROM users WHERE id=? AND is_active=1", (p["sub"],)).fetchone()
s = c.execute("SELECT * FROM sessions WHERE id=? AND is_active=1", (p["sid"],)).fetchone()
if not u or not s: raise HTTPException(401, "Sessão inválida")
return dict(u)
def require(*roles): def require(*roles):
async def dep(u=Depends(current_user)): async def dep(u=Depends(current_user)):
if u["role"] not in roles: raise HTTPException(403, f"Requer role: {', '.join(roles)}") if u["role"] not in roles: raise HTTPException(403, f"Requer role: {', '.join(roles)}")
@@ -929,7 +939,7 @@ async def explore_security_lists(cid: str, compartment_id: str = Query(None), re
_, default_comp = _explore_comp(cid) _, default_comp = _explore_comp(cid)
comp = compartment_id or default_comp comp = compartment_id or default_comp
sls = vn.list_security_lists(comp).data sls = vn.list_security_lists(comp).data
def _fmt_rule(r, direction): def _fmt_rule(r, direction, idx):
proto_map = {"6":"TCP","17":"UDP","1":"ICMP","all":"ALL"} proto_map = {"6":"TCP","17":"UDP","1":"ICMP","all":"ALL"}
proto = proto_map.get(r.protocol, r.protocol) proto = proto_map.get(r.protocol, r.protocol)
src_dst = r.source if direction == "ingress" else r.destination src_dst = r.source if direction == "ingress" else r.destination
@@ -941,17 +951,98 @@ async def explore_security_lists(cid: str, compartment_id: str = Query(None), re
dp = r.udp_options.destination_port_range dp = r.udp_options.destination_port_range
if dp: ports = f"{dp.min}-{dp.max}" if dp.min != dp.max else str(dp.min) if dp: ports = f"{dp.min}-{dp.max}" if dp.min != dp.max else str(dp.min)
return {"direction":direction,"protocol":proto,"source_dest":src_dst or "","ports":ports, return {"direction":direction,"protocol":proto,"source_dest":src_dst or "","ports":ports,
"stateless":r.is_stateless,"description":getattr(r,'description','') or ""} "stateless":r.is_stateless,"description":getattr(r,'description','') or "","rule_index":idx}
result = [] result = []
for s in sls: for s in sls:
ingress = [_fmt_rule(r,"ingress") for r in (s.ingress_security_rules or [])] ingress = [_fmt_rule(r,"ingress",i) for i,r in enumerate(s.ingress_security_rules or [])]
egress = [_fmt_rule(r,"egress") for r in (s.egress_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, result.append({"id":s.id,"display_name":s.display_name,"vcn_id":s.vcn_id,"lifecycle_state":s.lifecycle_state,
"ingress_count":len(ingress),"egress_count":len(egress),"rules":ingress+egress}) "ingress_count":len(ingress),"egress_count":len(egress),"rules":ingress+egress})
return result return result
except Exception as e: except Exception as e:
return {"error": str(e)[:500]} return {"error": str(e)[:500]}
@app.post("/api/oci/explore/{cid}/security_lists/{sl_id}/rules")
async def add_security_list_rule(cid: str, sl_id: str, req: dict, u=Depends(require("admin", "user"))):
"""Add a rule to a Security List (appends to existing rules)."""
try:
import oci
config = _get_oci_config(cid)
region = req.get("region")
if region: config["region"] = region
vn = oci.core.VirtualNetworkClient(config)
sl = vn.get_security_list(sl_id).data
direction = req.get("direction", "ingress").lower()
protocol = str(req.get("protocol", "6"))
source_dest = req.get("source_dest", "0.0.0.0/0")
port_min = req.get("port_min")
port_max = req.get("port_max")
description = req.get("description", "").strip() or None
is_stateless = req.get("is_stateless", False)
tcp_opts = None
udp_opts = None
if protocol == "6" and port_min is not None:
tcp_opts = oci.core.models.TcpOptions(
destination_port_range=oci.core.models.PortRange(min=int(port_min), max=int(port_max or port_min)))
elif protocol == "17" and port_min is not None:
udp_opts = oci.core.models.UdpOptions(
destination_port_range=oci.core.models.PortRange(min=int(port_min), max=int(port_max or port_min)))
ingress_rules = list(sl.ingress_security_rules or [])
egress_rules = list(sl.egress_security_rules or [])
if direction == "ingress":
ingress_rules.append(oci.core.models.IngressSecurityRule(
protocol=protocol, source=source_dest, source_type="CIDR_BLOCK",
is_stateless=is_stateless, description=description,
tcp_options=tcp_opts, udp_options=udp_opts))
else:
egress_rules.append(oci.core.models.EgressSecurityRule(
protocol=protocol, destination=source_dest, destination_type="CIDR_BLOCK",
is_stateless=is_stateless, description=description,
tcp_options=tcp_opts, udp_options=udp_opts))
update = oci.core.models.UpdateSecurityListDetails(
ingress_security_rules=ingress_rules, egress_security_rules=egress_rules)
vn.update_security_list(sl_id, update)
_audit(u["id"], u["username"], "seclist_add_rule", sl_id,
f"{direction} {protocol} {source_dest} port={port_min or 'ALL'}")
return {"ok": True, "message": "Regra adicionada com sucesso"}
except Exception as e:
raise HTTPException(400, str(e)[:500])
@app.delete("/api/oci/explore/{cid}/security_lists/{sl_id}/rules")
async def remove_security_list_rule(cid: str, sl_id: str, req: dict, u=Depends(require("admin", "user"))):
"""Remove a rule from a Security List by index."""
try:
import oci
config = _get_oci_config(cid)
region = req.get("region")
if region: config["region"] = region
vn = oci.core.VirtualNetworkClient(config)
sl = vn.get_security_list(sl_id).data
direction = req.get("direction", "ingress").lower()
rule_index = req.get("rule_index")
if rule_index is None: raise HTTPException(400, "rule_index obrigatório")
ingress_rules = list(sl.ingress_security_rules or [])
egress_rules = list(sl.egress_security_rules or [])
if direction == "ingress":
if 0 <= rule_index < len(ingress_rules):
removed = ingress_rules.pop(rule_index)
desc = f"ingress idx={rule_index} src={removed.source}"
else:
raise HTTPException(400, "Índice inválido")
else:
if 0 <= rule_index < len(egress_rules):
removed = egress_rules.pop(rule_index)
desc = f"egress idx={rule_index} dst={removed.destination}"
else:
raise HTTPException(400, "Índice inválido")
update = oci.core.models.UpdateSecurityListDetails(
ingress_security_rules=ingress_rules, egress_security_rules=egress_rules)
vn.update_security_list(sl_id, update)
_audit(u["id"], u["username"], "seclist_remove_rule", sl_id, desc)
return {"ok": True, "message": "Regra removida com sucesso"}
except Exception as e:
raise HTTPException(400, str(e)[:500])
@app.get("/api/oci/explore/{cid}/block_volumes") @app.get("/api/oci/explore/{cid}/block_volumes")
async def explore_block_volumes(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): async def explore_block_volumes(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
try: try:
@@ -1029,11 +1120,97 @@ async def explore_nsgs(cid: str, compartment_id: str = Query(None), region: str
_, default_comp = _explore_comp(cid) _, default_comp = _explore_comp(cid)
comp = compartment_id or default_comp comp = compartment_id or default_comp
nsgs = vn.list_network_security_groups(compartment_id=comp).data nsgs = vn.list_network_security_groups(compartment_id=comp).data
return [{"id":n.id,"display_name":n.display_name,"vcn_id":n.vcn_id,"lifecycle_state":n.lifecycle_state, result = []
"time_created":str(n.time_created)} for n in nsgs] proto_map = {"1": "ICMP", "6": "TCP", "17": "UDP", "all": "ALL"}
for n in nsgs:
rules_data = oci.pagination.list_call_get_all_results(
vn.list_network_security_group_security_rules, n.id).data
rules = []
for r in rules_data:
ports = ""
if r.tcp_options and r.tcp_options.destination_port_range:
pr = r.tcp_options.destination_port_range
ports = str(pr.min) if pr.min == pr.max else f"{pr.min}-{pr.max}"
elif r.udp_options and r.udp_options.destination_port_range:
pr = r.udp_options.destination_port_range
ports = str(pr.min) if pr.min == pr.max else f"{pr.min}-{pr.max}"
src_dst = ""
src_dst_type = ""
if r.direction == "INGRESS":
src_dst = r.source or ""
src_dst_type = r.source_type or ""
else:
src_dst = r.destination or ""
src_dst_type = r.destination_type or ""
rules.append({
"id": r.id, "direction": r.direction,
"protocol": proto_map.get(r.protocol, r.protocol),
"protocol_num": r.protocol,
"source_dest": src_dst, "source_dest_type": src_dst_type,
"ports": ports, "is_stateless": r.is_stateless or False,
"description": r.description or ""
})
result.append({"id": n.id, "display_name": n.display_name, "vcn_id": n.vcn_id,
"lifecycle_state": n.lifecycle_state, "time_created": str(n.time_created),
"rule_count": len(rules), "rules": rules})
return result
except Exception as e: except Exception as e:
return {"error": str(e)[:500]} return {"error": str(e)[:500]}
@app.post("/api/oci/explore/{cid}/nsgs/{nsg_id}/rules")
async def add_nsg_rule(cid: str, nsg_id: str, req: dict, u=Depends(require("admin", "user"))):
"""Add a security rule to an NSG."""
try:
import oci
config = _get_oci_config(cid)
region = req.get("region")
if region: config["region"] = region
vn = oci.core.VirtualNetworkClient(config)
direction = req.get("direction", "INGRESS").upper()
protocol = str(req.get("protocol", "6"))
source_dest = req.get("source_dest", "0.0.0.0/0")
source_dest_type = req.get("source_dest_type", "CIDR_BLOCK")
port_min = req.get("port_min")
port_max = req.get("port_max")
description = req.get("description", "")
is_stateless = req.get("is_stateless", False)
rule = {"direction": direction, "protocol": protocol, "is_stateless": is_stateless, "description": description}
if direction == "INGRESS":
rule["source"] = source_dest
rule["source_type"] = source_dest_type
else:
rule["destination"] = source_dest
rule["destination_type"] = source_dest_type
if protocol == "6" and port_min is not None:
rule["tcp_options"] = oci.core.models.TcpOptions(
destination_port_range=oci.core.models.PortRange(min=int(port_min), max=int(port_max or port_min)))
elif protocol == "17" and port_min is not None:
rule["udp_options"] = oci.core.models.UdpOptions(
destination_port_range=oci.core.models.PortRange(min=int(port_min), max=int(port_max or port_min)))
details = oci.core.models.AddNetworkSecurityGroupSecurityRulesDetails(
security_rules=[oci.core.models.AddSecurityRuleDetails(**rule)])
resp = vn.add_network_security_group_security_rules(nsg_id, details)
_audit(u["id"], u["username"], "nsg_add_rule", nsg_id, f"{direction} {protocol} {source_dest} {port_min or 'ALL'}")
added = [{"id": r.id} for r in (resp.data.security_rules or [])]
return {"ok": True, "message": "Regra adicionada com sucesso", "rules_added": added}
except Exception as e:
raise HTTPException(400, str(e)[:500])
@app.delete("/api/oci/explore/{cid}/nsgs/{nsg_id}/rules/{rule_id}")
async def remove_nsg_rule(cid: str, nsg_id: str, rule_id: str, region: str = Query(None), u=Depends(require("admin", "user"))):
"""Remove a security rule from an NSG."""
try:
import oci
config = _get_oci_config(cid)
if region: config["region"] = region
vn = oci.core.VirtualNetworkClient(config)
details = oci.core.models.RemoveNetworkSecurityGroupSecurityRulesDetails(security_rule_ids=[rule_id])
vn.remove_network_security_group_security_rules(nsg_id, details)
_audit(u["id"], u["username"], "nsg_remove_rule", nsg_id, f"rule_id={rule_id}")
return {"ok": True, "message": "Regra removida com sucesso"}
except Exception as e:
raise HTTPException(400, str(e)[:500])
@app.get("/api/oci/explore/{cid}/route_tables") @app.get("/api/oci/explore/{cid}/route_tables")
async def explore_route_tables(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): async def explore_route_tables(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
try: try:
@@ -2225,44 +2402,92 @@ def _get_adb_connection(cfg: dict):
params["wallet_password"] = _dec(cfg["wallet_password_enc"]) if cfg.get("wallet_password_enc") else "" params["wallet_password"] = _dec(cfg["wallet_password_enc"]) if cfg.get("wallet_password_enc") else ""
return oracledb.connect(**params) return oracledb.connect(**params)
def _resolve_embed_config(oci_config_id: str = None, genai_cfg: dict = None) -> dict:
"""Resolve embedding config from genai_cfg or oci_config. Returns dict with oci_config_id, endpoint, genai_region, compartment_id."""
if genai_cfg:
return genai_cfg
if oci_config_id:
with db() as c:
# Try linked genai config first
gc = c.execute("SELECT * FROM genai_configs WHERE oci_config_id=? ORDER BY is_default DESC, created_at DESC",
(oci_config_id,)).fetchone()
if gc: return dict(gc)
# Fallback: build from oci_config directly
oc = c.execute("SELECT * FROM oci_configs WHERE id=?", (oci_config_id,)).fetchone()
if oc:
return {
"oci_config_id": oc["id"],
"genai_region": oc["region"],
"endpoint": f"https://inference.generativeai.{oc['region']}.oci.oraclecloud.com",
"compartment_id": oc.get("compartment_id") or oc["tenancy_ocid"],
}
# Last resort: any genai config
with db() as c:
gc = c.execute("SELECT * FROM genai_configs ORDER BY is_default DESC, created_at DESC").fetchone()
if gc: return dict(gc)
# Or any oci config
oc = c.execute("SELECT * FROM oci_configs ORDER BY created_at DESC").fetchone()
if oc:
return {
"oci_config_id": oc["id"],
"genai_region": oc["region"],
"endpoint": f"https://inference.generativeai.{oc['region']}.oci.oraclecloud.com",
"compartment_id": oc.get("compartment_id") or oc["tenancy_ocid"],
}
raise HTTPException(400, "Nenhuma credencial OCI configurada para gerar embeddings.")
def _embed_text(text: str, genai_cfg: dict, embedding_model_id: str) -> list: def _embed_text(text: str, genai_cfg: dict, embedding_model_id: str) -> list:
"""Generate embedding using OCI GenAI embed endpoint.""" """Generate embedding using OCI GenAI embed endpoint."""
import oci import oci
config_path = str(OCI_DIR / genai_cfg["oci_config_id"] / "config") config_path = str(OCI_DIR / genai_cfg["oci_config_id"] / "config")
config = oci.config.from_file(config_path, "DEFAULT") config = oci.config.from_file(config_path, "DEFAULT")
endpoint = genai_cfg["endpoint"] endpoint = genai_cfg.get("endpoint") or f"https://inference.generativeai.{genai_cfg.get('genai_region','us-ashburn-1')}.oci.oraclecloud.com"
client = oci.generative_ai_inference.GenerativeAiInferenceClient( client = oci.generative_ai_inference.GenerativeAiInferenceClient(
config=config, service_endpoint=endpoint, config=config, service_endpoint=endpoint,
retry_strategy=oci.retry.NoneRetryStrategy(), timeout=(10, 120) retry_strategy=oci.retry.NoneRetryStrategy(), timeout=(10, 120)
) )
embed_detail = oci.generative_ai_inference.models.EmbedTextDetails() embed_detail = oci.generative_ai_inference.models.EmbedTextDetails()
embed_detail.inputs = [text] embed_detail.inputs = [text]
# Resolve OCID for embedding model by region
emb_info = EMBEDDING_MODELS.get(embedding_model_id, {}) emb_info = EMBEDDING_MODELS.get(embedding_model_id, {})
region = genai_cfg.get("genai_region", "") region = genai_cfg.get("genai_region", "")
emb_ref = emb_info.get("ocids", {}).get(region) or embedding_model_id emb_ref = emb_info.get("ocids", {}).get(region) or embedding_model_id
embed_detail.serving_mode = oci.generative_ai_inference.models.OnDemandServingMode(model_id=emb_ref) embed_detail.serving_mode = oci.generative_ai_inference.models.OnDemandServingMode(model_id=emb_ref)
embed_detail.compartment_id = genai_cfg["compartment_id"] embed_detail.compartment_id = genai_cfg.get("compartment_id", "")
embed_detail.truncate = "NONE" embed_detail.truncate = "NONE"
embed_detail.input_type = "SEARCH_QUERY" embed_detail.input_type = "SEARCH_QUERY"
response = client.embed_text(embed_detail) response = client.embed_text(embed_detail)
return response.data.embeddings[0] return response.data.embeddings[0]
def _vector_search(cfg: dict, query_embedding: list, top_k: int = 5, table_name: str = None) -> list: def _vector_search(cfg: dict, query_embedding: list, top_k: int = 5, table_name: str = None, tenancy: str = None) -> list:
"""Search ADB vector store using cosine similarity. Returns top-K documents.""" """Search ADB vector store using cosine similarity. Returns top-K documents.
If tenancy is provided, filters METADATA JSON to match that tenancy only."""
import array import array
table_name = table_name or cfg.get("table_name", "") table_name = table_name or cfg.get("table_name", "")
conn = _get_adb_connection(cfg) conn = _get_adb_connection(cfg)
try: try:
cur = conn.cursor() cur = conn.cursor()
vec = array.array('d', query_embedding) vec = array.array('d', query_embedding)
cur.execute(f""" if tenancy:
SELECT ID, CONTENT, METADATA, SOURCE, # Filter by tenancy in METADATA JSON field using LIKE for broad compatibility
VECTOR_DISTANCE(EMBEDDING, :1, COSINE) AS distance # Matches both structured JSON {"tenancy":"X",...} and legacy "tenancy: X, ..."
FROM {table_name} tenancy_filter = f'%"tenancy":"{tenancy}"%'
ORDER BY distance ASC tenancy_filter_legacy = f'%tenancy: {tenancy}%'
FETCH FIRST :2 ROWS ONLY cur.execute(f"""
""", [vec, top_k]) SELECT ID, TEXT, METADATA,
VECTOR_DISTANCE(EMBEDDING, :1, COSINE) AS distance
FROM "{table_name}"
WHERE (METADATA LIKE :3 OR METADATA LIKE :4)
ORDER BY distance ASC
FETCH FIRST :2 ROWS ONLY
""", [vec, top_k, tenancy_filter, tenancy_filter_legacy])
else:
cur.execute(f"""
SELECT ID, TEXT, METADATA,
VECTOR_DISTANCE(EMBEDDING, :1, COSINE) AS distance
FROM "{table_name}"
ORDER BY distance ASC
FETCH FIRST :2 ROWS ONLY
""", [vec, top_k])
results = [] results = []
for row in cur: for row in cur:
content = row[1] content = row[1]
@@ -2270,7 +2495,7 @@ def _vector_search(cfg: dict, query_embedding: list, top_k: int = 5, table_name:
content = content.read() content = content.read()
results.append({ results.append({
"id": row[0], "content": content or "", "id": row[0], "content": content or "",
"metadata": row[2], "source": row[3], "distance": float(row[4]) "metadata": row[2], "distance": float(row[3])
}) })
cur.close() cur.close()
return results return results
@@ -2291,15 +2516,15 @@ def _build_rag_context(documents: list) -> str:
return "\n\n---\n\n".join(parts) return "\n\n---\n\n".join(parts)
def _get_active_adb_configs(user_id: str) -> list[dict]: def _get_active_adb_configs(user_id: str) -> list[dict]:
"""Get all active ADB vector configs with a linked GenAI config.""" """Get all active ADB vector configs. GenAI config is resolved automatically if not linked."""
with db() as c: with db() as c:
rows = c.execute( rows = c.execute(
"SELECT * FROM adb_vector_configs WHERE user_id=? AND is_active=1 AND genai_config_id IS NOT NULL ORDER BY created_at DESC", "SELECT * FROM adb_vector_configs WHERE user_id=? AND is_active=1 ORDER BY created_at DESC",
(user_id,) (user_id,)
).fetchall() ).fetchall()
if not rows: if not rows:
rows = c.execute( rows = c.execute(
"SELECT * FROM adb_vector_configs WHERE is_active=1 AND genai_config_id IS NOT NULL ORDER BY created_at DESC" "SELECT * FROM adb_vector_configs WHERE is_active=1 ORDER BY created_at DESC"
).fetchall() ).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]
@@ -2623,6 +2848,50 @@ async def test_adb(vid: str, u=Depends(require("admin","user"))):
_config_log("adb", vid, cname, "error", "test", msg, u["id"], u["username"]) _config_log("adb", vid, cname, "error", "test", msg, u["id"], u["username"])
return {"status":"error","message":msg} return {"status":"error","message":msg}
@app.post("/api/adb/{vid}/tables/check")
async def check_adb_tables(vid: str, u=Depends(require("admin","user"))):
"""Check which registered tables exist in ADB and list all user tables."""
with db() as c:
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
if not cfg: raise HTTPException(404)
registered = _get_tables_for_config(vid, active_only=False)
reg_names_upper = {t["table_name"].upper() for t in registered}
try:
conn = _get_adb_connection(dict(cfg))
cur = conn.cursor()
cur.execute("SELECT USER FROM DUAL")
current_user_name = cur.fetchone()[0]
cur.execute("SELECT table_name FROM user_tables ORDER BY table_name")
adb_tables = [r[0] for r in cur.fetchall()]
adb_lookup = {t.upper(): t for t in adb_tables}
results = []
for t in registered:
tname_reg = t["table_name"]
tname_upper = tname_reg.upper()
real_name = adb_lookup.get(tname_upper)
if real_name:
try:
cur.execute(f'SELECT COUNT(*) FROM "{real_name}"')
cnt = cur.fetchone()[0]
case_note = f" (ADB: {real_name})" if real_name != tname_reg else ""
results.append({"table": tname_reg, "adb_name": real_name, "status": "ok", "rows": cnt, "message": f"{cnt} registros{case_note}"})
except Exception as e:
results.append({"table": tname_reg, "status": "error", "message": str(e)[:200]})
else:
results.append({"table": tname_reg, "status": "not_found", "message": f"Não existe no schema {current_user_name}"})
unregistered = [t for t in adb_tables if t.upper() not in reg_names_upper]
cur.close()
conn.close()
return {
"status": "success",
"schema_user": current_user_name,
"adb_tables_total": len(adb_tables),
"registered": results,
"unregistered_in_adb": unregistered[:50]
}
except Exception as e:
return {"status": "error", "message": f"Erro de conexão: {str(e)[:500]}"}
@app.delete("/api/adb/configs/{vid}") @app.delete("/api/adb/configs/{vid}")
async def del_adb(vid: str, u=Depends(require("admin","user"))): async def del_adb(vid: str, u=Depends(require("admin","user"))):
with db() as c: c.execute("DELETE FROM adb_vector_configs WHERE id=?", (vid,)) with db() as c: c.execute("DELETE FROM adb_vector_configs WHERE id=?", (vid,))
@@ -2668,7 +2937,7 @@ async def update_adb(
_config_log("adb", vid, config_name, "success", "save", f"Conexão atualizada: {config_name} ({dsn})", u["id"], u["username"]) _config_log("adb", vid, config_name, "success", "save", f"Conexão atualizada: {config_name} ({dsn})", u["id"], u["username"])
return {"id": vid, "config_name": config_name} return {"id": vid, "config_name": config_name}
_TABLE_NAME_RE = re.compile(r'^[A-Z][A-Z0-9_]{0,127}$') _TABLE_NAME_RE = re.compile(r'^[A-Za-z][A-Za-z0-9_]{0,127}$')
@app.get("/api/adb/{vid}/tables") @app.get("/api/adb/{vid}/tables")
async def list_adb_tables(vid: str, u=Depends(current_user)): async def list_adb_tables(vid: str, u=Depends(current_user)):
@@ -2679,7 +2948,7 @@ async def list_adb_tables(vid: str, u=Depends(current_user)):
@app.post("/api/adb/{vid}/tables") @app.post("/api/adb/{vid}/tables")
async def add_adb_table(vid: str, req: dict, u=Depends(require("admin","user"))): async def add_adb_table(vid: str, req: dict, u=Depends(require("admin","user"))):
table_name = req.get("table_name", "").strip().upper() table_name = req.get("table_name", "").strip()
description = req.get("description", "").strip() description = req.get("description", "").strip()
if not table_name: raise HTTPException(400, "table_name é obrigatório") if not table_name: raise HTTPException(400, "table_name é obrigatório")
if not _TABLE_NAME_RE.match(table_name): raise HTTPException(400, "Nome da tabela inválido. Use letras maiúsculas, números e underscores.") if not _TABLE_NAME_RE.match(table_name): raise HTTPException(400, "Nome da tabela inválido. Use letras maiúsculas, números e underscores.")
@@ -2712,7 +2981,7 @@ async def update_adb_table(vid: str, tid: str, req: dict, u=Depends(require("adm
if not row: raise HTTPException(404, "Table entry not found") if not row: raise HTTPException(404, "Table entry not found")
sets, vals = [], [] sets, vals = [], []
if "table_name" in req: if "table_name" in req:
tn = req["table_name"].strip().upper() tn = req["table_name"].strip()
if not tn: raise HTTPException(400, "table_name é obrigatório") if not tn: raise HTTPException(400, "table_name é obrigatório")
if not _TABLE_NAME_RE.match(tn): raise HTTPException(400, "Nome da tabela inválido") if not _TABLE_NAME_RE.match(tn): raise HTTPException(400, "Nome da tabela inválido")
sets.append("table_name=?"); vals.append(tn) sets.append("table_name=?"); vals.append(tn)
@@ -2743,11 +3012,45 @@ async def ingest_documents(vid: str, req: IngestDocReq, bg: BackgroundTasks, u=D
bg.add_task(_ingest_documents_task, cfg, dict(gc), req.documents, u["id"], u["username"], table_name=req.table_name) bg.add_task(_ingest_documents_task, cfg, dict(gc), req.documents, u["id"], u["username"], table_name=req.table_name)
return {"ok": True, "message": f"Ingestão iniciada para {len(req.documents)} documentos", "config_id": vid} return {"ok": True, "message": f"Ingestão iniciada para {len(req.documents)} documentos", "config_id": vid}
def _ingest_documents_task(cfg: dict, genai_cfg: dict, documents: list, user_id: str, username: str, table_name: str = None): def _build_metadata_json(tenancy: str = "", compartments: str = "", section: str = "",
"""Background task: embed and insert documents into ADB via OCI GenAI.""" report_date: str = "", extra: dict = None) -> str:
"""Build a structured JSON metadata string for vector embeddings."""
meta = {}
if tenancy:
meta["tenancy"] = tenancy
if compartments:
meta["compartments"] = compartments
if section:
meta["section"] = section
if report_date:
meta["report_date"] = report_date
if extra:
meta.update(extra)
return json.dumps(meta, ensure_ascii=False) if meta else ""
def _auto_register_table(adb_config_id: str, table_name: str, description: str = ""):
"""Auto-register a table in adb_vector_tables if not already present."""
if not table_name:
return
with db() as c:
exists = c.execute("SELECT 1 FROM adb_vector_tables WHERE adb_config_id=? AND table_name=? COLLATE NOCASE",
(adb_config_id, table_name)).fetchone()
if not exists:
c.execute("INSERT INTO adb_vector_tables (id, adb_config_id, table_name, description) VALUES (?,?,?,?)",
(str(uuid.uuid4()), adb_config_id, table_name, description))
log.info(f"Auto-registered table '{table_name}' for ADB config {adb_config_id}")
def _ingest_documents_task(cfg: dict, genai_cfg: dict, documents: list, user_id: str, username: str,
table_name: str = None, tenancy: str = None, compartments: str = None,
report_date: str = None):
"""Background task: embed and insert documents into ADB via OCI GenAI.
Tenancy and compartments are stored in METADATA as structured JSON for filtering."""
import array import array
emb_model = cfg.get("embedding_model_id", "cohere.embed-v4.0") emb_model = cfg.get("embedding_model_id", "cohere.embed-v4.0")
table_name = table_name or cfg.get("table_name", "") table_name = table_name or cfg.get("table_name", "")
# Auto-register table so it appears in multi-table RAG search
_auto_register_table(cfg["id"], table_name)
conn = _get_adb_connection(cfg) conn = _get_adb_connection(cfg)
try: try:
cur = conn.cursor() cur = conn.cursor()
@@ -2758,18 +3061,31 @@ def _ingest_documents_task(cfg: dict, genai_cfg: dict, documents: list, user_id:
if not content: continue if not content: continue
embedding = _embed_text(content, genai_cfg, emb_model) embedding = _embed_text(content, genai_cfg, emb_model)
vec = array.array('d', embedding) vec = array.array('d', embedding)
# Build structured metadata with tenancy isolation
doc_tenancy = tenancy or doc.get("tenancy", "")
doc_compartments = compartments or doc.get("compartments", "")
metadata = _build_metadata_json(
tenancy=doc_tenancy,
compartments=doc_compartments,
section=doc.get("section", ""),
report_date=report_date or "",
extra={"legacy_metadata": doc.get("metadata", "")} if doc.get("metadata") else None
)
cur.execute(f""" cur.execute(f"""
INSERT INTO {table_name} (ID, CONTENT, EMBEDDING, METADATA, SOURCE) INSERT INTO "{table_name}" (ID, TEXT, EMBEDDING, METADATA)
VALUES (:1, :2, :3, :4, :5) VALUES (:1, :2, :3, :4)
""", [str(uuid.uuid4()), content, vec, doc.get("metadata", ""), doc.get("source", "manual_upload")]) """, [str(uuid.uuid4()), content, vec, metadata])
inserted += 1 inserted += 1
except Exception as e: except Exception as e:
log.error(f"Failed to ingest document: {e}") log.error(f"Failed to ingest document: {e}")
conn.commit() conn.commit()
cur.close() cur.close()
log.info(f"Ingested {inserted}/{len(documents)} documents into {table_name}") log.info(f"Ingested {inserted}/{len(documents)} documents into {table_name}" +
(f" (tenancy={tenancy})" if tenancy else ""))
_audit(user_id, username, "ingest_documents", cfg["id"], f"{inserted} documents") _audit(user_id, username, "ingest_documents", cfg["id"], f"{inserted} documents")
_config_log("adb", cfg["id"], cfg.get("config_name"), "success", "ingest", f"{inserted}/{len(documents)} documentos ingeridos em {table_name}", user_id, username) _config_log("adb", cfg["id"], cfg.get("config_name"), "success", "ingest",
f"{inserted}/{len(documents)} documentos ingeridos em {table_name}" +
(f" (tenancy: {tenancy})" if tenancy else ""), user_id, username)
except Exception as e: except Exception as e:
log.error(f"Ingestion task failed: {e}") log.error(f"Ingestion task failed: {e}")
_config_log("adb", cfg["id"], cfg.get("config_name"), "error", "ingest", str(e)[:500], user_id, username) _config_log("adb", cfg["id"], cfg.get("config_name"), "error", "ingest", str(e)[:500], user_id, username)
@@ -2781,6 +3097,8 @@ def _chunk_report_by_section(report_data: dict) -> list:
"""Chunk a CIS report into documents grouped by section.""" """Chunk a CIS report into documents grouped by section."""
if isinstance(report_data, str): if isinstance(report_data, str):
report_data = json.loads(report_data) report_data = json.loads(report_data)
if isinstance(report_data, list):
report_data = {"findings": {str(i): item for i, item in enumerate(report_data)}, "tenancy": "unknown"}
findings = report_data.get("findings", {}) findings = report_data.get("findings", {})
tenancy = report_data.get("tenancy", "unknown") tenancy = report_data.get("tenancy", "unknown")
generated_at = report_data.get("generated_at", "") generated_at = report_data.get("generated_at", "")
@@ -2813,6 +3131,9 @@ def _chunk_report_by_section(report_data: dict) -> list:
documents.append({ documents.append({
"content": "\n".join(lines), "content": "\n".join(lines),
"source": f"CIS Report - {tenancy} - {generated_at}", "source": f"CIS Report - {tenancy} - {generated_at}",
"section": section_name,
"tenancy": tenancy,
"compartments": ", ".join(compartments[:50]),
"metadata": f"tenancy: {tenancy}, section: {section_name}, total: {len(checks)}, passed: {passed}, failed: {failed}, review: {review}" "metadata": f"tenancy: {tenancy}, section: {section_name}, total: {len(checks)}, passed: {passed}, failed: {failed}, review: {review}"
}) })
return documents return documents
@@ -2834,18 +3155,20 @@ def _chunk_text_file(text: str, filename: str, chunk_size: int = 1000) -> list:
documents.append({"content": current_chunk, "source": filename, "metadata": f"chunk: {chunk_num}"}) documents.append({"content": current_chunk, "source": filename, "metadata": f"chunk: {chunk_num}"})
return documents return documents
def _get_adb_and_genai(vid: str): def _get_adb_and_genai(vid: str, oci_config_id: str = None):
"""Load ADB config and its linked GenAI config. Returns (adb_cfg, genai_cfg) or raises.""" """Load ADB config and resolve embed config.
Priority: ADB.genai_config_id → genai by oci_config_id → oci_config directly → any available."""
with db() as c: with db() as c:
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone() cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
if not cfg: raise HTTPException(404, "ADB config not found") if not cfg: raise HTTPException(404, "ADB config not found")
cfg = dict(cfg) cfg = dict(cfg)
if not cfg.get("genai_config_id"): genai_cfg = None
raise HTTPException(400, "GenAI config not linked to this ADB connection") if cfg.get("genai_config_id"):
with db() as c: with db() as c:
gc = c.execute("SELECT * FROM genai_configs WHERE id=?", (cfg["genai_config_id"],)).fetchone() row = c.execute("SELECT * FROM genai_configs WHERE id=?", (cfg["genai_config_id"],)).fetchone()
if not gc: raise HTTPException(400, "Linked GenAI config not found") if row: genai_cfg = dict(row)
return cfg, dict(gc) gc = _resolve_embed_config(oci_config_id=oci_config_id, genai_cfg=genai_cfg)
return cfg, gc
@app.get("/api/embeddings/preview/{rid}") @app.get("/api/embeddings/preview/{rid}")
async def preview_report_chunks(rid: str, u=Depends(current_user)): async def preview_report_chunks(rid: str, u=Depends(current_user)):
@@ -2861,9 +3184,10 @@ async def preview_report_chunks(rid: str, u=Depends(current_user)):
except Exception: except Exception:
raise HTTPException(400, "Invalid report data") raise HTTPException(400, "Invalid report data")
documents = _chunk_report_by_section(report_data) documents = _chunk_report_by_section(report_data)
return {"tenancy": report_data.get("tenancy", "unknown"), rd = report_data if isinstance(report_data, dict) else {}
"regions": report_data.get("regions", []), return {"tenancy": rd.get("tenancy", "unknown"),
"compartments": report_data.get("compartments", []), "regions": rd.get("regions", []),
"compartments": rd.get("compartments", []),
"total_chunks": len(documents), "total_chunks": len(documents),
"chunks": documents} "chunks": documents}
@@ -2872,7 +3196,7 @@ async def embed_report(rid: str, req: dict, bg: BackgroundTasks, u=Depends(requi
vid = req.get("adb_config_id") vid = req.get("adb_config_id")
if not vid: raise HTTPException(400, "adb_config_id is required") if not vid: raise HTTPException(400, "adb_config_id is required")
with db() as c: with db() as c:
r = c.execute("SELECT json_path,tenancy_name FROM reports WHERE id=? AND status='completed'", (rid,)).fetchone() r = c.execute("SELECT json_path,tenancy_name,config_id FROM reports WHERE id=? AND status='completed'", (rid,)).fetchone()
if not r: raise HTTPException(404, "Report not found or not completed") if not r: raise HTTPException(404, "Report not found or not completed")
json_path = r["json_path"] json_path = r["json_path"]
if not json_path or not Path(json_path).exists(): if not json_path or not Path(json_path).exists():
@@ -2883,17 +3207,63 @@ async def embed_report(rid: str, req: dict, bg: BackgroundTasks, u=Depends(requi
raise HTTPException(400, "Invalid report data") raise HTTPException(400, "Invalid report data")
documents = _chunk_report_by_section(report_data) documents = _chunk_report_by_section(report_data)
if not documents: raise HTTPException(400, "No sections found in report") if not documents: raise HTTPException(400, "No sections found in report")
cfg, gc = _get_adb_and_genai(vid) # Optional section filter — embed only a specific section
section_filter = req.get("section")
if section_filter:
documents = [d for d in documents if d.get("section") == section_filter]
if not documents: raise HTTPException(400, f"Section '{section_filter}' not found in report")
cfg, gc = _get_adb_and_genai(vid, oci_config_id=r.get("config_id"))
target_table = req.get("table_name") or None target_table = req.get("table_name") or None
bg.add_task(_ingest_documents_task, cfg, gc, documents, u["id"], u["username"], table_name=target_table) # Extract tenancy and compartments for isolation
_audit(u["id"], u["username"], "embed_report", rid, f"{len(documents)} sections") tenancy = report_data.get("tenancy", r["tenancy_name"] or "unknown")
return {"ok": True, "message": f"Embedding de {len(documents)} seções iniciado", "sections": len(documents)} report_date = report_data.get("generated_at", "")
compartments_list = report_data.get("compartments", [])
compartments_str = json.dumps(compartments_list) if compartments_list else ""
bg.add_task(_ingest_documents_task, cfg, gc, documents, u["id"], u["username"],
table_name=target_table, tenancy=tenancy, compartments=compartments_str,
report_date=report_date)
label = f"section={section_filter}" if section_filter else f"{len(documents)} sections"
_audit(u["id"], u["username"], "embed_report", rid, f"{label}, tenancy={tenancy}")
return {"ok": True, "message": f"Embedding de {len(documents)} seção(ões) iniciado (tenancy: {tenancy})", "sections": len(documents), "tenancy": tenancy}
def _extract_pdf_text(file_bytes: bytes) -> str:
"""Extract text from a PDF file using PyPDF2 or pdfplumber."""
try:
import PyPDF2
import io
reader = PyPDF2.PdfReader(io.BytesIO(file_bytes))
pages = []
for page in reader.pages:
text = page.extract_text()
if text:
pages.append(text.strip())
return "\n\n".join(pages)
except ImportError:
pass
try:
import pdfplumber
import io
pages = []
with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
for page in pdf.pages:
text = page.extract_text()
if text:
pages.append(text.strip())
return "\n\n".join(pages)
except ImportError:
raise HTTPException(400, "PDF support requires PyPDF2 or pdfplumber. Install: pip install PyPDF2")
@app.post("/api/embeddings/upload") @app.post("/api/embeddings/upload")
async def embed_upload(adb_config_id: str = Form(...), table_name: str = Form(""), file: UploadFile = File(...), bg: BackgroundTasks = None, u=Depends(require("admin","user"))): async def embed_upload(adb_config_id: str = Form(...), table_name: str = Form(""), file: UploadFile = File(...), bg: BackgroundTasks = None, u=Depends(require("admin","user"))):
if not file.filename.lower().endswith(".txt"): fname = file.filename.lower()
raise HTTPException(400, "Only .txt files are supported") allowed = ('.txt', '.pdf', '.csv', '.json', '.md')
content = (await file.read()).decode("utf-8", errors="replace") if not any(fname.endswith(ext) for ext in allowed):
raise HTTPException(400, f"Formatos aceitos: {', '.join(allowed)}")
raw = await file.read()
if fname.endswith('.pdf'):
content = _extract_pdf_text(raw)
else:
content = raw.decode("utf-8", errors="replace")
if not content.strip(): raise HTTPException(400, "File is empty") if not content.strip(): raise HTTPException(400, "File is empty")
documents = _chunk_text_file(content, file.filename) documents = _chunk_text_file(content, file.filename)
if not documents: raise HTTPException(400, "No content chunks found") if not documents: raise HTTPException(400, "No content chunks found")
@@ -2903,6 +3273,52 @@ async def embed_upload(adb_config_id: str = Form(...), table_name: str = Form(""
_audit(u["id"], u["username"], "embed_upload", file.filename, f"{len(documents)} chunks") _audit(u["id"], u["username"], "embed_upload", file.filename, f"{len(documents)} chunks")
return {"ok": True, "message": f"Embedding de {len(documents)} chunks iniciado", "chunks": len(documents), "filename": file.filename} return {"ok": True, "message": f"Embedding de {len(documents)} chunks iniciado", "chunks": len(documents), "filename": file.filename}
def _extract_text_from_html(html: str) -> str:
"""Extract readable text from HTML, stripping tags and scripts."""
import re as _re
text = _re.sub(r'<script[^>]*>[\s\S]*?</script>', ' ', html, flags=_re.IGNORECASE)
text = _re.sub(r'<style[^>]*>[\s\S]*?</style>', ' ', text, flags=_re.IGNORECASE)
text = _re.sub(r'<[^>]+>', ' ', text)
text = _re.sub(r'&[a-zA-Z]+;', ' ', text)
text = _re.sub(r'&#\d+;', ' ', text)
text = _re.sub(r'\s+', ' ', text).strip()
return text
@app.post("/api/embeddings/upload-url")
async def embed_upload_url(
adb_config_id: str = Form(...),
table_name: str = Form(""),
url: str = Form(...),
bg: BackgroundTasks = None,
u=Depends(require("admin", "user"))
):
import requests as req
url = url.strip()
if not url.startswith(("http://", "https://")):
raise HTTPException(400, "URL inválida — deve começar com http:// ou https://")
try:
resp = req.get(url, timeout=30, headers={"User-Agent": "Mozilla/5.0 OCI-CIS-Agent/1.0"})
resp.raise_for_status()
except Exception as e:
raise HTTPException(400, f"Erro ao acessar URL: {str(e)[:300]}")
ct = resp.headers.get("content-type", "")
if "pdf" in ct:
content = _extract_pdf_text(resp.content)
elif "html" in ct or "text" in ct:
content = _extract_text_from_html(resp.text)
else:
content = resp.text
if not content or not content.strip():
raise HTTPException(400, "Nenhum conteúdo extraído da URL")
documents = _chunk_text_file(content, url)
if not documents:
raise HTTPException(400, "Nenhum chunk gerado do conteúdo")
cfg, gc = _get_adb_and_genai(adb_config_id)
target_table = table_name.strip() or None
bg.add_task(_ingest_documents_task, cfg, gc, documents, u["id"], u["username"], table_name=target_table)
_audit(u["id"], u["username"], "embed_url", url, f"{len(documents)} chunks")
return {"ok": True, "message": f"Embedding de {len(documents)} chunks iniciado", "chunks": len(documents), "url": url}
@app.get("/api/embeddings/{vid}/list") @app.get("/api/embeddings/{vid}/list")
async def list_embeddings(vid: str, table_name: str = Query(""), limit: int = Query(50), offset: int = Query(0), u=Depends(current_user)): async def list_embeddings(vid: str, table_name: str = Query(""), limit: int = Query(50), offset: int = Query(0), u=Depends(current_user)):
with db() as c: with db() as c:
@@ -2913,17 +3329,18 @@ async def list_embeddings(vid: str, table_name: str = Query(""), limit: int = Qu
cur = conn.cursor() cur = conn.cursor()
table_name = table_name.strip() or cfg["table_name"] table_name = table_name.strip() or cfg["table_name"]
if not table_name: raise HTTPException(400, "Nenhuma tabela selecionada") if not table_name: raise HTTPException(400, "Nenhuma tabela selecionada")
cur.execute(f"SELECT COUNT(*) FROM {table_name}") cur.execute(f'SELECT COUNT(*) FROM "{table_name}"')
total = cur.fetchone()[0] total = cur.fetchone()[0]
cur.execute(f""" cur.execute(f"""
SELECT ID, SOURCE, METADATA, CREATED_AT FROM {table_name} SELECT ID, METADATA FROM "{table_name}"
ORDER BY CREATED_AT DESC
OFFSET :1 ROWS FETCH NEXT :2 ROWS ONLY OFFSET :1 ROWS FETCH NEXT :2 ROWS ONLY
""", [offset, limit]) """, [offset, limit])
rows = [] rows = []
for row in cur: for row in cur:
rows.append({"id": row[0], "source": row[1], "metadata": row[2], rid = row[0].hex() if isinstance(row[0], bytes) else str(row[0])
"created_at": str(row[3]) if row[3] else None}) meta = row[1]
if hasattr(meta, 'read'): meta = meta.read()
rows.append({"id": rid, "metadata": meta})
cur.close(); conn.close() cur.close(); conn.close()
return {"total": total, "offset": offset, "limit": limit, "documents": rows} return {"total": total, "offset": offset, "limit": limit, "documents": rows}
except Exception as e: except Exception as e:
@@ -2939,13 +3356,42 @@ async def delete_embedding(vid: str, doc_id: str, table_name: str = Query(""), u
cur = conn.cursor() cur = conn.cursor()
table_name = table_name.strip() or cfg["table_name"] table_name = table_name.strip() or cfg["table_name"]
if not table_name: raise HTTPException(400, "Nenhuma tabela selecionada") if not table_name: raise HTTPException(400, "Nenhuma tabela selecionada")
cur.execute(f"DELETE FROM {table_name} WHERE ID = :1", [doc_id]) cur.execute(f'DELETE FROM "{table_name}" WHERE ID = :1', [doc_id])
conn.commit() conn.commit()
cur.close(); conn.close() cur.close(); conn.close()
return {"ok": True} return {"ok": True}
except Exception as e: except Exception as e:
raise HTTPException(500, f"Erro ao deletar: {str(e)[:500]}") raise HTTPException(500, f"Erro ao deletar: {str(e)[:500]}")
@app.post("/api/embeddings/{vid}/purge")
async def purge_embeddings(vid: str, req: dict, u=Depends(require("admin","user"))):
"""Delete old embeddings from a table, optionally filtered by tenancy."""
table_name = req.get("table_name", "").strip()
tenancy = req.get("tenancy", "").strip()
if not table_name: raise HTTPException(400, "table_name é obrigatório")
with db() as c:
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
if not cfg: raise HTTPException(404)
try:
conn = _get_adb_connection(dict(cfg))
cur = conn.cursor()
if tenancy:
cur.execute(f'SELECT COUNT(*) FROM "{table_name}" WHERE METADATA LIKE :1',
[f'%"tenancy":"{tenancy}"%'])
count = cur.fetchone()[0]
cur.execute(f'DELETE FROM "{table_name}" WHERE METADATA LIKE :1',
[f'%"tenancy":"{tenancy}"%'])
else:
cur.execute(f'SELECT COUNT(*) FROM "{table_name}"')
count = cur.fetchone()[0]
cur.execute(f'DELETE FROM "{table_name}"')
conn.commit()
cur.close(); conn.close()
_audit(u["id"], u["username"], "purge_embeddings", vid, f"table={table_name}, tenancy={tenancy or 'ALL'}, deleted={count}")
return {"ok": True, "deleted": count, "table": table_name, "tenancy": tenancy or "ALL"}
except Exception as e:
raise HTTPException(500, f"Erro ao limpar: {str(e)[:500]}")
# ── Reports ─────────────────────────────────────────────────────────────────── # ── Reports ───────────────────────────────────────────────────────────────────
def _classify_report_file(fname: str) -> str: def _classify_report_file(fname: str) -> str:
@@ -3158,7 +3604,9 @@ async def list_report_files(rid: str, u=Depends(current_user)):
return [dict(f) for f in files] return [dict(f) for f in files]
@app.get("/api/reports/{rid}/files/{fid}/download") @app.get("/api/reports/{rid}/files/{fid}/download")
async def download_report_file(rid: str, fid: str, u=Depends(current_user)): async def download_report_file(rid: str, fid: str, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
if not u: raise HTTPException(401, "Not authenticated")
with db() as c: with db() as c:
r = c.execute("SELECT user_id FROM reports WHERE id=?", (rid,)).fetchone() r = c.execute("SELECT user_id FROM reports WHERE id=?", (rid,)).fetchone()
if not r: raise HTTPException(404) if not r: raise HTTPException(404)
@@ -3176,7 +3624,9 @@ async def report_html(rid):
return FileResponse(r["html_path"], media_type="text/html") return FileResponse(r["html_path"], media_type="text/html")
@app.get("/api/reports/{rid}/download") @app.get("/api/reports/{rid}/download")
async def report_dl(rid, fmt: str = Query("json"), u=Depends(current_user)): async def report_dl(rid, fmt: str = Query("json"), token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
if not u: raise HTTPException(401, "Not authenticated")
with db() as c: r=c.execute("SELECT * FROM reports WHERE id=?",(rid,)).fetchone() with db() as c: r=c.execute("SELECT * FROM reports WHERE id=?",(rid,)).fetchone()
if not r: raise HTTPException(404) if not r: raise HTTPException(404)
p = r["json_path"] if fmt=="json" else r["html_path"] p = r["json_path"] if fmt=="json" else r["html_path"]
@@ -3265,23 +3715,37 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
history = [{"role":r["role"],"content":r["content"]} for r in prev] history = [{"role":r["role"],"content":r["content"]} for r in prev]
# ── RAG: augment with vector context from ALL active ADB configs ── # ── RAG: augment with vector context from ALL active ADB configs ──
# Resolve active tenancy for filtered vector search
rag_tenancy = None
active_oci_for_rag = genai_cfg.get("oci_config_id") or (msg.oci_config_id if hasattr(msg, 'oci_config_id') and msg.oci_config_id else None)
if active_oci_for_rag:
with db() as c:
oci_for_rag = c.execute("SELECT tenancy_name FROM oci_configs WHERE id=?", (active_oci_for_rag,)).fetchone()
if oci_for_rag:
rag_tenancy = oci_for_rag["tenancy_name"]
log.info(f"RAG: filtering by tenancy '{rag_tenancy}'")
rag_context = "" rag_context = ""
adb_cfgs = _get_active_adb_configs(user["id"]) adb_cfgs = _get_active_adb_configs(user["id"])
if adb_cfgs: if adb_cfgs:
all_documents = [] all_documents = []
for adb_cfg in adb_cfgs: for adb_cfg in adb_cfgs:
try: try:
with db() as c: genai_linked = None
emb_genai = c.execute("SELECT * FROM genai_configs WHERE id=?", (adb_cfg["genai_config_id"],)).fetchone() if adb_cfg.get("genai_config_id"):
with db() as c:
row = c.execute("SELECT * FROM genai_configs WHERE id=?", (adb_cfg["genai_config_id"],)).fetchone()
if row: genai_linked = dict(row)
emb_genai = _resolve_embed_config(oci_config_id=active_oci_for_rag, genai_cfg=genai_linked)
if emb_genai: if emb_genai:
emb_model = adb_cfg.get("embedding_model_id", "cohere.embed-v4.0") emb_model = adb_cfg.get("embedding_model_id", "cohere.embed-v4.0")
query_embedding = _embed_text(msg.message, dict(emb_genai), emb_model) query_embedding = _embed_text(msg.message, emb_genai, emb_model)
tables = _get_tables_for_config(adb_cfg["id"], active_only=True) tables = _get_tables_for_config(adb_cfg["id"], active_only=True)
if not tables: if not tables:
tables = [{"table_name": adb_cfg.get("table_name", "")}] tables = [{"table_name": adb_cfg.get("table_name", "")}]
for tbl in tables: for tbl in tables:
try: try:
documents = _vector_search(adb_cfg, query_embedding, top_k=5, table_name=tbl["table_name"]) documents = _vector_search(adb_cfg, query_embedding, top_k=5, table_name=tbl["table_name"], tenancy=rag_tenancy)
if documents: if documents:
for doc in documents: for doc in documents:
doc["source"] = f"{doc.get('source', 'unknown')} [{tbl['table_name']}]" doc["source"] = f"{doc.get('source', 'unknown')} [{tbl['table_name']}]"
@@ -3993,7 +4457,9 @@ async def tf_workspace_status(wid: str, u=Depends(current_user)):
@app.get("/api/terraform/workspaces/{wid}/download") @app.get("/api/terraform/workspaces/{wid}/download")
async def tf_download(wid: str, u=Depends(current_user)): async def tf_download(wid: str, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
if not u: raise HTTPException(401, "Not authenticated")
with db() as c: with db() as c:
r = c.execute("SELECT tf_code, name FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone() r = c.execute("SELECT tf_code, name FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone()
if not r or not r["tf_code"]: raise HTTPException(404, "No code found") if not r or not r["tf_code"]: raise HTTPException(404, "No code found")

View File

@@ -7,3 +7,4 @@ oci==2.133.0
oracledb==2.4.1 oracledb==2.4.1
mcp>=1.0.0 mcp>=1.0.0
requests>=2.31.0 requests>=2.31.0
PyPDF2>=3.0.0

View File

@@ -526,7 +526,7 @@ const S={user:null,token:null,tab:'chat',msgs:[],sid:null,reports:[],ociCfg:[],g
chatParams:{temperature:1,max_tokens:6000,top_p:0.95,top_k:1,frequency_penalty:0,presence_penalty:0},chatPanel:'',chatUseTools:true, chatParams:{temperature:1,max_tokens:6000,top_p:0.95,top_k:1,frequency_penalty:0,presence_penalty:0},chatPanel:'',chatUseTools:true,
chatPrompts:[],editingPrompt:null,trackingReportId:null,ociRegions:{},rptSelRegions:[],rptRegionsOpen:false,rptRegionFilter:'', chatPrompts:[],editingPrompt:null,trackingReportId:null,ociRegions:{},rptSelRegions:[],rptRegionsOpen:false,rptRegionFilter:'',
rptOciOpen:false,rptOciFilter:'',rptOciVal:'',rptRselOpen:false,rptRselFilter:'',rptRselVal:'',ociFormRegOpen:false,ociFormRegFilter:'',ociFormRegVal:'', rptOciOpen:false,rptOciFilter:'',rptOciVal:'',rptRselOpen:false,rptRselFilter:'',rptRselVal:'',ociFormRegOpen:false,ociFormRegFilter:'',ociFormRegVal:'',
rptLevel:2,rptObp:false,rptRaw:false,rptRedact:false,reportFiles:{},dlExpandedRid:null,dlTenancyFilter:'', rptLevel:2,rptObp:false,rptRaw:false,rptRedact:false,reportFiles:{},dlExpandedRid:null,dlTenancyFilter:'',dlSectionFilter:'',
cisVer:null,cisCheckResult:null,cisUpdating:false,rptHistFilter:'',rptKpi:null,rptKpiLoading:false,chatFiles:[], cisVer:null,cisCheckResult:null,cisUpdating:false,rptHistFilter:'',rptKpi:null,rptKpiLoading:false,chatFiles:[],
tfMsgs:[],tfSid:null,tfModel:'',tfOci:'',tfRegion:'',tfCompartment:'',tfPlan:[],tfCode:'',tfPanel:'', tfMsgs:[],tfSid:null,tfModel:'',tfOci:'',tfRegion:'',tfCompartment:'',tfPlan:[],tfCode:'',tfPanel:'',
tfWs:null,tfWsList:[],tfPlanOut:'',tfApplyOut:'',tfDestroyOut:'',tfStatus:'draft',tfRunning:false,tfConfirmDest:false,tfMdOpen:false, tfWs:null,tfWsList:[],tfPlanOut:'',tfApplyOut:'',tfDestroyOut:'',tfStatus:'draft',tfRunning:false,tfConfirmDest:false,tfMdOpen:false,
@@ -647,7 +647,7 @@ function rChat(){
else if(S.chatModel){const mi=S.models[S.chatModel];curLabel=mi?mi.name:S.chatModel} else if(S.chatModel){const mi=S.models[S.chatModel];curLabel=mi?mi.name:S.chatModel}
const isDirect=S.chatModel&&!S.chatModel.startsWith('cfg:'); const isDirect=S.chatModel&&!S.chatModel.startsWith('cfg:');
const ociSel=isDirect?`<select id="coci" onchange="chatOciChanged()" style="max-width:200px"><option value="">Credencial OCI...</option>${S.ociCfg.map(c=>`<option value="${c.id}" ${S.chatOci===c.id?'selected':''}>${c.tenancy_name} (${c.region})</option>`).join('')}</select>`:''; const ociSel=isDirect?`<select id="coci" onchange="chatOciChanged()" style="max-width:200px"><option value="">Credencial OCI...</option>${S.ociCfg.map(c=>`<option value="${c.id}" ${S.chatOci===c.id?'selected':''}>${c.tenancy_name} (${c.region})</option>`).join('')}</select>`:'';
const ragBadge=S.adbCfg.some(c=>c.genai_config_id&&c.is_active)?'<span class="tag" style="background:var(--gnl);color:var(--gn);font-size:.62rem">RAG</span>':''; const ragBadge=S.adbCfg.some(c=>c.is_active)&&(S.genaiCfg.length||S.ociCfg.length)?'<span class="tag" style="background:var(--gnl);color:var(--gn);font-size:.62rem">RAG</span>':'';
const toolCount=S.mcpSvr.filter(m=>m.is_active&&Array.isArray(m.tools)&&m.tools.length).reduce((a,m)=>a+m.tools.length,0); const toolCount=S.mcpSvr.filter(m=>m.is_active&&Array.isArray(m.tools)&&m.tools.length).reduce((a,m)=>a+m.tools.length,0);
let sideContent=''; let sideContent='';
if(S.chatPanel==='config')sideContent=rChatConfig(isDirect,toolCount); if(S.chatPanel==='config')sideContent=rChatConfig(isDirect,toolCount);
@@ -1729,6 +1729,7 @@ async function expLoadRes(){
function renderExpData(d){if(!d)return'<div class="emp"><p>Selecione um compartment na árvore.</p></div>'; function renderExpData(d){if(!d)return'<div class="emp"><p>Selecione um compartment na árvore.</p></div>';
if(d.error)return`<div class="al al-e">${d.error}</div>`;if(!Array.isArray(d)||!d.length)return'<div class="emp"><p>Nenhum recurso encontrado neste compartment.</p></div>'; if(d.error)return`<div class="al al-e">${d.error}</div>`;if(!Array.isArray(d)||!d.length)return'<div class="emp"><p>Nenhum recurso encontrado neste compartment.</p></div>';
if(S.expResType==='security_lists')return rExpSecLists(d); if(S.expResType==='security_lists')return rExpSecLists(d);
if(S.expResType==='nsgs')return rExpNsgs(d);
if(S.expResType==='route_tables')return rExpRouteTables(d); if(S.expResType==='route_tables')return rExpRouteTables(d);
const isInst=S.expResType==='instances'; const isInst=S.expResType==='instances';
const isAdb=S.expResType==='databases'; const isAdb=S.expResType==='databases';
@@ -1750,18 +1751,119 @@ function rExpSecLists(d){
const ingress=rules.filter(r=>r.direction==='ingress'); const ingress=rules.filter(r=>r.direction==='ingress');
const egress=rules.filter(r=>r.direction==='egress'); const egress=rules.filter(r=>r.direction==='egress');
const expanded=S._expExpanded&&S._expExpanded[idx]; const expanded=S._expExpanded&&S._expExpanded[idx];
const showForm=S._slAddForm===sl.id;
return`<div class="exp-c" style="max-width:100%"><div style="display:flex;justify-content:space-between;align-items:center;cursor:pointer" onclick="S._expExpanded=S._expExpanded||{};S._expExpanded[${idx}]=!S._expExpanded[${idx}];R()"> return`<div class="exp-c" style="max-width:100%"><div style="display:flex;justify-content:space-between;align-items:center;cursor:pointer" onclick="S._expExpanded=S._expExpanded||{};S._expExpanded[${idx}]=!S._expExpanded[${idx}];R()">
<div><strong style="display:inline">${sl.display_name}</strong> <span class="badge ${sl.lifecycle_state==='AVAILABLE'?'b-pass':'b-pend'}">${sl.lifecycle_state}</span></div> <div><strong style="display:inline">${sl.display_name}</strong> <span class="badge ${sl.lifecycle_state==='AVAILABLE'?'b-pass':'b-pend'}">${sl.lifecycle_state}</span></div>
<div style="font-size:.66rem;color:var(--t4)">${expanded?'▼':'▶'} ${ingress.length} ingress · ${egress.length} egress</div></div> <div style="font-size:.66rem;color:var(--t4)">${expanded?'▼':'▶'} ${ingress.length} ingress · ${egress.length} egress</div></div>
<div class="em" style="margin-top:.3rem">id: ${sl.id}</div> <div class="em" style="margin-top:.3rem">id: ${sl.id}</div>
${expanded?`<div style="margin-top:.5rem">${ingress.length?`<div style="font-size:.68rem;font-weight:600;color:var(--t2);margin:.4rem 0 .2rem">↓ Ingress (${ingress.length})</div> ${expanded?`<div style="margin-top:.5rem">
<table style="width:100%;font-size:.66rem;border-collapse:collapse"><thead><tr style="background:var(--bg3);text-align:left"><th style="padding:.25rem .4rem">Protocolo</th><th style="padding:.25rem .4rem">Origem</th><th style="padding:.25rem .4rem">Portas</th><th style="padding:.25rem .4rem">Stateless</th><th style="padding:.25rem .4rem">Descrição</th></tr></thead><tbody> <div style="display:flex;justify-content:flex-end;margin-bottom:.4rem"><button class="btn bp bsm" onclick="event.stopPropagation();S._slAddForm=S._slAddForm==='${sl.id}'?null:'${sl.id}';R()" style="font-size:.62rem">${showForm?'✕ Cancelar':'+ Adicionar Regra'}</button></div>
${ingress.map(r=>`<tr style="border-top:1px solid var(--bd)"><td style="padding:.25rem .4rem;font-weight:600">${r.protocol}</td><td style="padding:.25rem .4rem;font-family:var(--fm);font-size:.62rem">${r.source_dest}</td><td style="padding:.25rem .4rem">${r.ports||'ALL'}</td><td style="padding:.25rem .4rem">${r.stateless?'Sim':'Não'}</td><td style="padding:.25rem .4rem;color:var(--t4);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${(r.description||'').replace(/"/g,'&quot;')}">${r.description||'—'}</td></tr>`).join('')} ${showForm?`<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:.6rem;margin-bottom:.6rem" onclick="event.stopPropagation()">
<div style="font-size:.7rem;font-weight:600;color:var(--t2);margin-bottom:.4rem">${IC.shield} Nova Regra</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:.4rem">
<div class="ig"><label style="font-size:.62rem">Direção</label><select id="slDir" style="font-size:.72rem"><option value="ingress">Ingress</option><option value="egress">Egress</option></select></div>
<div class="ig"><label style="font-size:.62rem">Protocolo</label><select id="slProto" style="font-size:.72rem"><option value="6">TCP</option><option value="17">UDP</option><option value="1">ICMP</option><option value="all">ALL</option></select></div>
<div class="ig"><label style="font-size:.62rem">Stateless</label><select id="slStateless" style="font-size:.72rem"><option value="false">Não</option><option value="true">Sim</option></select></div>
<div class="ig"><label style="font-size:.62rem">Origem/Destino (CIDR)</label><input id="slCidr" style="font-size:.72rem" placeholder="0.0.0.0/0" value="0.0.0.0/0"></div>
<div class="ig"><label style="font-size:.62rem">Porta Início</label><input id="slPortMin" type="number" style="font-size:.72rem" placeholder="ex: 443"></div>
<div class="ig"><label style="font-size:.62rem">Porta Fim</label><input id="slPortMax" type="number" style="font-size:.72rem" placeholder="ex: 443"></div>
</div>
<div class="ig" style="margin-top:.3rem"><label style="font-size:.62rem">Descrição</label><input id="slDesc" style="font-size:.72rem" placeholder="Descrição da regra"></div>
<div style="display:flex;gap:.3rem;margin-top:.5rem;justify-content:flex-end"><button class="btn bp bsm" onclick="slAddRule('${sl.id}')" style="font-size:.64rem">Salvar Regra</button>
<button class="btn bs bsm" onclick="slAddMyIp('${sl.id}')" style="font-size:.64rem" title="Detecta seu IP público e adiciona como regra">${IC.globe} Meu IP</button></div>
<div id="slFormMsg" style="margin-top:.3rem"></div>
</div>`:''}
${ingress.length?`<div style="font-size:.68rem;font-weight:600;color:var(--t2);margin:.4rem 0 .2rem">↓ Ingress (${ingress.length})</div>
<table style="width:100%;font-size:.66rem;border-collapse:collapse"><thead><tr style="background:var(--bg3);text-align:left"><th style="padding:.25rem .4rem">Protocolo</th><th style="padding:.25rem .4rem">Origem</th><th style="padding:.25rem .4rem">Portas</th><th style="padding:.25rem .4rem">Stateless</th><th style="padding:.25rem .4rem">Descrição</th><th style="padding:.25rem .4rem;width:32px"></th></tr></thead><tbody>
${ingress.map(r=>`<tr style="border-top:1px solid var(--bd)"><td style="padding:.25rem .4rem;font-weight:600">${r.protocol}</td><td style="padding:.25rem .4rem;font-family:var(--fm);font-size:.62rem">${r.source_dest}</td><td style="padding:.25rem .4rem">${r.ports||'ALL'}</td><td style="padding:.25rem .4rem">${r.stateless?'Sim':'Não'}</td><td style="padding:.25rem .4rem;color:var(--t4);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${(r.description||'').replace(/"/g,'&quot;')}">${r.description||'—'}</td><td style="padding:.25rem .2rem"><button class="btn bd bsm" style="font-size:.55rem;padding:1px 5px" onclick="event.stopPropagation();slRemoveRule('${sl.id}','ingress',${r.rule_index})" title="Remover regra">✕</button></td></tr>`).join('')}
</tbody></table>`:''} </tbody></table>`:''}
${egress.length?`<div style="font-size:.68rem;font-weight:600;color:var(--t2);margin:.4rem 0 .2rem">↑ Egress (${egress.length})</div> ${egress.length?`<div style="font-size:.68rem;font-weight:600;color:var(--t2);margin:.4rem 0 .2rem">↑ Egress (${egress.length})</div>
<table style="width:100%;font-size:.66rem;border-collapse:collapse"><thead><tr style="background:var(--bg3);text-align:left"><th style="padding:.25rem .4rem">Protocolo</th><th style="padding:.25rem .4rem">Destino</th><th style="padding:.25rem .4rem">Portas</th><th style="padding:.25rem .4rem">Stateless</th><th style="padding:.25rem .4rem">Descrição</th></tr></thead><tbody> <table style="width:100%;font-size:.66rem;border-collapse:collapse"><thead><tr style="background:var(--bg3);text-align:left"><th style="padding:.25rem .4rem">Protocolo</th><th style="padding:.25rem .4rem">Destino</th><th style="padding:.25rem .4rem">Portas</th><th style="padding:.25rem .4rem">Stateless</th><th style="padding:.25rem .4rem">Descrição</th><th style="padding:.25rem .4rem;width:32px"></th></tr></thead><tbody>
${egress.map(r=>`<tr style="border-top:1px solid var(--bd)"><td style="padding:.25rem .4rem;font-weight:600">${r.protocol}</td><td style="padding:.25rem .4rem;font-family:var(--fm);font-size:.62rem">${r.source_dest}</td><td style="padding:.25rem .4rem">${r.ports||'ALL'}</td><td style="padding:.25rem .4rem">${r.stateless?'Sim':'Não'}</td><td style="padding:.25rem .4rem;color:var(--t4);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${(r.description||'').replace(/"/g,'&quot;')}">${r.description||'—'}</td></tr>`).join('')} ${egress.map(r=>`<tr style="border-top:1px solid var(--bd)"><td style="padding:.25rem .4rem;font-weight:600">${r.protocol}</td><td style="padding:.25rem .4rem;font-family:var(--fm);font-size:.62rem">${r.source_dest}</td><td style="padding:.25rem .4rem">${r.ports||'ALL'}</td><td style="padding:.25rem .4rem">${r.stateless?'Sim':'Não'}</td><td style="padding:.25rem .4rem;color:var(--t4);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${(r.description||'').replace(/"/g,'&quot;')}">${r.description||'—'}</td><td style="padding:.25rem .2rem"><button class="btn bd bsm" style="font-size:.55rem;padding:1px 5px" onclick="event.stopPropagation();slRemoveRule('${sl.id}','egress',${r.rule_index})" title="Remover regra">✕</button></td></tr>`).join('')}
</tbody></table>`:''}</div>`:''}</div>`}).join('')}`} </tbody></table>`:''}</div>`:''}</div>`}).join('')}`}
async function slAddRule(slId){
const dir=document.getElementById('slDir').value;
const proto=document.getElementById('slProto').value;
const cidr=document.getElementById('slCidr').value.trim();
const portMin=document.getElementById('slPortMin').value;
const portMax=document.getElementById('slPortMax').value;
const desc=document.getElementById('slDesc').value;
const stateless=document.getElementById('slStateless').value==='true';
if(!cidr){sm('slFormMsg','Informe o CIDR.','e');return}
const region=S.expSelRegions.length===1?S.expSelRegions[0]:null;
const body={direction:dir,protocol:proto,source_dest:cidr,description:desc,is_stateless:stateless,region:region||undefined};
if(proto!=='all'&&proto!=='1'&&portMin)Object.assign(body,{port_min:parseInt(portMin),port_max:parseInt(portMax||portMin)});
try{const d=await $api('/oci/explore/'+S.expCfg+'/security_lists/'+slId+'/rules',{method:'POST',body});
sm('slFormMsg',IC.ok+' '+d.message,'s');S._slAddForm=null;await expLoadRes()}catch(e){sm('slFormMsg',e.message,'e')}}
async function slRemoveRule(slId,direction,ruleIndex){
if(!confirm('Remover esta regra da Security List?'))return;
const region=S.expSelRegions.length===1?S.expSelRegions[0]:null;
try{await $api('/oci/explore/'+S.expCfg+'/security_lists/'+slId+'/rules',{method:'DELETE',body:{direction,rule_index:ruleIndex,region:region||undefined}});await expLoadRes()}catch(e){alert('Erro: '+e.message)}}
async function slAddMyIp(slId){
try{const resp=await fetch('https://api.ipify.org?format=json');const data=await resp.json();const myIp=data.ip+'/32';
document.getElementById('slCidr').value=myIp;
document.getElementById('slDesc').value='Acesso temporário - '+myIp;
sm('slFormMsg',IC.ok+' IP detectado: '+myIp+' — ajuste porta e clique Salvar','s')}catch(e){sm('slFormMsg','Não foi possível detectar o IP: '+e.message,'e')}}
function rExpNsgs(d){
return`<div style="font-size:.74rem;color:var(--t4);margin-bottom:.5rem">${d.length} NSG(s)</div>${d.map((nsg,idx)=>{
const rules=nsg.rules||[];
const ingress=rules.filter(r=>r.direction==='INGRESS');
const egress=rules.filter(r=>r.direction==='EGRESS');
const expanded=S._expExpanded&&S._expExpanded['nsg'+idx];
const showForm=S._nsgAddForm===nsg.id;
return`<div class="exp-c" style="max-width:100%"><div style="display:flex;justify-content:space-between;align-items:center;cursor:pointer" onclick="S._expExpanded=S._expExpanded||{};S._expExpanded['nsg'+${idx}]=!S._expExpanded['nsg'+${idx}];R()">
<div><strong style="display:inline">${nsg.display_name}</strong> <span class="badge ${nsg.lifecycle_state==='AVAILABLE'?'b-pass':'b-pend'}">${nsg.lifecycle_state}</span></div>
<div style="font-size:.66rem;color:var(--t4)">${expanded?'▼':'▶'} ${ingress.length} ingress · ${egress.length} egress</div></div>
<div class="em" style="margin-top:.3rem">id: ${nsg.id}</div>
${expanded?`<div style="margin-top:.5rem">
<div style="display:flex;justify-content:flex-end;margin-bottom:.4rem"><button class="btn bp bsm" onclick="event.stopPropagation();S._nsgAddForm=S._nsgAddForm==='${nsg.id}'?null:'${nsg.id}';R()" style="font-size:.62rem">${showForm?'✕ Cancelar':'+ Adicionar Regra'}</button></div>
${showForm?`<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:.6rem;margin-bottom:.6rem" onclick="event.stopPropagation()">
<div style="font-size:.7rem;font-weight:600;color:var(--t2);margin-bottom:.4rem">${IC.shield} Nova Regra</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:.4rem">
<div class="ig"><label style="font-size:.62rem">Direção</label><select id="nsgDir" style="font-size:.72rem"><option value="INGRESS">Ingress</option><option value="EGRESS">Egress</option></select></div>
<div class="ig"><label style="font-size:.62rem">Protocolo</label><select id="nsgProto" style="font-size:.72rem"><option value="6">TCP</option><option value="17">UDP</option><option value="1">ICMP</option><option value="all">ALL</option></select></div>
<div class="ig"><label style="font-size:.62rem">Stateless</label><select id="nsgStateless" style="font-size:.72rem"><option value="false">Não</option><option value="true">Sim</option></select></div>
<div class="ig"><label style="font-size:.62rem">Origem/Destino (CIDR)</label><input id="nsgCidr" style="font-size:.72rem" placeholder="0.0.0.0/0" value="0.0.0.0/0"></div>
<div class="ig"><label style="font-size:.62rem">Porta Início</label><input id="nsgPortMin" type="number" style="font-size:.72rem" placeholder="ex: 443"></div>
<div class="ig"><label style="font-size:.62rem">Porta Fim</label><input id="nsgPortMax" type="number" style="font-size:.72rem" placeholder="ex: 443"></div>
</div>
<div class="ig" style="margin-top:.3rem"><label style="font-size:.62rem">Descrição</label><input id="nsgDesc" style="font-size:.72rem" placeholder="Descrição da regra"></div>
<div style="display:flex;gap:.3rem;margin-top:.5rem;justify-content:flex-end"><button class="btn bp bsm" onclick="nsgAddRule('${nsg.id}')" style="font-size:.64rem">Salvar Regra</button>
<button class="btn bs bsm" onclick="nsgAddMyIp('${nsg.id}')" style="font-size:.64rem" title="Detecta seu IP público e adiciona como regra">${IC.globe} Meu IP</button></div>
<div id="nsgFormMsg" style="margin-top:.3rem"></div>
</div>`:''}
${ingress.length?`<div style="font-size:.68rem;font-weight:600;color:var(--t2);margin:.4rem 0 .2rem">↓ Ingress (${ingress.length})</div>
<table style="width:100%;font-size:.66rem;border-collapse:collapse"><thead><tr style="background:var(--bg3);text-align:left"><th style="padding:.25rem .4rem">Protocolo</th><th style="padding:.25rem .4rem">Origem</th><th style="padding:.25rem .4rem">Portas</th><th style="padding:.25rem .4rem">Stateless</th><th style="padding:.25rem .4rem">Descrição</th><th style="padding:.25rem .4rem;width:32px"></th></tr></thead><tbody>
${ingress.map(r=>`<tr style="border-top:1px solid var(--bd)"><td style="padding:.25rem .4rem;font-weight:600">${r.protocol}</td><td style="padding:.25rem .4rem;font-family:var(--fm);font-size:.62rem">${r.source_dest}</td><td style="padding:.25rem .4rem">${r.ports||'ALL'}</td><td style="padding:.25rem .4rem">${r.is_stateless?'Sim':'Não'}</td><td style="padding:.25rem .4rem;color:var(--t4);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${(r.description||'').replace(/"/g,'&quot;')}">${r.description||'—'}</td><td style="padding:.25rem .2rem"><button class="btn bd bsm" style="font-size:.55rem;padding:1px 5px" onclick="event.stopPropagation();nsgRemoveRule('${nsg.id}','${r.id}')" title="Remover regra">✕</button></td></tr>`).join('')}
</tbody></table>`:''}
${egress.length?`<div style="font-size:.68rem;font-weight:600;color:var(--t2);margin:.4rem 0 .2rem">↑ Egress (${egress.length})</div>
<table style="width:100%;font-size:.66rem;border-collapse:collapse"><thead><tr style="background:var(--bg3);text-align:left"><th style="padding:.25rem .4rem">Protocolo</th><th style="padding:.25rem .4rem">Destino</th><th style="padding:.25rem .4rem">Portas</th><th style="padding:.25rem .4rem">Stateless</th><th style="padding:.25rem .4rem">Descrição</th><th style="padding:.25rem .4rem;width:32px"></th></tr></thead><tbody>
${egress.map(r=>`<tr style="border-top:1px solid var(--bd)"><td style="padding:.25rem .4rem;font-weight:600">${r.protocol}</td><td style="padding:.25rem .4rem;font-family:var(--fm);font-size:.62rem">${r.source_dest}</td><td style="padding:.25rem .4rem">${r.ports||'ALL'}</td><td style="padding:.25rem .4rem">${r.is_stateless?'Sim':'Não'}</td><td style="padding:.25rem .4rem;color:var(--t4);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${(r.description||'').replace(/"/g,'&quot;')}">${r.description||'—'}</td><td style="padding:.25rem .2rem"><button class="btn bd bsm" style="font-size:.55rem;padding:1px 5px" onclick="event.stopPropagation();nsgRemoveRule('${nsg.id}','${r.id}')" title="Remover regra">✕</button></td></tr>`).join('')}
</tbody></table>`:''}</div>`:''}</div>`}).join('')}`}
async function nsgAddRule(nsgId){
const dir=document.getElementById('nsgDir').value;
const proto=document.getElementById('nsgProto').value;
const cidr=document.getElementById('nsgCidr').value.trim();
const portMin=document.getElementById('nsgPortMin').value;
const portMax=document.getElementById('nsgPortMax').value;
const desc=document.getElementById('nsgDesc').value;
const stateless=document.getElementById('nsgStateless').value==='true';
if(!cidr){sm('nsgFormMsg','Informe o CIDR.','e');return}
const region=S.expSelRegions.length===1?S.expSelRegions[0]:null;
const body={direction:dir,protocol:proto,source_dest:cidr,source_dest_type:'CIDR_BLOCK',description:desc,is_stateless:stateless,region:region||undefined};
if(proto!=='all'&&proto!=='1'&&portMin)Object.assign(body,{port_min:parseInt(portMin),port_max:parseInt(portMax||portMin)});
try{const d=await $api('/oci/explore/'+S.expCfg+'/nsgs/'+nsgId+'/rules',{method:'POST',body});
sm('nsgFormMsg',IC.ok+' '+d.message,'s');S._nsgAddForm=null;await expLoadRes()}catch(e){sm('nsgFormMsg',e.message,'e')}}
async function nsgRemoveRule(nsgId,ruleId){
if(!confirm('Remover esta regra do NSG?'))return;
const region=S.expSelRegions.length===1?S.expSelRegions[0]:null;
const q=region?'?region='+region:'';
try{await $api('/oci/explore/'+S.expCfg+'/nsgs/'+nsgId+'/rules/'+ruleId+q,{method:'DELETE'});await expLoadRes()}catch(e){alert('Erro: '+e.message)}}
async function nsgAddMyIp(nsgId){
try{const resp=await fetch('https://api.ipify.org?format=json');const data=await resp.json();const myIp=data.ip+'/32';
document.getElementById('nsgCidr').value=myIp;
document.getElementById('nsgDesc').value='Acesso temporário - '+myIp;
sm('nsgFormMsg',IC.ok+' IP detectado: '+myIp+' — ajuste porta e clique Salvar','s')}catch(e){sm('nsgFormMsg','Não foi possível detectar o IP: '+e.message,'e')}}
function rExpRouteTables(d){ function rExpRouteTables(d){
return`<div style="font-size:.74rem;color:var(--t4);margin-bottom:.5rem">${d.length} route table(s)</div>${d.map((rt,idx)=>{ return`<div style="font-size:.74rem;color:var(--t4);margin-bottom:.5rem">${d.length} route table(s)</div>${d.map((rt,idx)=>{
const routes=rt.routes||[]; const routes=rt.routes||[];
@@ -2026,22 +2128,98 @@ ${!filtered.length?'<div class="cd"><div class="emp"><div class="eic">${IC.folde
${isExp?fHtml:''}</div>`}).join('')}</div>`}`} ${isExp?fHtml:''}</div>`}).join('')}</div>`}`}
async function toggleFiles(rid){if(S.dlExpandedRid===rid){S.dlExpandedRid=null;R();return} async function toggleFiles(rid){if(S.dlExpandedRid===rid){S.dlExpandedRid=null;R();return}
S.dlExpandedRid=rid;if(!S.reportFiles[rid]){try{S.reportFiles[rid]=await $api('/reports/'+rid+'/files')}catch(e){S.reportFiles[rid]=[]}}R()} S.dlExpandedRid=rid;if(!S.reportFiles[rid]){try{S.reportFiles[rid]=await $api('/reports/'+rid+'/files')}catch(e){S.reportFiles[rid]=[]}}R()}
function _dlSection(name){
// Extract CIS section from filename: cis_Networking_2-1.csv → Networking
const m=name.match(/^(?:cis|obp)_([A-Za-z_]+?)_\d/);
if(m)return m[1].replace(/_/g,' ');
if(name.includes('summary'))return 'Summary';
if(name.includes('error'))return 'Error';
return 'Other';
}
const DL_SECTION_TABLE={
'Identity and Access Management':'identityandaccess',
'Networking':'networking',
'Compute':'computeinstances',
'Logging and Monitoring':'loggingandmonitoring',
'Storage Object Storage':'objectstorage',
'Storage Block Volumes':'storageblockvolume',
'Storage File Storage Service':'filestorageservice',
'Asset Management':'assetmanagement',
'Summary':'summaryreportcsvvector'
};
function renderFileList(rid,files){if(!files.length)return'<div style="padding:.8rem;color:var(--t4);font-size:.72rem;text-align:center">Nenhum arquivo encontrado</div>'; function renderFileList(rid,files){if(!files.length)return'<div style="padding:.8rem;color:var(--t4);font-size:.72rem;text-align:center">Nenhum arquivo encontrado</div>';
const groups={};files.forEach(f=>{if(!groups[f.file_category])groups[f.file_category]=[];groups[f.file_category].push(f)}); const secIcons={'Identity and Access Management':IC.lock,'Networking':IC.globe,'Compute':IC.server,'Logging and Monitoring':IC.log,'Storage Object Storage':IC.db,'Asset Management':IC.pkg,'Summary':IC.chart,'Error':IC.err,'Other':IC.file};
const labels={summary:'Summary Reports',cis_finding:'CIS Findings',obp_finding:'OBP Findings',obp_best_practice:'OBP Best Practices',raw_data:'Raw Data',error:'Error Reports',diagram:'Diagrams',consolidated:'Consolidated',other:'Other'}; // Group files by section
const icons={summary:IC.chart,cis_finding:IC.shield,obp_finding:IC.warn,obp_best_practice:IC.ok,raw_data:IC.log,error:IC.err,diagram:IC.barChart,consolidated:IC.file,other:IC.file}; const bySection={};files.forEach(f=>{const s=_dlSection(f.file_name);if(!bySection[s])bySection[s]=[];bySection[s].push(f)});
let h='<div style="border-top:1px solid var(--bd);background:var(--bg2)">'; const sectionOrder=Object.keys(bySection).sort((a,b)=>{if(a==='Summary')return -1;if(b==='Summary')return 1;if(a==='Other')return 1;if(b==='Other')return -1;return a.localeCompare(b)});
for(const[cat,cf] of Object.entries(groups)){ const hasAdb=S.adbCfg.length>0&&(S.genaiCfg.length>0||S.ociCfg.length>0);
h+=`<div style="padding:.4rem .8rem .1rem"><div style="font-size:.64rem;font-weight:600;color:var(--t4);text-transform:uppercase;letter-spacing:.4px;margin-bottom:.25rem">${icons[cat]||IC.file} ${labels[cat]||cat} (${cf.length})</div></div>`; let h='<div style="border-top:1px solid var(--bd);padding:.65rem .8rem;background:var(--bg2)">';
cf.forEach(f=>{ // Header with total + embed all button
h+=`<a href="${API}/reports/${rid}/files/${f.id}/download" target="_blank" style="display:flex;align-items:center;gap:.5rem;padding:.35rem .8rem;text-decoration:none;color:inherit;transition:background .12s;cursor:pointer" onmouseover="this.style.background='var(--bg3)'" onmouseout="this.style.background=''"> h+=`<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.6rem">`;
h+=`<span style="font-size:.72rem;color:var(--t3)">${files.length} arquivo(s) em ${sectionOrder.length} seções</span>`;
if(hasAdb)h+=`<button class="btn bp bsm" onclick="dlEmbedSection('${rid}',null)" style="font-size:.62rem">${IC.dna} Embed Tudo</button>`;
h+=`</div>`;
sectionOrder.forEach(sec=>{
const sf=bySection[sec];
const icon=secIcons[sec]||IC.file;
// Section header with embed button
h+=`<div style="display:flex;justify-content:space-between;align-items:center;margin-top:.6rem;margin-bottom:.35rem;padding-bottom:.25rem;border-bottom:1px solid var(--bd)">`;
h+=`<div style="display:flex;align-items:center;gap:.4rem"><span style="opacity:.7">${icon}</span><span style="font-size:.72rem;font-weight:700;color:var(--t1)">${sec}</span><span style="font-size:.6rem;color:var(--t4);font-weight:400">(${sf.length})</span></div>`;
if(hasAdb)h+=`<button class="btn bs bsm" onclick="dlEmbedSection('${rid}','${sec.replace(/'/g,"\\'")}')" style="font-size:.58rem;padding:.15rem .45rem" title="Gerar embedding desta seção">${IC.dna} Embed</button>`;
h+=`</div>`;
// File grid
h+=`<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:.4rem;margin-bottom:.3rem">`;
sf.forEach(f=>{
const ext=f.file_name.split('.').pop().toLowerCase();
const colors={html:'#e44d26',csv:'#217346',json:'#f5a623',xlsx:'#217346',txt:'#6b7280',pdf:'#dc2626'};
const ac=colors[ext]||'var(--ac)';
h+=`<a href="${API}/reports/${rid}/files/${f.id}/download?token=${S.token}" target="_blank" class="dl-card" style="display:flex;align-items:center;gap:.5rem;padding:.5rem .6rem;text-decoration:none;color:inherit;background:var(--bg);border:1px solid var(--bd);border-radius:8px;transition:all .15s;cursor:pointer" onmouseover="this.style.borderColor='${ac}';this.style.boxShadow='0 2px 8px ${ac}18'" onmouseout="this.style.borderColor='var(--bd)';this.style.boxShadow='none'">
${_dlFileIcon(f.file_name)} ${_dlFileIcon(f.file_name)}
<div style="flex:1;min-width:0;overflow:hidden"><div style="font-size:.72rem;font-weight:500;color:var(--t1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${f.file_name}</div></div> <div style="flex:1;min-width:0;overflow:hidden"><div style="font-size:.7rem;font-weight:500;color:var(--t1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${f.file_name}">${f.file_name}</div>
<span style="font-size:.62rem;color:var(--t4);flex-shrink:0">${_dlFmtSize(f.file_size)}</span> <div style="font-size:.58rem;color:var(--t4);margin-top:1px">${_dlFmtSize(f.file_size)}</div></div>
<svg viewBox="0 0 16 16" width="12" height="12" style="flex-shrink:0;opacity:.35"><path d="M8 1v9m0 0L5 7m3 3l3-3M2 12v1.5A1.5 1.5 0 003.5 15h9a1.5 1.5 0 001.5-1.5V12" stroke="var(--t3)" stroke-width="1.5" fill="none" stroke-linecap="round"/></svg></a>`}); <svg viewBox="0 0 16 16" width="12" height="12" style="flex-shrink:0;opacity:.3"><path d="M8 1v9m0 0L5 7m3 3l3-3M2 12v1.5A1.5 1.5 0 003.5 15h9a1.5 1.5 0 001.5-1.5V12" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/></svg></a>`});
} h+=`</div>`;
});
h+='</div>';return h} h+='</div>';return h}
function dlRpt(id,f){window.open(API+'/reports/'+id+'/download?fmt='+f,'_blank')} async function dlEmbedSection(rid,section){
if(!S.adbCfg.length){alert('Nenhuma conexão ADB configurada.');return}
if(!S.genaiCfg.length&&!S.ociCfg.length){alert('Nenhuma credencial OCI configurada.');return}
const vid=S.adbCfg.find(c=>c.is_active)?.id||S.adbCfg[0].id;
const cfgName=S.adbCfg.find(c=>c.id===vid)?.config_name||'ADB';
const report=S.reports.find(r=>r.id===rid);
const reportDate=report?.created_at?new Date(report.created_at).toLocaleDateString('pt-BR'):'';
const tenancy=report?.tenancy_name||'';
const label=section||'todas as seções';
const tableName=section?DL_SECTION_TABLE[section]:null;
const tableInfo=tableName?`\nTabela: ${tableName}`:'(cada seção → tabela correspondente)';
const purge=confirm(`Embed "${label}"\nConexão: ${cfgName}${tableInfo}\nReport: ${tenancy}${reportDate}\n\nDeseja LIMPAR os dados antigos antes de embedar?\n\nOK = Limpar antigos + embedar novos\nCancelar = Apenas adicionar (sem limpar)`);
// Either way, proceed with embedding. purge=true means clean first.
if(section&&tableName){
try{
if(purge)await $api('/embeddings/'+vid+'/purge',{method:'POST',body:{table_name:tableName,tenancy}});
const d=await $api('/embeddings/report/'+rid,{method:'POST',body:{adb_config_id:vid,section,table_name:tableName}});
alert(d.message);
}catch(e){alert('Erro: '+e.message)}
}else if(!section){
const files=S.reportFiles[rid]||[];
const sections=[...new Set(files.map(f=>_dlSection(f.file_name)))].filter(s=>DL_SECTION_TABLE[s]);
let ok=0,fail=0;
for(const sec of sections){
const tbl=DL_SECTION_TABLE[sec];
try{
if(purge)await $api('/embeddings/'+vid+'/purge',{method:'POST',body:{table_name:tbl,tenancy}});
await $api('/embeddings/report/'+rid,{method:'POST',body:{adb_config_id:vid,section:sec,table_name:tbl}});ok++;
}catch(e){fail++}
}
alert(`Embedding: ${ok} seções enviadas${fail?' ('+fail+' erros)':''}`);
}else{
try{
if(purge)await $api('/embeddings/'+vid+'/purge',{method:'POST',body:{table_name:section,tenancy}});
const d=await $api('/embeddings/report/'+rid,{method:'POST',body:{adb_config_id:vid,section}});
alert(d.message);
}catch(e){alert('Erro: '+e.message)}
}}
function dlRpt(id,f){window.open(API+'/reports/'+id+'/download?fmt='+f+'&token='+S.token,'_blank')}
async function refreshDl(){S.reports=await $api('/reports');S.reportFiles={};S.dlExpandedRid=null;R()} async function refreshDl(){S.reports=await $api('/reports');S.reportFiles={};S.dlExpandedRid=null;R()}
/* ── Edit helpers ── */ /* ── Edit helpers ── */
@@ -2315,37 +2493,52 @@ ${S.adbCfg.map(c=>{const em=S.embModels[c.embedding_model_id];const tbls=c.table
<td>${c.username}</td><td>${tbls.length?tbls.map(t=>'<span class="tag" style="font-size:.62rem">'+t.table_name+'</span>').join(' '):'<span style="color:var(--t4);font-size:.72rem">—</span>'}</td> <td>${c.username}</td><td>${tbls.length?tbls.map(t=>'<span class="tag" style="font-size:.62rem">'+t.table_name+'</span>').join(' '):'<span style="color:var(--t4);font-size:.72rem">—</span>'}</td>
<td>${c.use_mtls?IC.ok:'—'}</td><td>${c.wallet_dir?IC.ok:IC.err}</td> <td>${c.use_mtls?IC.ok:'—'}</td><td>${c.wallet_dir?IC.ok:IC.err}</td>
<td>${c.genai_config_id?'<span class="tag" style="background:var(--gnl);color:var(--gn)">'+(em?em.name:c.embedding_model_id)+'</span>':'<span style="color:var(--t4)">—</span>'}</td> <td>${c.genai_config_id?'<span class="tag" style="background:var(--gnl);color:var(--gn)">'+(em?em.name:c.embedding_model_id)+'</span>':'<span style="color:var(--t4)">—</span>'}</td>
<td><button class="btn bs bsm" onclick="editCfg('adb','${c.id}')">Editar</button> <button class="btn bs bsm" onclick="tAdb('${c.id}')">Testar</button> <button class="btn bd bsm" onclick="dAdb('${c.id}')">Excluir</button></td></tr>`}).join('')}</tbody></table> <td><button class="btn bs bsm" onclick="editCfg('adb','${c.id}')">Editar</button> <button class="btn bs bsm" onclick="tAdb('${c.id}')">Testar</button> <button class="btn bs bsm" onclick="checkTables('${c.id}')">${IC.db} Verificar</button> <button class="btn bd bsm" onclick="dAdb('${c.id}')">Excluir</button></td></tr>`}).join('')}</tbody></table>
${!S.adbCfg.length?'<div class="emp" style="padding:.85rem"><p>Nenhuma conexão ADB registrada.</p></div>':''}</div> ${!S.adbCfg.length?'<div class="emp" style="padding:.85rem"><p>Nenhuma conexão ADB registrada.</p></div>':''}</div>
<div class="cd"><div class="ct">${ea?IC.edit+' Editar Conexão':IC.plus+' Nova Conexão'}</div> ${ea?`<div class="cd"><div class="ct">${IC.edit} Editar Conexão${ea.config_name}</div><div id="am"></div>
<div class="cdesc">${ea?'Editando: <strong>'+ea.config_name+'</strong>':'Conexão via <code style="font-size:.72rem">python-oracledb</code> Thin mode com Wallet (mTLS) para Oracle Autonomous Database. Envie o Wallet ZIP para preencher o DSN automaticamente.'}</div><div id="am"></div> <div class="g2"><div class="ig"><label>Nome da Conexão</label><input type="text" id="an" placeholder="Produção ADB" value="${ea.config_name}"></div>
<div class="g2"><div class="ig"><label>Nome da Conexão</label><input type="text" id="an" placeholder="Produção ADB" value="${ea?ea.config_name:''}"></div> <div class="ig"><label>Wallet ZIP</label><div class="ht">Envie novo wallet ou deixe vazio para manter o atual</div>
<div class="ig"><label>Wallet ZIP</label><div class="ht">${ea?'Envie novo wallet ou deixe vazio para manter o atual':'Envie o wallet para extrair os DSNs do tnsnames.ora'}</div>
<div style="display:flex;gap:6px;align-items:center"><input type="file" id="awf" accept=".zip" style="flex:1"><button class="btn bs bsm" id="wpbtn" type="button" onclick="parseWallet()">Analisar</button></div></div> <div style="display:flex;gap:6px;align-items:center"><input type="file" id="awf" accept=".zip" style="flex:1"><button class="btn bs bsm" id="wpbtn" type="button" onclick="parseWallet()">Analisar</button></div></div>
<div class="ig"><label>DSN (TNS Name)</label><div class="ht">Selecione ou digite o DSN. Ex: myatp_high</div> <div class="ig"><label>DSN (TNS Name)</label><div class="ht">Selecione ou digite o DSN. Ex: myatp_high</div>
<div id="adsn-wrap"><input type="text" id="adsn" placeholder="myatp_high" value="${ea?ea.dsn:''}"></div></div> <div id="adsn-wrap"><input type="text" id="adsn" placeholder="myatp_high" value="${ea.dsn}"></div></div>
<div class="ig"><label>Username</label><input type="text" id="auser" placeholder="ADMIN" value="${ea?ea.username:''}"></div> <div class="ig"><label>Username</label><input type="text" id="auser" placeholder="ADMIN" value="${ea.username}"></div>
<div class="ig"><label>Password</label><input type="password" id="apw" placeholder="${ea?'(manter atual)':''}"></div> <div class="ig"><label>Password</label><input type="password" id="apw" placeholder="(manter atual)"></div>
<div class="ig"><label>Wallet Password</label><input type="password" id="awpw" placeholder="(opcional)"></div> <div class="ig"><label>Wallet Password</label><input type="password" id="awpw" placeholder="(opcional)"></div>
<div class="ig"><label>Config GenAI (Embeddings)</label><div class="ht">Credenciais OCI usadas para gerar embeddings via GenAI.</div> <div class="ig"><label>Config GenAI (Embeddings)</label><div class="ht">Credenciais OCI usadas para gerar embeddings via GenAI.</div>
<select id="agcfg"><option value="">Nenhum (sem RAG)</option>${S.genaiCfg.map(g=>'<option value="'+g.id+'"'+(ea&&ea.genai_config_id===g.id?' selected':'')+'>'+(g.name||g.model_id)+' · '+g.genai_region+'</option>').join('')}</select></div> <select id="agcfg"><option value="">Nenhum (sem RAG)</option>${S.genaiCfg.map(g=>'<option value="'+g.id+'"'+(ea.genai_config_id===g.id?' selected':'')+'>'+(g.name||g.model_id)+' · '+g.genai_region+'</option>').join('')}</select></div>
<div class="ig"><label>Modelo de Embedding</label><div class="ht">Modelo OCI GenAI para gerar vetores.</div> <div class="ig"><label>Modelo de Embedding</label><div class="ht">Modelo OCI GenAI para gerar vetores.</div>
<select id="aemb">${Object.entries(S.embModels).map(([k,v])=>'<option value="'+k+'"'+(ea&&ea.embedding_model_id===k?' selected':'')+'>'+v.name+' ('+v.dims+'d)</option>').join('')}</select></div></div> <select id="aemb">${Object.entries(S.embModels).map(([k,v])=>'<option value="'+k+'"'+(ea.embedding_model_id===k?' selected':'')+'>'+v.name+' ('+v.dims+'d)</option>').join('')}</select></div></div>
<div style="display:flex;gap:8px"><button class="btn bp" id="abtn" onclick="sAdb()">${ea?'Salvar Alterações':'Salvar Conexão'}</button>${ea?'<button class="btn bs" onclick="cancelEdit()">Cancelar</button>':''}</div></div> <div style="display:flex;gap:8px;margin-bottom:1rem"><button class="btn bp" id="abtn" onclick="sAdb()">Salvar Alterações</button><button class="btn bs" onclick="cancelEdit()">Cancelar</button></div>
${ea?`<div class="cd"><div class="ct">${IC.log} Tabelas de Vetores — ${ea.config_name}</div> <div style="border-top:1px solid var(--br);padding-top:.85rem">
<div class="cdesc">Tabelas no ADB com dados vetorizados. A busca RAG consultará todas as tabelas ativas.</div><div id="tbm"></div> <div style="font-weight:600;font-size:.78rem;margin-bottom:.5rem">${IC.log} Tabelas de Vetores</div>
<div class="cdesc" style="margin-bottom:.6rem">Registre aqui as tabelas que já existem no ADB. A busca RAG consultará todas as tabelas ativas.</div><div id="tbm"></div>
${(ea.tables||[]).length?`<table><thead><tr><th>Tabela</th><th>Descrição</th><th>Ativa</th><th>Ações</th></tr></thead><tbody> ${(ea.tables||[]).length?`<table><thead><tr><th>Tabela</th><th>Descrição</th><th>Ativa</th><th>Ações</th></tr></thead><tbody>
${(ea.tables||[]).map(t=>`<tr${!t.is_active?' style="opacity:.55"':''}> ${(ea.tables||[]).map(t=>`<tr${!t.is_active?' style="opacity:.55"':''}>
<td><input type="text" value="${t.table_name}" id="tn_${t.id}" style="font-family:var(--fm);font-size:.72rem;text-transform:uppercase;width:100%;max-width:180px"></td> <td><input type="text" value="${t.table_name}" id="tn_${t.id}" style="font-family:var(--fm);font-size:.72rem;width:100%;max-width:180px"></td>
<td><input type="text" value="${t.description||''}" id="td_${t.id}" placeholder="Descrição..." style="font-size:.72rem;width:100%"></td> <td><input type="text" value="${t.description||''}" id="td_${t.id}" placeholder="Descrição..." style="font-size:.72rem;width:100%"></td>
<td><button class="btn bs bsm" style="font-size:.62rem" onclick="toggleTable('${ea.id}','${t.id}',${t.is_active?0:1})">${t.is_active?IC.ok+' Sim':IC.err+' Não'}</button></td> <td><button class="btn bs bsm" style="font-size:.62rem" onclick="toggleTable('${ea.id}','${t.id}',${t.is_active?0:1})">${t.is_active?IC.ok+' Sim':IC.err+' Não'}</button></td>
<td style="white-space:nowrap"><button class="btn bs bsm" onclick="saveTable('${ea.id}','${t.id}')">Salvar</button> <button class="btn bd bsm" onclick="removeTable('${ea.id}','${t.id}')">Excluir</button></td> <td style="white-space:nowrap"><button class="btn bs bsm" onclick="saveTable('${ea.id}','${t.id}')">Salvar</button> <button class="btn bd bsm" onclick="removeTable('${ea.id}','${t.id}')">Excluir</button></td>
</tr>`).join('')}</tbody></table>`:'<div class="emp" style="padding:.5rem"><p style="font-size:.74rem">Nenhuma tabela registrada.</p></div>'} </tr>`).join('')}</tbody></table>`:'<div class="emp" style="padding:.5rem"><p style="font-size:.74rem">Nenhuma tabela registrada.</p></div>'}
<div style="display:flex;gap:6px;margin-top:.65rem;align-items:end"> <div style="display:flex;gap:6px;margin-top:.65rem;align-items:end">
<div class="ig" style="flex:1;margin:0"><label>Nova Tabela</label><input type="text" id="newTblName" placeholder="NOME_DA_TABELA" style="text-transform:uppercase"></div> <div class="ig" style="flex:1;margin:0"><label>Nome da Tabela Existente</label><input type="text" id="newTblName" placeholder="Ex: networking"></div>
<div class="ig" style="flex:1;margin:0"><label>Descrição</label><input type="text" id="newTblDesc" placeholder="Ex: Dados de compliance CIS"></div> <div class="ig" style="flex:1;margin:0"><label>Descrição</label><input type="text" id="newTblDesc" placeholder="Ex: Dados de networking do CIS"></div>
<button class="btn bs" onclick="addTable('${ea.id}')">+ Adicionar</button> <button class="btn bs" onclick="addTable('${ea.id}')">+ Registrar</button>
</div></div>`:''} </div></div></div>`
:`<div class="cd"><div class="ct">${IC.plus} Nova Conexão</div>
<div class="cdesc">Conexão via <code style="font-size:.72rem">python-oracledb</code> Thin mode com Wallet (mTLS) para Oracle Autonomous Database. Envie o Wallet ZIP para preencher o DSN automaticamente.</div><div id="am"></div>
<div class="g2"><div class="ig"><label>Nome da Conexão</label><input type="text" id="an" placeholder="Produção ADB" value=""></div>
<div class="ig"><label>Wallet ZIP</label><div class="ht">Envie o wallet para extrair os DSNs do tnsnames.ora</div>
<div style="display:flex;gap:6px;align-items:center"><input type="file" id="awf" accept=".zip" style="flex:1"><button class="btn bs bsm" id="wpbtn" type="button" onclick="parseWallet()">Analisar</button></div></div>
<div class="ig"><label>DSN (TNS Name)</label><div class="ht">Selecione ou digite o DSN. Ex: myatp_high</div>
<div id="adsn-wrap"><input type="text" id="adsn" placeholder="myatp_high" value=""></div></div>
<div class="ig"><label>Username</label><input type="text" id="auser" placeholder="ADMIN" value=""></div>
<div class="ig"><label>Password</label><input type="password" id="apw" placeholder=""></div>
<div class="ig"><label>Wallet Password</label><input type="password" id="awpw" placeholder="(opcional)"></div>
<div class="ig"><label>Config GenAI (Embeddings)</label><div class="ht">Credenciais OCI usadas para gerar embeddings via GenAI.</div>
<select id="agcfg"><option value="">Nenhum (sem RAG)</option>${S.genaiCfg.map(g=>'<option value="'+g.id+'">'+(g.name||g.model_id)+' · '+g.genai_region+'</option>').join('')}</select></div>
<div class="ig"><label>Modelo de Embedding</label><div class="ht">Modelo OCI GenAI para gerar vetores.</div>
<select id="aemb">${Object.entries(S.embModels).map(([k,v])=>'<option value="'+k+'">'+v.name+' ('+v.dims+'d)</option>').join('')}</select></div></div>
<div style="display:flex;gap:8px"><button class="btn bp" id="abtn" onclick="sAdb()">Salvar Conexão</button></div></div>`}
${S.adbCfg.length?`<div class="cd"><div class="ct">${IC.upload} Atualizar Wallet</div><div id="wm"></div> ${S.adbCfg.length?`<div class="cd"><div class="ct">${IC.upload} Atualizar Wallet</div><div id="wm"></div>
<div class="g2"><div class="ig"><label>Conexão ADB</label><select id="awsel">${S.adbCfg.map(c=>`<option value="${c.id}">${c.config_name}</option>`).join('')}</select></div> <div class="g2"><div class="ig"><label>Conexão ADB</label><select id="awsel">${S.adbCfg.map(c=>`<option value="${c.id}">${c.config_name}</option>`).join('')}</select></div>
<div class="ig"><label>Wallet ZIP</label><input type="file" id="awf2" accept=".zip"></div></div> <div class="ig"><label>Wallet ZIP</label><input type="file" id="awf2" accept=".zip"></div></div>
@@ -2371,6 +2564,16 @@ async function sAdb(){const btn=document.getElementById('abtn');btn.disabled=tru
await $api(url,{method,body:fd,headers:{}});S.editing=null; await $api(url,{method,body:fd,headers:{}});S.editing=null;
S.adbCfg=await $api('/adb/configs');sm('am',IC.ok+' Conexão '+(isEdit?'atualizada':'salva')+'!'+(wf?' Wallet incluído.':''),'s');R()}catch(e){sm('am',e.message,'e');btn.disabled=false;btn.textContent=isEdit?'Salvar Alterações':'Salvar Conexão'}} S.adbCfg=await $api('/adb/configs');sm('am',IC.ok+' Conexão '+(isEdit?'atualizada':'salva')+'!'+(wf?' Wallet incluído.':''),'s');R()}catch(e){sm('am',e.message,'e');btn.disabled=false;btn.textContent=isEdit?'Salvar Alterações':'Salvar Conexão'}}
async function tAdb(id){try{const d=await $api('/adb/test/'+id,{method:'POST'});sm('am',d.status==='success'?IC.ok+' '+d.message:IC.err+' '+d.message,d.status==='success'?'s':'e');refreshCLogs('adb')}catch(e){sm('am',e.message,'e')}} async function tAdb(id){try{const d=await $api('/adb/test/'+id,{method:'POST'});sm('am',d.status==='success'?IC.ok+' '+d.message:IC.err+' '+d.message,d.status==='success'?'s':'e');refreshCLogs('adb')}catch(e){sm('am',e.message,'e')}}
async function checkTables(id){sm('am','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Verificando tabelas no ADB...','i');
try{const d=await $api('/adb/'+id+'/tables/check',{method:'POST'});
if(d.status==='error'){sm('am',IC.err+' '+d.message,'e');return}
let html='<strong>Schema: '+d.schema_user+'</strong> ('+d.adb_tables_total+' tabelas no ADB)<br><br>';
html+='<table style="width:100%;font-size:.74rem"><thead><tr><th>Tabela Registrada</th><th>Status</th><th>Detalhes</th></tr></thead><tbody>';
d.registered.forEach(r=>{const st=r.status==='ok'?'<span style="color:var(--gn)">'+IC.ok+' Existe</span>':r.status==='not_found'?'<span style="color:var(--rd)">'+IC.err+' Não encontrada</span>':'<span style="color:var(--rd)">'+IC.err+' Erro</span>';
html+='<tr><td><strong>'+r.table+'</strong></td><td>'+st+'</td><td>'+(r.rows!==undefined?r.rows+' registros':(r.message||''))+'</td></tr>'});
html+='</tbody></table>';
if(d.unregistered_in_adb&&d.unregistered_in_adb.length){html+='<br><details><summary style="cursor:pointer;font-size:.72rem;color:var(--t3)">Tabelas no ADB não registradas ('+d.unregistered_in_adb.length+')</summary><div style="font-size:.68rem;color:var(--t4);margin-top:.3rem">'+d.unregistered_in_adb.join(', ')+'</div></details>'}
sm('am',html,'i')}catch(e){sm('am',IC.err+' '+e.message,'e')}}
async function dAdb(id){if(!confirm('Excluir conexão?'))return;await $api('/adb/configs/'+id,{method:'DELETE'});S.adbCfg=await $api('/adb/configs');R()} async function dAdb(id){if(!confirm('Excluir conexão?'))return;await $api('/adb/configs/'+id,{method:'DELETE'});S.adbCfg=await $api('/adb/configs');R()}
async function uWallet(){const fd=new FormData();const f=document.getElementById('awf2').files[0];if(!f)return sm('wm','Selecione um arquivo wallet ZIP.','e'); async function uWallet(){const fd=new FormData();const f=document.getElementById('awf2').files[0];if(!f)return sm('wm','Selecione um arquivo wallet ZIP.','e');
fd.append('wallet',f);const id=document.getElementById('awsel').value;if(!id)return sm('wm','Selecione uma conexão ADB.','e'); fd.append('wallet',f);const id=document.getElementById('awsel').value;if(!id)return sm('wm','Selecione uma conexão ADB.','e');
@@ -2381,7 +2584,7 @@ async function addTable(vid){const name=document.getElementById('newTblName').va
try{await $api('/adb/'+vid+'/tables',{method:'POST',body:{table_name:name,description:desc}});S.adbCfg=await $api('/adb/configs');sm('tbm',IC.ok+' Tabela '+name+' registrada!','s');R()}catch(e){sm('tbm',e.message,'e')}} try{await $api('/adb/'+vid+'/tables',{method:'POST',body:{table_name:name,description:desc}});S.adbCfg=await $api('/adb/configs');sm('tbm',IC.ok+' Tabela '+name+' registrada!','s');R()}catch(e){sm('tbm',e.message,'e')}}
async function saveTable(vid,tid){const name=document.getElementById('tn_'+tid)?.value.trim();const desc=document.getElementById('td_'+tid)?.value.trim(); async function saveTable(vid,tid){const name=document.getElementById('tn_'+tid)?.value.trim();const desc=document.getElementById('td_'+tid)?.value.trim();
if(!name)return sm('tbm','Nome da tabela é obrigatório.','e'); if(!name)return sm('tbm','Nome da tabela é obrigatório.','e');
try{await $api('/adb/'+vid+'/tables/'+tid,{method:'PUT',body:{table_name:name.toUpperCase(),description:desc}});S.adbCfg=await $api('/adb/configs');sm('tbm',IC.ok+' Tabela atualizada.','s');R()}catch(e){sm('tbm',e.message,'e')}} try{await $api('/adb/'+vid+'/tables/'+tid,{method:'PUT',body:{table_name:name,description:desc}});S.adbCfg=await $api('/adb/configs');sm('tbm',IC.ok+' Tabela atualizada.','s');R()}catch(e){sm('tbm',e.message,'e')}}
async function toggleTable(vid,tid,val){try{await $api('/adb/'+vid+'/tables/'+tid,{method:'PUT',body:{is_active:val}});S.adbCfg=await $api('/adb/configs');R()}catch(e){sm('tbm',e.message,'e')}} async function toggleTable(vid,tid,val){try{await $api('/adb/'+vid+'/tables/'+tid,{method:'PUT',body:{is_active:val}});S.adbCfg=await $api('/adb/configs');R()}catch(e){sm('tbm',e.message,'e')}}
async function removeTable(vid,tid){if(!confirm('Excluir esta tabela do registro?'))return; async function removeTable(vid,tid){if(!confirm('Excluir esta tabela do registro?'))return;
try{await $api('/adb/'+vid+'/tables/'+tid,{method:'DELETE'});S.adbCfg=await $api('/adb/configs');sm('tbm',IC.ok+' Tabela removida.','s');R()}catch(e){sm('tbm',e.message,'e')}} try{await $api('/adb/'+vid+'/tables/'+tid,{method:'DELETE'});S.adbCfg=await $api('/adb/configs');sm('tbm',IC.ok+' Tabela removida.','s');R()}catch(e){sm('tbm',e.message,'e')}}
@@ -2390,40 +2593,47 @@ async function removeTable(vid,tid){if(!confirm('Excluir esta tabela do registro
function tblOpts(vid){const cfg=S.adbCfg.find(c=>c.id===vid);if(!cfg||!(cfg.tables||[]).length)return'<option value="">Padrão</option>'; function tblOpts(vid){const cfg=S.adbCfg.find(c=>c.id===vid);if(!cfg||!(cfg.tables||[]).length)return'<option value="">Padrão</option>';
return cfg.tables.filter(t=>t.is_active).map(t=>'<option value="'+t.table_name+'">'+t.table_name+'</option>').join('')} return cfg.tables.filter(t=>t.is_active).map(t=>'<option value="'+t.table_name+'">'+t.table_name+'</option>').join('')}
function rEmbeddings(){ function rEmbeddings(){
const adbOpts=S.adbCfg.filter(c=>c.genai_config_id).map(c=>'<option value="'+c.id+'">'+c.config_name+'</option>').join(''); const adbOpts=S.adbCfg.map(c=>'<option value="'+c.id+'">'+c.config_name+'</option>').join('');
const rptOpts=S.reports.filter(r=>r.status==='completed').map(r=>'<option value="'+r.id+'">'+r.tenancy_name+' ('+r.created_at?.substring(0,10)+')</option>').join(''); const rptOpts=S.reports.filter(r=>r.status==='completed').map(r=>'<option value="'+r.id+'">'+r.tenancy_name+' ('+r.created_at?.substring(0,10)+')</option>').join('');
if(!S.adbCfg.filter(c=>c.genai_config_id).length)return`<div class="cd"><div class="emp"><div class="eic">${IC.dna}</div><p>Nenhuma conexão ADB com GenAI configurada.</p><p style="font-size:.74rem;margin-top:.35rem">Configure em <strong>ADB Vector</strong> vinculando uma Config GenAI.</p></div></div>`; if(!S.adbCfg.length)return`<div class="cd"><div class="emp"><div class="eic">${IC.dna}</div><p>Nenhuma conexão ADB configurada.</p><p style="font-size:.74rem;margin-top:.35rem">Configure em <strong>ADB Vector</strong>.</p></div></div>`;
const firstVid=S.adbCfg.filter(c=>c.genai_config_id)[0]?.id||''; if(!S.genaiCfg.length&&!S.ociCfg.length)return`<div class="cd"><div class="emp"><div class="eic">${IC.dna}</div><p>Nenhuma credencial OCI configurada.</p><p style="font-size:.74rem;margin-top:.35rem">Configure em <strong>Config → OCI</strong>.</p></div></div>`;
const firstVid=S.adbCfg.find(c=>c.is_active)?.id||S.adbCfg[0]?.id||'';
return`<div class="cd"><div class="ct">${IC.log} Embeddings Existentes</div> return`<div class="cd"><div class="ct">${IC.log} Embeddings Existentes</div>
<div class="cdesc">Consulte os documentos armazenados nas tabelas de embeddings do ADB.</div> <div class="cdesc">Consulte os documentos armazenados nas tabelas de embeddings do ADB.</div>
<div class="g3"><div class="ig"><label>Conexão ADB</label><select id="elsel" onchange="document.getElementById('eltbl').innerHTML=tblOpts(this.value)">${adbOpts}</select></div> <div class="g3"><div class="ig"><label>Conexão ADB</label><select id="elsel" onchange="document.getElementById('eltbl').innerHTML=tblOpts(this.value)">${adbOpts}</select></div>
<div class="ig"><label>Tabela</label><select id="eltbl">${tblOpts(firstVid)}</select></div> <div class="ig"><label>Tabela</label><select id="eltbl">${tblOpts(firstVid)}</select></div>
<div class="ig" style="align-self:end"><button class="btn bs" onclick="loadEmbs()">Carregar</button></div></div> <div class="ig" style="align-self:end"><button class="btn bs" onclick="loadEmbs()">Carregar</button></div></div>
<div id="emb-list"></div></div> <div id="emb-list"></div></div>
<div class="cd"><div class="ct">${IC.chart} Embed Relatório CIS</div> <div class="cd"><div class="ct">${IC.shield} CIS Recommendations</div>
<div class="cdesc">Visualize os chunks gerados antes de criar os embeddings. Cada seção do relatório gera um chunk com tenancy, regiões e compartments.</div><div id="erm"></div> <div class="cdesc">Envie a versão mais recente do PDF para manter as recomendações CIS Oracle Cloud atualizadas na base vetorial.</div><div id="crm"></div>
<div class="g3"><div class="ig"><label>Conexão ADB</label><select id="ersel" onchange="document.getElementById('ertbl').innerHTML=tblOpts(this.value)">${adbOpts}</select></div> <div class="g3"><div class="ig"><label>Conexão ADB</label><select id="crsel">${adbOpts}</select></div>
<div class="ig"><label>Tabela</label><select id="ertbl">${tblOpts(firstVid)}</select></div> <div class="ig"><label>Arquivo PDF</label><input type="file" id="crf" accept=".pdf,.txt"></div>
<div class="ig"><label>Relatório</label><select id="errpt">${rptOpts||'<option value="">Nenhum relatório disponível</option>'}</select></div></div> <div class="ig" style="align-self:end"><button class="btn bp" id="crbtn" onclick="embedCisRecom()">${IC.upload} Upload CIS Recommendations</button></div></div></div>
<div style="display:flex;gap:8px;margin-top:.35rem"><button class="btn bs" id="epbtn" onclick="previewChunks()">${IC.eye} Visualizar Chunks</button> <div class="cd"><div class="ct">${IC.scroll} Base de Conhecimento</div>
<button class="btn bp" id="erbtn" onclick="embedReport()" style="display:none">Gerar Embeddings</button></div> <div class="cdesc">Envie documentos para alimentar a base de conhecimento da equipe. Os arquivos serão vetorizados e disponibilizados para consulta via chat.</div><div id="efm"></div>
<div id="chunk-preview"></div></div> <div class="g3"><div class="ig"><label>Conexão ADB</label><select id="efsel">${adbOpts}</select></div></div>
<div class="cd"><div class="ct">${IC.file} Upload de Arquivo</div> <div style="display:flex;gap:.75rem;align-items:start;flex-wrap:wrap;margin-top:.5rem">
<div class="cdesc">Faça upload de um arquivo .txt para gerar embeddings automaticamente (chunking por parágrafos).</div><div id="efm"></div> <div style="flex:1;min-width:240px">
<div class="g3"><div class="ig"><label>Conexão ADB</label><select id="efsel" onchange="document.getElementById('eftbl').innerHTML=tblOpts(this.value)">${adbOpts}</select></div> <label style="font-size:.72rem;font-weight:600;display:block;margin-bottom:.35rem">Arquivo (.txt, .pdf, .csv, .json, .md)</label>
<div class="ig"><label>Tabela</label><select id="eftbl">${tblOpts(firstVid)}</select></div> <input type="file" id="eff" accept=".txt,.pdf,.csv,.json,.md">
<div class="ig"><label>Arquivo (.txt)</label><input type="file" id="eff" accept=".txt"></div></div> <button class="btn bp" id="efbtn" onclick="embedFile()" style="margin-top:.5rem;width:100%">Upload Arquivo</button>
<button class="btn bp" id="efbtn" onclick="embedFile()">Upload e Gerar Embeddings</button></div>`} </div>
<div style="width:1px;background:var(--brd);align-self:stretch;margin:0 .25rem"></div>
<div style="flex:1;min-width:240px">
<label style="font-size:.72rem;font-weight:600;display:block;margin-bottom:.35rem">URL (página web ou PDF)</label>
<input type="text" id="efurl" placeholder="https://docs.oracle.com/..." style="width:100%;padding:.45rem .6rem;border:1px solid var(--brd);border-radius:var(--r);background:var(--bg2);color:var(--t1);font-size:.78rem">
<button class="btn bp" id="efurlbtn" onclick="embedUrl()" style="margin-top:.5rem;width:100%">Importar URL</button>
</div></div></div>`}
async function loadEmbs(){const vid=document.getElementById('elsel').value;if(!vid)return; async function loadEmbs(){const vid=document.getElementById('elsel').value;if(!vid)return;
const tbl=document.getElementById('eltbl')?.value||''; const tbl=document.getElementById('eltbl')?.value||'';
try{const d=await $api('/embeddings/'+vid+'/list'+(tbl?'?table_name='+encodeURIComponent(tbl):'')); try{const d=await $api('/embeddings/'+vid+'/list'+(tbl?'?table_name='+encodeURIComponent(tbl):''));
if(!d.documents.length){document.getElementById('emb-list').innerHTML='<div class="emp" style="padding:.65rem"><p>Nenhum embedding encontrado.</p></div>';return} if(!d.documents.length){document.getElementById('emb-list').innerHTML='<div class="emp" style="padding:.65rem"><p>Nenhum embedding encontrado.</p></div>';return}
document.getElementById('emb-list').innerHTML='<table style="margin-top:.65rem"><thead><tr><th>ID</th><th>Source</th><th>Metadata</th><th>Data</th><th>Ações</th></tr></thead><tbody>'+ document.getElementById('emb-list').innerHTML='<div style="overflow-x:auto"><table style="margin-top:.65rem;table-layout:fixed;width:100%"><thead><tr><th style="width:80px">ID</th><th>Metadata</th></tr></thead><tbody>'+
d.documents.map(e=>'<tr><td style="font-family:var(--fm);font-size:.62rem">'+e.id.substring(0,8)+'...</td><td>'+ d.documents.map(e=>{let meta=e.metadata||'—';if(typeof meta==='object')meta=JSON.stringify(meta);if(meta.length>200)meta=meta.substring(0,200)+'…';
(e.source||'—')+'</td><td style="font-size:.72rem;color:var(--t3)">'+(e.metadata||'—')+'</td><td style="font-size:.72rem;color:var(--t4)">'+ return'<tr><td style="font-family:var(--fm);font-size:.62rem;word-break:break-all">'+e.id.substring(0,12)+'</td>'+
(e.created_at||'—')+'</td><td><button class="btn bd bsm" onclick="delEmb(\''+vid+'\',\''+e.id+'\')">Excluir</button></td></tr>').join('')+ '<td style="font-size:.68rem;color:var(--t3);word-break:break-word;white-space:pre-wrap;max-width:0;overflow:hidden">'+meta+'</td></tr>'}).join('')+
'</tbody></table><div style="padding:.5rem;font-size:.72rem;color:var(--t4)">Total: '+d.total+' documentos</div>'} '</tbody></table></div><div style="padding:.5rem;font-size:.72rem;color:var(--t4)">Total: '+d.total+' documentos</div>'}
catch(e){document.getElementById('emb-list').innerHTML='<div style="color:var(--rd);padding:.5rem">Erro: '+e.message+'</div>'}} catch(e){document.getElementById('emb-list').innerHTML='<div style="color:var(--rd);padding:.5rem">Erro: '+e.message+'</div>'}}
async function previewChunks(){const rid=document.getElementById('errpt').value; async function previewChunks(){const rid=document.getElementById('errpt').value;
@@ -2455,12 +2665,22 @@ async function embedReport(){const vid=document.getElementById('ersel').value;co
const btn=document.getElementById('erbtn');btn.disabled=true;btn.textContent='Gerando...';sm('erm','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Gerando embeddings do relatório...','i'); const btn=document.getElementById('erbtn');btn.disabled=true;btn.textContent='Gerando...';sm('erm','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Gerando embeddings do relatório...','i');
try{const d=await $api('/embeddings/report/'+rid,{method:'POST',body:{adb_config_id:vid,table_name:tbl||undefined}});sm('erm',IC.ok+' '+d.message,'s');btn.disabled=false;btn.textContent='Gerar Embeddings do Relatório'}catch(e){sm('erm',e.message,'e');btn.disabled=false;btn.textContent='Gerar Embeddings do Relatório'}} try{const d=await $api('/embeddings/report/'+rid,{method:'POST',body:{adb_config_id:vid,table_name:tbl||undefined}});sm('erm',IC.ok+' '+d.message,'s');btn.disabled=false;btn.textContent='Gerar Embeddings do Relatório'}catch(e){sm('erm',e.message,'e');btn.disabled=false;btn.textContent='Gerar Embeddings do Relatório'}}
async function embedCisRecom(){const vid=document.getElementById('crsel').value;const f=document.getElementById('crf').files[0];
if(!vid||!f)return sm('crm','Selecione conexão ADB e arquivo.','e');
const fd=new FormData();fd.append('file',f);fd.append('adb_config_id',vid);fd.append('table_name','cisrecom');
const btn=document.getElementById('crbtn');btn.disabled=true;btn.textContent='Enviando...';sm('crm','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Processando PDF e gerando embeddings...','i');
try{const d=await $api('/embeddings/upload',{method:'POST',body:fd,headers:{}});sm('crm',IC.ok+' '+d.message+' → tabela CISRECOM','s');btn.disabled=false;btn.textContent=IC.upload+' Upload CIS Recommendations'}catch(e){sm('crm',e.message,'e');btn.disabled=false;btn.textContent=IC.upload+' Upload CIS Recommendations'}}
async function embedFile(){const vid=document.getElementById('efsel').value;const f=document.getElementById('eff').files[0]; async function embedFile(){const vid=document.getElementById('efsel').value;const f=document.getElementById('eff').files[0];
if(!vid||!f)return sm('efm','Selecione conexão ADB e arquivo.','e'); if(!vid||!f)return sm('efm','Selecione conexão ADB e arquivo.','e');
const tbl=document.getElementById('eftbl')?.value||''; const tbl='engineerknowledgebase';
const fd=new FormData();fd.append('file',f);fd.append('adb_config_id',vid);if(tbl)fd.append('table_name',tbl); const fd=new FormData();fd.append('file',f);fd.append('adb_config_id',vid);fd.append('table_name',tbl);
const btn=document.getElementById('efbtn');btn.disabled=true;btn.textContent='Enviando...';sm('efm','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Enviando e gerando embeddings...','i'); const btn=document.getElementById('efbtn');btn.disabled=true;btn.textContent='Enviando...';sm('efm','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Enviando e gerando embeddings...','i');
try{const d=await $api('/embeddings/upload',{method:'POST',body:fd,headers:{}});sm('efm',IC.ok+' '+d.message,'s');btn.disabled=false;btn.textContent='Upload e Gerar Embeddings'}catch(e){sm('efm',e.message,'e');btn.disabled=false;btn.textContent='Upload e Gerar Embeddings'}} try{const d=await $api('/embeddings/upload',{method:'POST',body:fd,headers:{}});sm('efm',IC.ok+' '+d.message,'s');btn.disabled=false;btn.textContent='Upload Arquivo'}catch(e){sm('efm',e.message,'e');btn.disabled=false;btn.textContent='Upload Arquivo'}}
async function embedUrl(){const vid=document.getElementById('efsel').value;const url=document.getElementById('efurl').value.trim();
if(!vid||!url)return sm('efm','Selecione conexão ADB e informe a URL.','e');
const fd=new FormData();fd.append('adb_config_id',vid);fd.append('table_name','engineerknowledgebase');fd.append('url',url);
const btn=document.getElementById('efurlbtn');btn.disabled=true;btn.textContent='Importando...';sm('efm','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Buscando conteúdo e gerando embeddings...','i');
try{const d=await $api('/embeddings/upload-url',{method:'POST',body:fd,headers:{}});sm('efm',IC.ok+' '+d.message,'s');btn.disabled=false;btn.textContent='Importar URL';document.getElementById('efurl').value=''}catch(e){sm('efm',e.message,'e');btn.disabled=false;btn.textContent='Importar URL'}}
async function delEmb(vid,docId){if(!confirm('Excluir este embedding?'))return; async function delEmb(vid,docId){if(!confirm('Excluir este embedding?'))return;
const tbl=document.getElementById('eltbl')?.value||''; const tbl=document.getElementById('eltbl')?.value||'';