feat: Oracle CIS report engine, CIS engine auto-update, and granular report params
Replace lightweight cis_runner.py with Oracle's official cis_reports.py engine (6660 lines, 48 CIS + 11 OBP checks). Add granular execution parameters (Level, OBP, Raw Data, Redact), per-report file storage with category browser, tenancy filter in Downloads, and individual file download. Add CIS Engine auto-update: check/download latest cis_reports.py from Oracle's GitHub repo with automatic patch reapplication (admin UI card + 3 new endpoints). Save engine version to app_settings on startup. Update README to v1.8 with new features, API endpoints, and versioning table.
This commit is contained in:
56
README.md
56
README.md
@@ -9,7 +9,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-1.5-C74634?style=flat-square" alt="Version">
|
||||
<img src="https://img.shields.io/badge/version-1.8-C74634?style=flat-square" alt="Version">
|
||||
<img src="https://img.shields.io/badge/python-3.12-3776AB?style=flat-square" alt="Python">
|
||||
<img src="https://img.shields.io/badge/FastAPI-0.115-009688?style=flat-square" alt="FastAPI">
|
||||
<img src="https://img.shields.io/badge/OCI-GenAI-C74634?style=flat-square" alt="OCI">
|
||||
@@ -33,6 +33,8 @@ The platform combines security compliance scanning, AI-powered chat with **RAG (
|
||||
- **OCI Generative AI** integration via official SDK (`oci.generative_ai_inference`)
|
||||
- **RAG (Retrieval-Augmented Generation)**: automatically queries ADB vector store for relevant context before generating responses
|
||||
- **MCP Tool Use (Function Calling)**: GenAI models can call tools from registered MCP servers during chat — supports both Cohere and Generic (OpenAI-style) function calling formats with automatic tool execution loop (max 5 iterations)
|
||||
- **Chat Memory Compaction**: automatic summarization of older messages when conversation exceeds ~8000 tokens — keeps 6 recent messages intact and generates an LLM-based summary of older context, similar to Claude Code's context compression
|
||||
- **Thinking Indicator**: button disables and shows spinner + "Pensando..." while waiting for GenAI response
|
||||
- 69 chat models + 11 embedding models across 6 providers: **Cohere**, **Meta**, **Google**, **OpenAI** (GPT-5.3/5.2/5.1/5/4.1/4o, Codex, Image, Audio, o1/o3/o4-mini, GPT-oss), **xAI** (Grok 4.1/4/3), **ProtectAI**
|
||||
- OCID-based model resolution: catalog maps model IDs to OCI resource IDs per region for reliable API calls
|
||||
- 16 OCI regions supported with auto-generated endpoints
|
||||
@@ -47,12 +49,33 @@ The platform combines security compliance scanning, AI-powered chat with **RAG (
|
||||
- Select which OCI connection to explore
|
||||
- Real-time API calls via OCI Python SDK
|
||||
|
||||
### 📊 CIS Compliance Reports
|
||||
- Automated CIS OCI Foundations Benchmark 3.0 execution
|
||||
- 54 security controls across 8 domains (IAM, Networking, Compute, Logging, Storage, etc.)
|
||||
- HTML and JSON report output
|
||||
- Optional MCP server selection per report execution
|
||||
- Region filtering
|
||||
### 📊 CIS Compliance Reports (Oracle Official Engine)
|
||||
- Powered by Oracle's official `cis_reports.py` (6660 lines, 48 CIS + 11 OBP checks)
|
||||
- **Granular execution parameters**: CIS Level (1/2), OCI Best Practices, Raw Data, OCID Redaction
|
||||
- **Level 1**: Essential security controls that can be implemented with minimal impact on operations. Recommended as baseline for all organizations.
|
||||
- **Level 2**: Advanced security controls that may restrict functionality or require more effort to implement. Recommended for high-security environments.
|
||||
- **Multiple output formats**: HTML summary, CSV per section/finding, JSON summary, optional XLSX
|
||||
- **File browser**: all generated files stored per report, browsable and downloadable individually
|
||||
- **Tenancy filter**: filter reports by tenancy in the Downloads tab
|
||||
- Region filtering with multi-select
|
||||
- Real-time progress tracking with phase-based progress bar
|
||||
- **CIS Engine auto-update**: check for new versions of `cis_reports.py` from Oracle's GitHub repository and update with one click (admin only). Custom patches are automatically reapplied after update
|
||||
|
||||
### 🛡️ Built-in CIS MCP Server (Granular Per-Section)
|
||||
- **Auto-registered** CIS Compliance Scanner MCP server — available out of the box
|
||||
- **12 granular tools** instead of monolithic full-tenancy scan:
|
||||
- `cis_scan_iam` — IAM checks (CIS 1.1–1.17): users, policies, groups, MFA, API keys
|
||||
- `cis_scan_networking` — Network checks (CIS 2.1–2.8): security lists, NSGs, VCNs
|
||||
- `cis_scan_compute` — Compute checks (CIS 3.1–3.3): instance metadata, monitoring
|
||||
- `cis_scan_logging_monitoring` — Logging/Monitoring checks (CIS 4.1–4.17): audit, alarms, events
|
||||
- `cis_scan_storage` — Storage checks (CIS 5.1–5.3): buckets, block volumes, file systems
|
||||
- `cis_scan_asset_management` — Asset checks (CIS 6.1–6.2): compartments, tagging
|
||||
- `cis_list_configs` / `cis_list_checks` — list available OCI configs and CIS checks
|
||||
- `cis_get_check` / `cis_get_remediation` — detailed findings and remediation guidance
|
||||
- `cis_get_scan_status` / `cis_invalidate_cache` — session status and cache management
|
||||
- **Per-section data collection**: each scan tool collects only the OCI data needed for that section, avoiding unnecessary API calls
|
||||
- **Session caching**: collected data is cached per config, so subsequent scans on different sections reuse shared prerequisites (compartments, identity domains)
|
||||
- Based on Oracle's official `cis_reports.py` (6660 lines, 48 CIS + 11 OBP checks)
|
||||
|
||||
### 🔌 MCP Server Registry + Tool Discovery
|
||||
- Register multiple MCP servers (stdio, SSE, Python module)
|
||||
@@ -289,8 +312,9 @@ Allow group <group-name> to read buckets in compartment <compartment-name>
|
||||
```
|
||||
oci-cis-agent/
|
||||
├── backend/
|
||||
│ ├── app.py # FastAPI application (~2200 lines)
|
||||
│ ├── cis_runner.py # CIS Benchmark check executor
|
||||
│ ├── app.py # FastAPI application (~2600 lines)
|
||||
│ ├── cis_reports.py # Oracle CIS Benchmark checker (6660 lines, report engine)
|
||||
│ ├── mcp_cis_server.py # MCP server with 12 granular CIS tools
|
||||
│ ├── Dockerfile # Python 3.12 + OCI CLI
|
||||
│ └── requirements.txt # Dependencies
|
||||
├── frontend/
|
||||
@@ -398,6 +422,16 @@ oci-cis-agent/
|
||||
| GET | `/api/reports` | List reports |
|
||||
| GET | `/api/reports/{id}/html` | View HTML report |
|
||||
| GET | `/api/reports/{id}/download` | Download report |
|
||||
| GET | `/api/reports/{rid}/files` | List report files by category |
|
||||
| GET | `/api/reports/{rid}/files/{fid}/download` | Download individual report file |
|
||||
|
||||
### CIS Engine
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/cis-engine/version` | Current CIS engine version |
|
||||
| GET | `/api/cis-engine/check-update` | Check GitHub for newer version (admin) |
|
||||
| POST | `/api/cis-engine/update` | Download + apply update with patches (admin) |
|
||||
|
||||
### Config Logs
|
||||
|
||||
@@ -534,6 +568,7 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the "
|
||||
| GenAI | `oci.generative_ai_inference` |
|
||||
| Container | Docker Compose, Nginx reverse proxy |
|
||||
| MCP | Model Context Protocol SDK (stdio/SSE) with tool discovery + execution |
|
||||
| CIS Scanner | Oracle CIS Foundations Benchmark 3.0 checker (`cis_reports.py`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -541,6 +576,9 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the "
|
||||
|
||||
| Version | Date | Changes |
|
||||
|---------|------|---------|
|
||||
| **v1.8** | 2026-03 | CIS Engine auto-update from Oracle GitHub with automatic patch reapplication, version check UI card (admin), new `/api/cis-engine/*` endpoints, report file listing and individual download endpoints |
|
||||
| **v1.7** | 2026-03 | Oracle official CIS report engine (replaces lightweight checker), granular report parameters (Level, OBP, Raw Data, Redact), per-report file storage with category browser, tenancy filter in Downloads, individual file download |
|
||||
| **v1.6** | 2026-03 | Granular CIS MCP server (12 per-section scan tools: IAM, Networking, Compute, Logging/Monitoring, Storage, Asset Management), chat memory compaction with LLM-based summarization, GenAI tool use loop fix (accumulated conversation), chat thinking indicator, auto-registered CIS MCP server |
|
||||
| **v1.5** | 2026-03 | MCP Tool Use in Chat (GenAI function calling with auto tool discovery + execution via MCP SDK), multi-table ADB vector search, preview chunks before embedding, enriched embeddings with tenancy/regions/compartments, searchable dropdowns, editable vector tables, orphaned report cleanup on restart |
|
||||
| **v1.4** | 2026-03 | In-place config editing, 69 chat + 11 embedding models with OCID resolution (OpenAI GPT-5.3/5.2/5.1/5/4.1/4o/Codex/Image/Audio, xAI, Google, Meta, ProtectAI), OpenAI Text Embedding 3, custom OCID support, wallet auto-parse with DSN extraction |
|
||||
| **v1.3** | 2026-03 | Persistent config logs per tab, GenAI auto-fill from OCI credentials, inline UX feedback with loading spinners, MCP type-switch fix, encrypted key passphrase support, Docker stdin hang fix |
|
||||
|
||||
@@ -14,7 +14,6 @@ RUN pip install --no-cache-dir -r requirements.txt && \
|
||||
|
||||
# Copy app
|
||||
COPY app.py .
|
||||
COPY cis_runner.py .
|
||||
COPY cis_reports.py .
|
||||
COPY mcp_cis_server.py .
|
||||
|
||||
|
||||
215
backend/app.py
215
backend/app.py
@@ -262,13 +262,24 @@ def init_db():
|
||||
CREATE TABLE IF NOT EXISTS reports (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||||
tenancy_name TEXT NOT NULL, config_id TEXT,
|
||||
mcp_server_id TEXT,
|
||||
level INTEGER DEFAULT 2,
|
||||
obp_checks INTEGER DEFAULT 0,
|
||||
raw_data INTEGER DEFAULT 0,
|
||||
redact_output INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'pending', progress TEXT DEFAULT '',
|
||||
report_data TEXT,
|
||||
html_path TEXT, json_path TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
completed_at TEXT, error_msg TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS report_files (
|
||||
id TEXT PRIMARY KEY,
|
||||
report_id TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
file_type TEXT NOT NULL,
|
||||
file_category TEXT NOT NULL,
|
||||
file_size INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS adb_vector_configs (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||||
config_name TEXT NOT NULL,
|
||||
@@ -362,7 +373,8 @@ def init_db():
|
||||
c.execute(f"ALTER TABLE adb_vector_configs ADD COLUMN {col}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
for col in ["progress TEXT DEFAULT ''"]:
|
||||
for col in ["progress TEXT DEFAULT ''", "level INTEGER DEFAULT 2", "obp_checks INTEGER DEFAULT 0",
|
||||
"raw_data INTEGER DEFAULT 0", "redact_output INTEGER DEFAULT 0"]:
|
||||
try:
|
||||
c.execute(f"ALTER TABLE reports ADD COLUMN {col}")
|
||||
except sqlite3.OperationalError:
|
||||
@@ -473,7 +485,8 @@ class ChatMsg(BaseModel):
|
||||
frequency_penalty: Optional[float] = None; presence_penalty: Optional[float] = None
|
||||
use_tools: Optional[bool] = True
|
||||
class RunReportReq(BaseModel):
|
||||
config_id: str; mcp_server_id: Optional[str] = None; regions: Optional[List[str]] = None
|
||||
config_id: str; regions: Optional[List[str]] = None
|
||||
level: int = 2; obp: bool = False; raw: bool = False; redact_output: bool = False
|
||||
class GenAIConfigReq(BaseModel):
|
||||
name: str = "default"
|
||||
oci_config_id: str; model_id: str; model_ocid: Optional[str] = None
|
||||
@@ -1974,38 +1987,50 @@ async def delete_embedding(vid: str, doc_id: str, table_name: str = Query(""), u
|
||||
raise HTTPException(500, f"Erro ao deletar: {str(e)[:500]}")
|
||||
|
||||
# ── Reports ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _classify_report_file(fname: str) -> str:
|
||||
"""Classify a report file into a category based on its filename."""
|
||||
fl = fname.lower()
|
||||
if "summary_report" in fl: return "summary"
|
||||
if "error_report" in fl or "error" in fl and fl.endswith(".csv"): return "error"
|
||||
if fl.startswith("obp_") and "findings" in fl: return "obp_finding"
|
||||
if fl.startswith("obp_") and "best_practices" in fl: return "obp_best_practice"
|
||||
if fl.startswith("obp_"): return "obp_finding"
|
||||
if fl.startswith("raw_data_"): return "raw_data"
|
||||
if fl.startswith("cis_"): return "cis_finding"
|
||||
if "consolidated_report" in fl: return "consolidated"
|
||||
if fl.endswith(".png"): return "diagram"
|
||||
return "other"
|
||||
|
||||
@app.post("/api/reports/run")
|
||||
async def run_report(req: RunReportReq, bg: BackgroundTasks, u=Depends(require("admin","user"))):
|
||||
if req.level not in (1, 2): raise HTTPException(400, "Level must be 1 or 2")
|
||||
with db() as c:
|
||||
cfg = c.execute("SELECT * FROM oci_configs WHERE id=?",(req.config_id,)).fetchone()
|
||||
if not cfg: raise HTTPException(404, "Config não encontrada")
|
||||
rid = str(uuid.uuid4())
|
||||
c.execute("INSERT INTO reports (id,user_id,tenancy_name,config_id,mcp_server_id,status) VALUES (?,?,?,?,?,?)",
|
||||
(rid, u["id"], cfg["tenancy_name"], req.config_id, req.mcp_server_id, "running"))
|
||||
bg.add_task(_exec_report, rid, dict(cfg), req.regions, req.mcp_server_id)
|
||||
c.execute("INSERT INTO reports (id,user_id,tenancy_name,config_id,level,obp_checks,raw_data,redact_output,status) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(rid, u["id"], cfg["tenancy_name"], req.config_id, req.level, int(req.obp), int(req.raw), int(req.redact_output), "running"))
|
||||
bg.add_task(_exec_report, rid, dict(cfg), req.regions, req.level, req.obp, req.raw, req.redact_output)
|
||||
_audit(u["id"], u["username"], "run_report", rid)
|
||||
return {"report_id": rid, "status": "running"}
|
||||
|
||||
async def _exec_report(rid, cfg, regions, mcp_server_id):
|
||||
async def _exec_report(rid, cfg, regions, level=2, obp=False, raw=False, redact_output=False):
|
||||
rdir = REPORTS / rid; rdir.mkdir(parents=True, exist_ok=True)
|
||||
config_path = str(OCI_DIR / cfg["id"] / "config")
|
||||
try:
|
||||
cmd = ["python3", "-u", "/app/cis_runner.py", "--config", config_path,
|
||||
"--output", str(rdir), "--tenancy-name", cfg["tenancy_name"]]
|
||||
cmd = ["python3", "-u", "/app/cis_reports.py",
|
||||
"-c", config_path,
|
||||
"--report-directory", str(rdir),
|
||||
"--level", str(level),
|
||||
"--print-to-screen", "True",
|
||||
"--report-summary-json"]
|
||||
if regions: cmd += ["--regions", ",".join(regions)]
|
||||
if mcp_server_id:
|
||||
with db() as c:
|
||||
mcp = c.execute("SELECT * FROM mcp_servers WHERE id=?",(mcp_server_id,)).fetchone()
|
||||
if mcp:
|
||||
if mcp["module_path"]: cmd += ["--mcp-module", mcp["module_path"]]
|
||||
if mcp.get("linked_adb_id"):
|
||||
adb_cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?",(mcp["linked_adb_id"],)).fetchone()
|
||||
if adb_cfg:
|
||||
cmd += ["--adb-dsn", adb_cfg["dsn"], "--adb-user", adb_cfg["username"]]
|
||||
if adb_cfg.get("wallet_dir"): cmd += ["--adb-wallet", adb_cfg["wallet_dir"]]
|
||||
if obp: cmd.append("--obp")
|
||||
if raw: cmd.append("--raw")
|
||||
if redact_output: cmd.append("--redact-output")
|
||||
proc = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
_running_reports[rid] = proc
|
||||
# Read stdout line by line for real-time progress
|
||||
progress_lines = []
|
||||
try:
|
||||
while True:
|
||||
@@ -2017,27 +2042,41 @@ async def _exec_report(rid, cfg, regions, mcp_server_id):
|
||||
progress_lines.append(text)
|
||||
with db() as c:
|
||||
c.execute("UPDATE reports SET progress=? WHERE id=?",
|
||||
("\n".join(progress_lines[-30:]), rid))
|
||||
("\n".join(progress_lines[-50:]), rid))
|
||||
await proc.wait()
|
||||
finally:
|
||||
_running_reports.pop(rid, None)
|
||||
stderr_data = await proc.stderr.read()
|
||||
jp = rdir / "report.json"; hp = rdir / "report.html"
|
||||
# Check if cancelled
|
||||
with db() as c:
|
||||
cur_status = c.execute("SELECT status FROM reports WHERE id=?", (rid,)).fetchone()
|
||||
if cur_status and cur_status["status"] == "cancelled":
|
||||
return # Already marked as cancelled by the cancel endpoint
|
||||
with db() as c:
|
||||
if proc.returncode == 0 and jp.exists():
|
||||
c.execute("UPDATE reports SET status='completed',progress=?,report_data=?,html_path=?,json_path=?,completed_at=datetime('now') WHERE id=?",
|
||||
("\n".join(progress_lines), jp.read_text()[:500_000], str(hp) if hp.exists() else None, str(jp), rid))
|
||||
_config_log("oci", cfg["id"], cfg["tenancy_name"], "success", "report", f"Relatório concluído: {rid}")
|
||||
else:
|
||||
err = (stderr_data.decode(errors="replace") if stderr_data else "Unknown")[:2000]
|
||||
return
|
||||
if proc.returncode == 0:
|
||||
# Scan output directory for all generated files
|
||||
html_path = None; json_path = None
|
||||
with db() as c:
|
||||
for fpath in rdir.iterdir():
|
||||
if fpath.is_file():
|
||||
fname = fpath.name
|
||||
ftype = fpath.suffix.lstrip(".")
|
||||
category = _classify_report_file(fname)
|
||||
fsize = fpath.stat().st_size
|
||||
c.execute("INSERT INTO report_files (id,report_id,file_name,file_path,file_type,file_category,file_size) VALUES (?,?,?,?,?,?,?)",
|
||||
(str(uuid.uuid4()), rid, fname, str(fpath), ftype, category, fsize))
|
||||
if "summary_report" in fname and fname.endswith(".html"):
|
||||
html_path = str(fpath)
|
||||
elif "summary_report" in fname and fname.endswith(".json"):
|
||||
json_path = str(fpath)
|
||||
c.execute("UPDATE reports SET status='completed',progress=?,html_path=?,json_path=?,completed_at=datetime('now') WHERE id=?",
|
||||
("\n".join(progress_lines), html_path, json_path, rid))
|
||||
_config_log("oci", cfg["id"], cfg["tenancy_name"], "success", "report", f"Report completed: {rid}")
|
||||
else:
|
||||
err = (stderr_data.decode(errors="replace") if stderr_data else "Unknown")[:2000]
|
||||
with db() as c:
|
||||
c.execute("UPDATE reports SET status='failed',progress=?,error_msg=?,completed_at=datetime('now') WHERE id=?",
|
||||
("\n".join(progress_lines), err, rid))
|
||||
_config_log("oci", cfg["id"], cfg["tenancy_name"], "error", "report", err)
|
||||
_config_log("oci", cfg["id"], cfg["tenancy_name"], "error", "report", err)
|
||||
except Exception as e:
|
||||
_running_reports.pop(rid, None)
|
||||
with db() as c:
|
||||
@@ -2047,7 +2086,7 @@ async def _exec_report(rid, cfg, regions, mcp_server_id):
|
||||
@app.get("/api/reports")
|
||||
async def list_reports(u=Depends(current_user)):
|
||||
with db() as c:
|
||||
q = "SELECT id,user_id,tenancy_name,status,progress,mcp_server_id,created_at,completed_at,error_msg FROM reports"
|
||||
q = "SELECT id,user_id,tenancy_name,status,progress,level,obp_checks,raw_data,redact_output,created_at,completed_at,error_msg FROM reports"
|
||||
rows = c.execute(q+" ORDER BY created_at DESC").fetchall() if u["role"]=="admin" \
|
||||
else c.execute(q+" WHERE user_id=? ORDER BY created_at DESC",(u["id"],)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -2065,7 +2104,7 @@ async def cancel_report(rid: str, u=Depends(require("admin", "user"))):
|
||||
r = c.execute("SELECT status FROM reports WHERE id=?", (rid,)).fetchone()
|
||||
if not r: raise HTTPException(404)
|
||||
if r["status"] != "running":
|
||||
raise HTTPException(400, "Relatório não está em execução")
|
||||
raise HTTPException(400, "Report is not running")
|
||||
proc = _running_reports.get(rid)
|
||||
if proc:
|
||||
try:
|
||||
@@ -2078,7 +2117,7 @@ async def cancel_report(rid: str, u=Depends(require("admin", "user"))):
|
||||
pass
|
||||
_running_reports.pop(rid, None)
|
||||
with db() as c:
|
||||
c.execute("UPDATE reports SET status='cancelled',error_msg='Cancelado pelo usuário',completed_at=datetime('now') WHERE id=?", (rid,))
|
||||
c.execute("UPDATE reports SET status='cancelled',error_msg='Cancelled by user',completed_at=datetime('now') WHERE id=?", (rid,))
|
||||
_audit(u["id"], u["username"], "cancel_report", rid)
|
||||
return {"ok": True, "status": "cancelled"}
|
||||
|
||||
@@ -2088,11 +2127,34 @@ async def get_report(rid, u=Depends(current_user)):
|
||||
if not r: raise HTTPException(404)
|
||||
if u["role"]!="admin" and r["user_id"]!=u["id"]: raise HTTPException(403)
|
||||
d=dict(r)
|
||||
if d.get("report_data"):
|
||||
try: d["report_data"]=json.loads(d["report_data"])
|
||||
except: pass
|
||||
d.pop("report_data", None)
|
||||
d.pop("mcp_server_id", None)
|
||||
return d
|
||||
|
||||
@app.get("/api/reports/{rid}/files")
|
||||
async def list_report_files(rid: str, u=Depends(current_user)):
|
||||
with db() as c:
|
||||
r = c.execute("SELECT user_id FROM reports WHERE id=?", (rid,)).fetchone()
|
||||
if not r: raise HTTPException(404)
|
||||
if u["role"] != "admin" and r["user_id"] != u["id"]: raise HTTPException(403)
|
||||
files = c.execute(
|
||||
"SELECT id,file_name,file_type,file_category,file_size FROM report_files WHERE report_id=? ORDER BY file_category,file_name",
|
||||
(rid,)
|
||||
).fetchall()
|
||||
return [dict(f) for f in files]
|
||||
|
||||
@app.get("/api/reports/{rid}/files/{fid}/download")
|
||||
async def download_report_file(rid: str, fid: str, u=Depends(current_user)):
|
||||
with db() as c:
|
||||
r = c.execute("SELECT user_id FROM reports WHERE id=?", (rid,)).fetchone()
|
||||
if not r: raise HTTPException(404)
|
||||
if u["role"] != "admin" and r["user_id"] != u["id"]: raise HTTPException(403)
|
||||
f = c.execute("SELECT * FROM report_files WHERE id=? AND report_id=?", (fid, rid)).fetchone()
|
||||
if not f: raise HTTPException(404)
|
||||
p = Path(f["file_path"])
|
||||
if not p.exists(): raise HTTPException(404, "File not found on disk")
|
||||
return FileResponse(p, filename=f["file_name"])
|
||||
|
||||
@app.get("/api/reports/{rid}/html")
|
||||
async def report_html(rid):
|
||||
with db() as c: r=c.execute("SELECT html_path FROM reports WHERE id=?",(rid,)).fetchone()
|
||||
@@ -2446,6 +2508,73 @@ async def delete_prompt(pid: str, u=Depends(require("admin"))):
|
||||
c.execute("DELETE FROM system_prompts WHERE id=?", (pid,))
|
||||
return {"ok": True}
|
||||
|
||||
# ── CIS Engine Version Management ─────────────────────────────────────────────
|
||||
CIS_REPORTS_PATH = Path("/app/cis_reports.py")
|
||||
CIS_GITHUB_RAW = "https://raw.githubusercontent.com/oci-landing-zones/oci-cis-landingzone-quickstart/main/scripts/cis_reports.py"
|
||||
CIS_PATCHES = [
|
||||
{"name": "region_none_check_1",
|
||||
"original": "elif region.region_name in self.__regions_to_run_in or self.__run_in_all_regions:",
|
||||
"patched": "elif self.__run_in_all_regions or (self.__regions_to_run_in and region.region_name in self.__regions_to_run_in):"},
|
||||
{"name": "region_none_check_2",
|
||||
"original": "if self.__home_region not in self.__regions_to_run_in:",
|
||||
"patched": "if not self.__run_in_all_regions and self.__regions_to_run_in and self.__home_region not in self.__regions_to_run_in:"},
|
||||
]
|
||||
|
||||
def _read_cis_version(content: str = None) -> dict:
|
||||
if content is None:
|
||||
if not CIS_REPORTS_PATH.exists(): return {"version": "unknown", "date": "unknown"}
|
||||
content = CIS_REPORTS_PATH.read_text(encoding="utf-8", errors="replace")
|
||||
ver = re.search(r'RELEASE_VERSION\s*=\s*["\']([^"\']+)["\']', content)
|
||||
dt = re.search(r'UPDATED_DATE\s*=\s*["\']([^"\']+)["\']', content)
|
||||
return {"version": ver.group(1) if ver else "unknown", "date": dt.group(1) if dt else "unknown"}
|
||||
|
||||
@app.get("/api/cis-engine/version")
|
||||
async def cis_engine_version(u=Depends(current_user)):
|
||||
info = _read_cis_version()
|
||||
return {"version": info["version"], "updated_date": info["date"], "file_path": str(CIS_REPORTS_PATH)}
|
||||
|
||||
@app.get("/api/cis-engine/check-update")
|
||||
async def cis_engine_check_update(u=Depends(require("admin"))):
|
||||
import requests as req
|
||||
local = _read_cis_version()
|
||||
try:
|
||||
resp = req.get(CIS_GITHUB_RAW, timeout=30)
|
||||
resp.raise_for_status()
|
||||
remote = _read_cis_version(resp.text)
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"Failed to check GitHub: {str(e)[:300]}")
|
||||
return {
|
||||
"local_version": local["version"], "local_date": local["date"],
|
||||
"remote_version": remote["version"], "remote_date": remote["date"],
|
||||
"update_available": remote["version"] != local["version"]
|
||||
}
|
||||
|
||||
@app.post("/api/cis-engine/update")
|
||||
async def cis_engine_update(u=Depends(require("admin"))):
|
||||
import requests as req
|
||||
old = _read_cis_version()
|
||||
try:
|
||||
resp = req.get(CIS_GITHUB_RAW, timeout=60)
|
||||
resp.raise_for_status()
|
||||
content = resp.text
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"Failed to download from GitHub: {str(e)[:300]}")
|
||||
new = _read_cis_version(content)
|
||||
# Apply patches
|
||||
patches_applied = []
|
||||
for p in CIS_PATCHES:
|
||||
if p["original"] in content:
|
||||
content = content.replace(p["original"], p["patched"])
|
||||
patches_applied.append(p["name"])
|
||||
CIS_REPORTS_PATH.write_text(content, encoding="utf-8")
|
||||
# Save version info
|
||||
with db() as c:
|
||||
c.execute("INSERT INTO app_settings (key,value,updated_at) VALUES ('cis_engine_version',?,datetime('now')) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
|
||||
(new["version"],))
|
||||
log.info(f"CIS engine updated: {old['version']} → {new['version']}, patches: {patches_applied}")
|
||||
_audit(u["id"], u["username"], "cis_engine_update", None, f"{old['version']} → {new['version']}")
|
||||
return {"ok": True, "old_version": old["version"], "new_version": new["version"], "patches_applied": patches_applied}
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status":"ok","ts":datetime.utcnow().isoformat(),"version":VERSION}
|
||||
@@ -2471,6 +2600,16 @@ async def startup():
|
||||
)
|
||||
log.info(f"Auto-registered CIS Compliance Scanner MCP server (id={mid})")
|
||||
|
||||
# Save CIS engine version to app_settings
|
||||
try:
|
||||
vi = _read_cis_version()
|
||||
with db() as c:
|
||||
c.execute("INSERT INTO app_settings (key,value,updated_at) VALUES ('cis_engine_version',?,datetime('now')) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
|
||||
(vi["version"],))
|
||||
log.info(f"CIS engine version: {vi['version']} ({vi['date']})")
|
||||
except Exception as e:
|
||||
log.warning(f"Could not read CIS engine version: {e}")
|
||||
|
||||
log.info(f"OCI CIS AI Agent v{VERSION} API started")
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -216,7 +216,9 @@ const S={user:null,token:null,tab:'chat',msgs:[],sid:null,reports:[],ociCfg:[],g
|
||||
chatModel:'',chatOci:'',chatRegion:'',chatCompartment:'',
|
||||
chatParams:{temperature:1,max_tokens:6000,top_p:0.95,top_k:1,frequency_penalty:0,presence_penalty:0},chatParamsOpen:false,chatUseTools:true,
|
||||
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:'',
|
||||
cisVer:null,cisCheckResult:null,cisUpdating:false};
|
||||
const API='/api';
|
||||
|
||||
async function $api(p,o={}){const h={...(o.headers||{})};if(S.token)h['Authorization']='Bearer '+S.token;
|
||||
@@ -234,6 +236,7 @@ async function loadData(){try{
|
||||
try{S.adbCfg=await $api('/adb/configs')}catch(e){S.adbCfg=[]}
|
||||
try{S.chatPrompts=await $api('/prompts/chat')}catch(e){S.chatPrompts=[]}
|
||||
if(S.user.role==='admin'){try{S.users=await $api('/users')}catch(e){}}
|
||||
try{S.cisVer=await $api('/cis-engine/version')}catch(e){}
|
||||
}catch(e){console.error(e)}}
|
||||
function R(){document.getElementById('app').innerHTML=S.user?rApp():rLogin();bind();if(S.editing?.type==='mcp')setTimeout(mcpTypeFields,0)}
|
||||
function switchTab(t){S.tab=t;S.expData=null;R();if(t==='audit'&&S.user.role==='admin')loadAudit();if(t==='downloads')refreshDl();
|
||||
@@ -441,8 +444,9 @@ ${S.rptOciOpen?`<div class="mdrop-list" style="position:absolute;top:100%;left:0
|
||||
${ociDdItems||'<div style="padding:.5rem;color:var(--t4);font-size:.72rem">Nenhuma credencial encontrada</div>'}
|
||||
</div>`:''}
|
||||
</div></div>
|
||||
<div class="ig"><label>MCP Server (opcional)</label><select id="rmcp"><option value="">Padrão (stub)</option>
|
||||
${S.mcpSvr.filter(m=>m.is_active).map(m=>`<option value="${m.id}">${m.name}${m.linked_adb_id?' 🗄️':''}</option>`).join('')}</select></div>
|
||||
<div class="ig"><label>CIS Level</label><select onchange="S.rptLevel=parseInt(this.value);R()">
|
||||
<option value="1" ${S.rptLevel===1?'selected':''}>Level 1</option>
|
||||
<option value="2" ${S.rptLevel===2?'selected':''}>Level 2 (default)</option></select></div>
|
||||
<div class="ig"><label>Regiões (opcional — vazio = todas)</label>
|
||||
<div class="dd" style="position:relative"><div onclick="S.rptRegionsOpen=!S.rptRegionsOpen;R()" style="min-height:34px;display:flex;flex-wrap:wrap;gap:.25rem;align-items:center;cursor:pointer;padding:.35rem .65rem;background:var(--bg2);border:1.5px solid var(--bd);border-radius:10px;font-size:.78rem">
|
||||
${S.rptSelRegions.length?selTags:'<span style="color:var(--t4);font-size:.78rem">Todas as regiões</span>'}</div>
|
||||
@@ -451,7 +455,29 @@ ${S.rptRegionsOpen?`<div class="mdrop-list" style="position:absolute;top:100%;le
|
||||
${ddItems||'<div style="padding:.5rem;color:var(--t4);font-size:.72rem">Nenhuma região encontrada</div>'}
|
||||
</div>`:''}
|
||||
</div></div></div>
|
||||
<button class="btn bp" onclick="runRpt()">▶ Executar CIS Compliance</button></div>`}
|
||||
<div style="display:flex;gap:1.2rem;flex-wrap:wrap;margin-top:.5rem">
|
||||
<label style="display:flex;align-items:center;gap:.35rem;font-size:.76rem;cursor:pointer"><input type="checkbox" ${S.rptObp?'checked':''} onchange="S.rptObp=this.checked"> OCI Best Practices (OBP)</label>
|
||||
<label style="display:flex;align-items:center;gap:.35rem;font-size:.76rem;cursor:pointer"><input type="checkbox" ${S.rptRaw?'checked':''} onchange="S.rptRaw=this.checked"> Raw Data Output</label>
|
||||
<label style="display:flex;align-items:center;gap:.35rem;font-size:.76rem;cursor:pointer"><input type="checkbox" ${S.rptRedact?'checked':''} onchange="S.rptRedact=this.checked"> Redact OCIDs</label>
|
||||
</div>
|
||||
<button class="btn bp" style="margin-top:.6rem" onclick="runRpt()">▶ Executar CIS Compliance</button></div>${rCisEngine()}`}
|
||||
function rCisEngine(){
|
||||
if(S.user?.role!=='admin')return '';
|
||||
const v=S.cisVer;const cr=S.cisCheckResult;
|
||||
let status='';
|
||||
if(S.cisUpdating)status='<span style="color:var(--w);font-size:.74rem">⏳ Atualizando...</span>';
|
||||
else if(cr){
|
||||
if(cr.update_available)status=`<span style="color:var(--w);font-size:.74rem">Nova versão: <b>${cr.remote_version}</b> (${cr.remote_date})</span> <button class="btn bp" style="font-size:.7rem;padding:.2rem .6rem;margin-left:.4rem" onclick="cisDoUpdate()">Atualizar</button>`;
|
||||
else status='<span style="color:var(--ok);font-size:.74rem">✓ Versão mais recente</span>';
|
||||
}
|
||||
return`<div class="cd" style="margin-top:.85rem"><div class="ct">⚙️ CIS Engine</div>
|
||||
<div style="display:flex;align-items:center;gap:.8rem;flex-wrap:wrap">
|
||||
<span style="font-size:.8rem">Versão: <b>${v?v.version:'...'}</b>${v&&v.updated_date!=='unknown'?' ('+v.updated_date+')':''}</span>
|
||||
<button class="btn bs" style="font-size:.7rem;padding:.2rem .6rem" onclick="cisCheckUpdate()">Verificar Atualização</button>
|
||||
${status}</div></div>`}
|
||||
async function cisLoadVersion(){try{S.cisVer=await $api('/cis-engine/version');R()}catch(e){console.error('cisVer',e)}}
|
||||
async function cisCheckUpdate(){S.cisCheckResult=null;R();try{S.cisCheckResult=await $api('/cis-engine/check-update');R()}catch(e){alert('Erro ao verificar: '+e.message)}}
|
||||
async function cisDoUpdate(){if(!confirm('Atualizar CIS Engine para a versão mais recente do GitHub?'))return;S.cisUpdating=true;R();try{const d=await $api('/cis-engine/update',{method:'POST'});alert(`Atualizado: ${d.old_version} → ${d.new_version}\nPatches aplicados: ${d.patches_applied.join(', ')||'nenhum'}`);S.cisCheckResult=null;S.cisUpdating=false;await cisLoadVersion()}catch(e){S.cisUpdating=false;R();alert('Erro: '+e.message)}}
|
||||
function ldRpt(id){const f=document.getElementById('rfr');if(f)f.src=API+'/reports/'+id+'/html'}
|
||||
function rptPickRegion(r){S.rptSelRegions.push(r);S.rptRegionFilter='';R()}
|
||||
function rptRemoveRegion(r){S.rptSelRegions=S.rptSelRegions.filter(x=>x!==r);R()}
|
||||
@@ -460,9 +486,8 @@ function rptPickRsel(id){S.rptRselVal=id;S.rptRselOpen=false;S.rptRselFilter='';
|
||||
async function runRpt(){if(S.reports.some(r=>r.status==='running'))return alert('Já existe um relatório em execução. Aguarde ou cancele antes de iniciar outro.');
|
||||
const c=S.rptOciVal;if(!c)return alert('Selecione config OCI');
|
||||
const rg=S.rptSelRegions;
|
||||
const mcp=document.getElementById('rmcp').value||null;
|
||||
S.rptRegionsOpen=false;
|
||||
try{const d=await $api('/reports/run',{method:'POST',body:{config_id:c,mcp_server_id:mcp,regions:rg.length?rg:null}});
|
||||
try{const d=await $api('/reports/run',{method:'POST',body:{config_id:c,regions:rg.length?rg:null,level:S.rptLevel,obp:S.rptObp,raw:S.rptRaw,redact_output:S.rptRedact}});
|
||||
S.trackingReportId=d.report_id;S.rptSelRegions=[];S.rptOciVal='';S.reports=await $api('/reports');R();startRptPoll()}catch(e){alert('Erro: '+e.message)}}
|
||||
let _rptPollTimer=null;
|
||||
function startRptPoll(){stopRptPoll();_rptPollTimer=setInterval(pollRptProgress,3000)}
|
||||
@@ -472,7 +497,15 @@ async function pollRptProgress(){if(!S.trackingReportId)return stopRptPoll();
|
||||
const el=document.getElementById('rpt-progress');
|
||||
if(el){const lines=(p.progress||'').split('\n').filter(Boolean);
|
||||
const last=lines[lines.length-1]||'Iniciando...';
|
||||
const m=last.match(/\[(\d+)\/(\d+)\]/);const pct=m?Math.round((parseInt(m[1])/parseInt(m[2]))*100):0;
|
||||
const ft=(p.progress||'').toLowerCase();
|
||||
let pct=5;
|
||||
if(ft.includes('identity domains'))pct=10;
|
||||
if(ft.includes('processing home region'))pct=20;
|
||||
if(ft.includes('processing regional'))pct=35;
|
||||
if(ft.includes('cis summary report'))pct=60;
|
||||
if(ft.includes('writing cis reports'))pct=75;
|
||||
if(ft.includes('best practices'))pct=85;
|
||||
if(ft.includes('finished'))pct=100;
|
||||
el.innerHTML=rProgressCard(p.status,last,pct,lines,S.trackingReportId)}
|
||||
if(p.status==='completed'||p.status==='failed'||p.status==='cancelled'){stopRptPoll();S.trackingReportId=null;S.reports=await $api('/reports');R()}}catch(e){}}
|
||||
async function cancelRpt(rid){if(!confirm('Cancelar este relatório?'))return;
|
||||
@@ -490,17 +523,47 @@ ${lines.length>1?`<details style="margin-top:.35rem"><summary style="font-size:.
|
||||
</div></div>`}
|
||||
|
||||
/* ── Downloads ── */
|
||||
function rDl(){return`<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.55rem">
|
||||
<span style="font-size:.78rem;color:var(--t3)">${S.reports.length} relatório(s)</span>
|
||||
function rDl(){
|
||||
const tenancies=[...new Set(S.reports.map(r=>r.tenancy_name))].sort();
|
||||
const filtered=S.dlTenancyFilter?S.reports.filter(r=>r.tenancy_name===S.dlTenancyFilter):S.reports;
|
||||
return`<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.55rem;gap:.5rem;flex-wrap:wrap">
|
||||
<div style="display:flex;align-items:center;gap:.5rem">
|
||||
<select style="font-size:.76rem;min-width:180px" onchange="S.dlTenancyFilter=this.value;S.dlExpandedRid=null;R()">
|
||||
<option value="">Todas as Tenancies (${S.reports.length})</option>
|
||||
${tenancies.map(t=>`<option value="${t}" ${S.dlTenancyFilter===t?'selected':''}>${t} (${S.reports.filter(r=>r.tenancy_name===t).length})</option>`).join('')}
|
||||
</select>
|
||||
<span style="font-size:.72rem;color:var(--t4)">${filtered.length} report(s)</span></div>
|
||||
<button class="btn bs bsm" onclick="refreshDl()">🔄 Atualizar</button></div>
|
||||
<div class="cd"><table><thead><tr><th>Tenancy</th><th>Status</th><th>Criado</th><th>Concluído</th><th>Ações</th></tr></thead>
|
||||
<tbody>${S.reports.map(r=>`<tr><td><strong>${r.tenancy_name}</strong></td>
|
||||
<td><span class="badge ${r.status==='completed'?'b-pass':r.status==='failed'||r.status==='cancelled'?'b-fail':'b-pend'}">${r.status==='cancelled'?'cancelado':r.status}</span>${r.status==='running'&&r.progress?`<div style="font-size:.62rem;color:var(--t4);margin-top:.2rem">${(r.progress||'').split('\\n').filter(Boolean).pop()||''}</div>`:''}</td>
|
||||
<td style="font-size:.72rem">${r.created_at||'—'}</td><td style="font-size:.72rem">${r.completed_at||'—'}</td>
|
||||
<td>${r.status==='completed'?`<button class="btn bs bsm" onclick="dlRpt('${r.id}','json')">JSON</button> <button class="btn bs bsm" onclick="dlRpt('${r.id}','html')">HTML</button>`:r.status==='running'?`<button class="btn bs bsm" style="color:var(--err);border-color:var(--err)" onclick="cancelRpt('${r.id}')">Cancelar</button>`:'<span style="color:var(--t4)">—</span>'}</td></tr>`).join('')}</tbody></table>
|
||||
${!S.reports.length?'<div class="emp"><div class="eic">📂</div><p>Nenhum relatório.</p></div>':''}</div>`}
|
||||
<div class="cd"><table><thead><tr><th>Tenancy</th><th>Level</th><th>Opções</th><th>Status</th><th>Executado</th><th>Concluído</th><th>Arquivos</th><th>Ações</th></tr></thead>
|
||||
<tbody>${filtered.map(r=>{
|
||||
const opts=[];if(r.obp_checks)opts.push('OBP');if(r.raw_data)opts.push('Raw');if(r.redact_output)opts.push('Redact');
|
||||
const isExp=S.dlExpandedRid===r.id;
|
||||
const fHtml=isExp&&S.reportFiles[r.id]?renderFileList(r.id,S.reportFiles[r.id]):'';
|
||||
return`<tr><td><strong>${r.tenancy_name}</strong></td>
|
||||
<td style="font-size:.72rem">${r.level||2}</td>
|
||||
<td style="font-size:.68rem">${opts.join(', ')||'—'}</td>
|
||||
<td><span class="badge ${r.status==='completed'?'b-pass':r.status==='failed'||r.status==='cancelled'?'b-fail':'b-pend'}">${r.status==='cancelled'?'cancelado':r.status}</span></td>
|
||||
<td style="font-size:.72rem">${r.created_at||'—'}</td>
|
||||
<td style="font-size:.72rem">${r.completed_at||'—'}</td>
|
||||
<td>${r.status==='completed'?`<button class="btn bs bsm" style="font-size:.68rem" onclick="toggleFiles('${r.id}')">${isExp?'Ocultar':'Ver'} Arquivos</button>`:'—'}</td>
|
||||
<td>${r.status==='completed'?`<button class="btn bs bsm" onclick="dlRpt('${r.id}','html')">HTML</button> <button class="btn bs bsm" onclick="dlRpt('${r.id}','json')">JSON</button>`:r.status==='running'?`<button class="btn bs bsm" style="color:var(--err);border-color:var(--err)" onclick="cancelRpt('${r.id}')">Cancelar</button>`:'—'}</td></tr>
|
||||
${isExp?`<tr><td colspan="8" style="padding:0">${fHtml}</td></tr>`:''}`}).join('')}</tbody></table>
|
||||
${!filtered.length?'<div class="emp"><div class="eic">📂</div><p>Nenhum relatório.</p></div>':''}</div>`}
|
||||
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()}
|
||||
function renderFileList(rid,files){if(!files.length)return'<div style="padding:.6rem;color:var(--t4);font-size:.72rem">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 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'};
|
||||
let h='<div style="padding:.6rem .8rem;background:var(--bg2);border-top:1px solid var(--b2)">';
|
||||
for(const[cat,cf] of Object.entries(groups)){
|
||||
h+=`<div style="margin-bottom:.5rem"><div style="font-size:.7rem;font-weight:600;color:var(--t3);margin-bottom:.3rem">${labels[cat]||cat} (${cf.length})</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:.3rem">`;
|
||||
cf.forEach(f=>{const kb=Math.round(f.file_size/1024);
|
||||
h+=`<a href="${API}/reports/${rid}/files/${f.id}/download" target="_blank" class="btn bs bsm" style="font-size:.64rem;text-decoration:none">${f.file_name} (${kb}KB)</a>`});
|
||||
h+='</div></div>'}
|
||||
h+='</div>';return h}
|
||||
function dlRpt(id,f){window.open(API+'/reports/'+id+'/download?fmt='+f,'_blank')}
|
||||
async function refreshDl(){S.reports=await $api('/reports');R()}
|
||||
async function refreshDl(){S.reports=await $api('/reports');S.reportFiles={};S.dlExpandedRid=null;R()}
|
||||
|
||||
/* ── Edit helpers ── */
|
||||
function editCfg(type,id){S.editing={type,id};if(type==='oci'){const c=S.ociCfg.find(x=>x.id===id);S.ociFormRegVal=c?c.region:'';S.ociFormRegFilter=''}R();
|
||||
|
||||
Reference in New Issue
Block a user