From 5a28f0d56fca6f6e95e9c4cbaedf8e5d3db23f99 Mon Sep 17 00:00:00 2001 From: nogueiraguh Date: Tue, 3 Mar 2026 15:21:38 -0300 Subject: [PATCH] feat: add persistent config logs panel and auto-fill GenAI from OCI credentials Backend: new config_logs table with _config_log() helper, instrumented across all test/save/upload/report/ingest endpoints (OCI, GenAI, ADB, MCP). Added GET/DELETE /api/config-logs with filtering by type, severity, and auto-cleanup of logs older than 30 days. Frontend: inline "Log de Atividades" panel at the bottom of each config tab (OCI, GenAI, ADB, MCP) with severity filter dropdown. Replaced all remaining alert() calls in test functions with inline sm() messages. GenAI config now auto-fills Region and Compartment OCID when selecting an OCI credential. --- backend/app.py | 126 ++++++++++++++++++++++++++++++++++++++------ frontend/index.html | 58 +++++++++++++++----- 2 files changed, 155 insertions(+), 29 deletions(-) diff --git a/backend/app.py b/backend/app.py index 09e0a48..f5d0967 100644 --- a/backend/app.py +++ b/backend/app.py @@ -181,7 +181,20 @@ def init_db(): action TEXT NOT NULL, resource TEXT, details TEXT, ip TEXT, created_at TEXT DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS config_logs ( + id TEXT PRIMARY KEY, + config_type TEXT NOT NULL, + config_id TEXT NOT NULL, + config_name TEXT, + severity TEXT NOT NULL, + action TEXT NOT NULL, + message TEXT NOT NULL, + user_id TEXT, + username TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); """) + c.execute("DELETE FROM config_logs WHERE created_at < datetime('now', '-30 days')") # ── Migrations ── for col in ["genai_config_id TEXT", "embedding_model_id TEXT DEFAULT 'cohere.embed-multilingual-v3.0'"]: try: @@ -241,6 +254,11 @@ def _audit(uid, uname, action, resource=None, details=None, ip=None): c.execute("INSERT INTO audit_log (id,user_id,username,action,resource,details,ip) VALUES (?,?,?,?,?,?,?)", (str(uuid.uuid4()), uid, uname, action, resource, details, ip)) +def _config_log(config_type, config_id, config_name, severity, action, message, uid=None, uname=None): + with db() as c: + c.execute("INSERT INTO config_logs (id,config_type,config_id,config_name,severity,action,message,user_id,username) VALUES (?,?,?,?,?,?,?,?,?)", + (str(uuid.uuid4()), config_type, config_id, config_name or "", severity, action, str(message)[:2000], uid, uname)) + # ── Models ──────────────────────────────────────────────────────────────────── class LoginReq(BaseModel): username: str; password: str; totp_code: Optional[str] = None @@ -391,6 +409,7 @@ async def save_oci( c.execute("INSERT INTO oci_configs (id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,key_file_path,compartment_id) VALUES (?,?,?,?,?,?,?,?,?)", (cid, u["id"], tenancy_name, tenancy_ocid, user_ocid, fingerprint, region, str(kp), compartment_id or None)) _audit(u["id"], u["username"], "save_oci_config", cid, f"tenancy={tenancy_name}") + _config_log("oci", cid, tenancy_name, "success", "save", f"Credencial salva: {tenancy_name} ({region})", u["id"], u["username"]) return {"id": cid, "tenancy_name": tenancy_name, "region": region} @app.get("/api/oci/configs") @@ -414,14 +433,30 @@ async def del_oci(cid: str, u=Depends(require("admin","user"))): @app.post("/api/oci/test/{cid}") async def test_oci(cid: str, u=Depends(require("admin","user"))): cp = OCI_DIR / cid / "config" - if not cp.exists(): return {"status":"error","message":"Config não encontrada"} + cname = None + with db() as c: + row = c.execute("SELECT tenancy_name FROM oci_configs WHERE id=?", (cid,)).fetchone() + if row: cname = row["tenancy_name"] + if not cp.exists(): + _config_log("oci", cid, cname, "error", "test", "Config não encontrada", u["id"], u["username"]) + return {"status":"error","message":"Config não encontrada"} try: r = subprocess.run(["oci","iam","region","list","--config-file",str(cp),"--output","json"], capture_output=True, text=True, timeout=30) - if r.returncode == 0: return {"status":"success","message":"Conexão OK","data":json.loads(r.stdout)} - return {"status":"error","message":r.stderr[:500]} - except FileNotFoundError: return {"status":"error","message":"OCI CLI não disponível no container."} - except subprocess.TimeoutExpired: return {"status":"error","message":"Timeout na conexão"} - except Exception as e: return {"status":"error","message":str(e)} + if r.returncode == 0: + _config_log("oci", cid, cname, "success", "test", "Conexão OK", u["id"], u["username"]) + return {"status":"success","message":"Conexão OK","data":json.loads(r.stdout)} + msg = r.stderr[:500] + _config_log("oci", cid, cname, "error", "test", msg, u["id"], u["username"]) + return {"status":"error","message":msg} + except FileNotFoundError: + _config_log("oci", cid, cname, "error", "test", "OCI CLI não disponível no container.", u["id"], u["username"]) + return {"status":"error","message":"OCI CLI não disponível no container."} + except subprocess.TimeoutExpired: + _config_log("oci", cid, cname, "error", "test", "Timeout na conexão", u["id"], u["username"]) + return {"status":"error","message":"Timeout na conexão"} + except Exception as e: + _config_log("oci", cid, cname, "error", "test", str(e)[:500], u["id"], u["username"]) + return {"status":"error","message":str(e)[:500]} # ── OCI Account Explorer ────────────────────────────────────────────────────── @app.get("/api/oci/explore/{cid}/compartments") @@ -531,6 +566,7 @@ async def save_genai(req: GenAIConfigReq, u=Depends(require("admin","user"))): req.temperature, req.max_tokens, req.top_p, req.top_k, req.frequency_penalty, req.presence_penalty, int(req.is_default))) _audit(u["id"], u["username"], "save_genai_config", gid, req.model_id) + _config_log("genai", gid, req.name, "success", "save", f"Modelo salvo: {req.model_id} ({req.genai_region})", u["id"], u["username"]) return {"id": gid, "model_id": req.model_id, "endpoint": ep} @app.get("/api/genai/configs") @@ -550,11 +586,15 @@ async def test_genai(gid: str, u=Depends(require("admin","user"))): with db() as c: gc = c.execute("SELECT * FROM genai_configs WHERE id=?",(gid,)).fetchone() if not gc: raise HTTPException(404) + gname = gc["name"] try: resp = _call_genai(dict(gc), "Say 'connection successful' in one short sentence.") + _config_log("genai", gid, gname, "success", "test", f"GenAI OK: {resp[:200]}", u["id"], u["username"]) return {"status":"success","message":"GenAI OK","response":resp[:300]} except Exception as e: - return {"status":"error","message":str(e)[:500]} + msg = str(e)[:500] + _config_log("genai", gid, gname, "error", "test", msg, u["id"], u["username"]) + return {"status":"error","message":msg} def _call_genai(gc: dict, message: str, history: list = None) -> str: """ @@ -801,6 +841,7 @@ async def register_mcp(req: MCPServerReq, u=Depends(require("admin","user"))): json.dumps(req.env_vars) if req.env_vars else None, req.url, req.module_path, json.dumps(req.tools) if req.tools else None, req.linked_adb_id)) _audit(u["id"], u["username"], "register_mcp", mid, req.name) + _config_log("mcp", mid, req.name, "success", "save", f"MCP registrado: {req.name} ({req.server_type})", u["id"], u["username"]) return {"id": mid, "name": req.name, "server_type": req.server_type} @app.get("/api/mcp/servers") @@ -855,17 +896,28 @@ async def save_adb(req: ADBVectorReq, u=Depends(require("admin","user"))): _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) + _config_log("adb", vid, req.config_name, "success", "save", f"Conexão salva: {req.config_name} ({req.dsn})", u["id"], u["username"]) return {"id": vid, "config_name": req.config_name} @app.post("/api/adb/{vid}/upload-wallet") async def upload_wallet(vid: str, wallet: UploadFile = File(...), u=Depends(require("admin","user"))): - wdir = WALLET_DIR / vid; wdir.mkdir(parents=True, exist_ok=True) - zp = wdir / "wallet.zip" - zp.write_bytes(await wallet.read()) - import zipfile - with zipfile.ZipFile(str(zp), 'r') as z: z.extractall(str(wdir)) - with db() as c: c.execute("UPDATE adb_vector_configs SET wallet_dir=? WHERE id=?", (str(wdir), vid)) - return {"ok": True, "wallet_dir": str(wdir), "files": os.listdir(str(wdir))} + cname = None + with db() as c: + row = c.execute("SELECT config_name FROM adb_vector_configs WHERE id=?", (vid,)).fetchone() + if row: cname = row["config_name"] + try: + wdir = WALLET_DIR / vid; wdir.mkdir(parents=True, exist_ok=True) + zp = wdir / "wallet.zip" + zp.write_bytes(await wallet.read()) + import zipfile + with zipfile.ZipFile(str(zp), 'r') as z: z.extractall(str(wdir)) + with db() as c: c.execute("UPDATE adb_vector_configs SET wallet_dir=? WHERE id=?", (str(wdir), vid)) + files = os.listdir(str(wdir)) + _config_log("adb", vid, cname, "success", "upload", f"Wallet enviada: {', '.join(files)}", u["id"], u["username"]) + return {"ok": True, "wallet_dir": str(wdir), "files": files} + except Exception as e: + _config_log("adb", vid, cname, "error", "upload", str(e)[:500], u["id"], u["username"]) + raise HTTPException(500, str(e)[:500]) @app.get("/api/adb/configs") async def list_adb(u=Depends(current_user)): @@ -880,14 +932,19 @@ async def test_adb(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) + cname = cfg["config_name"] try: conn = _get_adb_connection(dict(cfg)) cur = conn.cursor(); cur.execute("SELECT 1 FROM DUAL"); cur.close(); conn.close() + _config_log("adb", vid, cname, "success", "test", "Conexão Autonomous DB OK!", u["id"], u["username"]) return {"status":"success","message":"Conexão Autonomous DB OK!"} except ImportError: + _config_log("adb", vid, cname, "error", "test", "python-oracledb não disponível no container.", u["id"], u["username"]) return {"status":"error","message":"python-oracledb não disponível no container."} except Exception as e: - return {"status":"error","message":str(e)[:500]} + msg = str(e)[:500] + _config_log("adb", vid, cname, "error", "test", msg, u["id"], u["username"]) + return {"status":"error","message":msg} @app.delete("/api/adb/configs/{vid}") async def del_adb(vid: str, u=Depends(require("admin","user"))): @@ -948,8 +1005,10 @@ def _ingest_documents_task(cfg: dict, genai_cfg: dict, documents: list, user_id: cur.close() log.info(f"Ingested {inserted}/{len(documents)} documents into {table_name}") _audit(user_id, username, "ingest_documents", cfg["id"], f"{inserted} documents") + _config_log("adb", cfg["id"], cfg.get("config_name"), "success", "ingest", f"{inserted}/{len(documents)} documentos ingeridos em {table_name}", user_id, username) except Exception as e: log.error(f"Ingestion task failed: {e}") + _config_log("adb", cfg["id"], cfg.get("config_name"), "error", "ingest", str(e)[:500], user_id, username) finally: conn.close() @@ -1124,12 +1183,15 @@ async def _exec_report(rid, cfg, regions, mcp_server_id): if proc.returncode == 0 and jp.exists(): c.execute("UPDATE reports SET status='completed',report_data=?,html_path=?,json_path=?,completed_at=datetime('now') WHERE id=?", (jp.read_text()[:500_000], str(hp) if hp.exists() else None, str(jp), rid)) + _config_log("oci", cfg["id"], cfg["tenancy_name"], "success", "report", f"Relatório concluído: {rid}") else: - c.execute("UPDATE reports SET status='failed',error_msg=?,completed_at=datetime('now') WHERE id=?", - ((stderr.decode() if stderr else "Unknown")[:2000], rid)) + err = (stderr.decode() if stderr else "Unknown")[:2000] + c.execute("UPDATE reports SET status='failed',error_msg=?,completed_at=datetime('now') WHERE id=?", (err, rid)) + _config_log("oci", cfg["id"], cfg["tenancy_name"], "error", "report", err) except Exception as e: with db() as c: c.execute("UPDATE reports SET status='failed',error_msg=?,completed_at=datetime('now') WHERE id=?", (str(e)[:2000], rid)) + _config_log("oci", cfg["id"], cfg["tenancy_name"], "error", "report", str(e)[:2000]) @app.get("/api/reports") async def list_reports(u=Depends(current_user)): @@ -1270,6 +1332,36 @@ async def audit_log(limit:int=Query(100,le=500), u=Depends(require("admin"))): with db() as c: rows=c.execute("SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ?",(limit,)).fetchall() return [dict(r) for r in rows] +# ── Config Logs ─────────────────────────────────────────────────────────────── +@app.get("/api/config-logs") +async def get_config_logs( + config_type: str = Query(None), config_id: str = Query(None), + severity: str = Query(None), limit: int = Query(50, le=200), + u=Depends(current_user) +): + query = "SELECT * FROM config_logs WHERE 1=1" + params = [] + if config_type: query += " AND config_type=?"; params.append(config_type) + if config_id: query += " AND config_id=?"; params.append(config_id) + if severity: query += " AND severity=?"; params.append(severity) + if u["role"] != "admin": query += " AND user_id=?"; params.append(u["id"]) + query += " ORDER BY created_at DESC LIMIT ?" + params.append(limit) + with db() as c: rows = c.execute(query, params).fetchall() + return [dict(r) for r in rows] + +@app.delete("/api/config-logs") +async def clear_config_logs( + config_type: str = Query(None), config_id: str = Query(None), + u=Depends(require("admin")) +): + query = "DELETE FROM config_logs WHERE 1=1" + params = [] + if config_type: query += " AND config_type=?"; params.append(config_type) + if config_id: query += " AND config_id=?"; params.append(config_id) + with db() as c: c.execute(query, params) + return {"ok": True} + # ── Health ──────────────────────────────────────────────────────────────────── @app.get("/api/health") async def health(): diff --git a/frontend/index.html b/frontend/index.html index 98fff9e..ac6454d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -213,7 +213,8 @@ async function loadData(){try{ if(S.user.role==='admin'){try{S.users=await $api('/users')}catch(e){}} }catch(e){console.error(e)}} function R(){document.getElementById('app').innerHTML=S.user?rApp():rLogin();bind()} -function switchTab(t){S.tab=t;S.expData=null;R();if(t==='audit'&&S.user.role==='admin')loadAudit();if(t==='downloads')refreshDl()} +function switchTab(t){S.tab=t;S.expData=null;R();if(t==='audit'&&S.user.role==='admin')loadAudit();if(t==='downloads')refreshDl(); + const tm={'oci-config':'oci','genai':'genai','adb':'adb','mcp':'mcp'};if(tm[t])setTimeout(()=>refreshCLogs(tm[t]),100)} /* ── Login ── */ function rLogin(){return`
@@ -330,7 +331,7 @@ function dlRpt(id,f){window.open(API+'/reports/'+id+'/download?fmt='+f,'_blank') async function refreshDl(){S.reports=await $api('/reports');R()} /* ── OCI Config ── */ -function rOci(){return`
☁️ Credenciais Registradas
+function rOci(){return`
☁️ Credenciais Registradas
${S.ociCfg.map(c=>` @@ -347,7 +348,8 @@ ${!S.ociCfg.length?'

Nenhuma credencia

-`} + +`+rConfigLogs('oci')} async function sOci(){const fd=new FormData();fd.append('tenancy_name',document.getElementById('otn').value);fd.append('tenancy_ocid',document.getElementById('oto').value); fd.append('user_ocid',document.getElementById('ouo').value);fd.append('fingerprint',document.getElementById('ofp').value); fd.append('region',document.getElementById('org').value);fd.append('compartment_id',document.getElementById('ocp').value); @@ -355,7 +357,7 @@ async function sOci(){const fd=new FormData();fd.append('tenancy_name',document. const pk=document.getElementById('opk').files[0];if(pk)fd.append('public_key',pk); const btn=document.getElementById('obtn');btn.disabled=true;btn.textContent='Salvando...';sm('om',' Salvando credencial...','i'); try{await $api('/oci/config',{method:'POST',body:fd,headers:{}});S.ociCfg=await $api('/oci/configs');sm('om','✅ Credencial salva com sucesso!','s');R()}catch(e){sm('om',e.message,'e');btn.disabled=false;btn.textContent='Salvar Credencial'}} -async function tOci(id){try{const d=await $api('/oci/test/'+id,{method:'POST'});alert(d.status==='success'?'✅ Conexão OK!':'❌ '+d.message)}catch(e){alert(e.message)}} +async function tOci(id){try{const d=await $api('/oci/test/'+id,{method:'POST'});sm('ocm',d.status==='success'?'✅ Conexão OK!':'❌ '+d.message,d.status==='success'?'s':'e');refreshCLogs('oci')}catch(e){sm('ocm',e.message,'e')}} async function dOci(id){if(!confirm('Excluir credencial?'))return;await $api('/oci/configs/'+id,{method:'DELETE'});S.ociCfg=await $api('/oci/configs');R()} /* ── GenAI Config ── */ @@ -372,8 +374,8 @@ ${!S.genaiCfg.length?'

Nenhum modelo c

➕ Novo Modelo GenAI
Configura conexão via OCI SDK (GenerativeAiInferenceClient). O endpoint é gerado automaticamente pela região selecionada.
-
+
@@ -385,7 +387,8 @@ ${S.ociCfg.map(c=>``).join('')
-
`} +
+`+rConfigLogs('genai')} async function sGenai(){const btn=document.getElementById('gbtn');btn.disabled=true;btn.textContent='Salvando...';sm('gm',' Salvando modelo...','i');try{await $api('/genai/config',{method:'POST',body:{ name:document.getElementById('gname').value||'default', oci_config_id:document.getElementById('goci').value,model_id:document.getElementById('gmod').value, @@ -397,7 +400,11 @@ async function sGenai(){const btn=document.getElementById('gbtn');btn.disabled=t frequency_penalty:parseFloat(document.getElementById('gfp').value),presence_penalty:parseFloat(document.getElementById('gpp').value), is_default:S.genaiCfg.length===0}}); S.genaiCfg=await $api('/genai/configs');sm('gm','✅ Modelo salvo com sucesso!','s');R()}catch(e){sm('gm',e.message,'e');btn.disabled=false;btn.textContent='Salvar Modelo'}} -async function tGenai(id){try{const d=await $api('/genai/test/'+id,{method:'POST'});alert(d.status==='success'?'✅ '+d.message+'\n'+d.response:'❌ '+d.message)}catch(e){alert(e.message)}} +function fillGenaiFromOci(){const sel=document.getElementById('goci');const cfg=S.ociCfg.find(c=>c.id===sel.value);if(!cfg)return; + const greg=document.getElementById('greg');if(greg){for(let i=0;i'+cfg.tenancy_name+'','i')} +async function tGenai(id){try{const d=await $api('/genai/test/'+id,{method:'POST'});sm('gm',d.status==='success'?'✅ '+d.message+(d.response?' — '+d.response:''):'❌ '+d.message,d.status==='success'?'s':'e');refreshCLogs('genai')}catch(e){sm('gm',e.message,'e')}} async function dGenai(id){if(!confirm('Excluir modelo?'))return;await $api('/genai/configs/'+id,{method:'DELETE'});S.genaiCfg=await $api('/genai/configs');R()} /* ── MCP Servers ── */ @@ -450,7 +457,8 @@ ${m.module_path?`
Módulo:
Opcional — o MCP server poderá usar este banco como ferramenta.
-`} + +`+rConfigLogs('mcp')} async function sMcp(){const btn=document.getElementById('mcbtn');btn.disabled=true;btn.textContent='Registrando...';sm('mcm',' Registrando servidor...','i');try{let args=null;const a=document.getElementById('margs')?.value;if(a)try{args=JSON.parse(a)}catch(e){} const mt=document.querySelector('input[name="mt"]:checked')?.value||'stdio'; await $api('/mcp/servers',{method:'POST',body:{name:document.getElementById('mn').value,description:document.getElementById('md').value, @@ -491,20 +499,21 @@ ${!S.adbCfg.length?'

Nenhuma conexão

📤 Upload Wallet
-
`} +
+`+rConfigLogs('adb')} async function sAdb(){const btn=document.getElementById('abtn');btn.disabled=true;btn.textContent='Salvando...';sm('am',' Salvando conexão...','i');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, 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');btn.disabled=false;btn.textContent='Salvar Conexão'}} -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 tAdb(id){try{const d=await $api('/adb/test/'+id,{method:'POST'});sm('am',d.status==='success'?'✅ '+d.message:'❌ '+d.message,d.status==='success'?'s':'e');refreshCLogs('adb')}catch(e){sm('am',e.message,'e')}} async function dAdb(id){if(!confirm('Excluir conexão?'))return;await $api('/adb/configs/'+id,{method:'DELETE'});S.adbCfg=await $api('/adb/configs');R()} async function uWallet(){const fd=new FormData();const f=document.getElementById('awf').files[0];if(!f)return sm('wm','Selecione um arquivo wallet ZIP.','e'); fd.append('wallet',f);const id=document.getElementById('awsel').value;if(!id)return sm('wm','Selecione uma conexão ADB.','e'); const btn=document.getElementById('wbtn');btn.disabled=true;btn.textContent='Enviando...';sm('wm',' Enviando wallet...','i'); try{const d=await $api('/adb/'+id+'/upload-wallet',{method:'POST',body:fd,headers:{}});S.adbCfg=await $api('/adb/configs');sm('wm','✅ Wallet enviada! Arquivos: '+d.files.join(', '),'s');R()}catch(e){sm('wm',e.message,'e');btn.disabled=false;btn.textContent='Upload Wallet'}} -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 ensureTable(id){try{const d=await $api('/adb/'+id+'/ensure-table',{method:'POST'});sm('am','✅ Tabela criada/verificada: '+d.table,'s')}catch(e){sm('am','Erro: '+e.message,'e')}} /* ── Embeddings ── */ function rEmbeddings(){ @@ -646,6 +655,31 @@ document.getElementById('ac').innerHTML=`
TenancyRegionCompartmentCriadoAções
${c.tenancy_name}${c.region} ${c.compartment_id?(c.compartment_id.slice(0,22)+'…'):'—'}${c.created_at}
`).join('')}
DataUsu ${logs.map(l=>`
${l.created_at}${l.username||'—'}${l.action} ${l.resource?(l.resource.slice(0,14)+'…'):'—'}${l.ip||'—'}
`}catch(e){document.getElementById('ac').innerHTML='
'+e.message+'
'}} +/* ── Config Logs ── */ +function rConfigLogs(type){return`
+📜 Log de Atividades +
+ +
+
Carregando logs...
`} + +async function refreshCLogs(type){ + const sev=document.getElementById('cl-sev-'+type)?.value||''; + const qs='config_type='+type+'&limit=30'+(sev?'&severity='+sev:''); + try{const logs=await $api('/config-logs?'+qs); + const el=document.getElementById('cl-'+type);if(!el)return; + if(!logs.length){el.innerHTML='

Nenhum log registrado.

';return} + el.innerHTML=''+ + logs.map(l=>{const sc=l.severity==='error'?'rd':l.severity==='success'?'gn':'bl'; + return''+ + ''+ + ''+ + ''+ + ''}).join('')+ + '
DataConfigAçãoStatusMensagem
'+l.created_at+''+(l.config_name||l.config_id?.slice(0,8)||'—')+''+l.action+''+l.severity+''+(l.message?.slice(0,150)||'—')+'
'} + catch(e){const el=document.getElementById('cl-'+type);if(el)el.innerHTML='
'+e.message+'
'}} + /* ── Helpers ── */ function sm(id,msg,t){const el=document.getElementById(id);if(el)el.innerHTML=`
${msg}
`} let _lu='',_lp='';