feat: add RAG pipeline with OCI GenAI embeddings and ADB vector search
Integrate Retrieval-Augmented Generation into the chat flow using OCI
GenAI embed_text API and Oracle Autonomous Database vector storage.
The chat automatically queries ADB for relevant context when an active
ADB config with a linked GenAI config exists.
- Add EMBEDDING_MODELS catalog (Cohere Embed v3.0/light)
- Add _embed_text() using OCI GenAI SDK embed endpoint
- Add _vector_search() with VECTOR_DISTANCE cosine similarity
- Add _get_adb_connection(), _ensure_embeddings_table(), _build_rag_context()
- Add document ingestion endpoint (POST /api/adb/{vid}/ingest)
- Add table creation endpoint (POST /api/adb/{vid}/ensure-table)
- Modify chat endpoint with automatic RAG augmentation (non-fatal)
- Add GenAI config and embedding model selectors to ADB UI
- Add RAG status indicator in chat toolbar
- Add document ingestion section in ADB tab
This commit is contained in:
260
backend/app.py
260
backend/app.py
@@ -60,6 +60,13 @@ GENAI_MODELS = {
|
||||
"xai.grok-3": {"provider":"xai","name":"xAI Grok 3","api_format":"GENERIC"},
|
||||
}
|
||||
|
||||
EMBEDDING_MODELS = {
|
||||
"cohere.embed-english-v3.0": {"name":"Cohere Embed English v3.0","dims":1024},
|
||||
"cohere.embed-multilingual-v3.0": {"name":"Cohere Embed Multilingual v3.0","dims":1024},
|
||||
"cohere.embed-english-light-v3.0": {"name":"Cohere Embed English Light v3.0","dims":384},
|
||||
"cohere.embed-multilingual-light-v3.0": {"name":"Cohere Embed Multilingual Light v3.0","dims":384},
|
||||
}
|
||||
|
||||
GENAI_REGIONS = [
|
||||
"us-chicago-1","us-ashburn-1","us-phoenix-1","uk-london-1",
|
||||
"eu-frankfurt-1","ap-tokyo-1","ap-osaka-1","sa-saopaulo-1",
|
||||
@@ -84,7 +91,11 @@ def init_db():
|
||||
with db() as c:
|
||||
c.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, email TEXT,
|
||||
id TEXT PRIMARY KEY,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT,
|
||||
password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'viewer',
|
||||
mfa_secret TEXT, mfa_enabled INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
@@ -171,11 +182,17 @@ def init_db():
|
||||
ip TEXT, created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
""")
|
||||
# ── Migrations ──
|
||||
for col in ["genai_config_id TEXT", "embedding_model_id TEXT DEFAULT 'cohere.embed-multilingual-v3.0'"]:
|
||||
try:
|
||||
c.execute(f"ALTER TABLE adb_vector_configs ADD COLUMN {col}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
adm = c.execute("SELECT id FROM users WHERE username='admin'").fetchone()
|
||||
if not adm:
|
||||
c.execute(
|
||||
"INSERT INTO users (id,username,email,password_hash,role) VALUES (?,?,?,?,?)",
|
||||
(str(uuid.uuid4()), "admin", "admin@local", _hash_pw("admin123"), "admin")
|
||||
"INSERT INTO users (id,first_name,last_name,username,email,password_hash,role) VALUES (?,?,?,?,?,?,?)",
|
||||
(str(uuid.uuid4()), "Admin", "Sistema", "admin", "admin@local", _hash_pw("admin123"), "admin")
|
||||
)
|
||||
log.info("Default admin created: admin / admin123")
|
||||
|
||||
@@ -228,7 +245,7 @@ def _audit(uid, uname, action, resource=None, details=None, ip=None):
|
||||
class LoginReq(BaseModel):
|
||||
username: str; password: str; totp_code: Optional[str] = None
|
||||
class RegisterReq(BaseModel):
|
||||
username: str; email: str; password: str; role: str = "viewer"
|
||||
first_name: str; last_name: str; username: str; email: str; password: str; role: str = "viewer"
|
||||
class TOTPVerify(BaseModel):
|
||||
totp_code: str
|
||||
class ChangePwReq(BaseModel):
|
||||
@@ -251,6 +268,9 @@ class GenAIConfigReq(BaseModel):
|
||||
class ADBVectorReq(BaseModel):
|
||||
config_name: str; dsn: str; username: str; password: str
|
||||
wallet_password: Optional[str] = None; table_name: str = "CIS_EMBEDDINGS"; use_mtls: bool = True
|
||||
genai_config_id: Optional[str] = None; embedding_model_id: str = "cohere.embed-multilingual-v3.0"
|
||||
class IngestDocReq(BaseModel):
|
||||
adb_config_id: str; documents: List[Dict[str, Any]]
|
||||
class MCPServerReq(BaseModel):
|
||||
name: str; description: Optional[str] = None; server_type: str = "stdio"
|
||||
command: Optional[str] = None; args: Optional[List[str]] = None
|
||||
@@ -274,7 +294,7 @@ async def login(req: LoginReq, request: Request):
|
||||
c.execute("UPDATE users SET last_login=datetime('now') WHERE id=?", (u["id"],))
|
||||
_audit(u["id"], u["username"], "login", ip=request.client.host if request.client else None)
|
||||
return {"token":_make_token(u["id"],u["role"],sid),
|
||||
"user":{"id":u["id"],"username":u["username"],"email":u["email"],"role":u["role"],"mfa_enabled":bool(u["mfa_enabled"])}}
|
||||
"user":{"id":u["id"],"first_name":u["first_name"],"last_name":u["last_name"],"username":u["username"],"email":u["email"],"role":u["role"],"mfa_enabled":bool(u["mfa_enabled"])}}
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
async def logout_ep(u=Depends(current_user)):
|
||||
@@ -287,7 +307,8 @@ async def register(req: RegisterReq, adm=Depends(require("admin"))):
|
||||
uid = str(uuid.uuid4())
|
||||
with db() as c:
|
||||
if c.execute("SELECT 1 FROM users WHERE username=?", (req.username,)).fetchone(): raise HTTPException(400, "Usuário já existe")
|
||||
c.execute("INSERT INTO users (id,username,email,password_hash,role) VALUES (?,?,?,?,?)", (uid, req.username, req.email, _hash_pw(req.password), req.role))
|
||||
c.execute("INSERT INTO users (id,first_name,last_name,username,email,password_hash,role) VALUES (?,?,?,?,?,?,?)",
|
||||
(uid, req.first_name, req.last_name, req.username, req.email, _hash_pw(req.password), req.role))
|
||||
_audit(adm["id"], adm["username"], "create_user", uid, f"user={req.username} role={req.role}")
|
||||
return {"id": uid, "username": req.username, "role": req.role}
|
||||
|
||||
@@ -320,12 +341,12 @@ async def mfa_disable(user_id: str, adm=Depends(require("admin"))):
|
||||
# ── Users ─────────────────────────────────────────────────────────────────────
|
||||
@app.get("/api/users")
|
||||
async def list_users(u=Depends(require("admin"))):
|
||||
with db() as c: rows = c.execute("SELECT id,username,email,role,mfa_enabled,is_active,created_at,last_login FROM users").fetchall()
|
||||
with db() as c: rows = c.execute("SELECT id,first_name,last_name,username,email,role,mfa_enabled,is_active,created_at,last_login FROM users").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@app.get("/api/users/me")
|
||||
async def me(u=Depends(current_user)):
|
||||
return {k: u[k] for k in ("id","username","email","role","mfa_enabled")}
|
||||
return {k: u[k] for k in ("id","first_name","last_name","username","email","role","mfa_enabled")}
|
||||
|
||||
@app.put("/api/users/{uid}")
|
||||
async def update_user(uid: str, req: UserUpdateReq, adm=Depends(require("admin"))):
|
||||
@@ -492,7 +513,7 @@ async def explore_buckets(cid: str, compartment_id: str = Query(None), u=Depends
|
||||
# ── OCI GenAI Config & Chat ───────────────────────────────────────────────────
|
||||
@app.get("/api/genai/models")
|
||||
async def list_genai_models(u=Depends(current_user)):
|
||||
return {"models": GENAI_MODELS, "regions": GENAI_REGIONS}
|
||||
return {"models": GENAI_MODELS, "regions": GENAI_REGIONS, "embedding_models": EMBEDDING_MODELS}
|
||||
|
||||
@app.post("/api/genai/config")
|
||||
async def save_genai(req: GenAIConfigReq, u=Depends(require("admin","user"))):
|
||||
@@ -644,6 +665,130 @@ def _call_genai(gc: dict, message: str, history: list = None) -> str:
|
||||
return contents[0].text
|
||||
return str(resp)
|
||||
|
||||
# ── RAG Helpers ───────────────────────────────────────────────────────────────
|
||||
RAG_SYSTEM_PROMPT = """You are the OCI CIS AI Agent, an expert assistant for Oracle Cloud Infrastructure security and compliance.
|
||||
|
||||
You have been provided with relevant context documents retrieved from a knowledge base. Use this context to provide accurate, specific answers. If the context does not contain relevant information for the question, say so and answer based on your general knowledge.
|
||||
|
||||
Always cite the source documents when using information from the context.
|
||||
|
||||
--- RETRIEVED CONTEXT ---
|
||||
{context}
|
||||
--- END CONTEXT ---
|
||||
|
||||
User question: {question}"""
|
||||
|
||||
def _get_adb_connection(cfg: dict):
|
||||
"""Create an oracledb connection from an adb_vector_configs row."""
|
||||
import oracledb
|
||||
params = {"user": cfg["username"], "password": _dec(cfg["password_enc"]), "dsn": cfg["dsn"]}
|
||||
if cfg["use_mtls"] and cfg.get("wallet_dir"):
|
||||
params["config_dir"] = cfg["wallet_dir"]
|
||||
params["wallet_location"] = cfg["wallet_dir"]
|
||||
if cfg.get("wallet_password_enc"):
|
||||
params["wallet_password"] = _dec(cfg["wallet_password_enc"])
|
||||
return oracledb.connect(**params)
|
||||
|
||||
def _ensure_embeddings_table(cfg: dict):
|
||||
"""Create the embeddings table in ADB if it doesn't exist."""
|
||||
table_name = cfg.get("table_name", "CIS_EMBEDDINGS")
|
||||
emb_model = cfg.get("embedding_model_id", "cohere.embed-multilingual-v3.0")
|
||||
dims = EMBEDDING_MODELS.get(emb_model, {}).get("dims", 1024)
|
||||
conn = _get_adb_connection(cfg)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM user_tables WHERE table_name = :1", (table_name.upper(),))
|
||||
if cur.fetchone()[0] == 0:
|
||||
cur.execute(f"""
|
||||
CREATE TABLE {table_name} (
|
||||
ID VARCHAR2(100) PRIMARY KEY,
|
||||
CONTENT CLOB,
|
||||
EMBEDDING VECTOR({dims}, FLOAT64),
|
||||
METADATA VARCHAR2(4000),
|
||||
SOURCE VARCHAR2(500),
|
||||
CREATED_AT TIMESTAMP DEFAULT SYSTIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
log.info(f"Created embeddings table: {table_name} (dims={dims})")
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _embed_text(text: str, genai_cfg: dict, embedding_model_id: str) -> list:
|
||||
"""Generate embedding using OCI GenAI embed endpoint."""
|
||||
import oci
|
||||
config_path = str(OCI_DIR / genai_cfg["oci_config_id"] / "config")
|
||||
config = oci.config.from_file(config_path, "DEFAULT")
|
||||
endpoint = genai_cfg["endpoint"]
|
||||
client = oci.generative_ai_inference.GenerativeAiInferenceClient(
|
||||
config=config, service_endpoint=endpoint,
|
||||
retry_strategy=oci.retry.NoneRetryStrategy(), timeout=(10, 120)
|
||||
)
|
||||
embed_detail = oci.generative_ai_inference.models.EmbedTextDetails()
|
||||
embed_detail.inputs = [text]
|
||||
embed_detail.serving_mode = oci.generative_ai_inference.models.OnDemandServingMode(model_id=embedding_model_id)
|
||||
embed_detail.compartment_id = genai_cfg["compartment_id"]
|
||||
embed_detail.truncate = "NONE"
|
||||
embed_detail.input_type = "SEARCH_QUERY"
|
||||
response = client.embed_text(embed_detail)
|
||||
return response.data.embeddings[0]
|
||||
|
||||
def _vector_search(cfg: dict, query_embedding: list, top_k: int = 5) -> list:
|
||||
"""Search ADB vector store using cosine similarity. Returns top-K documents."""
|
||||
import array
|
||||
table_name = cfg.get("table_name", "CIS_EMBEDDINGS")
|
||||
conn = _get_adb_connection(cfg)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
vec = array.array('d', query_embedding)
|
||||
cur.execute(f"""
|
||||
SELECT ID, CONTENT, METADATA, SOURCE,
|
||||
VECTOR_DISTANCE(EMBEDDING, :1, COSINE) AS distance
|
||||
FROM {table_name}
|
||||
ORDER BY distance ASC
|
||||
FETCH FIRST :2 ROWS ONLY
|
||||
""", [vec, top_k])
|
||||
results = []
|
||||
for row in cur:
|
||||
content = row[1]
|
||||
if hasattr(content, 'read'):
|
||||
content = content.read()
|
||||
results.append({
|
||||
"id": row[0], "content": content or "",
|
||||
"metadata": row[2], "source": row[3], "distance": float(row[4])
|
||||
})
|
||||
cur.close()
|
||||
return results
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _build_rag_context(documents: list) -> str:
|
||||
"""Format retrieved documents into a context string for the LLM prompt."""
|
||||
if not documents:
|
||||
return ""
|
||||
parts = []
|
||||
for i, doc in enumerate(documents, 1):
|
||||
source = doc.get("source", "unknown")
|
||||
content = doc.get("content", "")
|
||||
if len(content) > 2000:
|
||||
content = content[:2000] + "..."
|
||||
parts.append(f"[Document {i} | Source: {source}]\n{content}")
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
def _get_active_adb_config(user_id: str) -> dict | None:
|
||||
"""Get the first active ADB vector config with a linked GenAI config."""
|
||||
with db() as c:
|
||||
cfg = c.execute(
|
||||
"SELECT * FROM adb_vector_configs WHERE user_id=? AND is_active=1 AND genai_config_id IS NOT NULL ORDER BY created_at DESC LIMIT 1",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
if not cfg:
|
||||
cfg = c.execute(
|
||||
"SELECT * FROM adb_vector_configs WHERE is_active=1 AND genai_config_id IS NOT NULL ORDER BY created_at DESC LIMIT 1"
|
||||
).fetchone()
|
||||
return dict(cfg) if cfg else None
|
||||
|
||||
# ── MCP Servers ───────────────────────────────────────────────────────────────
|
||||
@app.post("/api/mcp/servers")
|
||||
async def register_mcp(req: MCPServerReq, u=Depends(require("admin","user"))):
|
||||
@@ -705,9 +850,10 @@ async def save_adb(req: ADBVectorReq, u=Depends(require("admin","user"))):
|
||||
vid = str(uuid.uuid4())
|
||||
with db() as c:
|
||||
c.execute(
|
||||
"INSERT INTO adb_vector_configs (id,user_id,config_name,dsn,username,password_enc,wallet_password_enc,table_name,use_mtls) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
"INSERT INTO adb_vector_configs (id,user_id,config_name,dsn,username,password_enc,wallet_password_enc,table_name,use_mtls,genai_config_id,embedding_model_id) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(vid, u["id"], req.config_name, req.dsn, req.username, _enc(req.password),
|
||||
_enc(req.wallet_password) if req.wallet_password else None, req.table_name, int(req.use_mtls)))
|
||||
_enc(req.wallet_password) if req.wallet_password else None, req.table_name, int(req.use_mtls),
|
||||
req.genai_config_id, req.embedding_model_id))
|
||||
_audit(u["id"], u["username"], "save_adb_config", vid, req.config_name)
|
||||
return {"id": vid, "config_name": req.config_name}
|
||||
|
||||
@@ -724,8 +870,9 @@ async def upload_wallet(vid: str, wallet: UploadFile = File(...), u=Depends(requ
|
||||
@app.get("/api/adb/configs")
|
||||
async def list_adb(u=Depends(current_user)):
|
||||
with db() as c:
|
||||
if u["role"]=="admin": rows=c.execute("SELECT id,config_name,dsn,username,table_name,use_mtls,is_active,wallet_dir,created_at FROM adb_vector_configs").fetchall()
|
||||
else: rows=c.execute("SELECT id,config_name,dsn,username,table_name,use_mtls,is_active,wallet_dir,created_at FROM adb_vector_configs WHERE user_id=?",(u["id"],)).fetchall()
|
||||
cols = "id,config_name,dsn,username,table_name,use_mtls,is_active,wallet_dir,genai_config_id,embedding_model_id,created_at"
|
||||
if u["role"]=="admin": rows=c.execute(f"SELECT {cols} FROM adb_vector_configs").fetchall()
|
||||
else: rows=c.execute(f"SELECT {cols} FROM adb_vector_configs WHERE user_id=?",(u["id"],)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@app.post("/api/adb/test/{vid}")
|
||||
@@ -734,14 +881,7 @@ async def test_adb(vid: str, u=Depends(require("admin","user"))):
|
||||
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?",(vid,)).fetchone()
|
||||
if not cfg: raise HTTPException(404)
|
||||
try:
|
||||
import oracledb
|
||||
params = {"user": cfg["username"], "password": _dec(cfg["password_enc"]), "dsn": cfg["dsn"]}
|
||||
if cfg["use_mtls"] and cfg.get("wallet_dir"):
|
||||
params["config_dir"] = cfg["wallet_dir"]
|
||||
params["wallet_location"] = cfg["wallet_dir"]
|
||||
if cfg.get("wallet_password_enc"):
|
||||
params["wallet_password"] = _dec(cfg["wallet_password_enc"])
|
||||
conn = oracledb.connect(**params)
|
||||
conn = _get_adb_connection(dict(cfg))
|
||||
cur = conn.cursor(); cur.execute("SELECT 1 FROM DUAL"); cur.close(); conn.close()
|
||||
return {"status":"success","message":"Conexão Autonomous DB OK!"}
|
||||
except ImportError:
|
||||
@@ -756,6 +896,63 @@ async def del_adb(vid: str, u=Depends(require("admin","user"))):
|
||||
if d.exists(): shutil.rmtree(d)
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/api/adb/{vid}/ensure-table")
|
||||
async def ensure_table(vid: str, u=Depends(require("admin","user"))):
|
||||
with db() as c:
|
||||
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
||||
if not cfg: raise HTTPException(404)
|
||||
try:
|
||||
_ensure_embeddings_table(dict(cfg))
|
||||
return {"ok": True, "table": cfg["table_name"]}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Falha ao criar tabela: {str(e)[:500]}")
|
||||
|
||||
@app.post("/api/adb/{vid}/ingest")
|
||||
async def ingest_documents(vid: str, req: IngestDocReq, bg: BackgroundTasks, u=Depends(require("admin","user"))):
|
||||
with db() as c:
|
||||
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
||||
if not cfg: raise HTTPException(404, "ADB config not found")
|
||||
cfg = dict(cfg)
|
||||
if not cfg.get("genai_config_id"):
|
||||
raise HTTPException(400, "GenAI config not linked to this ADB connection")
|
||||
with db() as c:
|
||||
gc = c.execute("SELECT * FROM genai_configs WHERE id=?", (cfg["genai_config_id"],)).fetchone()
|
||||
if not gc: raise HTTPException(400, "Linked GenAI config not found")
|
||||
bg.add_task(_ingest_documents_task, cfg, dict(gc), req.documents, u["id"], u["username"])
|
||||
return {"ok": True, "message": f"Ingestão iniciada para {len(req.documents)} documentos", "config_id": vid}
|
||||
|
||||
def _ingest_documents_task(cfg: dict, genai_cfg: dict, documents: list, user_id: str, username: str):
|
||||
"""Background task: embed and insert documents into ADB via OCI GenAI."""
|
||||
import array
|
||||
emb_model = cfg.get("embedding_model_id", "cohere.embed-multilingual-v3.0")
|
||||
table_name = cfg.get("table_name", "CIS_EMBEDDINGS")
|
||||
_ensure_embeddings_table(cfg)
|
||||
conn = _get_adb_connection(cfg)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
inserted = 0
|
||||
for doc in documents:
|
||||
try:
|
||||
content = doc.get("content", "")
|
||||
if not content: continue
|
||||
embedding = _embed_text(content, genai_cfg, emb_model)
|
||||
vec = array.array('d', embedding)
|
||||
cur.execute(f"""
|
||||
INSERT INTO {table_name} (ID, CONTENT, EMBEDDING, METADATA, SOURCE)
|
||||
VALUES (:1, :2, :3, :4, :5)
|
||||
""", [str(uuid.uuid4()), content, vec, doc.get("metadata", ""), doc.get("source", "manual_upload")])
|
||||
inserted += 1
|
||||
except Exception as e:
|
||||
log.error(f"Failed to ingest document: {e}")
|
||||
conn.commit()
|
||||
cur.close()
|
||||
log.info(f"Ingested {inserted}/{len(documents)} documents into {table_name}")
|
||||
_audit(user_id, username, "ingest_documents", cfg["id"], f"{inserted} documents")
|
||||
except Exception as e:
|
||||
log.error(f"Ingestion task failed: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ── Reports ───────────────────────────────────────────────────────────────────
|
||||
@app.post("/api/reports/run")
|
||||
async def run_report(req: RunReportReq, bg: BackgroundTasks, u=Depends(require("admin","user"))):
|
||||
@@ -852,7 +1049,26 @@ async def chat(msg: ChatMsg, u=Depends(current_user)):
|
||||
with db() as c:
|
||||
prev = c.execute("SELECT role,content FROM chat_messages WHERE session_id=? AND role IN ('user','assistant') ORDER BY created_at ASC", (sid,)).fetchall()
|
||||
history = [{"role":r["role"],"content":r["content"]} for r in prev]
|
||||
resp = _call_genai(dict(genai_cfg), msg.message, history[:-1] if len(history) > 1 else None)
|
||||
|
||||
# ── RAG: augment with vector context if ADB config is active ──
|
||||
rag_context = ""
|
||||
adb_cfg = _get_active_adb_config(u["id"])
|
||||
if adb_cfg:
|
||||
try:
|
||||
with db() as c:
|
||||
emb_genai = c.execute("SELECT * FROM genai_configs WHERE id=?", (adb_cfg["genai_config_id"],)).fetchone()
|
||||
if emb_genai:
|
||||
emb_model = adb_cfg.get("embedding_model_id", "cohere.embed-multilingual-v3.0")
|
||||
query_embedding = _embed_text(msg.message, dict(emb_genai), emb_model)
|
||||
documents = _vector_search(adb_cfg, query_embedding, top_k=5)
|
||||
if documents:
|
||||
rag_context = _build_rag_context(documents)
|
||||
log.info(f"RAG: Retrieved {len(documents)} documents for query")
|
||||
except Exception as e:
|
||||
log.warning(f"RAG retrieval failed (non-fatal): {e}")
|
||||
|
||||
augmented_message = RAG_SYSTEM_PROMPT.format(context=rag_context, question=msg.message) if rag_context else msg.message
|
||||
resp = _call_genai(dict(genai_cfg), augmented_message, history[:-1] if len(history) > 1 else None)
|
||||
except Exception as e:
|
||||
resp = f"❌ Erro GenAI: {str(e)[:400]}"
|
||||
else:
|
||||
|
||||
@@ -194,7 +194,7 @@ const V='1.1';
|
||||
const LOGO_W=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="26" height="26" style="flex-shrink:0"><rect x="2" y="5" width="32" height="26" rx="13" ry="13" fill="rgba(255,255,255,0.12)" stroke="#fff" stroke-width="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" stroke-width="1.2" stroke-linecap="round"/><circle cx="18" cy="5.5" r="1.5" fill="#fff" opacity="0.9"/><line x1="11" y1="18" x2="6" y2="18" stroke="#fff" stroke-width="1" stroke-linecap="round" opacity="0.7"/><line x1="25" y1="18" x2="30" y2="18" stroke="#fff" stroke-width="1" stroke-linecap="round" opacity="0.7"/><circle cx="5.5" cy="18" r="1" fill="#fff" opacity="0.7"/><circle cx="30.5" cy="18" r="1" fill="#fff" opacity="0.7"/></svg>`;
|
||||
const LOGO_R=`<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" stroke-width="1.8"/><rect x="11" y="11" width="14" height="14" rx="4" fill="#fff" stroke="#C74634" stroke-width="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"/><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.25" stroke="#C74634" stroke-width="0.7"/><line x1="11" y1="18" x2="6" y2="18" stroke="#C74634" stroke-width="1" stroke-linecap="round" opacity="0.4"/><line x1="25" y1="18" x2="30" y2="18" stroke="#C74634" stroke-width="1" stroke-linecap="round" opacity="0.4"/><circle cx="5.5" cy="18" r="1" fill="#C74634" opacity="0.35"/><circle cx="30.5" cy="18" r="1" fill="#C74634" opacity="0.35"/></svg>`;
|
||||
|
||||
const S={user:null,token:null,tab:'chat',msgs:[],sid:null,reports:[],ociCfg:[],genaiCfg:[],adbCfg:[],mcpSvr:[],users:[],auditLogs:[],models:{},regions:[],selGenai:'',expData:null};
|
||||
const S={user:null,token:null,tab:'chat',msgs:[],sid:null,reports:[],ociCfg:[],genaiCfg:[],adbCfg:[],mcpSvr:[],users:[],auditLogs:[],models:{},regions:[],embModels:{},selGenai:'',expData:null};
|
||||
const API='/api';
|
||||
|
||||
async function $api(p,o={}){const h={...(o.headers||{})};if(S.token)h['Authorization']='Bearer '+S.token;
|
||||
@@ -208,7 +208,7 @@ async function doLogout(){try{await $api('/auth/logout',{method:'POST'})}catch(e
|
||||
async function checkAuth(){const t=localStorage.getItem('t');if(!t)return;S.token=t;try{S.user=await $api('/users/me');await loadData()}catch(e){S.token=null;localStorage.removeItem('t')}}
|
||||
async function loadData(){try{
|
||||
[S.ociCfg,S.reports,S.genaiCfg,S.mcpSvr]=await Promise.all([$api('/oci/configs'),$api('/reports'),$api('/genai/configs'),$api('/mcp/servers')]);
|
||||
try{const m=await $api('/genai/models');S.models=m.models;S.regions=m.regions}catch(e){}
|
||||
try{const m=await $api('/genai/models');S.models=m.models;S.regions=m.regions;S.embModels=m.embedding_models||{}}catch(e){}
|
||||
try{S.adbCfg=await $api('/adb/configs')}catch(e){S.adbCfg=[]}
|
||||
if(S.user.role==='admin'){try{S.users=await $api('/users')}catch(e){}}
|
||||
}catch(e){console.error(e)}}
|
||||
@@ -235,7 +235,7 @@ function rSb(){
|
||||
const tabs=[['chat','💬','Chat Agent'],['explorer','🔍','OCI Explorer'],['report','📊','Reports'],['downloads','📁','Downloads']];
|
||||
const ctabs=[['oci-config','☁️','Credenciais OCI'],['genai','🧠','GenAI Config'],['mcp','🔌','MCP Servers'],['adb','🗄️','ADB Vector']];
|
||||
const atabs=[['users','👥','Usuários'],['mfa','🔐','MFA'],['audit','📋','Audit Log']];
|
||||
const i=(S.user?.username||'?')[0].toUpperCase();
|
||||
const i=(S.user?.first_name||S.user?.username||'?')[0].toUpperCase();
|
||||
return`<div class="sb">
|
||||
<div class="sb-h"><h1>${LOGO_W} OCI CIS Agent</h1><div class="st">Infrastructure & Security · v${V}</div></div>
|
||||
<div class="nav"><div class="nl">Principal</div>
|
||||
@@ -244,7 +244,7 @@ ${tabs.map(t=>`<div class="ni ${S.tab===t[0]?'on':''}" onclick="switchTab('${t[0
|
||||
${ctabs.map(t=>`<div class="ni ${S.tab===t[0]?'on':''}" onclick="switchTab('${t[0]}')"><span class="ic">${t[1]}</span>${t[2]}</div>`).join('')}
|
||||
${S.user?.role==='admin'?`<div class="nl">Administração</div>
|
||||
${atabs.map(t=>`<div class="ni ${S.tab===t[0]?'on':''}" onclick="switchTab('${t[0]}')"><span class="ic">${t[1]}</span>${t[2]}</div>`).join('')}`:''}</div>
|
||||
<div class="sb-f"><div class="ui"><div class="ua">${i}</div><div><div class="un">${S.user?.username}</div><div class="ur">${S.user?.role}</div></div>
|
||||
<div class="sb-f"><div class="ui"><div class="ua">${i}</div><div><div class="un">${S.user?.first_name?S.user.first_name+' '+S.user.last_name:S.user?.username}</div><div class="ur">${S.user?.role}</div></div>
|
||||
<div class="lo-btn" onclick="doLogout()" title="Sair">⏻</div></div></div></div>`}
|
||||
|
||||
function rTb(){const t={'chat':'💬 AI Agent Chat','explorer':'🔍 OCI Account Explorer','report':'📊 Compliance Reports','downloads':'📁 Downloads','oci-config':'☁️ Credenciais OCI','genai':'🧠 OCI Generative AI','mcp':'🔌 MCP Servers','adb':'🗄️ Autonomous DB Vector','users':'👥 Gerenciar Usuários','mfa':'🔐 Autenticação MFA','audit':'📋 Audit Log'};
|
||||
@@ -259,6 +259,7 @@ function rChat(){
|
||||
const gcOpts=S.genaiCfg.map(g=>{const mi=S.models[g.model_id];return`<option value="${g.id}" ${S.selGenai===g.id?'selected':''}>${g.name||mi?.name||g.model_id} · ${g.genai_region}</option>`}).join('');
|
||||
return`<div class="ch-c">
|
||||
<div class="ch-t"><label>🧠 Modelo:</label><select id="gsel" onchange="S.selGenai=this.value"><option value="">Agente Local (sem IA)</option>${gcOpts}</select>
|
||||
${S.adbCfg.some(c=>c.genai_config_id&&c.is_active)?'<span class="tag" style="background:var(--gnl);color:var(--gn);font-size:.62rem;margin-left:.5rem">RAG Ativo</span>':''}
|
||||
<div style="flex:1"></div><button class="btn bd bsm" onclick="clrChat()">Limpar</button></div>
|
||||
<div class="ch-m" id="chm">${ms}</div>
|
||||
<div class="ch-i"><input type="text" id="chi" placeholder="Digite sua mensagem..." onkeydown="if(event.key==='Enter')sChat()">
|
||||
@@ -442,11 +443,12 @@ async function lnkMcp(){const mid=document.getElementById('mup').value;const aid
|
||||
|
||||
/* ── ADB Vector ── */
|
||||
function rADB(){return`<div class="cd"><div class="ct">🗄️ Conexões Registradas</div>
|
||||
<table><thead><tr><th>Nome</th><th>DSN</th><th>User</th><th>Tabela</th><th>mTLS</th><th>Wallet</th><th>Ações</th></tr></thead><tbody>
|
||||
${S.adbCfg.map(c=>`<tr><td><strong>${c.config_name}</strong></td><td style="font-family:var(--fm);font-size:.68rem;color:var(--t4)">${c.dsn}</td>
|
||||
<table><thead><tr><th>Nome</th><th>DSN</th><th>User</th><th>Tabela</th><th>mTLS</th><th>Wallet</th><th>Embed</th><th>Ações</th></tr></thead><tbody>
|
||||
${S.adbCfg.map(c=>{const em=S.embModels[c.embedding_model_id];return`<tr><td><strong>${c.config_name}</strong></td><td style="font-family:var(--fm);font-size:.68rem;color:var(--t4)">${c.dsn}</td>
|
||||
<td>${c.username}</td><td><span class="tag">${c.table_name}</span></td>
|
||||
<td>${c.use_mtls?'✅':'—'}</td><td>${c.wallet_dir?'✅':'❌'}</td>
|
||||
<td><button class="btn bs bsm" onclick="tAdb('${c.id}')">Testar</button> <button class="btn bd bsm" onclick="dAdb('${c.id}')">Excluir</button></td></tr>`).join('')}</tbody></table>
|
||||
<td>${c.genai_config_id?'<span class="tag" style="background:var(--gnl);color:var(--gn)">'+(em?em.name:c.embedding_model_id)+'</span>':'<span style="color:var(--t4)">—</span>'}</td>
|
||||
<td><button class="btn bs bsm" onclick="tAdb('${c.id}')">Testar</button> <button class="btn bs bsm" onclick="ensureTable('${c.id}')">Criar Tabela</button> <button class="btn bd bsm" onclick="dAdb('${c.id}')">Excluir</button></td></tr>`}).join('')}</tbody></table>
|
||||
${!S.adbCfg.length?'<div class="emp" style="padding:.85rem"><p>Nenhuma conexão ADB registrada.</p></div>':''}</div>
|
||||
<div class="cd"><div class="ct">➕ Nova Conexão</div>
|
||||
<div class="cdesc">Conexão via <code style="font-size:.72rem">python-oracledb</code> Thin mode com Wallet (mTLS) para Oracle Autonomous Database.</div><div id="am"></div>
|
||||
@@ -455,40 +457,115 @@ ${!S.adbCfg.length?'<div class="emp" style="padding:.85rem"><p>Nenhuma conexão
|
||||
<div class="ig"><label>Username</label><input type="text" id="auser" placeholder="ADMIN"></div>
|
||||
<div class="ig"><label>Password</label><input type="password" id="apw"></div>
|
||||
<div class="ig"><label>Wallet Password</label><input type="password" id="awpw" placeholder="(opcional)"></div>
|
||||
<div class="ig"><label>Tabela de Embeddings</label><input type="text" id="atbl" value="CIS_EMBEDDINGS"></div></div>
|
||||
<div class="ig"><label>Tabela de Embeddings</label><input type="text" id="atbl" value="CIS_EMBEDDINGS"></div>
|
||||
<div class="ig"><label>Config GenAI (Embeddings)</label><div class="ht">Credenciais OCI usadas para gerar embeddings via GenAI.</div>
|
||||
<select id="agcfg"><option value="">Nenhum (sem RAG)</option>${S.genaiCfg.map(g=>'<option value="'+g.id+'">'+(g.name||g.model_id)+' · '+g.genai_region+'</option>').join('')}</select></div>
|
||||
<div class="ig"><label>Modelo de Embedding</label><div class="ht">Modelo OCI GenAI para gerar vetores.</div>
|
||||
<select id="aemb">${Object.entries(S.embModels).map(([k,v])=>'<option value="'+k+'">'+v.name+' ('+v.dims+'d)</option>').join('')}</select></div></div>
|
||||
<button class="btn bp" onclick="sAdb()">Salvar Conexão</button></div>
|
||||
<div class="cd"><div class="ct">📤 Upload Wallet</div>
|
||||
<div class="g2"><div class="ig"><label>Conexão ADB</label><select id="awsel">${S.adbCfg.map(c=>`<option value="${c.id}">${c.config_name}</option>`).join('')}</select></div>
|
||||
<div class="ig"><label>Wallet ZIP</label><input type="file" id="awf" accept=".zip"></div></div>
|
||||
<button class="btn bs" onclick="uWallet()">Upload Wallet</button></div>`}
|
||||
<button class="btn bs" onclick="uWallet()">Upload Wallet</button></div>
|
||||
<div class="cd"><div class="ct">📄 Ingerir Documentos</div>
|
||||
<div class="cdesc">Gere embeddings e insira documentos na tabela ADB para uso com RAG.</div>
|
||||
<div class="g2"><div class="ig"><label>Conexão ADB</label>
|
||||
<select id="aisel">${S.adbCfg.filter(c=>c.genai_config_id).map(c=>'<option value="'+c.id+'">'+c.config_name+'</option>').join('')}</select></div>
|
||||
<div class="ig"><label>Documentos (JSON)</label>
|
||||
<div class="ht">Array de objetos: [{"content":"texto","source":"fonte","metadata":"info"}]</div>
|
||||
<textarea id="adocs" rows="6" style="font-family:var(--fm);font-size:.72rem;width:100%;padding:.5rem;border:1px solid var(--bd);border-radius:var(--r)" placeholder='[{"content":"...","source":"doc.pdf","metadata":"page 1"}]'></textarea></div></div>
|
||||
<button class="btn bp" onclick="ingestDocs()">Ingerir Documentos</button></div>`}
|
||||
async function sAdb(){try{await $api('/adb/config',{method:'POST',body:{config_name:document.getElementById('an').value,dsn:document.getElementById('adsn').value,
|
||||
username:document.getElementById('auser').value,password:document.getElementById('apw').value,
|
||||
wallet_password:document.getElementById('awpw').value||null,table_name:document.getElementById('atbl').value}});
|
||||
wallet_password:document.getElementById('awpw').value||null,table_name:document.getElementById('atbl').value,
|
||||
genai_config_id:document.getElementById('agcfg').value||null,
|
||||
embedding_model_id:document.getElementById('aemb').value||'cohere.embed-multilingual-v3.0'}});
|
||||
S.adbCfg=await $api('/adb/configs');sm('am','✅ Conexão salva!','s');R()}catch(e){sm('am',e.message,'e')}}
|
||||
async function tAdb(id){try{const d=await $api('/adb/test/'+id,{method:'POST'});alert(d.status==='success'?'✅ '+d.message:'❌ '+d.message)}catch(e){alert(e.message)}}
|
||||
async function dAdb(id){if(!confirm('Excluir conexão?'))return;await $api('/adb/configs/'+id,{method:'DELETE'});S.adbCfg=await $api('/adb/configs');R()}
|
||||
async function uWallet(){const fd=new FormData();const f=document.getElementById('awf').files[0];if(!f)return alert('Selecione wallet ZIP');
|
||||
fd.append('wallet',f);const id=document.getElementById('awsel').value;if(!id)return alert('Selecione conexão');
|
||||
try{const d=await $api('/adb/'+id+'/upload-wallet',{method:'POST',body:fd,headers:{}});alert('Wallet enviada! Arquivos: '+d.files.join(', '))}catch(e){alert(e.message)}}
|
||||
async function ensureTable(id){try{const d=await $api('/adb/'+id+'/ensure-table',{method:'POST'});alert('Tabela criada/verificada: '+d.table)}catch(e){alert('Erro: '+e.message)}}
|
||||
async function ingestDocs(){const id=document.getElementById('aisel').value;
|
||||
if(!id)return alert('Selecione conexão ADB com GenAI configurado');
|
||||
let docs;try{docs=JSON.parse(document.getElementById('adocs').value)}catch(e){return alert('JSON inválido')}
|
||||
if(!Array.isArray(docs)||!docs.length)return alert('Forneça array de documentos');
|
||||
try{const d=await $api('/adb/'+id+'/ingest',{method:'POST',body:{adb_config_id:id,documents:docs}});alert(d.message)}catch(e){alert('Erro: '+e.message)}}
|
||||
|
||||
/* ── Users ── */
|
||||
function rUsers(){return`<div class="cd"><div class="ct">👥 Usuários</div>
|
||||
<table><thead><tr><th>Usuário</th><th>Email</th><th>Role</th><th>MFA</th><th>Status</th><th>Último Login</th><th>Ações</th></tr></thead><tbody>
|
||||
${S.users.map(u=>`<tr><td><strong>${u.username}</strong></td><td>${u.email||'—'}</td>
|
||||
<td><span class="badge b-${u.role}">${u.role}</span></td><td>${u.mfa_enabled?'✅':'❌'}</td>
|
||||
<td><span class="badge ${u.is_active?'b-pass':'b-fail'}">${u.is_active?'Ativo':'Inativo'}</span></td>
|
||||
function rUsers(){return`<div class="cd"><div class="ct">👥 Gerenciamento de Usuários</div><div id="um"></div>
|
||||
<table style="margin-top:.65rem"><thead><tr><th>Nome</th><th>Usuário</th><th>Email</th><th>Role</th><th>MFA</th><th>Status</th><th>Último Login</th><th>Ações</th></tr></thead><tbody>
|
||||
${S.users.map(u=>`<tr>
|
||||
<td><strong>${u.first_name||''} ${u.last_name||''}</strong></td>
|
||||
<td style="font-family:var(--fm);font-size:.7rem">${u.username}</td>
|
||||
<td style="font-size:.72rem">${u.email||'—'}</td>
|
||||
<td><span class="badge b-${u.role}">${u.role}</span></td>
|
||||
<td>${u.mfa_enabled?'<span class="tag" style="background:var(--gnl);color:var(--gn)">✓</span>':'—'}</td>
|
||||
<td>${u.is_active?'<span class="tag" style="background:var(--gnl);color:var(--gn)">Ativo</span>':'<span class="tag">Inativo</span>'}</td>
|
||||
<td style="font-size:.72rem;color:var(--t4)">${u.last_login||'Nunca'}</td>
|
||||
<td><select onchange="uRole('${u.id}',this.value)" style="max-width:90px;padding:.22rem .35rem;font-size:.72rem"><option value="admin"${u.role==='admin'?' selected':''}>Admin</option>
|
||||
<option value="user"${u.role==='user'?' selected':''}>User</option><option value="viewer"${u.role==='viewer'?' selected':''}>Viewer</option></select>
|
||||
${u.is_active?` <button class="btn bd bsm" onclick="deact('${u.id}')">Desativar</button>`:''}</td></tr>`).join('')}</tbody></table></div>
|
||||
<div class="cd"><div class="ct">➕ Criar Usuário</div><div id="um"></div>
|
||||
<div class="g3"><div class="ig"><label>Usuário</label><input type="text" id="nu"></div>
|
||||
<div class="ig"><label>Email</label><input type="email" id="ne"></div>
|
||||
<div class="ig"><label>Senha</label><input type="password" id="np"></div></div>
|
||||
<div class="ig" style="max-width:180px"><label>Role</label><select id="nr"><option value="viewer">Viewer</option><option value="user">User</option><option value="admin">Admin</option></select></div>
|
||||
<td><select onchange="uRole('${u.id}',this.value)" style="max-width:90px;padding:.22rem .35rem;font-size:.72rem" ${u.username==='admin'?'disabled':''}>
|
||||
<option value="admin"${u.role==='admin'?' selected':''}>Admin</option>
|
||||
<option value="user"${u.role==='user'?' selected':''}>User</option>
|
||||
<option value="viewer"${u.role==='viewer'?' selected':''}>Viewer</option></select>
|
||||
${u.username!=='admin'&&u.is_active?` <button class="btn bd bsm" onclick="deact('${u.id}')">Desativar</button>`:''}</td></tr>`).join('')}
|
||||
</tbody></table></div>
|
||||
<div class="cd"><div class="ct">➕ Criar Novo Usuário</div><div id="um2"></div>
|
||||
<div class="g3">
|
||||
<div class="ig"><label>Nome</label><input type="text" id="nfn" placeholder="João"></div>
|
||||
<div class="ig"><label>Sobrenome</label><input type="text" id="nln" placeholder="Silva"></div>
|
||||
<div class="ig"><label>Usuário (login)</label><input type="text" id="nu" placeholder="joao.silva"></div></div>
|
||||
<div class="g3"><div class="ig"><label>Email</label><input type="email" id="ne" placeholder="joao@empresa.com"></div>
|
||||
<div class="ig"><label>Senha</label><input type="password" id="np"></div>
|
||||
<div class="ig"><label>Role</label><select id="nr"><option value="viewer">Viewer</option><option value="user">User</option><option value="admin">Admin</option></select></div></div>
|
||||
<button class="btn bp" onclick="cUser()">Criar Usuário</button></div>`}
|
||||
async function cUser(){try{await $api('/auth/register',{method:'POST',body:{username:document.getElementById('nu').value,email:document.getElementById('ne').value,
|
||||
password:document.getElementById('np').value,role:document.getElementById('nr').value}});S.users=await $api('/users');sm('um','✅ Usuário criado!','s');R()}catch(e){sm('um',e.message,'e')}}
|
||||
|
||||
async function cUser(){
|
||||
const firstName = document.getElementById('nfn').value.trim();
|
||||
const lastName = document.getElementById('nln').value.trim();
|
||||
const username = document.getElementById('nu').value.trim();
|
||||
const email = document.getElementById('ne').value.trim();
|
||||
const password = document.getElementById('np').value;
|
||||
const role = document.getElementById('nr').value;
|
||||
|
||||
if (!firstName || !lastName) {
|
||||
sm('um2', '❌ Nome e sobrenome são obrigatórios', 'e');
|
||||
return;
|
||||
}
|
||||
if (!username) {
|
||||
sm('um2', '❌ Usuário (login) é obrigatório', 'e');
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
sm('um2', '❌ Senha é obrigatória', 'e');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await $api('/auth/register', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
username: username,
|
||||
email: email,
|
||||
password: password,
|
||||
role: role
|
||||
}
|
||||
});
|
||||
S.users = await $api('/users');
|
||||
sm('um2', '✅ Usuário criado com sucesso!', 's');
|
||||
document.getElementById('nfn').value = '';
|
||||
document.getElementById('nln').value = '';
|
||||
document.getElementById('nu').value = '';
|
||||
document.getElementById('ne').value = '';
|
||||
document.getElementById('np').value = '';
|
||||
R();
|
||||
} catch(e) {
|
||||
sm('um2', e.message, 'e');
|
||||
}
|
||||
}
|
||||
|
||||
async function uRole(id,r){try{await $api('/users/'+id,{method:'PUT',body:{role:r}});S.users=await $api('/users')}catch(e){alert(e.message)}}
|
||||
async function deact(id){if(!confirm('Desativar usuário?'))return;await $api('/users/'+id,{method:'DELETE'});S.users=await $api('/users');R()}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user