feat: LAD A-Team CIS Compliance Report, React SPA, i18n, report management, explorer UX fixes
- Professional Oracle-format compliance report with RAG remediation from ADB vector store - React 19 SPA at /app/ (16 pages, TypeScript, Vite, Zustand, i18n pt/en 625 keys) - Report delete endpoint, embed individual report files into vector store - Terraform ZIP download (client-side, pure JS) - Explorer silent polling during start/stop (no flickering) - HTML report and compliance report sections start minimized - Token auth fix for compliance report CSV download links
This commit is contained in:
44
README.md
44
README.md
@@ -9,7 +9,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-2.6-C74634?style=flat-square" alt="Version">
|
||||
<img src="https://img.shields.io/badge/version-2.7-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">
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
AI Agent — Infrastructure & Security Engineer is a self-hosted web application that automates **CIS Oracle Cloud Infrastructure Foundations Benchmark 3.0** compliance checks, powered by **OCI Generative AI** for intelligent analysis and an **MCP (Model Context Protocol)** server architecture for extensible task execution.
|
||||
|
||||
The platform combines security compliance scanning, AI-powered chat with **RAG (Retrieval-Augmented Generation)**, infrastructure exploration, and vector-based knowledge storage into a single, containerized solution with an **Oracle Dark Premium** theme (light/dark modes), **KPI dashboard** with compliance gauge, and **Chart.js** visualizations.
|
||||
The platform combines security compliance scanning, AI-powered chat with **RAG (Retrieval-Augmented Generation)**, infrastructure exploration, and vector-based knowledge storage into a single, containerized solution with a **dual frontend** (legacy SPA + React SPA at `/app/`), **Oracle Dark Premium** theme (light/dark modes), **KPI dashboard** with compliance gauge, **i18n** (pt/en), and **Chart.js** visualizations.
|
||||
|
||||
---
|
||||
|
||||
@@ -127,11 +127,26 @@ The platform combines security compliance scanning, AI-powered chat with **RAG (
|
||||
- **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
|
||||
- **Report history**: full execution history with status, tenancy filter, and download actions (HTML/JSON) in the Reports tab
|
||||
- **Delete reports**: remove completed reports and their associated files from the system
|
||||
- **File browser**: dedicated Downloads tab with expandable cards per completed report, files grouped by category (Summary, CIS Findings, OBP, Raw Data, etc.)
|
||||
- **Embed individual files**: embed any report file (CSV, TXT, JSON, PDF) directly into the ADB vector store for RAG enrichment
|
||||
- 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
|
||||
|
||||
### 📋 LAD A-Team CIS Compliance Report
|
||||
- **Professional compliance report** following Oracle Cloud Security Assessment format (cover page, purpose statement, disclaimer, copyright)
|
||||
- **Table of Contents** with links to each section and recommendation
|
||||
- **Security Overview**: 7 Oracle security pillars (Customer Isolation, Data Encryption, Data Management, etc.)
|
||||
- **CIS Assessment Summary**: table with Domains / Total Controls / Failed / Passed per section
|
||||
- **CIS Benchmark Recommendation Summary**: full table with Item / Description / Status (OK/FAILED)
|
||||
- **Detailed findings**: per-recommendation cards with compliance percentage bar, result description, and remediation steps
|
||||
- **RAG-powered remediation**: enriched remediation steps fetched from ADB vector store (`CISRECOM` table) using cosine similarity search — filters by exact recommendation number, extracts only remediation section from matched chunks
|
||||
- **Affected resources CSV links**: non-compliant findings show a clickable link to download the CSV with affected resource details (authenticated via JWT token)
|
||||
- **Download PDF**: `window.print()` with CSS `@page A4` formatting for professional PDF output
|
||||
- **Caching**: generated HTML cached to disk, subsequent views are instant; "Regenerar" button clears cache and re-generates
|
||||
- **Collapsible sections**: HTML report and compliance report start minimized, expandable on demand
|
||||
|
||||
### 🛡️ 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:
|
||||
@@ -418,16 +433,27 @@ Allow group <group-name> to read buckets in compartment <compartment-name>
|
||||
```
|
||||
oci-cis-agent/
|
||||
├── backend/
|
||||
│ ├── app.py # FastAPI application (~5900 lines)
|
||||
│ ├── app.py # FastAPI application (~6700 lines)
|
||||
│ ├── cis_reports.py # Oracle CIS Benchmark checker (6660 lines, report engine)
|
||||
│ ├── mcp_cis_server.py # MCP server with 12 granular CIS tools (~700 lines)
|
||||
│ ├── gen_tf_reference.py # OCI Terraform provider resource catalog generator
|
||||
│ ├── Dockerfile # Python 3.12 + OCI CLI + Terraform CLI
|
||||
│ └── requirements.txt # Dependencies
|
||||
├── frontend/
|
||||
│ └── index.html # SPA with Oracle Dark Premium theme (~3200 lines)
|
||||
│ └── index.html # Legacy SPA with Oracle Dark Premium theme (~2800 lines)
|
||||
├── frontend-react/
|
||||
│ ├── src/ # React 19 SPA (TypeScript, 42 source files, ~12500 lines)
|
||||
│ │ ├── pages/ # 8 page components (Dashboard, Explorer, Reports, Chat, Terraform, etc.)
|
||||
│ │ ├── api/ # API client + endpoint modules
|
||||
│ │ ├── stores/ # Zustand auth store
|
||||
│ │ ├── hooks/ # Custom hooks (theme, i18n)
|
||||
│ │ └── i18n/ # Internationalization (pt/en, 625 keys)
|
||||
│ └── dist/ # Built SPA served at /app/
|
||||
├── scripts/
|
||||
│ ├── reportgenerator.js # n8n reference script (report layout)
|
||||
│ └── *.pdf # Oracle CIS report PDF template
|
||||
├── nginx/
|
||||
│ └── default.conf # Reverse proxy config
|
||||
│ └── default.conf # Reverse proxy (legacy / + React /app/ + API /api/)
|
||||
├── docker-compose.yml # Orchestration
|
||||
├── logo.svg # Project logo (Oracle AI Robot)
|
||||
├── .env.example # Environment template
|
||||
@@ -562,6 +588,7 @@ oci-cis-agent/
|
||||
|--------|----------|-------------|
|
||||
| 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`, `report_date`) |
|
||||
| POST | `/api/embeddings/report/{rid}/file/{fid}` | Embed individual report file (CSV/TXT/JSON/PDF) into vector store |
|
||||
| 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/consult` | Query embeddings via vector search + GenAI (natural language Q&A) |
|
||||
@@ -601,11 +628,15 @@ oci-cis-agent/
|
||||
| DELETE | `/api/chat/{sid}` | Delete chat session and messages |
|
||||
| POST | `/api/reports/run` | Execute CIS report |
|
||||
| GET | `/api/reports` | List reports |
|
||||
| DELETE | `/api/reports/{rid}` | Delete report and associated files |
|
||||
| GET | `/api/reports/{id}/html` | View HTML report |
|
||||
| GET | `/api/reports/{id}/download` | Download report |
|
||||
| GET | `/api/reports/{rid}/summary` | Report KPI summary (score, pass/fail, sections) |
|
||||
| GET | `/api/reports/{rid}/files` | List report files by category |
|
||||
| GET | `/api/reports/{rid}/files/{fid}/download` | Download individual report file |
|
||||
| GET | `/api/reports/{rid}/compliance-report` | Serve cached LAD A-Team compliance report HTML |
|
||||
| POST | `/api/reports/{rid}/compliance-report/generate` | Generate compliance report (background, with RAG) |
|
||||
| GET | `/api/reports/{rid}/compliance-report/status` | Check if compliance report is ready |
|
||||
|
||||
### CIS Engine
|
||||
|
||||
@@ -680,7 +711,7 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the "
|
||||
| Component | Technology |
|
||||
|-----------|-----------|
|
||||
| Backend | Python 3.12, FastAPI 0.115, Uvicorn |
|
||||
| Frontend | Vanilla JS SPA, Oracle Dark Premium theme, Chart.js |
|
||||
| Frontend | React 19 SPA (Vite + TypeScript) at `/app/` + legacy Vanilla JS SPA, Oracle Dark Premium theme, Chart.js, i18n (pt/en) |
|
||||
| Auth | JWT + TOTP MFA + RBAC |
|
||||
| Database | SQLite (WAL mode) |
|
||||
| OCI SDK | `oci` 2.133.0, `oci-cli` |
|
||||
@@ -697,6 +728,7 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the "
|
||||
|
||||
| Version | Date | Changes |
|
||||
|---------|------|---------|
|
||||
| **v2.7** | 2026-03 | **LAD A-Team CIS Compliance Report**: professional Oracle-format compliance report with cover page, TOC, Security Overview (7 pillars), CIS Assessment Summary, detailed findings with compliance % bars, RAG-powered remediation from ADB vector store (`CISRECOM` table with strict recommendation number filtering), affected resources CSV download links per non-compliant finding (JWT auth via query param). **React SPA**: 16-page React 19 frontend at `/app/` (TypeScript, Vite, Zustand, 42 source files), dual-served alongside legacy SPA via nginx. **i18n**: full internationalization with 625 keys (pt/en), language switcher persisted in localStorage. **Report management**: delete reports endpoint, embed individual report files (CSV/TXT/JSON/PDF) into vector store. **Terraform ZIP download**: replaced individual file downloads with client-side ZIP generation (pure JS, no external lib). **Explorer UX**: silent polling during start/stop actions eliminates flickering (resources update in-place without clearing the list). **Report sections collapsed by default**: HTML report and compliance report iframes start minimized. |
|
||||
| **v2.6** | 2026-03 | **Rename**: app renamed to "AI Agent — Infrastructure & Security Engineer". **Session Token Auth**: OCI session token authentication support (type selector API Key/Session Token, conditional form fields, collapsible credential form, type tags in credentials table, `SecurityTokenSigner` backend). **Terraform Workspaces submenu**: sidebar sub-tab listing all workspaces grouped by date with accordion UI, filtered by existing chat sessions (INNER JOIN), actions (plan/apply/destroy/delete/open). **Retry on failure**: "Reenviar" button on failed messages across Chat Agent, Terraform Agent, and Prompt Generator. **Terraform fixes**: `f2` undefined variable in dedup logic, `oci_core_services` false positive validation (data sources excluded), `S.tfCode` reconstruction from `S.tfFiles` for Re-plan, auto-fix button visible without pre-selected model, DRG attachment rules in system prompt. **Resilience**: stuck workspace recovery on container restart (planning/applying/destroying → reset + lock file cleanup). |
|
||||
| **v2.5** | 2026-03 | **Explorer Expansion**: KPI stats bar with per-category resource counts (cached in SQLite `explorer_counts_cache`, background refresh on compartment access), enhanced views for VCNs/Subnets/Load Balancers/Buckets with expandable cards, start/stop for DB Systems + MySQL + Container Instances (5 resource types total), dead state filtering (`TERMINATED`/`DELETED`/`DELETING` hidden across all 36 endpoints), wider compartment tree (280px), IAM excluded from compartment totals. **Prompt Generator Intelligence**: same knowledge pipeline as Terraform Agent — auto-detects resource types from user message, fetches official docs (Example Usage + Arguments) from GitHub cached in SQLite, uses compact categorized resource reference. **Network Firewall fix**: `NetworkFirewallPolicySummaryCollection` not iterable — added `.items` accessor for collection objects. |
|
||||
| **v2.4** | 2026-03 | **Terraform Safety & UX**: region safety guard (plan blocked if model omits `variable "region"` — prevents wrong-region provisioning), rollback button on apply failure (manual `terraform destroy` with "ROLLBACK" confirmation), SSH public key field in OCI configs (auto-injected into `terraform.tfvars` for compute instances), reasoning_effort uppercase fix for OCI SDK, split-panel layout (terminal right 40%, files/plan/resources below chat 60%), plan button persists after destroy. **Terraform tfvars**: expanded `oci_var_map` with `ssh_public_key`/`ssh_authorized_keys` auto-mapping from OCI config |
|
||||
|
||||
738
backend/app.py
738
backend/app.py
@@ -17,7 +17,7 @@ from fastapi import (
|
||||
Query, Body, BackgroundTasks
|
||||
)
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, FileResponse, StreamingResponse
|
||||
from fastapi.responses import JSONResponse, FileResponse, StreamingResponse, HTMLResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel
|
||||
import jwt as pyjwt
|
||||
@@ -3566,6 +3566,38 @@ async def embed_report(rid: str, req: dict, bg: BackgroundTasks, u=Depends(requi
|
||||
_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}
|
||||
|
||||
@app.post("/api/embeddings/report/{rid}/file/{fid}")
|
||||
async def embed_report_file(rid: str, fid: str, req: dict, bg: BackgroundTasks, u=Depends(require("admin","user"))):
|
||||
"""Embed a specific report file (CSV, JSON, TXT, etc.) into ADB vector store."""
|
||||
vid = req.get("adb_config_id")
|
||||
if not vid: raise HTTPException(400, "adb_config_id is required")
|
||||
with db() as c:
|
||||
r = c.execute("SELECT 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")
|
||||
f = c.execute("SELECT * FROM report_files WHERE id=? AND report_id=?", (fid, rid)).fetchone()
|
||||
if not f: raise HTTPException(404, "File not found")
|
||||
p = Path(f["file_path"])
|
||||
if not p.exists(): raise HTTPException(404, "File not found on disk")
|
||||
fname = f["file_name"].lower()
|
||||
allowed = ('.txt', '.csv', '.json', '.md', '.pdf')
|
||||
if not any(fname.endswith(ext) for ext in allowed):
|
||||
raise HTTPException(400, f"Formatos aceitos para embedding: {', '.join(allowed)}")
|
||||
raw = p.read_bytes()
|
||||
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")
|
||||
documents = _chunk_text_file(content, f["file_name"])
|
||||
if not documents: raise HTTPException(400, "No content chunks found")
|
||||
cfg, gc = _get_adb_and_genai(vid, oci_config_id=r.get("config_id"))
|
||||
target_table = req.get("table_name") or None
|
||||
tenancy = r["tenancy_name"] or "unknown"
|
||||
bg.add_task(_ingest_documents_task, cfg, gc, documents, u["id"], u["username"],
|
||||
table_name=target_table, tenancy=tenancy)
|
||||
_audit(u["id"], u["username"], "embed_report_file", f"{rid}/{fid}", f"{f['file_name']}, {len(documents)} chunks, tenancy={tenancy}")
|
||||
return {"ok": True, "message": f"Embedding de {f['file_name']} iniciado ({len(documents)} chunks)", "chunks": len(documents)}
|
||||
|
||||
def _extract_pdf_text(file_bytes: bytes) -> str:
|
||||
"""Extract text from a PDF file using PyPDF2 or pdfplumber."""
|
||||
try:
|
||||
@@ -3983,6 +4015,23 @@ async def get_report(rid, u=Depends(current_user)):
|
||||
if u["role"]!="admin" and r["user_id"]!=u["id"]: raise HTTPException(403)
|
||||
return dict(r)
|
||||
|
||||
@app.delete("/api/reports/{rid}")
|
||||
async def delete_report(rid: str, u=Depends(current_user)):
|
||||
with db() as c:
|
||||
r = c.execute("SELECT id, user_id, status, json_path, html_path 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)
|
||||
if r["status"] == "running": raise HTTPException(400, "Cannot delete a running report")
|
||||
# Remove associated files
|
||||
for path_col in ("json_path", "html_path"):
|
||||
p = r[path_col]
|
||||
if p and Path(p).exists():
|
||||
try: Path(p).unlink()
|
||||
except OSError: pass
|
||||
c.execute("DELETE FROM reports WHERE id=?", (rid,))
|
||||
_audit(u["id"], u["username"], "delete_report", rid)
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/reports/{rid}/summary")
|
||||
async def report_summary(rid: str, u=Depends(current_user)):
|
||||
with db() as c:
|
||||
@@ -4064,6 +4113,689 @@ async def report_html(rid):
|
||||
if not r or not r["html_path"] or not Path(r["html_path"]).exists(): raise HTTPException(404)
|
||||
return FileResponse(r["html_path"], media_type="text/html")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# LAD A-Team CIS Compliance Report
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _extract_section(text: str, section_name: str) -> str:
|
||||
"""Extract a named section from a CIS Benchmark chunk (Description, Remediation, etc.)."""
|
||||
import re as _re
|
||||
pattern = _re.compile(r'\n' + _re.escape(section_name) + r'[:\s]*\n', _re.IGNORECASE)
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
return ""
|
||||
start = match.end()
|
||||
end_match = _re.search(
|
||||
r'\n(?:Description|Rationale|Audit|Remediation|Additional Information|CIS Controls|Controls\s*\nVersion|References|Default Value|Impact|Recommendation|Profile Applicability)[:\s]*\n',
|
||||
text[start:], _re.IGNORECASE
|
||||
)
|
||||
if end_match:
|
||||
return text[start:start + end_match.start()].strip()
|
||||
return text[start:].strip()
|
||||
|
||||
|
||||
def _extract_remediation_section(text: str) -> str:
|
||||
"""Extract only the Remediation section."""
|
||||
return _extract_section(text, "Remediation")
|
||||
|
||||
|
||||
def _extract_description_section(text: str) -> str:
|
||||
"""Extract the Description section (what this CIS finding is about)."""
|
||||
return _extract_section(text, "Description")
|
||||
|
||||
|
||||
def _format_remediation_html(text: str) -> str:
|
||||
"""Format remediation text with code snippets and proper indentation."""
|
||||
import re as _re
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
def esc(v):
|
||||
return (v or "").replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||
|
||||
lines = text.split('\n')
|
||||
html_parts = []
|
||||
in_code = False
|
||||
code_buf = []
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
# Detect CLI commands and policy statements
|
||||
is_code = (
|
||||
stripped.startswith('oci ') or
|
||||
stripped.startswith('Allow ') or
|
||||
stripped.startswith('Define ') or
|
||||
stripped.startswith('Endorse ') or
|
||||
stripped.startswith('Admit ') or
|
||||
stripped.startswith('terraform ') or
|
||||
stripped.startswith('curl ') or
|
||||
stripped.startswith('openssl ') or
|
||||
stripped.startswith('ssh ') or
|
||||
stripped.startswith('python') or
|
||||
stripped.startswith('$ ') or
|
||||
stripped.startswith('# ') and not stripped.startswith('# ') and len(stripped) < 5 or
|
||||
(in_code and stripped and not stripped[0].isupper() and not stripped.startswith('http'))
|
||||
)
|
||||
|
||||
if is_code:
|
||||
if not in_code:
|
||||
in_code = True
|
||||
code_buf = []
|
||||
code_buf.append(stripped)
|
||||
else:
|
||||
if in_code and code_buf:
|
||||
html_parts.append('<pre class="code-snippet">' + esc('\n'.join(code_buf)) + '</pre>')
|
||||
code_buf = []
|
||||
in_code = False
|
||||
if stripped:
|
||||
# Numbered steps
|
||||
step_match = _re.match(r'^(\d+)\.\s+(.+)', stripped)
|
||||
if step_match:
|
||||
html_parts.append(f'<div class="rem-step"><span class="step-num">{step_match.group(1)}.</span> {esc(step_match.group(2))}</div>')
|
||||
else:
|
||||
html_parts.append(f'<p class="rem-text">{esc(stripped)}</p>')
|
||||
|
||||
# Flush remaining code
|
||||
if code_buf:
|
||||
html_parts.append('<pre class="code-snippet">' + esc('\n'.join(code_buf)) + '</pre>')
|
||||
|
||||
return '\n'.join(html_parts)
|
||||
|
||||
|
||||
def _search_cis_remediation(title: str, rec_num: str, user_id: str) -> dict:
|
||||
"""Search CIS Recommendations in vector DB. Returns {'description': str, 'remediation': str}."""
|
||||
try:
|
||||
with db() as c:
|
||||
adb_cfgs = c.execute(
|
||||
"SELECT * FROM adb_vector_configs WHERE user_id=? AND is_active=1", (user_id,)
|
||||
).fetchall()
|
||||
if not adb_cfgs:
|
||||
return {"description": "", "remediation": ""}
|
||||
query = f"Recommendation: {rec_num} {title} Remediation Audit From Console From Command Line"
|
||||
for adb_cfg in adb_cfgs:
|
||||
adb_cfg = dict(adb_cfg)
|
||||
try:
|
||||
# Resolve embedding config: use the ADB's own embedding model
|
||||
emb_model = adb_cfg.get("embedding_model_id", "cohere.embed-v4.0")
|
||||
|
||||
tables = _get_tables_for_config(adb_cfg["id"], active_only=True)
|
||||
if not tables:
|
||||
continue
|
||||
|
||||
# Resolve GenAI config for embedding generation
|
||||
genai_linked = None
|
||||
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(genai_cfg=genai_linked)
|
||||
|
||||
# Search across cisrecom + section-specific tables
|
||||
all_docs = []
|
||||
search_tables = [t["table_name"] for t in tables if "cisrecom" in t["table_name"].lower()]
|
||||
if not search_tables:
|
||||
search_tables = [t["table_name"] for t in tables]
|
||||
|
||||
for tbl_name in search_tables:
|
||||
try:
|
||||
# Detect dimension per table
|
||||
tbl_model = emb_model
|
||||
try:
|
||||
actual_dim = _get_table_embedding_dim(adb_cfg, tbl_name)
|
||||
if actual_dim and actual_dim in _DIM_TO_MODEL:
|
||||
tbl_model = _DIM_TO_MODEL[actual_dim]
|
||||
except Exception:
|
||||
pass
|
||||
query_embedding = _embed_text(query, emb_genai, tbl_model)
|
||||
docs = _vector_search(adb_cfg, query_embedding, top_k=10, table_name=tbl_name)
|
||||
if docs:
|
||||
all_docs.extend(docs)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if all_docs:
|
||||
import re as _re
|
||||
all_docs.sort(key=lambda d: d.get("distance", 999))
|
||||
# STRICT FILTER: only keep chunks that mention THIS exact recommendation number
|
||||
exact_pattern = _re.compile(
|
||||
r'(?:Recommendation[:\s]*' + _re.escape(rec_num) + r'|^' + _re.escape(rec_num) + r')\s',
|
||||
_re.MULTILINE | _re.IGNORECASE
|
||||
)
|
||||
matched = [d["content"] for d in all_docs
|
||||
if d.get("content") and len(d["content"]) > 500
|
||||
and exact_pattern.search(d["content"])]
|
||||
if matched:
|
||||
best = matched[0]
|
||||
desc = _extract_description_section(best)
|
||||
rem = _extract_remediation_section(best)
|
||||
# Try second chunk if first has no remediation
|
||||
if not rem and len(matched) > 1:
|
||||
rem = _extract_remediation_section(matched[1])
|
||||
return {"description": desc, "remediation": rem or best}
|
||||
except Exception as e:
|
||||
log.warning(f"CIS remediation search failed for {adb_cfg.get('config_name','?')}: {e}")
|
||||
return {"description": "", "remediation": ""}
|
||||
except Exception as e:
|
||||
log.warning(f"CIS remediation search error: {e}")
|
||||
return {"description": "", "remediation": ""}
|
||||
|
||||
|
||||
def _build_compliance_html(filtered_rows: list, tenancy_name: str, extract_date: str, sections_data: dict, rid: str = "", file_id_map: dict = None) -> str:
|
||||
"""Build the full LAD A-Team CIS Compliance Report HTML."""
|
||||
import re as _re
|
||||
from datetime import datetime as _dt
|
||||
|
||||
now = _dt.utcnow()
|
||||
months_pt = ["Janeiro","Fevereiro","Março","Abril","Maio","Junho","Julho","Agosto","Setembro","Outubro","Novembro","Dezembro"]
|
||||
month_year = f"{months_pt[now.month - 1]}, {now.year}"
|
||||
|
||||
def esc(v): return (v or "").replace("&","&").replace("<","<").replace(">",">").replace('"',""")
|
||||
def slug(s): return _re.sub(r"[^\w-]","", _re.sub(r"\s+","-",(s or "").strip().lower()))
|
||||
|
||||
total_all = len(filtered_rows)
|
||||
total_passed = sum(1 for r in filtered_rows if r["__compliant"])
|
||||
total_failed = total_all - total_passed
|
||||
pct_global = round(total_passed / total_all * 100) if total_all else 0
|
||||
|
||||
# ── Domains table rows ──
|
||||
domains_rows = ""
|
||||
for sec_name, sd in sections_data.items():
|
||||
domains_rows += f'<tr><td class="dom">{esc(sec_name)}</td><td class="num">{sd["total"]}</td><td class="num fail">{sd["failed"]}</td><td class="num pass">{sd["passed"]}</td></tr>'
|
||||
|
||||
# ── CIS Benchmark Summary table ──
|
||||
summary_rows = ""
|
||||
current_section = ""
|
||||
for r in filtered_rows:
|
||||
sec = r["Section"]
|
||||
rec = r.get("Recommendation #", "")
|
||||
title = r.get("Title", "")
|
||||
is_compliant = r["__compliant"]
|
||||
if sec != current_section:
|
||||
current_section = sec
|
||||
summary_rows += f'<tr class="section-header"><td colspan="3"><b>{esc(sec)}</b></td></tr>'
|
||||
status_cls = "pass" if is_compliant else "fail"
|
||||
status_txt = "OK" if is_compliant else "FAILED"
|
||||
summary_rows += f'<tr><td class="rec-num">{esc(rec)}</td><td>{esc(title)}</td><td class="num {status_cls}">{status_txt}</td></tr>'
|
||||
|
||||
# ── Detailed sections ──
|
||||
sections_html = ""
|
||||
for sec_name, sd in sections_data.items():
|
||||
sec_id = f"section-{slug(sec_name)}"
|
||||
sections_html += f'<div class="detail-section page-break-before" id="{esc(sec_id)}">'
|
||||
sections_html += f'<h1 class="section-num">{esc(sec_name)}</h1>'
|
||||
|
||||
for r in sd["items"]:
|
||||
rec = r.get("Recommendation #", "")
|
||||
title = r.get("Title", "")
|
||||
rec_id = f"rec-{slug(sec_name)}-{slug(rec)}"
|
||||
is_compliant = r["__compliant"]
|
||||
findings = r.get("Findings", "").strip()
|
||||
findings_num = r["__findings"]
|
||||
total_items = r.get("Total", "").strip()
|
||||
compliant_items = r.get("Compliant Items", "").strip()
|
||||
pct = r["__pct"]
|
||||
csv_remediation = (r.get("Remediation", "") or "").strip()
|
||||
rag_data = r.get("__rag_remediation", {})
|
||||
if isinstance(rag_data, str):
|
||||
rag_data = {"description": "", "remediation": rag_data}
|
||||
rag_desc = (rag_data.get("description", "") or "").strip()
|
||||
rag_rem = (rag_data.get("remediation", "") or "").strip()
|
||||
|
||||
# Status line
|
||||
if is_compliant:
|
||||
result_html = f'<div class="result-box ok"><b>Result:</b> Tenancy {esc(tenancy_name)}: <span class="pass-text">Compliant</span></div>'
|
||||
else:
|
||||
detail = ""
|
||||
if findings_num is not None and total_items:
|
||||
detail = f" ({findings_num} of {esc(total_items)} items)"
|
||||
result_html = f'<div class="result-box fail"><b>Result:</b> Tenancy {esc(tenancy_name)}: <span class="fail-text">Non-Compliant{detail}</span></div>'
|
||||
|
||||
# Description (what this CIS finding is about)
|
||||
description_html = ""
|
||||
if not is_compliant and rag_desc:
|
||||
description_html = f'<div class="finding-desc"><h4>Description:</h4><p>{esc(rag_desc)}</p></div>'
|
||||
|
||||
# Remediation (only for non-compliant) — formatted with code snippets
|
||||
remediation_html = ""
|
||||
if not is_compliant:
|
||||
rem_text = rag_rem or csv_remediation
|
||||
if rem_text:
|
||||
formatted = _format_remediation_html(rem_text)
|
||||
remediation_html = f'<div class="remediation"><h4>Remediation:</h4><div class="remediation-content">{formatted}</div></div>'
|
||||
|
||||
# Compliance bar
|
||||
bar_html = ""
|
||||
if pct is not None:
|
||||
cls = "bad" if pct <= 20 else "warn" if pct <= 60 else "ok"
|
||||
bar_html = f'<div class="compliance-bar"><span class="bar-label">{pct:.0f}% Compliant</span><div class="bar"><div class="fill {cls}" style="width:{pct:.0f}%"></div></div></div>'
|
||||
|
||||
# Findings file link
|
||||
findings_file_html = ""
|
||||
csv_filename = (r.get("Filename", "") or "").strip()
|
||||
if csv_filename and not is_compliant and file_id_map:
|
||||
fid = file_id_map.get(csv_filename, "")
|
||||
if fid and rid:
|
||||
dl_url = f"/api/reports/{rid}/files/{fid}/download"
|
||||
findings_file_html = f'<div class="findings-file"><a href="{dl_url}" target="_blank" class="file-link">📄 {esc(csv_filename)}</a><span class="file-hint">Affected resources details</span></div>'
|
||||
|
||||
sections_html += f'''
|
||||
<div class="finding" id="{esc(rec_id)}">
|
||||
<h2>{esc(rec)} {esc(title)}</h2>
|
||||
{bar_html}
|
||||
{result_html}
|
||||
{findings_file_html}
|
||||
{description_html}
|
||||
{remediation_html}
|
||||
</div>'''
|
||||
|
||||
sections_html += '</div>'
|
||||
|
||||
# ── TOC ──
|
||||
toc_lines = '<div class="toc-line toc-l0"><a href="#cis-summary">CIS Security Assessment Summary</a></div>'
|
||||
toc_lines += '<div class="toc-line toc-l0"><a href="#benchmark-table">CIS Benchmark Recommendation Summary</a></div>'
|
||||
for sec_name, sd in sections_data.items():
|
||||
sec_id = f"section-{slug(sec_name)}"
|
||||
toc_lines += f'<div class="toc-line toc-l0"><a href="#{sec_id}">{esc(sec_name)}</a><span class="toc-count">{sd["total"]}</span></div>'
|
||||
for r in sd["items"]:
|
||||
rec = r.get("Recommendation #", "-")
|
||||
title = r.get("Title", "-")
|
||||
rec_id = f"rec-{slug(sec_name)}-{slug(rec)}"
|
||||
toc_lines += f'<div class="toc-line toc-l1"><a href="#{rec_id}">{esc(rec)} {esc(title)}</a></div>'
|
||||
|
||||
PURPOSE_TEXT = "The purpose of the cloud security assessment is to comprehensively evaluate the organization's security posture in its cloud environment, identifying vulnerabilities and gaps that could compromise data integrity, confidentiality, and availability. This assessment aims to enhance information security maturity, strengthen defenses against cyber threats, ensure compliance with regulations, and foster a culture of security awareness. Through periodic assessments, the organization proactively mitigates risks, ensuring continuous security for cloud-configured assets and maintaining stakeholders' trust in data protection capabilities."
|
||||
DISCLAIMER_TEXT = "This document in any form, software or printed matter, contains proprietary information that is the exclusive property of Oracle. Your access to and use of this confidential material is subject to the terms and conditions of your Oracle software license and service agreement, which has been executed and with which you agree to comply. This document and information contained herein may not be disclosed, copied, reproduced or distributed to anyone outside Oracle without prior written consent of Oracle. This document is not part of your license agreement, nor can it be incorporated into any contractual agreement with Oracle or its subsidiaries or affiliates.\n\nThis document is for informational purposes only and is intended solely to assist you in planning for the implementation and upgrade of the product features described. It is not a commitment to deliver any material, code, or functionality, and should not be relied upon in making purchasing decisions."
|
||||
SECURITY_OVERVIEW = """Oracle's mission is to build cloud infrastructure and platform services for your business to have effective and manageable security to run your mission-critical workloads and store your data with confidence. Oracle Cloud Infrastructure's security approach is based on seven core pillars:
|
||||
|
||||
<b>Customer Isolation</b> — Allow customers to deploy their application and data assets in an environment that commits full isolation from other tenants and Oracle's staff.
|
||||
|
||||
<b>Data Encryption</b> — Protect customer data at-rest and in-transit in a way that allows customers to meet their security and compliance requirements for cryptographic algorithms and key management.
|
||||
|
||||
<b>Security Controls</b> — Offer customers effective and easy-to-use security management solutions that allow them to constrain access to their services and segregate operational responsibilities.
|
||||
|
||||
<b>Visibility</b> — Offer customers comprehensive log data and security analytics that they can use to audit and monitor actions on their resources.
|
||||
|
||||
<b>Secure Hybrid Cloud</b> — Enable customers to use their existing security assets, such as user accounts and policies, as well as third-party security solutions when accessing their cloud resources.
|
||||
|
||||
<b>High Availability</b> — Offer fault-independent data centers that enable high availability scale-out architectures and are resilient against network attacks.
|
||||
|
||||
<b>Verifiably Secure Infrastructure</b> — Follow rigorous processes and use effective security controls in all phases of cloud service development and operation."""
|
||||
|
||||
return f'''<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>LAD A-Team CIS Compliance Report — {esc(tenancy_name)}</title>
|
||||
<style>
|
||||
@page {{ size: A4; margin: 14mm; }}
|
||||
@media print {{ .no-print {{ display: none !important; }} body {{ background: #fff !important; }} }}
|
||||
html,body {{ -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
|
||||
:root {{
|
||||
--text:#0f172a; --muted:#64748b; --line:#e2e8f0; --soft:#f8fafc;
|
||||
--oracle-red:#ff0000; --header-blue:#2f5597; --row-alt:#e8f0f7;
|
||||
--fail:#c00000; --pass:#0f7b0f; --bad:#ef4444; --warn:#f59e0b; --ok:#22c55e;
|
||||
}}
|
||||
body {{ margin:0; font-family:Arial,Helvetica,sans-serif; color:var(--text); background:#fff; font-size:12px; line-height:1.5; }}
|
||||
.wrap {{ max-width:960px; margin:0 auto; padding:0 14px; }}
|
||||
a {{ color:var(--header-blue); text-decoration:none; }}
|
||||
a:hover {{ text-decoration:underline; }}
|
||||
.page-break-before {{ break-before:page; page-break-before:always; }}
|
||||
|
||||
/* Cover */
|
||||
.cover {{ position:relative; min-height:calc(297mm - 28mm); page-break-after:always; }}
|
||||
.cover-oracle {{ position:absolute; top:0; left:0; font-weight:700; letter-spacing:.6px; color:var(--oracle-red); font-size:18px; }}
|
||||
.cover-rect {{ position:absolute; top:26mm; left:6mm; right:6mm; height:82mm; border:1px solid #9ca3af; }}
|
||||
.cover-title {{ position:absolute; left:0; bottom:62mm; font-size:42px; line-height:1.08; font-weight:500; color:#111827; max-width:165mm; }}
|
||||
.cover-subtitle {{ position:absolute; left:0; bottom:52mm; font-size:12px; color:#111827; }}
|
||||
.cover-meta {{ position:absolute; left:0; bottom:18mm; font-size:11px; color:#111827; line-height:1.45; }}
|
||||
|
||||
/* Text blocks */
|
||||
.text-block {{ border:1px solid var(--line); border-radius:10px; padding:14px 18px; margin:0 0 14px; break-inside:avoid; page-break-inside:avoid; }}
|
||||
.text-block h2 {{ font-size:14px; margin:0 0 8px; color:var(--oracle-red); }}
|
||||
.text-block p {{ margin:0; font-size:11px; line-height:1.5; white-space:pre-wrap; overflow-wrap:anywhere; text-align:justify; }}
|
||||
|
||||
/* TOC */
|
||||
.toc {{ margin:0 0 14px; }}
|
||||
.toc-brand {{ color:var(--oracle-red); font-weight:700; letter-spacing:.6px; font-size:16px; margin:0 0 6px; }}
|
||||
.toc-heading {{ color:var(--oracle-red); font-size:14px; margin:0 0 10px; }}
|
||||
.toc-rule {{ height:1px; background:#cbd5e1; margin:0 0 10px; }}
|
||||
.toc-lines {{ display:flex; flex-direction:column; gap:5px; font-size:11px; }}
|
||||
.toc-line {{ display:flex; align-items:baseline; gap:6px; }}
|
||||
.toc-l0 {{ font-weight:700; }}
|
||||
.toc-l1 {{ padding-left:16px; font-weight:400; color:#374151; }}
|
||||
.toc-line a {{ flex:1; min-width:0; overflow-wrap:anywhere; }}
|
||||
.toc-count {{ font-weight:700; color:var(--muted); min-width:20px; text-align:right; }}
|
||||
|
||||
/* Summary tables */
|
||||
.summary-card {{ border:1px solid var(--line); border-radius:10px; padding:14px 18px; margin:0 0 14px; }}
|
||||
.summary-card h1 {{ font-size:16px; margin:0 0 4px; }}
|
||||
.summary-card .sub {{ font-size:11px; color:var(--muted); margin:0 0 10px; }}
|
||||
table.domains {{ width:100%; border-collapse:collapse; border:1px solid #cbd5e1; font-size:11px; }}
|
||||
table.domains thead th {{ background:var(--header-blue); color:#fff; padding:7px 10px; text-transform:uppercase; letter-spacing:.4px; font-size:10px; text-align:center; border-right:1px solid rgba(255,255,255,.2); }}
|
||||
table.domains thead th:first-child {{ text-align:left; }}
|
||||
table.domains thead th:last-child {{ border-right:none; }}
|
||||
table.domains tbody td {{ padding:6px 10px; border-top:1px solid #cbd5e1; border-right:1px solid #cbd5e1; }}
|
||||
table.domains tbody td:last-child {{ border-right:none; }}
|
||||
table.domains tbody tr:nth-child(odd) {{ background:var(--row-alt); }}
|
||||
.dom {{ font-weight:600; }}
|
||||
.num {{ text-align:center; font-variant-numeric:tabular-nums; }}
|
||||
.fail {{ color:var(--fail); font-weight:700; }}
|
||||
.pass {{ color:var(--pass); font-weight:700; }}
|
||||
.totals {{ font-size:12px; margin-top:8px; color:var(--muted); }}
|
||||
|
||||
/* Benchmark summary table */
|
||||
table.benchmark {{ width:100%; border-collapse:collapse; border:1px solid #cbd5e1; font-size:11px; }}
|
||||
table.benchmark thead th {{ background:var(--header-blue); color:#fff; padding:7px 10px; text-align:left; font-size:10px; text-transform:uppercase; border-right:1px solid rgba(255,255,255,.2); }}
|
||||
table.benchmark thead th:last-child {{ text-align:center; border-right:none; }}
|
||||
table.benchmark tbody td {{ padding:5px 10px; border-top:1px solid #e5e7eb; }}
|
||||
table.benchmark .section-header td {{ background:#f1f5f9; font-weight:700; font-size:11px; padding:8px 10px; border-top:2px solid #cbd5e1; }}
|
||||
.rec-num {{ width:40px; font-weight:600; color:var(--header-blue); white-space:nowrap; }}
|
||||
.pass-text {{ color:var(--pass); font-weight:700; }}
|
||||
.fail-text {{ color:var(--fail); font-weight:700; }}
|
||||
|
||||
/* Detail sections */
|
||||
.detail-section {{ margin-top:20px; }}
|
||||
.detail-section h1.section-num {{ font-size:18px; color:var(--header-blue); border-bottom:2px solid var(--header-blue); padding-bottom:6px; margin:0 0 16px; }}
|
||||
.finding {{ border:1px solid var(--line); border-radius:10px; padding:14px 18px; margin:0 0 14px; break-inside:avoid; page-break-inside:avoid; }}
|
||||
.finding h2 {{ font-size:13px; font-weight:700; margin:0 0 8px; color:#111827; line-height:1.35; }}
|
||||
|
||||
/* Result box */
|
||||
.result-box {{ padding:8px 12px; border-radius:8px; font-size:11px; margin:8px 0; }}
|
||||
.result-box.ok {{ background:#f0fdf4; border:1px solid #bbf7d0; }}
|
||||
.result-box.fail {{ background:#fef2f2; border:1px solid #fecaca; }}
|
||||
|
||||
/* Findings file link */
|
||||
.findings-file {{ display:flex; align-items:center; gap:8px; margin:8px 0; padding:8px 12px; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; }}
|
||||
.file-link {{ font-size:12px; font-weight:700; color:var(--header-blue); text-decoration:none; }}
|
||||
.file-link:hover {{ text-decoration:underline; }}
|
||||
.file-hint {{ font-size:10px; color:var(--muted); }}
|
||||
@media print {{ .file-link {{ color:var(--header-blue) !important; }} }}
|
||||
|
||||
/* Description */
|
||||
.finding-desc {{ margin:10px 0 0; }}
|
||||
.finding-desc h4 {{ font-size:12px; margin:0 0 6px; color:var(--header-blue); }}
|
||||
.finding-desc p {{ font-size:11px; line-height:1.6; text-align:justify; color:#374151; margin:0; padding:10px 14px; background:#f0f4ff; border:1px solid #dbeafe; border-radius:8px; }}
|
||||
|
||||
/* Remediation */
|
||||
.remediation {{ margin:10px 0 0; }}
|
||||
.remediation h4 {{ font-size:12px; margin:0 0 6px; color:var(--oracle-red); }}
|
||||
.remediation-content {{ font-size:11px; line-height:1.55; }}
|
||||
.rem-step {{ display:flex; gap:6px; padding:3px 0; align-items:flex-start; }}
|
||||
.rem-step .step-num {{ font-weight:700; color:var(--header-blue); min-width:20px; }}
|
||||
.rem-text {{ margin:4px 0; text-align:justify; color:#374151; }}
|
||||
.code-snippet {{ background:#1e293b; color:#e2e8f0; padding:10px 14px; border-radius:8px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:11px; line-height:1.5; overflow-x:auto; white-space:pre-wrap; word-break:break-all; margin:6px 0; border-left:3px solid var(--header-blue); }}
|
||||
|
||||
/* Compliance bar */
|
||||
.compliance-bar {{ display:flex; align-items:center; gap:10px; margin:6px 0 8px; }}
|
||||
.bar-label {{ font-size:11px; font-weight:700; width:100px; white-space:nowrap; }}
|
||||
.bar {{ flex:1; height:10px; background:#eef2ff; border:1px solid #e5e7eb; border-radius:999px; overflow:hidden; }}
|
||||
.fill {{ height:100%; border-radius:999px; }}
|
||||
.fill.bad {{ background:var(--bad); }}
|
||||
.fill.warn {{ background:var(--warn); }}
|
||||
.fill.ok {{ background:var(--ok); }}
|
||||
|
||||
/* Print button */
|
||||
.print-btn {{ position:fixed; bottom:20px; right:20px; padding:12px 24px; background:var(--header-blue); color:#fff; border:none; border-radius:8px; font-size:14px; font-weight:700; cursor:pointer; box-shadow:0 4px 12px rgba(0,0,0,.3); z-index:999; }}
|
||||
.print-btn:hover {{ background:#1e3a6e; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<!-- COVER -->
|
||||
<div class="cover">
|
||||
<div class="cover-oracle">ORACLE</div>
|
||||
<div class="cover-rect"></div>
|
||||
<div class="cover-title">Oracle Cloud Security<br/>Assessment</div>
|
||||
<div class="cover-subtitle">{esc(tenancy_name)} – Oracle Cloud Infrastructure</div>
|
||||
<div class="cover-meta">
|
||||
<div><b>Tenancy:</b> {esc(tenancy_name)}</div>
|
||||
<div>{esc(month_year)}, Version [1.0]</div>
|
||||
{f'<div><b>Extract date:</b> {esc(extract_date)}</div>' if extract_date else ''}
|
||||
<div>Copyright © {now.year}, Oracle and/or its affiliates</div>
|
||||
<div>Confidential – Oracle Restricted</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PURPOSE + DISCLAIMER -->
|
||||
<div class="text-block">
|
||||
<h2>Purpose Statement</h2>
|
||||
<p>{esc(PURPOSE_TEXT)}</p>
|
||||
</div>
|
||||
<div class="text-block">
|
||||
<h2>Disclaimer</h2>
|
||||
<p>{esc(DISCLAIMER_TEXT)}</p>
|
||||
</div>
|
||||
|
||||
<!-- TOC -->
|
||||
<div class="toc page-break-before">
|
||||
<div class="toc-brand">ORACLE</div>
|
||||
<div class="toc-heading">Table of contents</div>
|
||||
<div class="toc-rule"></div>
|
||||
<div class="toc-lines">{toc_lines}</div>
|
||||
</div>
|
||||
|
||||
<!-- SECURITY OVERVIEW -->
|
||||
<div class="text-block page-break-before">
|
||||
<h2>Security Overview</h2>
|
||||
<p>{SECURITY_OVERVIEW}</p>
|
||||
</div>
|
||||
|
||||
<!-- CIS SECURITY ASSESSMENT SUMMARY -->
|
||||
<div class="summary-card page-break-before" id="cis-summary">
|
||||
<h1>CIS Security Assessment</h1>
|
||||
<div class="sub">CIS Oracle Cloud Infrastructure Foundations Benchmark v3.0.0</div>
|
||||
<p style="margin-bottom:12px">Number of controls per domain and non-compliant controls on tenancy <b>{esc(tenancy_name)}</b>:</p>
|
||||
<table class="domains">
|
||||
<thead><tr><th style="text-align:left">DOMAINS</th><th>TOTAL CONTROLS</th><th>FAILED</th><th>PASSED</th></tr></thead>
|
||||
<tbody>{domains_rows}</tbody>
|
||||
</table>
|
||||
<div class="totals">Total: <b>{total_all}</b> • Passed: <b class="pass">{total_passed}</b> • Failed: <b class="fail">{total_failed}</b> • Compliance: <b>{pct_global}%</b></div>
|
||||
</div>
|
||||
|
||||
<!-- CIS BENCHMARK RECOMMENDATION SUMMARY -->
|
||||
<div class="summary-card page-break-before" id="benchmark-table">
|
||||
<h1>CIS Benchmark Recommendation Summary</h1>
|
||||
<div class="sub">Status per recommendation</div>
|
||||
<table class="benchmark">
|
||||
<thead><tr><th style="width:50px">Item</th><th>Description</th><th style="width:70px;text-align:center">Status</th></tr></thead>
|
||||
<tbody>{summary_rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- DETAILED SECTIONS -->
|
||||
{sections_html}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Print/PDF button -->
|
||||
<button class="print-btn no-print" onclick="window.print()">🖶 Download PDF</button>
|
||||
<script>
|
||||
// Add auth token to findings file download links
|
||||
document.addEventListener('DOMContentLoaded', function() {{
|
||||
var token = '';
|
||||
try {{ token = new URLSearchParams(window.location.search).get('token') || ''; }} catch(e) {{}}
|
||||
if (!token) try {{ token = window.parent.localStorage.getItem('t') || ''; }} catch(e) {{}}
|
||||
if (!token) try {{ token = localStorage.getItem('t') || ''; }} catch(e) {{}}
|
||||
document.querySelectorAll('.file-link').forEach(function(a) {{
|
||||
if (token && a.href.includes('/download')) {{
|
||||
a.href += (a.href.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(token);
|
||||
}}
|
||||
}});
|
||||
}});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
|
||||
def _generate_compliance_report(rid: str, user_id: str, force_rag: bool = True) -> str:
|
||||
"""Generate and cache the LAD A-Team CIS Compliance Report HTML."""
|
||||
import csv as csvmod
|
||||
import re as _re
|
||||
|
||||
with db() as c:
|
||||
report = c.execute("SELECT * FROM reports WHERE id=?", (rid,)).fetchone()
|
||||
if not report:
|
||||
raise HTTPException(404, "Report not found")
|
||||
|
||||
rdir = REPORTS / rid
|
||||
csv_path = rdir / "cis_summary_report.csv"
|
||||
if not csv_path.exists():
|
||||
raise HTTPException(404, "cis_summary_report.csv not found")
|
||||
|
||||
rows = []
|
||||
with open(csv_path, "r", encoding="utf-8") as f:
|
||||
for row in csvmod.DictReader(f):
|
||||
rows.append(row)
|
||||
if not rows:
|
||||
raise HTTPException(404, "No data in summary CSV")
|
||||
|
||||
tenancy_name = report["tenancy_name"] or "Unknown"
|
||||
extract_date = rows[0].get("extract_date", "") if rows else ""
|
||||
|
||||
# Build filename → file_id map for download links
|
||||
file_id_map = {}
|
||||
with db() as c:
|
||||
rf = c.execute("SELECT id, file_name FROM report_files WHERE report_id=?", (rid,)).fetchall()
|
||||
for f in rf:
|
||||
file_id_map[f["file_name"]] = f["id"]
|
||||
|
||||
ALLOWED_SECTIONS = {
|
||||
"Asset Management", "Compute", "Identity and Access Management",
|
||||
"Logging and Monitoring", "Networking",
|
||||
"Storage - Block Volumes", "Storage - File Storage Service", "Storage - Object Storage",
|
||||
}
|
||||
|
||||
def norm_section(raw):
|
||||
s = _re.sub(r"\s+", " ", (raw or "").strip())
|
||||
if _re.match(r"^Logging and Monitoring\s*\d*$", s, _re.I): return "Logging and Monitoring"
|
||||
if _re.match(r"^Storage$", s, _re.I): return None
|
||||
if _re.match(r"^Storage\s*-\s*Block Volumes$", s, _re.I): return "Storage - Block Volumes"
|
||||
if _re.match(r"^Storage\s*-\s*File Storage Service$", s, _re.I): return "Storage - File Storage Service"
|
||||
if _re.match(r"^Storage\s*-\s*Object Storage$", s, _re.I): return "Storage - Object Storage"
|
||||
return s or None
|
||||
|
||||
seen = set()
|
||||
filtered = []
|
||||
for r in rows:
|
||||
sec = norm_section(r.get("Section", ""))
|
||||
if not sec or sec not in ALLOWED_SECTIONS: continue
|
||||
key = f"{sec}||{r.get('Recommendation #', '')}||{r.get('Title', '')}".lower()
|
||||
if key in seen: continue
|
||||
seen.add(key)
|
||||
r["Section"] = sec
|
||||
status_raw = (r.get("Compliant", "") or "").strip().lower()
|
||||
r["__compliant"] = status_raw in ("yes", "true", "compliant", "ok")
|
||||
pct_str = (r.get("Compliance Percentage Per Recommendation", "") or "").replace(",", ".")
|
||||
m = _re.search(r"(\d+(\.\d+)?)", pct_str)
|
||||
r["__pct"] = max(0, min(100, float(m.group(1)))) if m else None
|
||||
f_str = (r.get("Findings", "") or "").replace(",", "")
|
||||
fm = _re.search(r"\d+", f_str)
|
||||
r["__findings"] = int(fm.group(0)) if fm else None
|
||||
filtered.append(r)
|
||||
|
||||
# RAG remediations (with per-item timeout)
|
||||
for r in filtered:
|
||||
r["__rag_remediation"] = {"description": "", "remediation": ""}
|
||||
if force_rag:
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||
non_compliant = [r for r in filtered if not r["__compliant"]]
|
||||
log.info(f"Compliance report: searching RAG for {len(non_compliant)} non-compliant items")
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
for r in non_compliant:
|
||||
try:
|
||||
fut = pool.submit(_search_cis_remediation,
|
||||
r.get("Title", ""), r.get("Recommendation #", ""), user_id)
|
||||
r["__rag_remediation"] = fut.result(timeout=30)
|
||||
log.info(f" RAG OK: {r.get('Recommendation #', '?')}")
|
||||
except FuturesTimeout:
|
||||
log.warning(f" RAG timeout: {r.get('Recommendation #', '?')}")
|
||||
except Exception as e:
|
||||
log.warning(f" RAG error {r.get('Recommendation #', '?')}: {e}")
|
||||
|
||||
SECTION_ORDER = [
|
||||
"Identity and Access Management", "Networking", "Compute", "Logging and Monitoring",
|
||||
"Storage - Object Storage", "Storage - Block Volumes", "Storage - File Storage Service",
|
||||
"Asset Management",
|
||||
]
|
||||
from collections import OrderedDict
|
||||
sections = OrderedDict()
|
||||
for sec in SECTION_ORDER:
|
||||
items = [r for r in filtered if r["Section"] == sec]
|
||||
if items:
|
||||
items.sort(key=lambda x: [int(p) if p.isdigit() else 0 for p in (x.get("Recommendation #", "0") or "0").split(".")])
|
||||
passed = sum(1 for x in items if x["__compliant"])
|
||||
sections[sec] = {"items": items, "total": len(items), "passed": passed, "failed": len(items) - passed}
|
||||
|
||||
html = _build_compliance_html(filtered, tenancy_name, extract_date, sections, rid, file_id_map)
|
||||
|
||||
# Cache to disk
|
||||
cache_path = rdir / "compliance_report.html"
|
||||
cache_path.write_text(html, encoding="utf-8")
|
||||
log.info(f"Compliance report cached: {cache_path} ({len(html)} bytes)")
|
||||
return html
|
||||
|
||||
|
||||
@app.get("/api/reports/{rid}/compliance-report")
|
||||
async def compliance_report(rid: str, regen: int = Query(0)):
|
||||
"""Serve cached LAD A-Team CIS Compliance Report, or return 404 if not generated yet."""
|
||||
rdir = REPORTS / rid
|
||||
cache_path = rdir / "compliance_report.html"
|
||||
|
||||
if regen == 1 and cache_path.exists():
|
||||
cache_path.unlink()
|
||||
|
||||
if cache_path.exists():
|
||||
return FileResponse(cache_path, media_type="text/html")
|
||||
|
||||
raise HTTPException(404, "Compliance report not generated yet")
|
||||
|
||||
|
||||
@app.post("/api/reports/{rid}/compliance-report/generate")
|
||||
async def generate_compliance_report(rid: str, bg: BackgroundTasks, u=Depends(current_user)):
|
||||
"""Start generating the compliance report in background."""
|
||||
with db() as c:
|
||||
report = c.execute("SELECT * FROM reports WHERE id=?", (rid,)).fetchone()
|
||||
if not report: raise HTTPException(404, "Report not found")
|
||||
|
||||
rdir = REPORTS / rid
|
||||
csv_path = rdir / "cis_summary_report.csv"
|
||||
if not csv_path.exists():
|
||||
raise HTTPException(404, "cis_summary_report.csv not found")
|
||||
|
||||
# Delete existing cache to signal "generating"
|
||||
cache_path = rdir / "compliance_report.html"
|
||||
if cache_path.exists():
|
||||
cache_path.unlink()
|
||||
|
||||
has_adb = False
|
||||
with db() as c:
|
||||
adb_cfgs = c.execute("SELECT id FROM adb_vector_configs WHERE user_id=? AND is_active=1", (u["id"],)).fetchall()
|
||||
if adb_cfgs:
|
||||
has_adb = True
|
||||
|
||||
user_id = u["id"]
|
||||
log.info(f"Compliance report generation started for {rid} (has_adb={has_adb})")
|
||||
|
||||
def _bg_generate():
|
||||
try:
|
||||
log.info(f"Compliance report thread running for {rid}")
|
||||
_generate_compliance_report(rid, user_id, force_rag=has_adb)
|
||||
log.info(f"Compliance report generation completed for {rid}")
|
||||
except Exception as e:
|
||||
log.error(f"Compliance report generation failed for {rid}: {e}", exc_info=True)
|
||||
|
||||
_chat_executor.submit(_bg_generate)
|
||||
return {"status": "generating", "has_rag": has_adb}
|
||||
|
||||
|
||||
@app.get("/api/reports/{rid}/compliance-report/status")
|
||||
async def compliance_report_status(rid: str):
|
||||
"""Check if the compliance report exists."""
|
||||
cache_path = REPORTS / rid / "compliance_report.html"
|
||||
return {"ready": cache_path.exists()}
|
||||
|
||||
@app.get("/api/reports/{rid}/download")
|
||||
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)
|
||||
@@ -4261,11 +4993,11 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
|
||||
base_prompt = cfg_dict.get("system_prompt", "")
|
||||
cfg_dict["system_prompt"] = f"{base_prompt}\n\n{config_hint}" if base_prompt else config_hint
|
||||
|
||||
# ── Terraform agent: boost max_tokens to model limit + force reasoning_effort=high ──
|
||||
# ── Terraform agent: boost max_tokens (capped at 65K to avoid context overflow) + force reasoning_effort=high ──
|
||||
if agent_type == "terraform":
|
||||
tf_model_info = GENAI_MODELS.get(cfg_dict.get("model_id", ""), {})
|
||||
tf_model_max = tf_model_info.get("max_tokens", 32768)
|
||||
cfg_dict["max_tokens"] = tf_model_max
|
||||
cfg_dict["max_tokens"] = min(tf_model_max, 65000)
|
||||
if tf_model_info.get("reasoning") and not cfg_dict.get("reasoning_effort"):
|
||||
cfg_dict["reasoning_effort"] = "HIGH"
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ services:
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./frontend/index.html:/usr/share/nginx/html/index.html:ro
|
||||
- ./frontend-react/dist:/usr/share/nginx/html/app:ro
|
||||
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
ports:
|
||||
- "${PORT:-8080}:80"
|
||||
|
||||
73
frontend-react/README.md
Normal file
73
frontend-react/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
frontend-react/eslint.config.js
Normal file
23
frontend-react/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
21
frontend-react/index.html
Normal file
21
frontend-react/index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/app/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI Agent - Infrastructure & Security Engineer</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
// Restore theme before React hydration to avoid flash
|
||||
(function() {
|
||||
var t = localStorage.getItem('theme') || 'light';
|
||||
document.documentElement.className = t;
|
||||
})();
|
||||
</script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5920
frontend-react/package-lock.json
generated
Normal file
5920
frontend-react/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend-react/package.json
Normal file
41
frontend-react/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "frontend-react",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.6",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"recharts": "^3.8.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
5
frontend-react/postcss.config.js
Normal file
5
frontend-react/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
21
frontend-react/public/logo.svg
Normal file
21
frontend-react/public/logo.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="128" height="128">
|
||||
<!-- Oracle-style rounded rectangle -->
|
||||
<rect x="2" y="5" width="32" height="26" rx="13" ry="13" fill="rgba(199,70,52,0.08)" stroke="#C74634" stroke-width="2"/>
|
||||
<!-- Robot head -->
|
||||
<rect x="11" y="11" width="14" height="14" rx="4" fill="#fff" stroke="#C74634" stroke-width="0.5"/>
|
||||
<!-- Eyes -->
|
||||
<circle cx="15" cy="17" r="2" fill="#C74634"/>
|
||||
<circle cx="21" cy="17" r="2" fill="#C74634"/>
|
||||
<circle cx="15" cy="16.7" r="0.7" fill="#fff"/>
|
||||
<circle cx="21" cy="16.7" r="0.7" fill="#fff"/>
|
||||
<!-- Mouth -->
|
||||
<rect x="14" y="21" width="8" height="1.5" rx="0.75" fill="#C74634" opacity="0.6"/>
|
||||
<!-- Antenna -->
|
||||
<line x1="18" y1="11" x2="18" y2="6" stroke="#C74634" stroke-width="1.2" stroke-linecap="round"/>
|
||||
<circle cx="18" cy="5.5" r="1.5" fill="#C74634" opacity="0.3" stroke="#C74634" stroke-width="0.8"/>
|
||||
<!-- Ears/signal -->
|
||||
<line x1="11" y1="18" x2="6" y2="18" stroke="#C74634" stroke-width="1" stroke-linecap="round" opacity="0.5"/>
|
||||
<line x1="25" y1="18" x2="30" y2="18" stroke="#C74634" stroke-width="1" stroke-linecap="round" opacity="0.5"/>
|
||||
<circle cx="5.5" cy="18" r="1" fill="#C74634" opacity="0.4"/>
|
||||
<circle cx="30.5" cy="18" r="1" fill="#C74634" opacity="0.4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
63
frontend-react/src/App.tsx
Normal file
63
frontend-react/src/App.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import LoginPage from '@/pages/LoginPage';
|
||||
import ChatPage from '@/pages/ChatPage';
|
||||
import TerraformPage from '@/pages/TerraformPage';
|
||||
import PromptGeneratorPage from '@/pages/PromptGeneratorPage';
|
||||
import WorkspacesPage from '@/pages/WorkspacesPage';
|
||||
import ExplorerPage from '@/pages/ExplorerPage';
|
||||
import ReportsPage from '@/pages/ReportsPage';
|
||||
import OciConfigPage from '@/pages/config/OciConfigPage';
|
||||
import GenAiConfigPage from '@/pages/config/GenAiConfigPage';
|
||||
import McpServersPage from '@/pages/config/McpServersPage';
|
||||
import AdbConfigPage from '@/pages/config/AdbConfigPage';
|
||||
import EmbeddingsPage from '@/pages/config/EmbeddingsPage';
|
||||
import EmbConsultPage from '@/pages/config/EmbConsultPage';
|
||||
import UsersPage from '@/pages/admin/UsersPage';
|
||||
import MfaPage from '@/pages/admin/MfaPage';
|
||||
import AuditPage from '@/pages/admin/AuditPage';
|
||||
|
||||
export default function App() {
|
||||
const checkAuth = useAuthStore((s) => s.checkAuth);
|
||||
const loadData = useAppStore((s) => s.loadData);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth().then(() => {
|
||||
if (useAuthStore.getState().user) loadData();
|
||||
});
|
||||
}, [checkAuth, loadData]);
|
||||
|
||||
// Reload data when user changes (login)
|
||||
useEffect(() => {
|
||||
if (user) loadData();
|
||||
}, [user, loadData]);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<Navigate to="/chat" replace />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/terraform" element={<TerraformPage />} />
|
||||
<Route path="/terraform/prompt" element={<PromptGeneratorPage />} />
|
||||
<Route path="/terraform/workspaces" element={<WorkspacesPage />} />
|
||||
<Route path="/explorer" element={<ExplorerPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/config/oci" element={<OciConfigPage />} />
|
||||
<Route path="/config/genai" element={<GenAiConfigPage />} />
|
||||
<Route path="/config/mcp" element={<McpServersPage />} />
|
||||
<Route path="/config/adb" element={<AdbConfigPage />} />
|
||||
<Route path="/config/embeddings" element={<EmbeddingsPage />} />
|
||||
<Route path="/config/embeddings/consult" element={<EmbConsultPage />} />
|
||||
<Route path="/admin/users" element={<UsersPage />} />
|
||||
<Route path="/admin/mfa" element={<MfaPage />} />
|
||||
<Route path="/admin/audit" element={<AuditPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/chat" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
30
frontend-react/src/api/client.ts
Normal file
30
frontend-react/src/api/client.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
// Request: attach JWT
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('t');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response: handle 401 + extract data
|
||||
client.interceptors.response.use(
|
||||
(res) => res.data,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('t');
|
||||
window.location.href = '/app/login';
|
||||
}
|
||||
const detail = err.response?.data?.detail || err.response?.data?.message || err.message || 'Erro';
|
||||
return Promise.reject(new Error(detail));
|
||||
}
|
||||
);
|
||||
|
||||
export default client;
|
||||
71
frontend-react/src/api/endpoints/adb.ts
Normal file
71
frontend-react/src/api/endpoints/adb.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import client from '../client';
|
||||
|
||||
export interface AdbTableDef {
|
||||
id: string;
|
||||
table_name: string;
|
||||
description?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface AdbConfigFull {
|
||||
id: string;
|
||||
config_name: string;
|
||||
dsn: string;
|
||||
username: string;
|
||||
table_name?: string;
|
||||
use_mtls: boolean;
|
||||
is_active: boolean;
|
||||
wallet_dir?: string;
|
||||
genai_config_id?: string;
|
||||
embedding_model_id?: string;
|
||||
tables?: AdbTableDef[];
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface AdbTestResult {
|
||||
status: 'success' | 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface WalletParseResult {
|
||||
dsn_names: string[];
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export const adbApi = {
|
||||
list: () =>
|
||||
client.get('/adb/configs') as unknown as Promise<AdbConfigFull[]>,
|
||||
|
||||
create: (fd: FormData) =>
|
||||
client.post('/adb/config', fd, { headers: { 'Content-Type': 'multipart/form-data' } }) as unknown as Promise<{ id: string; config_name: string }>,
|
||||
|
||||
update: (id: string, fd: FormData) =>
|
||||
client.put(`/adb/configs/${id}`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }) as unknown as Promise<{ id: string; config_name: string }>,
|
||||
|
||||
remove: (id: string) =>
|
||||
client.delete(`/adb/configs/${id}`) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
test: (id: string) =>
|
||||
client.post(`/adb/test/${id}`) as unknown as Promise<AdbTestResult>,
|
||||
|
||||
parseWallet: (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('wallet', file);
|
||||
return client.post('/adb/parse-wallet', fd, { headers: { 'Content-Type': 'multipart/form-data' } }) as unknown as Promise<WalletParseResult>;
|
||||
},
|
||||
|
||||
uploadWallet: (id: string, file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('wallet', file);
|
||||
return client.post(`/adb/${id}/upload-wallet`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }) as unknown as Promise<{ ok: boolean; files: string[] }>;
|
||||
},
|
||||
|
||||
addTable: (vid: string, tableName: string, description: string) =>
|
||||
client.post(`/adb/${vid}/tables`, { table_name: tableName, description }) as unknown as Promise<{ id: string }>,
|
||||
|
||||
updateTable: (vid: string, tid: string, body: { table_name?: string; description?: string; is_active?: boolean }) =>
|
||||
client.put(`/adb/${vid}/tables/${tid}`, body) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
removeTable: (vid: string, tid: string) =>
|
||||
client.delete(`/adb/${vid}/tables/${tid}`) as unknown as Promise<{ ok: boolean }>,
|
||||
};
|
||||
72
frontend-react/src/api/endpoints/admin.ts
Normal file
72
frontend-react/src/api/endpoints/admin.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import client from '../client';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export interface AuditEntry {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
username: string | null;
|
||||
action: string;
|
||||
resource: string | null;
|
||||
details: string | null;
|
||||
ip: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ConfigLogEntry {
|
||||
id: string;
|
||||
config_type: string;
|
||||
config_id: string;
|
||||
config_name: string | null;
|
||||
severity: string;
|
||||
action: string;
|
||||
message: string;
|
||||
user_id: string | null;
|
||||
username: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ChatLogEntry {
|
||||
id: string;
|
||||
session_id: string | null;
|
||||
message_id: string | null;
|
||||
user_id: string | null;
|
||||
severity: string;
|
||||
source: string;
|
||||
action: string;
|
||||
message: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/* ── API calls ── */
|
||||
|
||||
export const adminApi = {
|
||||
getAuditLog: (limit = 100) =>
|
||||
client.get(`/audit-log?limit=${limit}`) as unknown as Promise<AuditEntry[]>,
|
||||
|
||||
getConfigLogs: (params: {
|
||||
config_type?: string;
|
||||
config_id?: string;
|
||||
severity?: string;
|
||||
limit?: number;
|
||||
} = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.config_type) qs.set('config_type', params.config_type);
|
||||
if (params.config_id) qs.set('config_id', params.config_id);
|
||||
if (params.severity) qs.set('severity', params.severity);
|
||||
qs.set('limit', String(params.limit ?? 50));
|
||||
return client.get(`/config-logs?${qs}`) as unknown as Promise<ConfigLogEntry[]>;
|
||||
},
|
||||
|
||||
getChatLogs: (params: {
|
||||
severity?: string;
|
||||
session_id?: string;
|
||||
limit?: number;
|
||||
} = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.severity) qs.set('severity', params.severity);
|
||||
if (params.session_id) qs.set('session_id', params.session_id);
|
||||
qs.set('limit', String(params.limit ?? 50));
|
||||
return client.get(`/chat-logs?${qs}`) as unknown as Promise<ChatLogEntry[]>;
|
||||
},
|
||||
};
|
||||
31
frontend-react/src/api/endpoints/auth.ts
Normal file
31
frontend-react/src/api/endpoints/auth.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import client from '../client';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
email?: string;
|
||||
mfa_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token?: string;
|
||||
user?: User;
|
||||
mfa_required?: boolean;
|
||||
mfa_setup?: boolean;
|
||||
totp_uri?: string;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
login: (username: string, password: string, totp_code?: string) =>
|
||||
client.post<never, LoginResponse>('/auth/login', { username, password, ...(totp_code && { totp_code }) }),
|
||||
|
||||
logout: () => client.post('/auth/logout'),
|
||||
|
||||
me: () => client.get<never, User>('/users/me'),
|
||||
|
||||
changePassword: (current_password: string, new_password: string) =>
|
||||
client.post('/auth/change-password', { current_password, new_password }),
|
||||
};
|
||||
94
frontend-react/src/api/endpoints/chat.ts
Normal file
94
frontend-react/src/api/endpoints/chat.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import client from '../client';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
agent_type: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
message_count?: number;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
session_id?: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
model_id?: string;
|
||||
status?: 'processing' | 'done' | 'failed';
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface ChatSendBody {
|
||||
message: string;
|
||||
session_id?: string;
|
||||
genai_config_id?: string;
|
||||
model_id?: string;
|
||||
oci_config_id?: string;
|
||||
genai_region?: string;
|
||||
compartment_id?: string;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
top_p?: number;
|
||||
top_k?: number;
|
||||
frequency_penalty?: number;
|
||||
presence_penalty?: number;
|
||||
reasoning_effort?: string;
|
||||
use_tools?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatSendResponse {
|
||||
message_id: string;
|
||||
session_id: string;
|
||||
status: 'processing' | 'done';
|
||||
}
|
||||
|
||||
export interface MessageStatusResponse {
|
||||
id: string;
|
||||
session_id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
model_id?: string;
|
||||
status: 'processing' | 'done' | 'failed';
|
||||
}
|
||||
|
||||
export interface SessionMessagesResponse {
|
||||
session: ChatSession;
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
/* ── API ── */
|
||||
|
||||
export const chatApi = {
|
||||
/** Send a chat message (text only) */
|
||||
send: (body: ChatSendBody) =>
|
||||
client.post('/chat', body) as unknown as Promise<ChatSendResponse>,
|
||||
|
||||
/** Send a chat message with file attachments */
|
||||
upload: (formData: FormData) =>
|
||||
client.post('/chat/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}) as unknown as Promise<ChatSendResponse>,
|
||||
|
||||
/** Poll message processing status */
|
||||
pollStatus: (messageId: string) =>
|
||||
client.get(`/chat/${messageId}/status`) as unknown as Promise<MessageStatusResponse>,
|
||||
|
||||
/** List chat sessions */
|
||||
listSessions: (agentType = 'chat', limit = 50) =>
|
||||
client.get(`/chat/sessions?agent_type=${agentType}&limit=${limit}`) as unknown as Promise<ChatSession[]>,
|
||||
|
||||
/** Load session with all messages */
|
||||
loadSession: (sessionId: string) =>
|
||||
client.get(`/chat/sessions/${sessionId}/messages`) as unknown as Promise<SessionMessagesResponse>,
|
||||
|
||||
/** Delete a session */
|
||||
deleteSession: (sessionId: string) =>
|
||||
client.delete(`/chat/${sessionId}`) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
/** Rename a session */
|
||||
renameSession: (sessionId: string, title: string) =>
|
||||
client.put(`/chat/sessions/${sessionId}/title`, { title }) as unknown as Promise<{ ok: boolean }>,
|
||||
};
|
||||
97
frontend-react/src/api/endpoints/embeddings.ts
Normal file
97
frontend-react/src/api/endpoints/embeddings.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import client from '../client';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export interface EmbeddingDoc {
|
||||
id: string;
|
||||
metadata: string;
|
||||
}
|
||||
|
||||
export interface EmbeddingListResult {
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
documents: EmbeddingDoc[];
|
||||
}
|
||||
|
||||
export interface EmbedUploadResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
chunks: number;
|
||||
filename?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface PurgeResult {
|
||||
ok: boolean;
|
||||
deleted: number;
|
||||
table: string;
|
||||
tenancy: string;
|
||||
}
|
||||
|
||||
export interface ConsultResult {
|
||||
answer: string;
|
||||
documents: {
|
||||
content: string;
|
||||
source: string;
|
||||
distance: number;
|
||||
metadata: string;
|
||||
}[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/* ── API ── */
|
||||
|
||||
export const embeddingsApi = {
|
||||
/** List embeddings for a given ADB config + optional table */
|
||||
list: (vid: string, tableName?: string, limit = 50, offset = 0) => {
|
||||
const params = new URLSearchParams();
|
||||
if (tableName) params.set('table_name', tableName);
|
||||
params.set('limit', String(limit));
|
||||
params.set('offset', String(offset));
|
||||
return client.get(`/embeddings/${vid}/list?${params}`) as unknown as Promise<EmbeddingListResult>;
|
||||
},
|
||||
|
||||
/** Delete a single embedding document */
|
||||
remove: (vid: string, docId: string, tableName?: string) => {
|
||||
const qs = tableName ? `?table_name=${encodeURIComponent(tableName)}` : '';
|
||||
return client.delete(`/embeddings/${vid}/${docId}${qs}`) as unknown as Promise<{ ok: boolean }>;
|
||||
},
|
||||
|
||||
/** Upload a file to generate embeddings */
|
||||
uploadFile: (adbConfigId: string, tableName: string, file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('adb_config_id', adbConfigId);
|
||||
fd.append('table_name', tableName);
|
||||
fd.append('file', file);
|
||||
return client.post('/embeddings/upload', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}) as unknown as Promise<EmbedUploadResult>;
|
||||
},
|
||||
|
||||
/** Import a URL to generate embeddings */
|
||||
uploadUrl: (adbConfigId: string, tableName: string, url: string) => {
|
||||
const fd = new FormData();
|
||||
fd.append('adb_config_id', adbConfigId);
|
||||
fd.append('table_name', tableName);
|
||||
fd.append('url', url);
|
||||
return client.post('/embeddings/upload-url', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}) as unknown as Promise<EmbedUploadResult>;
|
||||
},
|
||||
|
||||
/** Purge embeddings from a table (optionally filtered by tenancy) */
|
||||
purge: (vid: string, tableName: string, tenancy?: string) =>
|
||||
client.post(`/embeddings/${vid}/purge`, {
|
||||
table_name: tableName,
|
||||
tenancy: tenancy || '',
|
||||
}) as unknown as Promise<PurgeResult>,
|
||||
|
||||
/** Consult embeddings with a question */
|
||||
consult: (query: string, tableName?: string, topK = 10) =>
|
||||
client.post('/embeddings/consult', {
|
||||
query,
|
||||
table_name: tableName || '',
|
||||
top_k: topK,
|
||||
}) as unknown as Promise<ConsultResult>,
|
||||
};
|
||||
129
frontend-react/src/api/endpoints/explorer.ts
Normal file
129
frontend-react/src/api/endpoints/explorer.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import client from '../client';
|
||||
|
||||
/* ── Types ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface CompartmentNode {
|
||||
id: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
lifecycle_state?: string;
|
||||
}
|
||||
|
||||
export interface Region {
|
||||
name: string;
|
||||
key: string;
|
||||
status: string;
|
||||
is_home: boolean;
|
||||
}
|
||||
|
||||
export interface CountEntry {
|
||||
count: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type CountsMap = Record<string, CountEntry>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type ResourceItem = Record<string, any>;
|
||||
|
||||
export interface ActionResult {
|
||||
ok: boolean;
|
||||
action: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MyIpResult {
|
||||
ip: string;
|
||||
}
|
||||
|
||||
/* ── API ───────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const explorerApi = {
|
||||
/* Compartment tree (flat list with parent_id) */
|
||||
getCompartmentTree: (cid: string) =>
|
||||
client.get(`/oci/explore/${cid}/compartment-tree`) as unknown as Promise<CompartmentNode[]>,
|
||||
|
||||
/* Regions subscribed */
|
||||
getRegions: (cid: string) =>
|
||||
client.get(`/oci/explore/${cid}/regions`) as unknown as Promise<Region[]>,
|
||||
|
||||
/* Cached counts */
|
||||
getCounts: (cid: string, compartmentId: string, regions: string = '') =>
|
||||
client.get(`/oci/explore/${cid}/counts`, {
|
||||
params: { compartment_id: compartmentId, regions },
|
||||
}) as unknown as Promise<CountsMap>,
|
||||
|
||||
/* Trigger background counts refresh */
|
||||
refreshCounts: (cid: string, compartmentId: string, regions: string[]) =>
|
||||
client.post(`/oci/explore/${cid}/counts/refresh`, {
|
||||
compartment_id: compartmentId,
|
||||
regions,
|
||||
}) as unknown as Promise<{ status: string; types: number }>,
|
||||
|
||||
/* Fetch resources of a given type */
|
||||
getResources: (cid: string, resourceType: string, compartmentId: string, region?: string) =>
|
||||
client.get(`/oci/explore/${cid}/${resourceType}`, {
|
||||
params: { compartment_id: compartmentId, region: region || undefined },
|
||||
}) as unknown as Promise<ResourceItem[]>,
|
||||
|
||||
/* ── Actions ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
instanceAction: (id: string, action: 'START' | 'STOP', ociConfigId: string, region?: string) =>
|
||||
client.post(`/oci/instances/${id}/action`, {
|
||||
action, oci_config_id: ociConfigId, region,
|
||||
}) as unknown as Promise<ActionResult>,
|
||||
|
||||
adbAction: (id: string, action: 'start' | 'stop', ociConfigId: string, region?: string) =>
|
||||
client.post(`/oci/autonomous-databases/${id}/action`, {
|
||||
action, oci_config_id: ociConfigId, region,
|
||||
}) as unknown as Promise<ActionResult>,
|
||||
|
||||
dbSystemAction: (id: string, action: 'START' | 'STOP', ociConfigId: string, region?: string) =>
|
||||
client.post(`/oci/db-systems/${id}/action`, {
|
||||
action, oci_config_id: ociConfigId, region,
|
||||
}) as unknown as Promise<ActionResult>,
|
||||
|
||||
mysqlAction: (id: string, action: 'start' | 'stop', ociConfigId: string, region?: string) =>
|
||||
client.post(`/oci/mysql-db-systems/${id}/action`, {
|
||||
action, oci_config_id: ociConfigId, region,
|
||||
}) as unknown as Promise<ActionResult>,
|
||||
|
||||
containerAction: (id: string, action: 'start' | 'stop', ociConfigId: string, region?: string) =>
|
||||
client.post(`/oci/container-instances/${id}/action`, {
|
||||
action, oci_config_id: ociConfigId, region,
|
||||
}) as unknown as Promise<ActionResult>,
|
||||
|
||||
getMyIp: () =>
|
||||
client.get('/oci/my-ip') as unknown as Promise<MyIpResult>,
|
||||
|
||||
adbUpdateNetwork: (id: string, ip: string, ociConfigId: string, region?: string) =>
|
||||
client.post(`/oci/autonomous-databases/${id}/update-network-access`, {
|
||||
ip, oci_config_id: ociConfigId, region,
|
||||
}) as unknown as Promise<ActionResult>,
|
||||
|
||||
/* ── Security List Rules ─────────────────────────────────────────────────── */
|
||||
|
||||
addSlRule: (cid: string, slId: string, body: {
|
||||
direction: string; protocol: string; source_dest: string;
|
||||
port_min?: number; port_max?: number; description?: string;
|
||||
is_stateless?: boolean; region?: string;
|
||||
}) =>
|
||||
client.post(`/oci/explore/${cid}/security_lists/${slId}/rules`, body) as unknown as Promise<ActionResult>,
|
||||
|
||||
removeSlRule: (cid: string, slId: string, body: {
|
||||
direction: string; rule_index: number; region?: string;
|
||||
}) =>
|
||||
client.delete(`/oci/explore/${cid}/security_lists/${slId}/rules`, { data: body }) as unknown as Promise<ActionResult>,
|
||||
|
||||
/* ── NSG Rules ───────────────────────────────────────────────────────────── */
|
||||
|
||||
addNsgRule: (cid: string, nsgId: string, body: {
|
||||
direction: string; protocol: string; source_dest: string;
|
||||
source_dest_type?: string; port_min?: number; port_max?: number;
|
||||
description?: string; is_stateless?: boolean; region?: string;
|
||||
}) =>
|
||||
client.post(`/oci/explore/${cid}/nsgs/${nsgId}/rules`, body) as unknown as Promise<ActionResult>,
|
||||
|
||||
removeNsgRule: (cid: string, nsgId: string, ruleId: string, region?: string) =>
|
||||
client.delete(`/oci/explore/${cid}/nsgs/${nsgId}/rules/${ruleId}${region ? `?region=${region}` : ''}`) as unknown as Promise<ActionResult>,
|
||||
};
|
||||
66
frontend-react/src/api/endpoints/genai.ts
Normal file
66
frontend-react/src/api/endpoints/genai.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import client from '../client';
|
||||
|
||||
export interface GenAiConfigFull {
|
||||
id: string;
|
||||
name: string;
|
||||
oci_config_id: string;
|
||||
model_id: string;
|
||||
model_ocid?: string;
|
||||
compartment_id: string;
|
||||
genai_region: string;
|
||||
endpoint?: string;
|
||||
serving_type: string;
|
||||
dedicated_endpoint_id?: string;
|
||||
temperature: number;
|
||||
max_tokens: number;
|
||||
top_p: number;
|
||||
top_k: number;
|
||||
frequency_penalty: number;
|
||||
presence_penalty: number;
|
||||
reasoning_effort?: string;
|
||||
is_default: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface GenAiConfigPayload {
|
||||
name: string;
|
||||
oci_config_id: string;
|
||||
model_id: string;
|
||||
model_ocid?: string | null;
|
||||
compartment_id: string;
|
||||
genai_region: string;
|
||||
endpoint?: string | null;
|
||||
serving_type?: string;
|
||||
dedicated_endpoint_id?: string | null;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
top_p?: number;
|
||||
top_k?: number;
|
||||
frequency_penalty?: number;
|
||||
presence_penalty?: number;
|
||||
reasoning_effort?: string | null;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface GenAiTestResult {
|
||||
status: 'success' | 'error';
|
||||
message: string;
|
||||
response?: string;
|
||||
}
|
||||
|
||||
export const genaiApi = {
|
||||
list: () =>
|
||||
client.get('/genai/configs') as unknown as Promise<GenAiConfigFull[]>,
|
||||
|
||||
create: (body: GenAiConfigPayload) =>
|
||||
client.post('/genai/config', body) as unknown as Promise<{ id: string; model_id: string; endpoint: string }>,
|
||||
|
||||
update: (id: string, body: GenAiConfigPayload) =>
|
||||
client.put(`/genai/configs/${id}`, body) as unknown as Promise<{ id: string; model_id: string; endpoint: string }>,
|
||||
|
||||
remove: (id: string) =>
|
||||
client.delete(`/genai/configs/${id}`) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
test: (id: string) =>
|
||||
client.post(`/genai/test/${id}`) as unknown as Promise<GenAiTestResult>,
|
||||
};
|
||||
68
frontend-react/src/api/endpoints/mcp.ts
Normal file
68
frontend-react/src/api/endpoints/mcp.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import client from '../client';
|
||||
|
||||
export interface McpServerFull {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
server_type: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env_vars?: Record<string, string>;
|
||||
url?: string;
|
||||
module_path?: string;
|
||||
is_active: boolean;
|
||||
tools?: McpToolDef[];
|
||||
linked_adb_id?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface McpToolDef {
|
||||
name: string;
|
||||
description?: string;
|
||||
input_schema?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface McpServerPayload {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
server_type: string;
|
||||
command?: string | null;
|
||||
args?: string[] | null;
|
||||
env_vars?: Record<string, string> | null;
|
||||
url?: string | null;
|
||||
module_path?: string | null;
|
||||
tools?: McpToolDef[] | null;
|
||||
linked_adb_id?: string | null;
|
||||
}
|
||||
|
||||
export const mcpApi = {
|
||||
list: () =>
|
||||
client.get('/mcp/servers') as unknown as Promise<McpServerFull[]>,
|
||||
|
||||
create: (body: McpServerPayload) =>
|
||||
client.post('/mcp/servers', body) as unknown as Promise<{ id: string; name: string; server_type: string }>,
|
||||
|
||||
update: (id: string, body: McpServerPayload) =>
|
||||
client.put(`/mcp/servers/${id}`, body) as unknown as Promise<{ id: string; name: string; server_type: string }>,
|
||||
|
||||
remove: (id: string) =>
|
||||
client.delete(`/mcp/servers/${id}`) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
toggle: (id: string) =>
|
||||
client.put(`/mcp/servers/${id}/toggle`) as unknown as Promise<{ ok: boolean; is_active: boolean }>,
|
||||
|
||||
discoverTools: (id: string) =>
|
||||
client.post(`/mcp/servers/${id}/discover-tools`) as unknown as Promise<{ ok: boolean; discovered: number; total: number; tools: McpToolDef[] }>,
|
||||
|
||||
updateTools: (id: string, tools: McpToolDef[]) =>
|
||||
client.put(`/mcp/servers/${id}/tools`, { tools }) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
upload: (id: string, file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return client.post(`/mcp/servers/${id}/upload`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }) as unknown as Promise<{ ok: boolean; path: string }>;
|
||||
},
|
||||
|
||||
linkAdb: (id: string, adbId: string) =>
|
||||
client.put(`/mcp/servers/${id}/link-adb?adb_id=${adbId}`) as unknown as Promise<{ ok: boolean }>,
|
||||
};
|
||||
39
frontend-react/src/api/endpoints/oci.ts
Normal file
39
frontend-react/src/api/endpoints/oci.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import client from '../client';
|
||||
|
||||
export interface OciConfigFull {
|
||||
id: string;
|
||||
user_id: string;
|
||||
tenancy_name: string;
|
||||
tenancy_ocid: string;
|
||||
user_ocid: string;
|
||||
fingerprint: string;
|
||||
region: string;
|
||||
compartment_id?: string;
|
||||
ssh_public_key?: string;
|
||||
ssh_public_key_preview?: string;
|
||||
auth_type: 'api_key' | 'session_token';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface OciTestResult {
|
||||
status: 'success' | 'error';
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export const ociApi = {
|
||||
list: () =>
|
||||
client.get('/oci/configs') as unknown as Promise<OciConfigFull[]>,
|
||||
|
||||
create: (fd: FormData) =>
|
||||
client.post('/oci/config', fd, { headers: { 'Content-Type': 'multipart/form-data' } }) as unknown as Promise<{ id: string; tenancy_name: string; region: string }>,
|
||||
|
||||
update: (id: string, fd: FormData) =>
|
||||
client.put(`/oci/configs/${id}`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }) as unknown as Promise<{ id: string; tenancy_name: string; region: string }>,
|
||||
|
||||
remove: (id: string) =>
|
||||
client.delete(`/oci/configs/${id}`) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
test: (id: string) =>
|
||||
client.post(`/oci/test/${id}`) as unknown as Promise<OciTestResult>,
|
||||
};
|
||||
142
frontend-react/src/api/endpoints/reports.ts
Normal file
142
frontend-react/src/api/endpoints/reports.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import client from '../client';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export interface Report {
|
||||
id: string;
|
||||
tenancy_name: string;
|
||||
status: string;
|
||||
level: number | null;
|
||||
regions?: string[];
|
||||
obp?: boolean;
|
||||
raw?: boolean;
|
||||
redact_output?: boolean;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
error_msg?: string | null;
|
||||
}
|
||||
|
||||
export interface RunReportBody {
|
||||
config_id: string;
|
||||
regions?: string[];
|
||||
level: 1 | 2;
|
||||
obp: boolean;
|
||||
raw: boolean;
|
||||
redact_output: boolean;
|
||||
}
|
||||
|
||||
export interface RunReportResult {
|
||||
report_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ReportProgress {
|
||||
status: string;
|
||||
progress: string[];
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
error_msg: string | null;
|
||||
}
|
||||
|
||||
export interface ReportSummary {
|
||||
tenancy: string;
|
||||
level: number;
|
||||
regions: string[];
|
||||
generated_at: string;
|
||||
total: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
total_findings: number;
|
||||
total_compliant: number;
|
||||
score: number;
|
||||
sections: Record<string, { passed: number; failed: number; total: number }>;
|
||||
}
|
||||
|
||||
export interface ReportFile {
|
||||
id: string;
|
||||
file_name: string;
|
||||
file_type: string;
|
||||
file_category: string;
|
||||
file_size: number;
|
||||
}
|
||||
|
||||
export interface CisEngineVersion {
|
||||
version: string;
|
||||
updated_date: string;
|
||||
file_path: string;
|
||||
}
|
||||
|
||||
export interface CisEngineUpdate {
|
||||
local_version: string;
|
||||
local_date: string;
|
||||
remote_version: string;
|
||||
remote_date: string;
|
||||
update_available: boolean;
|
||||
}
|
||||
|
||||
/* ── API ── */
|
||||
|
||||
export const reportsApi = {
|
||||
run: (body: RunReportBody) =>
|
||||
client.post('/reports/run', body) as unknown as Promise<RunReportResult>,
|
||||
|
||||
list: () =>
|
||||
client.get('/reports') as unknown as Promise<Report[]>,
|
||||
|
||||
progress: (rid: string) =>
|
||||
client.get(`/reports/${rid}/progress`) as unknown as Promise<ReportProgress>,
|
||||
|
||||
cancel: (rid: string) =>
|
||||
client.post(`/reports/${rid}/cancel`) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
delete: (rid: string) =>
|
||||
client.delete(`/reports/${rid}`) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
summary: (rid: string) =>
|
||||
client.get(`/reports/${rid}/summary`) as unknown as Promise<ReportSummary>,
|
||||
|
||||
htmlUrl: (rid: string) => {
|
||||
const token = localStorage.getItem('t') || '';
|
||||
return `/api/reports/${rid}/html?token=${encodeURIComponent(token)}`;
|
||||
},
|
||||
|
||||
complianceReportUrl: (rid: string) => {
|
||||
const token = localStorage.getItem('t') || '';
|
||||
return `/api/reports/${rid}/compliance-report?token=${encodeURIComponent(token)}`;
|
||||
},
|
||||
|
||||
generateComplianceReport: (rid: string) =>
|
||||
client.post(`/reports/${rid}/compliance-report/generate`) as unknown as Promise<{ status: string; has_rag: boolean }>,
|
||||
|
||||
complianceReportStatus: (rid: string) =>
|
||||
client.get(`/reports/${rid}/compliance-report/status`) as unknown as Promise<{ ready: boolean }>,
|
||||
|
||||
files: (rid: string) =>
|
||||
client.get(`/reports/${rid}/files`) as unknown as Promise<ReportFile[]>,
|
||||
|
||||
downloadUrl: (rid: string, fid: string) => {
|
||||
const token = localStorage.getItem('t') || '';
|
||||
return `/api/reports/${rid}/files/${fid}/download?token=${encodeURIComponent(token)}`;
|
||||
},
|
||||
|
||||
engineVersion: () =>
|
||||
client.get('/cis-engine/version') as unknown as Promise<CisEngineVersion>,
|
||||
|
||||
engineCheckUpdate: () =>
|
||||
client.get('/cis-engine/check-update') as unknown as Promise<CisEngineUpdate>,
|
||||
|
||||
engineUpdate: () =>
|
||||
client.post('/cis-engine/update') as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
embedReport: (rid: string, adbConfigId: string, tableName?: string) =>
|
||||
client.post(`/embeddings/report/${rid}`, {
|
||||
adb_config_id: adbConfigId,
|
||||
...(tableName ? { table_name: tableName } : {}),
|
||||
}) as unknown as Promise<{ ok: boolean; message: string; sections: number; tenancy: string }>,
|
||||
|
||||
embedFile: (rid: string, fid: string, adbConfigId: string, tableName?: string) =>
|
||||
client.post(`/embeddings/report/${rid}/file/${fid}`, {
|
||||
adb_config_id: adbConfigId,
|
||||
...(tableName ? { table_name: tableName } : {}),
|
||||
}) as unknown as Promise<{ ok: boolean; message: string; chunks: number }>,
|
||||
};
|
||||
136
frontend-react/src/api/endpoints/terraform.ts
Normal file
136
frontend-react/src/api/endpoints/terraform.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import client from '../client';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export type WsStatus =
|
||||
| 'draft' | 'planning' | 'planned'
|
||||
| 'applying' | 'applied'
|
||||
| 'destroying' | 'destroyed'
|
||||
| 'failed';
|
||||
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
session_id: string;
|
||||
session_title?: string;
|
||||
oci_config_id: string;
|
||||
compartment_id?: string;
|
||||
status: WsStatus;
|
||||
plan_output?: string;
|
||||
apply_output?: string;
|
||||
destroy_output?: string;
|
||||
error?: string;
|
||||
tf_code?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WsStatusResult {
|
||||
status: WsStatus;
|
||||
plan_output?: string;
|
||||
apply_output?: string;
|
||||
destroy_output?: string;
|
||||
error?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
agent_type: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface PromptResponse {
|
||||
message_id: string;
|
||||
session_id: string;
|
||||
status: 'processing' | 'done';
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
export interface MessageStatus {
|
||||
status: 'processing' | 'done' | 'failed';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/* ── Workspace API ── */
|
||||
|
||||
export const terraformApi = {
|
||||
/* Workspaces */
|
||||
listWorkspaces: () =>
|
||||
client.get('/terraform/workspaces') as unknown as Promise<Workspace[]>,
|
||||
|
||||
getWorkspace: (wid: string) =>
|
||||
client.get(`/terraform/workspaces/${wid}`) as unknown as Promise<Workspace>,
|
||||
|
||||
deleteWorkspace: (wid: string) =>
|
||||
client.delete(`/terraform/workspaces/${wid}`) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
plan: (wid: string) =>
|
||||
client.post(`/terraform/workspaces/${wid}/plan`) as unknown as Promise<{ status: string }>,
|
||||
|
||||
apply: (wid: string) =>
|
||||
client.post(`/terraform/workspaces/${wid}/apply`) as unknown as Promise<{ status: string }>,
|
||||
|
||||
destroy: (wid: string) =>
|
||||
client.post(`/terraform/workspaces/${wid}/destroy`, { confirm: 'DESTROY' }) as unknown as Promise<{ status: string }>,
|
||||
|
||||
cancel: (wid: string) =>
|
||||
client.post(`/terraform/workspaces/${wid}/cancel`) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
status: (wid: string) =>
|
||||
client.get(`/terraform/workspaces/${wid}/status`) as unknown as Promise<WsStatusResult>,
|
||||
|
||||
downloadUrl: (wid: string) => {
|
||||
const token = localStorage.getItem('t') || '';
|
||||
return `/api/terraform/workspaces/${wid}/download?token=${encodeURIComponent(token)}`;
|
||||
},
|
||||
|
||||
/* Prompt Generator */
|
||||
generatePrompt: (body: {
|
||||
message: string;
|
||||
session_id?: string;
|
||||
genai_config: Record<string, string | undefined>;
|
||||
history?: { role: string; content: string }[] | null;
|
||||
}) =>
|
||||
client.post('/terraform/generate-prompt', body) as unknown as Promise<PromptResponse>,
|
||||
|
||||
/* Chat sessions / history */
|
||||
listSessions: (agentType: string, limit = 50) =>
|
||||
client.get(`/chat/sessions?agent_type=${agentType}&limit=${limit}`) as unknown as Promise<ChatSession[]>,
|
||||
|
||||
loadSession: (sid: string) =>
|
||||
client.get(`/chat/sessions/${sid}/messages`) as unknown as Promise<{ messages: ChatMessage[] }>,
|
||||
|
||||
deleteSession: (sid: string) =>
|
||||
client.delete(`/chat/sessions/${sid}`) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
pollMessageStatus: (mid: string) =>
|
||||
client.get(`/chat/${mid}/status`) as unknown as Promise<MessageStatus>,
|
||||
|
||||
createWorkspace: (body: { session_id?: string; oci_config_id: string; compartment_id?: string; name: string; tf_code: string }) =>
|
||||
client.post('/terraform/workspaces', body) as unknown as Promise<Workspace>,
|
||||
|
||||
updateCode: (wid: string, tf_code: string) =>
|
||||
client.put(`/terraform/workspaces/${wid}/code`, { tf_code }) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
chat: (body: { message: string; session_id?: string; genai_config_id?: string; model_id?: string; oci_config_id?: string; genai_region?: string; compartment_id?: string; temperature?: number; max_tokens?: number; use_tools?: boolean }) =>
|
||||
client.post('/terraform/chat', body) as unknown as Promise<{ message_id: string; session_id: string; status: string }>,
|
||||
|
||||
loadResources: (ociConfigId: string, compartmentId: string, region?: string) => {
|
||||
const params = new URLSearchParams({ oci_config_id: ociConfigId, compartment_id: compartmentId });
|
||||
if (region) params.set('region', region);
|
||||
return client.get(`/terraform/resources?${params}`) as unknown as Promise<Record<string, any[]>>;
|
||||
},
|
||||
|
||||
getCompartmentTree: (cid: string) =>
|
||||
client.get(`/oci/explore/${cid}/compartment-tree`) as unknown as Promise<any[]>,
|
||||
};
|
||||
27
frontend-react/src/components/layout/AppShell.tsx
Normal file
27
frontend-react/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Outlet, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
export default function AppShell() {
|
||||
const { user, loading } = useAuthStore();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen" style={{ background: 'var(--bg)' }}>
|
||||
<div className="animate-spin w-8 h-8 rounded-full border-2 border-t-transparent"
|
||||
style={{ borderColor: 'var(--ac)', borderTopColor: 'transparent' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-[260px] overflow-y-auto h-screen" style={{ background: 'var(--bg)' }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
frontend-react/src/components/layout/Sidebar.tsx
Normal file
186
frontend-react/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
MessageSquare, Search, BarChart3, Cloud,
|
||||
Brain, Plug, Database, Dna, Users, Lock, FileText,
|
||||
Sparkles, Sun, Moon, LogOut, FolderOpen, Languages
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
const LOGO = (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width={30} height={30} style={{ flexShrink: 0 }}>
|
||||
<rect x="2" y="5" width="32" height="26" rx="13" ry="13" fill="rgba(255,255,255,0.12)" stroke="#fff" strokeWidth="2" />
|
||||
<rect x="11" y="11" width="14" height="14" rx="4" fill="#fff" opacity="0.95" />
|
||||
<circle cx="15" cy="17" r="2" fill="#C74634" />
|
||||
<circle cx="21" cy="17" r="2" fill="#C74634" />
|
||||
<circle cx="15" cy="16.7" r="0.7" fill="#fff" />
|
||||
<circle cx="21" cy="16.7" r="0.7" fill="#fff" />
|
||||
<rect x="14" y="21" width="8" height="1.5" rx="0.75" fill="#C74634" opacity="0.6" />
|
||||
<line x1="18" y1="11" x2="18" y2="6" stroke="#fff" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<circle cx="18" cy="5.5" r="1.5" fill="#fff" opacity="0.9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SZ = 18;
|
||||
|
||||
const TerraformIcon = ({ size = 18 }: { size?: number }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 64 64" fill="#7B42BC" style={{ flexShrink: 0 }}>
|
||||
<polygon points="22.4,0 41.6,11.1 41.6,33.2 22.4,22.1" />
|
||||
<polygon points="44,12.3 63.2,23.4 63.2,45.4 44,34.4" />
|
||||
<polygon points="0.8,12.3 20,23.4 20,45.4 0.8,34.4" />
|
||||
<polygon points="22.4,35.5 41.6,46.6 41.6,68.7 22.4,57.6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
icon: React.ReactNode;
|
||||
labelKey: string;
|
||||
adminOnly?: boolean;
|
||||
sub?: boolean;
|
||||
}
|
||||
|
||||
const NAV_SECTIONS: { titleKey: string; items: NavItem[] }[] = [
|
||||
{
|
||||
titleKey: 'nav.principal',
|
||||
items: [
|
||||
{ to: '/chat', icon: <MessageSquare size={SZ} />, labelKey: 'nav.chat' },
|
||||
{ to: '/terraform', icon: <TerraformIcon size={SZ + 2} />, labelKey: 'nav.terraform' },
|
||||
{ to: '/terraform/prompt', icon: <Sparkles size={SZ} />, labelKey: 'nav.promptGen', sub: true },
|
||||
{ to: '/terraform/workspaces', icon: <FolderOpen size={SZ} />, labelKey: 'nav.workspaces', sub: true },
|
||||
{ to: '/explorer', icon: <Search size={SZ} />, labelKey: 'nav.explorer' },
|
||||
{ to: '/reports', icon: <BarChart3 size={SZ} />, labelKey: 'nav.reports' },
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: 'nav.config',
|
||||
items: [
|
||||
{ to: '/config/oci', icon: <Cloud size={SZ} />, labelKey: 'nav.ociCreds' },
|
||||
{ to: '/config/genai', icon: <Brain size={SZ} />, labelKey: 'nav.genai' },
|
||||
{ to: '/config/mcp', icon: <Plug size={SZ} />, labelKey: 'nav.mcp' },
|
||||
{ to: '/config/adb', icon: <Database size={SZ} />, labelKey: 'nav.adb' },
|
||||
{ to: '/config/embeddings', icon: <Dna size={SZ} />, labelKey: 'nav.embeddings' },
|
||||
{ to: '/config/embeddings/consult', icon: <MessageSquare size={SZ} />, labelKey: 'nav.embConsult', sub: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: 'nav.admin',
|
||||
items: [
|
||||
{ to: '/admin/users', icon: <Users size={SZ} />, labelKey: 'nav.users', adminOnly: true },
|
||||
{ to: '/admin/mfa', icon: <Lock size={SZ} />, labelKey: 'nav.mfa' },
|
||||
{ to: '/admin/audit', icon: <FileText size={SZ} />, labelKey: 'nav.audit', adminOnly: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const { toggle, isDark } = useTheme();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { t, lang, setLang } = useI18n();
|
||||
|
||||
return (
|
||||
<aside className="fixed top-0 left-0 bottom-0 w-[260px] flex flex-col z-50"
|
||||
style={{ background: 'var(--bg1)', borderRight: '1px solid var(--bd)' }}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-5 py-5 relative overflow-hidden"
|
||||
style={{ background: 'linear-gradient(145deg, #c74634 0%, #a83428 50%, #8b2a20 100%)' }}>
|
||||
<div className="absolute top-[-30%] right-[-20%] w-[120px] h-[120px] rounded-full"
|
||||
style={{ background: 'radial-gradient(circle, rgba(255,255,255,.08) 0%, transparent 70%)' }} />
|
||||
<div className="flex items-center gap-3 relative z-10">
|
||||
{LOGO}
|
||||
<div>
|
||||
<h1 className="text-sm font-bold text-white tracking-tight">AI Agent</h1>
|
||||
<p className="text-[.65rem] text-white/60 mt-0.5" style={{ fontFamily: 'var(--fm)' }}>
|
||||
{t('sidebar.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto px-2.5 py-3">
|
||||
{NAV_SECTIONS.map((section) => (
|
||||
<div key={section.titleKey} className="mb-1">
|
||||
<div className="text-[.6rem] uppercase tracking-[.14em] font-bold px-3 pt-4 pb-2"
|
||||
style={{ color: 'var(--t4)' }}>
|
||||
{t(section.titleKey)}
|
||||
</div>
|
||||
{section.items
|
||||
.filter((item) => !item.adminOnly || user?.role === 'admin')
|
||||
.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/terraform' || item.to === '/config/embeddings'}
|
||||
className={`flex items-center gap-3 rounded-xl font-medium transition-all duration-200 ${
|
||||
item.sub
|
||||
? 'pl-9 pr-3 py-2 text-[.78rem] opacity-90'
|
||||
: 'px-3 py-2.5 text-[.82rem]'
|
||||
}`}
|
||||
style={({ isActive }) => isActive ? {
|
||||
background: 'var(--acl2)',
|
||||
color: 'var(--ac)',
|
||||
boxShadow: 'inset 3px 0 0 var(--ac)',
|
||||
} : {
|
||||
color: 'var(--t2)',
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
{t(item.labelKey)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-3 py-3.5 flex items-center gap-2" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="p-2.5 rounded-xl transition-all duration-200"
|
||||
style={{ color: 'var(--t3)' }}
|
||||
title={isDark ? t('sidebar.lightMode') : t('sidebar.darkMode')}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg3)'; e.currentTarget.style.color = 'var(--t1)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = ''; e.currentTarget.style.color = 'var(--t3)'; }}
|
||||
>
|
||||
{isDark ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLang(lang === 'pt' ? 'en' : 'pt')}
|
||||
className="p-2.5 rounded-xl transition-all duration-200 flex items-center gap-1"
|
||||
style={{ color: 'var(--t3)' }}
|
||||
title={lang === 'pt' ? 'English' : 'Português'}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg3)'; e.currentTarget.style.color = 'var(--t1)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = ''; e.currentTarget.style.color = 'var(--t3)'; }}
|
||||
>
|
||||
<Languages size={18} />
|
||||
<span className="text-[.65rem] font-bold uppercase">{lang}</span>
|
||||
</button>
|
||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||
<div className="w-7 h-7 rounded-lg flex items-center justify-center text-[.7rem] font-bold text-white flex-shrink-0"
|
||||
style={{ background: 'var(--ac)' }}>
|
||||
{user?.username?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[.75rem] font-medium truncate" style={{ color: 'var(--t1)' }}>{user?.username}</p>
|
||||
<p className="text-[.6rem] truncate" style={{ color: 'var(--t4)' }}>
|
||||
{user?.role === 'admin' ? t('sidebar.roleAdmin') : t('sidebar.roleUser')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="p-2.5 rounded-xl transition-all duration-200"
|
||||
style={{ color: 'var(--t3)' }}
|
||||
title={t('sidebar.logout')}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--rdl)'; e.currentTarget.style.color = 'var(--rd)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = ''; e.currentTarget.style.color = 'var(--t3)'; }}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
68
frontend-react/src/components/shared/StubPage.tsx
Normal file
68
frontend-react/src/components/shared/StubPage.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { ArrowRight, Wrench } from 'lucide-react';
|
||||
|
||||
interface StubPageProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
legacyTab: string;
|
||||
color?: string;
|
||||
features?: string[];
|
||||
}
|
||||
|
||||
export default function StubPage({ icon, title, description, legacyTab, color = 'var(--ac)', features }: StubPageProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full p-8">
|
||||
<div className="text-center max-w-md w-full animate-[fadeIn_.4s_ease]">
|
||||
{/* Icon with glow */}
|
||||
<div className="relative inline-flex items-center justify-center mb-6">
|
||||
<div className="absolute inset-0 rounded-full blur-2xl opacity-20"
|
||||
style={{ background: color, transform: 'scale(2)' }} />
|
||||
<div className="relative w-20 h-20 rounded-2xl flex items-center justify-center"
|
||||
style={{ background: `color-mix(in srgb, ${color} 10%, transparent)`, border: `1px solid color-mix(in srgb, ${color} 20%, transparent)` }}>
|
||||
<div style={{ color }}>{icon}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-xl font-bold mb-2" style={{ color: 'var(--t1)' }}>{title}</h2>
|
||||
<p className="text-sm mb-6" style={{ color: 'var(--t3)' }}>{description}</p>
|
||||
|
||||
{/* Features preview */}
|
||||
{features && (
|
||||
<div className="mb-6 text-left rounded-xl p-4 mx-auto max-w-xs"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Wrench size={14} style={{ color: 'var(--t4)' }} />
|
||||
<span className="text-[.7rem] font-semibold uppercase tracking-wider" style={{ color: 'var(--t4)' }}>
|
||||
Em desenvolvimento
|
||||
</span>
|
||||
</div>
|
||||
{features.map((f, i) => (
|
||||
<div key={i} className="flex items-center gap-2 py-1.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: color, opacity: 0.6 }} />
|
||||
<span className="text-xs" style={{ color: 'var(--t2)' }}>{f}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<a
|
||||
href={`/?tab=${legacyTab}`}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold transition-all duration-200"
|
||||
style={{ background: color, color: '#fff' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = 'var(--sh2)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = ''; }}
|
||||
>
|
||||
Abrir versao atual
|
||||
<ArrowRight size={16} />
|
||||
</a>
|
||||
|
||||
<p className="text-[.65rem] mt-4" style={{ color: 'var(--t4)' }}>
|
||||
Migrando para React — em breve nesta interface
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
frontend-react/src/hooks/usePolling.ts
Normal file
25
frontend-react/src/hooks/usePolling.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export function usePolling(
|
||||
callback: () => Promise<boolean | void>,
|
||||
intervalMs: number,
|
||||
enabled: boolean
|
||||
) {
|
||||
const savedCallback = useRef(callback);
|
||||
savedCallback.current = callback;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
let active = true;
|
||||
const poll = async () => {
|
||||
if (!active) return;
|
||||
const shouldStop = await savedCallback.current();
|
||||
if (shouldStop || !active) return;
|
||||
setTimeout(poll, intervalMs);
|
||||
};
|
||||
poll();
|
||||
|
||||
return () => { active = false; };
|
||||
}, [intervalMs, enabled]);
|
||||
}
|
||||
20
frontend-react/src/hooks/useTheme.ts
Normal file
20
frontend-react/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
return (localStorage.getItem('theme') as Theme) || 'light';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.className = theme;
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
|
||||
}, []);
|
||||
|
||||
return { theme, toggle, isDark: theme === 'dark' };
|
||||
}
|
||||
666
frontend-react/src/i18n/en.ts
Normal file
666
frontend-react/src/i18n/en.ts
Normal file
@@ -0,0 +1,666 @@
|
||||
const en: Record<string, string> = {
|
||||
// ── Sidebar ──
|
||||
'nav.principal': 'Main',
|
||||
'nav.config': 'Configuration',
|
||||
'nav.admin': 'Administration',
|
||||
'nav.chat': 'Chat Agent',
|
||||
'nav.terraform': 'Terraform',
|
||||
'nav.promptGen': 'Prompt Generator',
|
||||
'nav.workspaces': 'Workspaces',
|
||||
'nav.explorer': 'OCI Explorer',
|
||||
'nav.reports': 'Reports',
|
||||
'nav.ociCreds': 'OCI Credentials',
|
||||
'nav.genai': 'GenAI Config',
|
||||
'nav.mcp': 'MCP Servers',
|
||||
'nav.adb': 'ADB Vector',
|
||||
'nav.embeddings': 'Embeddings',
|
||||
'nav.embConsult': 'Query Embeddings',
|
||||
'nav.users': 'Users',
|
||||
'nav.mfa': 'MFA',
|
||||
'nav.audit': 'Audit Log',
|
||||
'sidebar.lightMode': 'Light mode',
|
||||
'sidebar.darkMode': 'Dark mode',
|
||||
'sidebar.logout': 'Logout',
|
||||
'sidebar.roleAdmin': 'Administrator',
|
||||
'sidebar.roleUser': 'User',
|
||||
'sidebar.subtitle': 'Infrastructure & Security',
|
||||
|
||||
// ── Login ──
|
||||
'login.subtitle': 'Infrastructure & Security Engineer',
|
||||
'login.userPlaceholder': 'Username',
|
||||
'login.passPlaceholder': 'Password',
|
||||
'login.mfaScanQr': 'Scan the QR code with your authenticator app:',
|
||||
'login.totpPlaceholder': 'TOTP Code',
|
||||
'login.loading': 'Signing in...',
|
||||
'login.verify': 'Verify',
|
||||
'login.submit': 'Sign in',
|
||||
'login.error': 'Login failed',
|
||||
|
||||
// ── Chat ──
|
||||
'chat.timeNow': 'now',
|
||||
'chat.today': 'Today',
|
||||
'chat.yesterday': 'Yesterday',
|
||||
'chat.last7': 'Last 7 days',
|
||||
'chat.last30': 'Last 30 days',
|
||||
'chat.older': 'Older',
|
||||
'chat.other': 'Other',
|
||||
'chat.history': 'History',
|
||||
'chat.newChat': 'New Chat',
|
||||
'chat.untitled': 'Untitled',
|
||||
'chat.noConversations': 'No conversations yet',
|
||||
'chat.selectModel': 'Select a model...',
|
||||
'chat.savedConfigs': 'Saved Configs',
|
||||
'chat.searchModel': 'Search model...',
|
||||
'chat.noModels': 'No models found',
|
||||
'chat.ociCred': 'OCI Credential...',
|
||||
'chat.settings': 'Settings',
|
||||
'chat.newChatTitle': 'New chat',
|
||||
'chat.emptyTitle': 'Start a conversation with the agent.',
|
||||
'chat.emptySubtitle': 'Select a model to get started.',
|
||||
'chat.thinking': 'Thinking...',
|
||||
'chat.retry': 'Retry',
|
||||
'chat.attachFile': 'Attach file',
|
||||
'chat.placeholder': 'Type your message... (Shift+Enter for new line)',
|
||||
'chat.send': 'Send',
|
||||
'chat.selectOciCred': 'Select an OCI credential to use direct models.',
|
||||
'chat.sendError': 'Failed to send message',
|
||||
'chat.requestFailed': 'The request failed.',
|
||||
'chat.timeout': 'Timeout: the response is taking too long. Try again.',
|
||||
'chat.toolsAvailable': 'tools available',
|
||||
'chat.ociCredLabel': 'OCI Credential',
|
||||
'chat.select': 'Select...',
|
||||
'chat.analyzeFiles': 'Analyze the attached files.',
|
||||
|
||||
// ── Terraform ──
|
||||
'tf.history': 'History',
|
||||
'tf.newChat': 'New',
|
||||
'tf.noConversations': 'No conversations',
|
||||
'tf.searchModel': 'Search model...',
|
||||
'tf.selectModel': 'Select model...',
|
||||
'tf.savedConfigs': 'Saved Configs',
|
||||
'tf.ociConfig': 'OCI Config...',
|
||||
'tf.settings': 'Settings',
|
||||
'tf.newConversation': 'New conversation',
|
||||
'tf.emptyTitle': 'Describe the OCI infrastructure',
|
||||
'tf.emptySubtitle': 'The agent will generate Terraform code based on your description.',
|
||||
'tf.thinking': 'Processing...',
|
||||
'tf.retry': 'Retry',
|
||||
'tf.placeholder': 'Describe the desired infrastructure... (Shift+Enter for new line)',
|
||||
'tf.send': 'Send',
|
||||
'tf.workspace': 'Workspace',
|
||||
'tf.files': 'Files',
|
||||
'tf.terminal': 'Terminal',
|
||||
'tf.resources': 'Resources',
|
||||
'tf.plan': 'Plan',
|
||||
'tf.apply': 'Apply',
|
||||
'tf.destroy': 'Destroy',
|
||||
'tf.cancel': 'Cancel',
|
||||
'tf.download': 'Download ZIP',
|
||||
'tf.createWs': 'Create Workspace',
|
||||
'tf.noFiles': 'No files generated',
|
||||
'tf.timeout': 'Timeout: generation is taking too long.',
|
||||
'tf.selectOci': 'Select an OCI credential',
|
||||
'tf.compartment': 'Compartment',
|
||||
'tf.region': 'Region',
|
||||
'tf.rollback': 'Rollback',
|
||||
'tf.copy': 'Copy',
|
||||
'tf.copied': 'Copied',
|
||||
'tf.noWs': 'No active workspace',
|
||||
'tf.deleteWs': 'Delete',
|
||||
|
||||
// ── Prompt Generator ──
|
||||
'pg.history': 'History',
|
||||
'pg.new': 'New',
|
||||
'pg.noConversations': 'No conversations',
|
||||
'pg.emptyTitle': 'Describe the OCI infrastructure you need',
|
||||
'pg.emptySubtitle': 'The agent will generate a structured, optimized prompt for the Terraform Agent.',
|
||||
'pg.loading': 'Generating prompt...',
|
||||
'pg.placeholder': 'Describe the desired infrastructure... (Shift+Enter for new line)',
|
||||
'pg.copy': 'Copy',
|
||||
'pg.copied': 'Copied',
|
||||
'pg.retry': 'Retry',
|
||||
'pg.timeout': 'Timeout: generation is taking too long.',
|
||||
'pg.search': 'Search...',
|
||||
'pg.selectModel': 'Select model...',
|
||||
|
||||
// ── Workspaces ──
|
||||
'ws.title': 'Terraform Workspaces',
|
||||
'ws.all': 'All',
|
||||
'ws.active': 'Active',
|
||||
'ws.draft': 'Draft',
|
||||
'ws.failed': 'Failed',
|
||||
'ws.destroyed': 'Destroyed',
|
||||
'ws.loading': 'Loading...',
|
||||
'ws.refresh': 'Refresh',
|
||||
'ws.noWs': 'No workspaces found',
|
||||
'ws.noWsHint': 'Create workspaces via the Terraform Agent',
|
||||
'ws.loadingWs': 'Loading workspaces...',
|
||||
'ws.open': 'Open',
|
||||
'ws.plan': 'Plan',
|
||||
'ws.apply': 'Apply',
|
||||
'ws.destroy': 'Destroy',
|
||||
'ws.delete': 'Delete',
|
||||
'ws.cancel': 'Cancel',
|
||||
'ws.hideOutput': 'hide',
|
||||
'ws.viewOutput': 'view output',
|
||||
'ws.confirmDestroy': 'Confirm Destroy',
|
||||
'ws.confirmDestroyMsg': 'This action will destroy all resources provisioned by this workspace.',
|
||||
'ws.typeDestroy': 'Type <strong>DESTROY</strong> to confirm:',
|
||||
'ws.deleteWs': 'Delete Workspace',
|
||||
'ws.deleteWsMsg': 'Delete this workspace permanently? Local files will be removed.',
|
||||
'ws.noDate': 'No date',
|
||||
|
||||
// ── Explorer ──
|
||||
'exp.title': 'OCI Explorer',
|
||||
'exp.subtitle': 'Explore your Oracle Cloud Infrastructure resources',
|
||||
'exp.search': 'Search resource...',
|
||||
'exp.refresh': 'Refresh',
|
||||
'exp.loading': 'Loading...',
|
||||
'exp.noResources': 'No resources found',
|
||||
'exp.selectResource': 'Select a resource type',
|
||||
'exp.compartment': 'Compartment',
|
||||
'exp.region': 'Region',
|
||||
'exp.allRegions': 'All regions',
|
||||
'exp.start': 'Start',
|
||||
'exp.stop': 'Stop',
|
||||
'exp.details': 'Details',
|
||||
'exp.total': 'Total',
|
||||
'exp.network': 'Network',
|
||||
'exp.observability': 'Observability',
|
||||
'exp.security': 'Security',
|
||||
'exp.selectRegions': 'Select regions',
|
||||
'exp.nRegions': '{0} regions',
|
||||
'exp.all': 'All',
|
||||
'exp.none': 'None',
|
||||
'exp.noCompartment': 'No compartments found',
|
||||
'exp.selectConfig': 'Select an OCI config',
|
||||
'exp.loadingResources': 'Loading resources...',
|
||||
'exp.clearFilter': 'Try clearing the search filter',
|
||||
'exp.resources': 'resources',
|
||||
'exp.updating': 'updating...',
|
||||
'exp.regions': 'region(s)',
|
||||
'exp.filter': 'Filter...',
|
||||
'exp.startAction': 'Start',
|
||||
'exp.stopAction': 'Stop',
|
||||
'exp.updatingState': 'Updating...',
|
||||
'exp.createdAt': 'Created at',
|
||||
'exp.public': 'Public',
|
||||
'exp.yesPublic': 'Yes',
|
||||
'exp.noPrivate': 'No (private)',
|
||||
'exp.freeTier': 'Free Tier',
|
||||
'exp.yes': 'Yes',
|
||||
'exp.no': 'No',
|
||||
'exp.noRestrictions': 'No restrictions (open)',
|
||||
'exp.updateNetworkAccess': 'Update Network Access',
|
||||
'exp.accessUpdated': 'Access updated: ',
|
||||
'exp.objects': 'Objects',
|
||||
'exp.size': 'Size',
|
||||
'exp.visibility': 'Visibility',
|
||||
'exp.private': 'Private',
|
||||
'exp.rules': 'Rules',
|
||||
'exp.routes': 'Routes',
|
||||
'exp.hideRules': 'Hide rules',
|
||||
'exp.viewRules': 'View rules',
|
||||
'exp.add': 'Add',
|
||||
'exp.direction': 'Direction',
|
||||
'exp.protocol': 'Protocol',
|
||||
'exp.originDest': 'Source/Dest',
|
||||
'exp.ports': 'Ports',
|
||||
'exp.description': 'Description',
|
||||
'exp.portMin': 'Port Min',
|
||||
'exp.portMax': 'Port Max',
|
||||
'exp.myIp': 'My IP',
|
||||
'exp.save': 'Save',
|
||||
'exp.cancel': 'Cancel',
|
||||
'exp.ruleAdded': 'Rule added.',
|
||||
'exp.enterCidr': 'Enter the CIDR.',
|
||||
'exp.removeRule': 'Remove this rule?',
|
||||
'exp.removeNsgRule': 'Remove this NSG rule?',
|
||||
'exp.ipDetected': 'IP detected: {0} — adjust port and click Save',
|
||||
'exp.ipDetectFailed': 'Could not detect IP.',
|
||||
'exp.destination': 'Destination',
|
||||
'exp.destType': 'Dest Type',
|
||||
'exp.version': 'Version',
|
||||
'exp.ha': 'HA',
|
||||
|
||||
// ── Reports ──
|
||||
'rpt.title': 'CIS Reports',
|
||||
'rpt.subtitle': 'Run CIS OCI Benchmark compliance scans',
|
||||
'rpt.startScan': 'Start Scan',
|
||||
'rpt.running': 'Running...',
|
||||
'rpt.cancel': 'Cancel',
|
||||
'rpt.tenancy': 'Tenancy',
|
||||
'rpt.level': 'Level',
|
||||
'rpt.region': 'Region',
|
||||
'rpt.allRegions': 'All regions',
|
||||
'rpt.noReports': 'No reports found',
|
||||
'rpt.noReportsHint': 'Run a scan to generate compliance reports.',
|
||||
'rpt.progress': 'Progress',
|
||||
'rpt.completed': 'Completed',
|
||||
'rpt.failed': 'Failed',
|
||||
'rpt.viewReport': 'View Report',
|
||||
'rpt.download': 'Download',
|
||||
'rpt.delete': 'Delete',
|
||||
'rpt.summary': 'Summary',
|
||||
'rpt.passed': 'Passed',
|
||||
'rpt.findings': 'Findings',
|
||||
'rpt.recommendations': 'Recommendations',
|
||||
'rpt.selectTenancy': 'Select a tenancy',
|
||||
'rpt.selectCredential': 'Select a credential',
|
||||
'rpt.loadingReports': 'Loading reports...',
|
||||
'rpt.refresh': 'Refresh',
|
||||
'rpt.pageTitle': 'CIS Reports',
|
||||
'rpt.pageSubtitle': 'CIS Benchmark 3.0 — Compliance Scan & Dashboard',
|
||||
'rpt.runScanCis': 'Run CIS Scan',
|
||||
'rpt.inExecution': 'Running',
|
||||
'rpt.configParams': 'Configure the parameters and run the CIS compliance scan.',
|
||||
'rpt.ociCredential': 'OCI Credential',
|
||||
'rpt.selectOciCred': 'Select OCI credential...',
|
||||
'rpt.cisLevel': 'CIS Level',
|
||||
'rpt.level1Desc': 'Essential security checks',
|
||||
'rpt.level2Desc': 'Complete checks (includes Level 1)',
|
||||
'rpt.regions': 'Regions',
|
||||
'rpt.leaveEmpty': 'Leave empty to scan all subscribed regions',
|
||||
'rpt.executeCompliance': 'Run CIS Compliance',
|
||||
'rpt.scanProgress': 'Scan Progress',
|
||||
'rpt.hideLog': 'Hide log',
|
||||
'rpt.showLog': 'View full log',
|
||||
'rpt.lines': 'lines',
|
||||
'rpt.cancelScan': 'Cancel Scan',
|
||||
'rpt.selectReport': 'Select Report',
|
||||
'rpt.selectCompletedReport': 'Select a completed report...',
|
||||
'rpt.searchTenancy': 'Search tenancy...',
|
||||
'rpt.searchByTenancy': 'Search by tenancy...',
|
||||
'rpt.noCredentials': 'No credentials found',
|
||||
'rpt.allRegionsDefault': 'All regions (default)',
|
||||
'rpt.searchRegion': 'Search region...',
|
||||
'rpt.loadingDashboard': 'Loading dashboard...',
|
||||
'rpt.complianceScore': 'Compliance Score',
|
||||
'rpt.compliant': 'Compliant',
|
||||
'rpt.nonCompliant': 'Non-Compliant',
|
||||
'rpt.ofTotal': 'of total',
|
||||
'rpt.compliantItems': 'compliant items',
|
||||
'rpt.totalFindings': 'total findings',
|
||||
'rpt.evaluated': 'Evaluated',
|
||||
'rpt.controlsVerified': 'Controls verified',
|
||||
'rpt.regionsCount': 'region(s)',
|
||||
'rpt.complianceDist': 'Compliance Distribution',
|
||||
'rpt.findingsBySection': 'Findings by Section',
|
||||
'rpt.generatedAt': 'Generated at',
|
||||
'rpt.htmlReport': 'HTML Report',
|
||||
'rpt.files': 'Files',
|
||||
'rpt.inSections': 'sections',
|
||||
'rpt.fullEmbedding': 'Full Embedding',
|
||||
'rpt.noActiveTables': 'No active tables',
|
||||
'rpt.executionHistory': 'Execution History',
|
||||
'rpt.reports': 'report(s)',
|
||||
'rpt.loading': 'Loading...',
|
||||
'rpt.firstScanHint': 'Run the first CIS scan above.',
|
||||
'rpt.selected': 'Selected',
|
||||
'rpt.deleteConfirm': 'Delete this report?',
|
||||
'rpt.deleteFailed': 'Failed to delete',
|
||||
'rpt.selectOciCredError': 'Select an OCI credential',
|
||||
'rpt.alreadyRunning': 'A scan is already running',
|
||||
'rpt.errorLoadReports': 'Error loading reports',
|
||||
'rpt.errorStartScan': 'Error starting scan',
|
||||
'rpt.selectAdb': 'Select an ADB connection.',
|
||||
'rpt.selectTable': 'Select a table.',
|
||||
'rpt.errorEmbedding': 'Error generating embedding',
|
||||
'rpt.cancelled': 'Cancelled',
|
||||
'rpt.completedAt': 'Completed:',
|
||||
|
||||
// ── Downloads ──
|
||||
'dl.title': 'Downloads',
|
||||
'dl.subtitle': 'Download generated CIS reports',
|
||||
'dl.allTenancies': 'All Tenancies',
|
||||
'dl.allSections': 'All sections',
|
||||
'dl.reports': 'report(s)',
|
||||
'dl.refresh': 'Refresh',
|
||||
'dl.loadError': 'Error loading reports',
|
||||
'dl.loading': 'Loading reports...',
|
||||
'dl.noReports': 'No completed reports.',
|
||||
'dl.noReportsHint': 'Run a scan in the Reports tab to generate reports.',
|
||||
'dl.files': 'file(s)',
|
||||
'dl.hide': 'Hide',
|
||||
'dl.viewFiles': 'View Files',
|
||||
'dl.loadingFiles': 'Loading files...',
|
||||
'dl.noFiles': 'No files found',
|
||||
'dl.filesInSections': 'file(s) in {0} sections',
|
||||
|
||||
// ── OCI Config ──
|
||||
'oci.title': 'OCI Credentials',
|
||||
'oci.subtitle': 'Manage Oracle Cloud Infrastructure credentials',
|
||||
'oci.registered': 'Registered Credentials',
|
||||
'oci.credential': 'credential',
|
||||
'oci.credentials': 'credentials',
|
||||
'oci.noCredentials': 'No credentials registered.',
|
||||
'oci.edit': 'Edit',
|
||||
'oci.test': 'Test',
|
||||
'oci.delete': 'Delete',
|
||||
'oci.yes': 'Yes',
|
||||
'oci.no': 'No',
|
||||
'oci.newCredential': 'New Credential',
|
||||
'oci.editCredential': 'Edit Credential',
|
||||
'oci.addDesc': 'Add your OCI account credentials to run reports and access services.',
|
||||
'oci.editing': 'Editing:',
|
||||
'oci.tenancyName': 'Tenancy Name',
|
||||
'oci.ocidTenancy': 'OCID Tenancy',
|
||||
'oci.region': 'Region',
|
||||
'oci.selectRegion': 'Select region...',
|
||||
'oci.searchRegion': 'Search region...',
|
||||
'oci.noRegion': 'No regions found',
|
||||
'oci.compartment': 'Compartment OCID',
|
||||
'oci.ocidUser': 'OCID User',
|
||||
'oci.fingerprint': 'Fingerprint',
|
||||
'oci.sessionToken': 'Session Token',
|
||||
'oci.sessionTokenHint': 'Paste the token file content generated by',
|
||||
'oci.sessionKeyLabel': 'Session Key (.pem)',
|
||||
'oci.privateKey': 'Private Key (.pem)',
|
||||
'oci.publicKey': 'Public Key (.pem)',
|
||||
'oci.keyPassphrase': 'Key Passphrase',
|
||||
'oci.keyPassphraseHint': 'Only if the private key is password protected',
|
||||
'oci.sshPublicKey': 'SSH Public Key',
|
||||
'oci.sshPublicKeyHint': 'SSH key for compute instance access (e.g. ssh-rsa AAAA...)',
|
||||
'oci.saveCredential': 'Save Credential',
|
||||
'oci.saveChanges': 'Save Changes',
|
||||
'oci.cancel': 'Cancel',
|
||||
'oci.saving': 'Saving credential...',
|
||||
'oci.saved': 'Credential saved successfully!',
|
||||
'oci.updated': 'Credential updated successfully!',
|
||||
'oci.deleting': 'Deleting credential...',
|
||||
'oci.deleted': 'Credential deleted successfully!',
|
||||
'oci.fillField': 'Fill in',
|
||||
'oci.fillOcidTenancy': 'Fill in OCID Tenancy',
|
||||
'oci.fillUserAndFP': 'Fill in OCID User and Fingerprint',
|
||||
'oci.pasteToken': 'Paste the Session Token',
|
||||
'oci.selectKey': 'Select the private key (.pem)',
|
||||
'oci.selectSessionKey': 'Select the session key (.pem)',
|
||||
'oci.changeHint': 'Fill to change. Current:',
|
||||
'oci.keepKey': 'Leave empty to keep current key',
|
||||
'oci.typeCannotChange': '(type cannot be changed)',
|
||||
'oci.sshNotConfigured': 'not configured',
|
||||
|
||||
// ── GenAI Config ──
|
||||
'genai.title': 'GenAI Models',
|
||||
'genai.subtitle': 'Configure generative AI models for the Chat Agent',
|
||||
'genai.configured': 'Configured Models',
|
||||
'genai.model': 'model',
|
||||
'genai.models': 'models',
|
||||
'genai.noModels': 'No models configured.',
|
||||
'genai.newModel': 'New GenAI Model',
|
||||
'genai.editModel': 'Edit Model',
|
||||
'genai.configName': 'Config Name',
|
||||
'genai.ociCredential': 'OCI Credential',
|
||||
'genai.modelSelect': 'Model',
|
||||
'genai.genaiRegion': 'GenAI Region',
|
||||
'genai.compartment': 'Compartment OCID',
|
||||
'genai.modelOcid': 'Model OCID',
|
||||
'genai.modelOcidHint': 'Paste the model OCID from OCI portal (optional)',
|
||||
'genai.customOcid': 'Custom (use OCID)',
|
||||
'genai.default': 'Default Model',
|
||||
'genai.setDefault': 'Set as Default',
|
||||
'genai.saveModel': 'Save Model',
|
||||
'genai.saveChanges': 'Save Changes',
|
||||
'genai.cancel': 'Cancel',
|
||||
'genai.saving': 'Saving model...',
|
||||
'genai.deleting': 'Deleting model...',
|
||||
'genai.addDesc': 'Add custom models or create presets with specific credentials.',
|
||||
'genai.fillName': 'Fill in config name',
|
||||
'genai.selectOci': 'Select an OCI credential',
|
||||
'genai.fillOcid': 'Enter Model OCID for custom model',
|
||||
|
||||
// ── MCP Servers ──
|
||||
'mcp.title': 'MCP Servers',
|
||||
'mcp.subtitle': 'MCP servers for tool integration and task execution',
|
||||
'mcp.registered': 'Registered Servers',
|
||||
'mcp.noServers': 'No MCP servers registered.',
|
||||
'mcp.edit': 'Edit',
|
||||
'mcp.activate': 'Activate',
|
||||
'mcp.deactivate': 'Deactivate',
|
||||
'mcp.active': 'Active',
|
||||
'mcp.inactive': 'Inactive',
|
||||
'mcp.delete': 'Delete',
|
||||
'mcp.yes': 'Yes',
|
||||
'mcp.no': 'No',
|
||||
'mcp.command': 'Command:',
|
||||
'mcp.url': 'URL:',
|
||||
'mcp.module': 'Module:',
|
||||
'mcp.adb': 'ADB:',
|
||||
'mcp.noAdb': 'None',
|
||||
'mcp.tools': 'Tools',
|
||||
'mcp.noTools': 'No tools registered',
|
||||
'mcp.discoverTools': 'Discover Tools',
|
||||
'mcp.discovering': 'Connecting to MCP server to discover tools...',
|
||||
'mcp.newServer': 'Register New Server',
|
||||
'mcp.editServer': 'Edit Server',
|
||||
'mcp.name': 'Name',
|
||||
'mcp.description': 'Description',
|
||||
'mcp.serverType': 'Server Type',
|
||||
'mcp.commandLabel': 'Command',
|
||||
'mcp.commandHint': 'Command to start the server',
|
||||
'mcp.argsLabel': 'Arguments (JSON)',
|
||||
'mcp.argsHint': 'Array of arguments passed to the command',
|
||||
'mcp.urlLabel': 'Server URL',
|
||||
'mcp.urlHint': 'MCP server SSE endpoint',
|
||||
'mcp.linkAdb': 'Link ADB Vector',
|
||||
'mcp.linkAdbHint': 'Optional — the MCP server can use this database as a tool.',
|
||||
'mcp.saveServer': 'Register Server',
|
||||
'mcp.saveChanges': 'Save Changes',
|
||||
'mcp.cancel': 'Cancel',
|
||||
'mcp.saving': 'Registering server...',
|
||||
'mcp.updating': 'Updating server...',
|
||||
'mcp.fillName': 'Fill in server name',
|
||||
'mcp.invalidArgs': 'Arguments must be a valid JSON array',
|
||||
'mcp.configDesc': 'Configure a new MCP server. Fields adjust based on the selected type.',
|
||||
|
||||
// ── ADB Config ──
|
||||
'adb.title': 'ADB Connections',
|
||||
'adb.subtitle': 'Oracle Autonomous Database connections via Wallet (mTLS)',
|
||||
'adb.registered': 'Registered Connections',
|
||||
'adb.connection': 'connection',
|
||||
'adb.connections': 'connections',
|
||||
'adb.noConnections': 'No ADB connections registered.',
|
||||
'adb.edit': 'Edit',
|
||||
'adb.test': 'Test',
|
||||
'adb.delete': 'Delete',
|
||||
'adb.yes': 'Yes',
|
||||
'adb.no': 'No',
|
||||
'adb.newConnection': 'New Connection',
|
||||
'adb.editConnection': 'Edit Connection',
|
||||
'adb.connName': 'Connection Name',
|
||||
'adb.walletZip': 'Wallet ZIP',
|
||||
'adb.walletHint': 'Upload wallet to extract DSNs from tnsnames.ora',
|
||||
'adb.walletEditHint': 'Upload new wallet or leave empty to keep current',
|
||||
'adb.analyze': 'Analyze',
|
||||
'adb.dsn': 'DSN (TNS Name)',
|
||||
'adb.dsnHint': 'Select or type the DSN. E.g. myatp_high',
|
||||
'adb.username': 'Username',
|
||||
'adb.password': 'Password',
|
||||
'adb.walletPassword': 'Wallet Password',
|
||||
'adb.genaiConfig': 'GenAI Config (Embeddings)',
|
||||
'adb.genaiConfigHint': 'OCI credentials used to generate embeddings via GenAI.',
|
||||
'adb.noRag': 'None (no RAG)',
|
||||
'adb.embeddingModel': 'Embedding Model',
|
||||
'adb.embeddingModelHint': 'OCI GenAI model to generate vectors.',
|
||||
'adb.saveConnection': 'Save Connection',
|
||||
'adb.saveChanges': 'Save Changes',
|
||||
'adb.cancel': 'Cancel',
|
||||
'adb.saving': 'Saving connection...',
|
||||
'adb.deleting': 'Deleting connection...',
|
||||
'adb.updateWallet': 'Update Wallet',
|
||||
'adb.adbConnection': 'ADB Connection',
|
||||
'adb.uploadWallet': 'Upload Wallet',
|
||||
'adb.connDesc': 'Connection via python-oracledb Thin mode with Wallet (mTLS) to Oracle Autonomous Database.',
|
||||
'adb.selectWallet': 'Select a wallet ZIP file.',
|
||||
'adb.selectConnection': 'Select an ADB connection.',
|
||||
'adb.fillName': 'Fill in connection name',
|
||||
'adb.fillDsn': 'Fill in DSN',
|
||||
'adb.fillUsername': 'Fill in username',
|
||||
'adb.fillPassword': 'Fill in password',
|
||||
|
||||
// ── Embeddings ──
|
||||
'emb.title': 'Embeddings',
|
||||
'emb.subtitle': 'Vector knowledge base — upload, import and management',
|
||||
'emb.noAdb': 'No ADB connection configured.',
|
||||
'emb.noAdbHint': 'Configure in ADB Vector.',
|
||||
'emb.noOci': 'No OCI credential configured.',
|
||||
'emb.noOciHint': 'Configure in Config → OCI.',
|
||||
'emb.existing': 'Existing Embeddings',
|
||||
'emb.existingDesc': 'Query documents stored in ADB embedding tables.',
|
||||
'emb.adbConnection': 'ADB Connection',
|
||||
'emb.table': 'Table',
|
||||
'emb.load': 'Load',
|
||||
'emb.noEmbeddings': 'No embeddings found.',
|
||||
'emb.total': 'Total: {0} documents',
|
||||
'emb.cisTitle': 'CIS Recommendations',
|
||||
'emb.cisDesc': 'Upload the latest PDF version to keep CIS Oracle Cloud recommendations updated in the vector database.',
|
||||
'emb.pdfFile': 'PDF File',
|
||||
'emb.uploadCis': 'Upload CIS Recommendations',
|
||||
'emb.kbTitle': 'Knowledge Base',
|
||||
'emb.kbDesc': 'Upload documents to feed the team knowledge base. Files will be vectorized and available for chat queries.',
|
||||
'emb.fileLabel': 'File (.txt, .pdf, .csv, .json, .md)',
|
||||
'emb.uploadFile': 'Upload File',
|
||||
'emb.urlLabel': 'URL (web page or PDF)',
|
||||
'emb.importUrl': 'Import URL',
|
||||
'emb.purgeTitle': 'Purge & Re-embed',
|
||||
'emb.purgeDesc': 'Clear embeddings from a table to free space or prepare for re-ingestion. This action is irreversible.',
|
||||
'emb.tenancyOptional': 'Tenancy (optional)',
|
||||
'emb.purge': 'Purge',
|
||||
'emb.confirm': 'Confirm',
|
||||
'emb.selectTable': 'Select a table.',
|
||||
'emb.selectAdbAndFile': 'Select ADB connection and file.',
|
||||
'emb.selectAdbAndUrl': 'Select ADB connection and enter URL.',
|
||||
'emb.processing': 'Processing PDF and generating embeddings...',
|
||||
'emb.uploading': 'Uploading and generating embeddings...',
|
||||
'emb.fetchingUrl': 'Fetching content and generating embeddings...',
|
||||
'emb.purging': 'Clearing embeddings...',
|
||||
|
||||
// ── EmbConsult ──
|
||||
'ec.title': 'Query Embeddings',
|
||||
'ec.subtitle': 'Ask about data stored in vector databases',
|
||||
'ec.clear': 'Clear',
|
||||
'ec.tableOptional': 'Table (optional)',
|
||||
'ec.allTables': 'All tables',
|
||||
'ec.emptyTitle': 'Make a query to search embeddings.',
|
||||
'ec.emptyHint': 'E.g. "findings for CIS 1.1", "networking recommendations", "how to fix MFA"',
|
||||
'ec.searching': 'Querying vector databases...',
|
||||
'ec.placeholder': 'Type your query...',
|
||||
'ec.submit': 'Query',
|
||||
'ec.sources': 'Consulted sources',
|
||||
'ec.noAnswer': 'No model response.',
|
||||
|
||||
// ── Users ──
|
||||
'usr.title': 'Users',
|
||||
'usr.activeCount': 'active',
|
||||
'usr.activesCount': 'active',
|
||||
'usr.inactiveCount': 'inactive',
|
||||
'usr.inactivesCount': 'inactive',
|
||||
'usr.total': 'total',
|
||||
'usr.newUser': 'New User',
|
||||
'usr.cancel': 'Cancel',
|
||||
'usr.createUser': 'Create New User',
|
||||
'usr.firstName': 'First Name',
|
||||
'usr.lastName': 'Last Name',
|
||||
'usr.username': 'Username',
|
||||
'usr.email': 'Email',
|
||||
'usr.password': 'Password',
|
||||
'usr.role': 'Role',
|
||||
'usr.creating': 'Creating...',
|
||||
'usr.create': 'Create User',
|
||||
'usr.noUsers': 'No users registered',
|
||||
'usr.noUsersHint': 'Click "New User" to create the first user.',
|
||||
'usr.name': 'Name',
|
||||
'usr.mfa': 'MFA',
|
||||
'usr.status': 'Status',
|
||||
'usr.lastLogin': 'Last Login',
|
||||
'usr.actions': 'Actions',
|
||||
'usr.active': 'Active',
|
||||
'usr.inactive': 'Inactive',
|
||||
'usr.never': 'Never',
|
||||
'usr.confirm': 'Confirm',
|
||||
'usr.deactivate': 'Deactivate user',
|
||||
'usr.created': 'User created successfully!',
|
||||
'usr.roleUpdated': 'Role updated',
|
||||
'usr.deactivated': 'User deactivated',
|
||||
'usr.requiredName': 'First and last name are required',
|
||||
'usr.requiredUsername': 'Username is required',
|
||||
'usr.requiredPassword': 'Password is required',
|
||||
|
||||
// ── MFA ──
|
||||
'mfa.title': 'MFA Authentication (TOTP)',
|
||||
'mfa.subtitle': 'Protect your account with two-factor authentication via Google Authenticator or Authy.',
|
||||
'mfa.enabled': 'MFA is enabled',
|
||||
'mfa.disabled': 'MFA is disabled',
|
||||
'mfa.enabledDesc': 'Your account is protected with two-factor authentication.',
|
||||
'mfa.disabledDesc': 'Enable MFA to add an extra layer of security.',
|
||||
'mfa.disableHint': 'To disable MFA, click the button below. This will reduce your account security.',
|
||||
'mfa.disableBtn': 'Disable MFA',
|
||||
'mfa.confirmDisable': 'Are you sure you want to disable MFA for your account?',
|
||||
'mfa.scanQr': 'Scan the QR Code or copy the secret',
|
||||
'mfa.openApp': 'Open your authenticator app (Google Authenticator, Authy, etc.)',
|
||||
'mfa.enterCode': 'Enter the code from your app',
|
||||
'mfa.activateMfa': 'Activate MFA',
|
||||
'mfa.generateSecret': 'Generate Secret',
|
||||
'mfa.generateHint': 'Click the button below to generate a TOTP secret. You will need an authenticator app to scan the QR Code.',
|
||||
'mfa.activated': 'MFA activated successfully!',
|
||||
'mfa.deactivated': 'MFA deactivated.',
|
||||
'mfa.invalidCode': 'Enter a 6-digit code',
|
||||
|
||||
// ── Audit ──
|
||||
'audit.title': 'Audit Log',
|
||||
'audit.subtitle': 'System activity monitoring, configurations and chat sessions.',
|
||||
'audit.tabAudit': 'Audit Log',
|
||||
'audit.tabConfig': 'Config Logs',
|
||||
'audit.tabChat': 'Chat Logs',
|
||||
'audit.records': 'records',
|
||||
'audit.refresh': 'Refresh',
|
||||
'audit.all': 'All',
|
||||
'audit.errors': 'Errors',
|
||||
'audit.success': 'Success',
|
||||
'audit.info': 'Info',
|
||||
'audit.loadingLogs': 'Loading logs...',
|
||||
'audit.noAudit': 'No audit records.',
|
||||
'audit.noConfig': 'No configuration logs recorded.',
|
||||
'audit.noChat': 'No chat logs recorded.',
|
||||
'audit.date': 'Date',
|
||||
'audit.user': 'User',
|
||||
'audit.action': 'Action',
|
||||
'audit.resource': 'Resource',
|
||||
'audit.details': 'Details',
|
||||
'audit.ip': 'IP',
|
||||
'audit.config': 'Config',
|
||||
'audit.status': 'Status',
|
||||
'audit.message': 'Message',
|
||||
'audit.session': 'Session',
|
||||
'audit.source': 'Source',
|
||||
|
||||
// ── Common ──
|
||||
'common.save': 'Save',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.delete': 'Delete',
|
||||
'common.edit': 'Edit',
|
||||
'common.create': 'Create',
|
||||
'common.update': 'Refresh',
|
||||
'common.close': 'Close',
|
||||
'common.confirm': 'Confirm',
|
||||
'common.back': 'Back',
|
||||
'common.search': 'Search',
|
||||
'common.refresh': 'Refresh',
|
||||
'common.download': 'Download',
|
||||
'common.upload': 'Upload',
|
||||
'common.copy': 'Copy',
|
||||
'common.retry': 'Retry',
|
||||
'common.send': 'Send',
|
||||
'common.clear': 'Clear',
|
||||
'common.test': 'Test',
|
||||
'common.yes': 'Yes',
|
||||
'common.no': 'No',
|
||||
'common.loading': 'Loading...',
|
||||
'common.saving': 'Saving...',
|
||||
'common.select': 'Select...',
|
||||
'common.noData': 'No data found',
|
||||
'common.error': 'Error',
|
||||
'common.success': 'Success',
|
||||
};
|
||||
|
||||
export default en;
|
||||
31
frontend-react/src/i18n/index.ts
Normal file
31
frontend-react/src/i18n/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { create } from 'zustand';
|
||||
import pt from './pt';
|
||||
import en from './en';
|
||||
|
||||
type Lang = 'pt' | 'en';
|
||||
|
||||
const LANGS: Record<Lang, Record<string, string>> = { pt, en };
|
||||
|
||||
function detectLang(): Lang {
|
||||
const saved = localStorage.getItem('lang') as Lang | null;
|
||||
if (saved && LANGS[saved]) return saved;
|
||||
return navigator.language.startsWith('pt') ? 'pt' : 'en';
|
||||
}
|
||||
|
||||
interface I18nState {
|
||||
lang: Lang;
|
||||
setLang: (lang: Lang) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export const useI18n = create<I18nState>((set, get) => ({
|
||||
lang: detectLang(),
|
||||
setLang: (lang: Lang) => {
|
||||
localStorage.setItem('lang', lang);
|
||||
set({ lang });
|
||||
},
|
||||
t: (key: string) => {
|
||||
const { lang } = get();
|
||||
return LANGS[lang]?.[key] ?? LANGS.pt[key] ?? key;
|
||||
},
|
||||
}));
|
||||
666
frontend-react/src/i18n/pt.ts
Normal file
666
frontend-react/src/i18n/pt.ts
Normal file
@@ -0,0 +1,666 @@
|
||||
const pt: Record<string, string> = {
|
||||
// ── Sidebar ──
|
||||
'nav.principal': 'Principal',
|
||||
'nav.config': 'Configuração',
|
||||
'nav.admin': 'Administração',
|
||||
'nav.chat': 'Chat Agent',
|
||||
'nav.terraform': 'Terraform',
|
||||
'nav.promptGen': 'Prompt Generator',
|
||||
'nav.workspaces': 'Workspaces',
|
||||
'nav.explorer': 'OCI Explorer',
|
||||
'nav.reports': 'Relatórios',
|
||||
'nav.ociCreds': 'Credenciais OCI',
|
||||
'nav.genai': 'GenAI Config',
|
||||
'nav.mcp': 'MCP Servers',
|
||||
'nav.adb': 'ADB Vector',
|
||||
'nav.embeddings': 'Embeddings',
|
||||
'nav.embConsult': 'Consultar Embeddings',
|
||||
'nav.users': 'Usuários',
|
||||
'nav.mfa': 'MFA',
|
||||
'nav.audit': 'Audit Log',
|
||||
'sidebar.lightMode': 'Modo claro',
|
||||
'sidebar.darkMode': 'Modo escuro',
|
||||
'sidebar.logout': 'Sair',
|
||||
'sidebar.roleAdmin': 'Administrador',
|
||||
'sidebar.roleUser': 'Usuário',
|
||||
'sidebar.subtitle': 'Infrastructure & Security',
|
||||
|
||||
// ── Login ──
|
||||
'login.subtitle': 'Infrastructure & Security Engineer',
|
||||
'login.userPlaceholder': 'Usuario',
|
||||
'login.passPlaceholder': 'Senha',
|
||||
'login.mfaScanQr': 'Escaneie o QR code no seu app autenticador:',
|
||||
'login.totpPlaceholder': 'Codigo TOTP',
|
||||
'login.loading': 'Entrando...',
|
||||
'login.verify': 'Verificar',
|
||||
'login.submit': 'Entrar',
|
||||
'login.error': 'Erro ao fazer login',
|
||||
|
||||
// ── Chat ──
|
||||
'chat.timeNow': 'agora',
|
||||
'chat.today': 'Hoje',
|
||||
'chat.yesterday': 'Ontem',
|
||||
'chat.last7': 'Ultimos 7 dias',
|
||||
'chat.last30': 'Ultimos 30 dias',
|
||||
'chat.older': 'Anteriores',
|
||||
'chat.other': 'Outras',
|
||||
'chat.history': 'Historico',
|
||||
'chat.newChat': 'Nova Conversa',
|
||||
'chat.untitled': 'Sem titulo',
|
||||
'chat.noConversations': 'Nenhuma conversa ainda',
|
||||
'chat.selectModel': 'Selecione um modelo...',
|
||||
'chat.savedConfigs': 'Configs Salvas',
|
||||
'chat.searchModel': 'Buscar modelo...',
|
||||
'chat.noModels': 'Nenhum modelo encontrado',
|
||||
'chat.ociCred': 'Credencial OCI...',
|
||||
'chat.settings': 'Configuracoes',
|
||||
'chat.newChatTitle': 'Nova conversa',
|
||||
'chat.emptyTitle': 'Inicie uma conversa com o agente.',
|
||||
'chat.emptySubtitle': 'Selecione um modelo para comecar.',
|
||||
'chat.thinking': 'Pensando...',
|
||||
'chat.retry': 'Reenviar',
|
||||
'chat.attachFile': 'Anexar arquivo',
|
||||
'chat.placeholder': 'Digite sua mensagem... (Shift+Enter para nova linha)',
|
||||
'chat.send': 'Enviar',
|
||||
'chat.selectOciCred': 'Selecione uma credencial OCI para usar modelos diretos.',
|
||||
'chat.sendError': 'Falha ao enviar mensagem',
|
||||
'chat.requestFailed': 'A requisicao falhou.',
|
||||
'chat.timeout': 'Timeout: a resposta esta demorando muito. Tente novamente.',
|
||||
'chat.toolsAvailable': 'tools disponiveis',
|
||||
'chat.ociCredLabel': 'Credencial OCI',
|
||||
'chat.select': 'Selecionar...',
|
||||
'chat.analyzeFiles': 'Analise os arquivos anexados.',
|
||||
|
||||
// ── Terraform ──
|
||||
'tf.history': 'Historico',
|
||||
'tf.newChat': 'Nova',
|
||||
'tf.noConversations': 'Nenhuma conversa',
|
||||
'tf.searchModel': 'Buscar modelo...',
|
||||
'tf.selectModel': 'Selecione modelo...',
|
||||
'tf.savedConfigs': 'Configs Salvas',
|
||||
'tf.ociConfig': 'OCI Config...',
|
||||
'tf.settings': 'Configuracoes',
|
||||
'tf.newConversation': 'Nova conversa',
|
||||
'tf.emptyTitle': 'Descreva a infraestrutura OCI',
|
||||
'tf.emptySubtitle': 'O agente vai gerar codigo Terraform baseado na sua descricao.',
|
||||
'tf.thinking': 'Processando...',
|
||||
'tf.retry': 'Reenviar',
|
||||
'tf.placeholder': 'Descreva a infraestrutura desejada... (Shift+Enter para nova linha)',
|
||||
'tf.send': 'Enviar',
|
||||
'tf.workspace': 'Workspace',
|
||||
'tf.files': 'Arquivos',
|
||||
'tf.terminal': 'Terminal',
|
||||
'tf.resources': 'Recursos',
|
||||
'tf.plan': 'Plan',
|
||||
'tf.apply': 'Apply',
|
||||
'tf.destroy': 'Destroy',
|
||||
'tf.cancel': 'Cancelar',
|
||||
'tf.download': 'Download ZIP',
|
||||
'tf.createWs': 'Criar Workspace',
|
||||
'tf.noFiles': 'Nenhum arquivo gerado',
|
||||
'tf.timeout': 'Timeout: a geracao esta demorando muito.',
|
||||
'tf.selectOci': 'Selecione uma credencial OCI',
|
||||
'tf.compartment': 'Compartimento',
|
||||
'tf.region': 'Regiao',
|
||||
'tf.rollback': 'Rollback',
|
||||
'tf.copy': 'Copiar',
|
||||
'tf.copied': 'Copiado',
|
||||
'tf.noWs': 'Nenhum workspace ativo',
|
||||
'tf.deleteWs': 'Excluir',
|
||||
|
||||
// ── Prompt Generator ──
|
||||
'pg.history': 'Historico',
|
||||
'pg.new': 'Nova',
|
||||
'pg.noConversations': 'Nenhuma conversa',
|
||||
'pg.emptyTitle': 'Descreva a infraestrutura OCI que voce precisa',
|
||||
'pg.emptySubtitle': 'O agente vai gerar um prompt estruturado e otimizado para o Terraform Agent.',
|
||||
'pg.loading': 'Gerando prompt...',
|
||||
'pg.placeholder': 'Descreva a infraestrutura desejada... (Shift+Enter para nova linha)',
|
||||
'pg.copy': 'Copiar',
|
||||
'pg.copied': 'Copiado',
|
||||
'pg.retry': 'Reenviar',
|
||||
'pg.timeout': 'Timeout: a geracao esta demorando muito.',
|
||||
'pg.search': 'Buscar...',
|
||||
'pg.selectModel': 'Selecione modelo...',
|
||||
|
||||
// ── Workspaces ──
|
||||
'ws.title': 'Terraform Workspaces',
|
||||
'ws.all': 'Todos',
|
||||
'ws.active': 'Ativos',
|
||||
'ws.draft': 'Draft',
|
||||
'ws.failed': 'Falha',
|
||||
'ws.destroyed': 'Destruídos',
|
||||
'ws.loading': 'Carregando...',
|
||||
'ws.refresh': 'Atualizar',
|
||||
'ws.noWs': 'Nenhum workspace encontrado',
|
||||
'ws.noWsHint': 'Crie workspaces pelo Terraform Agent',
|
||||
'ws.loadingWs': 'Carregando workspaces...',
|
||||
'ws.open': 'Abrir',
|
||||
'ws.plan': 'Plan',
|
||||
'ws.apply': 'Apply',
|
||||
'ws.destroy': 'Destroy',
|
||||
'ws.delete': 'Excluir',
|
||||
'ws.cancel': 'Cancelar',
|
||||
'ws.hideOutput': 'ocultar',
|
||||
'ws.viewOutput': 'ver output',
|
||||
'ws.confirmDestroy': 'Confirmar Destroy',
|
||||
'ws.confirmDestroyMsg': 'Esta acao ira destruir todos os recursos provisionados por este workspace.',
|
||||
'ws.typeDestroy': 'Digite <strong>DESTROY</strong> para confirmar:',
|
||||
'ws.deleteWs': 'Excluir Workspace',
|
||||
'ws.deleteWsMsg': 'Deseja excluir este workspace permanentemente? Arquivos locais serao removidos.',
|
||||
'ws.noDate': 'Sem data',
|
||||
|
||||
// ── Explorer ──
|
||||
'exp.title': 'OCI Explorer',
|
||||
'exp.subtitle': 'Explore recursos da sua infraestrutura Oracle Cloud',
|
||||
'exp.search': 'Buscar recurso...',
|
||||
'exp.refresh': 'Atualizar',
|
||||
'exp.loading': 'Carregando...',
|
||||
'exp.noResources': 'Nenhum recurso encontrado',
|
||||
'exp.selectResource': 'Selecione um tipo de recurso',
|
||||
'exp.compartment': 'Compartimento',
|
||||
'exp.region': 'Regiao',
|
||||
'exp.allRegions': 'Todas as regioes',
|
||||
'exp.start': 'Start',
|
||||
'exp.stop': 'Stop',
|
||||
'exp.details': 'Detalhes',
|
||||
'exp.total': 'Total',
|
||||
'exp.network': 'Rede',
|
||||
'exp.observability': 'Observabilidade',
|
||||
'exp.security': 'Seguranca',
|
||||
'exp.selectRegions': 'Selecionar regioes',
|
||||
'exp.nRegions': '{0} regioes',
|
||||
'exp.all': 'Todas',
|
||||
'exp.none': 'Nenhuma',
|
||||
'exp.noCompartment': 'Nenhum compartment encontrado',
|
||||
'exp.selectConfig': 'Selecione uma config OCI',
|
||||
'exp.loadingResources': 'Carregando recursos...',
|
||||
'exp.clearFilter': 'Tente limpar o filtro de busca',
|
||||
'exp.resources': 'recursos',
|
||||
'exp.updating': 'atualizando...',
|
||||
'exp.regions': 'regiao(oes)',
|
||||
'exp.filter': 'Filtrar...',
|
||||
'exp.startAction': 'Iniciar',
|
||||
'exp.stopAction': 'Parar',
|
||||
'exp.updatingState': 'Atualizando...',
|
||||
'exp.createdAt': 'Criado em',
|
||||
'exp.public': 'Publico',
|
||||
'exp.yesPublic': 'Sim',
|
||||
'exp.noPrivate': 'Nao (privada)',
|
||||
'exp.freeTier': 'Free Tier',
|
||||
'exp.yes': 'Sim',
|
||||
'exp.no': 'Nao',
|
||||
'exp.noRestrictions': 'Sem restricoes (aberto)',
|
||||
'exp.updateNetworkAccess': 'Atualizar Acesso de Rede',
|
||||
'exp.accessUpdated': 'Acesso atualizado: ',
|
||||
'exp.objects': 'Objetos',
|
||||
'exp.size': 'Tamanho',
|
||||
'exp.visibility': 'Visibilidade',
|
||||
'exp.private': 'Privado',
|
||||
'exp.rules': 'Regras',
|
||||
'exp.routes': 'Rotas',
|
||||
'exp.hideRules': 'Ocultar regras',
|
||||
'exp.viewRules': 'Ver regras',
|
||||
'exp.add': 'Adicionar',
|
||||
'exp.direction': 'Direcao',
|
||||
'exp.protocol': 'Protocolo',
|
||||
'exp.originDest': 'Origem/Destino',
|
||||
'exp.ports': 'Portas',
|
||||
'exp.description': 'Descricao',
|
||||
'exp.portMin': 'Porta Min',
|
||||
'exp.portMax': 'Porta Max',
|
||||
'exp.myIp': 'Meu IP',
|
||||
'exp.save': 'Salvar',
|
||||
'exp.cancel': 'Cancelar',
|
||||
'exp.ruleAdded': 'Regra adicionada.',
|
||||
'exp.enterCidr': 'Informe o CIDR.',
|
||||
'exp.removeRule': 'Remover esta regra?',
|
||||
'exp.removeNsgRule': 'Remover esta regra do NSG?',
|
||||
'exp.ipDetected': 'IP detectado: {0} — ajuste porta e clique Salvar',
|
||||
'exp.ipDetectFailed': 'Nao foi possivel detectar o IP.',
|
||||
'exp.destination': 'Destino',
|
||||
'exp.destType': 'Tipo Destino',
|
||||
'exp.version': 'Versao',
|
||||
'exp.ha': 'HA',
|
||||
|
||||
// ── Reports ──
|
||||
'rpt.title': 'CIS Reports',
|
||||
'rpt.subtitle': 'Execute scans de conformidade CIS OCI Benchmark',
|
||||
'rpt.startScan': 'Iniciar Scan',
|
||||
'rpt.running': 'Executando...',
|
||||
'rpt.cancel': 'Cancelar',
|
||||
'rpt.tenancy': 'Tenancy',
|
||||
'rpt.level': 'Level',
|
||||
'rpt.region': 'Regiao',
|
||||
'rpt.allRegions': 'Todas as regioes',
|
||||
'rpt.noReports': 'Nenhum relatorio encontrado',
|
||||
'rpt.noReportsHint': 'Execute um scan para gerar relatorios de conformidade.',
|
||||
'rpt.progress': 'Progresso',
|
||||
'rpt.completed': 'Concluido',
|
||||
'rpt.failed': 'Falhou',
|
||||
'rpt.viewReport': 'Ver Relatorio',
|
||||
'rpt.download': 'Download',
|
||||
'rpt.delete': 'Excluir',
|
||||
'rpt.summary': 'Resumo',
|
||||
'rpt.passed': 'Passou',
|
||||
'rpt.findings': 'Findings',
|
||||
'rpt.recommendations': 'Recomendacoes',
|
||||
'rpt.selectTenancy': 'Selecione uma tenancy',
|
||||
'rpt.selectCredential': 'Selecione uma credencial',
|
||||
'rpt.loadingReports': 'Carregando relatorios...',
|
||||
'rpt.refresh': 'Atualizar',
|
||||
'rpt.pageTitle': 'Relatorios CIS',
|
||||
'rpt.pageSubtitle': 'CIS Benchmark 3.0 — Compliance Scan & Dashboard',
|
||||
'rpt.runScanCis': 'Executar Scan CIS',
|
||||
'rpt.inExecution': 'Em execucao',
|
||||
'rpt.configParams': 'Configure os parametros e execute o scan de compliance CIS.',
|
||||
'rpt.ociCredential': 'Credencial OCI',
|
||||
'rpt.selectOciCred': 'Selecione a credencial OCI...',
|
||||
'rpt.cisLevel': 'CIS Level',
|
||||
'rpt.level1Desc': 'Verificacoes essenciais de seguranca',
|
||||
'rpt.level2Desc': 'Verificacoes completas (inclui Level 1)',
|
||||
'rpt.regions': 'Regioes',
|
||||
'rpt.leaveEmpty': 'Deixe vazio para escanear todas as regioes subscritas',
|
||||
'rpt.executeCompliance': 'Executar CIS Compliance',
|
||||
'rpt.scanProgress': 'Progresso do Scan',
|
||||
'rpt.hideLog': 'Ocultar log',
|
||||
'rpt.showLog': 'Ver log completo',
|
||||
'rpt.lines': 'linhas',
|
||||
'rpt.cancelScan': 'Cancelar Scan',
|
||||
'rpt.selectReport': 'Selecionar Relatorio',
|
||||
'rpt.selectCompletedReport': 'Selecione um relatorio concluido...',
|
||||
'rpt.searchTenancy': 'Buscar tenancy...',
|
||||
'rpt.searchByTenancy': 'Buscar por tenancy...',
|
||||
'rpt.noCredentials': 'Nenhuma credencial encontrada',
|
||||
'rpt.allRegionsDefault': 'Todas as regioes (padrao)',
|
||||
'rpt.searchRegion': 'Buscar regiao...',
|
||||
'rpt.loadingDashboard': 'Carregando dashboard...',
|
||||
'rpt.complianceScore': 'Score de Compliance',
|
||||
'rpt.compliant': 'Conforme',
|
||||
'rpt.nonCompliant': 'Nao Conforme',
|
||||
'rpt.ofTotal': 'do total',
|
||||
'rpt.compliantItems': 'itens conformes',
|
||||
'rpt.totalFindings': 'achados totais',
|
||||
'rpt.evaluated': 'Avaliados',
|
||||
'rpt.controlsVerified': 'Controles verificados',
|
||||
'rpt.regionsCount': 'regiao(oes)',
|
||||
'rpt.complianceDist': 'Distribuicao de Compliance',
|
||||
'rpt.findingsBySection': 'Achados por Secao',
|
||||
'rpt.generatedAt': 'Gerado em',
|
||||
'rpt.htmlReport': 'Relatorio HTML',
|
||||
'rpt.files': 'Arquivos',
|
||||
'rpt.inSections': 'secoes',
|
||||
'rpt.fullEmbedding': 'Embedding Completo',
|
||||
'rpt.noActiveTables': 'Sem tabelas ativas',
|
||||
'rpt.executionHistory': 'Historico de Execucoes',
|
||||
'rpt.reports': 'relatorio(s)',
|
||||
'rpt.loading': 'Carregando...',
|
||||
'rpt.firstScanHint': 'Execute o primeiro scan CIS acima.',
|
||||
'rpt.selected': 'Selecionado',
|
||||
'rpt.deleteConfirm': 'Excluir este relatorio?',
|
||||
'rpt.deleteFailed': 'Falha ao excluir',
|
||||
'rpt.selectOciCredError': 'Selecione uma credencial OCI',
|
||||
'rpt.alreadyRunning': 'Ja existe um scan em execucao',
|
||||
'rpt.errorLoadReports': 'Erro ao carregar relatorios',
|
||||
'rpt.errorStartScan': 'Erro ao iniciar scan',
|
||||
'rpt.selectAdb': 'Selecione uma conexao ADB.',
|
||||
'rpt.selectTable': 'Selecione uma tabela.',
|
||||
'rpt.errorEmbedding': 'Erro ao gerar embedding',
|
||||
'rpt.cancelled': 'Cancelado',
|
||||
'rpt.completedAt': 'Concluido:',
|
||||
|
||||
// ── Downloads ──
|
||||
'dl.title': 'Downloads',
|
||||
'dl.subtitle': 'Baixe relatorios CIS gerados',
|
||||
'dl.allTenancies': 'Todas as Tenancies',
|
||||
'dl.allSections': 'Todas as secoes',
|
||||
'dl.reports': 'relatorio(s)',
|
||||
'dl.refresh': 'Atualizar',
|
||||
'dl.loadError': 'Erro ao carregar relatorios',
|
||||
'dl.loading': 'Carregando relatorios...',
|
||||
'dl.noReports': 'Nenhum relatorio concluido.',
|
||||
'dl.noReportsHint': 'Execute um scan na aba Reports para gerar relatorios.',
|
||||
'dl.files': 'arquivo(s)',
|
||||
'dl.hide': 'Ocultar',
|
||||
'dl.viewFiles': 'Ver Arquivos',
|
||||
'dl.loadingFiles': 'Carregando arquivos...',
|
||||
'dl.noFiles': 'Nenhum arquivo encontrado',
|
||||
'dl.filesInSections': 'arquivo(s) em {0} secoes',
|
||||
|
||||
// ── OCI Config ──
|
||||
'oci.title': 'Credenciais OCI',
|
||||
'oci.subtitle': 'Gerencie credenciais Oracle Cloud Infrastructure',
|
||||
'oci.registered': 'Credenciais Registradas',
|
||||
'oci.credential': 'credencial',
|
||||
'oci.credentials': 'credenciais',
|
||||
'oci.noCredentials': 'Nenhuma credencial registrada.',
|
||||
'oci.edit': 'Editar',
|
||||
'oci.test': 'Testar',
|
||||
'oci.delete': 'Excluir',
|
||||
'oci.yes': 'Sim',
|
||||
'oci.no': 'Nao',
|
||||
'oci.newCredential': 'Nova Credencial',
|
||||
'oci.editCredential': 'Editar Credencial',
|
||||
'oci.addDesc': 'Adicione as credenciais da sua conta OCI para executar reports e acessar servicos.',
|
||||
'oci.editing': 'Editando:',
|
||||
'oci.tenancyName': 'Tenancy Name',
|
||||
'oci.ocidTenancy': 'OCID Tenancy',
|
||||
'oci.region': 'Region',
|
||||
'oci.selectRegion': 'Selecione a regiao...',
|
||||
'oci.searchRegion': 'Buscar regiao...',
|
||||
'oci.noRegion': 'Nenhuma regiao encontrada',
|
||||
'oci.compartment': 'Compartment OCID',
|
||||
'oci.ocidUser': 'OCID User',
|
||||
'oci.fingerprint': 'Fingerprint',
|
||||
'oci.sessionToken': 'Session Token',
|
||||
'oci.sessionTokenHint': 'Cole o conteudo do arquivo token gerado por',
|
||||
'oci.sessionKeyLabel': 'Chave de Sessao (.pem)',
|
||||
'oci.privateKey': 'Private Key (.pem)',
|
||||
'oci.publicKey': 'Public Key (.pem)',
|
||||
'oci.keyPassphrase': 'Key Passphrase',
|
||||
'oci.keyPassphraseHint': 'Apenas se a chave privada for protegida por senha',
|
||||
'oci.sshPublicKey': 'SSH Public Key',
|
||||
'oci.sshPublicKeyHint': 'Chave SSH para acesso a instancias compute (ex: ssh-rsa AAAA...)',
|
||||
'oci.saveCredential': 'Salvar Credencial',
|
||||
'oci.saveChanges': 'Salvar Alteracoes',
|
||||
'oci.cancel': 'Cancelar',
|
||||
'oci.saving': 'Salvando credencial...',
|
||||
'oci.saved': 'Credencial salva com sucesso!',
|
||||
'oci.updated': 'Credencial atualizada com sucesso!',
|
||||
'oci.deleting': 'Excluindo credencial...',
|
||||
'oci.deleted': 'Credencial excluida com sucesso!',
|
||||
'oci.fillField': 'Preencha o',
|
||||
'oci.fillOcidTenancy': 'Preencha o OCID Tenancy',
|
||||
'oci.fillUserAndFP': 'Preencha OCID User e Fingerprint',
|
||||
'oci.pasteToken': 'Cole o Session Token',
|
||||
'oci.selectKey': 'Selecione a chave privada (.pem)',
|
||||
'oci.selectSessionKey': 'Selecione a chave de sessao (.pem)',
|
||||
'oci.changeHint': 'Preencha para alterar. Atual:',
|
||||
'oci.keepKey': 'Deixe vazio para manter a chave atual',
|
||||
'oci.typeCannotChange': '(tipo nao pode ser alterado)',
|
||||
'oci.sshNotConfigured': 'nao configurada',
|
||||
|
||||
// ── GenAI Config ──
|
||||
'genai.title': 'Modelos GenAI',
|
||||
'genai.subtitle': 'Configure modelos de IA generativa para o Chat Agent',
|
||||
'genai.configured': 'Modelos Configurados',
|
||||
'genai.model': 'modelo',
|
||||
'genai.models': 'modelos',
|
||||
'genai.noModels': 'Nenhum modelo configurado.',
|
||||
'genai.newModel': 'Novo Modelo GenAI',
|
||||
'genai.editModel': 'Editar Modelo',
|
||||
'genai.configName': 'Nome da Config',
|
||||
'genai.ociCredential': 'Credencial OCI',
|
||||
'genai.modelSelect': 'Modelo',
|
||||
'genai.genaiRegion': 'Regiao GenAI',
|
||||
'genai.compartment': 'Compartment OCID',
|
||||
'genai.modelOcid': 'Model OCID',
|
||||
'genai.modelOcidHint': 'Cole o OCID do modelo do portal OCI (opcional)',
|
||||
'genai.customOcid': 'Personalizado (usar OCID)',
|
||||
'genai.default': 'Modelo Padrao',
|
||||
'genai.setDefault': 'Definir como Padrao',
|
||||
'genai.saveModel': 'Salvar Modelo',
|
||||
'genai.saveChanges': 'Salvar Alteracoes',
|
||||
'genai.cancel': 'Cancelar',
|
||||
'genai.saving': 'Salvando modelo...',
|
||||
'genai.deleting': 'Excluindo modelo...',
|
||||
'genai.addDesc': 'Adicione modelos personalizados ou crie presets com credenciais especificas.',
|
||||
'genai.fillName': 'Preencha o nome da config',
|
||||
'genai.selectOci': 'Selecione uma credencial OCI',
|
||||
'genai.fillOcid': 'Informe o Model OCID para modelo personalizado',
|
||||
|
||||
// ── MCP Servers ──
|
||||
'mcp.title': 'MCP Servers',
|
||||
'mcp.subtitle': 'Servidores MCP para integracao com ferramentas e execucao de tarefas',
|
||||
'mcp.registered': 'Servidores Registrados',
|
||||
'mcp.noServers': 'Nenhum MCP server registrado.',
|
||||
'mcp.edit': 'Editar',
|
||||
'mcp.activate': 'Ativar',
|
||||
'mcp.deactivate': 'Desativar',
|
||||
'mcp.active': 'Ativo',
|
||||
'mcp.inactive': 'Inativo',
|
||||
'mcp.delete': 'Excluir',
|
||||
'mcp.yes': 'Sim',
|
||||
'mcp.no': 'Nao',
|
||||
'mcp.command': 'Comando:',
|
||||
'mcp.url': 'URL:',
|
||||
'mcp.module': 'Modulo:',
|
||||
'mcp.adb': 'ADB:',
|
||||
'mcp.noAdb': 'Nenhum',
|
||||
'mcp.tools': 'Tools',
|
||||
'mcp.noTools': 'Nenhuma tool registrada',
|
||||
'mcp.discoverTools': 'Descobrir Tools',
|
||||
'mcp.discovering': 'Conectando ao MCP server para descobrir tools...',
|
||||
'mcp.newServer': 'Registrar Novo Server',
|
||||
'mcp.editServer': 'Editar Server',
|
||||
'mcp.name': 'Nome',
|
||||
'mcp.description': 'Descricao',
|
||||
'mcp.serverType': 'Tipo de Servidor',
|
||||
'mcp.commandLabel': 'Comando',
|
||||
'mcp.commandHint': 'Comando para iniciar o servidor',
|
||||
'mcp.argsLabel': 'Argumentos (JSON)',
|
||||
'mcp.argsHint': 'Array de argumentos passados ao comando',
|
||||
'mcp.urlLabel': 'URL do Servidor',
|
||||
'mcp.urlHint': 'Endpoint SSE do servidor MCP',
|
||||
'mcp.linkAdb': 'Vincular ADB Vector',
|
||||
'mcp.linkAdbHint': 'Opcional — o MCP server podera usar este banco como ferramenta.',
|
||||
'mcp.saveServer': 'Registrar Server',
|
||||
'mcp.saveChanges': 'Salvar Alteracoes',
|
||||
'mcp.cancel': 'Cancelar',
|
||||
'mcp.saving': 'Registrando servidor...',
|
||||
'mcp.updating': 'Atualizando servidor...',
|
||||
'mcp.fillName': 'Preencha o nome do server',
|
||||
'mcp.invalidArgs': 'Argumentos deve ser um JSON array valido',
|
||||
'mcp.configDesc': 'Configure um novo servidor MCP. Os campos se ajustam ao tipo selecionado.',
|
||||
|
||||
// ── ADB Config ──
|
||||
'adb.title': 'ADB Connections',
|
||||
'adb.subtitle': 'Conexoes com Oracle Autonomous Database via Wallet (mTLS)',
|
||||
'adb.registered': 'Conexoes Registradas',
|
||||
'adb.connection': 'conexao',
|
||||
'adb.connections': 'conexoes',
|
||||
'adb.noConnections': 'Nenhuma conexao ADB registrada.',
|
||||
'adb.edit': 'Editar',
|
||||
'adb.test': 'Testar',
|
||||
'adb.delete': 'Excluir',
|
||||
'adb.yes': 'Sim',
|
||||
'adb.no': 'Nao',
|
||||
'adb.newConnection': 'Nova Conexao',
|
||||
'adb.editConnection': 'Editar Conexao',
|
||||
'adb.connName': 'Nome da Conexao',
|
||||
'adb.walletZip': 'Wallet ZIP',
|
||||
'adb.walletHint': 'Envie o wallet para extrair os DSNs do tnsnames.ora',
|
||||
'adb.walletEditHint': 'Envie novo wallet ou deixe vazio para manter o atual',
|
||||
'adb.analyze': 'Analisar',
|
||||
'adb.dsn': 'DSN (TNS Name)',
|
||||
'adb.dsnHint': 'Selecione ou digite o DSN. Ex: myatp_high',
|
||||
'adb.username': 'Username',
|
||||
'adb.password': 'Password',
|
||||
'adb.walletPassword': 'Wallet Password',
|
||||
'adb.genaiConfig': 'Config GenAI (Embeddings)',
|
||||
'adb.genaiConfigHint': 'Credenciais OCI usadas para gerar embeddings via GenAI.',
|
||||
'adb.noRag': 'Nenhum (sem RAG)',
|
||||
'adb.embeddingModel': 'Modelo de Embedding',
|
||||
'adb.embeddingModelHint': 'Modelo OCI GenAI para gerar vetores.',
|
||||
'adb.saveConnection': 'Salvar Conexao',
|
||||
'adb.saveChanges': 'Salvar Alteracoes',
|
||||
'adb.cancel': 'Cancelar',
|
||||
'adb.saving': 'Salvando conexao...',
|
||||
'adb.deleting': 'Excluindo conexao...',
|
||||
'adb.updateWallet': 'Atualizar Wallet',
|
||||
'adb.adbConnection': 'Conexao ADB',
|
||||
'adb.uploadWallet': 'Upload Wallet',
|
||||
'adb.connDesc': 'Conexao via python-oracledb Thin mode com Wallet (mTLS) para Oracle Autonomous Database.',
|
||||
'adb.selectWallet': 'Selecione um arquivo wallet ZIP.',
|
||||
'adb.selectConnection': 'Selecione uma conexao ADB.',
|
||||
'adb.fillName': 'Preencha o nome da conexao',
|
||||
'adb.fillDsn': 'Preencha o DSN',
|
||||
'adb.fillUsername': 'Preencha o username',
|
||||
'adb.fillPassword': 'Preencha a senha',
|
||||
|
||||
// ── Embeddings ──
|
||||
'emb.title': 'Embeddings',
|
||||
'emb.subtitle': 'Base de conhecimento vetorial — upload, importacao e gerenciamento',
|
||||
'emb.noAdb': 'Nenhuma conexao ADB configurada.',
|
||||
'emb.noAdbHint': 'Configure em ADB Vector.',
|
||||
'emb.noOci': 'Nenhuma credencial OCI configurada.',
|
||||
'emb.noOciHint': 'Configure em Config → OCI.',
|
||||
'emb.existing': 'Embeddings Existentes',
|
||||
'emb.existingDesc': 'Consulte os documentos armazenados nas tabelas de embeddings do ADB.',
|
||||
'emb.adbConnection': 'Conexao ADB',
|
||||
'emb.table': 'Tabela',
|
||||
'emb.load': 'Carregar',
|
||||
'emb.noEmbeddings': 'Nenhum embedding encontrado.',
|
||||
'emb.total': 'Total: {0} documentos',
|
||||
'emb.cisTitle': 'CIS Recommendations',
|
||||
'emb.cisDesc': 'Envie a versao mais recente do PDF para manter as recomendacoes CIS Oracle Cloud atualizadas na base vetorial.',
|
||||
'emb.pdfFile': 'Arquivo PDF',
|
||||
'emb.uploadCis': 'Upload CIS Recommendations',
|
||||
'emb.kbTitle': 'Base de Conhecimento',
|
||||
'emb.kbDesc': 'Envie documentos para alimentar a base de conhecimento da equipe. Os arquivos serao vetorizados e disponibilizados para consulta via chat.',
|
||||
'emb.fileLabel': 'Arquivo (.txt, .pdf, .csv, .json, .md)',
|
||||
'emb.uploadFile': 'Upload Arquivo',
|
||||
'emb.urlLabel': 'URL (pagina web ou PDF)',
|
||||
'emb.importUrl': 'Importar URL',
|
||||
'emb.purgeTitle': 'Purge & Re-embed',
|
||||
'emb.purgeDesc': 'Limpe os embeddings de uma tabela para liberar espaco ou preparar para re-ingestao. Esta acao e irreversivel.',
|
||||
'emb.tenancyOptional': 'Tenancy (opcional)',
|
||||
'emb.purge': 'Purge',
|
||||
'emb.confirm': 'Confirmar',
|
||||
'emb.selectTable': 'Selecione uma tabela.',
|
||||
'emb.selectAdbAndFile': 'Selecione conexao ADB e arquivo.',
|
||||
'emb.selectAdbAndUrl': 'Selecione conexao ADB e informe a URL.',
|
||||
'emb.processing': 'Processando PDF e gerando embeddings...',
|
||||
'emb.uploading': 'Enviando e gerando embeddings...',
|
||||
'emb.fetchingUrl': 'Buscando conteudo e gerando embeddings...',
|
||||
'emb.purging': 'Limpando embeddings...',
|
||||
|
||||
// ── EmbConsult ──
|
||||
'ec.title': 'Consultar Embeddings',
|
||||
'ec.subtitle': 'Pergunte sobre os dados armazenados nas bases vetoriais',
|
||||
'ec.clear': 'Limpar',
|
||||
'ec.tableOptional': 'Tabela (opcional)',
|
||||
'ec.allTables': 'Todas as tabelas',
|
||||
'ec.emptyTitle': 'Faca uma consulta para pesquisar nos embeddings.',
|
||||
'ec.emptyHint': 'Ex: "findings para CIS 1.1", "recomendacoes de networking", "como corrigir MFA"',
|
||||
'ec.searching': 'Consultando bases vetoriais...',
|
||||
'ec.placeholder': 'Digite sua consulta...',
|
||||
'ec.submit': 'Consultar',
|
||||
'ec.sources': 'Fontes consultadas',
|
||||
'ec.noAnswer': 'Sem resposta do modelo.',
|
||||
|
||||
// ── Users ──
|
||||
'usr.title': 'Usuarios',
|
||||
'usr.activeCount': 'ativo',
|
||||
'usr.activesCount': 'ativos',
|
||||
'usr.inactiveCount': 'inativo',
|
||||
'usr.inactivesCount': 'inativos',
|
||||
'usr.total': 'total',
|
||||
'usr.newUser': 'Novo Usuario',
|
||||
'usr.cancel': 'Cancelar',
|
||||
'usr.createUser': 'Criar Novo Usuario',
|
||||
'usr.firstName': 'Nome',
|
||||
'usr.lastName': 'Sobrenome',
|
||||
'usr.username': 'Usuario (login)',
|
||||
'usr.email': 'Email',
|
||||
'usr.password': 'Senha',
|
||||
'usr.role': 'Role',
|
||||
'usr.creating': 'Criando...',
|
||||
'usr.create': 'Criar Usuario',
|
||||
'usr.noUsers': 'Nenhum usuario cadastrado',
|
||||
'usr.noUsersHint': 'Clique em "Novo Usuario" para criar o primeiro usuario.',
|
||||
'usr.name': 'Nome',
|
||||
'usr.mfa': 'MFA',
|
||||
'usr.status': 'Status',
|
||||
'usr.lastLogin': 'Ultimo Login',
|
||||
'usr.actions': 'Acoes',
|
||||
'usr.active': 'Ativo',
|
||||
'usr.inactive': 'Inativo',
|
||||
'usr.never': 'Nunca',
|
||||
'usr.confirm': 'Confirmar',
|
||||
'usr.deactivate': 'Desativar usuario',
|
||||
'usr.created': 'Usuario criado com sucesso!',
|
||||
'usr.roleUpdated': 'Role atualizada',
|
||||
'usr.deactivated': 'Usuario desativado',
|
||||
'usr.requiredName': 'Nome e sobrenome sao obrigatorios',
|
||||
'usr.requiredUsername': 'Usuario (login) e obrigatorio',
|
||||
'usr.requiredPassword': 'Senha e obrigatoria',
|
||||
|
||||
// ── MFA ──
|
||||
'mfa.title': 'Autenticacao MFA (TOTP)',
|
||||
'mfa.subtitle': 'Proteja sua conta com autenticacao em dois fatores via Google Authenticator ou Authy.',
|
||||
'mfa.enabled': 'MFA esta ativado',
|
||||
'mfa.disabled': 'MFA esta desativado',
|
||||
'mfa.enabledDesc': 'Sua conta esta protegida com autenticacao de dois fatores.',
|
||||
'mfa.disabledDesc': 'Ative o MFA para adicionar uma camada extra de seguranca.',
|
||||
'mfa.disableHint': 'Para desativar o MFA, clique no botao abaixo. Isso reduzira a seguranca da sua conta.',
|
||||
'mfa.disableBtn': 'Desativar MFA',
|
||||
'mfa.confirmDisable': 'Deseja realmente desativar o MFA da sua conta?',
|
||||
'mfa.scanQr': 'Escaneie o QR Code ou copie o secret',
|
||||
'mfa.openApp': 'Abra seu app autenticador (Google Authenticator, Authy, etc.)',
|
||||
'mfa.enterCode': 'Insira o codigo gerado pelo app',
|
||||
'mfa.activateMfa': 'Ativar MFA',
|
||||
'mfa.generateSecret': 'Gerar Secret',
|
||||
'mfa.generateHint': 'Clique no botao abaixo para gerar um secret TOTP. Voce precisara de um app autenticador para escanear o QR Code.',
|
||||
'mfa.activated': 'MFA ativado com sucesso!',
|
||||
'mfa.deactivated': 'MFA desativado.',
|
||||
'mfa.invalidCode': 'Informe um codigo de 6 digitos',
|
||||
|
||||
// ── Audit ──
|
||||
'audit.title': 'Log de Auditoria',
|
||||
'audit.subtitle': 'Monitoramento de atividades do sistema, configuracoes e sessoes de chat.',
|
||||
'audit.tabAudit': 'Audit Log',
|
||||
'audit.tabConfig': 'Config Logs',
|
||||
'audit.tabChat': 'Chat Logs',
|
||||
'audit.records': 'registros',
|
||||
'audit.refresh': 'Atualizar',
|
||||
'audit.all': 'Todos',
|
||||
'audit.errors': 'Erros',
|
||||
'audit.success': 'Sucesso',
|
||||
'audit.info': 'Info',
|
||||
'audit.loadingLogs': 'Carregando logs...',
|
||||
'audit.noAudit': 'Nenhum registro de auditoria.',
|
||||
'audit.noConfig': 'Nenhum log de configuracao registrado.',
|
||||
'audit.noChat': 'Nenhum log de chat registrado.',
|
||||
'audit.date': 'Data',
|
||||
'audit.user': 'Usuario',
|
||||
'audit.action': 'Acao',
|
||||
'audit.resource': 'Recurso',
|
||||
'audit.details': 'Detalhes',
|
||||
'audit.ip': 'IP',
|
||||
'audit.config': 'Config',
|
||||
'audit.status': 'Status',
|
||||
'audit.message': 'Mensagem',
|
||||
'audit.session': 'Sessao',
|
||||
'audit.source': 'Origem',
|
||||
|
||||
// ── Common ──
|
||||
'common.save': 'Salvar',
|
||||
'common.cancel': 'Cancelar',
|
||||
'common.delete': 'Excluir',
|
||||
'common.edit': 'Editar',
|
||||
'common.create': 'Criar',
|
||||
'common.update': 'Atualizar',
|
||||
'common.close': 'Fechar',
|
||||
'common.confirm': 'Confirmar',
|
||||
'common.back': 'Voltar',
|
||||
'common.search': 'Buscar',
|
||||
'common.refresh': 'Atualizar',
|
||||
'common.download': 'Download',
|
||||
'common.upload': 'Upload',
|
||||
'common.copy': 'Copiar',
|
||||
'common.retry': 'Tentar novamente',
|
||||
'common.send': 'Enviar',
|
||||
'common.clear': 'Limpar',
|
||||
'common.test': 'Testar',
|
||||
'common.yes': 'Sim',
|
||||
'common.no': 'Nao',
|
||||
'common.loading': 'Carregando...',
|
||||
'common.saving': 'Salvando...',
|
||||
'common.select': 'Selecione...',
|
||||
'common.noData': 'Nenhum dado encontrado',
|
||||
'common.error': 'Erro',
|
||||
'common.success': 'Sucesso',
|
||||
};
|
||||
|
||||
export default pt;
|
||||
437
frontend-react/src/index.css
Normal file
437
frontend-react/src/index.css
Normal file
@@ -0,0 +1,437 @@
|
||||
@import 'tailwindcss/theme' layer(theme);
|
||||
@import 'tailwindcss/preflight' layer(base);
|
||||
@import 'tailwindcss/utilities' layer(utilities);
|
||||
|
||||
/* ── Oracle Dark Premium Theme (in theme layer) ── */
|
||||
@layer theme {
|
||||
:root, html.light {
|
||||
--bg: #f4f2ef; --bg1: #ffffff; --bg2: #faf9f7; --bg3: #efeee9; --bg4: #e5e3dd;
|
||||
--bd: #ddd9d2; --bdf: #c5c0b8;
|
||||
--t1: #1c1917; --t2: #44403c; --t3: #78716c; --t4: #a8a29e;
|
||||
--ac: #c74634; --ach: #b13d2e; --acd: #9a3427; --acl: rgba(199,70,52,.06); --acl2: rgba(199,70,52,.1);
|
||||
--gn: #16a34a; --gnl: rgba(22,163,74,.08); --rd: #dc2626; --rdl: rgba(220,38,38,.06);
|
||||
--bl: #2563eb; --bll: rgba(37,99,235,.06); --yl: #ca8a04; --yll: rgba(202,138,4,.06);
|
||||
--pp: #7c3aed; --ppl: rgba(124,58,237,.06);
|
||||
--ok: #16a34a; --w: #ca8a04; --err: #dc2626;
|
||||
--r: 12px; --rl: 16px; --rr: 20px;
|
||||
--sh1: 0 1px 2px rgba(28,25,23,.04), 0 1px 3px rgba(28,25,23,.06);
|
||||
--sh2: 0 4px 6px -1px rgba(28,25,23,.05), 0 2px 4px -2px rgba(28,25,23,.04);
|
||||
--sh3: 0 10px 15px -3px rgba(28,25,23,.06), 0 4px 6px -4px rgba(28,25,23,.04);
|
||||
--shh: 0 20px 25px -5px rgba(28,25,23,.08), 0 8px 10px -6px rgba(28,25,23,.04);
|
||||
--tb-bg: rgba(255,255,255,.8);
|
||||
--fm: 'JetBrains Mono', monospace;
|
||||
--fs: 'Plus Jakarta Sans', -apple-system, system-ui, sans-serif;
|
||||
--trans: cubic-bezier(.22,1,.36,1);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--bg: #0d0f14; --bg1: #151820; --bg2: #1a1e28; --bg3: #222733; --bg4: #2a3040;
|
||||
--bd: #2a3040; --bdf: #3a4258;
|
||||
--t1: #f0f0f4; --t2: #c8cad0; --t3: #8b8fa3; --t4: #5a5e6e;
|
||||
--ac: #c74634; --ach: #d9523f; --acd: #a83428; --acl: rgba(199,70,52,.1); --acl2: rgba(199,70,52,.16);
|
||||
--gn: #34c759; --gnl: rgba(52,199,89,.1); --rd: #ff453a; --rdl: rgba(255,69,58,.1);
|
||||
--bl: #0a84ff; --bll: rgba(10,132,255,.1); --yl: #ffd60a; --yll: rgba(255,214,10,.1);
|
||||
--pp: #bf5af2; --ppl: rgba(191,90,242,.1);
|
||||
--ok: #34c759; --w: #ffd60a; --err: #ff453a;
|
||||
--sh1: 0 1px 3px rgba(0,0,0,.3), 0 1px 2px rgba(0,0,0,.2);
|
||||
--sh2: 0 4px 8px rgba(0,0,0,.3), 0 2px 4px rgba(0,0,0,.2);
|
||||
--sh3: 0 10px 20px rgba(0,0,0,.35), 0 4px 8px rgba(0,0,0,.2);
|
||||
--shh: 0 20px 30px rgba(0,0,0,.4), 0 8px 12px rgba(0,0,0,.25);
|
||||
--tb-bg: rgba(21,24,32,.85);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Base resets (in base layer) ── */
|
||||
@layer base {
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: var(--fs);
|
||||
background: var(--bg);
|
||||
color: var(--t1);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
::selection { background: rgba(199,70,52,.2); color: var(--t1); }
|
||||
|
||||
html.light *, html.dark * {
|
||||
transition: background-color .25s var(--trans), border-color .25s var(--trans), color .15s var(--trans);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--bd); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--bdf); }
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
select {
|
||||
appearance: none;
|
||||
background: var(--bg1) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2378716c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E") no-repeat right 12px center;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
padding: 9px 36px 9px 14px;
|
||||
font-size: .82rem;
|
||||
font-family: var(--fs);
|
||||
color: var(--t1);
|
||||
cursor: pointer;
|
||||
transition: border-color .2s, box-shadow .2s;
|
||||
min-width: 0;
|
||||
}
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--ac);
|
||||
box-shadow: 0 0 0 3px var(--acl);
|
||||
}
|
||||
select:hover { border-color: var(--bdf); }
|
||||
|
||||
input[type="text"], input[type="email"], input[type="password"], input[type="number"], input[type="search"],
|
||||
textarea {
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
padding: 9px 14px;
|
||||
font-size: .82rem;
|
||||
font-family: var(--fs);
|
||||
color: var(--t1);
|
||||
transition: border-color .2s, box-shadow .2s;
|
||||
width: 100%;
|
||||
}
|
||||
input[type="text"]:focus, input[type="email"]:focus, input[type="password"]:focus,
|
||||
input[type="number"]:focus, input[type="search"]:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--ac);
|
||||
box-shadow: 0 0 0 3px var(--acl);
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--bd);
|
||||
border-radius: 5px;
|
||||
background: var(--bg1);
|
||||
cursor: pointer;
|
||||
transition: all .15s;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
input[type="checkbox"]:checked {
|
||||
background: var(--ac);
|
||||
border-color: var(--ac);
|
||||
}
|
||||
input[type="checkbox"]:checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 6px;
|
||||
height: 10px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Component styles — OUTSIDE layers so they override Tailwind utilities ── */
|
||||
|
||||
/* Nav item hover */
|
||||
nav a:hover { background: var(--bg3); color: var(--t1) !important; }
|
||||
|
||||
/* Page wrapper */
|
||||
.page {
|
||||
padding: 28px 32px;
|
||||
max-width: 1280px;
|
||||
animation: fadeIn .35s ease;
|
||||
}
|
||||
.page-full {
|
||||
height: 100%;
|
||||
animation: fadeIn .35s ease;
|
||||
}
|
||||
|
||||
/* Page header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.page-header h1 {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--t1);
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
.page-header .subtitle {
|
||||
font-size: .8rem;
|
||||
color: var(--t3);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.page-header .spacer { flex: 1; }
|
||||
.page-header .count {
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
color: var(--t3);
|
||||
background: var(--bg3);
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--rl);
|
||||
box-shadow: var(--sh1);
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--bd);
|
||||
}
|
||||
.card-header h2 {
|
||||
font-size: .92rem;
|
||||
font-weight: 700;
|
||||
color: var(--t1);
|
||||
}
|
||||
.card-header .icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: .82rem;
|
||||
}
|
||||
.data-table thead th {
|
||||
text-align: left;
|
||||
font-size: .7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
color: var(--t3);
|
||||
padding: 10px 14px;
|
||||
background: var(--bg2);
|
||||
border-bottom: 1px solid var(--bd);
|
||||
}
|
||||
.data-table thead th:first-child { border-radius: var(--r) 0 0 0; }
|
||||
.data-table thead th:last-child { border-radius: 0 var(--r) 0 0; }
|
||||
.data-table tbody td {
|
||||
padding: 12px 14px;
|
||||
color: var(--t2);
|
||||
border-bottom: 1px solid var(--bg3);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.data-table tbody tr:last-child td { border-bottom: none; }
|
||||
.data-table tbody tr:hover td { background: var(--bg2); }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 18px;
|
||||
border-radius: var(--r);
|
||||
font-size: .82rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--fs);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all .2s var(--trans);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--ac);
|
||||
color: #fff;
|
||||
border-color: var(--ac);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { background: var(--ach); }
|
||||
.btn-secondary {
|
||||
background: var(--bg1);
|
||||
color: var(--t2);
|
||||
border-color: var(--bd);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) { background: var(--bg3); color: var(--t1); }
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
color: var(--rd);
|
||||
border-color: var(--rd);
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) { background: var(--rdl); }
|
||||
.btn-success {
|
||||
background: var(--gn);
|
||||
color: #fff;
|
||||
border-color: var(--gn);
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: .75rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: .68rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-green { background: var(--gnl); color: var(--gn); }
|
||||
.badge-red { background: var(--rdl); color: var(--rd); }
|
||||
.badge-blue { background: var(--bll); color: var(--bl); }
|
||||
.badge-yellow { background: var(--yll); color: var(--yl); }
|
||||
.badge-purple { background: var(--ppl); color: var(--pp); }
|
||||
.badge-gray { background: var(--bg3); color: var(--t3); }
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: var(--t4);
|
||||
}
|
||||
.empty-state svg { opacity: .35; margin-bottom: 12px; }
|
||||
.empty-state h3 { font-size: .92rem; font-weight: 600; color: var(--t3); margin-bottom: 4px; }
|
||||
.empty-state p { font-size: .78rem; }
|
||||
|
||||
/* KPI cards */
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.kpi-card {
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--rl);
|
||||
padding: 18px 20px;
|
||||
box-shadow: var(--sh1);
|
||||
}
|
||||
.kpi-card .label {
|
||||
font-size: .7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
color: var(--t3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.kpi-card .value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--t1);
|
||||
letter-spacing: -.02em;
|
||||
}
|
||||
.kpi-card .sub {
|
||||
font-size: .72rem;
|
||||
color: var(--t4);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Form groups */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
color: var(--t2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Tags/pills */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: .72rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg3);
|
||||
color: var(--t2);
|
||||
border: 1px solid var(--bd);
|
||||
}
|
||||
.tag button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--t3);
|
||||
padding: 0;
|
||||
font-size: .82rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.tag button:hover { color: var(--rd); }
|
||||
|
||||
/* Section divider */
|
||||
.section-title {
|
||||
font-size: .72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
color: var(--t4);
|
||||
margin: 24px 0 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--bg3);
|
||||
}
|
||||
|
||||
/* Prevent text overflow on resize */
|
||||
.prose pre, .chat-markdown pre {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
.prose table, .chat-markdown table {
|
||||
overflow-x: auto;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
.prose code, .chat-markdown code {
|
||||
word-break: break-all;
|
||||
}
|
||||
.prose p, .chat-markdown p {
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Tooltip-like labels */
|
||||
.field-label {
|
||||
font-size: .7rem;
|
||||
font-weight: 600;
|
||||
color: var(--t3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
13
frontend-react/src/main.tsx
Normal file
13
frontend-react/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter basename="/app">
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
1138
frontend-react/src/pages/ChatPage.tsx
Normal file
1138
frontend-react/src/pages/ChatPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
453
frontend-react/src/pages/DownloadsPage.tsx
Normal file
453
frontend-react/src/pages/DownloadsPage.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
FolderDown, ChevronRight, RefreshCw, Download, FileText, Lock, Globe,
|
||||
Server, Activity, Database, Package, BarChart3, AlertCircle, Loader2, Filter,
|
||||
} from 'lucide-react';
|
||||
import client from '@/api/client';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
interface Report {
|
||||
id: string;
|
||||
tenancy_name: string;
|
||||
status: string;
|
||||
level: number | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
interface ReportFile {
|
||||
id: string;
|
||||
file_name: string;
|
||||
file_type: string;
|
||||
file_category: string;
|
||||
file_size: number;
|
||||
}
|
||||
|
||||
/* ── Helpers ── */
|
||||
|
||||
function extractSection(name: string): string {
|
||||
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';
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function fileExt(name: string): string {
|
||||
return (name.split('.').pop() || '').toLowerCase();
|
||||
}
|
||||
|
||||
const extColors: Record<string, string> = {
|
||||
html: '#e44d26',
|
||||
csv: '#217346',
|
||||
json: '#f5a623',
|
||||
xlsx: '#217346',
|
||||
txt: '#6b7280',
|
||||
pdf: '#dc2626',
|
||||
};
|
||||
|
||||
const sectionIcons: Record<string, typeof Lock> = {
|
||||
'Identity and Access Management': Lock,
|
||||
'Networking': Globe,
|
||||
'Compute': Server,
|
||||
'Logging and Monitoring': Activity,
|
||||
'Storage Object Storage': Database,
|
||||
'Storage Block Volumes': Database,
|
||||
'Storage File Storage Service': Database,
|
||||
'Asset Management': Package,
|
||||
'Summary': BarChart3,
|
||||
'Error': AlertCircle,
|
||||
'Other': FileText,
|
||||
};
|
||||
|
||||
function sectionSort(a: string, b: string): number {
|
||||
if (a === 'Summary') return -1;
|
||||
if (b === 'Summary') return 1;
|
||||
if (a === 'Other') return 1;
|
||||
if (b === 'Other') return -1;
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
function groupBySection(files: ReportFile[]): Record<string, ReportFile[]> {
|
||||
const map: Record<string, ReportFile[]> = {};
|
||||
for (const f of files) {
|
||||
const sec = extractSection(f.file_name);
|
||||
(map[sec] ||= []).push(f);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/* ── Component ── */
|
||||
|
||||
export default function DownloadsPage() {
|
||||
const { t } = useI18n();
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [expandedRid, setExpandedRid] = useState<string | null>(null);
|
||||
const [filesCache, setFilesCache] = useState<Record<string, ReportFile[]>>({});
|
||||
const [loadingFiles, setLoadingFiles] = useState<string | null>(null);
|
||||
const [tenancyFilter, setTenancyFilter] = useState('');
|
||||
const [sectionFilter, setSectionFilter] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const token = localStorage.getItem('t') || '';
|
||||
|
||||
const loadReports = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await client.get('/reports') as unknown as Report[];
|
||||
setReports(data.filter((r) => r.status === 'completed'));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('dl.loadError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadReports(); }, [loadReports]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setFilesCache({});
|
||||
setExpandedRid(null);
|
||||
setSectionFilter('');
|
||||
loadReports();
|
||||
}, [loadReports]);
|
||||
|
||||
const toggleExpand = useCallback(async (rid: string) => {
|
||||
if (expandedRid === rid) {
|
||||
setExpandedRid(null);
|
||||
return;
|
||||
}
|
||||
setExpandedRid(rid);
|
||||
if (!filesCache[rid]) {
|
||||
setLoadingFiles(rid);
|
||||
try {
|
||||
const files = await client.get(`/reports/${rid}/files`) as unknown as ReportFile[];
|
||||
setFilesCache((prev) => ({ ...prev, [rid]: files }));
|
||||
} catch {
|
||||
setFilesCache((prev) => ({ ...prev, [rid]: [] }));
|
||||
} finally {
|
||||
setLoadingFiles(null);
|
||||
}
|
||||
}
|
||||
}, [expandedRid, filesCache]);
|
||||
|
||||
const handleDownload = useCallback((rid: string, fid: string) => {
|
||||
window.open(`/api/reports/${rid}/files/${fid}/download?token=${token}`, '_blank');
|
||||
}, [token]);
|
||||
|
||||
/* Derived data */
|
||||
const tenancies = [...new Set(reports.map((r) => r.tenancy_name))].sort();
|
||||
const filtered = tenancyFilter ? reports.filter((r) => r.tenancy_name === tenancyFilter) : reports;
|
||||
|
||||
/* Sections available in expanded report (for filter) */
|
||||
const expandedFiles = expandedRid ? filesCache[expandedRid] : null;
|
||||
const expandedSections = expandedFiles
|
||||
? [...new Set(expandedFiles.map((f) => extractSection(f.file_name)))].sort(sectionSort)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--bg3)' }}>
|
||||
<FolderDown size={24} style={{ color: 'var(--t3)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('dl.title')}</h1>
|
||||
<div className="subtitle">{t('dl.subtitle')}</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<span className="count">{filtered.length} {t('dl.reports')}</span>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="card" style={{ padding: '12px 20px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Tenancy filter */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Filter size={13} style={{ color: 'var(--t4)' }} />
|
||||
<select
|
||||
value={tenancyFilter}
|
||||
onChange={(e) => { setTenancyFilter(e.target.value); setExpandedRid(null); setSectionFilter(''); }}
|
||||
className="text-xs py-1.5 px-2.5 rounded-lg outline-none cursor-pointer"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 180 }}
|
||||
>
|
||||
<option value="">{t('dl.allTenancies')} ({reports.length})</option>
|
||||
{tenancies.map((tn) => (
|
||||
<option key={tn} value={tn}>
|
||||
{tn} ({reports.filter((r) => r.tenancy_name === tn).length})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Section filter (only when expanded) */}
|
||||
{expandedRid && expandedSections.length > 1 && (
|
||||
<select
|
||||
value={sectionFilter}
|
||||
onChange={(e) => setSectionFilter(e.target.value)}
|
||||
className="text-xs py-1.5 px-2.5 rounded-lg outline-none cursor-pointer"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 160 }}
|
||||
>
|
||||
<option value="">{t('dl.allSections')}</option>
|
||||
{expandedSections.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<span className="text-[.7rem]" style={{ color: 'var(--t4)' }}>
|
||||
{filtered.length} {t('dl.reports')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
||||
{t('dl.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg mb-4 text-xs font-medium"
|
||||
style={{ background: 'var(--rdl)', color: 'var(--rd)' }}
|
||||
>
|
||||
<AlertCircle size={14} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && !reports.length && (
|
||||
<div className="flex items-center justify-center gap-2 py-16" style={{ color: 'var(--t4)' }}>
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
<span className="text-sm">{t('dl.loading')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && !filtered.length && (
|
||||
<div className="card">
|
||||
<div className="empty-state">
|
||||
<FolderDown size={40} />
|
||||
<h3>{t('dl.noReports')}</h3>
|
||||
<p>{t('dl.noReportsHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Report list */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{filtered.map((report) => {
|
||||
const isExpanded = expandedRid === report.id;
|
||||
const files = filesCache[report.id];
|
||||
const isLoading = loadingFiles === report.id;
|
||||
const fileCount = files ? files.length : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={report.id}
|
||||
className="card"
|
||||
style={{
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
borderColor: isExpanded ? 'color-mix(in srgb, var(--ac) 30%, var(--bd))' : undefined,
|
||||
boxShadow: isExpanded ? 'var(--sh2)' : undefined,
|
||||
}}
|
||||
>
|
||||
{/* Report header */}
|
||||
<button
|
||||
onClick={() => toggleExpand(report.id)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-left transition-colors hover:bg-[var(--bg2)]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="transition-transform duration-200 flex-shrink-0"
|
||||
style={{
|
||||
color: 'var(--ac)',
|
||||
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-[.82rem] font-semibold" style={{ color: 'var(--t1)' }}>
|
||||
{report.tenancy_name}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>
|
||||
Level {report.level || 2}
|
||||
</span>
|
||||
<span className="text-[.5rem]" style={{ color: 'var(--t4)' }}>|</span>
|
||||
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>
|
||||
{report.created_at}
|
||||
</span>
|
||||
{fileCount != null && (
|
||||
<>
|
||||
<span className="text-[.5rem]" style={{ color: 'var(--t4)' }}>|</span>
|
||||
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>
|
||||
{fileCount} {t('dl.files')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[.68rem] flex-shrink-0" style={{ color: 'var(--t4)' }}>
|
||||
{isExpanded ? t('dl.hide') : t('dl.viewFiles')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded file list */}
|
||||
{isExpanded && (
|
||||
<div
|
||||
className="px-4 py-4"
|
||||
style={{ borderTop: '1px solid var(--bd)', background: 'var(--bg2)' }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6" style={{ color: 'var(--t4)' }}>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span className="text-xs">{t('dl.loadingFiles')}</span>
|
||||
</div>
|
||||
) : !files || !files.length ? (
|
||||
<p className="text-xs text-center py-6" style={{ color: 'var(--t4)' }}>
|
||||
{t('dl.noFiles')}
|
||||
</p>
|
||||
) : (
|
||||
<FileListGrouped
|
||||
files={files}
|
||||
reportId={report.id}
|
||||
sectionFilter={sectionFilter}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── FileListGrouped sub-component ── */
|
||||
|
||||
function FileListGrouped({
|
||||
files,
|
||||
reportId,
|
||||
sectionFilter,
|
||||
onDownload,
|
||||
}: {
|
||||
files: ReportFile[];
|
||||
reportId: string;
|
||||
sectionFilter: string;
|
||||
onDownload: (rid: string, fid: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const grouped = groupBySection(files);
|
||||
let sections = Object.keys(grouped).sort(sectionSort);
|
||||
if (sectionFilter) sections = sections.filter((s) => s === sectionFilter);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[.72rem]" style={{ color: 'var(--t3)' }}>
|
||||
{files.length} {t('dl.filesInSections').replace('{0}', String(Object.keys(grouped).length))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sections.map((sec) => {
|
||||
const secFiles = grouped[sec];
|
||||
const Icon = sectionIcons[sec] || FileText;
|
||||
return (
|
||||
<div key={sec} className="mb-3">
|
||||
{/* Section header */}
|
||||
<div
|
||||
className="flex items-center gap-2 mb-2 pb-1.5"
|
||||
style={{ borderBottom: '1px solid var(--bd)' }}
|
||||
>
|
||||
<Icon size={13} style={{ color: 'var(--t3)', opacity: 0.7 }} />
|
||||
<span className="text-[.72rem] font-bold" style={{ color: 'var(--t1)' }}>{sec}</span>
|
||||
<span className="text-[.6rem]" style={{ color: 'var(--t4)' }}>({secFiles.length})</span>
|
||||
</div>
|
||||
|
||||
{/* File grid */}
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(230px, 1fr))' }}>
|
||||
{secFiles.map((f) => {
|
||||
const ext = fileExt(f.file_name);
|
||||
const color = extColors[ext] || 'var(--ac)';
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => onDownload(reportId, f.id)}
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-left transition-all group"
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--bd)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = color;
|
||||
e.currentTarget.style.boxShadow = `0 2px 8px ${color}18`;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--bd)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
{/* File type badge */}
|
||||
<div
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: `${color}14` }}
|
||||
>
|
||||
<span
|
||||
className="text-[.58rem] font-bold uppercase tracking-wide"
|
||||
style={{ color }}
|
||||
>
|
||||
{ext}
|
||||
</span>
|
||||
</div>
|
||||
{/* File info */}
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div
|
||||
className="text-[.7rem] font-medium truncate"
|
||||
style={{ color: 'var(--t1)' }}
|
||||
title={f.file_name}
|
||||
>
|
||||
{f.file_name}
|
||||
</div>
|
||||
<div className="text-[.58rem] mt-0.5" style={{ color: 'var(--t4)' }}>
|
||||
{formatSize(f.file_size)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Download icon */}
|
||||
<Download
|
||||
size={12}
|
||||
className="flex-shrink-0 opacity-30 group-hover:opacity-70 transition-opacity"
|
||||
style={{ color: 'var(--t3)' }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1593
frontend-react/src/pages/ExplorerPage.tsx
Normal file
1593
frontend-react/src/pages/ExplorerPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
128
frontend-react/src/pages/LoginPage.tsx
Normal file
128
frontend-react/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const { login, mfaRequired, mfaSetup, totpUri, user } = useAuthStore();
|
||||
const { loadData } = useAppStore();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [totp, setTotp] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (user) {
|
||||
navigate('/chat', { replace: true });
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await login(username, password, totp || undefined);
|
||||
if (!res.mfa_required && res.token) {
|
||||
await loadData();
|
||||
navigate('/chat', { replace: true });
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('login.error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen" style={{ background: 'var(--bg)' }}>
|
||||
<div className="w-[380px] p-8 rounded-2xl" style={{ background: 'var(--bg1)', boxShadow: 'var(--sh3)', border: '1px solid var(--bd)' }}>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width={52} height={52}>
|
||||
<rect x="2" y="5" width="32" height="26" rx="13" ry="13" fill="rgba(199,70,52,0.08)" stroke="#C74634" strokeWidth="1.8" />
|
||||
<rect x="11" y="11" width="14" height="14" rx="4" fill="#fff" stroke="#C74634" strokeWidth="0.4" />
|
||||
<circle cx="15" cy="17" r="2" fill="#C74634" />
|
||||
<circle cx="21" cy="17" r="2" fill="#C74634" />
|
||||
<circle cx="15" cy="16.7" r="0.7" fill="#fff" />
|
||||
<circle cx="21" cy="16.7" r="0.7" fill="#fff" />
|
||||
<rect x="14" y="21" width="8" height="1.5" rx="0.75" fill="#C74634" opacity="0.6" />
|
||||
</svg>
|
||||
<h1 className="text-lg font-bold mt-3" style={{ color: 'var(--t1)' }}>AI Agent</h1>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--t3)' }}>{t('login.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
{!mfaRequired ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('login.userPlaceholder')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t('login.passPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{mfaSetup && totpUri && (
|
||||
<div className="text-center mb-2">
|
||||
<p className="text-xs mb-2" style={{ color: 'var(--t3)' }}>
|
||||
{t('login.mfaScanQr')}
|
||||
</p>
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpUri)}`}
|
||||
alt="QR Code"
|
||||
className="mx-auto rounded-lg"
|
||||
width={180}
|
||||
height={180}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('login.totpPlaceholder')}
|
||||
value={totp}
|
||||
onChange={(e) => setTotp(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none text-center tracking-[.3em]"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', fontFamily: 'var(--fm)' }}
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-xs px-3 py-2 rounded-lg" style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 rounded-lg text-sm font-semibold text-white transition-colors disabled:opacity-60"
|
||||
style={{ background: 'var(--ac)' }}
|
||||
>
|
||||
{loading ? t('login.loading') : mfaRequired ? t('login.verify') : t('login.submit')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
602
frontend-react/src/pages/PromptGeneratorPage.tsx
Normal file
602
frontend-react/src/pages/PromptGeneratorPage.tsx
Normal file
@@ -0,0 +1,602 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Sparkles, Send, RefreshCw, Copy, Check, PanelLeftOpen,
|
||||
PanelLeftClose, Plus, Trash2, ChevronDown, Loader2, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useAppStore, type ModelInfo } from '@/stores/app';
|
||||
import { terraformApi, type ChatSession } from '@/api/endpoints/terraform';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
/* ── Constants ── */
|
||||
|
||||
const TFP_EXAMPLES = [
|
||||
'VCN com subnets publica e privada, internet gateway, NAT gateway e 2 compute instances Ubuntu',
|
||||
'Ambiente multi-region MAD1 + MAD3 com DRG, RPC e VCNs espelhadas',
|
||||
'Cluster OKE com 3 node pools, load balancer e container registry',
|
||||
'Banco Autonomous Database com vault, encryption key e bastion para acesso',
|
||||
'Infraestrutura completa: VCN, compute, block storage, object storage, IAM policies e monitoring',
|
||||
];
|
||||
|
||||
const TFP_ALLOWED = [
|
||||
'openai.gpt-4.1', 'openai.o3', 'openai.o4-mini',
|
||||
'openai.gpt-5.1', 'openai.gpt-5.2',
|
||||
'google.gemini-2.5-pro', 'google.gemini-2.5-flash',
|
||||
];
|
||||
|
||||
const PROV_ORDER = ['openai', 'google'];
|
||||
|
||||
interface Msg {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
raw?: string;
|
||||
failed?: boolean;
|
||||
copied?: boolean;
|
||||
}
|
||||
|
||||
/* ── Component ── */
|
||||
|
||||
export default function PromptGeneratorPage() {
|
||||
const { models, genaiCfg, ociCfg } = useAppStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
/* state */
|
||||
const [msgs, setMsgs] = useState<Msg[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
const [sid, setSid] = useState<string | null>(null);
|
||||
|
||||
/* model selection */
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [ddOpen, setDdOpen] = useState(false);
|
||||
const [ddSearch, setDdSearch] = useState('');
|
||||
|
||||
/* OCI config (for direct models) */
|
||||
const [ociId, setOciId] = useState('');
|
||||
const [region, setRegion] = useState('');
|
||||
const [compartment, setCompartment] = useState('');
|
||||
|
||||
/* history sidebar */
|
||||
const [histOpen, setHistOpen] = useState(false);
|
||||
const [history, setHistory] = useState<ChatSession[]>([]);
|
||||
|
||||
const chatRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const ddRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/* ── auto-scroll ── */
|
||||
const scrollBottom = useCallback(() => {
|
||||
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
|
||||
}, []);
|
||||
|
||||
useEffect(() => { scrollBottom(); }, [msgs, loading, scrollBottom]);
|
||||
|
||||
/* ── close dropdown on outside click ── */
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ddRef.current && !ddRef.current.contains(e.target as Node)) setDdOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
/* ── history ── */
|
||||
const loadHistory = useCallback(async () => {
|
||||
try {
|
||||
const h = await terraformApi.listSessions('tf-prompt');
|
||||
setHistory(h);
|
||||
} catch { setHistory([]); }
|
||||
}, []);
|
||||
|
||||
const loadSession = useCallback(async (sessionId: string) => {
|
||||
try {
|
||||
const d = await terraformApi.loadSession(sessionId);
|
||||
setSid(sessionId);
|
||||
setMsgs(
|
||||
d.messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
raw: m.role === 'assistant' ? m.content : undefined,
|
||||
})),
|
||||
);
|
||||
} catch { /* noop */ }
|
||||
}, []);
|
||||
|
||||
const deleteSession = useCallback(async (sessionId: string) => {
|
||||
try { await terraformApi.deleteSession(sessionId); } catch { /* noop */ }
|
||||
if (sid === sessionId) { setSid(null); setMsgs([]); }
|
||||
loadHistory();
|
||||
}, [sid, loadHistory]);
|
||||
|
||||
const newConversation = () => { setSid(null); setMsgs([]); };
|
||||
|
||||
/* ── resolve genai config ── */
|
||||
const resolveGenai = (): Record<string, string | undefined> => {
|
||||
if (selectedModel.startsWith('cfg:')) return { genai_config_id: selectedModel.slice(4) };
|
||||
if (selectedModel && ociId) return { oci_config_id: ociId, model_id: selectedModel, genai_region: region, compartment_id: compartment };
|
||||
const def = genaiCfg.find((g) => g.is_default) || genaiCfg[0];
|
||||
if (def) return { genai_config_id: def.id };
|
||||
if (!ociCfg.length) return {};
|
||||
const oc = ociCfg[0];
|
||||
return { oci_config_id: oc.id, model_id: 'openai.gpt-4.1', genai_region: oc.region, compartment_id: oc.compartment_id };
|
||||
};
|
||||
|
||||
/* ── pick model ── */
|
||||
const pickModel = (v: string) => {
|
||||
setSelectedModel(v);
|
||||
setDdOpen(false);
|
||||
if (v && !v.startsWith('cfg:') && !ociId && ociCfg.length) {
|
||||
setOciId(ociCfg[0].id);
|
||||
setRegion(ociCfg[0].region);
|
||||
setCompartment(ociCfg[0].compartment_id || '');
|
||||
} else if (v.startsWith('cfg:')) {
|
||||
const g = genaiCfg.find((x) => x.id === v.slice(4));
|
||||
if (g?.oci_config_id && !ociId) {
|
||||
setOciId(g.oci_config_id);
|
||||
const c = ociCfg.find((x) => x.id === g.oci_config_id);
|
||||
if (c) { setRegion(c.region); setCompartment(c.compartment_id || ''); }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* ── send ── */
|
||||
const send = async (overrideMsg?: string) => {
|
||||
const m = overrideMsg || input.trim();
|
||||
if (!m || loading) return;
|
||||
if (!overrideMsg) setInput('');
|
||||
|
||||
const newMsgs: Msg[] = [...msgs, { role: 'user', content: m }];
|
||||
setMsgs(newMsgs);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const gc = resolveGenai();
|
||||
const hist = newMsgs.slice(0, -1)
|
||||
.filter((x) => x.role === 'user' || x.role === 'assistant')
|
||||
.map((x) => ({ role: x.role === 'user' ? 'USER' : 'CHATBOT', content: x.raw || x.content }));
|
||||
|
||||
const body = {
|
||||
message: m,
|
||||
genai_config: gc,
|
||||
history: hist.length ? hist : null,
|
||||
...(sid ? { session_id: sid } : {}),
|
||||
};
|
||||
|
||||
const d = await terraformApi.generatePrompt(body);
|
||||
if (d.session_id) setSid(d.session_id);
|
||||
|
||||
if (d.status === 'processing' && d.message_id) {
|
||||
await pollResult(d.message_id, newMsgs);
|
||||
} else if (d.prompt) {
|
||||
setMsgs([...newMsgs, { role: 'assistant', content: d.prompt, raw: d.prompt }]);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setMsgs([...newMsgs, { role: 'assistant', content: 'Erro: ' + (e as Error).message, failed: true }]);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pollResult = async (mid: string, currentMsgs: Msg[]) => {
|
||||
for (let i = 0; i < 600; i++) {
|
||||
await new Promise((r) => setTimeout(r, i < 10 ? 1000 : 3000));
|
||||
try {
|
||||
const r = await terraformApi.pollMessageStatus(mid);
|
||||
if (r.status === 'done') {
|
||||
setMsgs([...currentMsgs, { role: 'assistant', content: r.content, raw: r.content }]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (r.status === 'failed') {
|
||||
setMsgs([...currentMsgs, { role: 'assistant', content: r.content, failed: true }]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch { /* keep polling */ }
|
||||
}
|
||||
setMsgs([...currentMsgs, { role: 'assistant', content: t('pg.timeout'), failed: true }]);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
/* ── retry ── */
|
||||
const retry = (idx: number) => {
|
||||
let userMsg = '';
|
||||
for (let i = idx - 1; i >= 0; i--) { if (msgs[i].role === 'user') { userMsg = msgs[i].content; break; } }
|
||||
if (!userMsg) return;
|
||||
const next = [...msgs];
|
||||
next.splice(idx, 1);
|
||||
setMsgs(next);
|
||||
send(userMsg);
|
||||
};
|
||||
|
||||
/* ── copy ── */
|
||||
const copyPrompt = async (idx: number) => {
|
||||
const m = msgs[idx];
|
||||
if (!m?.raw) return;
|
||||
await navigator.clipboard.writeText(m.raw);
|
||||
setMsgs((prev) => prev.map((msg, i) => (i === idx ? { ...msg, copied: true } : msg)));
|
||||
setTimeout(() => {
|
||||
setMsgs((prev) => prev.map((msg, i) => (i === idx ? { ...msg, copied: false } : msg)));
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
/* ── model dropdown data ── */
|
||||
const provGroups: Record<string, { id: string; name: string }[]> = {};
|
||||
TFP_ALLOWED.forEach((mid) => {
|
||||
const mi: ModelInfo | undefined = models[mid];
|
||||
if (mi) {
|
||||
const p = mi.provider || 'other';
|
||||
if (!provGroups[p]) provGroups[p] = [];
|
||||
provGroups[p].push({ id: mid, name: mi.name });
|
||||
}
|
||||
});
|
||||
|
||||
const cfgFiltered = genaiCfg.filter((g) => TFP_ALLOWED.includes(g.model_id));
|
||||
|
||||
let curLabel = t('pg.selectModel');
|
||||
if (selectedModel.startsWith('cfg:')) {
|
||||
const g = genaiCfg.find((x) => x.id === selectedModel.slice(4));
|
||||
curLabel = g ? (g.name || g.model_id) : 'Config...';
|
||||
} else if (selectedModel) {
|
||||
const mi = models[selectedModel];
|
||||
curLabel = mi ? mi.name : selectedModel;
|
||||
}
|
||||
|
||||
const isDirect = selectedModel && !selectedModel.startsWith('cfg:');
|
||||
|
||||
/* ── auto-grow textarea ── */
|
||||
const autoGrow = (el: HTMLTextAreaElement) => {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
|
||||
};
|
||||
|
||||
/* ── history date grouping ── */
|
||||
const histDateGroup = (ts: string | undefined) => {
|
||||
const tFn = useI18n.getState().t;
|
||||
if (!ts) return '';
|
||||
const dt = ts.slice(0, 10);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
||||
if (dt === today) return tFn('chat.today');
|
||||
if (dt === yesterday) return tFn('chat.yesterday');
|
||||
const d = new Date(dt + 'T00:00:00');
|
||||
const diff = Math.floor((Date.now() - d.getTime()) / 86400000);
|
||||
if (diff < 7) return d.toLocaleDateString('pt-BR', { weekday: 'long' });
|
||||
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
};
|
||||
|
||||
/* ── render ── */
|
||||
return (
|
||||
<div className="page-full flex">
|
||||
{/* ── History sidebar ── */}
|
||||
{histOpen && (
|
||||
<div className="flex flex-col w-64 border-r shrink-0"
|
||||
style={{ background: 'var(--bg1)', borderColor: 'var(--bd)' }}>
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b"
|
||||
style={{ borderColor: 'var(--bd)' }}>
|
||||
<span className="text-xs font-semibold" style={{ color: 'var(--t2)' }}>{t('pg.history')}</span>
|
||||
<button onClick={newConversation}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[.66rem] cursor-pointer"
|
||||
style={{ background: 'var(--ppl)', color: 'var(--pp)', border: 'none' }}>
|
||||
<Plus size={12} /> {t('pg.new')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-1.5">
|
||||
{history.length === 0 && (
|
||||
<p className="text-center text-[.7rem] p-4" style={{ color: 'var(--t4)' }}>
|
||||
{t('pg.noConversations')}
|
||||
</p>
|
||||
)}
|
||||
{(() => {
|
||||
let lastGroup = '';
|
||||
return history.map((s) => {
|
||||
const g = histDateGroup(s.updated_at || s.created_at);
|
||||
const showGroup = g !== lastGroup;
|
||||
lastGroup = g;
|
||||
return (
|
||||
<div key={s.id}>
|
||||
{showGroup && (
|
||||
<div className="text-[.62rem] font-semibold uppercase tracking-wider px-2 pt-3 pb-1"
|
||||
style={{ color: 'var(--t4)' }}>{g}</div>
|
||||
)}
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1.5 rounded-md cursor-pointer group transition-colors"
|
||||
style={{
|
||||
background: s.id === sid ? 'var(--ppl)' : 'transparent',
|
||||
color: s.id === sid ? 'var(--pp)' : 'var(--t2)',
|
||||
}}
|
||||
onClick={() => loadSession(s.id)}>
|
||||
<span className="flex-1 truncate text-[.72rem]">{s.title || t('tf.newConversation')}</span>
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 rounded cursor-pointer"
|
||||
style={{ color: 'var(--rd)', background: 'none', border: 'none' }}
|
||||
onClick={(e) => { e.stopPropagation(); deleteSession(s.id); }}
|
||||
title={t('common.delete')}>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Main chat area ── */}
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
{/* toolbar */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b"
|
||||
style={{ background: 'var(--bg1)', borderColor: 'var(--bd)' }}>
|
||||
<button onClick={() => { setHistOpen(!histOpen); if (!histOpen) loadHistory(); }}
|
||||
className="p-1.5 rounded-md cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: histOpen ? 'var(--ppl)' : 'transparent',
|
||||
color: histOpen ? 'var(--pp)' : 'var(--t3)',
|
||||
border: 'none',
|
||||
}}
|
||||
title={t('pg.history')}>
|
||||
{histOpen ? <PanelLeftClose size={16} /> : <PanelLeftOpen size={16} />}
|
||||
</button>
|
||||
|
||||
<Sparkles size={16} style={{ color: 'var(--pp)' }} />
|
||||
|
||||
{/* Model dropdown */}
|
||||
<div className="relative" ref={ddRef}>
|
||||
<button onClick={() => setDdOpen(!ddOpen)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[.72rem] font-medium cursor-pointer"
|
||||
style={{ background: 'var(--bg2)', color: 'var(--t2)', border: '1px solid var(--bd)' }}>
|
||||
{curLabel}
|
||||
<ChevronDown size={13} className={`transition-transform ${ddOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{ddOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 w-64 rounded-xl overflow-hidden z-50 animate-[fadeIn_.15s_ease]"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)', boxShadow: 'var(--sh3)' }}>
|
||||
<div className="p-2">
|
||||
<input type="text" placeholder={t('pg.search')}
|
||||
value={ddSearch} onChange={(e) => setDdSearch(e.target.value)}
|
||||
className="w-full px-2.5 py-1.5 rounded-md text-[.72rem]"
|
||||
style={{ background: 'var(--bg2)', color: 'var(--t1)', border: '1px solid var(--bd)' }}
|
||||
autoFocus />
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto px-1 pb-1">
|
||||
{cfgFiltered.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[.62rem] font-semibold uppercase tracking-wider"
|
||||
style={{ color: 'var(--t4)' }}>{t('chat.savedConfigs')}</div>
|
||||
{cfgFiltered
|
||||
.filter((g) => !ddSearch || (g.name || g.model_id).toLowerCase().includes(ddSearch.toLowerCase()))
|
||||
.map((g) => {
|
||||
const mi = models[g.model_id];
|
||||
return (
|
||||
<DdItem key={'cfg:' + g.id} label={g.name || mi?.name || g.model_id}
|
||||
selected={selectedModel === 'cfg:' + g.id}
|
||||
onClick={() => pickModel('cfg:' + g.id)} />
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{PROV_ORDER.filter((p) => provGroups[p]).map((p) => (
|
||||
<div key={p}>
|
||||
<div className="px-2 py-1 text-[.62rem] font-semibold uppercase tracking-wider"
|
||||
style={{ color: 'var(--t4)' }}>{p[0].toUpperCase() + p.slice(1)}</div>
|
||||
{provGroups[p]
|
||||
.filter((m) => !ddSearch || m.name.toLowerCase().includes(ddSearch.toLowerCase()))
|
||||
.map((m) => (
|
||||
<DdItem key={m.id} label={m.name}
|
||||
selected={selectedModel === m.id}
|
||||
onClick={() => pickModel(m.id)} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OCI config selector (direct models only) */}
|
||||
{isDirect && (
|
||||
<select
|
||||
value={ociId}
|
||||
onChange={(e) => {
|
||||
setOciId(e.target.value);
|
||||
const c = ociCfg.find((x) => x.id === e.target.value);
|
||||
if (c) { setRegion(c.region); setCompartment(c.compartment_id || ''); }
|
||||
}}
|
||||
className="px-2 py-1.5 rounded-lg text-[.72rem] max-w-[180px]"
|
||||
style={{ background: 'var(--bg2)', color: 'var(--t2)', border: '1px solid var(--bd)' }}>
|
||||
<option value="">{t('tf.ociConfig')}</option>
|
||||
{ociCfg.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.tenancy_name} ({c.region})</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
{/* messages area */}
|
||||
<div ref={chatRef} className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
||||
{msgs.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<Sparkles size={32} className="mb-3 opacity-30" style={{ color: 'var(--pp)' }} />
|
||||
<p className="text-sm font-medium mb-1" style={{ color: 'var(--t2)' }}>
|
||||
{t('pg.emptyTitle')}
|
||||
</p>
|
||||
<p className="text-xs mb-5 opacity-60" style={{ color: 'var(--t3)' }}>
|
||||
{t('pg.emptySubtitle')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center max-w-2xl">
|
||||
{TFP_EXAMPLES.map((ex, i) => (
|
||||
<button key={i} onClick={() => { setInput(ex); inputRef.current?.focus(); }}
|
||||
className="px-3 py-2 rounded-lg text-[.68rem] text-left cursor-pointer transition-colors leading-snug"
|
||||
style={{
|
||||
background: 'var(--bg2)', color: 'var(--t3)',
|
||||
border: '1px solid var(--bd)', maxWidth: 280,
|
||||
}}>
|
||||
{ex}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msgs.map((m, i) => (
|
||||
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className="max-w-[80%] animate-[fadeIn_.25s_ease]">
|
||||
<div className="rounded-xl px-4 py-3 text-[.8rem] leading-relaxed"
|
||||
style={{
|
||||
background: m.role === 'user' ? 'var(--pp)' : 'var(--bg2)',
|
||||
color: m.role === 'user' ? '#fff' : 'var(--t1)',
|
||||
border: m.role === 'user' ? 'none' : '1px solid var(--bd)',
|
||||
}}>
|
||||
{m.role === 'assistant' ? (
|
||||
<div className="prose-sm prose-invert max-w-none [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:text-[.72rem] [&_pre]:overflow-auto [&_code]:text-[.72rem] [&_pre]:relative [&_pre]:group"
|
||||
style={{
|
||||
['--tw-prose-body' as string]: 'var(--t1)',
|
||||
['--tw-prose-headings' as string]: 'var(--t1)',
|
||||
['--tw-prose-code' as string]: 'var(--pp)',
|
||||
}}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
pre: ({ children }) => (
|
||||
<div className="relative group">
|
||||
<pre className="rounded-lg p-3 text-[.72rem] overflow-auto"
|
||||
style={{ background: 'var(--bg3)', fontFamily: 'var(--fm)', color: 'var(--t2)' }}>
|
||||
{children}
|
||||
</pre>
|
||||
<CopyCodeBtn>{children}</CopyCodeBtn>
|
||||
</div>
|
||||
),
|
||||
code: ({ children, className }) => {
|
||||
if (className?.includes('language-')) return <code className={className}>{children}</code>;
|
||||
return (
|
||||
<code className="px-1 py-0.5 rounded text-[.72rem]"
|
||||
style={{ background: 'var(--bg3)', color: 'var(--pp)', fontFamily: 'var(--fm)' }}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
}}>
|
||||
{m.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ whiteSpace: 'pre-wrap' }}>{m.content}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* assistant actions */}
|
||||
{m.role === 'assistant' && m.raw && !m.failed && (
|
||||
<div className="flex gap-1.5 mt-1.5">
|
||||
<button onClick={() => copyPrompt(i)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[.64rem] cursor-pointer transition-colors"
|
||||
style={{ background: 'var(--bg3)', color: m.copied ? 'var(--gn)' : 'var(--t3)', border: '1px solid var(--bd)' }}>
|
||||
{m.copied ? <Check size={11} /> : <Copy size={11} />}
|
||||
{m.copied ? t('pg.copied') : t('pg.copy')}
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
const url = `/terraform`;
|
||||
window.open(url, '_blank');
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[.64rem] cursor-pointer transition-colors"
|
||||
style={{ background: 'var(--ppl)', color: 'var(--pp)', border: '1px solid var(--pp)' }}>
|
||||
<ExternalLink size={11} /> Terraform
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* retry on failure */}
|
||||
{m.failed && (
|
||||
<button onClick={() => retry(i)}
|
||||
className="flex items-center gap-1 mt-1.5 px-2 py-1 rounded-md text-[.64rem] cursor-pointer"
|
||||
style={{ background: 'var(--rdl)', color: 'var(--rd)', border: '1px solid var(--rd)' }}>
|
||||
<RefreshCw size={11} /> {t('pg.retry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* loading indicator */}
|
||||
{loading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-xl px-4 py-3 text-[.8rem] flex items-center gap-2"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t3)' }}>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{t('pg.loading')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* input area */}
|
||||
<div className="px-4 py-3 border-t" style={{ borderColor: 'var(--bd)', background: 'var(--bg1)' }}>
|
||||
<div className="flex items-end gap-2 max-w-3xl mx-auto">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); autoGrow(e.target); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
|
||||
}}
|
||||
placeholder={t('pg.placeholder')}
|
||||
rows={1}
|
||||
className="flex-1 px-3 py-2.5 rounded-xl text-[.8rem] resize-none outline-none"
|
||||
style={{
|
||||
background: 'var(--bg2)', color: 'var(--t1)',
|
||||
border: '1px solid var(--bd)', maxHeight: 200, overflow: 'auto',
|
||||
}}
|
||||
/>
|
||||
<button onClick={() => send()} disabled={loading || !input.trim()}
|
||||
className="p-2.5 rounded-xl cursor-pointer transition-colors disabled:opacity-40"
|
||||
style={{ background: '#7b42bc', color: '#fff', border: 'none' }}>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Copy code block button ── */
|
||||
|
||||
function CopyCodeBtn({ children }: { children: React.ReactNode }) {
|
||||
const extractText = (node: React.ReactNode): string => {
|
||||
if (typeof node === 'string') return node;
|
||||
if (Array.isArray(node)) return node.map(extractText).join('');
|
||||
if (node && typeof node === 'object' && 'props' in node) {
|
||||
return extractText((node as React.ReactElement<{ children?: React.ReactNode }>).props.children);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className="absolute top-2 right-2 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
style={{ background: 'var(--bg4)', color: 'var(--t3)' }}
|
||||
onClick={() => navigator.clipboard.writeText(extractText(children))}
|
||||
title={useI18n.getState().t('common.copy')}>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Dropdown item ── */
|
||||
|
||||
function DdItem({ label, selected, onClick }: { label: string; selected: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button onClick={onClick}
|
||||
className="w-full text-left px-2.5 py-1.5 rounded-md text-[.72rem] cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: selected ? 'var(--ppl)' : 'transparent',
|
||||
color: selected ? 'var(--pp)' : 'var(--t2)',
|
||||
border: 'none',
|
||||
}}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
1629
frontend-react/src/pages/ReportsPage.tsx
Normal file
1629
frontend-react/src/pages/ReportsPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1566
frontend-react/src/pages/TerraformPage.tsx
Normal file
1566
frontend-react/src/pages/TerraformPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
462
frontend-react/src/pages/WorkspacesPage.tsx
Normal file
462
frontend-react/src/pages/WorkspacesPage.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
FolderOpen, RefreshCw, ChevronRight, Cloud, Download,
|
||||
Play, Rocket, Flame, X, ExternalLink, Ban, Clock,
|
||||
} from 'lucide-react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { terraformApi, type Workspace, type WsStatus } from '@/api/endpoints/terraform';
|
||||
|
||||
/* ── helpers ── */
|
||||
|
||||
type FilterKey = 'all' | 'active' | 'draft' | 'failed' | 'destroyed';
|
||||
|
||||
const RUNNING: WsStatus[] = ['planning', 'applying', 'destroying'];
|
||||
const ACTIVE: WsStatus[] = ['applied', 'planned', 'planning', 'applying'];
|
||||
|
||||
function dateLabel(dt: string | undefined): string {
|
||||
const tFn = useI18n.getState().t;
|
||||
if (!dt) return tFn('ws.noDate');
|
||||
const slice = dt.slice(0, 10);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
||||
if (slice === today) return tFn('chat.today');
|
||||
if (slice === yesterday) return tFn('chat.yesterday');
|
||||
const d = new Date(slice + 'T00:00:00');
|
||||
const diff = Math.floor((Date.now() - d.getTime()) / 86400000);
|
||||
if (diff < 7) return d.toLocaleDateString('pt-BR', { weekday: 'long' });
|
||||
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
function timeAgo(ts: string | undefined): string {
|
||||
if (!ts) return '—';
|
||||
const d = new Date(ts + 'Z');
|
||||
const s = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
const tFn = useI18n.getState().t;
|
||||
if (s < 60) return tFn('chat.timeNow');
|
||||
if (s < 3600) return Math.floor(s / 60) + 'min';
|
||||
if (s < 86400) return Math.floor(s / 3600) + 'h';
|
||||
return Math.floor(s / 86400) + 'd';
|
||||
}
|
||||
|
||||
const BADGE: Record<string, { cls: string; label: string }> = {
|
||||
draft: { cls: 'bg-[var(--bg3)] text-[var(--t3)]', label: 'Draft' },
|
||||
planning: { cls: 'bg-[var(--ppl)] text-[var(--pp)]', label: 'Planning...' },
|
||||
planned: { cls: 'bg-[var(--gnl)] text-[var(--gn)]', label: 'Plan OK' },
|
||||
applying: { cls: 'bg-[var(--bll)] text-[var(--bl)]', label: 'Applying...' },
|
||||
applied: { cls: 'bg-[var(--gnl)] text-[var(--gn)]', label: 'Applied' },
|
||||
destroying: { cls: 'bg-[var(--rdl)] text-[var(--rd)]', label: 'Destroying...' },
|
||||
destroyed: { cls: 'bg-[var(--bg3)] text-[var(--t4)]', label: 'Destroyed' },
|
||||
failed: { cls: 'bg-[var(--rdl)] text-[var(--rd)]', label: 'Failed' },
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: WsStatus }) {
|
||||
const b = BADGE[status] || BADGE.draft;
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[.68rem] font-semibold whitespace-nowrap ${b.cls}`}>
|
||||
{(RUNNING.includes(status)) && (
|
||||
<span className="inline-block w-2 h-2 rounded-full mr-1.5 animate-pulse"
|
||||
style={{ background: 'currentColor' }} />
|
||||
)}
|
||||
{b.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── component ── */
|
||||
|
||||
export default function WorkspacesPage() {
|
||||
const ociCfg = useAppStore((s) => s.ociCfg);
|
||||
const { t } = useI18n();
|
||||
|
||||
const [list, setList] = useState<Workspace[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<FilterKey>('all');
|
||||
const [closedGroups, setClosedGroups] = useState<Set<string>>(new Set());
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
/* confirm modals */
|
||||
const [confirmDestroy, setConfirmDestroy] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [destroyInput, setDestroyInput] = useState('');
|
||||
|
||||
const pollRef = useRef<Set<string>>(new Set());
|
||||
|
||||
/* ── load ── */
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const ws = await terraformApi.listWorkspaces();
|
||||
setList(ws);
|
||||
} catch {
|
||||
setList([]);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
/* ── polling ── */
|
||||
const pollWs = useCallback(async (wid: string) => {
|
||||
if (pollRef.current.has(wid)) return;
|
||||
pollRef.current.add(wid);
|
||||
for (let i = 0; i < 300; i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
try {
|
||||
const r = await terraformApi.status(wid);
|
||||
setList((prev) =>
|
||||
prev.map((w) =>
|
||||
w.id === wid
|
||||
? {
|
||||
...w,
|
||||
status: r.status,
|
||||
plan_output: r.plan_output || w.plan_output,
|
||||
apply_output: r.apply_output || w.apply_output,
|
||||
destroy_output: r.destroy_output || w.destroy_output,
|
||||
error: r.error || '',
|
||||
}
|
||||
: w,
|
||||
),
|
||||
);
|
||||
if (!RUNNING.includes(r.status)) {
|
||||
pollRef.current.delete(wid);
|
||||
load();
|
||||
return;
|
||||
}
|
||||
} catch { /* keep polling */ }
|
||||
}
|
||||
pollRef.current.delete(wid);
|
||||
}, [load]);
|
||||
|
||||
/* ── actions ── */
|
||||
const doPlan = async (wid: string) => {
|
||||
try { await terraformApi.plan(wid); } catch (e: unknown) { alert('Erro: ' + (e as Error).message); return; }
|
||||
load(); pollWs(wid);
|
||||
};
|
||||
const doApply = async (wid: string) => {
|
||||
try { await terraformApi.apply(wid); } catch (e: unknown) { alert('Erro: ' + (e as Error).message); return; }
|
||||
load(); pollWs(wid);
|
||||
};
|
||||
const doDestroy = async () => {
|
||||
if (!confirmDestroy || destroyInput !== 'DESTROY') return;
|
||||
const wid = confirmDestroy;
|
||||
setConfirmDestroy(null); setDestroyInput('');
|
||||
try { await terraformApi.destroy(wid); } catch (e: unknown) { alert('Erro: ' + (e as Error).message); load(); return; }
|
||||
load(); pollWs(wid);
|
||||
};
|
||||
const doDelete = async () => {
|
||||
if (!confirmDelete) return;
|
||||
const wid = confirmDelete;
|
||||
setConfirmDelete(null);
|
||||
try { await terraformApi.deleteWorkspace(wid); } catch (e: unknown) { alert('Erro: ' + (e as Error).message); }
|
||||
load();
|
||||
};
|
||||
const doCancel = async (wid: string) => {
|
||||
try { await terraformApi.cancel(wid); } catch { /* noop */ }
|
||||
load();
|
||||
};
|
||||
|
||||
/* ── filter + group ── */
|
||||
let items = list;
|
||||
if (filter === 'active') items = list.filter((w) => ACTIVE.includes(w.status));
|
||||
else if (filter === 'destroyed') items = list.filter((w) => w.status === 'destroyed');
|
||||
else if (filter === 'failed') items = list.filter((w) => w.status === 'failed');
|
||||
else if (filter === 'draft') items = list.filter((w) => w.status === 'draft');
|
||||
|
||||
const counts = {
|
||||
all: list.length,
|
||||
active: list.filter((w) => ACTIVE.includes(w.status)).length,
|
||||
draft: list.filter((w) => w.status === 'draft').length,
|
||||
failed: list.filter((w) => w.status === 'failed').length,
|
||||
destroyed: list.filter((w) => w.status === 'destroyed').length,
|
||||
};
|
||||
|
||||
const groups: { label: string; date: string; items: Workspace[] }[] = [];
|
||||
const gMap: Record<string, number> = {};
|
||||
for (const w of items) {
|
||||
const dt = (w.updated_at || w.created_at || '').slice(0, 10);
|
||||
const label = dateLabel(dt);
|
||||
if (gMap[label] === undefined) { gMap[label] = groups.length; groups.push({ label, date: dt, items: [] }); }
|
||||
groups[gMap[label]].items.push(w);
|
||||
}
|
||||
|
||||
const toggleGroup = (label: string) =>
|
||||
setClosedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(label)) next.delete(label); else next.add(label);
|
||||
return next;
|
||||
});
|
||||
|
||||
const ociName = (id: string) => {
|
||||
const oc = ociCfg.find((c) => c.id === id);
|
||||
return oc ? (oc.tenancy_name || oc.region || id) : '—';
|
||||
};
|
||||
|
||||
/* ── render ── */
|
||||
return (
|
||||
<div className="page-full flex flex-col">
|
||||
{/* header */}
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b"
|
||||
style={{ borderColor: 'var(--bd)', background: 'var(--bg1)' }}>
|
||||
<FolderOpen size={20} style={{ color: 'var(--pp)' }} />
|
||||
<h2 className="text-base font-bold" style={{ color: 'var(--t1)' }}>{t('ws.title')}</h2>
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* filters */}
|
||||
<div className="flex gap-1">
|
||||
{([
|
||||
['all', t('ws.all')],
|
||||
['active', t('ws.active')],
|
||||
['draft', t('ws.draft')],
|
||||
['failed', t('ws.failed')],
|
||||
['destroyed', t('ws.destroyed')],
|
||||
] as [FilterKey, string][]).map(([k, l]) => (
|
||||
<button key={k} onClick={() => setFilter(k)}
|
||||
className="px-2.5 py-1 rounded-full text-[.68rem] font-medium transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: filter === k ? 'var(--pp)' : 'var(--bg3)',
|
||||
color: filter === k ? '#fff' : 'var(--t3)',
|
||||
border: 'none',
|
||||
}}>
|
||||
{l} ({counts[k]})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button onClick={load} disabled={loading}
|
||||
className="btn btn-secondary btn-sm">
|
||||
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
||||
{loading ? t('ws.loading') : t('ws.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* body */}
|
||||
<div className="flex-1 overflow-y-auto p-4" style={{ background: 'var(--bg)' }}>
|
||||
{groups.length === 0 && !loading && (
|
||||
<div className="empty-state">
|
||||
<FolderOpen size={40} />
|
||||
<h3>{t('ws.noWs')}</h3>
|
||||
<p>{t('ws.noWsHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.length === 0 && loading && (
|
||||
<div className="flex items-center justify-center py-16" style={{ color: 'var(--t4)' }}>
|
||||
<RefreshCw size={18} className="animate-spin mr-2" />
|
||||
<span className="text-sm">{t('ws.loadingWs')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map((g) => {
|
||||
const open = !closedGroups.has(g.label);
|
||||
return (
|
||||
<div key={g.label} className="mb-3">
|
||||
{/* group header */}
|
||||
<button onClick={() => toggleGroup(g.label)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg cursor-pointer transition-colors"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)' }}>
|
||||
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''}`}
|
||||
style={{ color: 'var(--t3)' }} />
|
||||
<span className="text-[.78rem] font-semibold" style={{ color: 'var(--t1)' }}>{g.label}</span>
|
||||
<span className="text-[.66rem]" style={{ color: 'var(--t4)' }}>
|
||||
{g.items.length} workspace{g.items.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* group body */}
|
||||
{open && (
|
||||
<div className="mt-1 space-y-1 pl-1">
|
||||
{g.items.map((w) => {
|
||||
const running = RUNNING.includes(w.status);
|
||||
const hasOutput = !!(w.plan_output || w.apply_output || w.destroy_output || w.error);
|
||||
const isExpanded = expanded === w.id;
|
||||
const title = w.session_title || w.name || 'workspace';
|
||||
|
||||
return (
|
||||
<div key={w.id}>
|
||||
{/* row */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors group"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}
|
||||
onClick={() => {
|
||||
if (hasOutput && !running) setExpanded(isExpanded ? null : w.id);
|
||||
}}>
|
||||
<StatusBadge status={w.status} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[.78rem] font-medium truncate" style={{ color: 'var(--t1)' }}
|
||||
title={title}>
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5 text-[.66rem]" style={{ color: 'var(--t4)' }}>
|
||||
<span className="flex items-center gap-1">
|
||||
<Cloud size={10} /> {ociName(w.oci_config_id)}
|
||||
</span>
|
||||
{w.compartment_id && (
|
||||
<span title={w.compartment_id}>...{w.compartment_id.slice(-8)}</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={10} /> {timeAgo(w.updated_at || w.created_at)}
|
||||
</span>
|
||||
{hasOutput && !running && (
|
||||
<span style={{ color: 'var(--pp)', cursor: 'pointer' }}>
|
||||
{isExpanded ? `▾ ${t('ws.hideOutput')}` : `▸ ${t('ws.viewOutput')}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* actions */}
|
||||
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
{running ? (
|
||||
<ActionBtn color="var(--rd)" icon={<Ban size={12} />}
|
||||
label={t('ws.cancel')} onClick={() => doCancel(w.id)} />
|
||||
) : (
|
||||
<>
|
||||
<ActionBtn color="var(--bg3)" textColor="var(--t2)"
|
||||
icon={<ExternalLink size={12} />} label={t('ws.open')}
|
||||
onClick={() => window.open(`/terraform?ws=${w.id}`, '_blank')} />
|
||||
|
||||
{['draft', 'failed', 'destroyed', 'planned', 'applied'].includes(w.status) && (
|
||||
<ActionBtn color="var(--pp)" icon={<Play size={12} />}
|
||||
label={t('ws.plan')} onClick={() => doPlan(w.id)} />
|
||||
)}
|
||||
|
||||
{w.status === 'planned' && (
|
||||
<ActionBtn color="var(--gn)" icon={<Rocket size={12} />}
|
||||
label={t('ws.apply')} onClick={() => doApply(w.id)} />
|
||||
)}
|
||||
|
||||
{(w.status === 'applied' || w.status === 'planned') && (
|
||||
<ActionBtn color="var(--rd)" icon={<Flame size={12} />}
|
||||
label={t('ws.destroy')} onClick={() => { setConfirmDestroy(w.id); setDestroyInput(''); }} />
|
||||
)}
|
||||
|
||||
<ActionBtn color="transparent" textColor="var(--rd)"
|
||||
borderColor="var(--rd)" icon={<X size={12} />}
|
||||
label="" title={t('ws.delete')}
|
||||
onClick={() => setConfirmDelete(w.id)} />
|
||||
|
||||
<a href={terraformApi.downloadUrl(w.id)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[.66rem] font-medium transition-colors cursor-pointer"
|
||||
style={{ background: 'var(--bg3)', color: 'var(--t3)', border: '1px solid var(--bd)' }}
|
||||
title="Download ZIP" download
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<Download size={12} />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* expanded output */}
|
||||
{isExpanded && hasOutput && (
|
||||
<div className="mt-1 ml-4 rounded-lg overflow-hidden"
|
||||
style={{ border: '1px solid var(--bd)', background: 'var(--bg1)' }}>
|
||||
<pre className="p-3 text-[.68rem] overflow-auto max-h-80 whitespace-pre-wrap"
|
||||
style={{ color: w.error ? 'var(--rd)' : 'var(--t2)', fontFamily: 'var(--fm)' }}>
|
||||
{w.error
|
||||
? 'ERROR: ' + w.error
|
||||
: (w.destroy_output || w.apply_output || w.plan_output || '')}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Destroy confirm modal */}
|
||||
{confirmDestroy && (
|
||||
<Modal onClose={() => setConfirmDestroy(null)}>
|
||||
<h3 className="text-sm font-bold mb-2" style={{ color: 'var(--t1)' }}>{t('ws.confirmDestroy')}</h3>
|
||||
<p className="text-[.76rem] mb-3" style={{ color: 'var(--t2)' }}>
|
||||
{t('ws.confirmDestroyMsg')}
|
||||
{' '}
|
||||
Digite <strong>DESTROY</strong> para confirmar:
|
||||
</p>
|
||||
<input type="text" value={destroyInput}
|
||||
onChange={(e) => setDestroyInput(e.target.value)}
|
||||
placeholder="DESTROY"
|
||||
className="w-full px-3 py-2 rounded-lg text-sm text-center"
|
||||
style={{
|
||||
background: 'var(--bg2)', color: 'var(--t1)',
|
||||
border: `1px solid ${destroyInput === 'DESTROY' ? 'var(--gn)' : 'var(--rd)'}`,
|
||||
}}
|
||||
autoFocus
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') doDestroy(); }}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 mt-3">
|
||||
<button onClick={() => setConfirmDestroy(null)} className="btn btn-secondary btn-sm">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={doDestroy} disabled={destroyInput !== 'DESTROY'}
|
||||
className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff', borderColor: 'var(--rd)' }}>
|
||||
{t('ws.destroy')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Delete confirm modal */}
|
||||
{confirmDelete && (
|
||||
<Modal onClose={() => setConfirmDelete(null)}>
|
||||
<h3 className="text-sm font-bold mb-2" style={{ color: 'var(--t1)' }}>{t('ws.deleteWs')}</h3>
|
||||
<p className="text-[.76rem] mb-3" style={{ color: 'var(--t2)' }}>
|
||||
{t('ws.deleteWsMsg')}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 mt-3">
|
||||
<button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-sm">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={doDelete} className="btn btn-sm"
|
||||
style={{ background: 'var(--rd)', color: '#fff', borderColor: 'var(--rd)' }}>
|
||||
{t('ws.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Small shared components ── */
|
||||
|
||||
function ActionBtn({
|
||||
color, textColor, borderColor, icon, label, title, onClick,
|
||||
}: {
|
||||
color: string; textColor?: string; borderColor?: string;
|
||||
icon: React.ReactNode; label: string; title?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button onClick={onClick} title={title || label}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[.66rem] font-medium transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: color,
|
||||
color: textColor || '#fff',
|
||||
border: `1px solid ${borderColor || color}`,
|
||||
}}>
|
||||
{icon}
|
||||
{label && <span>{label}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ background: 'rgba(0,0,0,.55)' }} onClick={onClose}>
|
||||
<div className="w-full max-w-sm rounded-xl p-5 animate-[fadeIn_.2s_ease]"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)', boxShadow: 'var(--sh3)' }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
536
frontend-react/src/pages/admin/AuditPage.tsx
Normal file
536
frontend-react/src/pages/admin/AuditPage.tsx
Normal file
@@ -0,0 +1,536 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
ScrollText,
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
adminApi,
|
||||
type AuditEntry,
|
||||
type ConfigLogEntry,
|
||||
type ChatLogEntry,
|
||||
} from '@/api/endpoints/admin';
|
||||
import { usePolling } from '@/hooks/usePolling';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
/* ── Helpers ── */
|
||||
|
||||
function formatDate(iso: string) {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z');
|
||||
return d.toLocaleString('pt-BR', {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: string }) {
|
||||
const map: Record<string, { bg: string; fg: string }> = {
|
||||
error: { bg: 'var(--rdl)', fg: 'var(--rd)' },
|
||||
success: { bg: 'var(--gnl)', fg: 'var(--gn)' },
|
||||
info: { bg: 'var(--bll)', fg: 'var(--bl)' },
|
||||
warning: { bg: 'var(--yll)', fg: 'var(--yl)' },
|
||||
};
|
||||
const s = map[severity] ?? { bg: 'var(--bg3)', fg: 'var(--t3)' };
|
||||
return (
|
||||
<span
|
||||
className="inline-block px-2 py-0.5 rounded-full text-[0.68rem] font-semibold"
|
||||
style={{ background: s.bg, color: s.fg }}
|
||||
>
|
||||
{severity}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionTag({ action }: { action: string }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-block px-2 py-0.5 rounded-md text-[0.7rem] font-medium"
|
||||
style={{ background: 'var(--acl)', color: 'var(--ac)', border: '1px solid var(--acl2)' }}
|
||||
>
|
||||
{action}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center py-12"
|
||||
style={{ color: 'var(--t4)' }}
|
||||
>
|
||||
<FileText size={32} className="mb-3 opacity-40" />
|
||||
<p className="text-sm">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-3 rounded-xl text-sm"
|
||||
style={{ background: 'var(--rdl)', color: 'var(--rd)', border: '1px solid var(--rd)' }}
|
||||
>
|
||||
<AlertCircle size={16} />
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingRow({ cols, text }: { cols: number; text: string }) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={cols} className="text-center py-10">
|
||||
<Loader2 size={22} className="inline animate-spin" style={{ color: 'var(--ac)' }} />
|
||||
<span className="ml-2 text-sm" style={{ color: 'var(--t4)' }}>{text}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Shared table wrapper ── */
|
||||
|
||||
const tableContainerStyle: React.CSSProperties = {
|
||||
background: 'var(--bg1)',
|
||||
border: '1px solid var(--bd)',
|
||||
borderRadius: 'var(--r)',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
padding: '0.6rem 0.75rem',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
color: 'var(--t3)',
|
||||
background: 'var(--bg2)',
|
||||
borderBottom: '1px solid var(--bd)',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: '0.55rem 0.75rem',
|
||||
fontSize: '0.76rem',
|
||||
borderBottom: '1px solid var(--bg3)',
|
||||
color: 'var(--t2)',
|
||||
};
|
||||
|
||||
const tdDateStyle: React.CSSProperties = {
|
||||
...tdStyle,
|
||||
fontSize: '0.68rem',
|
||||
color: 'var(--t4)',
|
||||
whiteSpace: 'nowrap',
|
||||
fontFamily: 'var(--fm)',
|
||||
};
|
||||
|
||||
const tdMonoStyle: React.CSSProperties = {
|
||||
...tdStyle,
|
||||
fontSize: '0.68rem',
|
||||
color: 'var(--t4)',
|
||||
fontFamily: 'var(--fm)',
|
||||
};
|
||||
|
||||
/* ── Toolbar ── */
|
||||
|
||||
interface ToolbarProps {
|
||||
limit: number;
|
||||
onLimitChange: (n: number) => void;
|
||||
severity?: string;
|
||||
onSeverityChange?: (s: string) => void;
|
||||
onRefresh: () => void;
|
||||
loading: boolean;
|
||||
showSeverity?: boolean;
|
||||
}
|
||||
|
||||
function Toolbar({
|
||||
limit,
|
||||
onLimitChange,
|
||||
severity,
|
||||
onSeverityChange,
|
||||
onRefresh,
|
||||
loading,
|
||||
showSeverity = false,
|
||||
}: ToolbarProps) {
|
||||
const { t } = useI18n();
|
||||
const selectStyle: React.CSSProperties = {
|
||||
fontSize: '0.72rem',
|
||||
padding: '0.3rem 0.5rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--bd)',
|
||||
background: 'var(--bg2)',
|
||||
color: 'var(--t2)',
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{showSeverity && onSeverityChange && (
|
||||
<select
|
||||
value={severity ?? ''}
|
||||
onChange={(e) => onSeverityChange(e.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">{t('audit.all')}</option>
|
||||
<option value="error">{t('audit.errors')}</option>
|
||||
<option value="success">{t('audit.success')}</option>
|
||||
<option value="info">{t('audit.info')}</option>
|
||||
</select>
|
||||
)}
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(e) => onLimitChange(Number(e.target.value))}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value={50}>50 {t('audit.records')}</option>
|
||||
<option value={100}>100 {t('audit.records')}</option>
|
||||
<option value={200}>200 {t('audit.records')}</option>
|
||||
<option value={500}>500 {t('audit.records')}</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
||||
{t('audit.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Tab definitions ── */
|
||||
|
||||
type TabId = 'audit' | 'config' | 'chat';
|
||||
|
||||
/* ── Audit Log Table ── */
|
||||
|
||||
function AuditLogTable() {
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<AuditEntry[]>([]);
|
||||
const [limit, setLimit] = useState(100);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const rows = await adminApi.getAuditLog(limit);
|
||||
setData(rows);
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Erro ao carregar audit log');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [limit]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
usePolling(async () => { await load(); }, 30_000, true);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>
|
||||
{data.length > 0 && `${data.length} ${t('audit.records')}`}
|
||||
</p>
|
||||
<Toolbar limit={limit} onLimitChange={setLimit} onRefresh={load} loading={loading} />
|
||||
</div>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<div className="overflow-x-auto" style={{ borderRadius: 'var(--r)', border: '1px solid var(--bd)' }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('audit.date')}</th>
|
||||
<th>{t('audit.user')}</th>
|
||||
<th>{t('audit.action')}</th>
|
||||
<th>{t('audit.resource')}</th>
|
||||
<th>{t('audit.details')}</th>
|
||||
<th>{t('audit.ip')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && data.length === 0 ? (
|
||||
<LoadingRow cols={6} text={t('audit.loadingLogs')} />
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6}><EmptyState text={t('audit.noAudit')} /></td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td style={{ whiteSpace: 'nowrap', fontFamily: 'var(--fm)', fontSize: '.72rem', color: 'var(--t4)' }}>{formatDate(row.created_at)}</td>
|
||||
<td>{row.username || '—'}</td>
|
||||
<td><ActionTag action={row.action} /></td>
|
||||
<td style={{ fontFamily: 'var(--fm)', fontSize: '.72rem', color: 'var(--t4)' }} title={row.resource ?? ''}>
|
||||
{row.resource ? (row.resource.length > 20 ? row.resource.slice(0, 20) + '...' : row.resource) : '—'}
|
||||
</td>
|
||||
<td
|
||||
style={{ fontSize: '0.72rem', maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
title={row.details ?? ''}
|
||||
>
|
||||
{row.details?.slice(0, 120) || '—'}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.72rem' }}>{row.ip || '—'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Config Logs Table ── */
|
||||
|
||||
function ConfigLogsTable() {
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<ConfigLogEntry[]>([]);
|
||||
const [limit, setLimit] = useState(50);
|
||||
const [severity, setSeverity] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const rows = await adminApi.getConfigLogs({
|
||||
severity: severity || undefined,
|
||||
limit,
|
||||
});
|
||||
setData(rows);
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Erro ao carregar config logs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [limit, severity]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
usePolling(async () => { await load(); }, 30_000, true);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>
|
||||
{data.length > 0 && `${data.length} ${t('audit.records')}`}
|
||||
</p>
|
||||
<Toolbar
|
||||
limit={limit}
|
||||
onLimitChange={setLimit}
|
||||
severity={severity}
|
||||
onSeverityChange={setSeverity}
|
||||
onRefresh={load}
|
||||
loading={loading}
|
||||
showSeverity
|
||||
/>
|
||||
</div>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<div className="overflow-x-auto" style={{ borderRadius: 'var(--r)', border: '1px solid var(--bd)' }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('audit.date')}</th>
|
||||
<th>{t('audit.config')}</th>
|
||||
<th>{t('audit.action')}</th>
|
||||
<th>{t('audit.status')}</th>
|
||||
<th>{t('audit.message')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && data.length === 0 ? (
|
||||
<LoadingRow cols={5} text={t('audit.loadingLogs')} />
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5}><EmptyState text={t('audit.noConfig')} /></td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td style={{ whiteSpace: 'nowrap', fontFamily: 'var(--fm)', fontSize: '.72rem', color: 'var(--t4)' }}>{formatDate(row.created_at)}</td>
|
||||
<td style={{ fontSize: '0.72rem' }}>
|
||||
{row.config_name || row.config_id?.slice(0, 8) || '—'}
|
||||
</td>
|
||||
<td><ActionTag action={row.action} /></td>
|
||||
<td><SeverityBadge severity={row.severity} /></td>
|
||||
<td
|
||||
style={{ fontSize: '0.72rem', maxWidth: '400px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
title={row.message || ''}
|
||||
>
|
||||
{row.message?.slice(0, 150) || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Chat Logs Table ── */
|
||||
|
||||
function ChatLogsTable() {
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<ChatLogEntry[]>([]);
|
||||
const [limit, setLimit] = useState(50);
|
||||
const [severity, setSeverity] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const rows = await adminApi.getChatLogs({
|
||||
severity: severity || undefined,
|
||||
limit,
|
||||
});
|
||||
setData(rows);
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Erro ao carregar chat logs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [limit, severity]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
usePolling(async () => { await load(); }, 30_000, true);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>
|
||||
{data.length > 0 && `${data.length} ${t('audit.records')}`}
|
||||
</p>
|
||||
<Toolbar
|
||||
limit={limit}
|
||||
onLimitChange={setLimit}
|
||||
severity={severity}
|
||||
onSeverityChange={setSeverity}
|
||||
onRefresh={load}
|
||||
loading={loading}
|
||||
showSeverity
|
||||
/>
|
||||
</div>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<div className="overflow-x-auto" style={{ borderRadius: 'var(--r)', border: '1px solid var(--bd)' }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('audit.date')}</th>
|
||||
<th>{t('audit.session')}</th>
|
||||
<th>{t('audit.source')}</th>
|
||||
<th>{t('audit.action')}</th>
|
||||
<th>{t('audit.status')}</th>
|
||||
<th>{t('audit.message')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && data.length === 0 ? (
|
||||
<LoadingRow cols={6} text={t('audit.loadingLogs')} />
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6}><EmptyState text={t('audit.noChat')} /></td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td style={{ whiteSpace: 'nowrap', fontFamily: 'var(--fm)', fontSize: '.72rem', color: 'var(--t4)' }}>{formatDate(row.created_at)}</td>
|
||||
<td style={{ fontFamily: 'var(--fm)', fontSize: '.72rem', color: 'var(--t4)' }} title={row.session_id ?? ''}>
|
||||
{row.session_id ? row.session_id.slice(0, 8) + '...' : '—'}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.72rem' }}>{row.source || '—'}</td>
|
||||
<td><ActionTag action={row.action} /></td>
|
||||
<td><SeverityBadge severity={row.severity} /></td>
|
||||
<td
|
||||
style={{ fontSize: '0.72rem', maxWidth: '400px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
title={row.message || ''}
|
||||
>
|
||||
{row.message?.slice(0, 150) || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main Page ── */
|
||||
|
||||
export default function AuditPage() {
|
||||
const { t } = useI18n();
|
||||
const [activeTab, setActiveTab] = useState<TabId>('audit');
|
||||
|
||||
const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'audit', label: t('audit.tabAudit'), icon: <FileText size={15} /> },
|
||||
{ id: 'config', label: t('audit.tabConfig'), icon: <ScrollText size={15} /> },
|
||||
{ id: 'chat', label: t('audit.tabChat'), icon: <MessageSquare size={15} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="page" style={{ overflow: 'auto', height: '100%' }}>
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="card-header icon" style={{ background: 'var(--acl)' }}>
|
||||
<FileText size={20} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('audit.title')}</h1>
|
||||
<div className="subtitle">
|
||||
{t('audit.subtitle')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card with tabs + content */}
|
||||
<div className="card">
|
||||
{/* Tab bar */}
|
||||
<div
|
||||
className="flex gap-1 p-1 rounded-xl mb-4"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className="btn btn-sm"
|
||||
style={{
|
||||
background: activeTab === tab.id ? 'var(--bg1)' : 'transparent',
|
||||
color: activeTab === tab.id ? 'var(--t1)' : 'var(--t3)',
|
||||
border: activeTab === tab.id ? '1px solid var(--bd)' : '1px solid transparent',
|
||||
boxShadow: activeTab === tab.id ? 'var(--sh1)' : 'none',
|
||||
}}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div style={{ animation: 'fadeIn .3s ease-out' }}>
|
||||
{activeTab === 'audit' && <AuditLogTable />}
|
||||
{activeTab === 'config' && <ConfigLogsTable />}
|
||||
{activeTab === 'chat' && <ChatLogsTable />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
281
frontend-react/src/pages/admin/MfaPage.tsx
Normal file
281
frontend-react/src/pages/admin/MfaPage.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Lock, ShieldCheck, ShieldOff, KeyRound, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import client from '@/api/client';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
interface MfaSetupResponse {
|
||||
secret: string;
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export default function MfaPage() {
|
||||
const { user, checkAuth } = useAuthStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
const [totpUri, setTotpUri] = useState<string | null>(null);
|
||||
const [code, setCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [msg, setMsg] = useState<{ text: string; type: 's' | 'e' | 'i' } | null>(null);
|
||||
|
||||
const mfaEnabled = !!user?.mfa_enabled;
|
||||
|
||||
const handleSetup = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const data = await client.post('/mfa/setup') as unknown as MfaSetupResponse;
|
||||
setSecret(data.secret);
|
||||
setTotpUri(data.uri);
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Erro ao gerar secret', type: 'e' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleVerify = useCallback(async () => {
|
||||
if (!code || code.length !== 6) {
|
||||
setMsg({ text: t('mfa.invalidCode'), type: 'e' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await client.post('/mfa/verify', { totp_code: code });
|
||||
setMsg({ text: t('mfa.activated'), type: 's' });
|
||||
setSecret(null);
|
||||
setTotpUri(null);
|
||||
setCode('');
|
||||
await checkAuth();
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Codigo invalido', type: 'e' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [code, checkAuth, t]);
|
||||
|
||||
const handleDisable = useCallback(async () => {
|
||||
if (!user) return;
|
||||
if (!window.confirm(t('mfa.confirmDisable'))) return;
|
||||
setLoading(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await client.post(`/mfa/disable/${user.id}`);
|
||||
setMsg({ text: t('mfa.deactivated'), type: 'i' });
|
||||
await checkAuth();
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Erro ao desativar MFA', type: 'e' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user, checkAuth, t]);
|
||||
|
||||
const alertStyles: Record<string, { bg: string; color: string; icon: typeof CheckCircle2 }> = {
|
||||
s: { bg: 'var(--gnl)', color: 'var(--gn)', icon: CheckCircle2 },
|
||||
e: { bg: 'var(--rdl)', color: 'var(--rd)', icon: AlertCircle },
|
||||
i: { bg: 'color-mix(in srgb, var(--bl) 12%, transparent)', color: 'var(--bl)', icon: AlertCircle },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page" style={{ maxWidth: 580 }}>
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="card-header icon" style={{ background: 'color-mix(in srgb, var(--ac) 10%, transparent)' }}>
|
||||
<Lock size={20} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('mfa.title')}</h1>
|
||||
<div className="subtitle">
|
||||
{t('mfa.subtitle')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
{/* Status banner */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-5 py-4"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--bd)',
|
||||
background: mfaEnabled
|
||||
? 'color-mix(in srgb, var(--gn) 6%, transparent)'
|
||||
: 'color-mix(in srgb, var(--yl) 6%, transparent)',
|
||||
}}
|
||||
>
|
||||
{mfaEnabled ? (
|
||||
<ShieldCheck size={22} style={{ color: 'var(--gn)' }} />
|
||||
) : (
|
||||
<ShieldOff size={22} style={{ color: 'var(--yl)' }} />
|
||||
)}
|
||||
<div>
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--t1)' }}>
|
||||
{mfaEnabled ? t('mfa.enabled') : t('mfa.disabled')}
|
||||
</span>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--t3)' }}>
|
||||
{mfaEnabled
|
||||
? t('mfa.enabledDesc')
|
||||
: t('mfa.disabledDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5">
|
||||
{/* Message */}
|
||||
{msg && (() => {
|
||||
const st = alertStyles[msg.type];
|
||||
const Icon = st.icon;
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg mb-4 text-xs font-medium"
|
||||
style={{ background: st.bg, color: st.color }}
|
||||
>
|
||||
<Icon size={15} />
|
||||
{msg.text}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{mfaEnabled ? (
|
||||
/* Enabled state: show disable button */
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
<div
|
||||
className="w-16 h-16 rounded-full flex items-center justify-center"
|
||||
style={{ background: 'color-mix(in srgb, var(--gn) 10%, transparent)' }}
|
||||
>
|
||||
<ShieldCheck size={32} style={{ color: 'var(--gn)' }} />
|
||||
</div>
|
||||
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>
|
||||
{t('mfa.disableHint')}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleDisable}
|
||||
disabled={loading}
|
||||
className="btn btn-danger btn-sm"
|
||||
>
|
||||
{loading ? <Loader2 size={14} className="animate-spin" /> : <ShieldOff size={14} />}
|
||||
{t('mfa.disableBtn')}
|
||||
</button>
|
||||
</div>
|
||||
) : secret && totpUri ? (
|
||||
/* Setup step 2: show secret + QR + verify */
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Instructions */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 text-[.65rem] font-bold"
|
||||
style={{ background: 'var(--ac)', color: '#fff' }}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold" style={{ color: 'var(--t1)' }}>
|
||||
{t('mfa.scanQr')}
|
||||
</p>
|
||||
<p className="text-[.68rem] mt-0.5" style={{ color: 'var(--t4)' }}>
|
||||
{t('mfa.openApp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR code */}
|
||||
<div className="flex justify-center">
|
||||
<div className="p-3 rounded-xl" style={{ background: '#fff' }}>
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(totpUri)}`}
|
||||
alt="QR Code TOTP"
|
||||
width={180}
|
||||
height={180}
|
||||
className="rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secret */}
|
||||
<div
|
||||
className="px-3.5 py-2.5 rounded-lg text-center"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}
|
||||
>
|
||||
<span className="text-[.62rem] block mb-1" style={{ color: 'var(--t4)' }}>SECRET</span>
|
||||
<code
|
||||
className="text-xs font-bold tracking-wider select-all cursor-pointer"
|
||||
style={{ color: 'var(--ac)', fontFamily: 'var(--fm)' }}
|
||||
title="Clique para selecionar"
|
||||
>
|
||||
{secret}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Verify */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 text-[.65rem] font-bold"
|
||||
style={{ background: 'var(--ac)', color: '#fff' }}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--t1)' }}>
|
||||
{t('mfa.enterCode')}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
className="flex-1 px-3 py-2 rounded-lg text-sm text-center tracking-[.3em] font-semibold outline-none transition-colors"
|
||||
style={{
|
||||
background: 'var(--bg2)',
|
||||
border: '1px solid var(--bd)',
|
||||
color: 'var(--t1)',
|
||||
fontFamily: 'var(--fm)',
|
||||
}}
|
||||
onFocus={(e) => { e.target.style.borderColor = 'var(--ac)'; }}
|
||||
onBlur={(e) => { e.target.style.borderColor = 'var(--bd)'; }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleVerify(); }}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleVerify}
|
||||
disabled={loading || code.length !== 6}
|
||||
className="btn btn-success btn-sm"
|
||||
>
|
||||
{loading ? <Loader2 size={14} className="animate-spin" /> : <KeyRound size={14} />}
|
||||
{t('mfa.activateMfa')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Setup step 1: generate secret */
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
<div
|
||||
className="w-16 h-16 rounded-full flex items-center justify-center"
|
||||
style={{ background: 'color-mix(in srgb, var(--yl) 10%, transparent)' }}
|
||||
>
|
||||
<KeyRound size={32} style={{ color: 'var(--yl)' }} />
|
||||
</div>
|
||||
<p className="text-xs text-center max-w-xs" style={{ color: 'var(--t3)' }}>
|
||||
{t('mfa.generateHint')}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSetup}
|
||||
disabled={loading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : <KeyRound size={16} />}
|
||||
{t('mfa.generateSecret')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
463
frontend-react/src/pages/admin/UsersPage.tsx
Normal file
463
frontend-react/src/pages/admin/UsersPage.tsx
Normal file
@@ -0,0 +1,463 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Users, Plus, Shield, Eye, User as UserIcon, Trash2, X, AlertTriangle, CheckCircle, Loader2, UserPlus } from 'lucide-react';
|
||||
import client from '@/api/client';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
/* ── Types ── */
|
||||
interface AppUser {
|
||||
id: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
username: string;
|
||||
email: string | null;
|
||||
role: 'admin' | 'user' | 'viewer';
|
||||
mfa_enabled: boolean | number;
|
||||
is_active: boolean | number;
|
||||
created_at: string | null;
|
||||
last_login: string | null;
|
||||
}
|
||||
|
||||
type Role = 'admin' | 'user' | 'viewer';
|
||||
|
||||
const ROLE_CONFIG: Record<Role, { label: string; color: string; bg: string; icon: typeof Shield }> = {
|
||||
admin: { label: 'Admin', color: 'var(--rd)', bg: 'var(--rdl)', icon: Shield },
|
||||
user: { label: 'User', color: 'var(--bl)', bg: 'var(--bll)', icon: UserIcon },
|
||||
viewer: { label: 'Viewer', color: 'var(--t3)', bg: 'var(--bg3)', icon: Eye },
|
||||
};
|
||||
|
||||
/* ── Component ── */
|
||||
export default function UsersPage() {
|
||||
const { t } = useI18n();
|
||||
const [users, setUsers] = useState<AppUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState<Role>('viewer');
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const data = await client.get('/users') as unknown as AppUser[];
|
||||
setUsers(data);
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Erro ao carregar usuarios', type: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchUsers(); }, [fetchUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (msg) {
|
||||
const timer = setTimeout(() => setMsg(null), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [msg]);
|
||||
|
||||
const resetForm = () => {
|
||||
setFirstName(''); setLastName(''); setUsername('');
|
||||
setEmail(''); setPassword(''); setRole('viewer');
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!firstName.trim() || !lastName.trim()) {
|
||||
setMsg({ text: t('usr.requiredName'), type: 'error' });
|
||||
return;
|
||||
}
|
||||
if (!username.trim()) {
|
||||
setMsg({ text: t('usr.requiredUsername'), type: 'error' });
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
setMsg({ text: t('usr.requiredPassword'), type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await client.post('/auth/register', {
|
||||
first_name: firstName.trim(),
|
||||
last_name: lastName.trim(),
|
||||
username: username.trim(),
|
||||
email: email.trim() || undefined,
|
||||
password,
|
||||
role,
|
||||
});
|
||||
setMsg({ text: t('usr.created'), type: 'success' });
|
||||
resetForm();
|
||||
setShowForm(false);
|
||||
await fetchUsers();
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Erro ao criar usuario', type: 'error' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = async (userId: string, newRole: Role) => {
|
||||
try {
|
||||
await client.put(`/users/${userId}`, { role: newRole });
|
||||
setUsers(prev => prev.map(u => u.id === userId ? { ...u, role: newRole } : u));
|
||||
setMsg({ text: t('usr.roleUpdated'), type: 'success' });
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Erro ao atualizar role', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async (userId: string) => {
|
||||
try {
|
||||
await client.delete(`/users/${userId}`);
|
||||
setConfirmDelete(null);
|
||||
setMsg({ text: t('usr.deactivated'), type: 'success' });
|
||||
await fetchUsers();
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Erro ao desativar usuario', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (d: string | null) => {
|
||||
if (!d) return '---';
|
||||
try {
|
||||
return new Intl.DateTimeFormat('pt-BR', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
}).format(new Date(d));
|
||||
} catch {
|
||||
return d;
|
||||
}
|
||||
};
|
||||
|
||||
const activeUsers = users.filter(u => u.is_active);
|
||||
const inactiveUsers = users.filter(u => !u.is_active);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 size={28} className="animate-spin" style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
{/* Toast */}
|
||||
{msg && (
|
||||
<div
|
||||
className="fixed top-4 right-4 z-50 flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium shadow-lg"
|
||||
style={{
|
||||
background: msg.type === 'success' ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: msg.type === 'success' ? 'var(--gn)' : 'var(--rd)',
|
||||
border: `1px solid ${msg.type === 'success' ? 'var(--gn)' : 'var(--rd)'}`,
|
||||
borderColor: `color-mix(in srgb, ${msg.type === 'success' ? 'var(--gn)' : 'var(--rd)'} 30%, transparent)`,
|
||||
animation: 'fadeIn .3s ease',
|
||||
}}
|
||||
>
|
||||
{msg.type === 'success' ? <CheckCircle size={16} /> : <AlertTriangle size={16} />}
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<Users size={24} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('usr.title')}</h1>
|
||||
<div className="subtitle">
|
||||
{activeUsers.length} {activeUsers.length !== 1 ? t('usr.activesCount') : t('usr.activeCount')}
|
||||
{inactiveUsers.length > 0 && ` · ${inactiveUsers.length} ${inactiveUsers.length !== 1 ? t('usr.inactivesCount') : t('usr.inactiveCount')}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<span className="count">{users.length} {t('usr.total')}</span>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{showForm ? <X size={16} /> : <Plus size={16} />}
|
||||
{showForm ? t('usr.cancel') : t('usr.newUser')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create User Form */}
|
||||
{showForm && (
|
||||
<div className="card" style={{ animation: 'fadeIn .3s ease' }}>
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<UserPlus size={16} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<h2>{t('usr.createUser')}</h2>
|
||||
</div>
|
||||
|
||||
<div className="form-row" style={{ marginBottom: 12 }}>
|
||||
<div className="form-group">
|
||||
<label>{t('usr.firstName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Joao"
|
||||
value={firstName}
|
||||
onChange={e => setFirstName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('usr.lastName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Silva"
|
||||
value={lastName}
|
||||
onChange={e => setLastName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('usr.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="joao.silva"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
style={{ fontFamily: 'var(--fm)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row" style={{ marginBottom: 16 }}>
|
||||
<div className="form-group">
|
||||
<label>{t('usr.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="joao@empresa.com"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('usr.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('usr.role')}</label>
|
||||
<div className="flex gap-2">
|
||||
{(['viewer', 'user', 'admin'] as Role[]).map(r => {
|
||||
const cfg = ROLE_CONFIG[r];
|
||||
const Icon = cfg.icon;
|
||||
const selected = role === r;
|
||||
return (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRole(r)}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-semibold transition-all duration-200"
|
||||
style={{
|
||||
background: selected ? cfg.bg : 'var(--bg2)',
|
||||
border: `1.5px solid ${selected ? cfg.color : 'var(--bd)'}`,
|
||||
color: selected ? cfg.color : 'var(--t3)',
|
||||
}}
|
||||
>
|
||||
<Icon size={13} />
|
||||
{cfg.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={submitting}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{submitting ? <Loader2 size={15} className="animate-spin" /> : <Plus size={15} />}
|
||||
{submitting ? t('usr.creating') : t('usr.create')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Table */}
|
||||
{users.length === 0 ? (
|
||||
<div className="card">
|
||||
<div className="empty-state">
|
||||
<Users size={40} style={{ color: 'var(--ac)' }} />
|
||||
<h3>{t('usr.noUsers')}</h3>
|
||||
<p>{t('usr.noUsersHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('usr.name')}</th>
|
||||
<th>{t('usr.username')}</th>
|
||||
<th>{t('usr.email')}</th>
|
||||
<th>{t('usr.role')}</th>
|
||||
<th style={{ textAlign: 'center' }}>{t('usr.mfa')}</th>
|
||||
<th style={{ textAlign: 'center' }}>{t('usr.status')}</th>
|
||||
<th>{t('usr.lastLogin')}</th>
|
||||
<th style={{ textAlign: 'right' }}>{t('usr.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u, idx) => {
|
||||
const cfg = ROLE_CONFIG[u.role];
|
||||
const isActive = !!u.is_active;
|
||||
const isAdmin = u.username === 'admin';
|
||||
const RoleIcon = cfg.icon;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={u.id}
|
||||
className="transition-colors duration-150"
|
||||
style={{
|
||||
borderBottom: idx < users.length - 1 ? '1px solid var(--bd)' : undefined,
|
||||
opacity: isActive ? 1 : 0.5,
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg2)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = ''; }}
|
||||
>
|
||||
{/* Name */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--t1)' }}>
|
||||
{u.first_name || ''} {u.last_name || ''}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Username */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs" style={{ color: 'var(--t2)', fontFamily: 'var(--fm)' }}>
|
||||
{u.username}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Email */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs" style={{ color: 'var(--t3)' }}>
|
||||
{u.email || '---'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Role Badge */}
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[.7rem] font-semibold"
|
||||
style={{ background: cfg.bg, color: cfg.color }}
|
||||
>
|
||||
<RoleIcon size={12} />
|
||||
{cfg.label}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* MFA */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
{u.mfa_enabled ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.65rem] font-semibold"
|
||||
style={{ background: 'var(--gnl)', color: 'var(--gn)' }}
|
||||
>
|
||||
<CheckCircle size={11} /> {t('usr.active')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs" style={{ color: 'var(--t4)' }}>---</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span
|
||||
className="inline-block px-2 py-0.5 rounded-md text-[.65rem] font-semibold"
|
||||
style={{
|
||||
background: isActive ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: isActive ? 'var(--gn)' : 'var(--rd)',
|
||||
}}
|
||||
>
|
||||
{isActive ? t('usr.active') : t('usr.inactive')}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Last login */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs" style={{ color: 'var(--t4)' }}>
|
||||
{u.last_login ? formatDate(u.last_login) : t('usr.never')}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{/* Role selector */}
|
||||
<select
|
||||
value={u.role}
|
||||
onChange={e => handleRoleChange(u.id, e.target.value as Role)}
|
||||
disabled={isAdmin}
|
||||
className="px-2 py-1 rounded-lg text-xs outline-none cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: 'var(--bg2)',
|
||||
border: '1px solid var(--bd)',
|
||||
color: 'var(--t2)',
|
||||
}}
|
||||
>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="user">User</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
|
||||
{/* Deactivate */}
|
||||
{!isAdmin && isActive && (
|
||||
<>
|
||||
{confirmDelete === u.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleDeactivate(u.id)}
|
||||
className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}
|
||||
>
|
||||
{t('usr.confirm')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
{t('common.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(u.id)}
|
||||
className="p-1.5 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--t4)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--rdl)'; e.currentTarget.style.color = 'var(--rd)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = ''; e.currentTarget.style.color = 'var(--t4)'; }}
|
||||
title={t('usr.deactivate')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
644
frontend-react/src/pages/config/AdbConfigPage.tsx
Normal file
644
frontend-react/src/pages/config/AdbConfigPage.tsx
Normal file
@@ -0,0 +1,644 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { adbApi, type AdbConfigFull } from '@/api/endpoints/adb';
|
||||
import {
|
||||
Database, Plus, Pencil, Trash2, FlaskConical, Check, X, Loader2,
|
||||
ChevronRight, Upload, Shield, Table2, ChevronDown, ChevronUp, Search,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Msg ── */
|
||||
function Msg({ type, text }: { type: 's' | 'e' | 'i'; text: string }) {
|
||||
const bg = type === 's' ? 'var(--gnl)' : type === 'e' ? 'var(--rdl)' : 'var(--bg2)';
|
||||
const fg = type === 's' ? 'var(--gn)' : type === 'e' ? 'var(--rd)' : 'var(--t2)';
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs" style={{ background: bg, color: fg }}>
|
||||
{type === 's' && <Check size={14} />}
|
||||
{type === 'e' && <X size={14} />}
|
||||
{type === 'i' && <Loader2 size={14} className="animate-spin" />}
|
||||
<span dangerouslySetInnerHTML={{ __html: text }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
export default function AdbConfigPage() {
|
||||
const { genaiCfg, embModels } = useAppStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const [configs, setConfigs] = useState<AdbConfigFull[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Form
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<AdbConfigFull | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formMsg, setFormMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
const walletRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Form fields
|
||||
const [cfgName, setCfgName] = useState('');
|
||||
const [dsn, setDsn] = useState('');
|
||||
const [dsnOptions, setDsnOptions] = useState<string[]>([]);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [walletPassword, setWalletPassword] = useState('');
|
||||
const [genaiConfigId, setGenaiConfigId] = useState('');
|
||||
const [embeddingModelId, setEmbeddingModelId] = useState('cohere.embed-v4.0');
|
||||
const [parsingWallet, setParsingWallet] = useState(false);
|
||||
|
||||
// Table
|
||||
const [tableMsg, setTableMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
const [testResults, setTestResults] = useState<Record<string, { type: 's' | 'e'; text: string }>>({});
|
||||
const [testingMap, setTestingMap] = useState<Record<string, boolean>>({});
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [expandedTables, setExpandedTables] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Wallet upload section
|
||||
const walletUploadRef = useRef<HTMLInputElement>(null);
|
||||
const [walletUploadId, setWalletUploadId] = useState('');
|
||||
const [uploadingWallet, setUploadingWallet] = useState(false);
|
||||
const [walletMsg, setWalletMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
|
||||
// New table form
|
||||
const [newTableName, setNewTableName] = useState('');
|
||||
const [newTableDesc, setNewTableDesc] = useState('');
|
||||
|
||||
const fetchConfigs = useCallback(async () => {
|
||||
try {
|
||||
const data = await adbApi.list();
|
||||
setConfigs(data);
|
||||
} catch {} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchConfigs(); }, [fetchConfigs]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setFormOpen(false);
|
||||
setCfgName('');
|
||||
setDsn('');
|
||||
setDsnOptions([]);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setWalletPassword('');
|
||||
setGenaiConfigId('');
|
||||
setEmbeddingModelId('cohere.embed-v4.0');
|
||||
setFormMsg(null);
|
||||
setSaving(false);
|
||||
if (walletRef.current) walletRef.current.value = '';
|
||||
};
|
||||
|
||||
const startEdit = (cfg: AdbConfigFull) => {
|
||||
setEditing(cfg);
|
||||
setCfgName(cfg.config_name);
|
||||
setDsn(cfg.dsn);
|
||||
setDsnOptions([]);
|
||||
setUsername(cfg.username);
|
||||
setPassword('');
|
||||
setWalletPassword('');
|
||||
setGenaiConfigId(cfg.genai_config_id || '');
|
||||
setEmbeddingModelId(cfg.embedding_model_id || 'cohere.embed-v4.0');
|
||||
setFormOpen(true);
|
||||
setFormMsg(null);
|
||||
setTimeout(() => formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100);
|
||||
};
|
||||
|
||||
const parseWallet = async () => {
|
||||
const file = walletRef.current?.files?.[0];
|
||||
if (!file) { setFormMsg({ type: 'e', text: t('adb.selectWallet') }); return; }
|
||||
setParsingWallet(true);
|
||||
try {
|
||||
const result = await adbApi.parseWallet(file);
|
||||
setDsnOptions(result.dsn_names);
|
||||
if (result.dsn_names.length > 0) setDsn(result.dsn_names[0]);
|
||||
setFormMsg({ type: 's', text: `Wallet analisado! ${result.dsn_names.length} DSN(s): <strong>${result.dsn_names.join(', ')}</strong>. Arquivos: ${result.files.join(', ')}` });
|
||||
} catch (err) {
|
||||
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao analisar wallet' });
|
||||
} finally {
|
||||
setParsingWallet(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!cfgName.trim()) { setFormMsg({ type: 'e', text: t('adb.fillName') }); return; }
|
||||
if (!dsn.trim()) { setFormMsg({ type: 'e', text: t('adb.fillDsn') }); return; }
|
||||
if (!username.trim()) { setFormMsg({ type: 'e', text: t('adb.fillUsername') }); return; }
|
||||
if (!editing && !password) { setFormMsg({ type: 'e', text: t('adb.fillPassword') }); return; }
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('config_name', cfgName.trim());
|
||||
fd.append('dsn', dsn.trim());
|
||||
fd.append('username', username.trim());
|
||||
fd.append('password', password);
|
||||
fd.append('wallet_password', walletPassword);
|
||||
fd.append('use_mtls', 'true');
|
||||
fd.append('genai_config_id', genaiConfigId);
|
||||
fd.append('embedding_model_id', embeddingModelId);
|
||||
const walletFile = walletRef.current?.files?.[0];
|
||||
if (walletFile) fd.append('wallet', walletFile);
|
||||
|
||||
setSaving(true);
|
||||
setFormMsg({ type: 'i', text: t('adb.saving') });
|
||||
|
||||
try {
|
||||
if (editing) {
|
||||
await adbApi.update(editing.id, fd);
|
||||
} else {
|
||||
await adbApi.create(fd);
|
||||
}
|
||||
setFormMsg({ type: 's', text: `Conexao ${editing ? 'atualizada' : 'salva'}!${walletFile ? ' Wallet incluido.' : ''}` });
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTimeout(resetForm, 1200);
|
||||
} catch (err) {
|
||||
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao salvar' });
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setConfirmDelete(null);
|
||||
setTableMsg({ type: 'i', text: t('adb.deleting') });
|
||||
try {
|
||||
await adbApi.remove(id);
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: 'Conexao excluida com sucesso!' });
|
||||
if (editing?.id === id) resetForm();
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao excluir' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
setTestingMap((p) => ({ ...p, [id]: true }));
|
||||
setTestResults((p) => { const n = { ...p }; delete n[id]; return n; });
|
||||
try {
|
||||
const res = await adbApi.test(id);
|
||||
setTestResults((p) => ({
|
||||
...p,
|
||||
[id]: { type: res.status === 'success' ? 's' : 'e', text: res.message },
|
||||
}));
|
||||
} catch (err) {
|
||||
setTestResults((p) => ({ ...p, [id]: { type: 'e', text: err instanceof Error ? err.message : 'Erro' } }));
|
||||
} finally {
|
||||
setTestingMap((p) => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleWalletUpload = async () => {
|
||||
const file = walletUploadRef.current?.files?.[0];
|
||||
if (!file) { setWalletMsg({ type: 'e', text: t('adb.selectWallet') }); return; }
|
||||
if (!walletUploadId) { setWalletMsg({ type: 'e', text: t('adb.selectConnection') }); return; }
|
||||
setUploadingWallet(true);
|
||||
setWalletMsg({ type: 'i', text: 'Enviando wallet...' });
|
||||
try {
|
||||
const res = await adbApi.uploadWallet(walletUploadId, file);
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setWalletMsg({ type: 's', text: `Wallet enviada! Arquivos: ${res.files.join(', ')}` });
|
||||
if (walletUploadRef.current) walletUploadRef.current.value = '';
|
||||
} catch (err) {
|
||||
setWalletMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao enviar wallet' });
|
||||
} finally {
|
||||
setUploadingWallet(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTable = async (vid: string) => {
|
||||
if (!newTableName.trim()) { setTableMsg({ type: 'e', text: 'Digite o nome da tabela.' }); return; }
|
||||
try {
|
||||
await adbApi.addTable(vid, newTableName.trim(), newTableDesc.trim());
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: `Tabela ${newTableName.trim()} registrada!` });
|
||||
setNewTableName('');
|
||||
setNewTableDesc('');
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTable = async (vid: string, tid: string, currentActive: boolean) => {
|
||||
try {
|
||||
await adbApi.updateTable(vid, tid, { is_active: !currentActive });
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTable = async (vid: string, tid: string) => {
|
||||
if (!confirm('Excluir esta tabela do registro?')) return;
|
||||
try {
|
||||
await adbApi.removeTable(vid, tid);
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: 'Tabela removida.' });
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
}
|
||||
};
|
||||
|
||||
const isFormVisible = formOpen || !!editing;
|
||||
const inputCls = "w-full px-3 py-2 rounded-lg text-[.78rem] outline-none transition-colors focus:ring-1";
|
||||
const inputStyle = { background: 'var(--bg2)', border: '1.5px solid var(--bd)', color: 'var(--t1)' };
|
||||
const labelCls = "block";
|
||||
const labelStyle = {};
|
||||
const hintCls = "text-[.64rem] mt-0.5";
|
||||
const hintStyle = { color: 'var(--t4)' };
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--bll)' }}>
|
||||
<Database size={24} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('adb.title')}</h1>
|
||||
<div className="subtitle">{t('adb.subtitle')}</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<span className="count">
|
||||
{configs.length} {configs.length === 1 ? t('adb.connection') : t('adb.connections')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--bll)' }}>
|
||||
<Database size={16} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
<h2>{t('adb.registered')}</h2>
|
||||
</div>
|
||||
|
||||
{tableMsg && <div className="px-5 py-2"><Msg type={tableMsg.type} text={tableMsg.text} /></div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--t4)' }} />
|
||||
</div>
|
||||
) : configs.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<Database size={28} style={{ color: 'var(--t4)', margin: '0 auto 8px' }} />
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>{t('adb.noConnections')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{['Nome', 'DSN', 'User', 'Tabelas', 'mTLS', 'Wallet', 'Embed', 'Acoes'].map((h) => (
|
||||
<th key={h}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{configs.map((c) => {
|
||||
const tables = c.tables || [];
|
||||
const emb = c.embedding_model_id ? embModels[c.embedding_model_id] : null;
|
||||
const isExpanded = expandedTables[c.id];
|
||||
return (
|
||||
<tr
|
||||
key={c.id}
|
||||
className="transition-colors align-top"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--bd)',
|
||||
background: editing?.id === c.id ? 'color-mix(in srgb, var(--bl) 5%, var(--bg1))' : undefined,
|
||||
}}
|
||||
onMouseEnter={(e) => { if (editing?.id !== c.id) e.currentTarget.style.background = 'var(--bg2)'; }}
|
||||
onMouseLeave={(e) => { if (editing?.id !== c.id) e.currentTarget.style.background = ''; }}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<strong className="text-xs" style={{ color: 'var(--t1)' }}>{c.config_name}</strong>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-[.68rem]" style={{ fontFamily: 'var(--fm)', color: 'var(--t4)' }}>{c.dsn}</code>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[.72rem]" style={{ color: 'var(--t2)' }}>{c.username}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{tables.length > 0 ? (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<button
|
||||
onClick={() => setExpandedTables((p) => ({ ...p, [c.id]: !p[c.id] }))}
|
||||
className="flex items-center gap-1 text-[.62rem] font-semibold px-1.5 py-0.5 rounded"
|
||||
style={{ background: 'var(--bg)', color: 'var(--t3)' }}
|
||||
>
|
||||
<Table2 size={10} /> {tables.length}
|
||||
{isExpanded ? <ChevronUp size={8} /> : <ChevronDown size={8} />}
|
||||
</button>
|
||||
{!isExpanded && tables.slice(0, 3).map((tb) => (
|
||||
<span key={tb.id} className="px-1.5 py-0.5 rounded text-[.58rem]" style={{ background: 'var(--bg)', color: 'var(--t4)' }}>{tb.table_name}</span>
|
||||
))}
|
||||
{!isExpanded && tables.length > 3 && (
|
||||
<span className="text-[.58rem]" style={{ color: 'var(--t4)' }}>+{tables.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[.72rem]" style={{ color: 'var(--t4)' }}>{'\u2014'}</span>
|
||||
)}
|
||||
{isExpanded && tables.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<table className="w-full text-[.68rem]" style={{ borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{['Tabela', 'Descrição', 'Ativa', 'Ações'].map((h) => (
|
||||
<th key={h} className="text-left px-2 py-1 text-[.6rem] font-semibold uppercase" style={{ color: 'var(--t4)', borderBottom: '1px solid var(--bd)' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tables.map((tb) => (
|
||||
<tr key={tb.id} style={{ opacity: tb.is_active ? 1 : 0.55 }}>
|
||||
<td className="px-2 py-1.5">
|
||||
<span className="font-mono text-[.68rem]" style={{ color: 'var(--t1)' }}>{tb.table_name}</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<span className="text-[.66rem]" style={{ color: 'var(--t3)' }}>{tb.description || '—'}</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<button onClick={() => handleToggleTable(c.id, tb.id, tb.is_active)} className="text-[.6rem] px-1.5 py-0.5 rounded font-medium" style={{ background: tb.is_active ? 'var(--gnl)' : 'var(--bg)', color: tb.is_active ? 'var(--gn)' : 'var(--t4)' }}>
|
||||
{tb.is_active ? t('adb.yes') : t('adb.no')}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<button onClick={() => handleRemoveTable(c.id, tb.id)} className="text-[.58rem] px-1.5 py-0.5 rounded" style={{ color: 'var(--rd)', background: 'var(--rdl)' }}>
|
||||
{t('adb.delete')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* Add table inline */}
|
||||
{editing?.id === c.id && (
|
||||
<div className="flex gap-1 items-end mt-1">
|
||||
<input type="text" value={newTableName} onChange={(e) => setNewTableName(e.target.value)} placeholder="Nome" className="px-2 py-1 rounded text-[.64rem] w-28" style={{ background: 'var(--bg)', border: '1px solid var(--bd)', color: 'var(--t1)' }} />
|
||||
<input type="text" value={newTableDesc} onChange={(e) => setNewTableDesc(e.target.value)} placeholder="Descricao" className="px-2 py-1 rounded text-[.64rem] w-32" style={{ background: 'var(--bg)', border: '1px solid var(--bd)', color: 'var(--t1)' }} />
|
||||
<button onClick={() => handleAddTable(c.id)} className="px-2 py-1 rounded text-[.62rem] font-semibold" style={{ background: 'var(--bl)', color: '#fff' }}>
|
||||
<Plus size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{c.use_mtls ? (
|
||||
<Shield size={14} style={{ color: 'var(--gn)' }} />
|
||||
) : (
|
||||
<span style={{ color: 'var(--t4)' }}>{'\u2014'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{c.wallet_dir ? (
|
||||
<Check size={14} style={{ color: 'var(--gn)' }} />
|
||||
) : (
|
||||
<X size={14} style={{ color: 'var(--rd)' }} />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{c.genai_config_id ? (
|
||||
<span className="px-1.5 py-0.5 rounded text-[.6rem] font-semibold" style={{ background: 'var(--gnl)', color: 'var(--gn)' }}>
|
||||
{emb?.name || c.embedding_model_id}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--t4)' }}>{'\u2014'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<button onClick={() => startEdit(c)} className="btn btn-secondary btn-sm">
|
||||
<Pencil size={10} /> {t('adb.edit')}
|
||||
</button>
|
||||
<button onClick={() => handleTest(c.id)} disabled={testingMap[c.id]} className="btn btn-secondary btn-sm">
|
||||
{testingMap[c.id] ? <Loader2 size={10} className="animate-spin" /> : <FlaskConical size={10} />} {t('adb.test')}
|
||||
</button>
|
||||
{confirmDelete === c.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleDelete(c.id)} className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}>
|
||||
<Check size={10} /> {t('adb.yes')}
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-sm">
|
||||
{t('adb.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setConfirmDelete(c.id)} className="btn btn-danger btn-sm">
|
||||
<Trash2 size={10} /> {t('adb.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{testResults[c.id] && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded text-[.62rem] font-medium"
|
||||
style={{
|
||||
background: testResults[c.id].type === 's' ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: testResults[c.id].type === 's' ? 'var(--gn)' : 'var(--rd)',
|
||||
}}>
|
||||
{testResults[c.id].type === 's' ? <Check size={10} /> : <X size={10} />}
|
||||
{testResults[c.id].text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div ref={formRef} className="card" style={{ overflow: 'hidden' }}>
|
||||
<div className="flex items-center gap-2.5 px-5 py-3.5 cursor-pointer select-none" onClick={() => { if (!editing) setFormOpen(!formOpen); }}>
|
||||
<ChevronRight size={14} style={{ color: 'var(--t3)', transition: 'transform .2s', transform: isFormVisible ? 'rotate(90deg)' : 'rotate(0)' }} />
|
||||
<div className="flex items-center gap-2">
|
||||
{editing ? <Pencil size={14} style={{ color: 'var(--bl)' }} /> : <Plus size={14} style={{ color: 'var(--bl)' }} />}
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
|
||||
{editing ? `${t('adb.editConnection')} — ${editing.config_name}` : t('adb.newConnection')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFormVisible && (
|
||||
<div className="px-5 pb-5 flex flex-col gap-4" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<p className="text-[.7rem] pt-3" style={{ color: 'var(--t4)' }}>
|
||||
{editing
|
||||
? <>Editando: <strong style={{ color: 'var(--t2)' }}>{editing.config_name}</strong></>
|
||||
: <>{t('adb.connDesc')}</>}
|
||||
</p>
|
||||
|
||||
{formMsg && <Msg type={formMsg.type} text={formMsg.text} />}
|
||||
|
||||
{/* Row 1 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.connName')}</label>
|
||||
<input type="text" value={cfgName} onChange={(e) => setCfgName(e.target.value)} placeholder="Producao ADB" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.walletZip')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>
|
||||
{editing ? t('adb.walletEditHint') : t('adb.walletHint')}
|
||||
</p>
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<input
|
||||
ref={walletRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="flex-1 text-[.72rem] file:mr-2 file:px-3 file:py-1.5 file:rounded-lg file:border-0 file:text-[.68rem] file:font-semibold file:cursor-pointer"
|
||||
style={{ color: 'var(--t3)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={parseWallet}
|
||||
disabled={parsingWallet}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-[.68rem] font-semibold disabled:opacity-50"
|
||||
style={{ background: 'var(--bg2)', color: 'var(--t2)', border: '1px solid var(--bd)' }}
|
||||
>
|
||||
{parsingWallet ? <Loader2 size={12} className="animate-spin" /> : <Search size={12} />}
|
||||
{t('adb.analyze')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.dsn')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('adb.dsnHint')}</p>
|
||||
{dsnOptions.length > 0 ? (
|
||||
<select value={dsn} onChange={(e) => setDsn(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
{dsnOptions.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input type="text" value={dsn} onChange={(e) => setDsn(e.target.value)} placeholder="myatp_high" className={inputCls} style={inputStyle} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.username')}</label>
|
||||
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="ADMIN" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.password')}</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder={editing ? '(manter atual)' : ''} className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.walletPassword')}</label>
|
||||
<input type="password" value={walletPassword} onChange={(e) => setWalletPassword(e.target.value)} placeholder="(opcional)" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4 - Embedding config */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.genaiConfig')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('adb.genaiConfigHint')}</p>
|
||||
<select value={genaiConfigId} onChange={(e) => setGenaiConfigId(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
<option value="">{t('adb.noRag')}</option>
|
||||
{genaiCfg.map((g) => (
|
||||
<option key={g.id} value={g.id}>{g.name || g.model_id} {g.genai_region ? `- ${g.genai_region}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.embeddingModel')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('adb.embeddingModelHint')}</p>
|
||||
<select value={embeddingModelId} onChange={(e) => setEmbeddingModelId(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
{Object.entries(embModels).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.name} ({v.dims}d)</option>
|
||||
))}
|
||||
{Object.keys(embModels).length === 0 && (
|
||||
<option value="cohere.embed-v4.0">cohere.embed-v4.0</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button onClick={handleSave} disabled={saving} className="btn btn-primary" style={{ background: 'var(--bl)', borderColor: 'var(--bl)' }}>
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{editing ? t('adb.saveChanges') : t('adb.saveConnection')}
|
||||
</button>
|
||||
{editing && (
|
||||
<button onClick={resetForm} className="btn btn-secondary">
|
||||
{t('adb.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Wallet Upload card */}
|
||||
{configs.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--bll)' }}>
|
||||
<Upload size={16} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
<h2>{t('adb.updateWallet')}</h2>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex flex-col gap-3">
|
||||
{walletMsg && <Msg type={walletMsg.type} text={walletMsg.text} />}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.adbConnection')}</label>
|
||||
<select value={walletUploadId} onChange={(e) => setWalletUploadId(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
<option value="">{t('common.select')}</option>
|
||||
{configs.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.config_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('adb.walletZip')}</label>
|
||||
<input
|
||||
ref={walletUploadRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="text-[.72rem] file:mr-2 file:px-3 file:py-1.5 file:rounded-lg file:border-0 file:text-[.68rem] file:font-semibold file:cursor-pointer"
|
||||
style={{ color: 'var(--t3)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={handleWalletUpload}
|
||||
disabled={uploadingWallet}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
{uploadingWallet ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
|
||||
{t('adb.uploadWallet')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
305
frontend-react/src/pages/config/EmbConsultPage.tsx
Normal file
305
frontend-react/src/pages/config/EmbConsultPage.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useAppStore, type AdbConfig, type AdbTable } from '@/stores/app';
|
||||
import { embeddingsApi, type ConsultResult } from '@/api/endpoints/embeddings';
|
||||
import { useI18n } from '@/i18n';
|
||||
import {
|
||||
Search, Send, Loader2, Database, Trash2, User, Bot,
|
||||
FileText, Hash,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Types ── */
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
sources?: ConsultResult['documents'];
|
||||
totalSources?: number;
|
||||
}
|
||||
|
||||
/* ── Markdown-lite renderer ── */
|
||||
function renderMarkdown(text: string): string {
|
||||
let html = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// Bold
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
// Italic
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
// Inline code
|
||||
html = html.replace(/`(.+?)`/g, '<code style="background:var(--bg);padding:1px 4px;border-radius:3px;font-size:.72rem;font-family:var(--fm)">$1</code>');
|
||||
// Horizontal rule
|
||||
html = html.replace(/\n---\n/g, '<hr style="border:none;border-top:1px solid var(--bd);margin:8px 0">');
|
||||
// Line breaks
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
export default function EmbConsultPage() {
|
||||
const { t } = useI18n();
|
||||
const { adbCfg } = useAppStore();
|
||||
|
||||
// Config selectors
|
||||
const [selTable, setSelTable] = useState('');
|
||||
const [topK, setTopK] = useState(10);
|
||||
|
||||
// Chat
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const msgsRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Auto scroll
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (msgsRef.current) {
|
||||
msgsRef.current.scrollTop = msgsRef.current.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, loading, scrollToBottom]);
|
||||
|
||||
// Focus input on mount
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Table options from all active ADB configs
|
||||
const allTables: { name: string; configName: string }[] = [];
|
||||
for (const cfg of adbCfg) {
|
||||
for (const t of (cfg.tables || []).filter((t) => t.is_active)) {
|
||||
allTables.push({ name: t.table_name, configName: cfg.config_name });
|
||||
}
|
||||
}
|
||||
|
||||
// Preconditions
|
||||
if (!adbCfg.length) {
|
||||
return (
|
||||
<div className="page-full flex items-center justify-center">
|
||||
<div className="empty-state">
|
||||
<Database size={36} />
|
||||
<h3>{t('emb.noAdb')}</h3>
|
||||
<p>{t('emb.noAdbHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Submit ── */
|
||||
const handleSubmit = async () => {
|
||||
const q = query.trim();
|
||||
if (!q || loading) return;
|
||||
|
||||
const userMsg: ChatMessage = { role: 'user', content: q };
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setQuery('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const d = await embeddingsApi.consult(q, selTable || undefined, topK);
|
||||
let answer = d.answer || t('ec.noAnswer');
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: answer,
|
||||
sources: d.documents,
|
||||
totalSources: d.total,
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
} catch (err) {
|
||||
const assistantMsg: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: `Erro: ${err instanceof Error ? err.message : 'Erro desconhecido'}`,
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const clearChat = () => {
|
||||
setMessages([]);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-full flex flex-col">
|
||||
<div className="max-w-5xl mx-auto w-full flex flex-col h-full p-6 gap-3">
|
||||
|
||||
{/* Header */}
|
||||
<div className="card flex items-center justify-between flex-shrink-0" style={{ marginBottom: 0, padding: '14px 20px' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="card-header icon" style={{ background: 'color-mix(in srgb, var(--bl) 12%, transparent)', margin: 0, padding: 0, border: 'none' }}>
|
||||
<Search size={18} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '1rem', fontWeight: 700, color: 'var(--t1)' }}>{t('ec.title')}</h1>
|
||||
<p className="subtitle" style={{ fontSize: '.72rem' }}>
|
||||
{t('ec.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{messages.length > 0 && (
|
||||
<button onClick={clearChat} className="btn btn-secondary btn-sm">
|
||||
<Trash2 size={12} />
|
||||
{t('ec.clear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config bar */}
|
||||
<div className="flex gap-3 flex-wrap flex-shrink-0">
|
||||
<div className="flex-1 min-w-[180px]">
|
||||
<label className="block text-[.68rem] font-semibold mb-1" style={{ color: 'var(--t4)' }}>{t('ec.tableOptional')}</label>
|
||||
<select
|
||||
value={selTable}
|
||||
onChange={(e) => setSelTable(e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-[.76rem] outline-none"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)', color: 'var(--t1)' }}
|
||||
>
|
||||
<option value="">{t('ec.allTables')}</option>
|
||||
{allTables.map((t) => (
|
||||
<option key={t.name} value={t.name}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-[80px]">
|
||||
<label className="block text-[.68rem] font-semibold mb-1" style={{ color: 'var(--t4)' }}>Top-K</label>
|
||||
<input
|
||||
type="number"
|
||||
value={topK}
|
||||
onChange={(e) => setTopK(Math.max(1, Math.min(30, parseInt(e.target.value) || 10)))}
|
||||
min={1}
|
||||
max={30}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-[.76rem] outline-none text-center"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)', color: 'var(--t1)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages area */}
|
||||
<div
|
||||
ref={msgsRef}
|
||||
className="flex-1 overflow-y-auto rounded-xl p-4 flex flex-col gap-3 min-h-0"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)' }}
|
||||
>
|
||||
{messages.length === 0 && !loading && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Search size={32} style={{ color: 'var(--t4)', margin: '0 auto 10px', opacity: 0.5 }} />
|
||||
<p className="text-[.82rem] font-medium" style={{ color: 'var(--t3)' }}>
|
||||
{t('ec.emptyTitle')}
|
||||
</p>
|
||||
<p className="text-[.7rem] mt-2" style={{ color: 'var(--t4)' }}>
|
||||
{t('ec.emptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex gap-2.5 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center mt-0.5" style={{ background: 'color-mix(in srgb, var(--bl) 12%, transparent)' }}>
|
||||
<Bot size={14} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="max-w-[80%] rounded-xl px-4 py-3"
|
||||
style={{
|
||||
background: msg.role === 'user' ? 'var(--ac)' : 'var(--bg2)',
|
||||
color: msg.role === 'user' ? '#fff' : 'var(--t1)',
|
||||
}}
|
||||
>
|
||||
{msg.role === 'user' ? (
|
||||
<p className="text-[.8rem] leading-relaxed">{msg.content}</p>
|
||||
) : (
|
||||
<div className="text-[.8rem] leading-relaxed" dangerouslySetInnerHTML={{ __html: renderMarkdown(msg.content) }} />
|
||||
)}
|
||||
|
||||
{/* Sources */}
|
||||
{msg.sources && msg.sources.length > 0 && (
|
||||
<div className="mt-3 pt-3" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<FileText size={12} style={{ color: 'var(--t4)' }} />
|
||||
<span className="text-[.66rem] font-semibold" style={{ color: 'var(--t4)' }}>
|
||||
{t('ec.sources')} ({msg.sources.length} documentos{msg.totalSources ? `, ${msg.totalSources} total` : ''})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{msg.sources.map((src, j) => (
|
||||
<div key={j} className="flex items-center gap-2 px-2 py-1 rounded" style={{ background: 'var(--bg)' }}>
|
||||
<Hash size={10} style={{ color: 'var(--t4)' }} />
|
||||
<span className="text-[.66rem] font-semibold" style={{ color: 'var(--bl)' }}>{src.source}</span>
|
||||
<span className="text-[.6rem]" style={{ color: 'var(--t4)' }}>
|
||||
dist: {src.distance.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{msg.role === 'user' && (
|
||||
<div className="flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center mt-0.5" style={{ background: 'color-mix(in srgb, var(--ac) 15%, transparent)' }}>
|
||||
<User size={14} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{loading && (
|
||||
<div className="flex gap-2.5 justify-start">
|
||||
<div className="flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center mt-0.5" style={{ background: 'color-mix(in srgb, var(--bl) 12%, transparent)' }}>
|
||||
<Bot size={14} style={{ color: 'var(--bl)' }} />
|
||||
</div>
|
||||
<div className="rounded-xl px-4 py-3 flex items-center gap-2" style={{ background: 'var(--bg2)' }}>
|
||||
<Loader2 size={14} className="animate-spin" style={{ color: 'var(--bl)' }} />
|
||||
<span className="text-[.76rem]" style={{ color: 'var(--t3)' }}>{t('ec.searching')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
<div className="flex gap-2.5 flex-shrink-0">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('ec.placeholder')}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl text-[.82rem] outline-none transition-colors focus:ring-1 disabled:opacity-60"
|
||||
style={{ background: 'var(--bg1)', border: '1.5px solid var(--bd)', color: 'var(--t1)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !query.trim()}
|
||||
className="btn"
|
||||
style={{ background: 'var(--bl)', color: '#fff', borderColor: 'var(--bl)', borderRadius: '12px' }}
|
||||
>
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||
{t('ec.submit')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
505
frontend-react/src/pages/config/EmbeddingsPage.tsx
Normal file
505
frontend-react/src/pages/config/EmbeddingsPage.tsx
Normal file
@@ -0,0 +1,505 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useAppStore, type AdbConfig, type AdbTable } from '@/stores/app';
|
||||
import { embeddingsApi, type EmbeddingDoc } from '@/api/endpoints/embeddings';
|
||||
import { useI18n } from '@/i18n';
|
||||
import {
|
||||
Dna, Shield, ScrollText, Upload, Loader2, Check, X,
|
||||
Trash2, RefreshCw, Link, FileUp, Database, Search, AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Helpers ── */
|
||||
function Msg({ type, text }: { type: 's' | 'e' | 'i'; text: string }) {
|
||||
const bg = type === 's' ? 'var(--gnl)' : type === 'e' ? 'var(--rdl)' : 'var(--bg2)';
|
||||
const fg = type === 's' ? 'var(--gn)' : type === 'e' ? 'var(--rd)' : 'var(--t2)';
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs" style={{ background: bg, color: fg }}>
|
||||
{type === 's' && <Check size={14} />}
|
||||
{type === 'e' && <X size={14} />}
|
||||
{type === 'i' && <Loader2 size={14} className="animate-spin" />}
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MsgState = { type: 's' | 'e' | 'i'; text: string } | null;
|
||||
|
||||
function getActiveTables(cfg: AdbConfig): AdbTable[] {
|
||||
return (cfg.tables || []).filter((t) => t.is_active);
|
||||
}
|
||||
|
||||
function getAllTables(cfg: AdbConfig): AdbTable[] {
|
||||
return cfg.tables || [];
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
export default function EmbeddingsPage() {
|
||||
const { t } = useI18n();
|
||||
const { adbCfg, ociCfg, genaiCfg } = useAppStore();
|
||||
|
||||
// ── Existing Embeddings section ──
|
||||
const [selVid, setSelVid] = useState(() => adbCfg.find((c) => c.is_active)?.id || adbCfg[0]?.id || '');
|
||||
const [selTable, setSelTable] = useState('');
|
||||
const [docs, setDocs] = useState<EmbeddingDoc[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
const [listMsg, setListMsg] = useState<MsgState>(null);
|
||||
|
||||
// ── CIS Recommendations section ──
|
||||
const [cisVid, setCisVid] = useState(() => adbCfg.find((c) => c.is_active)?.id || adbCfg[0]?.id || '');
|
||||
const cisPdfRef = useRef<HTMLInputElement>(null);
|
||||
const [cisUploading, setCisUploading] = useState(false);
|
||||
const [cisMsg, setCisMsg] = useState<MsgState>(null);
|
||||
|
||||
// ── Knowledge Base section ──
|
||||
const [kbVid, setKbVid] = useState(() => adbCfg.find((c) => c.is_active)?.id || adbCfg[0]?.id || '');
|
||||
const kbFileRef = useRef<HTMLInputElement>(null);
|
||||
const [kbUrl, setKbUrl] = useState('');
|
||||
const [kbFileUploading, setKbFileUploading] = useState(false);
|
||||
const [kbUrlUploading, setKbUrlUploading] = useState(false);
|
||||
const [kbMsg, setKbMsg] = useState<MsgState>(null);
|
||||
|
||||
// ── Purge section ──
|
||||
const [purgeVid, setPurgeVid] = useState(() => adbCfg.find((c) => c.is_active)?.id || adbCfg[0]?.id || '');
|
||||
const [purgeTable, setPurgeTable] = useState('');
|
||||
const [purgeTenancy, setPurgeTenancy] = useState('');
|
||||
const [purging, setPurging] = useState(false);
|
||||
const [purgeMsg, setPurgeMsg] = useState<MsgState>(null);
|
||||
const [purgeConfirm, setPurgeConfirm] = useState(false);
|
||||
|
||||
// Update selTable when selVid changes
|
||||
useEffect(() => {
|
||||
const cfg = adbCfg.find((c) => c.id === selVid);
|
||||
if (cfg) {
|
||||
const tables = getActiveTables(cfg);
|
||||
setSelTable(tables[0]?.table_name || '');
|
||||
}
|
||||
}, [selVid, adbCfg]);
|
||||
|
||||
// Preconditions
|
||||
if (!adbCfg.length) {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="empty-state">
|
||||
<Database size={36} />
|
||||
<h3>{t('emb.noAdb')}</h3>
|
||||
<p>{t('emb.noAdbHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!genaiCfg.length && !ociCfg.length) {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="empty-state">
|
||||
<Dna size={36} />
|
||||
<h3>{t('emb.noOci')}</h3>
|
||||
<p>{t('emb.noOciHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Handlers ── */
|
||||
|
||||
const loadEmbs = async () => {
|
||||
if (!selVid) return;
|
||||
setListLoading(true);
|
||||
setListMsg(null);
|
||||
setDocs([]);
|
||||
try {
|
||||
const d = await embeddingsApi.list(selVid, selTable || undefined);
|
||||
setDocs(d.documents);
|
||||
setTotal(d.total);
|
||||
if (!d.documents.length) setListMsg({ type: 'i', text: t('emb.noEmbeddings') });
|
||||
} catch (err) {
|
||||
setListMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao carregar embeddings' });
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteEmb = async (docId: string) => {
|
||||
if (!confirm('Excluir este embedding?')) return;
|
||||
try {
|
||||
await embeddingsApi.remove(selVid, docId, selTable || undefined);
|
||||
loadEmbs();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Erro ao excluir');
|
||||
}
|
||||
};
|
||||
|
||||
const uploadCis = async () => {
|
||||
const f = cisPdfRef.current?.files?.[0];
|
||||
if (!cisVid || !f) { setCisMsg({ type: 'e', text: t('emb.selectAdbAndFile') }); return; }
|
||||
setCisUploading(true);
|
||||
setCisMsg({ type: 'i', text: t('emb.processing') });
|
||||
try {
|
||||
const d = await embeddingsApi.uploadFile(cisVid, 'cisrecom', f);
|
||||
setCisMsg({ type: 's', text: `${d.message} -> tabela CISRECOM` });
|
||||
if (cisPdfRef.current) cisPdfRef.current.value = '';
|
||||
} catch (err) {
|
||||
setCisMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
} finally {
|
||||
setCisUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadKbFile = async () => {
|
||||
const f = kbFileRef.current?.files?.[0];
|
||||
if (!kbVid || !f) { setKbMsg({ type: 'e', text: t('emb.selectAdbAndFile') }); return; }
|
||||
setKbFileUploading(true);
|
||||
setKbMsg({ type: 'i', text: t('emb.uploading') });
|
||||
try {
|
||||
const d = await embeddingsApi.uploadFile(kbVid, 'engineerknowledgebase', f);
|
||||
setKbMsg({ type: 's', text: d.message });
|
||||
if (kbFileRef.current) kbFileRef.current.value = '';
|
||||
} catch (err) {
|
||||
setKbMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
} finally {
|
||||
setKbFileUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadKbUrl = async () => {
|
||||
if (!kbVid || !kbUrl.trim()) { setKbMsg({ type: 'e', text: t('emb.selectAdbAndUrl') }); return; }
|
||||
setKbUrlUploading(true);
|
||||
setKbMsg({ type: 'i', text: t('emb.fetchingUrl') });
|
||||
try {
|
||||
const d = await embeddingsApi.uploadUrl(kbVid, 'engineerknowledgebase', kbUrl.trim());
|
||||
setKbMsg({ type: 's', text: d.message });
|
||||
setKbUrl('');
|
||||
} catch (err) {
|
||||
setKbMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
} finally {
|
||||
setKbUrlUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const executePurge = async () => {
|
||||
if (!purgeVid || !purgeTable) { setPurgeMsg({ type: 'e', text: t('emb.selectTable') }); return; }
|
||||
setPurging(true);
|
||||
setPurgeMsg({ type: 'i', text: t('emb.purging') });
|
||||
try {
|
||||
const d = await embeddingsApi.purge(purgeVid, purgeTable, purgeTenancy || undefined);
|
||||
setPurgeMsg({ type: 's', text: `${d.deleted} embeddings removidos da tabela ${d.table} (tenancy: ${d.tenancy})` });
|
||||
setPurgeConfirm(false);
|
||||
} catch (err) {
|
||||
setPurgeMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
} finally {
|
||||
setPurging(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Shared styles ── */
|
||||
const inputCls = 'w-full px-3 py-2 rounded-lg text-[.78rem] outline-none transition-colors focus:ring-1';
|
||||
const inputStyle = { background: 'var(--bg2)', border: '1.5px solid var(--bd)', color: 'var(--t1)' };
|
||||
const labelCls = 'block text-[.72rem] font-semibold mb-1';
|
||||
const labelStyle = { color: 'var(--t3)' };
|
||||
const cardCls = 'card';
|
||||
const cardStyle = { padding: 0 };
|
||||
const cardHeaderCls = 'card-header';
|
||||
const cardHeaderStyle = { margin: 0, padding: '14px 20px' };
|
||||
|
||||
const adbOptions = adbCfg.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.config_name}</option>
|
||||
));
|
||||
|
||||
const tableOptions = (vid: string) => {
|
||||
const cfg = adbCfg.find((c) => c.id === vid);
|
||||
if (!cfg) return null;
|
||||
return getActiveTables(cfg).map((t) => (
|
||||
<option key={t.table_name} value={t.table_name}>{t.table_name}</option>
|
||||
));
|
||||
};
|
||||
|
||||
const allTableOptions = (vid: string) => {
|
||||
const cfg = adbCfg.find((c) => c.id === vid);
|
||||
if (!cfg) return null;
|
||||
return getAllTables(cfg).map((t) => (
|
||||
<option key={t.table_name} value={t.table_name}>{t.table_name}</option>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page" style={{ overflow: 'auto', height: '100%', maxWidth: 1100 }}>
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="card-header icon" style={{ background: 'color-mix(in srgb, var(--yl) 12%, transparent)' }}>
|
||||
<Dna size={18} style={{ color: 'var(--yl)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('emb.title')}</h1>
|
||||
<div className="subtitle">
|
||||
{t('emb.subtitle')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ────── Existing Embeddings ────── */}
|
||||
<div className={cardCls} style={cardStyle}>
|
||||
<div className={cardHeaderCls} style={cardHeaderStyle}>
|
||||
<Search size={14} style={{ color: 'var(--bl)' }} />
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>{t('emb.existing')}</span>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex flex-col gap-3">
|
||||
<p className="text-[.7rem]" style={{ color: 'var(--t4)' }}>
|
||||
{t('emb.existingDesc')}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('emb.adbConnection')}</label>
|
||||
<select value={selVid} onChange={(e) => setSelVid(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
{adbOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('emb.table')}</label>
|
||||
<select value={selTable} onChange={(e) => setSelTable(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
{tableOptions(selVid)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={loadEmbs}
|
||||
disabled={listLoading}
|
||||
className="btn btn-secondary btn-sm w-full justify-center"
|
||||
>
|
||||
{listLoading ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
|
||||
{t('emb.load')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{listMsg && <Msg type={listMsg.type} text={listMsg.text} />}
|
||||
|
||||
{docs.length > 0 && (
|
||||
<>
|
||||
<div className="overflow-x-auto" style={{ borderRadius: 'var(--r)', border: '1px solid var(--bd)' }}>
|
||||
<table className="data-table" style={{ tableLayout: 'fixed' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 100 }}>ID</th>
|
||||
<th>Metadata</th>
|
||||
<th style={{ width: 60 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{docs.map((doc) => {
|
||||
let meta = doc.metadata || '\u2014';
|
||||
if (typeof meta === 'object') meta = JSON.stringify(meta);
|
||||
if (meta.length > 200) meta = meta.substring(0, 200) + '\u2026';
|
||||
return (
|
||||
<tr key={doc.id}>
|
||||
<td>
|
||||
<code className="text-[.68rem]" style={{ fontFamily: 'var(--fm)', color: 'var(--t4)', wordBreak: 'break-all' }}>
|
||||
{doc.id.substring(0, 12)}
|
||||
</code>
|
||||
</td>
|
||||
<td>
|
||||
<span className="text-[.72rem]" style={{ color: 'var(--t3)', wordBreak: 'break-word', whiteSpace: 'pre-wrap' }}>
|
||||
{meta}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button onClick={() => deleteEmb(doc.id)} className="btn btn-sm" style={{ color: 'var(--rd)', background: 'none', border: 'none', padding: 4 }}>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-[.68rem]" style={{ color: 'var(--t4)' }}>Total: {total} documentos</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ────── CIS Recommendations ────── */}
|
||||
<div className={cardCls} style={cardStyle}>
|
||||
<div className={cardHeaderCls} style={cardHeaderStyle}>
|
||||
<Shield size={14} style={{ color: 'var(--gn)' }} />
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>{t('emb.cisTitle')}</span>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex flex-col gap-3">
|
||||
<p className="text-[.7rem]" style={{ color: 'var(--t4)' }}>
|
||||
{t('emb.cisDesc')}
|
||||
</p>
|
||||
{cisMsg && <Msg type={cisMsg.type} text={cisMsg.text} />}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('emb.adbConnection')}</label>
|
||||
<select value={cisVid} onChange={(e) => setCisVid(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
{adbOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('emb.pdfFile')}</label>
|
||||
<input
|
||||
ref={cisPdfRef}
|
||||
type="file"
|
||||
accept=".pdf,.txt"
|
||||
className="w-full text-[.72rem] file:mr-2 file:px-3 file:py-1.5 file:rounded-lg file:border-0 file:text-[.68rem] file:font-semibold file:cursor-pointer"
|
||||
style={{ color: 'var(--t3)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={uploadCis}
|
||||
disabled={cisUploading}
|
||||
className="btn btn-success btn-sm w-full justify-center"
|
||||
>
|
||||
{cisUploading ? <Loader2 size={14} className="animate-spin" /> : <Upload size={14} />}
|
||||
{t('emb.uploadCis')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ────── Knowledge Base ────── */}
|
||||
<div className={cardCls} style={cardStyle}>
|
||||
<div className={cardHeaderCls} style={cardHeaderStyle}>
|
||||
<ScrollText size={14} style={{ color: 'var(--yl)' }} />
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>{t('emb.kbTitle')}</span>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex flex-col gap-3">
|
||||
<p className="text-[.7rem]" style={{ color: 'var(--t4)' }}>
|
||||
{t('emb.kbDesc')}
|
||||
</p>
|
||||
{kbMsg && <Msg type={kbMsg.type} text={kbMsg.text} />}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('emb.adbConnection')}</label>
|
||||
<select value={kbVid} onChange={(e) => setKbVid(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
{adbOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 flex-wrap mt-1">
|
||||
{/* File upload */}
|
||||
<div className="flex-1 min-w-[240px] flex flex-col gap-2">
|
||||
<label className={labelCls} style={labelStyle}>{t('emb.fileLabel')}</label>
|
||||
<input
|
||||
ref={kbFileRef}
|
||||
type="file"
|
||||
accept=".txt,.pdf,.csv,.json,.md"
|
||||
className="w-full text-[.72rem] file:mr-2 file:px-3 file:py-1.5 file:rounded-lg file:border-0 file:text-[.68rem] file:font-semibold file:cursor-pointer"
|
||||
style={{ color: 'var(--t3)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={uploadKbFile}
|
||||
disabled={kbFileUploading}
|
||||
className="btn btn-sm w-full justify-center"
|
||||
style={{ background: 'var(--bl)', color: '#fff', borderColor: 'var(--bl)' }}
|
||||
>
|
||||
{kbFileUploading ? <Loader2 size={14} className="animate-spin" /> : <FileUp size={14} />}
|
||||
{t('emb.uploadFile')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px self-stretch my-1" style={{ background: 'var(--bd)' }} />
|
||||
|
||||
{/* URL import */}
|
||||
<div className="flex-1 min-w-[240px] flex flex-col gap-2">
|
||||
<label className={labelCls} style={labelStyle}>{t('emb.urlLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={kbUrl}
|
||||
onChange={(e) => setKbUrl(e.target.value)}
|
||||
placeholder="https://docs.oracle.com/..."
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<button
|
||||
onClick={uploadKbUrl}
|
||||
disabled={kbUrlUploading}
|
||||
className="btn btn-sm w-full justify-center"
|
||||
style={{ background: 'var(--bl)', color: '#fff', borderColor: 'var(--bl)' }}
|
||||
>
|
||||
{kbUrlUploading ? <Loader2 size={14} className="animate-spin" /> : <Link size={14} />}
|
||||
{t('emb.importUrl')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ────── Purge ────── */}
|
||||
<div className={cardCls} style={{ ...cardStyle, borderColor: 'color-mix(in srgb, var(--rd) 25%, var(--bd))' }}>
|
||||
<div className={cardHeaderCls} style={cardHeaderStyle}>
|
||||
<AlertTriangle size={14} style={{ color: 'var(--rd)' }} />
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>{t('emb.purgeTitle')}</span>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex flex-col gap-3">
|
||||
<p className="text-[.7rem]" style={{ color: 'var(--t4)' }}>
|
||||
{t('emb.purgeDesc')}
|
||||
</p>
|
||||
{purgeMsg && <Msg type={purgeMsg.type} text={purgeMsg.text} />}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('emb.adbConnection')}</label>
|
||||
<select value={purgeVid} onChange={(e) => setPurgeVid(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
{adbOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('emb.table')}</label>
|
||||
<select value={purgeTable} onChange={(e) => setPurgeTable(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
<option value="">{t('common.select')}</option>
|
||||
{allTableOptions(purgeVid)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('emb.tenancyOptional')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={purgeTenancy}
|
||||
onChange={(e) => setPurgeTenancy(e.target.value)}
|
||||
placeholder="Filtrar por tenancy"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
{!purgeConfirm ? (
|
||||
<button
|
||||
onClick={() => { if (!purgeTable) { setPurgeMsg({ type: 'e', text: t('emb.selectTable') }); return; } setPurgeConfirm(true); }}
|
||||
className="btn btn-danger btn-sm w-full justify-center"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('emb.purge')}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2 w-full">
|
||||
<button
|
||||
onClick={executePurge}
|
||||
disabled={purging}
|
||||
className="flex items-center gap-1 px-3 py-2 rounded-lg text-[.72rem] font-semibold text-white flex-1 justify-center disabled:opacity-50"
|
||||
style={{ background: 'var(--rd)' }}
|
||||
>
|
||||
{purging ? <Loader2 size={12} className="animate-spin" /> : <Check size={12} />}
|
||||
{t('emb.confirm')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPurgeConfirm(false)}
|
||||
className="px-3 py-2 rounded-lg text-[.72rem] font-medium"
|
||||
style={{ background: 'var(--bg2)', color: 'var(--t3)', border: '1px solid var(--bd)' }}
|
||||
>
|
||||
{t('common.no')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
446
frontend-react/src/pages/config/GenAiConfigPage.tsx
Normal file
446
frontend-react/src/pages/config/GenAiConfigPage.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { genaiApi, type GenAiConfigFull, type GenAiConfigPayload } from '@/api/endpoints/genai';
|
||||
import {
|
||||
Brain, Plus, Pencil, Trash2, FlaskConical, Check, X, Loader2,
|
||||
ChevronRight, Star, Link as LinkIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Msg ── */
|
||||
function Msg({ type, text }: { type: 's' | 'e' | 'i'; text: string }) {
|
||||
const bg = type === 's' ? 'var(--gnl)' : type === 'e' ? 'var(--rdl)' : 'var(--bg2)';
|
||||
const fg = type === 's' ? 'var(--gn)' : type === 'e' ? 'var(--rd)' : 'var(--t2)';
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs" style={{ background: bg, color: fg }}>
|
||||
{type === 's' && <Check size={14} />}
|
||||
{type === 'e' && <X size={14} />}
|
||||
{type === 'i' && <Loader2 size={14} className="animate-spin" />}
|
||||
<span dangerouslySetInnerHTML={{ __html: text }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
export default function GenAiConfigPage() {
|
||||
const { ociCfg, models, regions } = useAppStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const [configs, setConfigs] = useState<GenAiConfigFull[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Form
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<GenAiConfigFull | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formMsg, setFormMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Form fields
|
||||
const [name, setName] = useState('');
|
||||
const [ociConfigId, setOciConfigId] = useState('');
|
||||
const [modelId, setModelId] = useState('openai.gpt-4.1');
|
||||
const [modelOcid, setModelOcid] = useState('');
|
||||
const [compartmentId, setCompartmentId] = useState('');
|
||||
const [genaiRegion, setGenaiRegion] = useState('us-ashburn-1');
|
||||
const [isDefault, setIsDefault] = useState(false);
|
||||
|
||||
// Table
|
||||
const [tableMsg, setTableMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
const [testResults, setTestResults] = useState<Record<string, { type: 's' | 'e'; text: string }>>({});
|
||||
const [testing, setTesting] = useState<Record<string, boolean>>({});
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
|
||||
const fetchConfigs = useCallback(async () => {
|
||||
try {
|
||||
const data = await genaiApi.list();
|
||||
setConfigs(data);
|
||||
} catch {} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchConfigs(); }, [fetchConfigs]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setFormOpen(false);
|
||||
setName('');
|
||||
setOciConfigId('');
|
||||
setModelId('openai.gpt-4.1');
|
||||
setModelOcid('');
|
||||
setCompartmentId('');
|
||||
setGenaiRegion('us-ashburn-1');
|
||||
setIsDefault(false);
|
||||
setFormMsg(null);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const startEdit = (cfg: GenAiConfigFull) => {
|
||||
setEditing(cfg);
|
||||
setName(cfg.name || '');
|
||||
setOciConfigId(cfg.oci_config_id || '');
|
||||
setModelId(cfg.model_ocid ? '_custom' : (cfg.model_id || 'openai.gpt-4.1'));
|
||||
setModelOcid(cfg.model_ocid || '');
|
||||
setCompartmentId(cfg.compartment_id || '');
|
||||
setGenaiRegion(cfg.genai_region || 'us-ashburn-1');
|
||||
setIsDefault(!!cfg.is_default);
|
||||
setFormOpen(true);
|
||||
setFormMsg(null);
|
||||
setTimeout(() => formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100);
|
||||
};
|
||||
|
||||
const fillFromOci = (ociId: string) => {
|
||||
setOciConfigId(ociId);
|
||||
const cfg = ociCfg.find((c) => c.id === ociId);
|
||||
if (!cfg) return;
|
||||
setGenaiRegion(cfg.region);
|
||||
if (cfg.compartment_id) setCompartmentId(cfg.compartment_id);
|
||||
setFormMsg({ type: 'i', text: `Regiao e Compartment preenchidos de <strong>${cfg.tenancy_name}</strong>` });
|
||||
setTimeout(() => setFormMsg(null), 3000);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) { setFormMsg({ type: 'e', text: t('genai.fillName') }); return; }
|
||||
if (!ociConfigId) { setFormMsg({ type: 'e', text: t('genai.selectOci') }); return; }
|
||||
if (modelId === '_custom' && !modelOcid) { setFormMsg({ type: 'e', text: t('genai.fillOcid') }); return; }
|
||||
|
||||
const body: GenAiConfigPayload = {
|
||||
name: name.trim(),
|
||||
oci_config_id: ociConfigId,
|
||||
model_id: modelId === '_custom' ? 'custom' : modelId,
|
||||
model_ocid: modelOcid || null,
|
||||
compartment_id: compartmentId,
|
||||
genai_region: genaiRegion,
|
||||
endpoint: null,
|
||||
serving_type: 'ON_DEMAND',
|
||||
dedicated_endpoint_id: null,
|
||||
is_default: editing ? !!editing.is_default : configs.length === 0,
|
||||
};
|
||||
if (isDefault) body.is_default = true;
|
||||
|
||||
setSaving(true);
|
||||
setFormMsg({ type: 'i', text: t('genai.saving') });
|
||||
|
||||
try {
|
||||
if (editing) {
|
||||
await genaiApi.update(editing.id, body);
|
||||
} else {
|
||||
await genaiApi.create(body);
|
||||
}
|
||||
setFormMsg({ type: 's', text: `Modelo ${editing ? 'atualizado' : 'salvo'} com sucesso!` });
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTimeout(resetForm, 1200);
|
||||
} catch (err) {
|
||||
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao salvar' });
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setConfirmDelete(null);
|
||||
setTableMsg({ type: 'i', text: t('genai.deleting') });
|
||||
try {
|
||||
await genaiApi.remove(id);
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: 'Modelo excluido com sucesso!' });
|
||||
if (editing?.id === id) resetForm();
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao excluir' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
setTesting((p) => ({ ...p, [id]: true }));
|
||||
setTestResults((p) => { const n = { ...p }; delete n[id]; return n; });
|
||||
try {
|
||||
const res = await genaiApi.test(id);
|
||||
setTestResults((p) => ({
|
||||
...p,
|
||||
[id]: {
|
||||
type: res.status === 'success' ? 's' : 'e',
|
||||
text: res.message + (res.response ? ` — ${res.response}` : ''),
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
setTestResults((p) => ({ ...p, [id]: { type: 'e', text: err instanceof Error ? err.message : 'Erro' } }));
|
||||
} finally {
|
||||
setTesting((p) => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Group models by provider
|
||||
const groupedModels = Object.entries(models).reduce<Record<string, { key: string; name: string }[]>>((acc, [key, m]) => {
|
||||
const provider = m.provider || 'Other';
|
||||
if (!acc[provider]) acc[provider] = [];
|
||||
acc[provider].push({ key, name: m.name });
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const isFormVisible = formOpen || !!editing;
|
||||
const inputCls = "w-full px-3 py-2 rounded-lg text-[.78rem] outline-none transition-colors focus:ring-1";
|
||||
const inputStyle = { background: 'var(--bg2)', border: '1.5px solid var(--bd)', color: 'var(--t1)' };
|
||||
const labelCls = "block";
|
||||
const labelStyle = {};
|
||||
const hintCls = "text-[.64rem] mt-0.5";
|
||||
const hintStyle = { color: 'var(--t4)' };
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--ppl)' }}>
|
||||
<Brain size={24} style={{ color: 'var(--pp)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('genai.title')}</h1>
|
||||
<div className="subtitle">{t('genai.subtitle')}</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<span className="count">
|
||||
{configs.length} {configs.length === 1 ? t('genai.model') : t('genai.models')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--ppl)' }}>
|
||||
<Brain size={16} style={{ color: 'var(--pp)' }} />
|
||||
</div>
|
||||
<h2>{t('genai.configured')}</h2>
|
||||
</div>
|
||||
|
||||
{tableMsg && <div className="px-5 py-2"><Msg type={tableMsg.type} text={tableMsg.text} /></div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--t4)' }} />
|
||||
</div>
|
||||
) : configs.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<Brain size={28} style={{ color: 'var(--t4)', margin: '0 auto 8px' }} />
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>{t('genai.noModels')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{['Config', 'Modelo', 'OCI Config', 'Regiao', 'Acoes'].map((h) => (
|
||||
<th key={h}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{configs.map((c) => {
|
||||
const mi = models[c.model_id];
|
||||
const oci = ociCfg.find((o) => o.id === c.oci_config_id);
|
||||
return (
|
||||
<tr
|
||||
key={c.id}
|
||||
className="transition-colors"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--bd)',
|
||||
background: editing?.id === c.id ? 'color-mix(in srgb, var(--pp) 5%, var(--bg1))' : undefined,
|
||||
}}
|
||||
onMouseEnter={(e) => { if (editing?.id !== c.id) e.currentTarget.style.background = 'var(--bg2)'; }}
|
||||
onMouseLeave={(e) => { if (editing?.id !== c.id) e.currentTarget.style.background = ''; }}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold" style={{ color: 'var(--t1)' }}>{c.name || '\u2014'}</span>
|
||||
{c.is_default && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[.58rem] font-semibold"
|
||||
style={{ background: 'color-mix(in srgb, #f39c12 15%, transparent)', color: '#f39c12' }}>
|
||||
<Star size={8} /> {t('genai.default')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-xs" style={{ color: 'var(--t1)' }}>
|
||||
{mi?.name || (c.model_ocid ? 'OCID Personalizado' : c.model_id)}
|
||||
</div>
|
||||
<span className="inline-block mt-0.5 px-1.5 py-0.5 rounded text-[.58rem] font-semibold"
|
||||
style={{ background: 'var(--bg2)', color: 'var(--t4)' }}>
|
||||
{mi?.provider || (c.model_ocid ? 'custom' : '?')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[.7rem]" style={{ color: 'var(--t2)' }}>
|
||||
{oci?.tenancy_name || '\u2014'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-0.5 rounded text-[.64rem] font-medium" style={{ background: 'var(--bg2)', color: 'var(--t2)', fontFamily: 'var(--fm)' }}>
|
||||
{c.genai_region}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-1.5">
|
||||
<button onClick={() => startEdit(c)} className="btn btn-secondary btn-sm">
|
||||
<Pencil size={10} /> {t('oci.edit')}
|
||||
</button>
|
||||
<button onClick={() => handleTest(c.id)} disabled={testing[c.id]} className="btn btn-secondary btn-sm">
|
||||
{testing[c.id] ? <Loader2 size={10} className="animate-spin" /> : <FlaskConical size={10} />} {t('oci.test')}
|
||||
</button>
|
||||
{confirmDelete === c.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleDelete(c.id)} className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}>
|
||||
<Check size={10} /> {t('common.yes')}
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-sm">
|
||||
{t('common.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setConfirmDelete(c.id)} className="btn btn-danger btn-sm">
|
||||
<Trash2 size={10} /> {t('oci.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{testResults[c.id] && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded text-[.62rem] font-medium"
|
||||
style={{
|
||||
background: testResults[c.id].type === 's' ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: testResults[c.id].type === 's' ? 'var(--gn)' : 'var(--rd)',
|
||||
}}>
|
||||
{testResults[c.id].type === 's' ? <Check size={10} /> : <X size={10} />}
|
||||
<span className="truncate max-w-xs">{testResults[c.id].text}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div ref={formRef} className="card" style={{ overflow: 'hidden' }}>
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-5 py-3.5 cursor-pointer select-none"
|
||||
onClick={() => { if (!editing) setFormOpen(!formOpen); }}
|
||||
>
|
||||
<ChevronRight size={14} style={{ color: 'var(--t3)', transition: 'transform .2s', transform: isFormVisible ? 'rotate(90deg)' : 'rotate(0)' }} />
|
||||
<div className="flex items-center gap-2">
|
||||
{editing ? <Pencil size={14} style={{ color: 'var(--pp)' }} /> : <Plus size={14} style={{ color: 'var(--pp)' }} />}
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
|
||||
{editing ? `${t('genai.editModel')} — ${editing.name}` : t('genai.newModel')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFormVisible && (
|
||||
<div className="px-5 pb-5 flex flex-col gap-4" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<p className="text-[.7rem] pt-3" style={{ color: 'var(--t4)' }}>
|
||||
{editing
|
||||
? <>Editando: <strong style={{ color: 'var(--t2)' }}>{editing.name}</strong></>
|
||||
: t('genai.addDesc')}
|
||||
</p>
|
||||
|
||||
{formMsg && <Msg type={formMsg.type} text={formMsg.text} />}
|
||||
|
||||
{/* Row 1 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('genai.configName')}</label>
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Meu Modelo Custom" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('genai.ociCredential')}</label>
|
||||
<select value={ociConfigId} onChange={(e) => fillFromOci(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
<option value="">{t('common.select')}</option>
|
||||
{ociCfg.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.tenancy_name} ({c.region})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('genai.modelSelect')}</label>
|
||||
<select value={modelId} onChange={(e) => setModelId(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
<option value="_custom"><LinkIcon size={10} className="inline" /> {t('genai.customOcid')}</option>
|
||||
{Object.entries(groupedModels).map(([provider, items]) => (
|
||||
<optgroup key={provider} label={provider}>
|
||||
{items.map((m) => (
|
||||
<option key={m.key} value={m.key}>{m.name} ({provider})</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('genai.genaiRegion')}</label>
|
||||
<select value={genaiRegion} onChange={(e) => setGenaiRegion(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
{regions.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('genai.compartment')}</label>
|
||||
<input type="text" value={compartmentId} onChange={(e) => setCompartmentId(e.target.value)} placeholder="ocid1.compartment.oc1.." className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('genai.modelOcid')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('genai.modelOcidHint')}</p>
|
||||
<input
|
||||
type="text"
|
||||
value={modelOcid}
|
||||
onChange={(e) => setModelOcid(e.target.value)}
|
||||
placeholder="ocid1.generativeaimodel.oc1.iad.amaaaaaa..."
|
||||
className={inputCls}
|
||||
style={{ ...inputStyle, borderColor: modelOcid ? 'var(--pp)' : undefined, fontFamily: 'var(--fm)', fontSize: '.72rem' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default toggle */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDefault(!isDefault)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-[.72rem] font-semibold transition-all"
|
||||
style={{
|
||||
background: isDefault ? 'color-mix(in srgb, #f39c12 15%, transparent)' : 'var(--bg2)',
|
||||
color: isDefault ? '#f39c12' : 'var(--t4)',
|
||||
border: `1.5px solid ${isDefault ? '#f39c12' : 'var(--bd)'}`,
|
||||
}}
|
||||
>
|
||||
<Star size={12} /> {isDefault ? t('genai.default') : t('genai.setDefault')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button onClick={handleSave} disabled={saving} className="btn btn-primary" style={{ background: 'var(--pp)', borderColor: 'var(--pp)' }}>
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{editing ? t('genai.saveChanges') : t('genai.saveModel')}
|
||||
</button>
|
||||
{editing && (
|
||||
<button onClick={resetForm} className="btn btn-secondary">
|
||||
{t('genai.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
523
frontend-react/src/pages/config/McpServersPage.tsx
Normal file
523
frontend-react/src/pages/config/McpServersPage.tsx
Normal file
@@ -0,0 +1,523 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { mcpApi, type McpServerFull, type McpServerPayload, type McpToolDef } from '@/api/endpoints/mcp';
|
||||
import {
|
||||
Plug, Plus, Pencil, Trash2, Check, X, Loader2, ChevronRight,
|
||||
Power, PowerOff, Search, Wrench, Terminal, Globe, FileCode2, ChevronDown, ChevronUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Msg ── */
|
||||
function Msg({ type, text }: { type: 's' | 'e' | 'i'; text: string }) {
|
||||
const bg = type === 's' ? 'var(--gnl)' : type === 'e' ? 'var(--rdl)' : 'var(--bg2)';
|
||||
const fg = type === 's' ? 'var(--gn)' : type === 'e' ? 'var(--rd)' : 'var(--t2)';
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs" style={{ background: bg, color: fg }}>
|
||||
{type === 's' && <Check size={14} />}
|
||||
{type === 'e' && <X size={14} />}
|
||||
{type === 'i' && <Loader2 size={14} className="animate-spin" />}
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Type badge ── */
|
||||
function TypeBadge({ type }: { type: string }) {
|
||||
const cfg: Record<string, { icon: React.ReactNode; bg: string; fg: string }> = {
|
||||
stdio: { icon: <Terminal size={10} />, bg: 'color-mix(in srgb, var(--ac) 12%, transparent)', fg: 'var(--ac)' },
|
||||
sse: { icon: <Globe size={10} />, bg: 'color-mix(in srgb, var(--gn) 12%, transparent)', fg: 'var(--gn)' },
|
||||
module: { icon: <FileCode2 size={10} />, bg: 'color-mix(in srgb, #e67e22 12%, transparent)', fg: '#e67e22' },
|
||||
};
|
||||
const c = cfg[type] || cfg.stdio;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.62rem] font-semibold" style={{ background: c.bg, color: c.fg }}>
|
||||
{c.icon} {type}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
export default function McpServersPage() {
|
||||
const { adbCfg } = useAppStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const [servers, setServers] = useState<McpServerFull[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Form
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<McpServerFull | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formMsg, setFormMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Form fields
|
||||
const [sName, setSName] = useState('');
|
||||
const [sDesc, setSDesc] = useState('');
|
||||
const [sType, setSType] = useState<'stdio' | 'sse' | 'module'>('stdio');
|
||||
const [sCmd, setSCmd] = useState('');
|
||||
const [sArgs, setSArgs] = useState('');
|
||||
const [sUrl, setSUrl] = useState('');
|
||||
const [sAdb, setSAdb] = useState('');
|
||||
|
||||
// Table
|
||||
const [tableMsg, setTableMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [toggling, setToggling] = useState<Record<string, boolean>>({});
|
||||
const [discovering, setDiscovering] = useState<Record<string, boolean>>({});
|
||||
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>({});
|
||||
|
||||
const fetchServers = useCallback(async () => {
|
||||
try {
|
||||
const data = await mcpApi.list();
|
||||
setServers(data);
|
||||
} catch {} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchServers(); }, [fetchServers]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setFormOpen(false);
|
||||
setSName('');
|
||||
setSDesc('');
|
||||
setSType('stdio');
|
||||
setSCmd('');
|
||||
setSArgs('');
|
||||
setSUrl('');
|
||||
setSAdb('');
|
||||
setFormMsg(null);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const startEdit = (srv: McpServerFull) => {
|
||||
setEditing(srv);
|
||||
setSName(srv.name);
|
||||
setSDesc(srv.description || '');
|
||||
setSType((srv.server_type || 'stdio') as 'stdio' | 'sse' | 'module');
|
||||
setSCmd(srv.command || '');
|
||||
setSArgs(srv.args ? JSON.stringify(srv.args) : '');
|
||||
setSUrl(srv.url || '');
|
||||
setSAdb(srv.linked_adb_id || '');
|
||||
setFormOpen(true);
|
||||
setFormMsg(null);
|
||||
setTimeout(() => formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!sName.trim()) { setFormMsg({ type: 'e', text: t('mcp.fillName') }); return; }
|
||||
|
||||
let args: string[] | null = null;
|
||||
if (sArgs.trim()) {
|
||||
try { args = JSON.parse(sArgs); } catch { setFormMsg({ type: 'e', text: t('mcp.invalidArgs') }); return; }
|
||||
}
|
||||
|
||||
const body: McpServerPayload = {
|
||||
name: sName.trim(),
|
||||
description: sDesc.trim() || null,
|
||||
server_type: sType,
|
||||
command: sType !== 'sse' ? (sCmd.trim() || null) : null,
|
||||
url: sType === 'sse' ? (sUrl.trim() || null) : null,
|
||||
args,
|
||||
linked_adb_id: sAdb || null,
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
setFormMsg({ type: 'i', text: editing ? t('mcp.updating') : t('mcp.saving') });
|
||||
|
||||
try {
|
||||
if (editing) {
|
||||
await mcpApi.update(editing.id, body);
|
||||
} else {
|
||||
await mcpApi.create(body);
|
||||
}
|
||||
setFormMsg({ type: 's', text: `Server ${editing ? 'atualizado' : 'registrado'} com sucesso!` });
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
setTimeout(resetForm, 1200);
|
||||
} catch (err) {
|
||||
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao salvar' });
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setConfirmDelete(null);
|
||||
setTableMsg({ type: 'i', text: 'Excluindo server...' });
|
||||
try {
|
||||
await mcpApi.remove(id);
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: 'Server excluido com sucesso!' });
|
||||
if (editing?.id === id) resetForm();
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao excluir' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (id: string) => {
|
||||
setToggling((p) => ({ ...p, [id]: true }));
|
||||
try {
|
||||
await mcpApi.toggle(id);
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
} finally {
|
||||
setToggling((p) => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscover = async (id: string) => {
|
||||
setDiscovering((p) => ({ ...p, [id]: true }));
|
||||
setTableMsg({ type: 'i', text: t('mcp.discovering') });
|
||||
try {
|
||||
const res = await mcpApi.discoverTools(id);
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: `${res.discovered} tool(s) descoberta(s), ${res.total} total` });
|
||||
setExpandedTools((p) => ({ ...p, [id]: true }));
|
||||
setTimeout(() => setTableMsg(null), 4000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao descobrir tools' });
|
||||
} finally {
|
||||
setDiscovering((p) => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTool = async (srvId: string, idx: number) => {
|
||||
const srv = servers.find((s) => s.id === srvId);
|
||||
if (!srv?.tools) return;
|
||||
const tool = srv.tools[idx];
|
||||
const toolName = typeof tool === 'string' ? tool : (tool as McpToolDef).name;
|
||||
if (!confirm(`Remover tool "${toolName}"?`)) return;
|
||||
const newTools = [...srv.tools];
|
||||
newTools.splice(idx, 1);
|
||||
try {
|
||||
await mcpApi.updateTools(srvId, newTools);
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
}
|
||||
};
|
||||
|
||||
const isFormVisible = formOpen || !!editing;
|
||||
const inputCls = "w-full px-3 py-2 rounded-lg text-[.78rem] outline-none transition-colors focus:ring-1";
|
||||
const inputStyle = { background: 'var(--bg2)', border: '1.5px solid var(--bd)', color: 'var(--t1)' };
|
||||
const labelCls = "block";
|
||||
const labelStyle = {};
|
||||
const hintCls = "text-[.64rem] mt-0.5";
|
||||
const hintStyle = { color: 'var(--t4)' };
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--gnl)' }}>
|
||||
<Plug size={24} style={{ color: 'var(--gn)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('mcp.title')}</h1>
|
||||
<div className="subtitle">{t('mcp.subtitle')}</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<span className="count">
|
||||
{servers.length} {servers.length === 1 ? 'server' : 'servers'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Server cards */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--gnl)' }}>
|
||||
<Plug size={16} style={{ color: 'var(--gn)' }} />
|
||||
</div>
|
||||
<h2>{t('mcp.registered')}</h2>
|
||||
</div>
|
||||
|
||||
{tableMsg && <div className="px-5 py-2"><Msg type={tableMsg.type} text={tableMsg.text} /></div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--t4)' }} />
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<Plug size={28} style={{ color: 'var(--t4)', margin: '0 auto 8px' }} />
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>{t('mcp.noServers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{servers.map((srv) => {
|
||||
const tools: McpToolDef[] = Array.isArray(srv.tools) ? srv.tools : [];
|
||||
const isExpanded = expandedTools[srv.id];
|
||||
const adb = srv.linked_adb_id ? adbCfg.find((a) => a.id === srv.linked_adb_id) : null;
|
||||
return (
|
||||
<div
|
||||
key={srv.id}
|
||||
className="px-5 py-4 transition-colors"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--bd)',
|
||||
background: editing?.id === srv.id ? 'color-mix(in srgb, var(--gn) 5%, var(--bg1))' : undefined,
|
||||
}}
|
||||
>
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<strong className="text-[.88rem]" style={{ color: 'var(--t1)' }}>{srv.name}</strong>
|
||||
<TypeBadge type={srv.server_type} />
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.6rem] font-semibold"
|
||||
style={{
|
||||
background: srv.is_active ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: srv.is_active ? 'var(--gn)' : 'var(--rd)',
|
||||
}}
|
||||
>
|
||||
{srv.is_active ? <Power size={8} /> : <PowerOff size={8} />}
|
||||
{srv.is_active ? t('mcp.active') : t('mcp.inactive')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button onClick={() => startEdit(srv)} className="btn btn-secondary btn-sm">
|
||||
<Pencil size={10} /> {t('mcp.edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggle(srv.id)}
|
||||
disabled={toggling[srv.id]}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
{toggling[srv.id] ? <Loader2 size={10} className="animate-spin" /> : srv.is_active ? <PowerOff size={10} /> : <Power size={10} />}
|
||||
{srv.is_active ? t('mcp.deactivate') : t('mcp.activate')}
|
||||
</button>
|
||||
{confirmDelete === srv.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleDelete(srv.id)} className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}>
|
||||
<Check size={10} /> {t('common.yes')}
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-sm">
|
||||
{t('common.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setConfirmDelete(srv.id)} className="btn btn-danger btn-sm">
|
||||
<Trash2 size={10} /> {t('mcp.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{srv.description && (
|
||||
<div className="text-[.72rem] mb-2" style={{ color: 'var(--t3)' }}>{srv.description}</div>
|
||||
)}
|
||||
|
||||
{/* Details row */}
|
||||
<div className="flex gap-4 flex-wrap text-[.72rem] mb-2" style={{ color: 'var(--t4)' }}>
|
||||
{srv.command && (
|
||||
<div>
|
||||
<span className="font-semibold" style={{ color: 'var(--t3)' }}>{t('mcp.command')}</span>{' '}
|
||||
<code className="text-[.66rem]" style={{ fontFamily: 'var(--fm)' }}>{srv.command}</code>
|
||||
</div>
|
||||
)}
|
||||
{srv.url && (
|
||||
<div>
|
||||
<span className="font-semibold" style={{ color: 'var(--t3)' }}>{t('mcp.url')}</span>{' '}
|
||||
<code className="text-[.66rem]" style={{ fontFamily: 'var(--fm)' }}>{srv.url}</code>
|
||||
</div>
|
||||
)}
|
||||
{srv.module_path && (
|
||||
<div>
|
||||
<span className="font-semibold" style={{ color: 'var(--t3)' }}>{t('mcp.module')}</span>{' '}
|
||||
<code className="text-[.66rem]" style={{ fontFamily: 'var(--fm)' }}>{srv.module_path}</code>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-semibold" style={{ color: 'var(--t3)' }}>{t('mcp.adb')}</span>{' '}
|
||||
{adb ? (
|
||||
<span className="px-1.5 py-0.5 rounded text-[.6rem] font-semibold" style={{ background: 'var(--gnl)', color: 'var(--gn)' }}>{adb.config_name}</span>
|
||||
) : t('mcp.noAdb')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools section */}
|
||||
<div className="mt-2" style={{ borderTop: '1px solid var(--bd)', paddingTop: 8 }}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer select-none mb-1"
|
||||
onClick={() => setExpandedTools((p) => ({ ...p, [srv.id]: !p[srv.id] }))}
|
||||
>
|
||||
<Wrench size={12} style={{ color: 'var(--t2)' }} />
|
||||
<span className="text-[.74rem] font-semibold" style={{ color: 'var(--t2)' }}>
|
||||
{t('mcp.tools')} ({tools.length})
|
||||
</span>
|
||||
{isExpanded ? <ChevronUp size={12} style={{ color: 'var(--t4)' }} /> : <ChevronDown size={12} style={{ color: 'var(--t4)' }} />}
|
||||
</div>
|
||||
|
||||
{isExpanded && tools.length > 0 && (
|
||||
<div className="flex flex-col gap-1 mt-1">
|
||||
{tools.map((tool, i) => (
|
||||
<div key={i} className="flex items-start gap-2 px-3 py-1.5 rounded-md text-[.72rem]" style={{ background: 'var(--bg)', border: '1px solid var(--bd)' }}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<strong style={{ color: 'var(--t1)' }}>{typeof tool === 'string' ? tool : tool.name}</strong>
|
||||
{typeof tool === 'object' && tool.description && (
|
||||
<span style={{ color: 'var(--t4)' }}> — {tool.description}</span>
|
||||
)}
|
||||
{typeof tool === 'object' && tool.input_schema && (tool.input_schema as { properties?: Record<string, unknown> }).properties && (
|
||||
<div className="text-[.64rem] mt-0.5" style={{ color: 'var(--t4)' }}>
|
||||
Params: {Object.keys((tool.input_schema as { properties: Record<string, unknown> }).properties).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveTool(srv.id, i)}
|
||||
className="flex-shrink-0 px-1.5 py-0.5 rounded text-[.6rem] font-semibold transition-colors"
|
||||
style={{ color: 'var(--rd)', background: 'color-mix(in srgb, var(--rd) 8%, transparent)' }}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isExpanded && tools.length === 0 && (
|
||||
<div className="text-[.72rem] py-1" style={{ color: 'var(--t4)' }}>{t('mcp.noTools')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2 mt-3 flex-wrap">
|
||||
<button
|
||||
onClick={() => handleDiscover(srv.id)}
|
||||
disabled={discovering[srv.id]}
|
||||
className="btn btn-success btn-sm"
|
||||
>
|
||||
{discovering[srv.id] ? <Loader2 size={12} className="animate-spin" /> : <Search size={12} />}
|
||||
{t('mcp.discoverTools')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div ref={formRef} className="card" style={{ overflow: 'hidden' }}>
|
||||
<div className="flex items-center gap-2.5 px-5 py-3.5 cursor-pointer select-none" onClick={() => { if (!editing) setFormOpen(!formOpen); }}>
|
||||
<ChevronRight size={14} style={{ color: 'var(--t3)', transition: 'transform .2s', transform: isFormVisible ? 'rotate(90deg)' : 'rotate(0)' }} />
|
||||
<div className="flex items-center gap-2">
|
||||
{editing ? <Pencil size={14} style={{ color: 'var(--gn)' }} /> : <Plus size={14} style={{ color: 'var(--gn)' }} />}
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
|
||||
{editing ? `${t('mcp.editServer')} — ${editing.name}` : t('mcp.newServer')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFormVisible && (
|
||||
<div className="px-5 pb-5 flex flex-col gap-4" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<p className="text-[.7rem] pt-3" style={{ color: 'var(--t4)' }}>
|
||||
{editing
|
||||
? <>Editando: <strong style={{ color: 'var(--t2)' }}>{editing.name}</strong></>
|
||||
: t('mcp.configDesc')}
|
||||
</p>
|
||||
|
||||
{formMsg && <Msg type={formMsg.type} text={formMsg.text} />}
|
||||
|
||||
{/* Name + description */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.name')}</label>
|
||||
<input type="text" value={sName} onChange={(e) => setSName(e.target.value)} placeholder="CIS Benchmark Server" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.description')}</label>
|
||||
<input type="text" value={sDesc} onChange={(e) => setSDesc(e.target.value)} placeholder="Executa checks CIS 3.0" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server type */}
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.serverType')}</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
{(['stdio', 'sse', 'module'] as const).map((tp) => {
|
||||
const icons = { stdio: <Terminal size={12} />, sse: <Globe size={12} />, module: <FileCode2 size={12} /> };
|
||||
const labels = { stdio: 'stdio', sse: 'SSE (HTTP)', module: 'Python Module' };
|
||||
const selected = sType === tp;
|
||||
return (
|
||||
<button
|
||||
key={tp}
|
||||
type="button"
|
||||
onClick={() => setSType(tp)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.72rem] font-semibold transition-all"
|
||||
style={{
|
||||
background: selected ? 'color-mix(in srgb, var(--gn) 15%, transparent)' : 'var(--bg2)',
|
||||
color: selected ? 'var(--gn)' : 'var(--t4)',
|
||||
border: `1.5px solid ${selected ? 'var(--gn)' : 'var(--bd)'}`,
|
||||
}}
|
||||
>
|
||||
{icons[tp]} {labels[tp]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conditional fields */}
|
||||
{sType !== 'sse' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.commandLabel')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('mcp.commandHint')}</p>
|
||||
<input type="text" value={sCmd} onChange={(e) => setSCmd(e.target.value)} placeholder="python3 server.py" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.argsLabel')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('mcp.argsHint')}</p>
|
||||
<input type="text" value={sArgs} onChange={(e) => setSArgs(e.target.value)} placeholder='["--config", "/path/to/config"]' className={inputCls} style={{ ...inputStyle, fontFamily: 'var(--fm)', fontSize: '.72rem' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sType === 'sse' && (
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.urlLabel')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('mcp.urlHint')}</p>
|
||||
<input type="text" value={sUrl} onChange={(e) => setSUrl(e.target.value)} placeholder="http://localhost:8001/sse" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ADB link */}
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.linkAdb')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('mcp.linkAdbHint')}</p>
|
||||
<select value={sAdb} onChange={(e) => setSAdb(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
<option value="">{t('mcp.noAdb')}</option>
|
||||
{adbCfg.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.config_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button onClick={handleSave} disabled={saving} className="btn btn-success">
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{editing ? t('mcp.saveChanges') : t('mcp.saveServer')}
|
||||
</button>
|
||||
{editing && (
|
||||
<button onClick={resetForm} className="btn btn-secondary">
|
||||
{t('mcp.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
775
frontend-react/src/pages/config/OciConfigPage.tsx
Normal file
775
frontend-react/src/pages/config/OciConfigPage.tsx
Normal file
@@ -0,0 +1,775 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { ociApi, type OciConfigFull, type OciTestResult } from '@/api/endpoints/oci';
|
||||
import {
|
||||
Cloud, ChevronRight, Key, Lock, Plus, Pencil,
|
||||
Trash2, FlaskConical, Building2, User, Package,
|
||||
Check, X, Loader2, Search,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Static fallback OCI regions ── */
|
||||
const FALLBACK_REGIONS: Record<string, string[]> = {
|
||||
'Americas': [
|
||||
'us-ashburn-1', 'us-phoenix-1', 'us-sanjose-1', 'us-chicago-1',
|
||||
'ca-toronto-1', 'ca-montreal-1', 'sa-saopaulo-1', 'sa-vinhedo-1',
|
||||
'sa-santiago-1', 'sa-bogota-1', 'mx-queretaro-1', 'mx-monterrey-1',
|
||||
],
|
||||
'Europe': [
|
||||
'eu-frankfurt-1', 'eu-amsterdam-1', 'eu-zurich-1', 'eu-madrid-1',
|
||||
'eu-marseille-1', 'eu-milan-1', 'eu-stockholm-1', 'eu-paris-1',
|
||||
'uk-london-1', 'uk-cardiff-1', 'eu-jovanovac-1', 'eu-dcc-milan-1',
|
||||
],
|
||||
'Asia Pacific': [
|
||||
'ap-tokyo-1', 'ap-osaka-1', 'ap-seoul-1', 'ap-sydney-1',
|
||||
'ap-melbourne-1', 'ap-mumbai-1', 'ap-hyderabad-1', 'ap-singapore-1',
|
||||
'ap-chuncheon-1', 'ap-singapore-2',
|
||||
],
|
||||
'Middle East & Africa': [
|
||||
'me-jeddah-1', 'me-dubai-1', 'me-abudhabi-1',
|
||||
'af-johannesburg-1', 'il-jerusalem-1',
|
||||
],
|
||||
};
|
||||
|
||||
type AuthType = 'api_key' | 'session_token';
|
||||
|
||||
/* ── Msg component ── */
|
||||
function Msg({ type, text }: { type: 's' | 'e' | 'i'; text: string }) {
|
||||
const bg = type === 's' ? 'var(--gnl)' : type === 'e' ? 'var(--rdl)' : 'var(--bg2)';
|
||||
const fg = type === 's' ? 'var(--gn)' : type === 'e' ? 'var(--rd)' : 'var(--t2)';
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs" style={{ background: bg, color: fg }}>
|
||||
{type === 's' && <Check size={14} />}
|
||||
{type === 'e' && <X size={14} />}
|
||||
{type === 'i' && <Loader2 size={14} className="animate-spin" />}
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Region Dropdown ── */
|
||||
function RegionDropdown({
|
||||
value, onChange, regionsMap, t,
|
||||
}: { value: string; onChange: (r: string) => void; regionsMap: Record<string, string[]>; t: (key: string) => string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const f = filter.toLowerCase();
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<div
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center justify-between cursor-pointer px-3 py-2 rounded-lg text-[.78rem]"
|
||||
style={{ background: 'var(--bg2)', border: '1.5px solid var(--bd)', minHeight: 36 }}
|
||||
>
|
||||
<span style={{ color: value ? 'var(--t1)' : 'var(--t4)' }}>
|
||||
{value || t('oci.selectRegion')}
|
||||
</span>
|
||||
<span className="text-[.6rem]" style={{ color: 'var(--t4)' }}>▼</span>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute top-full left-0 right-0 rounded-lg overflow-hidden z-50"
|
||||
style={{ background: 'var(--bg)', border: '1px solid var(--bd)', boxShadow: '0 4px 16px rgba(0,0,0,.25)', maxHeight: 280 }}
|
||||
>
|
||||
<div className="p-1.5" style={{ borderBottom: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center gap-1.5 px-2" style={{ background: 'var(--bg2)', borderRadius: 6 }}>
|
||||
<Search size={12} style={{ color: 'var(--t4)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('oci.searchRegion')}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full border-none outline-none text-xs py-1.5"
|
||||
style={{ background: 'transparent', color: 'var(--t1)' }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto" style={{ maxHeight: 230 }}>
|
||||
{Object.entries(regionsMap).map(([grp, regs]) => {
|
||||
const filtered = regs.filter((r) => !f || r.includes(f));
|
||||
if (!filtered.length) return null;
|
||||
return (
|
||||
<div key={grp}>
|
||||
<div
|
||||
className="px-3 py-1 text-[.62rem] font-semibold uppercase tracking-wider"
|
||||
style={{ color: 'var(--t4)', background: 'var(--bg1)' }}
|
||||
>
|
||||
{grp}
|
||||
</div>
|
||||
{filtered.map((r) => (
|
||||
<div
|
||||
key={r}
|
||||
onClick={() => { onChange(r); setOpen(false); setFilter(''); }}
|
||||
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
|
||||
style={{
|
||||
color: value === r ? 'var(--ac)' : 'var(--t2)',
|
||||
background: value === r ? 'color-mix(in srgb, var(--ac) 8%, transparent)' : undefined,
|
||||
}}
|
||||
onMouseEnter={(e) => { if (value !== r) (e.target as HTMLDivElement).style.background = 'var(--bg2)'; }}
|
||||
onMouseLeave={(e) => { if (value !== r) (e.target as HTMLDivElement).style.background = ''; }}
|
||||
>
|
||||
{r}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Object.entries(regionsMap).every(([, regs]) => !regs.some((r) => !f || r.includes(f))) && (
|
||||
<div className="p-3 text-xs" style={{ color: 'var(--t4)' }}>{t('oci.noRegion')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Auth Type Tag ── */
|
||||
function AuthTag({ type }: { type: string }) {
|
||||
const isToken = type === 'session_token';
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.62rem] font-semibold"
|
||||
style={{
|
||||
background: isToken ? 'rgba(230,126,34,0.12)' : 'var(--gnl)',
|
||||
color: isToken ? '#e67e22' : 'var(--gn)',
|
||||
}}
|
||||
>
|
||||
{isToken ? <Lock size={10} /> : <Key size={10} />}
|
||||
{isToken ? 'Session Token' : 'API Key'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main Page ── */
|
||||
export default function OciConfigPage() {
|
||||
const ociRegions = useAppStore((s) => s.ociRegions);
|
||||
const { t } = useI18n();
|
||||
|
||||
const [configs, setConfigs] = useState<OciConfigFull[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Form state
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<OciConfigFull | null>(null);
|
||||
const [authType, setAuthType] = useState<AuthType>('api_key');
|
||||
const [region, setRegion] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formMsg, setFormMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
|
||||
// Table msg
|
||||
const [tableMsg, setTableMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
|
||||
// Test results per config
|
||||
const [testResults, setTestResults] = useState<Record<string, OciTestResult>>({});
|
||||
const [testing, setTesting] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Delete confirm
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
|
||||
// Form refs
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
const tnRef = useRef<HTMLInputElement>(null);
|
||||
const toRef = useRef<HTMLInputElement>(null);
|
||||
const uoRef = useRef<HTMLInputElement>(null);
|
||||
const fpRef = useRef<HTMLInputElement>(null);
|
||||
const cpRef = useRef<HTMLInputElement>(null);
|
||||
const kpRef = useRef<HTMLInputElement>(null);
|
||||
const skRef = useRef<HTMLInputElement>(null);
|
||||
const pkRef = useRef<HTMLInputElement>(null);
|
||||
const stkRef = useRef<HTMLTextAreaElement>(null);
|
||||
const sshRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Build regions map
|
||||
const regionsMap: Record<string, string[]> = (() => {
|
||||
if (ociRegions && typeof ociRegions === 'object') {
|
||||
const entries = Object.entries(ociRegions);
|
||||
if (entries.length > 0) {
|
||||
// ociRegions can be { group: string[] } or { group: string }
|
||||
// From the store it's Record<string,string>, but the backend returns Record<string,string[]>
|
||||
const first = entries[0][1];
|
||||
if (Array.isArray(first)) return ociRegions as unknown as Record<string, string[]>;
|
||||
// If it's flat, return as single-group
|
||||
const flat = entries.map(([, v]) => v as unknown as string);
|
||||
if (flat.length > 0) return { 'Regioes': flat };
|
||||
}
|
||||
}
|
||||
return FALLBACK_REGIONS;
|
||||
})();
|
||||
|
||||
const fetchConfigs = useCallback(async () => {
|
||||
try {
|
||||
const data = await ociApi.list();
|
||||
setConfigs(data);
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchConfigs(); }, [fetchConfigs]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setFormOpen(false);
|
||||
setAuthType('api_key');
|
||||
setRegion('');
|
||||
setFormMsg(null);
|
||||
setSaving(false);
|
||||
// Clear file inputs
|
||||
if (skRef.current) skRef.current.value = '';
|
||||
if (pkRef.current) pkRef.current.value = '';
|
||||
};
|
||||
|
||||
const startEdit = (cfg: OciConfigFull) => {
|
||||
setEditing(cfg);
|
||||
setAuthType(cfg.auth_type || 'api_key');
|
||||
setRegion(cfg.region);
|
||||
setFormOpen(true);
|
||||
setFormMsg(null);
|
||||
setTimeout(() => formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const tenancyName = tnRef.current?.value?.trim() || '';
|
||||
if (!tenancyName) { setFormMsg({ type: 'e', text: `${t('oci.fillField')} Tenancy Name` }); return; }
|
||||
|
||||
const isEdit = !!editing;
|
||||
const at = isEdit ? (editing!.auth_type || 'api_key') : authType;
|
||||
const isToken = at === 'session_token';
|
||||
|
||||
const tenancyOcid = toRef.current?.value?.trim() || '';
|
||||
const userOcid = uoRef.current?.value?.trim() || '';
|
||||
const fingerprint = fpRef.current?.value?.trim() || '';
|
||||
const compartmentId = cpRef.current?.value?.trim() || '';
|
||||
const keyPassphrase = kpRef.current?.value?.trim() || '';
|
||||
const securityToken = stkRef.current?.value?.trim() || '';
|
||||
const sshPubKey = sshRef.current?.value?.trim() || '';
|
||||
const privateKeyFile = skRef.current?.files?.[0];
|
||||
const publicKeyFile = pkRef.current?.files?.[0];
|
||||
|
||||
// Validations for new config
|
||||
if (!isEdit) {
|
||||
if (!tenancyOcid) { setFormMsg({ type: 'e', text: t('oci.fillOcidTenancy') }); return; }
|
||||
if (!isToken && (!userOcid || !fingerprint)) { setFormMsg({ type: 'e', text: t('oci.fillUserAndFP') }); return; }
|
||||
if (isToken && !securityToken) { setFormMsg({ type: 'e', text: t('oci.pasteToken') }); return; }
|
||||
if (!privateKeyFile) { setFormMsg({ type: 'e', text: isToken ? t('oci.selectSessionKey') : t('oci.selectKey') }); return; }
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('tenancy_name', tenancyName);
|
||||
fd.append('tenancy_ocid', tenancyOcid);
|
||||
fd.append('user_ocid', userOcid);
|
||||
fd.append('fingerprint', fingerprint);
|
||||
fd.append('region', region);
|
||||
fd.append('compartment_id', compartmentId);
|
||||
fd.append('key_passphrase', keyPassphrase);
|
||||
fd.append('ssh_public_key', sshPubKey);
|
||||
fd.append('auth_type', at);
|
||||
fd.append('security_token', securityToken);
|
||||
if (privateKeyFile) fd.append('private_key', privateKeyFile);
|
||||
if (publicKeyFile) fd.append('public_key', publicKeyFile);
|
||||
|
||||
setSaving(true);
|
||||
setFormMsg({ type: 'i', text: t('oci.saving') });
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await ociApi.update(editing!.id, fd);
|
||||
} else {
|
||||
await ociApi.create(fd);
|
||||
}
|
||||
setFormMsg({ type: 's', text: isEdit ? t('oci.updated') : t('oci.saved') });
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTimeout(resetForm, 1200);
|
||||
} catch (err) {
|
||||
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao salvar' });
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setConfirmDelete(null);
|
||||
setTableMsg({ type: 'i', text: t('oci.deleting') });
|
||||
try {
|
||||
await ociApi.remove(id);
|
||||
await fetchConfigs();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: t('oci.deleted') });
|
||||
if (editing?.id === id) resetForm();
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao excluir' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
setTesting((p) => ({ ...p, [id]: true }));
|
||||
setTestResults((p) => { const n = { ...p }; delete n[id]; return n; });
|
||||
try {
|
||||
const res = await ociApi.test(id);
|
||||
setTestResults((p) => ({ ...p, [id]: res }));
|
||||
} catch (err) {
|
||||
setTestResults((p) => ({ ...p, [id]: { status: 'error', message: err instanceof Error ? err.message : 'Erro' } }));
|
||||
} finally {
|
||||
setTesting((p) => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const isFormVisible = formOpen || !!editing;
|
||||
const currentAuthType = editing ? (editing.auth_type || 'api_key') : authType;
|
||||
const isToken = currentAuthType === 'session_token';
|
||||
|
||||
/* ── Input helper ── */
|
||||
const inputCls = "w-full px-3 py-2 rounded-lg text-[.78rem] outline-none transition-colors focus:ring-1";
|
||||
const inputStyle = { background: 'var(--bg2)', border: '1.5px solid var(--bd)', color: 'var(--t1)' };
|
||||
const labelCls = "block";
|
||||
const labelStyle = {};
|
||||
const hintCls = "text-[.64rem] mt-0.5";
|
||||
const hintStyle = { color: 'var(--t4)' };
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<Cloud size={24} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('oci.title')}</h1>
|
||||
<div className="subtitle">{t('oci.subtitle')}</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<span className="count">
|
||||
{configs.length} {configs.length === 1 ? t('oci.credential') : t('oci.credentials')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Table ── */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<Cloud size={16} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<h2>{t('oci.registered')}</h2>
|
||||
</div>
|
||||
|
||||
{tableMsg && <div className="px-5 py-2">{<Msg type={tableMsg.type} text={tableMsg.text} />}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--t4)' }} />
|
||||
</div>
|
||||
) : configs.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<Cloud size={28} style={{ color: 'var(--t4)', margin: '0 auto 8px' }} />
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>{t('oci.noCredentials')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{['Tenancy', 'Tipo', 'Region', 'Detalhes', 'Acoes'].map((h) => (
|
||||
<th key={h}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{configs.map((c) => (
|
||||
<tr
|
||||
key={c.id}
|
||||
className="transition-colors"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--bd)',
|
||||
background: editing?.id === c.id ? 'color-mix(in srgb, var(--ac) 5%, var(--bg1))' : undefined,
|
||||
}}
|
||||
onMouseEnter={(e) => { if (editing?.id !== c.id) (e.currentTarget.style.background = 'var(--bg2)'); }}
|
||||
onMouseLeave={(e) => { if (editing?.id !== c.id) (e.currentTarget.style.background = ''); }}
|
||||
>
|
||||
{/* Tenancy */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-xs font-semibold" style={{ color: 'var(--t1)' }}>{c.tenancy_name}</div>
|
||||
<div className="text-[.6rem] mt-0.5" style={{ color: 'var(--t4)' }}>{c.created_at}</div>
|
||||
</td>
|
||||
{/* Auth Type */}
|
||||
<td className="px-4 py-3"><AuthTag type={c.auth_type} /></td>
|
||||
{/* Region */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-0.5 rounded text-[.64rem] font-medium" style={{ background: 'var(--bg2)', color: 'var(--t2)' }}>
|
||||
{c.region}
|
||||
</span>
|
||||
</td>
|
||||
{/* Details */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-0.5 text-[.62rem] leading-relaxed" style={{ color: 'var(--t4)', fontFamily: 'var(--fm)' }}>
|
||||
{(c.auth_type || 'api_key') === 'session_token' ? (
|
||||
<>
|
||||
<span className="flex items-center gap-1" title="Tenancy OCID"><Building2 size={10} /> {c.tenancy_ocid}</span>
|
||||
<span className="flex items-center gap-1" title="Auth"><Lock size={10} /> Session Token</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex items-center gap-1" title="Fingerprint"><Key size={10} /> {'*'.repeat(20)}</span>
|
||||
<span className="flex items-center gap-1" title="Tenancy OCID"><Building2 size={10} /> {c.tenancy_ocid}</span>
|
||||
<span className="flex items-center gap-1" title="User OCID"><User size={10} /> {c.user_ocid}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="flex items-center gap-1" title="Compartment"><Package size={10} /> {c.compartment_id || '\u2014'}</span>
|
||||
<span className="flex items-center gap-1" title="SSH Public Key"><Key size={10} /> SSH: {c.ssh_public_key_preview || <em>{t('oci.sshNotConfigured')}</em>}</span>
|
||||
</div>
|
||||
</td>
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={() => startEdit(c)}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
<Pencil size={10} /> {t('oci.edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTest(c.id)}
|
||||
disabled={testing[c.id]}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
{testing[c.id] ? <Loader2 size={10} className="animate-spin" /> : <FlaskConical size={10} />} {t('oci.test')}
|
||||
</button>
|
||||
{confirmDelete === c.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}
|
||||
>
|
||||
<Check size={10} /> {t('oci.yes')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
{t('oci.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(c.id)}
|
||||
className="btn btn-danger btn-sm"
|
||||
>
|
||||
<Trash2 size={10} /> {t('oci.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Test result inline */}
|
||||
{testResults[c.id] && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded text-[.62rem] font-medium"
|
||||
style={{
|
||||
background: testResults[c.id].status === 'success' ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: testResults[c.id].status === 'success' ? 'var(--gn)' : 'var(--rd)',
|
||||
}}
|
||||
>
|
||||
{testResults[c.id].status === 'success' ? <Check size={10} /> : <X size={10} />}
|
||||
{testResults[c.id].message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Form Card ── */}
|
||||
<div ref={formRef} className="card" style={{ overflow: 'hidden' }}>
|
||||
{/* Collapsible header */}
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-5 py-3.5 cursor-pointer select-none"
|
||||
onClick={() => { if (!editing) setFormOpen(!formOpen); }}
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
style={{ color: 'var(--t3)', transition: 'transform .2s', transform: isFormVisible ? 'rotate(90deg)' : 'rotate(0)' }}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{editing ? <Pencil size={14} style={{ color: 'var(--ac)' }} /> : <Plus size={14} style={{ color: 'var(--ac)' }} />}
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
|
||||
{editing ? t('oci.editCredential') : t('oci.newCredential')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFormVisible && (
|
||||
<div className="px-5 pb-5 flex flex-col gap-4" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-[.7rem] pt-3" style={{ color: 'var(--t4)' }}>
|
||||
{editing
|
||||
? <>{t('oci.editing')} <strong style={{ color: 'var(--t2)' }}>{editing.tenancy_name}</strong></>
|
||||
: t('oci.addDesc')}
|
||||
</p>
|
||||
|
||||
{/* Form message */}
|
||||
{formMsg && <Msg type={formMsg.type} text={formMsg.text} />}
|
||||
|
||||
{/* Auth type toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => { if (!editing) setAuthType('api_key'); }}
|
||||
disabled={!!editing}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.72rem] font-semibold transition-all disabled:opacity-70"
|
||||
style={{
|
||||
background: !isToken ? 'color-mix(in srgb, var(--ac) 15%, transparent)' : 'var(--bg2)',
|
||||
color: !isToken ? 'var(--ac)' : 'var(--t4)',
|
||||
border: `1.5px solid ${!isToken ? 'var(--ac)' : 'var(--bd)'}`,
|
||||
}}
|
||||
>
|
||||
<Key size={12} /> API Key
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (!editing) setAuthType('session_token'); }}
|
||||
disabled={!!editing}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.72rem] font-semibold transition-all disabled:opacity-70"
|
||||
style={{
|
||||
background: isToken ? 'color-mix(in srgb, #e67e22 15%, transparent)' : 'var(--bg2)',
|
||||
color: isToken ? '#e67e22' : 'var(--t4)',
|
||||
border: `1.5px solid ${isToken ? '#e67e22' : 'var(--bd)'}`,
|
||||
}}
|
||||
>
|
||||
<Lock size={12} /> Session Token
|
||||
</button>
|
||||
{editing && (
|
||||
<span className="text-[.6rem]" style={{ color: 'var(--t4)' }}>{t('oci.typeCannotChange')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 1: Tenancy Name + OCID Tenancy */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.tenancyName')}</label>
|
||||
<input
|
||||
ref={tnRef}
|
||||
type="text"
|
||||
placeholder="minha-empresa"
|
||||
defaultValue={editing?.tenancy_name || ''}
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.ocidTenancy')}</label>
|
||||
<input
|
||||
ref={toRef}
|
||||
type="password"
|
||||
placeholder="ocid1.tenancy.oc1.."
|
||||
autoComplete="off"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{editing && (
|
||||
<p className={hintCls} style={hintStyle}>{t('oci.changeHint')} <code style={{ color: 'var(--t3)' }}>{editing.tenancy_ocid}</code></p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Region + Compartment */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.region')}</label>
|
||||
<RegionDropdown value={region} onChange={setRegion} regionsMap={regionsMap} t={t} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.compartment')}</label>
|
||||
<input
|
||||
ref={cpRef}
|
||||
type="password"
|
||||
placeholder="ocid1.compartment.oc1.."
|
||||
autoComplete="off"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{editing && (
|
||||
<p className={hintCls} style={hintStyle}>{t('oci.changeHint')} <code style={{ color: 'var(--t3)' }}>{editing.compartment_id || '\u2014'}</code></p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key fields: User OCID + Fingerprint */}
|
||||
{!isToken && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.ocidUser')}</label>
|
||||
<input
|
||||
ref={uoRef}
|
||||
type="password"
|
||||
placeholder="ocid1.user.oc1.."
|
||||
autoComplete="off"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{editing && (
|
||||
<p className={hintCls} style={hintStyle}>{t('oci.changeHint')} <code style={{ color: 'var(--t3)' }}>{editing.user_ocid}</code></p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.fingerprint')}</label>
|
||||
<input
|
||||
ref={fpRef}
|
||||
type="password"
|
||||
placeholder="aa:bb:cc:dd:..."
|
||||
autoComplete="off"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{editing && (
|
||||
<p className={hintCls} style={hintStyle}>{t('oci.changeHint')} <code style={{ color: 'var(--t3)' }}>{editing.fingerprint}</code></p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session Token fields */}
|
||||
{isToken && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.sessionToken')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>
|
||||
{t('oci.sessionTokenHint')} <code style={{ color: 'var(--t3)' }}>oci session authenticate</code>
|
||||
</p>
|
||||
<textarea
|
||||
ref={stkRef}
|
||||
rows={3}
|
||||
placeholder="Conteudo do arquivo ~/.oci/sessions/DEFAULT/token"
|
||||
className={inputCls}
|
||||
style={{ ...inputStyle, resize: 'vertical', fontFamily: 'var(--fm)', fontSize: '.68rem' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>
|
||||
{t('oci.ocidUser')} <span style={{ fontSize: '.6rem', color: 'var(--t4)' }}>(opcional)</span>
|
||||
</label>
|
||||
<input
|
||||
ref={uoRef}
|
||||
type="password"
|
||||
placeholder="ocid1.user.oc1.. (preenchido automaticamente)"
|
||||
autoComplete="off"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<p className={hintCls} style={hintStyle}>O SDK pode resolver o user a partir do token. Deixe vazio se preferir.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keys row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>
|
||||
{isToken ? t('oci.sessionKeyLabel') : t('oci.privateKey')}
|
||||
</label>
|
||||
{editing && <p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('oci.keepKey')}</p>}
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>
|
||||
{isToken
|
||||
? <span>Arquivo <code style={{ color: 'var(--t3)' }}>oci_api_key.pem</code> da pasta de sessao</span>
|
||||
: 'Chave privada da API Key OCI'}
|
||||
</p>
|
||||
<input
|
||||
ref={skRef}
|
||||
type="file"
|
||||
accept=".pem"
|
||||
className="text-[.72rem] file:mr-2 file:px-3 file:py-1.5 file:rounded-lg file:border-0 file:text-[.68rem] file:font-semibold file:cursor-pointer"
|
||||
style={{ color: 'var(--t3)' }}
|
||||
/>
|
||||
</div>
|
||||
{!isToken ? (
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.publicKey')}</label>
|
||||
<input
|
||||
ref={pkRef}
|
||||
type="file"
|
||||
accept=".pem"
|
||||
className="text-[.72rem] file:mr-2 file:px-3 file:py-1.5 file:rounded-lg file:border-0 file:text-[.68rem] file:font-semibold file:cursor-pointer"
|
||||
style={{ color: 'var(--t3)' }}
|
||||
/>
|
||||
</div>
|
||||
) : <div />}
|
||||
</div>
|
||||
|
||||
{/* Passphrase + SSH */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{!isToken ? (
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.keyPassphrase')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('oci.keyPassphraseHint')}</p>
|
||||
<input
|
||||
ref={kpRef}
|
||||
type="password"
|
||||
placeholder="(opcional)"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
) : <div />}
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('oci.sshPublicKey')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('oci.sshPublicKeyHint')}</p>
|
||||
<textarea
|
||||
ref={sshRef}
|
||||
rows={2}
|
||||
placeholder="ssh-rsa AAAAB3NzaC1yc2E... (opcional)"
|
||||
defaultValue={editing?.ssh_public_key || ''}
|
||||
className={inputCls}
|
||||
style={{ ...inputStyle, resize: 'vertical', fontFamily: 'var(--fm)', fontSize: '.72rem' }}
|
||||
/>
|
||||
{editing?.ssh_public_key_preview && (
|
||||
<p className={hintCls} style={hintStyle}>Atual: <code style={{ color: 'var(--t3)' }}>{editing.ssh_public_key_preview}</code></p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{editing ? t('oci.saveChanges') : t('oci.saveCredential')}
|
||||
</button>
|
||||
{editing && (
|
||||
<button
|
||||
onClick={resetForm}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('oci.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
frontend-react/src/stores/app.ts
Normal file
142
frontend-react/src/stores/app.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { create } from 'zustand';
|
||||
import client from '@/api/client';
|
||||
|
||||
export interface OciConfig {
|
||||
id: string;
|
||||
tenancy_name: string;
|
||||
region: string;
|
||||
compartment_id?: string;
|
||||
auth_type?: string;
|
||||
}
|
||||
|
||||
export interface GenAiConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
model_id: string;
|
||||
model_ocid?: string;
|
||||
oci_config_id: string;
|
||||
compartment_id?: string;
|
||||
genai_region?: string;
|
||||
endpoint?: string;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface EmbeddingModelInfo {
|
||||
name: string;
|
||||
dims: number;
|
||||
}
|
||||
|
||||
export interface McpServer {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
server_type: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
url?: string;
|
||||
module_path?: string;
|
||||
is_active: boolean;
|
||||
tools?: McpTool[];
|
||||
linked_adb_id?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface McpTool {
|
||||
name: string;
|
||||
description?: string;
|
||||
input_schema?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AdbTable {
|
||||
id: string;
|
||||
table_name: string;
|
||||
description?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface AdbConfig {
|
||||
id: string;
|
||||
config_name: string;
|
||||
dsn: string;
|
||||
username: string;
|
||||
table_name?: string;
|
||||
use_mtls: boolean;
|
||||
is_active: boolean;
|
||||
wallet_dir?: string;
|
||||
genai_config_id?: string;
|
||||
embedding_model_id?: string;
|
||||
tables?: AdbTable[];
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
// Shared config data
|
||||
ociCfg: OciConfig[];
|
||||
genaiCfg: GenAiConfig[];
|
||||
models: Record<string, ModelInfo>;
|
||||
regions: string[];
|
||||
ociRegions: Record<string, string[]>;
|
||||
embModels: Record<string, EmbeddingModelInfo>;
|
||||
mcpSvr: McpServer[];
|
||||
adbCfg: AdbConfig[];
|
||||
|
||||
// UI
|
||||
sidebarOpen: boolean;
|
||||
toggleSidebar: () => void;
|
||||
|
||||
// Load all shared data after auth
|
||||
loadData: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
ociCfg: [],
|
||||
genaiCfg: [],
|
||||
models: {},
|
||||
regions: [],
|
||||
ociRegions: {},
|
||||
embModels: {},
|
||||
mcpSvr: [],
|
||||
adbCfg: [],
|
||||
sidebarOpen: true,
|
||||
|
||||
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
|
||||
|
||||
loadData: async () => {
|
||||
try {
|
||||
const [ociCfg, genaiCfg, mcpSvr] = await Promise.all([
|
||||
client.get('/oci/configs'),
|
||||
client.get('/genai/configs'),
|
||||
client.get('/mcp/servers'),
|
||||
]) as unknown as [OciConfig[], GenAiConfig[], McpServer[]];
|
||||
set({ ociCfg, genaiCfg, mcpSvr });
|
||||
|
||||
try {
|
||||
const m = await client.get('/genai/models') as unknown as {
|
||||
models: Record<string, ModelInfo>;
|
||||
regions: string[];
|
||||
oci_regions: Record<string, string[]>;
|
||||
embedding_models: Record<string, EmbeddingModelInfo>;
|
||||
};
|
||||
set({
|
||||
models: m.models,
|
||||
regions: m.regions,
|
||||
ociRegions: m.oci_regions || {},
|
||||
embModels: m.embedding_models || {},
|
||||
});
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const adbCfg = await client.get('/adb/configs') as unknown as AdbConfig[];
|
||||
set({ adbCfg });
|
||||
} catch {
|
||||
set({ adbCfg: [] });
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
}));
|
||||
61
frontend-react/src/stores/auth.ts
Normal file
61
frontend-react/src/stores/auth.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { create } from 'zustand';
|
||||
import { authApi, type User, type LoginResponse } from '@/api/endpoints/auth';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
mfaRequired: boolean;
|
||||
mfaSetup: boolean;
|
||||
totpUri: string | null;
|
||||
|
||||
login: (username: string, password: string, totp?: string) => Promise<LoginResponse>;
|
||||
logout: () => Promise<void>;
|
||||
checkAuth: () => Promise<void>;
|
||||
setToken: (token: string) => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
token: localStorage.getItem('t'),
|
||||
loading: true,
|
||||
mfaRequired: false,
|
||||
mfaSetup: false,
|
||||
totpUri: null,
|
||||
|
||||
login: async (username, password, totp) => {
|
||||
const res = await authApi.login(username, password, totp);
|
||||
if (res.mfa_required) {
|
||||
set({ mfaRequired: true, mfaSetup: !!res.mfa_setup, totpUri: res.totp_uri || null });
|
||||
return res;
|
||||
}
|
||||
if (res.token && res.user) {
|
||||
localStorage.setItem('t', res.token);
|
||||
set({ token: res.token, user: res.user, mfaRequired: false, mfaSetup: false, totpUri: null });
|
||||
}
|
||||
return res;
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
try { await authApi.logout(); } catch {}
|
||||
localStorage.removeItem('t');
|
||||
set({ user: null, token: null, mfaRequired: false });
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
const token = get().token;
|
||||
if (!token) { set({ loading: false }); return; }
|
||||
try {
|
||||
const user = await authApi.me();
|
||||
set({ user, loading: false });
|
||||
} catch {
|
||||
localStorage.removeItem('t');
|
||||
set({ token: null, user: null, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setToken: (token) => {
|
||||
localStorage.setItem('t', token);
|
||||
set({ token });
|
||||
},
|
||||
}));
|
||||
28
frontend-react/tsconfig.app.json
Normal file
28
frontend-react/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend-react/tsconfig.json
Normal file
7
frontend-react/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend-react/tsconfig.node.json
Normal file
26
frontend-react/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
21
frontend-react/vite.config.ts
Normal file
21
frontend-react/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
base: '/app/',
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1433,7 +1433,7 @@ function rTerraform(){
|
||||
Resources${S.tfResources?(' ('+Object.values(S.tfResources).reduce((a,b)=>a+(Array.isArray(b)?b.length:0),0)+')'):''}
|
||||
</div>
|
||||
<div class="tf-bottom-actions">
|
||||
${S.tfFiles.length?'<button class="btn bs bsm" onclick="tfDlAll()" style="font-size:.66rem;padding:2px 8px">Download All</button>':''}
|
||||
${S.tfFiles.length?'<button class="btn bs bsm" onclick="tfDlAll()" style="font-size:.66rem;padding:2px 8px">Download ZIP</button>':''}
|
||||
${rTfActions()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1605,11 +1605,44 @@ function tfDlFile(i){
|
||||
const b=new Blob([f.content],{type:'text/plain'});const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download=f.name;a.click();URL.revokeObjectURL(a.href);
|
||||
}
|
||||
function tfDlAll(){
|
||||
// Download each file
|
||||
const files=[...S.tfFiles];
|
||||
const oc=S.ociCfg.find(x=>x.id===S.tfOci);
|
||||
if(oc){const comp=S.tfCompartment||oc.compartment_id||'';if(!files.find(f=>f.name==='terraform.tfvars'))files.push({name:'terraform.tfvars',content:`compartment_id = "${comp}"\nregion = "${oc.region}"`,auto:true})}
|
||||
files.forEach((f,i)=>{setTimeout(()=>{const b=new Blob([f.content],{type:'text/plain'});const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download=f.name;a.click();URL.revokeObjectURL(a.href)},i*200)});
|
||||
// Build ZIP using raw deflate-less store (no external lib needed)
|
||||
const enc=new TextEncoder();
|
||||
const parts=[];const centralDir=[];let offset=0;
|
||||
for(const f of files){
|
||||
const data=enc.encode(f.content);const nameB=enc.encode(f.name);
|
||||
const now=new Date();const time=((now.getHours()<<11)|(now.getMinutes()<<5)|(now.getSeconds()>>1))&0xFFFF;
|
||||
const date=(((now.getFullYear()-1980)<<9)|((now.getMonth()+1)<<5)|now.getDate())&0xFFFF;
|
||||
const crc=_crc32(data);
|
||||
// Local file header (30 + name + data)
|
||||
const lh=new Uint8Array(30+nameB.length+data.length);const lv=new DataView(lh.buffer);
|
||||
lv.setUint32(0,0x04034b50,true);lv.setUint16(4,20,true);lv.setUint16(6,0,true);lv.setUint16(8,0,true);
|
||||
lv.setUint16(10,time,true);lv.setUint16(12,date,true);lv.setUint32(14,crc,true);
|
||||
lv.setUint32(18,data.length,true);lv.setUint32(22,data.length,true);lv.setUint16(26,nameB.length,true);
|
||||
lh.set(nameB,30);lh.set(data,30+nameB.length);
|
||||
parts.push(lh);
|
||||
// Central directory entry (46 + name)
|
||||
const cd=new Uint8Array(46+nameB.length);const cv=new DataView(cd.buffer);
|
||||
cv.setUint32(0,0x02014b50,true);cv.setUint16(4,20,true);cv.setUint16(6,20,true);cv.setUint16(8,0,true);
|
||||
cv.setUint16(10,0,true);cv.setUint16(12,time,true);cv.setUint16(14,date,true);cv.setUint32(16,crc,true);
|
||||
cv.setUint32(20,data.length,true);cv.setUint32(24,data.length,true);cv.setUint16(28,nameB.length,true);
|
||||
cv.setUint32(42,offset,true);cd.set(nameB,46);
|
||||
centralDir.push(cd);
|
||||
offset+=lh.length;
|
||||
}
|
||||
const cdSize=centralDir.reduce((s,c)=>s+c.length,0);
|
||||
const eocd=new Uint8Array(22);const ev=new DataView(eocd.buffer);
|
||||
ev.setUint32(0,0x06054b50,true);ev.setUint16(8,files.length,true);ev.setUint16(10,files.length,true);
|
||||
ev.setUint32(12,cdSize,true);ev.setUint32(16,offset,true);
|
||||
const blob=new Blob([...parts,...centralDir,eocd],{type:'application/zip'});
|
||||
const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='terraform.zip';a.click();URL.revokeObjectURL(a.href);
|
||||
}
|
||||
function _crc32(buf){
|
||||
let crc=0xFFFFFFFF;
|
||||
for(let i=0;i<buf.length;i++){crc^=buf[i];for(let j=0;j<8;j++)crc=(crc>>>1)^(crc&1?0xEDB88320:0)}
|
||||
return(crc^0xFFFFFFFF)>>>0;
|
||||
}
|
||||
|
||||
function tfPickModel(v){S.tfModel=v;S.tfMdOpen=false;if(v&&!v.startsWith('cfg:')&&!S.tfOci&&S.ociCfg.length){S.tfOci=S.ociCfg[0].id;S.tfRegion=S.ociCfg[0].region;S.tfCompartment=S.ociCfg[0].compartment_id||'';tfLoadComps()}else if(v&&v.startsWith('cfg:')){const g=S.genaiCfg.find(x=>x.id===v.slice(4));if(g&&g.oci_config_id&&!S.tfOci){S.tfOci=g.oci_config_id;const c=S.ociCfg.find(x=>x.id===g.oci_config_id);if(c){S.tfRegion=c.region;S.tfCompartment=c.compartment_id||''}tfLoadComps()}}R()}
|
||||
@@ -1725,7 +1758,7 @@ function tfSwitchBtab(tab){
|
||||
const left=document.querySelector('.tf-bottom-content');if(left)left.innerHTML=rTfBottomContent();
|
||||
const tabs=document.querySelectorAll('.tf-btab');tabs.forEach(t=>{const isActive=t.textContent.trim().toLowerCase().startsWith(tab);t.classList.toggle('active',isActive)});
|
||||
// Update actions
|
||||
const acts=document.querySelector('.tf-bottom-actions');if(acts){acts.innerHTML=(S.tfFiles.length?'<button class="btn bs bsm" onclick="tfDlAll()" style="font-size:.66rem;padding:2px 8px">Download All</button>':'')+rTfActions()}
|
||||
const acts=document.querySelector('.tf-bottom-actions');if(acts){acts.innerHTML=(S.tfFiles.length?'<button class="btn bs bsm" onclick="tfDlAll()" style="font-size:.66rem;padding:2px 8px">Download ZIP</button>':'')+rTfActions()}
|
||||
}
|
||||
function tfToggleModelDrop(){const dd=document.getElementById('tfmdd');dd.classList.toggle('open');S.tfMdOpen=dd.classList.contains('open');if(S.tfMdOpen){const inp=document.getElementById('tfmds');if(inp){inp.value='';inp.focus();tfFilterModels('')}}}
|
||||
function tfFilterModels(q){const list=document.getElementById('tfmdl');if(!list)return;const items=list.querySelectorAll('.itm,.grp');const ql=q.toLowerCase();let lastGrp=null,vis=false;items.forEach(el=>{if(el.classList.contains('grp')){if(lastGrp)lastGrp.style.display=vis?'':'none';lastGrp=el;vis=false}else{const show=!q||el.textContent.toLowerCase().includes(ql);el.style.display=show?'':'none';if(show)vis=true}});if(lastGrp)lastGrp.style.display=vis?'':'none'}
|
||||
|
||||
@@ -3,7 +3,17 @@ server {
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 50M;
|
||||
absolute_redirect off;
|
||||
|
||||
# New React app at /app/
|
||||
location ^~ /app/ {
|
||||
root /usr/share/nginx/html;
|
||||
# Files are in /usr/share/nginx/html/app/ (mapped from dist/)
|
||||
try_files $uri $uri/ /app/index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# Legacy SPA at /
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
@@ -11,6 +21,7 @@ server {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# Backend API proxy
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
BIN
scripts/Cloud Security Assessment - Mutant - CIS 3.0.pdf
Normal file
BIN
scripts/Cloud Security Assessment - Mutant - CIS 3.0.pdf
Normal file
Binary file not shown.
657
scripts/reportgenerator.js
Normal file
657
scripts/reportgenerator.js
Normal file
@@ -0,0 +1,657 @@
|
||||
// n8n Code node (JavaScript)
|
||||
// Relatório HTML -> PDF (Gotenberg):
|
||||
// - Capa
|
||||
// - Purpose + Disclaimer juntos
|
||||
// - TOC em nova página (listando cada recomendação: "Rec — Title")
|
||||
// - Executive Summary em nova página (se existir)
|
||||
// - Findings Overview em nova página (tabela DOMAINS / TOTAL CONTROLS / FAILED / PASSED)
|
||||
// - Seções (cards): Asset Management começa em nova página
|
||||
//
|
||||
// Seções:
|
||||
// - "Logging and Monitoring1" -> "Logging and Monitoring"
|
||||
// - Remove seção "Storage" genérica; mantém apenas:
|
||||
// Storage - Block Volumes / Storage - File Storage Service / Storage - Object Storage
|
||||
//
|
||||
// Status:
|
||||
// - Compliant = Yes
|
||||
// - Non-compliant = tudo que NÃO for Yes (inclui Unknown)
|
||||
//
|
||||
// Deduplicação forte para evitar duplicatas vindas do fluxo/merge.
|
||||
//
|
||||
// Alteração pedida AGORA:
|
||||
// - Remove o valor que aparece logo abaixo do gráfico (ex.: "100%").
|
||||
// Ou seja: mantém o gráfico + o % dentro do próprio gráfico (lado esquerdo), mas remove a linha extra.
|
||||
|
||||
const items = $input.all();
|
||||
|
||||
// ====== INPUT SPLIT ======
|
||||
const summaryTexts = items
|
||||
.map(i => i.json?.output)
|
||||
.filter(v => typeof v === "string")
|
||||
.map(s => String(s).trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const allRowsRaw = items
|
||||
.map(i => i.json?.output)
|
||||
.filter(v => v && typeof v === "object" && !Array.isArray(v));
|
||||
|
||||
// Ajuste se tenancyName vier em outro campo:
|
||||
const tenancyName = ($json?.tenancyName || $json?.tenancy || "Nome do Tenancy");
|
||||
const version = "1.0";
|
||||
|
||||
// Texto fixo
|
||||
const PURPOSE_STATEMENT_TEXT =
|
||||
"The purpose of the cloud security assessment is to comprehensively evaluate the organization's security posture in its cloud environment, identifying vulnerabilities and gaps that could compromise data integrity, confidentiality, and availability. This assessment aims to enhance information security maturity, strengthen defenses against cyber threats, ensure compliance with regulations, and foster a culture of security awareness. Through periodic assessments, the organization proactively mitigates risks, ensuring continuous security for cloud-configured assets and maintaining stakeholders' trust in data protection capabilities.";
|
||||
|
||||
const DISCLAIMER_TEXT =
|
||||
"This document in any form, software or printed matter, contains proprietary information that is the exclusive property of Oracle. Your access to and use of this confidential material is subject to the terms and conditions of your Oracle software license and service agreement, which has been executed and with which you agree to comply. This document and information contained herein may not be disclosed, copied, reproduced or distributed to anyone outside Oracle without prior written consent of Oracle. This document is not part of your license agreement, nor can it be incorporated into any contractual agreement with Oracle or its subsidiaries or affiliates.\n\nThis document is for informational purposes only and is intended solely to assist you in planning for the implementation and upgrade of the product features described. It is not a commitment to deliver any material, code, or functionality, and should not be relied upon in making purchasing decisions. The development, release, timing, and pricing of any features or functionality described in this document remains at the sole discretion of Oracle. Due to the nature of the product architecture, it may not be possible to safely include all features described in this document without risking significant destabilization of the code.";
|
||||
|
||||
// ====== HELPERS ======
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function normStr(v) {
|
||||
return String(v ?? "").trim();
|
||||
}
|
||||
|
||||
// Apenas Yes/No (Unknown vira No)
|
||||
function normStatus2(v) {
|
||||
const s = normStr(v).toLowerCase();
|
||||
if (["yes", "true", "compliant", "ok"].includes(s)) return "Yes";
|
||||
return "No";
|
||||
}
|
||||
|
||||
function parsePercent(value) {
|
||||
const s = normStr(value);
|
||||
if (!s) return null;
|
||||
const m = s.replace(",", ".").match(/(\d+(\.\d+)?)/);
|
||||
if (!m) return null;
|
||||
const n = Number(m[1]);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return Math.max(0, Math.min(100, n));
|
||||
}
|
||||
|
||||
function parseFindingsNumber(value) {
|
||||
const s = normStr(value);
|
||||
if (!s) return null;
|
||||
const m = s.replaceAll(",", "").match(/\d+/);
|
||||
if (!m) return null;
|
||||
const n = Number(m[0]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function groupBy(arr, keyFn) {
|
||||
const map = new Map();
|
||||
for (const x of arr) {
|
||||
const k = keyFn(x);
|
||||
if (!map.has(k)) map.set(k, []);
|
||||
map.get(k).push(x);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// Ordenação "1.2" < "1.10" < "2.1"
|
||||
function sortRecommendation(a, b) {
|
||||
const pa = String(a ?? "").split(".").map(x => Number(x));
|
||||
const pb = String(b ?? "").split(".").map(x => Number(x));
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const da = pa[i] ?? 0;
|
||||
const db = pb[i] ?? 0;
|
||||
if (da !== db) return da - db;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function monthYearPt(date = new Date()) {
|
||||
const months = [
|
||||
"Janeiro", "Fevereiro", "Março", "Abril", "Maio", "Junho",
|
||||
"Julho", "Agosto", "Setembro", "Outubro", "Novembro", "Dezembro",
|
||||
];
|
||||
return `${months[date.getMonth()]}, ${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
function progressBar(pct) {
|
||||
if (pct === null) {
|
||||
return `<div class="pct-row"><div class="pct mono muted">N/A</div></div>`;
|
||||
}
|
||||
const width = Math.max(0, Math.min(100, pct));
|
||||
const cls = pct <= 20 ? "bad" : pct <= 60 ? "warn" : "ok";
|
||||
return `
|
||||
<div class="pct-row">
|
||||
<div class="pct mono">${pct.toFixed(0)}%</div>
|
||||
<div class="bar"><div class="fill ${cls}" style="width:${width}%"></div></div>
|
||||
</div>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
function field(label, value, opts = {}) {
|
||||
const v = value === undefined || value === null ? "" : String(value);
|
||||
if (!opts.showWhenEmpty && !normStr(v)) return "";
|
||||
return `
|
||||
<div class="field">
|
||||
<div class="label">${escapeHtml(label)}</div>
|
||||
<div class="value">${escapeHtml(v)}</div>
|
||||
</div>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
function pill(label, value) {
|
||||
const v = normStr(value);
|
||||
if (!v) return "";
|
||||
return `<span class="pill"><span class="pill-k">${escapeHtml(label)}:</span> ${escapeHtml(v)}</span>`;
|
||||
}
|
||||
|
||||
function badge2(status2) {
|
||||
return status2 === "Yes"
|
||||
? `<span class="badge yes">Compliant</span>`
|
||||
: `<span class="badge no">Non-compliant</span>`;
|
||||
}
|
||||
|
||||
function slugify(s) {
|
||||
return String(s ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function sectionId(sectionName) {
|
||||
return `section-${slugify(sectionName) || "unspecified"}`;
|
||||
}
|
||||
|
||||
// ====== SECTION NORMALIZATION + FILTERING ======
|
||||
const ALLOWED_SECTIONS = new Set([
|
||||
"Asset Management",
|
||||
"Compute",
|
||||
"Identity and Access Management",
|
||||
"Logging and Monitoring",
|
||||
"Networking",
|
||||
"Storage - Block Volumes",
|
||||
"Storage - File Storage Service",
|
||||
"Storage - Object Storage",
|
||||
]);
|
||||
|
||||
function normalizeSectionName(raw) {
|
||||
let s = String(raw ?? "").trim();
|
||||
s = s.replace(/\s+/g, " ");
|
||||
|
||||
if (/^Logging and Monitoring\s*\d*$/i.test(s)) return "Logging and Monitoring";
|
||||
if (/^Storage$/i.test(s)) return null;
|
||||
|
||||
if (/^Storage\s*-\s*Block Volumes$/i.test(s)) return "Storage - Block Volumes";
|
||||
if (/^Storage\s*-\s*File Storage Service$/i.test(s)) return "Storage - File Storage Service";
|
||||
if (/^Storage\s*-\s*Object Storage$/i.test(s)) return "Storage - Object Storage";
|
||||
|
||||
return s || null;
|
||||
}
|
||||
|
||||
// ====== DEDUPE ======
|
||||
function makeDedupKey(row, normalizedSection) {
|
||||
const section = normalizedSection || "Unspecified";
|
||||
const rec = normStr(row["Recommendation #"]) || "no-rec";
|
||||
const title = normStr(row["Title"]) || "no-title";
|
||||
return `${section}||${rec}||${title}`.toLowerCase();
|
||||
}
|
||||
|
||||
const dedupMap = new Map();
|
||||
for (const r of allRowsRaw) {
|
||||
const sec = normalizeSectionName(r["Section"]);
|
||||
if (!sec) continue;
|
||||
if (!ALLOWED_SECTIONS.has(sec)) continue;
|
||||
|
||||
const key = makeDedupKey(r, sec);
|
||||
if (!dedupMap.has(key)) {
|
||||
dedupMap.set(key, { ...r, Section: sec });
|
||||
}
|
||||
}
|
||||
const allRows = [...dedupMap.values()];
|
||||
|
||||
// ====== ENRICH ======
|
||||
const now = new Date();
|
||||
const monthYear = monthYearPt(now);
|
||||
|
||||
const enrichedAll = allRows.map(r => {
|
||||
const pctRaw = r["Compliance Percentage Per Recommendation"];
|
||||
const pct = parsePercent(pctRaw);
|
||||
const findingsRaw = r["Findings"];
|
||||
const findingsNum = parseFindingsNumber(findingsRaw);
|
||||
const status2 = normStatus2(r["Compliant"]);
|
||||
|
||||
const rec = normStr(r["Recommendation #"]) || "rec";
|
||||
const section = normStr(r["Section"]) || "Unspecified";
|
||||
const recIdVal = `rec-${slugify(section)}-${slugify(rec)}`;
|
||||
|
||||
return {
|
||||
...r,
|
||||
__status2: status2,
|
||||
__pct: pct,
|
||||
__pctRaw: pctRaw,
|
||||
__findingsNum: findingsNum,
|
||||
__findingsRaw: findingsRaw,
|
||||
__recId: recIdVal,
|
||||
};
|
||||
});
|
||||
|
||||
const bySection = groupBy(enrichedAll, r => normStr(r["Section"]) || "Unspecified");
|
||||
|
||||
const SECTION_ORDER = [
|
||||
"Asset Management",
|
||||
"Compute",
|
||||
"Identity and Access Management",
|
||||
"Logging and Monitoring",
|
||||
"Networking",
|
||||
"Storage - Block Volumes",
|
||||
"Storage - File Storage Service",
|
||||
"Storage - Object Storage",
|
||||
];
|
||||
|
||||
const sectionEntries = SECTION_ORDER
|
||||
.map(name => {
|
||||
const rows = bySection.get(name) || [];
|
||||
const total = rows.length;
|
||||
const passed = rows.filter(r => r.__status2 === "Yes").length;
|
||||
const failed = total - passed;
|
||||
return { name, id: sectionId(name), total, passed, failed, rows };
|
||||
})
|
||||
.filter(s => s.total > 0);
|
||||
|
||||
const totalAll = enrichedAll.length;
|
||||
const counts = enrichedAll.reduce((acc, r) => {
|
||||
acc[r.__status2] = (acc[r.__status2] ?? 0) + 1;
|
||||
return acc;
|
||||
}, { Yes: 0, No: 0 });
|
||||
|
||||
const extractDates = [...new Set(allRows.map(r => normStr(r["extract_date"])).filter(Boolean))];
|
||||
const extractDateCover = extractDates.length ? extractDates[0] : "";
|
||||
|
||||
// ====== TOC: Section + each recommendation ======
|
||||
const toc = [];
|
||||
toc.push({ title: "Purpose statement", href: "#purpose", right: "" });
|
||||
toc.push({ title: "Disclaimer", href: "#disclaimer", right: "" });
|
||||
if (summaryTexts.length) toc.push({ title: "Executive Summary", href: "#exec-summary", right: "" });
|
||||
toc.push({ title: "Findings Overview", href: "#findings-overview", right: "" });
|
||||
|
||||
for (const s of sectionEntries) {
|
||||
toc.push({ title: s.name, href: `#${s.id}`, right: String(s.total), level: 0 });
|
||||
|
||||
const rowsSorted = [...s.rows].sort((a, b) =>
|
||||
sortRecommendation(a["Recommendation #"], b["Recommendation #"])
|
||||
);
|
||||
|
||||
for (const r of rowsSorted) {
|
||||
const rec = normStr(r["Recommendation #"]) || "-";
|
||||
const title = normStr(r["Title"]) || "-";
|
||||
toc.push({
|
||||
title: `${rec} — ${title}`,
|
||||
href: `#${r.__recId}`,
|
||||
right: "",
|
||||
level: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function tocLine({ title, href, right, level = 0 }) {
|
||||
const indentClass = level === 1 ? "toc-l1" : "toc-l0";
|
||||
const safeRight = right ? escapeHtml(right) : " ";
|
||||
return `
|
||||
<div class="toc-line ${indentClass}">
|
||||
<a class="toc-link" href="${escapeHtml(href)}">${escapeHtml(title)}</a>
|
||||
<span class="toc-leader" aria-hidden="true"></span>
|
||||
<span class="toc-num">${safeRight}</span>
|
||||
</div>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
// ====== HTML ======
|
||||
const html = `
|
||||
<!doctype html>
|
||||
<html lang="pt-br">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Oracle Cloud Security Assessment</title>
|
||||
|
||||
<style>
|
||||
@page { size: A4; margin: 14mm; }
|
||||
html, body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
|
||||
:root{
|
||||
--text:#0f172a;
|
||||
--muted:#64748b;
|
||||
--line:#e2e8f0;
|
||||
--soft:#f8fafc;
|
||||
--oracle-red:#ff0000;
|
||||
|
||||
--header-blue:#2f5597;
|
||||
--row-alt:#e8f0f7;
|
||||
--fail:#c00000;
|
||||
--pass:#0f7b0f;
|
||||
|
||||
--bad:#ef4444;
|
||||
--warn:#f59e0b;
|
||||
--ok:#22c55e;
|
||||
}
|
||||
|
||||
body{ margin:0; font-family: Arial, Helvetica, sans-serif; color: var(--text); background: #ffffff; }
|
||||
.wrap{ max-width: 980px; margin: 0 auto; }
|
||||
a{ color: inherit; text-decoration: none; }
|
||||
a:hover{ text-decoration: underline; }
|
||||
.page-break-before{ break-before: page; page-break-before: always; }
|
||||
|
||||
/* CAPA */
|
||||
.cover{
|
||||
position: relative;
|
||||
min-height: calc(297mm - 28mm);
|
||||
page-break-after: always;
|
||||
background: #fff;
|
||||
}
|
||||
.cover-oracle{ position: absolute; top: 0; left: 0; font-weight: 700; letter-spacing: 0.6px; color: var(--oracle-red); font-size: 18px; }
|
||||
.cover-rect{ position: absolute; top: 26mm; left: 6mm; right: 6mm; height: 82mm; border: 1px solid #9ca3af; }
|
||||
.cover-title{ position: absolute; left: 0; bottom: 62mm; font-size: 44px; line-height: 1.05; font-weight: 500; color: #111827; max-width: 165mm; }
|
||||
.cover-subtitle{ position: absolute; left: 0; bottom: 52mm; font-size: 12px; color: #111827; }
|
||||
.cover-meta{ position: absolute; left: 0; bottom: 18mm; font-size: 11px; color: #111827; line-height: 1.45; }
|
||||
|
||||
/* PD */
|
||||
.pd{
|
||||
border:1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
margin: 0 0 12px;
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.pd h2{ font-size: 13px; margin: 0 0 6px; color: var(--oracle-red); }
|
||||
.pd p{
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
text-align: justify;
|
||||
text-justify: inter-word;
|
||||
}
|
||||
.pd .spacer{ height: 8px; }
|
||||
|
||||
/* TOC */
|
||||
.toc{ background: #fff; margin: 0 0 12px; padding: 0; }
|
||||
.toc-brand{ color: var(--oracle-red); font-weight: 700; letter-spacing: .6px; font-size: 16px; margin: 0 0 6px; }
|
||||
.toc-title{ color: var(--oracle-red); font-size: 13px; margin: 0 0 10px; }
|
||||
.toc-rule{ height: 1px; background: #cbd5e1; margin: 0 0 10px; }
|
||||
.toc-lines{ display: flex; flex-direction: column; gap: 6px; font-size: 12px; }
|
||||
.toc-line{ display: grid; grid-template-columns: auto 1fr auto; align-items: baseline; gap: 10px; }
|
||||
.toc-l0{ padding-left: 0; font-weight: 600; }
|
||||
.toc-l1{ padding-left: 14px; font-weight: 400; color: #111827; }
|
||||
.toc-link{ min-width: 0; overflow-wrap: anywhere; word-break: break-word; }
|
||||
.toc-leader{ border-bottom: 1px dotted #94a3b8; transform: translateY(-2px); }
|
||||
.toc-num{ width: 28px; text-align: right; color: #111827; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Summary */
|
||||
.summary{ border:1px solid var(--line); background: #fff; border-radius: 12px; padding: 12px 14px; margin: 0 0 12px; }
|
||||
.summary h2{ font-size: 14px; margin: 0 0 8px; }
|
||||
.summary .sub{ font-size: 12px; color: var(--muted); line-height: 1.4; margin: 0 0 10px; }
|
||||
.summary-text{
|
||||
font-size: 12px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;
|
||||
color: #111827; padding: 10px 10px; border: 1px solid #f1f5f9; border-radius: 10px;
|
||||
background: linear-gradient(180deg, #ffffff, var(--soft)); margin-top: 10px;
|
||||
break-inside: avoid; page-break-inside: avoid; text-align: justify; text-justify: inter-word;
|
||||
}
|
||||
|
||||
/* Findings Overview (tabela) */
|
||||
.overview{
|
||||
border:1px solid var(--line);
|
||||
background:#fff;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.overview h1{ font-size: 16px; margin: 0 0 10px; }
|
||||
.overview-sub{ font-size: 12px; color: var(--muted); margin-bottom: 10px; }
|
||||
.domains-table{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
}
|
||||
.domains-table thead th{
|
||||
background: var(--header-blue);
|
||||
color: #fff;
|
||||
padding: 8px 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .4px;
|
||||
font-size: 11px;
|
||||
border-right: 1px solid rgba(255,255,255,.2);
|
||||
text-align: center;
|
||||
}
|
||||
.domains-table thead th:first-child{ text-align: left; }
|
||||
.domains-table thead th:last-child{ border-right: none; }
|
||||
.domains-table tbody td{
|
||||
padding: 7px 10px;
|
||||
border-top: 1px solid #cbd5e1;
|
||||
border-right: 1px solid #cbd5e1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.domains-table tbody td:last-child{ border-right: none; }
|
||||
.domains-table tbody tr:nth-child(odd){ background: var(--row-alt); }
|
||||
.dom{ font-weight: 600; }
|
||||
.num{ text-align: center; font-variant-numeric: tabular-nums; }
|
||||
.fail{ color: var(--fail); font-weight: 700; }
|
||||
.pass{ color: var(--pass); font-weight: 700; }
|
||||
|
||||
/* Section + Cards */
|
||||
.section{ margin-top: 14px; }
|
||||
.section h2{ font-size: 14px; margin: 14px 0 10px; }
|
||||
.group-title{ margin: 12px 0 8px; font-size: 13px; font-weight: 800; }
|
||||
.group-title.yes{ color: #166534; }
|
||||
.group-title.no{ color: #991b1b; }
|
||||
|
||||
.cards{ display:flex; flex-direction:column; gap: 10px; }
|
||||
.card{
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
padding: 10px 12px;
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-head{
|
||||
display:flex;
|
||||
align-items:flex-start;
|
||||
justify-content:space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.card-head > div{ min-width: 0; }
|
||||
.left{ flex: 2 1 420px; min-width: 0; }
|
||||
.right{ flex: 1 1 240px; max-width: 320px; margin-left: auto; }
|
||||
.card-title{ font-size: 13px; font-weight: 800; margin: 0; line-height: 1.3; overflow-wrap: anywhere; word-break: break-word; }
|
||||
.meta-row{ margin-top: 6px; display:flex; flex-wrap:wrap; gap: 6px; align-items: center; }
|
||||
|
||||
.pill{ display:inline-block; padding: 2px 8px; border:1px solid var(--line); border-radius: 999px; font-size: 11px; color: var(--muted); background: #fff; white-space: nowrap; }
|
||||
.pill-k{ font-weight: 700; color: var(--muted); }
|
||||
|
||||
.badge{ display:inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 800; border: 1px solid var(--line); white-space: nowrap; }
|
||||
.badge.yes{ color:#166534; background: #dcfce7; border-color:#86efac; }
|
||||
.badge.no{ color:#991b1b; background: #fee2e2; border-color:#fca5a5; }
|
||||
|
||||
.mono{ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
.muted{ color: var(--muted); }
|
||||
|
||||
.field{ margin-top: 8px; }
|
||||
.label{ font-size: 11px; font-weight: 800; color: var(--muted); margin-bottom: 3px; }
|
||||
.value{
|
||||
font-size: 12px; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;
|
||||
text-align: justify; text-justify: inter-word;
|
||||
}
|
||||
|
||||
.pct-row{ display:flex; align-items:center; gap:10px; }
|
||||
.pct{ width: 44px; text-align:right; font-size: 12px; }
|
||||
.bar{ flex: 1; height: 10px; background: #eef2ff; border: 1px solid #e5e7eb; border-radius: 999px; overflow: hidden; }
|
||||
.fill{ height: 100%; border-radius: 999px; }
|
||||
.fill.bad{ background: var(--bad); }
|
||||
.fill.warn{ background: var(--warn); }
|
||||
.fill.ok{ background: var(--ok); }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<!-- CAPA -->
|
||||
<div class="cover">
|
||||
<div class="cover-oracle">ORACLE</div>
|
||||
<div class="cover-rect"></div>
|
||||
|
||||
<div class="cover-title">Oracle Cloud Security<br/>Assessment</div>
|
||||
<div class="cover-subtitle">Tenancy – Oracle Cloud Infrastructure</div>
|
||||
|
||||
<div class="cover-meta">
|
||||
<div><b>Tenancy:</b> ${escapeHtml(tenancyName)}</div>
|
||||
<div>${escapeHtml(monthYear)}, Version [${escapeHtml(version)}]</div>
|
||||
${extractDateCover ? `<div><b>Extract date:</b> ${escapeHtml(extractDateCover)}</div>` : ""}
|
||||
<div>Copyright © 2025, Oracle and/or its affiliates</div>
|
||||
<div>Confidential – Oracle Restricted</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Purpose + Disclaimer -->
|
||||
<div class="pd" id="purpose">
|
||||
<h2>Purpose Statement</h2>
|
||||
<p>${escapeHtml(PURPOSE_STATEMENT_TEXT)}</p>
|
||||
<div class="spacer"></div>
|
||||
<h2 id="disclaimer">Disclaimer</h2>
|
||||
<p>${escapeHtml(DISCLAIMER_TEXT)}</p>
|
||||
</div>
|
||||
|
||||
<!-- TOC em nova página -->
|
||||
<div class="toc page-break-before">
|
||||
<div class="toc-brand">ORACLE</div>
|
||||
<div class="toc-title">Table of contents</div>
|
||||
<div class="toc-rule"></div>
|
||||
<div class="toc-lines">
|
||||
${toc.map(t => tocLine(t)).join("")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${summaryTexts.length ? `
|
||||
<!-- Executive Summary em nova página -->
|
||||
<div class="summary page-break-before" id="exec-summary">
|
||||
<h2>Executive Summary</h2>
|
||||
<div class="sub">Conteúdo gerado dinamicamente pelo fluxo</div>
|
||||
${summaryTexts.map(t => `<div class="summary-text">${escapeHtml(t)}</div>`).join("")}
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
<!-- Findings Overview em nova página -->
|
||||
<div class="overview page-break-before" id="findings-overview">
|
||||
<h1>Findings Overview</h1>
|
||||
<div class="overview-sub">Resumo por domínio (Section)</div>
|
||||
|
||||
<table class="domains-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align:left;">DOMAINS</th>
|
||||
<th>TOTAL CONTROLS</th>
|
||||
<th>FAILED</th>
|
||||
<th>PASSED</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${sectionEntries.map(s => `
|
||||
<tr>
|
||||
<td class="dom">${escapeHtml(s.name)}</td>
|
||||
<td class="num">${s.total}</td>
|
||||
<td class="num fail">${s.failed}</td>
|
||||
<td class="num pass">${s.passed}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="overview-sub" style="margin-top:10px;">
|
||||
Total: <b>${totalAll}</b> • Passed: <b>${counts.Yes}</b> • Failed: <b>${counts.No}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seções -->
|
||||
${sectionEntries.map(s => {
|
||||
const yesRows = s.rows
|
||||
.filter(r => r.__status2 === "Yes")
|
||||
.sort((a, b) => sortRecommendation(a["Recommendation #"], b["Recommendation #"]));
|
||||
|
||||
const noRows = s.rows
|
||||
.filter(r => r.__status2 === "No")
|
||||
.sort((a, b) => sortRecommendation(a["Recommendation #"], b["Recommendation #"]));
|
||||
|
||||
const renderCards = (rows) => `
|
||||
<div class="cards">
|
||||
${rows.map(r => `
|
||||
<div class="card" id="${escapeHtml(r.__recId)}">
|
||||
<div class="card-head">
|
||||
<div class="left">
|
||||
<div class="card-title">
|
||||
${escapeHtml(normStr(r["Recommendation #"]) || "-")} — ${escapeHtml(normStr(r["Title"]) || "-")}
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
${badge2(r.__status2)}
|
||||
${pill("Section", r["Section"])}
|
||||
${pill("Level", r["Level"])}
|
||||
${pill("Findings", r.__findingsNum === null ? "N/A" : r.__findingsNum)}
|
||||
${pill("CIS v8", r["CIS v8"])}
|
||||
${pill("extract_date", r["extract_date"])}
|
||||
${pill("Filename", r["Filename"])}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
${progressBar(r.__pct)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${field("Findings (raw)", r.__findingsRaw, { showWhenEmpty: true })}
|
||||
${field("Remediation", r["Remediation"], { showWhenEmpty: true })}
|
||||
${field("CCCS Guard Rail", r["CCCS Guard Rail"], { showWhenEmpty: false })}
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const sectionExtraClass = s.name === "Asset Management" ? "page-break-before" : "";
|
||||
|
||||
// Compliant primeiro, depois Non-compliant
|
||||
return `
|
||||
<div class="section ${sectionExtraClass}" id="${escapeHtml(s.id)}">
|
||||
<h2>${escapeHtml(s.name)} <span class="muted">(${s.total})</span></h2>
|
||||
|
||||
${yesRows.length ? `
|
||||
<div class="group-title yes">Compliant (${yesRows.length})</div>
|
||||
${renderCards(yesRows)}
|
||||
` : ""}
|
||||
|
||||
${noRows.length ? `
|
||||
<div class="group-title no">Non-compliant (${noRows.length})</div>
|
||||
${renderCards(noRows)}
|
||||
` : ""}
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
|
||||
return [{ json: { html } }];
|
||||
Reference in New Issue
Block a user