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
| Tenancy | Region | Compartment | Criado | Ações |
${S.ociCfg.map(c=>`| ${c.tenancy_name} | ${c.region} |
${c.compartment_id?(c.compartment_id.slice(0,22)+'…'):'—'} | ${c.created_at} |
@@ -347,7 +348,8 @@ ${!S.ociCfg.length?'
-`}
+
+`+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=`| Data | Usu
${logs.map(l=>` |
|---|
| ${l.created_at} | ${l.username||'—'} | ${l.action} |
${l.resource?(l.resource.slice(0,14)+'…'):'—'} | ${l.ip||'—'} |
`).join('')}
`}catch(e){document.getElementById('ac').innerHTML=''+e.message+'
'}}
+/* ── Config Logs ── */
+function rConfigLogs(type){return`
+
📜 Log de Atividades
+
+
+
+
`}
+
+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='';return}
+ el.innerHTML='| Data | Config | Ação | Status | Mensagem |
'+
+ logs.map(l=>{const sc=l.severity==='error'?'rd':l.severity==='success'?'gn':'bl';
+ return'| '+l.created_at+' | '+
+ ''+(l.config_name||l.config_id?.slice(0,8)||'—')+' | '+
+ ''+l.action+' | '+
+ ''+l.severity+' | '+
+ ''+(l.message?.slice(0,150)||'—')+' |
'}).join('')+
+ '
'}
+ 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='';