- Add gen_tf_reference.py to generate OCI Terraform provider resource catalog (~937 resources) from `terraform providers schema -json` - Auto-inject categorized resource reference into Terraform Agent system prompt to prevent invalid resource type names - Build-time reference generation in Dockerfile, refreshable via POST /api/terraform/refresh-reference - Provider alias detection with brace-matching parser for multi-region deployments - Clean old .tf files before writing new ones on Re-plan - Message timestamps in terraform chat and history - Partial DOM rendering to eliminate screen flickering - Layout overflow fix (no scroll beyond viewport)
1982 lines
189 KiB
HTML
1982 lines
189 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="pt-BR">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>OCI CIS AI Agent</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">
|
||
<style>
|
||
:root {
|
||
--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;
|
||
--tx2:#78716c;--p:#1c1917;--pl:rgba(199,70,52,.06);
|
||
--b2:#ddd9d2;--b3:#c5c0b8;
|
||
--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);
|
||
--fm:'JetBrains Mono',monospace;
|
||
--fs:'Plus Jakarta Sans',-apple-system,system-ui,sans-serif;
|
||
--trans:cubic-bezier(.22,1,.36,1)
|
||
}
|
||
*{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,.15);color:var(--t1)}
|
||
|
||
/* ── Sidebar ── */
|
||
.app{display:flex;height:100vh;overflow:hidden}
|
||
.sb{width:260px;background:var(--bg1);display:flex;flex-direction:column;position:fixed;top:0;left:0;bottom:0;z-index:100;
|
||
border-right:1px solid var(--bd);transition:transform .4s var(--trans)}
|
||
.sb-h{padding:1.25rem 1.15rem;background:linear-gradient(145deg,#c74634 0%,#a83428 50%,#8b2a20 100%);position:relative;overflow:hidden}
|
||
.sb-h::after{content:'';position:absolute;top:-30%;right:-20%;width:120px;height:120px;background:radial-gradient(circle,rgba(255,255,255,.08) 0%,transparent 70%);border-radius:50%}
|
||
.sb-h h1{font-size:.82rem;font-weight:700;color:#fff;font-family:var(--fs);letter-spacing:-.02em;display:flex;align-items:center;gap:.55rem;position:relative;z-index:1}
|
||
.sb-h .st{font-size:.62rem;color:rgba(255,255,255,.6);margin-top:3px;font-family:var(--fm);font-weight:400;letter-spacing:.02em;position:relative;z-index:1}
|
||
.nav{padding:.75rem .6rem;flex:1;overflow-y:auto}
|
||
.nl{font-size:.6rem;color:var(--t4);text-transform:uppercase;letter-spacing:.12em;padding:.85rem .65rem .35rem;font-weight:700}
|
||
.ni{display:flex;align-items:center;gap:.65rem;padding:.55rem .7rem;border-radius:var(--r);cursor:pointer;color:var(--t3);
|
||
font-size:.82rem;font-weight:500;transition:all .25s var(--trans);margin-bottom:2px;position:relative}
|
||
.ni:hover{background:var(--bg2);color:var(--t2);transform:translateX(2px)}
|
||
.ni.on{background:var(--acl2);color:var(--ac);font-weight:600}
|
||
.ni.on::before{content:'';position:absolute;left:0;top:50%;transform:translateY(-50%);width:3px;height:60%;background:var(--ac);border-radius:0 3px 3px 0}
|
||
.ni .ic{width:18px;text-align:center;font-size:.95rem;transition:transform .2s var(--trans)}
|
||
.ni:hover .ic{transform:scale(1.12)}
|
||
.sb-f{padding:.85rem 1rem;border-top:1px solid var(--bd);background:var(--bg2)}
|
||
.ui{display:flex;align-items:center;gap:.6rem}
|
||
.ua{width:32px;height:32px;background:linear-gradient(135deg,var(--ac),var(--acd));border-radius:10px;display:flex;align-items:center;justify-content:center;
|
||
font-weight:700;font-size:.72rem;color:#fff;box-shadow:0 2px 8px rgba(199,70,52,.2)}
|
||
.un{font-weight:600;color:var(--t1);font-size:.78rem}.ur{color:var(--t4);font-size:.62rem;font-family:var(--fm)}
|
||
.lo-btn{margin-left:auto;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border-radius:8px;cursor:pointer;
|
||
color:var(--t4);border:1px solid var(--bd);background:var(--bg1);font-size:.82rem;transition:all .2s}
|
||
.lo-btn:hover{border-color:var(--rd);color:var(--rd);background:var(--rdl)}
|
||
|
||
/* ── Main Content ── */
|
||
.mc{flex:1;margin-left:260px;height:100vh;overflow:hidden;display:flex;flex-direction:column}
|
||
.tb{height:56px;background:rgba(255,255,255,.8);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);
|
||
border-bottom:1px solid var(--bd);display:flex;align-items:center;justify-content:space-between;padding:0 1.5rem;position:sticky;top:0;z-index:50}
|
||
.tb-t{font-weight:700;font-size:.92rem;color:var(--t1);letter-spacing:-.01em}
|
||
.tb-a{display:flex;align-items:center;gap:.6rem}
|
||
.pc{padding:1.5rem;max-width:1400px;flex:1;overflow-y:auto;min-height:0}
|
||
|
||
/* ── Cards ── */
|
||
.cd{background:var(--bg1);border:1px solid var(--bd);border-radius:var(--rl);padding:1.35rem;margin-bottom:.85rem;
|
||
box-shadow:var(--sh1);transition:box-shadow .3s var(--trans)}
|
||
.cd:hover{box-shadow:var(--sh2)}
|
||
.ct{font-weight:700;font-size:.92rem;margin-bottom:.85rem;display:flex;align-items:center;gap:.45rem;color:var(--t1);letter-spacing:-.01em}
|
||
.cdesc{font-size:.78rem;color:var(--t3);margin-top:-.45rem;margin-bottom:.85rem;line-height:1.55}
|
||
|
||
/* ── Buttons ── */
|
||
.btn{display:inline-flex;align-items:center;gap:.4rem;padding:.5rem .95rem;border-radius:10px;font-size:.78rem;font-weight:600;
|
||
cursor:pointer;border:1px solid transparent;transition:all .2s var(--trans);font-family:var(--fs);letter-spacing:-.01em}
|
||
.bp{background:linear-gradient(145deg,var(--ac),var(--ach));color:#fff;border-color:transparent;box-shadow:0 2px 8px rgba(199,70,52,.2)}
|
||
.bp:hover{background:linear-gradient(145deg,var(--ach),var(--acd));box-shadow:0 4px 12px rgba(199,70,52,.3);transform:translateY(-1px)}
|
||
.bp:active{transform:translateY(0);box-shadow:0 1px 4px rgba(199,70,52,.2)}
|
||
.bs{background:var(--bg1);color:var(--t2);border-color:var(--bd)}.bs:hover{border-color:var(--bdf);color:var(--t1);background:var(--bg2)}
|
||
.bd{background:var(--rdl);color:var(--rd);border-color:rgba(220,38,38,.12)}.bd:hover{background:var(--rd);color:#fff;box-shadow:0 2px 8px rgba(220,38,38,.2)}
|
||
.bsm{padding:.32rem .6rem;font-size:.72rem;border-radius:8px}
|
||
.btn:disabled{opacity:.45;cursor:not-allowed;transform:none!important;box-shadow:none!important}
|
||
|
||
/* ── Form ── */
|
||
.ig{margin-bottom:.75rem}
|
||
.ig label{display:block;font-size:.74rem;font-weight:600;color:var(--t2);margin-bottom:.3rem;letter-spacing:-.01em}
|
||
.ig .ht{font-size:.66rem;color:var(--t4);margin-bottom:.2rem;line-height:1.45}
|
||
input[type=text],input[type=password],input[type=email],input[type=number],select,textarea{
|
||
width:100%;padding:.55rem .75rem;background:var(--bg2);border:1.5px solid var(--bd);border-radius:10px;color:var(--t1);
|
||
font-size:.8rem;font-family:var(--fm);transition:all .2s var(--trans);font-weight:500}
|
||
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--ac);background:var(--bg1);box-shadow:0 0 0 3px rgba(199,70,52,.08)}
|
||
input::placeholder{color:var(--t4);font-weight:400}
|
||
input[type=file]{font-family:var(--fm);font-size:.73rem;color:var(--t3);padding:.4rem}
|
||
select{cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%2378716c'%3E%3Cpath d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||
background-repeat:no-repeat;background-position:right .7rem center}
|
||
|
||
/* ── Badges ── */
|
||
.badge{padding:.2rem .6rem;border-radius:20px;font-size:.66rem;font-weight:600;font-family:var(--fm);letter-spacing:.01em}
|
||
.b-pass{background:var(--gnl);color:var(--gn)}.b-fail{background:var(--rdl);color:var(--rd)}
|
||
.b-pend{background:var(--yll);color:var(--yl)}.b-rev{background:var(--bll);color:var(--bl)}
|
||
.b-admin{background:var(--ppl);color:var(--pp)}.b-user{background:var(--bll);color:var(--bl)}.b-viewer{background:var(--bg3);color:var(--t3)}
|
||
.b-on{background:var(--gnl);color:var(--gn)}.b-off{background:var(--bg3);color:var(--t4)}
|
||
|
||
/* ── Tables ── */
|
||
table{width:100%;border-collapse:separate;border-spacing:0;font-size:.78rem}
|
||
th{text-align:left;padding:.65rem .75rem;font-size:.66rem;text-transform:uppercase;letter-spacing:.08em;color:var(--t4);
|
||
border-bottom:1.5px solid var(--bd);font-weight:700;background:transparent}
|
||
td{padding:.7rem .75rem;border-bottom:1px solid var(--bg3);color:var(--t2);transition:background .15s}
|
||
tr{transition:background .2s}tr:hover td{background:var(--acl)}
|
||
tbody tr:last-child td{border-bottom:none}
|
||
|
||
/* ── Grid ── */
|
||
.g2{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}
|
||
.g3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:.75rem}
|
||
|
||
/* ── Chat History Panel (inside chat/terraform) ── */
|
||
.ch-hp{width:0;overflow:hidden;transition:width .25s ease;background:var(--bg1);border-right:0 solid var(--bd);flex-shrink:0;order:0}
|
||
.ch-hp.open{width:260px;border-right-width:1px}
|
||
.ch-hp-inner{width:260px;display:flex;flex-direction:column;height:100%;box-sizing:border-box}
|
||
.ch-hp-hdr{display:flex;align-items:center;justify-content:space-between;padding:.55rem .7rem;border-bottom:1px solid var(--bd);font-size:.74rem;font-weight:700;color:var(--t2)}
|
||
.ch-hp-new{font-size:.72rem;font-weight:600;color:var(--ac);cursor:pointer;padding:.35rem .7rem;border:1px dashed var(--ac);border-radius:6px;transition:background .15s}
|
||
.ch-hp-new:hover{background:var(--acl)}
|
||
.ch-hp-list{overflow-y:auto;padding:.3rem .45rem;flex:1}
|
||
.ch-hp-grp{font-size:.58rem;font-weight:700;color:var(--t4);text-transform:uppercase;letter-spacing:.06em;padding:.5rem .25rem .15rem}
|
||
.ch-hp-item{display:flex;align-items:center;gap:.3rem;padding:.4rem .5rem;border-radius:8px;cursor:pointer;font-size:.72rem;color:var(--t2);transition:background .15s;margin-bottom:1px}
|
||
.ch-hp-item:hover{background:var(--bg3)}
|
||
.ch-hp-item.active{background:var(--acl2);color:var(--ac);font-weight:600}
|
||
.ch-hp-ttl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.ch-hp-del{opacity:0;transition:opacity .15s;cursor:pointer;color:var(--t4);flex-shrink:0;display:flex;align-items:center;padding:2px}
|
||
.ch-hp-del:hover{color:var(--rd)}
|
||
.ch-hp-time{font-size:.58rem;color:var(--t4);flex-shrink:0;font-variant-numeric:tabular-nums}
|
||
.ch-hp-item:hover .ch-hp-del{opacity:1}
|
||
|
||
/* ── Chat ── */
|
||
.ch-c{display:flex;height:calc(100vh - 56px - 3rem);overflow:hidden;border:1px solid var(--bd);border-radius:var(--rl);box-shadow:var(--sh2)}
|
||
.ch-side{width:0;overflow:hidden;transition:width .3s cubic-bezier(.4,0,.2,1);background:var(--bg1);border-left:0 solid var(--bd);flex-shrink:0;order:2}
|
||
.ch-side.open{width:300px;border-left-width:1px}
|
||
.ch-side-inner{width:300px;padding:.85rem;overflow-y:auto;height:100%;box-sizing:border-box}
|
||
.ch-side-title{font-weight:700;font-size:.82rem;padding-bottom:.5rem;border-bottom:1px solid var(--bd);margin-bottom:.65rem;display:flex;align-items:center;gap:.4rem}
|
||
.ch-side-section{margin-bottom:.85rem}
|
||
.ch-side-section label{font-weight:600;color:var(--t3);font-size:.68rem;display:block;margin-bottom:3px}
|
||
.ch-side-section input[type="number"],.ch-side-section select{width:100%;padding:.32rem .5rem;font-size:.74rem;border:1px solid var(--bd);border-radius:6px;background:var(--bg2);box-sizing:border-box}
|
||
.ch-side .g2{display:grid;grid-template-columns:1fr 1fr;gap:.5rem}
|
||
.ch-main{flex:1;display:flex;flex-direction:column;min-width:0;order:1}
|
||
.ch-t{display:flex;gap:.5rem;align-items:center;padding:.55rem .85rem;background:var(--bg1);
|
||
border-bottom:1px solid var(--bd);font-size:.74rem;flex-wrap:wrap}
|
||
.ch-t select{max-width:260px;font-size:.74rem;padding:.35rem .5rem}
|
||
.ch-icon-btn{width:32px;height:32px;display:flex;align-items:center;justify-content:center;border-radius:8px;cursor:pointer;border:1px solid var(--bd);background:var(--bg1);font-size:.82rem;transition:all .2s;flex-shrink:0}
|
||
.ch-icon-btn:hover{border-color:var(--ac);color:var(--ac)}
|
||
.ch-icon-btn.active{background:var(--pl);border-color:var(--ac);color:var(--ac)}
|
||
.mdrop{position:relative;display:inline-block}
|
||
.mdrop-btn{background:var(--bg2);border:1px solid var(--bd);border-radius:8px;padding:.35rem .6rem;font-size:.74rem;cursor:pointer;max-width:320px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:.3rem;min-width:180px}
|
||
.mdrop-btn:hover{border-color:var(--ac)}
|
||
.mdrop-btn .arr{font-size:.55rem;margin-left:auto;opacity:.5}
|
||
.mdrop-dd{position:absolute;top:100%;left:0;z-index:100;background:var(--bg1);border:1px solid var(--bd);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.12);margin-top:4px;min-width:320px;max-width:400px;display:none}
|
||
.mdrop-dd.open{display:block}
|
||
.mdrop-dd input{width:100%;padding:.45rem .6rem;border:none;border-bottom:1px solid var(--bd);font-size:.74rem;background:var(--bg2);border-radius:10px 10px 0 0;outline:none;box-sizing:border-box}
|
||
.mdrop-list{max-height:220px;overflow-y:auto;padding:.25rem 0}
|
||
.mdrop-list .grp{font-size:.64rem;font-weight:700;color:var(--t3);padding:.4rem .7rem .15rem;text-transform:uppercase;letter-spacing:.04em}
|
||
.mdrop-list .itm{padding:.3rem .7rem;font-size:.74rem;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.mdrop-list .itm:hover{background:var(--bg3)}
|
||
.mdrop-list .itm.sel{background:var(--pl);color:var(--p);font-weight:600}
|
||
.ch-t label{font-weight:600;color:var(--t3);font-size:.72rem}
|
||
.ch-m{flex:1;overflow-y:auto;padding:1rem;background:var(--bg2)}
|
||
.cm{margin-bottom:.85rem;display:flex;gap:.6rem;animation:msgIn .3s var(--trans)}.cm-user{justify-content:flex-end}
|
||
@keyframes msgIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
||
.cm-user .cb{background:linear-gradient(145deg,var(--ac),var(--ach));color:#fff;border-radius:var(--rl) var(--rl) 6px var(--rl);border-color:transparent;box-shadow:0 2px 8px rgba(199,70,52,.15)}
|
||
.cm-user .cb code{background:rgba(255,255,255,.2);color:#fff}
|
||
.cm-user .cb strong{color:#ffe0db}
|
||
.cb{max-width:72%;padding:.7rem .95rem;border-radius:var(--rl) var(--rl) var(--rl) 6px;background:var(--bg1);font-size:.82rem;
|
||
line-height:1.6;border:1px solid var(--bd);white-space:pre-wrap;box-shadow:var(--sh1)}
|
||
.cm-ts{font-size:.65rem;color:#999;margin-top:3px;opacity:.7}
|
||
.cb code{font-family:var(--fm);font-size:.73rem;background:var(--bg3);padding:.12rem .3rem;border-radius:5px}
|
||
.cb strong{color:var(--ac)}
|
||
.ch-i{display:flex;gap:.45rem;padding:.7rem .85rem;background:var(--bg1);border-top:1px solid var(--bd);box-shadow:0 -2px 8px rgba(0,0,0,.02)}
|
||
.ch-i textarea,.ch-i input{flex:1;background:var(--bg2);border-radius:10px;font-family:inherit;font-size:inherit;line-height:1.4;padding:.45rem .65rem;border:1.5px solid var(--bd);min-height:36px;max-height:200px}
|
||
.ch-i .btn{border-radius:10px}
|
||
|
||
/* ── Report ── */
|
||
.rif{width:100%;height:calc(100vh - 56px - 6rem);border:1px solid var(--bd);border-radius:var(--rl);background:#fff;box-shadow:var(--sh2)}
|
||
|
||
/* ── Login ── */
|
||
.lp{min-height:100vh;display:flex;align-items:center;justify-content:center;
|
||
background:var(--bg);position:relative;overflow:hidden}
|
||
.lp::before{content:'';position:absolute;top:-30%;right:-15%;width:600px;height:600px;
|
||
background:radial-gradient(circle,rgba(199,70,52,.06) 0%,transparent 70%);border-radius:50%;pointer-events:none}
|
||
.lp::after{content:'';position:absolute;bottom:-20%;left:-10%;width:500px;height:500px;
|
||
background:radial-gradient(circle,rgba(199,70,52,.04) 0%,transparent 70%);border-radius:50%;pointer-events:none}
|
||
.lc{background:var(--bg1);border:1px solid var(--bd);border-radius:var(--rr);padding:2.5rem;width:100%;max-width:380px;
|
||
box-shadow:var(--sh3);position:relative;z-index:1;animation:lcIn .5s var(--trans)}
|
||
@keyframes lcIn{from{opacity:0;transform:translateY(16px) scale(.98)}to{opacity:1;transform:translateY(0) scale(1)}}
|
||
.lc h2{font-size:1.2rem;font-family:var(--fs);color:var(--t1);font-weight:800;letter-spacing:-.02em}
|
||
.lc .st{color:var(--t4);font-size:.76rem;margin-top:.15rem;margin-bottom:1.35rem;font-weight:400}
|
||
|
||
/* ── Alerts ── */
|
||
.al{padding:.65rem .85rem;border-radius:10px;font-size:.78rem;margin-bottom:.75rem;font-weight:500}
|
||
.al-e{background:var(--rdl);color:var(--rd);border:1px solid rgba(220,38,38,.1)}
|
||
.al-s{background:var(--gnl);color:var(--gn);border:1px solid rgba(22,163,74,.1)}
|
||
.al-i{background:var(--bll);color:var(--bl);border:1px solid rgba(37,99,235,.1)}
|
||
|
||
/* ── Tags ── */
|
||
.tag{display:inline-block;padding:.15rem .45rem;background:var(--acl2);color:var(--ac);border-radius:6px;font-size:.66rem;font-family:var(--fm);font-weight:600}
|
||
|
||
/* ── Empty States ── */
|
||
.emp{text-align:center;padding:2.5rem 1.5rem;color:var(--t4)}
|
||
.emp .eic{font-size:2.2rem;margin-bottom:.55rem;filter:grayscale(.3)}
|
||
.emp p{line-height:1.55}
|
||
|
||
/* ── Explorer ── */
|
||
.exp-wrap{display:flex;height:calc(100vh - 56px - 3rem);border:1px solid var(--bd);border-radius:var(--rl);overflow:hidden;box-shadow:var(--sh2)}
|
||
.exp-tree{width:240px;flex-shrink:0;background:var(--bg1);border-right:none;overflow-y:auto;padding:.5rem 0;min-width:140px;max-width:50%}
|
||
.exp-resize{width:5px;flex-shrink:0;cursor:col-resize;background:var(--bd);transition:background .15s;position:relative}
|
||
.exp-resize:hover,.exp-resize.dragging{background:var(--ac);opacity:.6}
|
||
.exp-resize::after{content:'';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:3px;height:28px;border-left:1px solid var(--t4);border-right:1px solid var(--t4);opacity:.4}
|
||
.exp-resize:hover::after,.exp-resize.dragging::after{border-color:var(--ac);opacity:.8}
|
||
.exp-main{flex:1;display:flex;flex-direction:column;min-width:0;overflow:hidden}
|
||
.exp-toolbar{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-bottom:1px solid var(--bd);background:var(--bg);flex-wrap:wrap}
|
||
.exp-toolbar select{font-size:.74rem;padding:.3rem .5rem;border-radius:8px;border:1px solid var(--bd);background:var(--bg2);color:var(--t1)}
|
||
.exp-nav{display:flex;border-bottom:1px solid var(--bd);background:var(--bg);flex-shrink:0}
|
||
.exp-cats{display:flex;overflow-x:auto;gap:0;flex:1}
|
||
.exp-cat{padding:.45rem .7rem;font-size:.66rem;font-weight:600;cursor:pointer;color:var(--t4);white-space:nowrap;border-bottom:2px solid transparent;transition:all .15s;text-transform:uppercase;letter-spacing:.3px}
|
||
.exp-cat:hover{color:var(--t2);background:var(--bg2)}
|
||
.exp-cat.active{color:var(--ac);border-bottom-color:var(--ac);background:var(--acl)}
|
||
.exp-tabs{display:flex;gap:3px;padding:.3rem .75rem;border-bottom:1px solid var(--bd);background:var(--bg2);overflow-x:auto;flex-shrink:0}
|
||
.exp-tab{padding:.3rem .6rem;font-size:.7rem;border-radius:8px;cursor:pointer;color:var(--t3);white-space:nowrap;transition:all .15s;border:1px solid transparent}
|
||
.exp-tab:hover{background:var(--bg1);color:var(--t1);border-color:var(--bd)}
|
||
.exp-tab.active{background:var(--bg1);color:var(--ac);font-weight:600;border-color:var(--ac);box-shadow:0 1px 3px rgba(199,70,52,.1)}
|
||
.exp-content{flex:1;overflow-y:auto;padding:.75rem}
|
||
.exp-r{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:.65rem}
|
||
.exp-c{background:var(--bg2);border:1px solid var(--bd);border-radius:var(--r);padding:.85rem;font-size:.78rem;transition:all .2s var(--trans);overflow:hidden;word-wrap:break-word;overflow-wrap:break-word;min-width:0;box-sizing:border-box}
|
||
.exp-c:hover{border-color:var(--bdf);box-shadow:var(--sh1);transform:translateY(-1px)}
|
||
.exp-c strong{color:var(--t1);font-size:.84rem;font-weight:600;display:block;overflow:hidden;text-overflow:ellipsis}
|
||
.exp-c .em{font-family:var(--fm);font-size:.65rem;color:var(--t4);word-break:break-all;overflow-wrap:break-word;display:inline-block;max-width:100%}
|
||
.exp-node{padding:.15rem 0;font-size:.74rem;color:var(--t2)}
|
||
.exp-node-row{display:flex;align-items:center;gap:.25rem;padding:.25rem .5rem;border-radius:6px;cursor:pointer;transition:all .15s}
|
||
.exp-node-row:hover{background:var(--bg2)}
|
||
.exp-node-row.selected{background:var(--acl);color:var(--ac);font-weight:600;border-left:2px solid var(--ac)}
|
||
.exp-node-toggle{width:16px;text-align:center;font-size:.55rem;color:var(--t4);flex-shrink:0;cursor:pointer;transition:transform .15s}
|
||
.exp-node-children{padding-left:.85rem;border-left:1px solid var(--bg4);margin-left:.65rem}
|
||
|
||
/* ── Animations ── */
|
||
@keyframes fi{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
|
||
.fi{animation:fi .35s var(--trans)}
|
||
@keyframes pu{0%,100%{opacity:1}50%{opacity:.4}}
|
||
.ld{animation:pu 1.2s ease infinite;color:var(--t4);font-size:.82rem;text-align:center;padding:1.5rem}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
.spinner{display:inline-block;width:16px;height:16px;border:2px solid var(--bd);border-top-color:var(--ac);border-radius:50%;animation:spin .6s linear infinite}
|
||
|
||
/* ── Scrollbar ── */
|
||
::-webkit-scrollbar{width:6px}
|
||
::-webkit-scrollbar-track{background:transparent}
|
||
::-webkit-scrollbar-thumb{background:var(--bd);border-radius:3px}
|
||
::-webkit-scrollbar-thumb:hover{background:var(--bdf)}
|
||
|
||
/* ── Responsive ── */
|
||
@media(max-width:768px){.sb{transform:translateX(-100%)}.sb.open{transform:translateX(0)}.mc{margin-left:0}.g2,.g3{grid-template-columns:1fr}.pc{padding:1rem}
|
||
.exp-wrap{flex-direction:column;height:auto;min-height:calc(100vh - 56px - 3rem)}
|
||
.exp-tree{width:100%;max-height:180px;border-right:none;border-bottom:1px solid var(--bd)}
|
||
.exp-r{grid-template-columns:1fr}
|
||
.exp-cats{flex-wrap:wrap}
|
||
.exp-cat{padding:.3rem .5rem;font-size:.6rem}
|
||
}
|
||
/* ── Terraform Agent ── */
|
||
.tf-wrap{display:flex;flex-direction:column;height:calc(100vh - 56px - 3rem);overflow:hidden}
|
||
.tf-chat{flex:1;display:flex;flex-direction:column;overflow:hidden;min-height:0}
|
||
.tf-toolbar{display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-bottom:1px solid var(--bd);flex-wrap:wrap;background:var(--bg)}
|
||
.tf-msgs{flex:1;overflow-y:auto;padding:.7rem}
|
||
.tf-input{display:flex;gap:.5rem;padding:.5rem .7rem;border-top:1px solid var(--bd)}
|
||
.tf-input textarea,.tf-input input{flex:1;font-family:inherit;font-size:inherit;line-height:1.4;padding:.45rem .65rem;border:1.5px solid var(--bd);border-radius:10px;background:var(--bg2);min-height:36px;max-height:200px}
|
||
.tf-resize{height:5px;cursor:row-resize;background:var(--bd);transition:background .15s;position:relative;flex-shrink:0}
|
||
.tf-resize:hover,.tf-resize.dragging{background:#7b42bc;opacity:.7}
|
||
.tf-resize::after{content:'';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:28px;height:3px;border-top:1px solid var(--t4);border-bottom:1px solid var(--t4);opacity:.4}
|
||
.tf-resize:hover::after{border-color:#7b42bc;opacity:.8}
|
||
.tf-bottom{flex:1;display:flex;flex-direction:column;overflow:hidden;border-top:1px solid var(--bd);background:var(--bg1);min-height:0}
|
||
.tf-bottom-tabs{display:flex;align-items:center;gap:0;border-bottom:1px solid var(--bd);background:var(--bg);padding:0 .5rem}
|
||
.tf-btab{padding:.4rem .75rem;font-size:.72rem;font-weight:600;cursor:pointer;border-bottom:2px solid transparent;color:var(--t3);display:flex;align-items:center;gap:.3rem;transition:all .15s}
|
||
.tf-btab:hover{color:var(--t1)}
|
||
.tf-btab.active{color:#7b42bc;border-bottom-color:#7b42bc}
|
||
.tf-btab svg{width:14px;height:14px}
|
||
.tf-bottom-actions{margin-left:auto;display:flex;align-items:center;gap:.4rem;padding-right:.3rem}
|
||
.tf-bottom-content{flex:1;overflow:hidden;display:flex;flex-direction:row}
|
||
.tf-bc-left{flex:1;overflow:auto;padding:.5rem .7rem;min-width:0}
|
||
.tf-bc-right{width:50%;overflow:auto;border-left:1px solid var(--bd);flex-shrink:0;display:flex;flex-direction:column}
|
||
.tf-bc-right .tf-term-hdr{padding:.3rem .6rem;font-size:.68rem;font-weight:700;color:#cdd6f4;background:#12122a;border-bottom:1px solid #2a2a4a;display:flex;align-items:center;gap:.4rem}
|
||
.tf-bc-right .tf-term-hdr svg{width:12px;height:12px;fill:#cdd6f4}
|
||
.tf-bc-right .tf-term-body{flex:1;overflow:auto;background:#1a1a2e;padding:.5rem;font-family:var(--fm);font-size:.68rem;line-height:1.5;color:#cdd6f4;white-space:pre-wrap;word-break:break-word}
|
||
.tf-file{display:flex;align-items:center;gap:.5rem;padding:.45rem .6rem;border-radius:8px;border:1px solid var(--bd);margin-bottom:.4rem;background:var(--bg);transition:border-color .15s;cursor:pointer}
|
||
.tf-file:hover{border-color:#7b42bc}
|
||
.tf-file.editing{border-color:#7b42bc;background:#7b42bc08}
|
||
.tf-editor{border:1px solid #e2ddf5;border-radius:var(--r);overflow:hidden;margin-bottom:.5rem;background:#f8f6ff}
|
||
.tf-editor-hdr{display:flex;align-items:center;gap:.4rem;padding:.35rem .6rem;background:#efe9fc;border-bottom:1px solid #e2ddf5;font-size:.7rem;font-weight:600;color:#4a3875}
|
||
.tf-editor-hdr .tf-ed-name{flex:1;font-family:var(--fm)}
|
||
.tf-editor-hdr .btn{font-size:.62rem;padding:2px 8px}
|
||
.tf-editor-ta{width:100%;min-height:120px;max-height:400px;border:none;outline:none;resize:vertical;font-family:var(--fm);font-size:.72rem;line-height:1.6;color:#3b2e58;background:#f8f6ff;padding:.6rem .7rem;box-sizing:border-box;tab-size:2}
|
||
.tf-file-icon{width:32px;height:32px;border-radius:6px;background:linear-gradient(135deg,#7b42bc22,#5c4ee522);display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||
.tf-file-icon svg{width:16px;height:16px;fill:#7b42bc}
|
||
.tf-file-info{flex:1;min-width:0}
|
||
.tf-file-name{font-size:.76rem;font-weight:600;font-family:var(--fm);color:var(--t1)}
|
||
.tf-file-meta{font-size:.64rem;color:var(--t3)}
|
||
.tf-file-actions{display:flex;gap:.3rem}
|
||
.tf-file-btn{width:26px;height:26px;border-radius:6px;border:1px solid var(--bd);background:var(--bg);display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:.7rem;transition:all .15s}
|
||
.tf-file-btn:hover{border-color:#7b42bc;background:#7b42bc11}
|
||
.tf-code-block{background:#f8f6ff;border:1px solid #e2ddf5;border-radius:var(--r);overflow:hidden;margin:.6rem 0}
|
||
.tf-code-header{display:flex;align-items:center;gap:.4rem;padding:.4rem .7rem;background:#efe9fc;font-size:.72rem;color:#4a3875;border-bottom:1px solid #e2ddf5}
|
||
.tf-code-header span{flex:1;font-weight:600}
|
||
.tf-code-header .btn{color:#7b42bc;border-color:#d4c8ef;background:transparent;font-size:.66rem;padding:2px 8px}
|
||
.tf-code-header .btn:hover{background:#e2ddf5}
|
||
.tf-pre{padding:.85rem;overflow-x:auto;font-size:.74rem;line-height:1.6;color:#3b2e58;font-family:var(--fm);margin:0;white-space:pre}
|
||
.tf-plan-block{background:var(--gnl);border:1px solid rgba(22,163,74,.15);border-radius:var(--r);margin:.6rem 0;overflow:hidden}
|
||
.tf-plan-header{padding:.45rem .7rem;font-weight:700;font-size:.76rem;color:var(--gn);border-bottom:1px solid rgba(22,163,74,.12)}
|
||
.tf-plan-list{padding:.4rem .7rem}
|
||
.tf-plan-item{display:flex;align-items:baseline;gap:.5rem;padding:.25rem 0;font-size:.74rem}
|
||
.tf-plan-add{color:var(--gn);font-weight:700;font-family:var(--fm)}
|
||
.tf-plan-type{font-family:var(--fm);font-weight:600;color:var(--t1)}
|
||
.tf-plan-desc{color:var(--t3);font-size:.68rem}
|
||
.tf-badge{font-size:.58rem;padding:1px 6px;border-radius:10px;font-weight:700;display:inline-flex;align-items:center;gap:3px}
|
||
.tf-badge-draft{background:#7b42bc18;color:#7b42bc}
|
||
.tf-badge-ok{background:var(--gnl);color:var(--gn)}
|
||
.tf-badge-run{background:var(--bll);color:var(--bl)}
|
||
.tf-badge-err{background:var(--rdl);color:var(--rd)}
|
||
.tf-badge-dest{background:var(--t4);color:var(--bg)}
|
||
.tf-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:100}
|
||
.tf-modal{background:var(--bg);border-radius:var(--r);padding:1.5rem;max-width:420px;width:90%;box-shadow:0 8px 30px rgba(0,0,0,.25)}
|
||
.tf-modal h3{margin:0 0 .5rem;color:var(--rd)}
|
||
.tf-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:var(--t3);font-size:.76rem;gap:.3rem}
|
||
.tf-empty svg{width:40px;height:40px;fill:#7b42bc;opacity:.3}
|
||
@media(max-width:768px){
|
||
.tf-bottom{min-height:0}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="app"></div>
|
||
<script>
|
||
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:[],models:{},regions:[],embModels:{},expData:null,expCfg:'',expSelRegions:[],expRegDdOpen:false,expTree:[],expSelComp:'',expCat:'Compute',expResType:'instances',expTreeOpen:{},expLoading:false,expRegions:[],expCounts:{},editing:null,
|
||
chatModel:'',chatOci:'',chatRegion:'',chatCompartment:'',chatHistOpen:false,chatHistory:[],tfHistOpen:false,tfHistory:[],
|
||
chatParams:{temperature:1,max_tokens:6000,top_p:0.95,top_k:1,frequency_penalty:0,presence_penalty:0},chatPanel:'',chatUseTools:true,
|
||
chatPrompts:[],editingPrompt:null,trackingReportId:null,ociRegions:{},rptSelRegions:[],rptRegionsOpen:false,rptRegionFilter:'',
|
||
rptOciOpen:false,rptOciFilter:'',rptOciVal:'',rptRselOpen:false,rptRselFilter:'',rptRselVal:'',ociFormRegOpen:false,ociFormRegFilter:'',ociFormRegVal:'',
|
||
rptLevel:2,rptObp:false,rptRaw:false,rptRedact:false,reportFiles:{},dlExpandedRid:null,dlTenancyFilter:'',
|
||
cisVer:null,cisCheckResult:null,cisUpdating:false,rptHistFilter:'',chatFiles:[],
|
||
tfMsgs:[],tfSid:null,tfModel:'',tfOci:'',tfRegion:'',tfCompartment:'',tfPlan:[],tfCode:'',tfPanel:'',
|
||
tfWs:null,tfWsList:[],tfPlanOut:'',tfApplyOut:'',tfDestroyOut:'',tfStatus:'draft',tfRunning:false,tfConfirmDest:false,tfMdOpen:false,
|
||
tfComps:[],tfCompLoading:false,tfFiles:[],tfBtab:'files',tfEditIdx:-1,tfResources:null,tfResLoading:false};
|
||
const API='/api';
|
||
|
||
async function $api(p,o={}){const h={...(o.headers||{})};if(S.token)h['Authorization']='Bearer '+S.token;
|
||
if(o.body&&!(o.body instanceof FormData)){h['Content-Type']='application/json';o.body=JSON.stringify(o.body)}
|
||
const r=await fetch(API+p,{...o,headers:h});if(r.status===401){S.user=null;S.token=null;localStorage.removeItem('t');R();throw new Error('Unauthorized')}
|
||
const ct=r.headers.get('content-type')||'';
|
||
if(!ct.includes('application/json')){const txt=await r.text();throw new Error(r.ok?'Resposta inesperada do servidor':`HTTP ${r.status}: ${txt.slice(0,120).replace(/<[^>]*>/g,'').trim()||'Erro do servidor'}`)}
|
||
const d=await r.json();if(!r.ok)throw new Error(d.detail||d.message||'Erro');return d;}
|
||
async function doLogin(u,pw,totp){const b={username:u,password:pw};if(totp)b.totp_code=totp;
|
||
const d=await $api('/auth/login',{method:'POST',body:b});if(d.mfa_required)return d;
|
||
S.token=d.token;S.user=d.user;localStorage.setItem('t',d.token);await loadData();R();return d;}
|
||
async function doLogout(){try{await $api('/auth/logout',{method:'POST'})}catch(e){}S.user=null;S.token=null;S.msgs=[];localStorage.removeItem('t');R()}
|
||
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;S.embModels=m.embedding_models||{};S.ociRegions=m.oci_regions||{}}catch(e){}
|
||
try{S.adbCfg=await $api('/adb/configs')}catch(e){S.adbCfg=[]}
|
||
try{S.chatPrompts=await $api('/prompts/chat')}catch(e){S.chatPrompts=[]}
|
||
if(!S.chatModel){const dc=S.genaiCfg.find(g=>g.is_default);if(dc)S.chatModel='cfg:'+dc.id;else if(S.models['openai.gpt-4.1'])S.chatModel='openai.gpt-4.1'}
|
||
if(S.user.role==='admin'){try{S.users=await $api('/users')}catch(e){}}
|
||
try{S.cisVer=await $api('/cis-engine/version')}catch(e){}
|
||
loadHistory('chat');
|
||
}catch(e){console.error(e)}}
|
||
function R(){try{
|
||
// Preserve inputs
|
||
const chiEl=document.getElementById('chi');if(chiEl)S._chiVal=chiEl.value;
|
||
const tfiEl=document.getElementById('tfi');if(tfiEl)S._tfiVal=tfiEl.value;
|
||
const edEl=document.getElementById('tfEdTa');if(edEl)S._tfEdVal=edEl.value;
|
||
// Preserve scroll positions
|
||
const scrollIds=['chm','tfchm','tfBottom','tfTermOut','pg'];
|
||
const scrollMap={};
|
||
scrollIds.forEach(id=>{const el=document.getElementById(id);if(el)scrollMap[id]=el.scrollTop});
|
||
const bcLeft=document.querySelector('.tf-bc-left');if(bcLeft)scrollMap._bcLeft=bcLeft.scrollTop;
|
||
|
||
const appEl=document.getElementById('app');
|
||
if(!S.user){appEl.innerHTML=rLogin();return}
|
||
// Partial update: only update page content + sidebar
|
||
const pgEl=document.getElementById('pg');
|
||
if(pgEl){
|
||
pgEl.innerHTML=rPg();
|
||
const sbEl=document.querySelector('.nav');if(sbEl){const tmp=document.createElement('div');tmp.innerHTML=rSb();const nn=tmp.querySelector('.nav');if(nn)sbEl.innerHTML=nn.innerHTML}
|
||
}else{
|
||
appEl.innerHTML=rApp();
|
||
}
|
||
// Restore inputs
|
||
const chi2=document.getElementById('chi');if(chi2&&S._chiVal){chi2.value=S._chiVal;autoGrow(chi2)}
|
||
const tfi2=document.getElementById('tfi');if(tfi2&&S._tfiVal){tfi2.value=S._tfiVal;autoGrow(tfi2)}
|
||
const ed2=document.getElementById('tfEdTa');if(ed2&&S._tfEdVal)ed2.value=S._tfEdVal;
|
||
// Restore scroll positions
|
||
requestAnimationFrame(()=>{
|
||
Object.entries(scrollMap).forEach(([id,top])=>{if(id==='_bcLeft'){const el=document.querySelector('.tf-bc-left');if(el)el.scrollTop=top}else{const el=document.getElementById(id);if(el)el.scrollTop=top}});
|
||
});
|
||
if(S.editing?.type==='mcp')setTimeout(mcpTypeFields,0)
|
||
}catch(e){console.error('Render error:',e);document.getElementById('app').innerHTML='<div style="padding:2rem;color:red"><h3>Render Error</h3><pre>'+e.message+'</pre></div>'}}
|
||
function switchTab(t){S.tab=t;R();if(t==='chat')loadHistory('chat');if(t==='terraform')loadHistory('terraform');if(t==='audit'&&S.user.role==='admin')loadAudit();if(t==='downloads')refreshDl();if(t==='explorer'&&S.ociCfg.length&&!S.expTree.length){if(!S.expCfg)S.expCfg=S.ociCfg[0].id;expLoadTree();expLoadRegions()}
|
||
const tm={'oci-config':'oci','genai':'genai','adb':'adb','mcp':'mcp'};if(tm[t])setTimeout(()=>refreshCLogs(tm[t]),100)}
|
||
|
||
/* ── Login ── */
|
||
function rLogin(){return`<div class="lp"><div class="lc fi">
|
||
<div style="text-align:center;margin-bottom:1.5rem">${LOGO_R}
|
||
<h2>OCI CIS Agent</h2><div class="st">Oracle Cloud Infrastructure · Security</div></div>
|
||
<div id="le"></div>
|
||
<div id="mfa-s" style="display:none"><div class="al al-i">Código MFA obrigatório</div>
|
||
<div class="ig"><label>Código TOTP</label><input type="text" id="mfa-c" placeholder="000000" maxlength="6" style="text-align:center;font-size:1.1rem;letter-spacing:.3em;font-weight:600"></div>
|
||
<button class="btn bp" style="width:100%;justify-content:center;padding:.65rem" onclick="hMfa()">Verificar</button></div>
|
||
<div id="lf"><div class="ig"><label>Usuário</label><input type="text" id="lu" placeholder="admin" autofocus></div>
|
||
<div class="ig"><label>Senha</label><input type="password" id="lp" placeholder="••••••••" onkeydown="if(event.key==='Enter')hLogin()"></div>
|
||
<button class="btn bp" style="width:100%;justify-content:center;padding:.65rem" onclick="hLogin()">Entrar</button>
|
||
<div style="text-align:center;margin-top:.85rem;font-size:.66rem;color:var(--t4)">Padrão: admin / admin123</div></div></div></div>`}
|
||
|
||
/* ── App Shell ── */
|
||
function rApp(){return`<div class="app">${rSb()}<div class="mc">${rTb()}<div class="pc" id="pg">${rPg()}</div></div></div>`}
|
||
|
||
const TF_IC='<svg viewBox="0 0 16 16" width="14" height="14" style="vertical-align:-2px;margin-right:2px"><path d="M5.6 1v4.2l3.6 2.1V3.1L5.6 1zm4.4 2.1v4.2l3.6-2.1V1L10 3.1zM1.4 3.5v4.2l3.6 2.1V5.6L1.4 3.5zM5.6 8.1v4.2L9.2 14.4V10.2L5.6 8.1z" fill="#7b42bc"/></svg>';
|
||
function rSb(){
|
||
const tabs=[['chat','💬','Chat Agent'],['terraform',TF_IC,'Terraform'],['explorer','🔍','OCI Explorer'],['report','📊','Reports'],['downloads','📁','Downloads']];
|
||
const ctabs=[['oci-config','☁️','Credenciais OCI'],['genai','🧠','GenAI Config'],['mcp','🔌','MCP Servers'],['adb','🗄️','ADB Vector'],['embeddings','🧬','Embeddings']];
|
||
const atabs=[['users','👥','Usuários'],['mfa','🔐','MFA'],['audit','📋','Audit Log']];
|
||
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>
|
||
${tabs.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 class="nl">Configuração</div>
|
||
${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?.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','terraform':TF_IC+' Terraform Agent','explorer':'🔍 OCI Account Explorer','report':'📊 Compliance Reports','downloads':'📁 Downloads','oci-config':'☁️ Credenciais OCI','genai':'🧠 OCI Generative AI','mcp':'🔌 MCP Servers','adb':'🗄️ Autonomous DB Vector','embeddings':'🧬 Embeddings','users':'👥 Gerenciar Usuários','mfa':'🔐 Autenticação MFA','audit':'📋 Audit Log'};
|
||
return`<div class="tb"><div class="tb-t">${t[S.tab]||''}</div><div class="tb-a"><span class="tag">v${V}</span></div></div>`}
|
||
|
||
function rPg(){switch(S.tab){case'chat':return rChat();case'terraform':return rTerraform();case'explorer':return rExplorer();case'report':return rReport();case'downloads':return rDl();case'oci-config':return rOci();case'genai':return rGenAI();case'mcp':return rMCP();case'adb':return rADB();case'embeddings':return rEmbeddings();case'users':return rUsers();case'mfa':return rMfa();case'audit':return rAudit();default:return''}}
|
||
|
||
/* ── Chat ── */
|
||
function rChat(){
|
||
const ms=S.msgs.length===0?'<div class="emp"><div class="eic">🤖</div><p>Inicie uma conversa com o agente.</p><p style="font-size:.74rem;margin-top:.35rem">Selecione um modelo para começar.</p></div>'
|
||
:S.msgs.map(m=>`<div class="cm cm-${m.r}"><div class="cb">${fm(m.c)}</div>${m.t?`<div class="cm-ts">${m.t}</div>`:''}</div>`).join('');
|
||
const provs={};for(const[mid,info]of Object.entries(S.models)){const p=info.provider||'other';if(!provs[p])provs[p]=[];provs[p].push({id:mid,name:info.name})}
|
||
const provOrder=['openai','google','meta','xai'];
|
||
let ddItems='';
|
||
if(S.genaiCfg.length){ddItems+=`<div class="grp">Configs Salvas</div>`;S.genaiCfg.forEach(g=>{const mi=S.models[g.model_id];ddItems+=`<div class="itm${S.chatModel==='cfg:'+g.id?' sel':''}" onclick="pickModel('cfg:${g.id}')">${g.name||mi?.name||g.model_id} (${g.genai_region})</div>`})}
|
||
provOrder.filter(p=>provs[p]).forEach(p=>{ddItems+=`<div class="grp">${p.charAt(0).toUpperCase()+p.slice(1)}</div>`;provs[p].forEach(m=>{ddItems+=`<div class="itm${S.chatModel===m.id?' sel':''}" onclick="pickModel('${m.id}')">${m.name}</div>`})});
|
||
let curLabel='Selecione um modelo...';
|
||
if(S.chatModel.startsWith('cfg:')){const g=S.genaiCfg.find(x=>x.id===S.chatModel.slice(4));const mi=g?S.models[g.model_id]:null;curLabel=g?(g.name||mi?.name||g.model_id)+' ('+g.genai_region+')':'Config...'}
|
||
else if(S.chatModel){const mi=S.models[S.chatModel];curLabel=mi?mi.name:S.chatModel}
|
||
const isDirect=S.chatModel&&!S.chatModel.startsWith('cfg:');
|
||
const ociSel=isDirect?`<select id="coci" onchange="chatOciChanged()" style="max-width:200px"><option value="">Credencial OCI...</option>${S.ociCfg.map(c=>`<option value="${c.id}" ${S.chatOci===c.id?'selected':''}>${c.tenancy_name} (${c.region})</option>`).join('')}</select>`:'';
|
||
const ragBadge=S.adbCfg.some(c=>c.genai_config_id&&c.is_active)?'<span class="tag" style="background:var(--gnl);color:var(--gn);font-size:.62rem">RAG</span>':'';
|
||
const toolCount=S.mcpSvr.filter(m=>m.is_active&&Array.isArray(m.tools)&&m.tools.length).reduce((a,m)=>a+m.tools.length,0);
|
||
let sideContent='';
|
||
if(S.chatPanel==='config')sideContent=rChatConfig(isDirect,toolCount);
|
||
else if(S.chatPanel==='logs')sideContent=rChatLogs();
|
||
return`<div class="ch-c">
|
||
<div class="ch-hp${S.chatHistOpen?' open':''}"><div class="ch-hp-inner">
|
||
<div class="ch-hp-hdr"><span>🕘 Histórico</span><div class="ch-hp-new" onclick="newChat()">+ Novo Chat</div></div>
|
||
<div class="ch-hp-list">${rHistList('chat',S.chatHistory,S.sid)}</div>
|
||
</div></div>
|
||
<div class="ch-side${S.chatPanel?' open':''}"><div class="ch-side-inner">${sideContent}</div></div>
|
||
<div class="ch-main">
|
||
<div class="ch-t">
|
||
<div class="ch-icon-btn${S.chatHistOpen?' active':''}" onclick="S.chatHistOpen=!S.chatHistOpen;if(S.chatHistOpen)loadHistory('chat');R()" title="Histórico">☰</div>
|
||
<label>🧠 Modelo:</label><div class="mdrop"><div class="mdrop-btn" onclick="toggleModelDrop()">${curLabel} <span class="arr">▼</span></div><div class="mdrop-dd" id="mdd"><input type="text" id="mds" placeholder="Buscar modelo..." oninput="filterModels(this.value)"><div class="mdrop-list" id="mdl">${ddItems}</div></div></div>
|
||
${ociSel}${ragBadge}<div style="flex:1"></div>
|
||
<div class="ch-icon-btn${S.chatPanel==='config'?' active':''}" onclick="togglePanel('config')" title="Configurações">⚙️</div>
|
||
<div class="ch-icon-btn${S.chatPanel==='logs'?' active':''}" onclick="togglePanel('logs')" title="Chat Logs">📋</div>
|
||
<div class="ch-icon-btn" onclick="newChat()" title="Nova conversa">✨</div></div>
|
||
<div class="ch-m" id="chm">${ms}</div>
|
||
${S.chatFiles.length?`<div style="display:flex;gap:.4rem;flex-wrap:wrap;padding:.4rem .85rem;background:var(--bg1);border-top:1px solid var(--bd)">${S.chatFiles.map((f,i)=>f.type==='image'?`<div style="position:relative;width:48px;height:48px"><img src="${f.preview}" style="width:48px;height:48px;object-fit:cover;border-radius:6px;border:1px solid var(--bd)"><span onclick="rmChatFile(${i})" style="position:absolute;top:-4px;right:-4px;background:var(--err);color:#fff;width:16px;height:16px;border-radius:50%;font-size:.6rem;display:flex;align-items:center;justify-content:center;cursor:pointer">×</span></div>`:`<div style="position:relative;display:flex;align-items:center;gap:.25rem;background:var(--bg2);border:1px solid var(--bd);border-radius:6px;padding:.25rem .5rem;font-size:.68rem"><span>📄 ${f.name}</span><span onclick="rmChatFile(${i})" style="color:var(--err);cursor:pointer;font-weight:700">×</span></div>`).join('')}</div>`:''}
|
||
<div class="ch-i"><input type="file" id="chf" multiple accept="image/*,.pdf,.txt,.csv,.json,.log,.xml,.yaml,.yml,.md,.py,.js,.html,.css,.sql" style="display:none" onchange="addChatFiles(this)">
|
||
<button class="btn bs" onclick="document.getElementById('chf').click()" title="Anexar arquivo" style="font-size:1rem;padding:.3rem .5rem">📎</button>
|
||
<textarea id="chi" placeholder="Digite sua mensagem... (Shift+Enter para nova linha)" rows="1" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sChat()}" oninput="autoGrow(this)" onpaste="setTimeout(()=>autoGrow(this),0)" style="resize:none;overflow-y:auto"></textarea>
|
||
<button class="btn bp" onclick="sChat()">Enviar →</button></div></div></div>`}
|
||
function rChatConfig(isDirect,toolCount){
|
||
const pp=S.chatParams;
|
||
return`<div class="ch-side-title">⚙️ Configurações</div>
|
||
<div class="ch-side-section"><div class="g2">
|
||
<div><label>Temperature</label><input type="number" value="${pp.temperature}" step="0.1" min="0" max="2" onchange="S.chatParams.temperature=+this.value"></div>
|
||
<div><label>Max Tokens</label><input type="number" value="${pp.max_tokens}" min="1" max="128000" onchange="S.chatParams.max_tokens=+this.value"></div>
|
||
<div><label>Top P</label><input type="number" value="${pp.top_p}" step="0.05" min="0" max="1" onchange="S.chatParams.top_p=+this.value"></div>
|
||
<div><label>Top K</label><input type="number" value="${pp.top_k}" min="-1" max="500" onchange="S.chatParams.top_k=+this.value"></div>
|
||
<div><label>Freq Penalty</label><input type="number" value="${pp.frequency_penalty}" step="0.1" min="0" max="2" onchange="S.chatParams.frequency_penalty=+this.value"></div>
|
||
<div><label>Pres Penalty</label><input type="number" value="${pp.presence_penalty}" step="0.1" min="0" max="2" onchange="S.chatParams.presence_penalty=+this.value"></div>
|
||
</div></div>
|
||
<div class="ch-side-section" style="border-top:1px solid var(--bd);padding-top:.65rem">
|
||
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.76rem"><input type="checkbox" ${S.chatUseTools?'checked':''} onchange="S.chatUseTools=this.checked"> 🔌 MCP Tools</label>
|
||
${toolCount?`<span style="font-size:.66rem;color:var(--t4);margin-top:.2rem;display:block">${toolCount} tools disponíveis</span>`:''}
|
||
</div>
|
||
${isDirect?`<div class="ch-side-section" style="border-top:1px solid var(--bd);padding-top:.65rem">
|
||
<label>Credencial OCI</label>
|
||
<select onchange="S.chatOci=this.value;const c=S.ociCfg.find(x=>x.id===this.value);if(c){S.chatRegion=c.region;S.chatCompartment=c.compartment_id||''}" style="margin-top:3px">
|
||
<option value="">Selecionar...</option>${S.ociCfg.map(c=>`<option value="${c.id}" ${S.chatOci===c.id?'selected':''}>${c.tenancy_name} (${c.region})</option>`).join('')}</select></div>`:''}`}
|
||
function rChatLogs(){
|
||
return`<div class="ch-side-title">📋 Chat Logs</div>
|
||
<div style="display:flex;gap:.4rem;margin-bottom:.5rem">
|
||
<select id="cl-sev-chat" onchange="loadChatLogs()" style="flex:1;font-size:.7rem;padding:.25rem .4rem;border-radius:6px;border:1px solid var(--bd)">
|
||
<option value="">Todos</option><option value="error">Erros</option><option value="warning">Avisos</option><option value="info">Info</option></select>
|
||
<button class="btn bs bsm" onclick="loadChatLogs()" style="font-size:.68rem">↻</button></div>
|
||
<div id="chat-logs-body"><div style="padding:.5rem;font-size:.74rem;color:var(--t4)">Carregando...</div></div>`}
|
||
function toggleModelDrop(){const dd=document.getElementById('mdd');dd.classList.toggle('open');if(dd.classList.contains('open')){const inp=document.getElementById('mds');inp.value='';inp.focus();filterModels('')}}
|
||
function pickModel(v){S.chatModel=v;document.getElementById('mdd').classList.remove('open');
|
||
if(v&&!v.startsWith('cfg:')&&!S.chatOci&&S.ociCfg.length){S.chatOci=S.ociCfg[0].id;const c=S.ociCfg[0];S.chatRegion=c.region;S.chatCompartment=c.compartment_id||''}
|
||
R()}
|
||
function filterModels(q){const list=document.getElementById('mdl');const items=list.querySelectorAll('.itm,.grp');const ql=q.toLowerCase();
|
||
let lastGrp=null,grpHasVisible=false;
|
||
items.forEach(el=>{if(el.classList.contains('grp')){if(lastGrp)lastGrp.style.display=grpHasVisible?'':'none';lastGrp=el;grpHasVisible=false}
|
||
else{const show=!q||el.textContent.toLowerCase().includes(ql);el.style.display=show?'':'none';if(show)grpHasVisible=true}});
|
||
if(lastGrp)lastGrp.style.display=grpHasVisible?'':'none'}
|
||
document.addEventListener('click',e=>{const dd=document.getElementById('mdd');if(dd&&dd.classList.contains('open')&&!e.target.closest('.mdrop'))dd.classList.remove('open');
|
||
if(!e.target.closest('.dd')){let changed=false;if(S.rptOciOpen){S.rptOciOpen=false;changed=true}if(S.rptRselOpen){S.rptRselOpen=false;changed=true}if(S.ociFormRegOpen){S.ociFormRegOpen=false;changed=true}if(S.rptRegionsOpen){S.rptRegionsOpen=false;changed=true}if(S.expRegDdOpen){S.expRegDdOpen=false;changed=true}if(changed)R()}})
|
||
function chatOciChanged(){S.chatOci=document.getElementById('coci').value;const c=S.ociCfg.find(x=>x.id===S.chatOci);if(c){S.chatRegion=c.region;S.chatCompartment=c.compartment_id||''}}
|
||
function togglePanel(p){S.chatPanel=S.chatPanel===p?'':p;R();if(S.chatPanel==='logs')loadChatLogs()}
|
||
function editPrompt(id){S.editingPrompt=id;R()}
|
||
async function savePrompt(){const name=document.getElementById('spname').value;const content=document.getElementById('gsysp').value;
|
||
if(!name||!content)return sm('spm','Preencha nome e conteúdo do prompt.','e');
|
||
try{if(S.editingPrompt){await $api('/prompts/'+S.editingPrompt,{method:'PUT',body:{name,content}})}
|
||
else{await $api('/prompts',{method:'POST',body:{agent:'chat',name,content,is_active:!S.chatPrompts.length}})}
|
||
S.editingPrompt=null;S.chatPrompts=await $api('/prompts/chat');sm('spm','✅ Prompt salvo!','s');R()}catch(e){sm('spm',e.message,'e')}}
|
||
async function activatePrompt(id){try{await $api('/prompts/'+id,{method:'PUT',body:{is_active:true}});S.chatPrompts=await $api('/prompts/chat');R()}catch(e){sm('spm',e.message,'e')}}
|
||
async function delPrompt(id){if(!confirm('Excluir prompt?'))return;try{await $api('/prompts/'+id,{method:'DELETE'});S.editingPrompt=null;S.chatPrompts=await $api('/prompts/chat');R()}catch(e){sm('spm',e.message,'e')}}
|
||
function fm(t){return t.replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/`(.*?)`/g,'<code>$1</code>').replace(/\n/g,'<br>')}
|
||
function autoGrow(el){el.style.height='auto';el.style.height=Math.min(el.scrollHeight,200)+'px'}
|
||
function addChatFiles(input){for(const f of input.files){if(S.chatFiles.length>=5){alert('Máximo 5 arquivos');break}
|
||
const entry={file:f,name:f.name,type:f.type.startsWith('image/')?'image':'file',preview:null};
|
||
if(entry.type==='image'){const r=new FileReader();r.onload=e=>{entry.preview=e.target.result;R()};r.readAsDataURL(f)}
|
||
S.chatFiles.push(entry)}input.value='';R()}
|
||
function rmChatFile(i){S.chatFiles.splice(i,1);R()}
|
||
function tstamp(){const d=new Date();return d.toLocaleTimeString('pt-BR',{hour:'2-digit',minute:'2-digit'})}
|
||
async function sChat(){const el=document.getElementById('chi');const m=el.value.trim();el.style.height='auto';S._chiVal='';
|
||
if(!m&&!S.chatFiles.length)return;
|
||
if(!S.chatModel){S.msgs.push({r:'assistant',c:'⚠️ Selecione um modelo antes de enviar uma mensagem.'});R();return}
|
||
el.value='';
|
||
const hasFiles=S.chatFiles.length>0;
|
||
const fileNames=S.chatFiles.map(f=>f.name);
|
||
let userDisplay=m||'';
|
||
if(hasFiles)userDisplay+=(userDisplay?'\n':'')+'📎 '+fileNames.join(', ');
|
||
S.msgs.push({r:'user',c:userDisplay,t:tstamp()});R();scCh();
|
||
const btns=document.querySelectorAll('.ch-i .btn');
|
||
el.disabled=true;btns.forEach(b=>{b.disabled=true});
|
||
const sendBtn=btns[btns.length-1];if(sendBtn){sendBtn.dataset.origText=sendBtn.textContent;sendBtn.innerHTML='<span class="spinner" style="width:14px;height:14px;border-width:2px;margin-right:6px"></span>Pensando...'}
|
||
S.msgs.push({r:'assistant',c:'⏳ _Pensando..._',thinking:true});R();scCh();
|
||
try{let d;
|
||
if(hasFiles){
|
||
const fd=new FormData();
|
||
fd.append('message',m||'Analise os arquivos anexados.');
|
||
if(S.sid)fd.append('session_id',S.sid);
|
||
fd.append('use_tools',S.chatUseTools);
|
||
if(S.chatModel.startsWith('cfg:')){fd.append('genai_config_id',S.chatModel.slice(4))}
|
||
else if(S.chatModel){
|
||
if(!S.chatOci){S.msgs.pop();S.msgs.push({r:'assistant',c:'⚠️ Selecione uma credencial OCI.'});R();el.disabled=false;btns.forEach(b=>{b.disabled=false});if(sendBtn)sendBtn.textContent=sendBtn.dataset.origText;return}
|
||
fd.append('model_id',S.chatModel);fd.append('oci_config_id',S.chatOci);fd.append('genai_region',S.chatRegion);fd.append('compartment_id',S.chatCompartment)}
|
||
fd.append('temperature',S.chatParams.temperature);fd.append('max_tokens',S.chatParams.max_tokens);
|
||
fd.append('top_p',S.chatParams.top_p);fd.append('top_k',S.chatParams.top_k);
|
||
fd.append('frequency_penalty',S.chatParams.frequency_penalty);fd.append('presence_penalty',S.chatParams.presence_penalty);
|
||
S.chatFiles.forEach(f=>fd.append('files',f.file));
|
||
d=await $api('/chat/upload',{method:'POST',body:fd});S.chatFiles=[]
|
||
}else{
|
||
const body={message:m,session_id:S.sid,use_tools:S.chatUseTools};
|
||
if(S.chatModel.startsWith('cfg:')){
|
||
body.genai_config_id=S.chatModel.slice(4);
|
||
body.temperature=S.chatParams.temperature;body.max_tokens=S.chatParams.max_tokens;body.top_p=S.chatParams.top_p;
|
||
body.top_k=S.chatParams.top_k;body.frequency_penalty=S.chatParams.frequency_penalty;body.presence_penalty=S.chatParams.presence_penalty}
|
||
else if(S.chatModel){
|
||
if(!S.chatOci){S.msgs.pop();S.msgs.push({r:'assistant',c:'⚠️ Selecione uma credencial OCI.'});R();el.disabled=false;btns.forEach(b=>{b.disabled=false});if(sendBtn)sendBtn.textContent=sendBtn.dataset.origText;return}
|
||
body.model_id=S.chatModel;body.oci_config_id=S.chatOci;body.genai_region=S.chatRegion;body.compartment_id=S.chatCompartment;
|
||
body.temperature=S.chatParams.temperature;body.max_tokens=S.chatParams.max_tokens;body.top_p=S.chatParams.top_p;
|
||
body.top_k=S.chatParams.top_k;body.frequency_penalty=S.chatParams.frequency_penalty;body.presence_penalty=S.chatParams.presence_penalty}
|
||
d=await $api('/chat',{method:'POST',body})}
|
||
S.sid=d.session_id;
|
||
if(S.chatHistOpen)loadHistory('chat');
|
||
if(d.status==='processing'&&d.message_id){
|
||
await pollChatResult(d.message_id)
|
||
}else{
|
||
S.msgs=S.msgs.filter(x=>!x.thinking);
|
||
let resp=d.response;
|
||
if(d.tools_used&&d.tools_used.length){resp+='\n\n🔧 **Tools utilizadas:** '+d.tools_used.map(t=>t.name).join(', ')}
|
||
S.msgs.push({r:'assistant',c:resp,t:tstamp()});R();scCh()}}
|
||
catch(e){S.msgs=S.msgs.filter(x=>!x.thinking);S.msgs.push({r:'assistant',c:'❌ Erro: '+e.message,t:tstamp()});R()}
|
||
finally{el.disabled=false;el.focus();btns.forEach(b=>{b.disabled=false});if(sendBtn)sendBtn.textContent=sendBtn.dataset.origText||'Enviar →'}}
|
||
async function pollChatResult(mid){
|
||
for(let i=0;i<1200;i++){
|
||
await new Promise(r=>setTimeout(r,i<10?1000:3000));
|
||
try{const r=await $api('/chat/'+mid+'/status');
|
||
if(r.status==='done'){S.msgs=S.msgs.filter(x=>!x.thinking);S.msgs.push({r:'assistant',c:r.content,t:tstamp()});R();scCh();return}
|
||
if(r.status==='failed'){S.msgs=S.msgs.filter(x=>!x.thinking);S.msgs.push({r:'assistant',c:'❌ '+r.content,t:tstamp()});R();scCh();return}
|
||
}catch(e){}}
|
||
S.msgs=S.msgs.filter(x=>!x.thinking);S.msgs.push({r:'assistant',c:'⏰ Timeout: a resposta está demorando muito. Tente novamente.',t:tstamp()});R()}
|
||
function scCh(){setTimeout(()=>{const e=document.getElementById('chm');if(e)e.scrollTop=e.scrollHeight},50)}
|
||
async function clrChat(){if(S.sid)try{await $api('/chat/'+S.sid,{method:'DELETE'})}catch(e){}S.msgs=[];S.sid=null;R()}
|
||
function newChat(){S.msgs=[];S.sid=null;S.chatFiles=[];R()}
|
||
function newTfChat(){S.tfMsgs=[];S.tfSid=null;S.tfPlan=[];S.tfCode='';S.tfFiles=[];S.tfWs=null;S.tfPlanOut='';S.tfApplyOut='';S.tfDestroyOut='';S.tfStatus='draft';S.tfRunning=false;S.tfConfirmDest=false;S.tfEditIdx=-1;R()}
|
||
|
||
async function loadHistory(type){
|
||
try{const h=await $api('/chat/sessions?agent_type='+type+'&limit=50');
|
||
if(type==='chat')S.chatHistory=h;else S.tfHistory=h}
|
||
catch(e){if(type==='chat')S.chatHistory=[];else S.tfHistory=[]}
|
||
R();
|
||
}
|
||
async function loadSession(sid,type){
|
||
try{const d=await $api('/chat/sessions/'+sid+'/messages');
|
||
if(type==='chat'){
|
||
S.sid=sid;S.msgs=d.messages.map(m=>({r:m.role,c:m.content,t:m.created_at?.slice(11,16)||''}));R();scCh();
|
||
}else{
|
||
S.tfSid=sid;S.tfMsgs=d.messages.map(m=>({r:m.role,c:m.content,t:m.created_at?.slice(11,16)||''}));
|
||
S.tfFiles=[];S.tfCode='';S.tfPlan=[];
|
||
const last=[...d.messages].reverse().find(m=>m.role==='assistant'&&m.content&&m.content.includes('```'));
|
||
if(last)fmTf(last.content);
|
||
try{const wsList=await $api('/terraform/workspaces');const ws=wsList.find(w=>w.session_id===sid);
|
||
if(ws){S.tfWs=ws.id;S.tfStatus=ws.status}else{S.tfWs=null;S.tfStatus='draft'}
|
||
}catch(e){S.tfWs=null;S.tfStatus='draft'}
|
||
R();
|
||
}
|
||
}catch(e){alert('Erro ao carregar sessão: '+e.message)}
|
||
}
|
||
async function delSession(sid,type){
|
||
if(!confirm('Excluir esta conversa?'))return;
|
||
try{await $api('/chat/'+sid,{method:'DELETE'})}catch(e){}
|
||
if(type==='chat'){if(S.sid===sid)newChat();S.chatHistory=S.chatHistory.filter(s=>s.id!==sid)}
|
||
else{if(S.tfSid===sid)newTfChat();S.tfHistory=S.tfHistory.filter(s=>s.id!==sid)}
|
||
R();
|
||
}
|
||
async function renameSession(sid,type){
|
||
const cur=(type==='chat'?S.chatHistory:S.tfHistory).find(s=>s.id===sid);
|
||
const t=prompt('Renomear conversa:',cur?.title||'');
|
||
if(t===null)return;
|
||
try{await $api('/chat/sessions/'+sid+'/title',{method:'PUT',body:{title:t}});
|
||
if(cur)cur.title=t;R()}catch(e){}
|
||
}
|
||
function _histDateGroup(dateStr){
|
||
if(!dateStr)return'Outras';
|
||
const d=new Date(dateStr+'Z'),now=new Date(),diff=(now-d)/86400000;
|
||
if(diff<1)return'Hoje';if(diff<2)return'Ontem';if(diff<7)return'Últimos 7 dias';if(diff<30)return'Últimos 30 dias';return'Anteriores';
|
||
}
|
||
|
||
function rHistList(type,hist,curSid){
|
||
if(!hist.length)return'<div style="text-align:center;padding:1rem .5rem;font-size:.7rem;color:var(--t4)">Nenhuma conversa</div>';
|
||
let html='';let lastGroup='';
|
||
hist.forEach(s=>{
|
||
const g=_histDateGroup(s.updated_at||s.created_at);
|
||
if(g!==lastGroup){lastGroup=g;html+=`<div class="ch-hp-grp">${g}</div>`}
|
||
const active=s.id===curSid?' active':'';
|
||
const title=s.title||'Nova conversa';
|
||
const ts=s.updated_at?new Date(s.updated_at+'Z').toLocaleTimeString('pt-BR',{hour:'2-digit',minute:'2-digit'}):'';
|
||
html+=`<div class="ch-hp-item${active}" onclick="loadSession('${s.id}','${type}')">
|
||
<span class="ch-hp-ttl" title="${title.replace(/"/g,'"')}">${escHtml(title)}</span>
|
||
${ts?`<span class="ch-hp-time">${ts}</span>`:''}
|
||
<span class="ch-hp-del" onclick="event.stopPropagation();delSession('${s.id}','${type}')" title="Excluir">
|
||
<svg viewBox="0 0 16 16" width="11" height="11" fill="currentColor"><path d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"/><path fill-rule="evenodd" d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118z"/></svg>
|
||
</span>
|
||
</div>`;
|
||
});
|
||
return html;
|
||
}
|
||
|
||
async function loadChatLogs(){
|
||
const sev=document.getElementById('cl-sev-chat')?.value||'';
|
||
const qs='limit=50'+(sev?'&severity='+sev:'')+(S.sid?'&session_id='+S.sid:'');
|
||
const el=document.getElementById('chat-logs-body');if(!el)return;
|
||
try{const logs=await $api('/chat-logs?'+qs);
|
||
if(!logs.length){el.innerHTML='<div class="emp" style="padding:.5rem"><p>Nenhum log registrado.</p></div>';return}
|
||
el.innerHTML='<table style="font-size:.74rem"><thead><tr><th>Data</th><th>Fonte</th><th>Ação</th><th>Status</th><th>Mensagem</th></tr></thead><tbody>'+
|
||
logs.map(l=>{const sc=l.severity==='error'?'err':l.severity==='warning'?'w':'ok';
|
||
return'<tr><td style="font-size:.62rem;color:var(--t4);white-space:nowrap">'+l.created_at+'</td>'+
|
||
'<td><span class="tag">'+l.source+'</span></td>'+
|
||
'<td style="font-size:.72rem">'+l.action+'</td>'+
|
||
'<td><span style="color:var(--'+sc+');font-weight:600;font-size:.7rem">'+l.severity+'</span></td>'+
|
||
'<td style="font-size:.72rem;max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="'+(l.message||'').replace(/"/g,'"')+'">'+(l.message?.slice(0,150)||'—')+'</td></tr>'}).join('')+
|
||
'</tbody></table>'}
|
||
catch(e){el.innerHTML='<div class="al al-e">'+e.message+'</div>'}}
|
||
|
||
/* ── Terraform Agent ── */
|
||
const TF_ICON=`<svg viewBox="0 0 16 16" width="16" height="16"><path d="M5.6 1v4.2l3.6 2.1V3.1L5.6 1zm4.4 2.1v4.2l3.6-2.1V1L10 3.1zM1.4 3.5v4.2l3.6 2.1V5.6L1.4 3.5zM5.6 8.1v4.2L9.2 14.4V10.2L5.6 8.1z" fill="#7b42bc"/></svg>`;
|
||
function escHtml(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||
|
||
function fmTf(t){
|
||
// Extract ALL hcl blocks, parse // filename: comments, build separate files
|
||
const codeBlocks=[];
|
||
let r=t.replace(/```(?:hcl|terraform)\s*\n([\s\S]*?)```/g,(m,code)=>{
|
||
const trimmed=code.trim();
|
||
// Check for // filename: comment on first line
|
||
const fnMatch=trimmed.match(/^\/\/\s*filename:\s*(\S+)/);
|
||
const fileName=fnMatch?fnMatch[1]:'main.tf';
|
||
// Content without the filename comment line
|
||
const content=fnMatch?trimmed.replace(/^\/\/\s*filename:\s*\S+\s*\n?/,'').trim():trimmed;
|
||
codeBlocks.push({name:fileName,content:content});
|
||
S.tfCode=content;
|
||
return '<div class="tf-code-block"><div class="tf-code-header"><span>'+TF_ICON+' '+escHtml(fileName)+'</span>'+
|
||
'<button class="btn" onclick="tfCopyBlock('+( codeBlocks.length-1)+')">Copy</button></div>'+
|
||
'<pre class="tf-pre"><code>'+escHtml(content)+'</code></pre></div>';
|
||
});
|
||
r=r.replace(/```plan\s*\n([\s\S]*?)```/g,(m,plan)=>{
|
||
const lines=plan.trim().split('\n');
|
||
S.tfPlan=lines.map(l=>{const m2=l.match(/^[+~]\s+(\S+)\s+\((.+)\)$/);return m2?{type:m2[1],desc:m2[2]}:{type:l.replace(/^[+~]\s*/,'').trim(),desc:''}}).filter(x=>x.type);
|
||
return '<div class="tf-plan-block"><div class="tf-plan-header">'+TF_ICON+' Resource Plan — '+S.tfPlan.length+' resource(s)</div><div class="tf-plan-list">'+
|
||
S.tfPlan.map(x=>'<div class="tf-plan-item"><span class="tf-plan-add">+</span><span class="tf-plan-type">'+escHtml(x.type)+'</span><span class="tf-plan-desc">'+escHtml(x.desc)+'</span></div>').join('')+'</div></div>';
|
||
});
|
||
// Build files list — merge blocks with same filename, keep separate files
|
||
if(codeBlocks.length){
|
||
const fileMap={};
|
||
codeBlocks.forEach(b=>{
|
||
if(fileMap[b.name])fileMap[b.name]+='\n\n'+b.content;
|
||
else fileMap[b.name]=b.content;
|
||
});
|
||
S.tfFiles=Object.entries(fileMap).map(([name,content])=>({name,content,size:content.length,type:'hcl'}));
|
||
// Set tfCode to combined content for backward compat
|
||
S.tfCode=S.tfFiles.map(f=>'// filename: '+f.name+'\n'+f.content).join('\n\n');
|
||
}
|
||
r=r.replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/`(.*?)`/g,'<code>$1</code>').replace(/\n/g,'<br>');
|
||
return r;
|
||
}
|
||
function tfCopyBlock(i){const b=S.tfFiles[i];if(b)navigator.clipboard.writeText(b.content).then(()=>alert('Copiado!'))}
|
||
|
||
function rTerraform(){
|
||
const ms=S.tfMsgs.length===0
|
||
?`<div class="tf-empty">${TF_ICON}<p>Descreva a infraestrutura OCI desejada</p><p style="font-size:.68rem;opacity:.6">Ex: "Crie uma VCN com subnets pública e privada, internet gateway e um compute instance"</p></div>`
|
||
:S.tfMsgs.map(m=>`<div class="cm cm-${m.r}"><div class="cb">${m.r==='assistant'?fmTf(m.c):fm(m.c)}</div>${m.t?`<div class="cm-ts">${m.t}</div>`:''}</div>`).join('');
|
||
|
||
// Model dropdown
|
||
const provs={};for(const[mid,info]of Object.entries(S.models)){const p=info.provider||'other';if(!provs[p])provs[p]=[];provs[p].push({id:mid,name:info.name})}
|
||
const provOrder=['openai','google','meta','xai'];
|
||
let ddItems='';
|
||
if(S.genaiCfg.length){ddItems+='<div class="grp">Configs Salvas</div>';S.genaiCfg.forEach(g=>{const mi=S.models[g.model_id];ddItems+=`<div class="itm${S.tfModel==='cfg:'+g.id?' sel':''}" onclick="tfPickModel('cfg:${g.id}')">${g.name||mi?.name||g.model_id}</div>`})}
|
||
provOrder.filter(p=>provs[p]).forEach(p=>{ddItems+=`<div class="grp">${p[0].toUpperCase()+p.slice(1)}</div>`;provs[p].forEach(m=>{ddItems+=`<div class="itm${S.tfModel===m.id?' sel':''}" onclick="tfPickModel('${m.id}')">${m.name}</div>`})});
|
||
let curLabel='Selecione modelo...';
|
||
if(S.tfModel.startsWith('cfg:')){const g=S.genaiCfg.find(x=>x.id===S.tfModel.slice(4));curLabel=g?(g.name||g.model_id):'Config...'}
|
||
else if(S.tfModel){const mi=S.models[S.tfModel];curLabel=mi?mi.name:S.tfModel}
|
||
|
||
const isDirect=S.tfModel&&!S.tfModel.startsWith('cfg:');
|
||
const ociSel=isDirect?`<select id="tfoci" onchange="S.tfOci=this.value;const c=S.ociCfg.find(x=>x.id===this.value);if(c){S.tfRegion=c.region;S.tfCompartment=c.compartment_id||''}tfLoadComps()" style="max-width:180px"><option value="">OCI Config...</option>${S.ociCfg.map(c=>`<option value="${c.id}"${S.tfOci===c.id?' selected':''}>${c.tenancy_name} (${c.region})</option>`).join('')}</select>`:'';
|
||
const compSel=(S.tfComps.length||S.tfCompLoading||S.tfOci)?`<select onchange="S.tfCompartment=this.value;tfLoadResources()" style="max-width:220px">${S.tfCompLoading?'<option>Carregando...</option>':S.tfComps.length?'<option value="">Compartment...</option>'+tfFlatComps(S.tfComps,0).map(c=>`<option value="${c.id}"${S.tfCompartment===c.id?' selected':''}>${c.name}</option>`).join(''):'<option value="">Selecione Compartment...</option>'}</select>`:'';
|
||
|
||
// Status badge
|
||
const stBadge=_tfBadge(S.tfStatus);
|
||
|
||
return `<div class="tf-wrap">
|
||
<div style="display:flex;flex:1;min-height:0;overflow:hidden">
|
||
<div class="ch-hp${S.tfHistOpen?' open':''}"><div class="ch-hp-inner">
|
||
<div class="ch-hp-hdr"><span>🕘 Histórico</span><div class="ch-hp-new" onclick="newTfChat()">+ Novo Chat</div></div>
|
||
<div class="ch-hp-list">${rHistList('terraform',S.tfHistory,S.tfSid)}</div>
|
||
</div></div>
|
||
<div class="tf-chat">
|
||
<div class="tf-toolbar">
|
||
<div class="ch-icon-btn${S.tfHistOpen?' active':''}" onclick="S.tfHistOpen=!S.tfHistOpen;if(S.tfHistOpen)loadHistory('terraform');R()" title="Histórico" style="width:24px;height:24px;font-size:.85rem">☰</div>
|
||
${TF_ICON}
|
||
<div class="mdrop"><div class="mdrop-btn" onclick="tfToggleModelDrop()">${curLabel} <span class="arr">▼</span></div>
|
||
<div class="mdrop-dd" id="tfmdd"><input type="text" id="tfmds" placeholder="Buscar..." oninput="tfFilterModels(this.value)"><div class="mdrop-list" id="tfmdl">${ddItems}</div></div>
|
||
</div>
|
||
${ociSel}${compSel}
|
||
${stBadge}
|
||
<div style="flex:1"></div>
|
||
<div class="tf-file-btn" onclick="newTfChat()" title="Nova conversa" style="width:24px;height:24px"><svg viewBox="0 0 16 16" width="12" height="12" fill="var(--t3)"><path d="M8 2v12M2 8h12" stroke="var(--t3)" stroke-width="2" stroke-linecap="round"/></svg></div>
|
||
</div>
|
||
<div class="tf-msgs" id="tfchm">${ms}</div>
|
||
<div class="tf-input">
|
||
<textarea id="tfi" placeholder="Descreva a infraestrutura desejada... (Shift+Enter para nova linha)" rows="1" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();tfSend()}" oninput="autoGrow(this)" onpaste="setTimeout(()=>autoGrow(this),0)" style="resize:none;overflow-y:auto"></textarea>
|
||
<button class="btn bp" onclick="tfSend()" style="background:#7b42bc;border-color:#7b42bc">Gerar</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="tf-resize" onmousedown="tfStartResize(event)"></div>
|
||
<div class="tf-bottom" id="tfBottom">
|
||
<div class="tf-bottom-tabs">
|
||
<div class="tf-btab${S.tfBtab==='files'?' active':''}" onclick="tfSwitchBtab('files')">
|
||
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M1 2.5A1.5 1.5 0 012.5 1h3.379a1.5 1.5 0 011.06.44l1.122 1.12A1.5 1.5 0 009.121 3H13.5A1.5 1.5 0 0115 4.5v8a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12.5z"/></svg>
|
||
Files${S.tfFiles.length?' ('+S.tfFiles.length+')':''}
|
||
</div>
|
||
<div class="tf-btab${S.tfBtab==='plan'?' active':''}" onclick="tfSwitchBtab('plan')">
|
||
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M2 2h12v2H2zm0 4h8v2H2zm0 4h10v2H2z"/></svg>
|
||
Plan${S.tfPlan.length?' ('+S.tfPlan.length+')':''}
|
||
</div>
|
||
<div class="tf-btab${S.tfBtab==='resources'?' active':''}" onclick="tfSwitchBtab('resources')">
|
||
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 1L1 5l7 4 7-4zm0 6L1 11l7 4 7-4z"/></svg>
|
||
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>':''}
|
||
${rTfActions()}
|
||
</div>
|
||
</div>
|
||
<div class="tf-bottom-content">
|
||
<div class="tf-bc-left">${rTfBottomContent()}</div>
|
||
${rTfTerminal()}
|
||
</div>
|
||
</div>
|
||
${S.tfConfirmDest?rTfConfirmDestroy():''}
|
||
</div>`;
|
||
}
|
||
|
||
function _tfBadge(st){
|
||
const m={draft:['draft','Draft'],planning:['run','Planning...'],planned:['ok','Plan OK'],applying:['run','Applying...'],applied:['ok','Applied'],destroying:['run','Destroying...'],destroyed:['dest','Destroyed'],failed:['err','Failed']};
|
||
const[cls,label]=m[st]||['draft',st];
|
||
return`<span class="tf-badge tf-badge-${cls}">${label}</span>`;
|
||
}
|
||
|
||
function rTfActions(){
|
||
if(S.tfRunning)return'<button class="btn bsm" style="background:var(--rd);color:#fff;border-color:var(--rd);font-size:.72rem;padding:4px 12px" onclick="tfCancel()">Cancel</button>';
|
||
const st=S.tfStatus;let btns='';
|
||
if(S.tfCode&&(st==='draft'||st==='failed'))btns+=`<button class="btn bsm" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.72rem;padding:4px 12px;gap:4px;display:inline-flex;align-items:center" onclick="tfSaveAndPlan()">${TF_ICON} Plan</button>`;
|
||
if(st==='planned'||st==='failed')btns+=`<button class="btn bsm" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.72rem;padding:4px 12px;gap:4px;display:inline-flex;align-items:center" onclick="tfSaveAndPlan()">${TF_ICON} Re-plan</button>`;
|
||
if(st==='planned')btns+='<button class="btn bsm" style="background:var(--gn);color:#fff;border-color:var(--gn);font-size:.72rem;padding:4px 12px" onclick="tfApply()">✓ Apply</button>';
|
||
if(st==='applied')btns+=`<button class="btn bsm" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.72rem;padding:4px 12px;gap:4px;display:inline-flex;align-items:center" onclick="tfSaveAndPlan()">${TF_ICON} Re-plan</button>`;
|
||
if(st==='applied'||st==='planned')btns+='<button class="btn bsm" style="background:var(--rd);color:#fff;border-color:var(--rd);font-size:.72rem;padding:4px 12px" onclick="S.tfConfirmDest=true;R()">✕ Destroy</button>';
|
||
return btns;
|
||
}
|
||
|
||
function rTfBottomContent(){
|
||
if(S.tfBtab==='files')return rTfFiles();
|
||
if(S.tfBtab==='plan')return rTfPlanPanel();
|
||
if(S.tfBtab==='resources')return rTfResourcesPanel();
|
||
return'';
|
||
}
|
||
|
||
function rTfTerminal(){
|
||
const out=S.tfPlanOut||S.tfApplyOut||S.tfDestroyOut;
|
||
let label='Terminal',color='#cdd6f4',content='';
|
||
if(out){
|
||
if(S.tfPlanOut)content=S.tfPlanOut;
|
||
if(S.tfApplyOut)content=S.tfApplyOut;
|
||
if(S.tfDestroyOut)content=S.tfDestroyOut;
|
||
label=S.tfDestroyOut?'Destroy Output':S.tfApplyOut?'Apply Output':'Plan Output';
|
||
color=S.tfDestroyOut?'#f38ba8':S.tfApplyOut?'#a6e3a1':'#cba6f7';
|
||
}
|
||
return`<div class="tf-bc-right">
|
||
<div class="tf-term-hdr"><svg viewBox="0 0 16 16"><path d="M2 1h12a1 1 0 011 1v12a1 1 0 01-1 1H2a1 1 0 01-1-1V2a1 1 0 011-1zm1 3v9h10V4zm2 1l3 2.5L5 10z"/></svg><span style="color:${color}">${label}</span>${S.tfRunning?'<span style="margin-left:auto;font-size:.6rem;color:#f9e2af">● Executando...</span>':''}</div>
|
||
<div class="tf-term-body" id="tfTermOut">${out?escHtml(content):'<span style="opacity:.4">Aguardando execução...</span>'}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function rTfFiles(){
|
||
if(!S.tfFiles.length)return`<div class="tf-empty">${TF_ICON}<p>Nenhum arquivo gerado ainda</p><p style="font-size:.66rem;opacity:.5">Descreva a infraestrutura desejada no chat acima</p></div>`;
|
||
const files=[...S.tfFiles];
|
||
if(S.tfWs||S.tfOci){
|
||
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}"`,size:0,type:'tfvars',auto:true});
|
||
}
|
||
}
|
||
let html='';
|
||
for(let i=0;i<files.length;i++){
|
||
const f=files[i];const isEdit=S.tfEditIdx===i;
|
||
const sz=f.content?f.content.length:f.size;
|
||
const sizeStr=sz>1024?(sz/1024).toFixed(1)+' KB':sz+' B';
|
||
const iconFill=f.auto?'#888':'#7b42bc';
|
||
html+=`<div class="tf-file${isEdit?' editing':''}" onclick="tfOpenFile(${i})">
|
||
<div class="tf-file-icon"><svg viewBox="0 0 16 16" fill="${iconFill}"><path d="M4 1a1 1 0 00-1 1v12a1 1 0 001 1h8a1 1 0 001-1V4.5L9.5 1H4zm5 0v3.5h3.5"/></svg></div>
|
||
<div class="tf-file-info"><div class="tf-file-name">${f.name}</div><div class="tf-file-meta">${sizeStr}${f.auto?' · auto-generated':''}</div></div>
|
||
<div class="tf-file-actions">
|
||
${!f.auto?`<div class="tf-file-btn" onclick="event.stopPropagation();tfOpenFile(${isEdit?-1:i})" title="${isEdit?'Fechar':'Editar'}"><svg viewBox="0 0 16 16" width="12" height="12" fill="var(--t3)"><path d="M12.146.854a.5.5 0 01.708 0l2.292 2.292a.5.5 0 010 .708L5.854 13.146a.5.5 0 01-.22.128l-3.5 1.166a.5.5 0 01-.632-.632l1.166-3.5a.5.5 0 01.128-.22z"/></svg></div>`:''}
|
||
<div class="tf-file-btn" onclick="event.stopPropagation();tfCopyFile(${i})" title="Copiar"><svg viewBox="0 0 16 16" width="12" height="12" fill="var(--t3)"><path d="M4 2a2 2 0 012-2h4.586L14 3.414V10a2 2 0 01-2 2H6a2 2 0 01-2-2zm6-1.414L13.414 4H10zM2 6a2 2 0 00-2 2v6a2 2 0 002 2h4a2 2 0 002-2v-1H6a3 3 0 01-3-3V6z"/></svg></div>
|
||
<div class="tf-file-btn" onclick="event.stopPropagation();tfDlFile(${i})" title="Download"><svg viewBox="0 0 16 16" width="12" height="12" fill="var(--t3)"><path d="M8 1v9m0 0L5 7m3 3l3-3M2 12v1.5A1.5 1.5 0 003.5 15h9a1.5 1.5 0 001.5-1.5V12" stroke="var(--t3)" stroke-width="1.5" fill="none" stroke-linecap="round"/></svg></div>
|
||
</div>
|
||
</div>`;
|
||
if(isEdit){
|
||
html+=`<div class="tf-editor">
|
||
<div class="tf-editor-hdr"><svg viewBox="0 0 16 16" width="12" height="12" fill="#7b42bc"><path d="M12.146.854a.5.5 0 01.708 0l2.292 2.292a.5.5 0 010 .708L5.854 13.146a.5.5 0 01-.22.128l-3.5 1.166a.5.5 0 01-.632-.632l1.166-3.5a.5.5 0 01.128-.22z"/></svg><span class="tf-ed-name">${f.name}</span>${f.auto?'<span style="font-size:.58rem;color:var(--t3);font-weight:400">somente leitura</span>':'<button class="btn bs" onclick="tfSaveFile(${i})" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.6rem;padding:1px 8px">Salvar</button>'}<button class="btn bs" onclick="S.tfEditIdx=-1;R()" style="font-size:.6rem;padding:1px 8px">Fechar</button></div>
|
||
<textarea class="tf-editor-ta" id="tfEdTa" ${f.auto?'readonly':''}onkeydown="if(event.key==='Tab'){event.preventDefault();const s=this.selectionStart,e=this.selectionEnd;this.value=this.value.substring(0,s)+' '+this.value.substring(e);this.selectionStart=this.selectionEnd=s+2}">${escHtml(f.content||'')}</textarea>
|
||
</div>`;
|
||
}
|
||
}
|
||
return html;
|
||
}
|
||
|
||
function rTfPlanPanel(){
|
||
if(!S.tfPlan.length)return`<div class="tf-empty">${TF_ICON}<p>Nenhum recurso no plano</p></div>`;
|
||
return S.tfPlan.map(r=>`<div style="display:flex;align-items:center;gap:.5rem;padding:.4rem .5rem;border-radius:8px;background:var(--gnl);margin-bottom:.3rem">
|
||
<span style="color:var(--gn);font-weight:700;font-size:.82rem;font-family:var(--fm)">+</span>
|
||
<div><div style="font-size:.74rem;font-weight:600;font-family:var(--fm);color:var(--t1)">${escHtml(r.type)}</div>
|
||
<div style="font-size:.64rem;color:var(--t3)">${escHtml(r.desc)}</div></div></div>`).join('');
|
||
}
|
||
|
||
function rTfConfirmDestroy(){
|
||
return`<div class="tf-modal-overlay" onclick="S.tfConfirmDest=false;R()"><div class="tf-modal" onclick="event.stopPropagation()">
|
||
<h3 style="display:flex;align-items:center;gap:.4rem"><svg viewBox="0 0 16 16" width="18" height="18" fill="var(--rd)"><path d="M8 1a7 7 0 100 14A7 7 0 008 1zm0 2.5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 018 3.5zM8 10a1 1 0 100 2 1 1 0 000-2z"/></svg> Confirmar Destroy</h3>
|
||
<p style="font-size:.8rem;margin:.5rem 0">Isso irá <strong>destruir todos os recursos</strong> provisionados. Esta ação é irreversível.</p>
|
||
<p style="font-size:.74rem;color:var(--t3);margin-bottom:.8rem">Digite <strong>DESTROY</strong> para confirmar:</p>
|
||
<input type="text" id="tfDestInput" placeholder="DESTROY" style="margin-bottom:.6rem">
|
||
<div style="display:flex;gap:.5rem;justify-content:flex-end">
|
||
<button class="btn bs" onclick="S.tfConfirmDest=false;R()">Cancelar</button>
|
||
<button class="btn bd" onclick="tfConfirmDestroy()">Destruir</button>
|
||
</div>
|
||
</div></div>`;
|
||
}
|
||
|
||
function tfStartResize(e){
|
||
e.preventDefault();const el=document.getElementById('tfBottom');if(!el)return;
|
||
const startY=e.clientY;const startH=el.offsetHeight;
|
||
const bar=e.target;bar.classList.add('dragging');
|
||
function onMove(ev){const dh=startY-ev.clientY;el.style.height=Math.max(100,Math.min(startH+dh,window.innerHeight-200))+'px'}
|
||
function onUp(){bar.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp)}
|
||
document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);
|
||
}
|
||
|
||
function tfOpenFile(i){S.tfEditIdx=S.tfEditIdx===i?-1:i;R();if(S.tfEditIdx>=0)setTimeout(()=>{const ta=document.getElementById('tfEdTa');if(ta){ta.style.height='auto';ta.style.height=Math.min(Math.max(ta.scrollHeight,120),400)+'px'}},20)}
|
||
function tfSaveFile(i){const ta=document.getElementById('tfEdTa');if(!ta||i<0||i>=S.tfFiles.length)return;S.tfFiles[i].content=ta.value;S.tfFiles[i].size=ta.value.length;S.tfCode=S.tfFiles.map(f=>'// filename: '+f.name+'\n'+f.content).join('\n\n');S.tfStatus='draft';R()}
|
||
function tfCopyFile(i){
|
||
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})}
|
||
if(files[i])navigator.clipboard.writeText(files[i].content).then(()=>alert('Copiado!'));
|
||
}
|
||
function tfDlFile(i){
|
||
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})}
|
||
const f=files[i];if(!f)return;
|
||
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)});
|
||
}
|
||
|
||
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()}
|
||
async function tfLoadComps(){
|
||
const oid=S.tfOci||(S.tfModel.startsWith('cfg:')?((S.genaiCfg.find(x=>x.id===S.tfModel.slice(4))||{}).oci_config_id||''):'');
|
||
if(!oid){S.tfComps=[];R();return}
|
||
S.tfCompLoading=true;R();
|
||
try{const d=await $api('/oci/explore/'+oid+'/compartment-tree');S.tfComps=d&&!d.error?d:[];
|
||
if(S.tfComps.length&&!S.tfCompartment){S.tfCompartment=S.tfComps[0].id;tfLoadResources()}
|
||
}catch(e){S.tfComps=[]}
|
||
S.tfCompLoading=false;R();
|
||
}
|
||
function tfFlatComps(comps,depth){
|
||
let r=[];for(const c of comps){r.push({id:c.id,name:' '.repeat(depth)+c.name,depth});if(c.children&&c.children.length)r=r.concat(tfFlatComps(c.children,depth+1))}return r;
|
||
}
|
||
async function tfLoadResources(){
|
||
const oid=S.tfOci||(S.tfModel.startsWith('cfg:')?((S.genaiCfg.find(x=>x.id===S.tfModel.slice(4))||{}).oci_config_id||''):'');
|
||
const comp=S.tfCompartment;
|
||
if(!oid||!comp){S.tfResources=null;R();return}
|
||
S.tfResLoading=true;R();
|
||
try{const d=await $api('/terraform/resources?oci_config_id='+encodeURIComponent(oid)+'&compartment_id='+encodeURIComponent(comp)+(S.tfRegion?'®ion='+encodeURIComponent(S.tfRegion):''));
|
||
S.tfResources=d&&!d.error?d:null;
|
||
}catch(e){S.tfResources=null}
|
||
S.tfResLoading=false;R();
|
||
}
|
||
function rTfResourcesPanel(){
|
||
if(S.tfResLoading)return`<div class="tf-empty"><div class="spinner"></div><p>Carregando recursos...</p></div>`;
|
||
if(!S.tfCompartment)return`<div class="tf-empty"><svg viewBox="0 0 16 16" width="36" height="36" fill="#7b42bc" opacity=".2"><path d="M8 1L1 5l7 4 7-4zm0 6L1 11l7 4 7-4z"/></svg><p>Selecione um compartment para ver os recursos existentes</p></div>`;
|
||
if(!S.tfResources)return`<div class="tf-empty"><svg viewBox="0 0 16 16" width="36" height="36" fill="#7b42bc" opacity=".2"><path d="M8 1L1 5l7 4 7-4zm0 6L1 11l7 4 7-4z"/></svg><p>Nenhum recurso carregado</p><button class="btn bs bsm" onclick="tfLoadResources()" style="margin-top:.5rem">Carregar Recursos</button></div>`;
|
||
const res=S.tfResources;
|
||
const cats=[
|
||
{key:'vcns',label:'VCNs',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M1 3h14v2H1zm2 4h10v2H3zm3 4h4v2H6z"/></svg>',fmt:r=>`${r.display_name} <span style="opacity:.6">(${(r.cidr_blocks||[]).join(', ')})</span>`},
|
||
{key:'subnets',label:'Subnets',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M2 2h5v5H2zm7 0h5v5H9zm-7 7h5v5H2zm7 0h5v5H9z"/></svg>',fmt:r=>`${r.display_name} <span style="opacity:.6">(${r.cidr_block}, ${r.public?'pública':'privada'})</span>`},
|
||
{key:'instances',label:'Compute',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M2 2h12v12H2z"/></svg>',fmt:r=>{const stopped=r.state==='STOPPED';const running=r.state==='RUNNING';const stC=stopped?'var(--rd)':running?'var(--gn)':'var(--t3)';return`${r.display_name} <span style="opacity:.6">(${r.shape})</span> <span style="color:${stC};font-weight:600;font-size:.62rem">${r.state}</span>${stopped?` <button class="btn bsm" style="font-size:.58rem;padding:1px 6px;background:var(--gn);color:#fff;border-color:var(--gn);margin-left:4px" onclick="event.stopPropagation();ociStartInstance('${r.id}')">▶ Start</button>`:running?` <button class="btn bsm" style="font-size:.58rem;padding:1px 6px;background:var(--rd);color:#fff;border-color:var(--rd);margin-left:4px" onclick="event.stopPropagation();ociStopInstance('${r.id}')">■ Stop</button>`:''}`}},
|
||
{key:'internet_gateways',label:'Internet GWs',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><circle cx="8" cy="8" r="6" fill="none" stroke="#7b42bc" stroke-width="1.5"/><path d="M1 8h14M8 2c-2 2-2 10 0 12m0-12c2 2 2 10 0 12"/></svg>',fmt:r=>r.display_name},
|
||
{key:'nat_gateways',label:'NAT GWs',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M3 3l5 5-5 5"/><path d="M8 8h6" stroke="#7b42bc" stroke-width="1.5"/></svg>',fmt:r=>r.display_name},
|
||
{key:'route_tables',label:'Route Tables',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M2 3h12v2H2zm0 4h8v2H2zm0 4h10v2H2z"/></svg>',fmt:r=>r.display_name},
|
||
{key:'security_lists',label:'Security Lists',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M8 1l6 3v4c0 3.5-2.5 6.5-6 7.5C4.5 14.5 2 11.5 2 8V4z"/></svg>',fmt:r=>r.display_name},
|
||
{key:'autonomous_databases',label:'Databases',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><ellipse cx="8" cy="4" rx="6" ry="2.5"/><path d="M2 4v8c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5V4"/></svg>',fmt:r=>{const stopped=r.state==='STOPPED';const avail=r.state==='AVAILABLE';const stC=stopped?'var(--rd)':avail?'var(--gn)':'var(--t3)';return`${r.display_name} <span style="opacity:.6">(${r.db_name}${r.is_free_tier?' Free':''})</span> <span style="color:${stC};font-weight:600;font-size:.62rem">${r.state}</span>${stopped?` <button class="btn bsm" style="font-size:.58rem;padding:1px 6px;background:var(--gn);color:#fff;border-color:var(--gn);margin-left:4px" onclick="event.stopPropagation();ociStartAdb('${r.id}')">▶ Start</button>`:avail?` <button class="btn bsm" style="font-size:.58rem;padding:1px 6px;background:var(--rd);color:#fff;border-color:var(--rd);margin-left:4px" onclick="event.stopPropagation();ociStopAdb('${r.id}')">■ Stop</button>`:''}`}},
|
||
{key:'block_volumes',label:'Block Volumes',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M3 2h10v12H3z"/></svg>',fmt:r=>`${r.display_name} <span style="opacity:.6">(${r.size_in_gbs||'?'} GB)</span>`},
|
||
{key:'load_balancers',label:'Load Balancers',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M8 2v12M2 8h12M4 4l8 8M12 4l-8 8"/></svg>',fmt:r=>`${r.display_name} <span style="opacity:.6">(${r.shape_name||'flexible'})</span>`}
|
||
];
|
||
let total=0;
|
||
let html='<div style="padding:.5rem">';
|
||
html+='<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.5rem"><span style="font-size:.72rem;font-weight:600;color:var(--t2)">Recursos existentes no compartment</span><button class="btn bs bsm" onclick="tfLoadResources()" style="font-size:.6rem;padding:1px 6px">Atualizar</button></div>';
|
||
for(const cat of cats){
|
||
const items=res[cat.key]||[];
|
||
if(!items.length)continue;
|
||
total+=items.length;
|
||
html+=`<div style="margin-bottom:.6rem"><div style="display:flex;align-items:center;gap:.3rem;font-size:.7rem;font-weight:600;color:#7b42bc;margin-bottom:.2rem">${cat.icon} ${cat.label} (${items.length})</div>`;
|
||
for(const r of items.slice(0,10)){
|
||
html+=`<div style="font-size:.68rem;padding:.15rem .4rem .15rem 1.2rem;color:var(--t2);border-left:2px solid #7b42bc22">${cat.fmt(r)}</div>`;
|
||
}
|
||
if(items.length>10)html+=`<div style="font-size:.62rem;padding:.1rem .4rem .1rem 1.2rem;color:var(--t3)">... e mais ${items.length-10}</div>`;
|
||
html+='</div>';
|
||
}
|
||
if(!total)html+='<div style="font-size:.72rem;color:var(--t3);text-align:center;padding:1rem">Nenhum recurso encontrado neste compartment</div>';
|
||
else html+=`<div style="font-size:.66rem;color:var(--t3);border-top:1px solid var(--bd);padding-top:.3rem;margin-top:.3rem">Total: <strong>${total}</strong> recursos. O agente irá reutilizá-los automaticamente quando possível.</div>`;
|
||
html+='</div>';
|
||
return html;
|
||
}
|
||
function _ociResOid(){return S.tfOci||(S.tfModel.startsWith('cfg:')?((S.genaiCfg.find(x=>x.id===S.tfModel.slice(4))||{}).oci_config_id||''):'')}
|
||
async function ociStartInstance(id){if(!confirm('Iniciar esta instância?'))return;try{await $api('/oci/instances/'+id+'/action',{method:'POST',body:{action:'START',oci_config_id:_ociResOid(),region:S.tfRegion||null}});alert('Instância iniciando...');tfLoadResources()}catch(e){alert('Erro: '+e.message)}}
|
||
async function ociStopInstance(id){if(!confirm('Parar esta instância?'))return;try{await $api('/oci/instances/'+id+'/action',{method:'POST',body:{action:'STOP',oci_config_id:_ociResOid(),region:S.tfRegion||null}});alert('Instância parando...');tfLoadResources()}catch(e){alert('Erro: '+e.message)}}
|
||
async function ociStartAdb(id){if(!confirm('Iniciar este Autonomous Database?'))return;try{await $api('/oci/autonomous-databases/'+id+'/action',{method:'POST',body:{action:'start',oci_config_id:_ociResOid(),region:S.tfRegion||null}});alert('Autonomous DB iniciando...');tfLoadResources()}catch(e){alert('Erro: '+e.message)}}
|
||
async function ociStopAdb(id){if(!confirm('Parar este Autonomous Database?'))return;try{await $api('/oci/autonomous-databases/'+id+'/action',{method:'POST',body:{action:'stop',oci_config_id:_ociResOid(),region:S.tfRegion||null}});alert('Autonomous DB parando...');tfLoadResources()}catch(e){alert('Erro: '+e.message)}}
|
||
function tfSwitchBtab(tab){
|
||
S.tfBtab=tab;S.tfEditIdx=-1;
|
||
if(tab==='resources'&&!S.tfResources&&S.tfCompartment)tfLoadResources();
|
||
// Update only bottom panel content + tab active states
|
||
const left=document.querySelector('.tf-bc-left');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()}
|
||
}
|
||
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'}
|
||
function tfTogglePanel(p){S.tfPanel=S.tfPanel===p?'':p;R()}
|
||
async function tfClear(){if(S.tfSid)try{await $api('/chat/'+S.tfSid,{method:'DELETE'})}catch(e){}S.tfMsgs=[];S.tfSid=null;S.tfPlan=[];S.tfCode='';S.tfFiles=[];S.tfWs=null;S.tfPlanOut='';S.tfApplyOut='';S.tfDestroyOut='';S.tfStatus='draft';S.tfRunning=false;S.tfConfirmDest=false;S.tfComps=[];S.tfCompartment='';S.tfResources=null;S.tfResLoading=false;R()}
|
||
function tfScroll(){setTimeout(()=>{const e=document.getElementById('tfchm');if(e)e.scrollTop=e.scrollHeight},50)}
|
||
function copyTfCode(){navigator.clipboard.writeText(S.tfCode).then(()=>alert('Código copiado!'))}
|
||
function dlTfCode(){if(!S.tfFiles.length)return;if(S.tfFiles.length===1){const f=S.tfFiles[0];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)}else{tfDlAll()}}
|
||
|
||
async function tfSend(){
|
||
const el=document.getElementById('tfi');const m=el.value.trim();if(!m)return;el.style.height='auto';S._tfiVal='';
|
||
if(!S.tfModel){S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione um modelo antes de enviar.',t:tstamp()});R();return}
|
||
if(!S.tfCompartment){S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione um compartment antes de enviar.',t:tstamp()});R();return}
|
||
el.value='';S.tfMsgs.push({r:'user',c:m,t:tstamp()});R();tfScroll();
|
||
S.tfMsgs.push({r:'assistant',c:'⏳ _Gerando Terraform..._',thinking:true});R();tfScroll();
|
||
try{
|
||
const body={message:m,session_id:S.tfSid,use_tools:false};
|
||
if(S.tfModel.startsWith('cfg:'))body.genai_config_id=S.tfModel.slice(4);
|
||
else{
|
||
if(!S.tfOci){S.tfMsgs.pop();S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione uma credencial OCI.',t:tstamp()});R();return}
|
||
body.model_id=S.tfModel;body.oci_config_id=S.tfOci;body.genai_region=S.tfRegion;body.compartment_id=S.tfCompartment;
|
||
}
|
||
const d=await $api('/terraform/chat',{method:'POST',body});
|
||
S.tfSid=d.session_id;
|
||
if(S.tfHistOpen)loadHistory('terraform');
|
||
if(d.status==='processing'&&d.message_id){await tfPollResult(d.message_id)}
|
||
else{S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:d.response||d.content||'Sem resposta',t:tstamp()});R();tfScroll()}
|
||
}catch(e){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'❌ Erro: '+e.message,t:tstamp()});R()}
|
||
}
|
||
|
||
async function tfPollResult(mid){
|
||
for(let i=0;i<600;i++){
|
||
await new Promise(r=>setTimeout(r,i<10?1000:3000));
|
||
try{const r=await $api('/chat/'+mid+'/status');
|
||
if(r.status==='done'){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:r.content,t:tstamp()});R();tfScroll();
|
||
if(r.content&&r.content.includes('```plan')){S.tfPanel='plan';R()}return}
|
||
if(r.status==='failed'){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'❌ '+r.content,t:tstamp()});R();tfScroll();return}
|
||
}catch(e){}
|
||
}
|
||
S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'⏰ Timeout.',t:tstamp()});R()
|
||
}
|
||
|
||
async function tfSaveAndPlan(){
|
||
if(!S.tfCode){alert('Gere código Terraform primeiro.');return}
|
||
// Resolve OCI config
|
||
let ociId=S.tfOci;
|
||
if(!ociId&&S.tfModel.startsWith('cfg:')){const g=S.genaiCfg.find(x=>x.id===S.tfModel.slice(4));if(g)ociId=g.oci_config_id}
|
||
if(!ociId&&S.ociCfg.length)ociId=S.ociCfg[0].id;
|
||
if(!ociId){alert('Selecione uma credencial OCI.');return}
|
||
try{
|
||
// Create or update workspace
|
||
if(!S.tfWs){
|
||
const d=await $api('/terraform/workspaces',{method:'POST',body:{session_id:S.tfSid||'none',oci_config_id:ociId,compartment_id:S.tfCompartment||null,name:'workspace',tf_code:S.tfCode}});
|
||
S.tfWs=d.id;
|
||
}else{
|
||
await $api('/terraform/workspaces/'+S.tfWs+'/code',{method:'PUT',body:{tf_code:S.tfCode}});
|
||
}
|
||
// Run plan
|
||
await $api('/terraform/workspaces/'+S.tfWs+'/plan',{method:'POST'});
|
||
S.tfStatus='planning';S.tfRunning=true;S.tfPlanOut='';S.tfBtab='plan';R();
|
||
await tfPollExec();
|
||
}catch(e){alert('Erro: '+e.message)}
|
||
}
|
||
|
||
async function tfApply(){
|
||
if(!S.tfWs){alert('Execute Plan primeiro.');return}
|
||
try{
|
||
await $api('/terraform/workspaces/'+S.tfWs+'/apply',{method:'POST'});
|
||
S.tfStatus='applying';S.tfRunning=true;S.tfApplyOut='';S.tfBtab='plan';R();
|
||
await tfPollExec();
|
||
}catch(e){alert('Erro: '+e.message)}
|
||
}
|
||
|
||
async function tfConfirmDestroy(){
|
||
const inp=document.getElementById('tfDestInput');
|
||
if(!inp||inp.value!=='DESTROY'){alert('Digite DESTROY para confirmar.');return}
|
||
S.tfConfirmDest=false;R();
|
||
if(!S.tfWs)return;
|
||
try{
|
||
await $api('/terraform/workspaces/'+S.tfWs+'/destroy',{method:'POST',body:{confirm:'DESTROY'}});
|
||
S.tfStatus='destroying';S.tfRunning=true;S.tfDestroyOut='';S.tfBtab='plan';R();
|
||
await tfPollExec();
|
||
}catch(e){alert('Erro: '+e.message)}
|
||
}
|
||
|
||
async function tfCancel(){
|
||
if(!S.tfWs)return;
|
||
try{await $api('/terraform/workspaces/'+S.tfWs+'/cancel',{method:'POST'})}catch(e){}
|
||
S.tfRunning=false;S.tfStatus='failed';R();
|
||
}
|
||
|
||
async function tfPollExec(){
|
||
for(let i=0;i<600;i++){
|
||
await new Promise(r=>setTimeout(r,2000));
|
||
try{
|
||
const r=await $api('/terraform/workspaces/'+S.tfWs+'/status');
|
||
S.tfPlanOut=r.plan_output||S.tfPlanOut;
|
||
S.tfApplyOut=r.apply_output||S.tfApplyOut;
|
||
S.tfDestroyOut=r.destroy_output||S.tfDestroyOut;
|
||
S.tfStatus=r.status;
|
||
if(!['planning','applying','destroying'].includes(r.status)){S.tfRunning=false;R();return}
|
||
R();
|
||
// Auto-scroll output
|
||
const t=document.getElementById('tfTermOut');if(t)t.scrollTop=t.scrollHeight;
|
||
}catch(e){}
|
||
}
|
||
S.tfRunning=false;R();
|
||
}
|
||
|
||
|
||
/* ── OCI Explorer ── */
|
||
const EXP_GROUPS=[
|
||
{name:'Compute',tabs:[
|
||
{key:'instances',label:'Instances'},{key:'boot_volumes',label:'Boot Vol'},{key:'instance_pools',label:'Pools'}]},
|
||
{name:'Rede',tabs:[
|
||
{key:'vcns',label:'VCN'},{key:'subnets',label:'Subnets'},{key:'security_lists',label:'Sec Lists'},
|
||
{key:'nsgs',label:'NSGs'},{key:'route_tables',label:'Routes'},{key:'nat_gateways',label:'NAT GW'},
|
||
{key:'internet_gateways',label:'IGW'},{key:'service_gateways',label:'SGW'},{key:'public_ips',label:'IPs Públicos'},
|
||
{key:'load_balancers',label:'Load Balancer'}]},
|
||
{name:'Storage',tabs:[
|
||
{key:'buckets',label:'Buckets'},{key:'block_volumes',label:'Block Vol'},
|
||
{key:'file_systems',label:'File Storage'},{key:'mount_targets',label:'Mount Targets'}]},
|
||
{name:'Database',tabs:[
|
||
{key:'databases',label:'ADB'},{key:'db_systems',label:'DB Systems'},{key:'mysql_db_systems',label:'MySQL'}]},
|
||
{name:'Containers',tabs:[
|
||
{key:'container_instances',label:'Containers'},{key:'oke_clusters',label:'OKE'}]},
|
||
{name:'Serverless',tabs:[
|
||
{key:'functions',label:'Functions'},{key:'api_gateways',label:'API Gateway'}]},
|
||
{name:'Observabilidade',tabs:[
|
||
{key:'alarms',label:'Alarmes'},{key:'log_groups',label:'Log Groups'},
|
||
{key:'notification_topics',label:'Notificações'},{key:'events_rules',label:'Events'}]},
|
||
{name:'Segurança',tabs:[
|
||
{key:'vaults',label:'Vaults'},{key:'dns_zones',label:'DNS'},
|
||
{key:'network_firewalls',label:'Firewalls'},{key:'network_firewall_policies',label:'FW Policies'}]},
|
||
{name:'IAM',tabs:[
|
||
{key:'iam_users',label:'Usuários'},{key:'iam_groups',label:'Grupos'},
|
||
{key:'iam_policies',label:'Policies'},{key:'iam_dynamic_groups',label:'Dynamic Groups'}]}
|
||
];
|
||
const EXP_TABS=EXP_GROUPS.flatMap(g=>g.tabs);
|
||
function rExplorer(){
|
||
if(!S.ociCfg.length)return`<div class="cd"><div class="emp"><div class="eic">☁️</div><p>Nenhuma credencial OCI configurada.</p><p style="font-size:.74rem;margin-top:.35rem">Configure em <strong>Credenciais OCI</strong>.</p></div></div>`;
|
||
if(!S.expCfg&&S.ociCfg.length)S.expCfg=S.ociCfg[0].id;
|
||
const cfgOpts=S.ociCfg.map(c=>`<option value="${c.id}"${c.id===S.expCfg?' selected':''}>${c.tenancy_name} (${c.region})</option>`).join('');
|
||
const regLabel=S.expSelRegions.length?S.expSelRegions.length+' região(ões)':'Todas as regiões';
|
||
const regChecks=S.expRegions.map(r=>`<label style="display:flex;align-items:center;gap:.35rem;padding:.25rem .5rem;font-size:.72rem;cursor:pointer;border-radius:4px;transition:background .1s" onmouseover="this.style.background='var(--bg2)'" onmouseout="this.style.background=''"><input type="checkbox" ${S.expSelRegions.includes(r.name)?'checked':''} onchange="expToggleRegion('${r.name}')" style="accent-color:var(--ac)"> ${r.name}${r.is_home?' <span style="font-size:.58rem;color:var(--ac)">(Home)</span>':''}</label>`).join('');
|
||
const catIcons={Compute:'💻',Rede:'🌐',Storage:'💾',Database:'🗄️',Containers:'📦',Serverless:'⚡',Observabilidade:'📊',Segurança:'🔒',IAM:'👤'};
|
||
const cats=EXP_GROUPS.map(g=>`<div class="exp-cat${S.expCat===g.name?' active':''}" onclick="expSetCat('${g.name}')">${catIcons[g.name]||''} ${g.name}</div>`).join('');
|
||
const activeGrp=EXP_GROUPS.find(g=>g.name===S.expCat)||EXP_GROUPS[0];
|
||
const tabs=activeGrp.tabs.map(t=>{const cnt=S.expCounts[t.key];return`<div class="exp-tab${S.expResType===t.key?' active':''}" onclick="expSetTab('${t.key}')">${t.label}${cnt!=null?' <span style="font-size:.58rem;opacity:.7">('+cnt+')</span>':''}</div>`}).join('');
|
||
return`<div class="exp-wrap">
|
||
<div class="exp-tree" id="exp-tree"><div style="padding:.5rem .65rem .4rem;font-size:.74rem;font-weight:700;color:var(--t2);border-bottom:1px solid var(--bd);margin-bottom:.3rem;display:flex;align-items:center;gap:.35rem">📁 Compartments</div>${S.expTree.length?rExpTree(null,0):'<div class="ld"><span class="spinner"></span> Carregando...</div>'}</div>
|
||
<div class="exp-resize" onmousedown="expStartResize(event)"></div>
|
||
<div class="exp-main">
|
||
<div class="exp-toolbar"><select onchange="expSetCfg(this.value)">${cfgOpts}</select>
|
||
${S.expRegions.length?`<div class="dd" style="position:relative" onclick="event.stopPropagation()"><div onclick="S.expRegDdOpen=!S.expRegDdOpen;R()" style="display:flex;align-items:center;gap:.3rem;cursor:pointer;padding:.3rem .6rem;background:var(--bg2);border:1px solid var(--bd);border-radius:8px;font-size:.72rem;white-space:nowrap;min-width:130px;justify-content:space-between"><span>🌍 ${regLabel}</span><span style="font-size:.55rem;color:var(--t4)">▼</span></div>
|
||
${S.expRegDdOpen?`<div style="position:absolute;top:100%;left:0;margin-top:4px;background:var(--bg);border:1px solid var(--bd);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.12);z-index:60;min-width:220px;overflow:hidden">
|
||
<div style="padding:.5rem .6rem;border-bottom:1px solid var(--bd);display:flex;justify-content:space-between;align-items:center"><span style="font-size:.7rem;font-weight:600;color:var(--t2)">Regiões</span><div style="display:flex;gap:.3rem"><button class="btn bs" style="font-size:.6rem;padding:.15rem .4rem" onclick="S.expSelRegions=S.expRegions.map(r=>r.name);R()">Todas</button><button class="btn bs" style="font-size:.6rem;padding:.15rem .4rem" onclick="S.expSelRegions=[];R()">Nenhuma</button></div></div>
|
||
<div style="max-height:240px;overflow-y:auto;padding:.3rem 0">${regChecks}</div>
|
||
<div style="padding:.4rem .6rem;border-top:1px solid var(--bd);text-align:right"><button class="btn bp" style="font-size:.68rem;padding:.25rem .7rem" onclick="S.expRegDdOpen=false;expApplyRegions()">Aplicar</button></div>
|
||
</div>`:''}</div>`:''}
|
||
${S.expSelRegions.length?`<div style="display:flex;gap:.25rem;flex-wrap:wrap;align-items:center">${S.expSelRegions.map(r=>`<span class="tag" style="font-size:.6rem;display:inline-flex;align-items:center;gap:.2rem;background:var(--acl);color:var(--ac);border:1px solid var(--ac);border-radius:6px;padding:.1rem .35rem">${r}<span onclick="expRemoveRegion('${r}')" style="cursor:pointer;font-weight:700;font-size:.7rem">×</span></span>`).join('')}</div>`:''}
|
||
<button class="btn" style="margin-left:auto;font-size:.7rem;padding:.25rem .6rem" onclick="expRefresh()">↻ Refresh</button></div>
|
||
<div class="exp-nav"><div class="exp-cats">${cats}</div></div>
|
||
<div class="exp-tabs">${tabs}</div>
|
||
<div class="exp-content">${S.expLoading?'<div class="ld"><span class="spinner"></span> Consultando OCI...</div>':renderExpData(S.expData)}</div>
|
||
</div></div>`}
|
||
function rExpTree(parentId,depth){
|
||
const children=S.expTree.filter(n=>n.parent_id===parentId);
|
||
if(!children.length)return'';
|
||
return children.map(n=>{
|
||
const hasKids=S.expTree.some(c=>c.parent_id===n.id);
|
||
const isOpen=S.expTreeOpen[n.id];
|
||
const isSel=S.expSelComp===n.id;
|
||
return`<div class="exp-node" style="padding-left:${depth*0.6}rem">
|
||
<div class="exp-node-row${isSel?' selected':''}" onclick="expSelectComp('${n.id}')">
|
||
<span class="exp-node-toggle" onclick="event.stopPropagation();expToggleNode('${n.id}')">${hasKids?(isOpen?'▼':'▶'):' '}</span>
|
||
<span>${n.name}</span></div>
|
||
${hasKids&&isOpen?`<div class="exp-node-children">${rExpTree(n.id,depth+1)}</div>`:''}</div>`}).join('')}
|
||
async function expSetCfg(cid){S.expCfg=cid;S.expTree=[];S.expSelComp='';S.expData=null;S.expSelRegions=[];S.expRegions=[];S.expCounts={};S.expRegDdOpen=false;R();await expLoadTree();await expLoadRegions()}
|
||
function expToggleRegion(r){const i=S.expSelRegions.indexOf(r);if(i>=0)S.expSelRegions.splice(i,1);else S.expSelRegions.push(r);R()}
|
||
function expRemoveRegion(r){S.expSelRegions=S.expSelRegions.filter(x=>x!==r);if(S.expSelComp)expLoadRes()}
|
||
async function expApplyRegions(){if(S.expSelComp)await expLoadRes()}
|
||
function expSetCat(c){S.expCat=c;const grp=EXP_GROUPS.find(g=>g.name===c);if(grp&&grp.tabs.length){S.expResType=grp.tabs[0].key;S.expData=null;R();if(S.expSelComp)expLoadRes()}else R()}
|
||
function expSetTab(t){S.expResType=t;S.expData=null;R();if(S.expSelComp)expLoadRes()}
|
||
function expToggleNode(id){S.expTreeOpen[id]=!S.expTreeOpen[id];R()}
|
||
async function expSelectComp(id){S.expSelComp=id;S.expCounts={};await expLoadRes()}
|
||
function expStartResize(e){e.preventDefault();const tree=document.getElementById('exp-tree');const handle=e.target;if(!tree)return;handle.classList.add('dragging');const startX=e.clientX;const startW=tree.offsetWidth;
|
||
const onMove=ev=>{const w=Math.max(140,Math.min(startW+(ev.clientX-startX),window.innerWidth*0.5));tree.style.width=w+'px'};
|
||
const onUp=()=>{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);document.body.style.cursor='';document.body.style.userSelect=''};
|
||
document.body.style.cursor='col-resize';document.body.style.userSelect='none';document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp)}
|
||
async function expRefresh(){if(S.expCfg){S.expTree=[];S.expCounts={};R();await expLoadTree();if(S.expSelComp)await expLoadRes()}}
|
||
async function expLoadTree(){
|
||
if(!S.expCfg)return;
|
||
try{const d=await $api('/oci/explore/'+S.expCfg+'/compartment-tree');
|
||
S.expTree=d.error?[]:(d||[]);
|
||
if(S.expTree.length&&!S.expSelComp){S.expSelComp=S.expTree[0].id;S.expTreeOpen[S.expTree[0].id]=true}
|
||
R();if(S.expSelComp)await expLoadRes()}catch(e){S.expTree=[];R()}}
|
||
async function expLoadRegions(){
|
||
if(!S.expCfg)return;
|
||
try{const d=await $api('/oci/explore/'+S.expCfg+'/regions');S.expRegions=d.error?[]:(d||[]);R()}catch(e){S.expRegions=[];R()}}
|
||
async function expLoadRes(){
|
||
if(!S.expCfg||!S.expSelComp)return;
|
||
S.expLoading=true;S._expExpanded={};R();
|
||
const regions=S.expSelRegions.length?S.expSelRegions:[null];
|
||
let all=[];let errors=[];
|
||
const BATCH=3;const DELAY=800;
|
||
for(let i=0;i<regions.length;i+=BATCH){
|
||
const batch=regions.slice(i,i+BATCH);
|
||
const fetches=batch.map(async reg=>{
|
||
const q=new URLSearchParams({compartment_id:S.expSelComp});
|
||
if(reg)q.set('region',reg);
|
||
try{const d=await $api('/oci/explore/'+S.expCfg+'/'+S.expResType+'?'+q);
|
||
if(d.error){errors.push(reg+': '+d.error)}
|
||
else if(Array.isArray(d)){d.forEach(item=>{if(reg&&!item.region)item._region=reg});all=all.concat(d)}}
|
||
catch(e){errors.push((reg||'default')+': '+e.message)}});
|
||
await Promise.all(fetches);
|
||
if(i+BATCH<regions.length)await new Promise(r=>setTimeout(r,DELAY))}
|
||
if(all.length)S.expData=all;
|
||
else if(errors.length)S.expData={error:errors.join('\n')};
|
||
else S.expData=[];
|
||
S.expCounts[S.expResType]=all.length;
|
||
S.expLoading=false;R()}
|
||
function renderExpData(d){if(!d)return'<div class="emp"><p>Selecione um compartment na árvore.</p></div>';
|
||
if(d.error)return`<div class="al al-e">${d.error}</div>`;if(!Array.isArray(d)||!d.length)return'<div class="emp"><p>Nenhum recurso encontrado neste compartment.</p></div>';
|
||
if(S.expResType==='security_lists')return rExpSecLists(d);
|
||
if(S.expResType==='route_tables')return rExpRouteTables(d);
|
||
return`<div style="font-size:.74rem;color:var(--t4);margin-bottom:.5rem">${d.length} item(ns) encontrado(s)</div><div class="exp-r">${d.map(i=>{
|
||
const keys=Object.keys(i);
|
||
const name=i.display_name||i.name||i.db_name||i.application||'—';
|
||
const skip=['display_name','name','db_name','lifecycle_state','application','rules','routes'];
|
||
return`<div class="exp-c"><strong>${name}</strong>${i.lifecycle_state?` <span class="badge ${i.lifecycle_state==='RUNNING'||i.lifecycle_state==='ACTIVE'||i.lifecycle_state==='AVAILABLE'?'b-pass':'b-pend'}">${i.lifecycle_state}</span>`:''}<br>
|
||
${keys.filter(k=>!skip.includes(k)).map(k=>`<span style="color:var(--t4);font-size:.66rem">${k}:</span> <span class="em">${typeof i[k]==='object'?JSON.stringify(i[k]):i[k]}</span>`).join('<br>')}</div>`}).join('')}</div>`}
|
||
function rExpSecLists(d){
|
||
return`<div style="font-size:.74rem;color:var(--t4);margin-bottom:.5rem">${d.length} security list(s)</div>${d.map((sl,idx)=>{
|
||
const rules=sl.rules||[];
|
||
const ingress=rules.filter(r=>r.direction==='ingress');
|
||
const egress=rules.filter(r=>r.direction==='egress');
|
||
const expanded=S._expExpanded&&S._expExpanded[idx];
|
||
return`<div class="exp-c" style="max-width:100%"><div style="display:flex;justify-content:space-between;align-items:center;cursor:pointer" onclick="S._expExpanded=S._expExpanded||{};S._expExpanded[${idx}]=!S._expExpanded[${idx}];R()">
|
||
<div><strong style="display:inline">${sl.display_name}</strong> <span class="badge ${sl.lifecycle_state==='AVAILABLE'?'b-pass':'b-pend'}">${sl.lifecycle_state}</span></div>
|
||
<div style="font-size:.66rem;color:var(--t4)">${expanded?'▼':'▶'} ${ingress.length} ingress · ${egress.length} egress</div></div>
|
||
<div class="em" style="margin-top:.3rem">id: ${sl.id}</div>
|
||
${expanded?`<div style="margin-top:.5rem">${ingress.length?`<div style="font-size:.68rem;font-weight:600;color:var(--t2);margin:.4rem 0 .2rem">↓ Ingress (${ingress.length})</div>
|
||
<table style="width:100%;font-size:.66rem;border-collapse:collapse"><thead><tr style="background:var(--bg3);text-align:left"><th style="padding:.25rem .4rem">Protocolo</th><th style="padding:.25rem .4rem">Origem</th><th style="padding:.25rem .4rem">Portas</th><th style="padding:.25rem .4rem">Stateless</th><th style="padding:.25rem .4rem">Descrição</th></tr></thead><tbody>
|
||
${ingress.map(r=>`<tr style="border-top:1px solid var(--bd)"><td style="padding:.25rem .4rem;font-weight:600">${r.protocol}</td><td style="padding:.25rem .4rem;font-family:var(--fm);font-size:.62rem">${r.source_dest}</td><td style="padding:.25rem .4rem">${r.ports||'ALL'}</td><td style="padding:.25rem .4rem">${r.stateless?'Sim':'Não'}</td><td style="padding:.25rem .4rem;color:var(--t4);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${(r.description||'').replace(/"/g,'"')}">${r.description||'—'}</td></tr>`).join('')}
|
||
</tbody></table>`:''}
|
||
${egress.length?`<div style="font-size:.68rem;font-weight:600;color:var(--t2);margin:.4rem 0 .2rem">↑ Egress (${egress.length})</div>
|
||
<table style="width:100%;font-size:.66rem;border-collapse:collapse"><thead><tr style="background:var(--bg3);text-align:left"><th style="padding:.25rem .4rem">Protocolo</th><th style="padding:.25rem .4rem">Destino</th><th style="padding:.25rem .4rem">Portas</th><th style="padding:.25rem .4rem">Stateless</th><th style="padding:.25rem .4rem">Descrição</th></tr></thead><tbody>
|
||
${egress.map(r=>`<tr style="border-top:1px solid var(--bd)"><td style="padding:.25rem .4rem;font-weight:600">${r.protocol}</td><td style="padding:.25rem .4rem;font-family:var(--fm);font-size:.62rem">${r.source_dest}</td><td style="padding:.25rem .4rem">${r.ports||'ALL'}</td><td style="padding:.25rem .4rem">${r.stateless?'Sim':'Não'}</td><td style="padding:.25rem .4rem;color:var(--t4);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${(r.description||'').replace(/"/g,'"')}">${r.description||'—'}</td></tr>`).join('')}
|
||
</tbody></table>`:''}</div>`:''}</div>`}).join('')}`}
|
||
function rExpRouteTables(d){
|
||
return`<div style="font-size:.74rem;color:var(--t4);margin-bottom:.5rem">${d.length} route table(s)</div>${d.map((rt,idx)=>{
|
||
const routes=rt.routes||[];
|
||
const expanded=S._expExpanded&&S._expExpanded['rt'+idx];
|
||
return`<div class="exp-c" style="max-width:100%"><div style="display:flex;justify-content:space-between;align-items:center;cursor:pointer" onclick="S._expExpanded=S._expExpanded||{};S._expExpanded['rt'+${idx}]=!S._expExpanded['rt'+${idx}];R()">
|
||
<div><strong style="display:inline">${rt.display_name}</strong> <span class="badge ${rt.lifecycle_state==='AVAILABLE'?'b-pass':'b-pend'}">${rt.lifecycle_state}</span></div>
|
||
<div style="font-size:.66rem;color:var(--t4)">${expanded?'▼':'▶'} ${routes.length} rota(s)</div></div>
|
||
<div class="em" style="margin-top:.3rem">id: ${rt.id}</div>
|
||
${expanded&&routes.length?`<div style="margin-top:.5rem">
|
||
<table style="width:100%;font-size:.66rem;border-collapse:collapse"><thead><tr style="background:var(--bg3);text-align:left"><th style="padding:.25rem .4rem">Destino</th><th style="padding:.25rem .4rem">Tipo</th><th style="padding:.25rem .4rem">Target</th><th style="padding:.25rem .4rem">Descrição</th></tr></thead><tbody>
|
||
${routes.map(r=>`<tr style="border-top:1px solid var(--bd)"><td style="padding:.25rem .4rem;font-family:var(--fm);font-size:.62rem;font-weight:600">${r.destination}</td><td style="padding:.25rem .4rem">${r.destination_type}</td><td style="padding:.25rem .4rem;font-size:.6rem;color:var(--t3)">${r.target_type}<br><span class="em">${r.target_id}</span></td><td style="padding:.25rem .4rem;color:var(--t4);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${(r.description||'').replace(/"/g,'"')}">${r.description||'—'}</td></tr>`).join('')}
|
||
</tbody></table></div>`:expanded?'<div style="font-size:.7rem;color:var(--t4);padding:.4rem">Nenhuma rota configurada</div>':''}</div>`}).join('')}`}
|
||
|
||
/* ── Report ── */
|
||
function rReport(){const comp=S.reports.filter(r=>r.status==='completed');
|
||
const running=S.reports.filter(r=>r.status==='running');
|
||
let progressHtml='';
|
||
if(S.trackingReportId||running.length){
|
||
if(!S.trackingReportId&&running.length)S.trackingReportId=running[0].id;
|
||
const rr=running.find(r=>r.id===S.trackingReportId);
|
||
const lines=(rr?.progress||'').split('\n').filter(Boolean);
|
||
const last=lines[lines.length-1]||'Iniciando...';
|
||
const m=last.match(/\[(\d+)\/(\d+)\]/);const pct=m?Math.round((parseInt(m[1])/parseInt(m[2]))*100):0;
|
||
progressHtml=`<div id="rpt-progress">${rProgressCard('running',last,pct,lines,S.trackingReportId)}</div>`;
|
||
if(!_rptPollTimer)startRptPoll()}
|
||
if(!comp.length)return`${progressHtml}<div class="cd"><div class="ct">📊 Compliance Reports</div><div class="emp"><div class="eic">📄</div><p>Nenhum relatório gerado ainda.</p></div></div>${rRunRpt()}${rReportHistory()}`;
|
||
const lt=comp[0];
|
||
const selRpt=S.rptRselVal?comp.find(r=>r.id===S.rptRselVal):lt;
|
||
if(!S.rptRselVal)S.rptRselVal=lt.id;
|
||
const rselF=S.rptRselFilter.toLowerCase();
|
||
const rselLabel=selRpt?`${selRpt.tenancy_name} — ${selRpt.created_at}`:'Selecione relatório...';
|
||
let rselDdItems=comp.filter(r=>!rselF||r.tenancy_name.toLowerCase().includes(rselF)||r.created_at.includes(rselF)).map(r=>`<div class="itm${S.rptRselVal===r.id?' sel':''}" onclick="rptPickRsel('${r.id}')">${r.tenancy_name} — ${r.created_at}</div>`).join('');
|
||
return`${progressHtml}<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.55rem"><span style="font-size:.78rem;color:var(--t3)">Report HTML — ${(selRpt||lt).tenancy_name}</span>
|
||
<div class="dd" style="position:relative;max-width:340px;flex:1;margin-left:.5rem"><div onclick="S.rptRselOpen=!S.rptRselOpen;R()" style="display:flex;align-items:center;cursor:pointer;padding:.3rem .65rem;background:var(--bg2);border:1.5px solid var(--bd);border-radius:10px;font-size:.74rem;justify-content:space-between;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||
<span style="overflow:hidden;text-overflow:ellipsis">${rselLabel}</span><span style="font-size:.6rem;color:var(--t4);margin-left:.3rem">▼</span></div>
|
||
${S.rptRselOpen?`<div class="mdrop-list" style="position:absolute;top:100%;left:0;right:0;background:var(--bg);border:1px solid var(--b3);border-radius:6px;max-height:220px;overflow:auto;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.15)">
|
||
<div style="padding:.3rem;border-bottom:1px solid var(--b2)"><input type="text" placeholder="Buscar relatório..." value="${S.rptRselFilter}" oninput="S.rptRselFilter=this.value;R()" style="width:100%;border:none;outline:none;font-size:.75rem;padding:.25rem .4rem;background:transparent;color:var(--t1)" onclick="event.stopPropagation()"></div>
|
||
${rselDdItems||'<div style="padding:.5rem;color:var(--t4);font-size:.72rem">Nenhum relatório encontrado</div>'}
|
||
</div>`:''}
|
||
</div></div>
|
||
<iframe class="rif" id="rfr" src="${API}/reports/${(selRpt||lt).id}/html"></iframe>${rRunRpt()}${rReportHistory()}`}
|
||
function rRunRpt(){if(S.user?.role==='viewer')return'';
|
||
const selTags=S.rptSelRegions.map(r=>`<span class="tag" style="background:var(--b2);font-size:.62rem;display:inline-flex;align-items:center;gap:.2rem">${r}<span onclick="rptRemoveRegion('${r}')" style="cursor:pointer;font-weight:700;color:var(--err)">×</span></span>`).join(' ');
|
||
const f=S.rptRegionFilter.toLowerCase();
|
||
let ddItems='';
|
||
for(const[grp,regs] of Object.entries(S.ociRegions)){
|
||
const filtered=regs.filter(r=>!S.rptSelRegions.includes(r)&&(!f||r.includes(f)));
|
||
if(!filtered.length)continue;
|
||
ddItems+=`<div class="grp">${grp}</div>`;
|
||
filtered.forEach(r=>{ddItems+=`<div class="itm" onclick="rptPickRegion('${r}')">${r}</div>`})}
|
||
const ociF=S.rptOciFilter.toLowerCase();
|
||
const selOci=S.ociCfg.find(c=>c.id===S.rptOciVal);
|
||
const ociLabel=selOci?`${selOci.tenancy_name} (${selOci.region})`:'Selecione config OCI...';
|
||
let ociDdItems=S.ociCfg.filter(c=>!ociF||c.tenancy_name.toLowerCase().includes(ociF)||c.region.toLowerCase().includes(ociF)).map(c=>`<div class="itm${S.rptOciVal===c.id?' sel':''}" onclick="rptPickOci('${c.id}')">${c.tenancy_name} (${c.region})</div>`).join('');
|
||
return`<div class="cd" style="margin-top:.85rem"><div class="ct">🚀 Executar Relatório</div>
|
||
<div class="g3"><div class="ig"><label>Config OCI</label>
|
||
<div class="dd" style="position:relative"><div onclick="S.rptOciOpen=!S.rptOciOpen;R()" style="min-height:34px;display:flex;align-items:center;cursor:pointer;padding:.35rem .65rem;background:var(--bg2);border:1.5px solid var(--bd);border-radius:10px;font-size:.78rem;justify-content:space-between">
|
||
<span style="${selOci?'':'color:var(--t4)'}">${ociLabel}</span><span style="font-size:.6rem;color:var(--t4)">▼</span></div>
|
||
${S.rptOciOpen?`<div class="mdrop-list" style="position:absolute;top:100%;left:0;right:0;background:var(--bg);border:1px solid var(--b3);border-radius:6px;max-height:220px;overflow:auto;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.15)">
|
||
<div style="padding:.3rem;border-bottom:1px solid var(--b2)"><input type="text" placeholder="Buscar credencial..." value="${S.rptOciFilter}" oninput="S.rptOciFilter=this.value;R()" style="width:100%;border:none;outline:none;font-size:.75rem;padding:.25rem .4rem;background:transparent;color:var(--t1)" onclick="event.stopPropagation()"></div>
|
||
${ociDdItems||'<div style="padding:.5rem;color:var(--t4);font-size:.72rem">Nenhuma credencial encontrada</div>'}
|
||
</div>`:''}
|
||
</div></div>
|
||
<div class="ig"><label>CIS Level</label><select onchange="S.rptLevel=parseInt(this.value);R()">
|
||
<option value="1" ${S.rptLevel===1?'selected':''}>Level 1</option>
|
||
<option value="2" ${S.rptLevel===2?'selected':''}>Level 2 (default)</option></select>
|
||
<div style="margin-top:.35rem;font-size:.68rem;color:var(--t4);line-height:1.45">
|
||
${S.rptLevel===1?'<b>Level 1</b> — Controles essenciais de segurança com impacto mínimo nas operações. Recomendado como baseline para todas as organizações.':'<b>Level 2</b> — Controles avançados que podem restringir funcionalidades ou exigir mais esforço. Recomendado para ambientes de alta segurança.'}
|
||
</div></div>
|
||
<div class="ig"><label>Regiões (opcional — vazio = todas)</label>
|
||
<div class="dd" style="position:relative"><div onclick="S.rptRegionsOpen=!S.rptRegionsOpen;R()" style="min-height:34px;display:flex;flex-wrap:wrap;gap:.25rem;align-items:center;cursor:pointer;padding:.35rem .65rem;background:var(--bg2);border:1.5px solid var(--bd);border-radius:10px;font-size:.78rem">
|
||
${S.rptSelRegions.length?selTags:'<span style="color:var(--t4);font-size:.78rem">Todas as regiões</span>'}</div>
|
||
${S.rptRegionsOpen?`<div class="mdrop-list" style="position:absolute;top:100%;left:0;right:0;background:var(--bg);border:1px solid var(--b3);border-radius:6px;max-height:260px;overflow:auto;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.15)">
|
||
<div style="padding:.3rem;border-bottom:1px solid var(--b2)"><input type="text" placeholder="Buscar região..." value="${S.rptRegionFilter}" oninput="S.rptRegionFilter=this.value;R()" style="width:100%;border:none;outline:none;font-size:.75rem;padding:.25rem .4rem;background:transparent;color:var(--t1)" onclick="event.stopPropagation()"></div>
|
||
${ddItems||'<div style="padding:.5rem;color:var(--t4);font-size:.72rem">Nenhuma região encontrada</div>'}
|
||
</div>`:''}
|
||
</div></div></div>
|
||
<div style="display:flex;gap:1.2rem;flex-wrap:wrap;margin-top:.5rem">
|
||
<label style="display:flex;align-items:center;gap:.35rem;font-size:.76rem;cursor:pointer"><input type="checkbox" ${S.rptObp?'checked':''} onchange="S.rptObp=this.checked"> OCI Best Practices (OBP)</label>
|
||
<label style="display:flex;align-items:center;gap:.35rem;font-size:.76rem;cursor:pointer"><input type="checkbox" ${S.rptRaw?'checked':''} onchange="S.rptRaw=this.checked"> Raw Data Output</label>
|
||
<label style="display:flex;align-items:center;gap:.35rem;font-size:.76rem;cursor:pointer"><input type="checkbox" ${S.rptRedact?'checked':''} onchange="S.rptRedact=this.checked"> Redact OCIDs</label>
|
||
</div>
|
||
<button class="btn bp" style="margin-top:.6rem" onclick="runRpt()">▶ Executar CIS Compliance</button></div>${rCisEngine()}`}
|
||
function rCisEngine(){
|
||
if(S.user?.role!=='admin')return '';
|
||
const v=S.cisVer;const cr=S.cisCheckResult;
|
||
let status='';
|
||
if(S.cisUpdating)status='<span style="color:var(--w);font-size:.74rem">⏳ Atualizando...</span>';
|
||
else if(cr){
|
||
if(cr.update_available)status=`<span style="color:var(--w);font-size:.74rem">Nova versão: <b>${cr.remote_version}</b> (${cr.remote_date})</span> <button class="btn bp" style="font-size:.7rem;padding:.2rem .6rem;margin-left:.4rem" onclick="cisDoUpdate()">Atualizar</button>`;
|
||
else status='<span style="color:var(--ok);font-size:.74rem">✓ Versão mais recente</span>';
|
||
}
|
||
return`<div class="cd" style="margin-top:.85rem"><div class="ct">⚙️ CIS Engine</div>
|
||
<div style="display:flex;align-items:center;gap:.8rem;flex-wrap:wrap">
|
||
<span style="font-size:.8rem">Versão: <b>${v?v.version:'...'}</b>${v&&v.updated_date!=='unknown'?' ('+v.updated_date+')':''}</span>
|
||
<button class="btn bs" style="font-size:.7rem;padding:.2rem .6rem" onclick="cisCheckUpdate()">Verificar Atualização</button>
|
||
${status}</div></div>`}
|
||
async function cisLoadVersion(){try{S.cisVer=await $api('/cis-engine/version');R()}catch(e){console.error('cisVer',e)}}
|
||
async function cisCheckUpdate(){S.cisCheckResult=null;R();try{S.cisCheckResult=await $api('/cis-engine/check-update');R()}catch(e){alert('Erro ao verificar: '+e.message)}}
|
||
async function cisDoUpdate(){if(!confirm('Atualizar CIS Engine para a versão mais recente do GitHub?'))return;S.cisUpdating=true;R();try{const d=await $api('/cis-engine/update',{method:'POST'});alert(`Atualizado: ${d.old_version} → ${d.new_version}\nPatches aplicados: ${d.patches_applied.join(', ')||'nenhum'}`);S.cisCheckResult=null;S.cisUpdating=false;await cisLoadVersion()}catch(e){S.cisUpdating=false;R();alert('Erro: '+e.message)}}
|
||
function rptPickRegion(r){S.rptSelRegions.push(r);S.rptRegionFilter='';R()}
|
||
function rptRemoveRegion(r){S.rptSelRegions=S.rptSelRegions.filter(x=>x!==r);R()}
|
||
function rptPickOci(id){S.rptOciVal=id;S.rptOciOpen=false;S.rptOciFilter='';R()}
|
||
function rptPickRsel(id){S.rptRselVal=id;S.rptRselOpen=false;S.rptRselFilter='';const f=document.getElementById('rfr');if(f)f.src=API+'/reports/'+id+'/html';R()}
|
||
async function runRpt(){if(S.reports.some(r=>r.status==='running'))return alert('Já existe um relatório em execução. Aguarde ou cancele antes de iniciar outro.');
|
||
const c=S.rptOciVal;if(!c)return alert('Selecione config OCI');
|
||
const rg=S.rptSelRegions;
|
||
S.rptRegionsOpen=false;
|
||
try{const d=await $api('/reports/run',{method:'POST',body:{config_id:c,regions:rg.length?rg:null,level:S.rptLevel,obp:S.rptObp,raw:S.rptRaw,redact_output:S.rptRedact}});
|
||
S.trackingReportId=d.report_id;S.rptSelRegions=[];S.rptOciVal='';S.reports=await $api('/reports');R();startRptPoll()}catch(e){alert('Erro: '+e.message)}}
|
||
let _rptPollTimer=null;
|
||
function startRptPoll(){stopRptPoll();_rptPollTimer=setInterval(pollRptProgress,3000)}
|
||
function stopRptPoll(){if(_rptPollTimer){clearInterval(_rptPollTimer);_rptPollTimer=null}}
|
||
async function pollRptProgress(){if(!S.trackingReportId)return stopRptPoll();
|
||
try{const p=await $api('/reports/'+S.trackingReportId+'/progress');
|
||
const el=document.getElementById('rpt-progress');
|
||
if(el){const lines=(p.progress||'').split('\n').filter(Boolean);
|
||
const last=lines[lines.length-1]||'Iniciando...';
|
||
const ft=(p.progress||'').toLowerCase();
|
||
let pct=5;
|
||
if(ft.includes('identity domains'))pct=10;
|
||
if(ft.includes('processing home region'))pct=20;
|
||
if(ft.includes('processing regional'))pct=35;
|
||
if(ft.includes('cis summary report'))pct=60;
|
||
if(ft.includes('writing cis reports'))pct=75;
|
||
if(ft.includes('best practices'))pct=85;
|
||
if(ft.includes('finished'))pct=100;
|
||
const wasOpen=el.querySelector('details[open]')!==null;
|
||
el.innerHTML=rProgressCard(p.status,last,pct,lines,S.trackingReportId);
|
||
if(wasOpen){const d=el.querySelector('details');if(d)d.open=true}}
|
||
if(p.status==='completed'||p.status==='failed'||p.status==='cancelled'){stopRptPoll();S.trackingReportId=null;S.reports=await $api('/reports');R()}}catch(e){}}
|
||
async function cancelRpt(rid){if(!confirm('Cancelar este relatório?'))return;
|
||
try{await $api('/reports/'+rid+'/cancel',{method:'POST'});stopRptPoll();S.trackingReportId=null;S.reports=await $api('/reports');R()}catch(e){alert('Erro: '+e.message)}}
|
||
function rProgressCard(status,lastStep,pct,lines,rid){
|
||
if(status==='completed')return'<div class="cd" style="margin-bottom:.75rem;border-left:3px solid var(--ok)"><div style="padding:.75rem"><strong style="color:var(--ok)">Relatório concluído!</strong></div></div>';
|
||
if(status==='failed')return'<div class="cd" style="margin-bottom:.75rem;border-left:3px solid var(--err)"><div style="padding:.75rem"><strong style="color:var(--err)">Relatório falhou</strong><p style="font-size:.72rem;color:var(--t4);margin-top:.25rem">'+lastStep+'</p></div></div>';
|
||
if(status==='cancelled')return'<div class="cd" style="margin-bottom:.75rem;border-left:3px solid var(--t4)"><div style="padding:.75rem"><strong style="color:var(--t4)">Relatório cancelado</strong></div></div>';
|
||
const cancelBtn=rid?` <button class="btn bs bsm" style="color:var(--err);border-color:var(--err);font-size:.68rem;padding:.15rem .5rem" onclick="cancelRpt('${rid}')">Cancelar</button>`:'';
|
||
return`<div class="cd" style="margin-bottom:.75rem;border-left:3px solid var(--ac)"><div style="padding:.75rem">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.4rem"><strong style="font-size:.82rem">Gerando relatório...${cancelBtn}</strong><span style="font-size:.72rem;color:var(--t4)">${pct}%</span></div>
|
||
<div style="background:var(--b2);border-radius:6px;height:8px;overflow:hidden;margin-bottom:.4rem"><div style="background:var(--ac);height:100%;border-radius:6px;width:${pct}%;transition:width .5s ease"></div></div>
|
||
<p style="font-size:.72rem;color:var(--t3);margin:0">${lastStep}</p>
|
||
${lines.length>1?`<details style="margin-top:.35rem"><summary style="font-size:.68rem;color:var(--t4);cursor:pointer">Ver log completo</summary><pre style="font-size:.62rem;color:var(--t4);margin-top:.25rem;max-height:120px;overflow:auto;white-space:pre-wrap">${lines.join('\n')}</pre></details>`:''}
|
||
</div></div>`}
|
||
|
||
/* ── Report History (inside Reports tab) ── */
|
||
function rReportHistory(){
|
||
const tenancies=[...new Set(S.reports.map(r=>r.tenancy_name))].sort();
|
||
const filtered=S.rptHistFilter?S.reports.filter(r=>r.tenancy_name===S.rptHistFilter):S.reports;
|
||
if(!filtered.length&&!S.reports.length)return'';
|
||
return`<div class="cd" style="margin-top:.85rem"><div class="ct">📋 Histórico de Relatórios</div>
|
||
<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem">
|
||
<select style="font-size:.76rem;min-width:180px" onchange="S.rptHistFilter=this.value;R()">
|
||
<option value="">Todas as Tenancies (${S.reports.length})</option>
|
||
${tenancies.map(t=>`<option value="${t}" ${S.rptHistFilter===t?'selected':''}>${t} (${S.reports.filter(r=>r.tenancy_name===t).length})</option>`).join('')}
|
||
</select>
|
||
<span style="font-size:.72rem;color:var(--t4)">${filtered.length} report(s)</span></div>
|
||
<table><thead><tr><th>Tenancy</th><th>Level</th><th>Opções</th><th>Status</th><th>Executado</th><th>Concluído</th><th>Ações</th></tr></thead>
|
||
<tbody>${filtered.map(r=>{
|
||
const opts=[];if(r.obp_checks)opts.push('OBP');if(r.raw_data)opts.push('Raw');if(r.redact_output)opts.push('Redact');
|
||
return`<tr><td><strong>${r.tenancy_name}</strong></td>
|
||
<td style="font-size:.72rem">${r.level||2}</td>
|
||
<td style="font-size:.68rem">${opts.join(', ')||'—'}</td>
|
||
<td><span class="badge ${r.status==='completed'?'b-pass':r.status==='failed'||r.status==='cancelled'?'b-fail':'b-pend'}">${r.status==='cancelled'?'cancelado':r.status}</span></td>
|
||
<td style="font-size:.72rem">${r.created_at||'—'}</td>
|
||
<td style="font-size:.72rem">${r.completed_at||'—'}</td>
|
||
<td>${r.status==='completed'?`<button class="btn bs bsm" onclick="dlRpt('${r.id}','html')">HTML</button> <button class="btn bs bsm" onclick="dlRpt('${r.id}','json')">JSON</button>`:r.status==='running'?`<button class="btn bs bsm" style="color:var(--err);border-color:var(--err)" onclick="cancelRpt('${r.id}')">Cancelar</button>`:'—'}</td></tr>`}).join('')}</tbody></table>
|
||
${!filtered.length?'<div class="emp" style="padding:.5rem"><p style="font-size:.72rem;color:var(--t4)">Nenhum relatório.</p></div>':''}</div>`}
|
||
|
||
/* ── Downloads (file browser only) ── */
|
||
function _dlFmtSize(b){if(b>=1048576)return(b/1048576).toFixed(1)+' MB';if(b>=1024)return(b/1024).toFixed(1)+' KB';return b+' B'}
|
||
function _dlFileIcon(name){
|
||
const ext=name.split('.').pop().toLowerCase();
|
||
const colors={html:'#e44d26',htm:'#e44d26',csv:'#217346',json:'#f5a623',xlsx:'#217346',xls:'#217346',txt:'#6b7280',pdf:'#dc2626',png:'#8b5cf6',svg:'#8b5cf6'};
|
||
const c=colors[ext]||'#c74634';
|
||
return`<div style="width:28px;height:28px;border-radius:6px;background:${c}14;display:flex;align-items:center;justify-content:center;flex-shrink:0"><span style="font-size:.58rem;font-weight:700;color:${c};text-transform:uppercase;letter-spacing:.3px">${ext}</span></div>`;
|
||
}
|
||
function rDl(){
|
||
const comp=S.reports.filter(r=>r.status==='completed');
|
||
const tenancies=[...new Set(comp.map(r=>r.tenancy_name))].sort();
|
||
const filtered=S.dlTenancyFilter?comp.filter(r=>r.tenancy_name===S.dlTenancyFilter):comp;
|
||
return`<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.55rem;gap:.5rem;flex-wrap:wrap">
|
||
<div style="display:flex;align-items:center;gap:.5rem">
|
||
<select style="font-size:.76rem;min-width:180px" onchange="S.dlTenancyFilter=this.value;S.dlExpandedRid=null;R()">
|
||
<option value="">Todas as Tenancies (${comp.length})</option>
|
||
${tenancies.map(t=>`<option value="${t}" ${S.dlTenancyFilter===t?'selected':''}>${t} (${comp.filter(r=>r.tenancy_name===t).length})</option>`).join('')}
|
||
</select>
|
||
<span style="font-size:.72rem;color:var(--t4)">${filtered.length} report(s)</span></div>
|
||
<button class="btn bs bsm" onclick="refreshDl()">🔄 Atualizar</button></div>
|
||
${!filtered.length?'<div class="cd"><div class="emp"><div class="eic">📂</div><p>Nenhum relatório concluído.</p></div></div>':`
|
||
<div style="display:flex;flex-direction:column;gap:.5rem">${filtered.map(r=>{
|
||
const isExp=S.dlExpandedRid===r.id;
|
||
const fHtml=isExp&&S.reportFiles[r.id]?renderFileList(r.id,S.reportFiles[r.id]):'';
|
||
const fCount=S.reportFiles[r.id]?S.reportFiles[r.id].length:null;
|
||
return`<div class="cd" style="padding:0;overflow:hidden">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;padding:.55rem .8rem;cursor:pointer;transition:background .15s" onmouseover="this.style.background='var(--bg2)'" onmouseout="this.style.background=''" onclick="toggleFiles('${r.id}')">
|
||
<div style="display:flex;align-items:center;gap:.5rem">
|
||
<svg viewBox="0 0 16 16" width="14" height="14" fill="var(--ac)" style="flex-shrink:0;transition:transform .2s;transform:rotate(${isExp?'90':'0'}deg)"><path d="M6 3l5 5-5 5z"/></svg>
|
||
<div><strong style="font-size:.78rem">${r.tenancy_name}</strong>
|
||
<div style="font-size:.66rem;color:var(--t4);margin-top:1px">Level ${r.level||2} · ${r.created_at}${fCount!=null?' · '+fCount+' arquivo(s)':''}</div></div></div>
|
||
<span style="font-size:.66rem;color:var(--t4)">${isExp?'Ocultar':'Ver Arquivos'}</span></div>
|
||
${isExp?fHtml:''}</div>`}).join('')}</div>`}`}
|
||
async function toggleFiles(rid){if(S.dlExpandedRid===rid){S.dlExpandedRid=null;R();return}
|
||
S.dlExpandedRid=rid;if(!S.reportFiles[rid]){try{S.reportFiles[rid]=await $api('/reports/'+rid+'/files')}catch(e){S.reportFiles[rid]=[]}}R()}
|
||
function renderFileList(rid,files){if(!files.length)return'<div style="padding:.8rem;color:var(--t4);font-size:.72rem;text-align:center">Nenhum arquivo encontrado</div>';
|
||
const groups={};files.forEach(f=>{if(!groups[f.file_category])groups[f.file_category]=[];groups[f.file_category].push(f)});
|
||
const labels={summary:'Summary Reports',cis_finding:'CIS Findings',obp_finding:'OBP Findings',obp_best_practice:'OBP Best Practices',raw_data:'Raw Data',error:'Error Reports',diagram:'Diagrams',consolidated:'Consolidated',other:'Other'};
|
||
const icons={summary:'📊',cis_finding:'🛡️',obp_finding:'⚠️',obp_best_practice:'✅',raw_data:'📋',error:'❌',diagram:'📈',consolidated:'📑',other:'📄'};
|
||
let h='<div style="border-top:1px solid var(--bd);background:var(--bg2)">';
|
||
for(const[cat,cf] of Object.entries(groups)){
|
||
h+=`<div style="padding:.4rem .8rem .1rem"><div style="font-size:.64rem;font-weight:600;color:var(--t4);text-transform:uppercase;letter-spacing:.4px;margin-bottom:.25rem">${icons[cat]||'📄'} ${labels[cat]||cat} (${cf.length})</div></div>`;
|
||
cf.forEach(f=>{
|
||
h+=`<a href="${API}/reports/${rid}/files/${f.id}/download" target="_blank" style="display:flex;align-items:center;gap:.5rem;padding:.35rem .8rem;text-decoration:none;color:inherit;transition:background .12s;cursor:pointer" onmouseover="this.style.background='var(--bg3)'" onmouseout="this.style.background=''">
|
||
${_dlFileIcon(f.file_name)}
|
||
<div style="flex:1;min-width:0;overflow:hidden"><div style="font-size:.72rem;font-weight:500;color:var(--t1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${f.file_name}</div></div>
|
||
<span style="font-size:.62rem;color:var(--t4);flex-shrink:0">${_dlFmtSize(f.file_size)}</span>
|
||
<svg viewBox="0 0 16 16" width="12" height="12" style="flex-shrink:0;opacity:.35"><path d="M8 1v9m0 0L5 7m3 3l3-3M2 12v1.5A1.5 1.5 0 003.5 15h9a1.5 1.5 0 001.5-1.5V12" stroke="var(--t3)" stroke-width="1.5" fill="none" stroke-linecap="round"/></svg></a>`});
|
||
}
|
||
h+='</div>';return h}
|
||
function dlRpt(id,f){window.open(API+'/reports/'+id+'/download?fmt='+f,'_blank')}
|
||
async function refreshDl(){S.reports=await $api('/reports');S.reportFiles={};S.dlExpandedRid=null;R()}
|
||
|
||
/* ── Edit helpers ── */
|
||
function editCfg(type,id){S.editing={type,id};if(type==='oci'){const c=S.ociCfg.find(x=>x.id===id);S.ociFormRegVal=c?c.region:'';S.ociFormRegFilter=''}R();
|
||
const form=document.querySelector('.cd:has(#obtn), .cd:has(#gbtn), .cd:has(#abtn), .cd:has(#mcbtn)');if(form)form.scrollIntoView({behavior:'smooth',block:'start'})}
|
||
function cancelEdit(){S.editing=null;S.ociFormRegVal='';S.ociFormRegFilter='';R()}
|
||
|
||
/* ── OCI Config ── */
|
||
function rOciRegionDd(eo){
|
||
if(eo&&!S.ociFormRegVal)S.ociFormRegVal=eo.region||'';
|
||
const val=S.ociFormRegVal;const f=S.ociFormRegFilter.toLowerCase();
|
||
let ddItems='';
|
||
for(const[grp,regs] of Object.entries(S.ociRegions)){
|
||
const filtered=regs.filter(r=>!f||r.includes(f));
|
||
if(!filtered.length)continue;
|
||
ddItems+=`<div class="grp">${grp}</div>`;
|
||
filtered.forEach(r=>{ddItems+=`<div class="itm${val===r?' sel':''}" onclick="ociFormPickReg('${r}')">${r}</div>`})}
|
||
return`<div class="dd" style="position:relative"><div onclick="S.ociFormRegOpen=!S.ociFormRegOpen;R()" style="min-height:34px;display:flex;align-items:center;cursor:pointer;padding:.35rem .65rem;background:var(--bg2);border:1.5px solid var(--bd);border-radius:10px;font-size:.78rem;justify-content:space-between">
|
||
<span style="${val?'':'color:var(--t4)'}">${val||'Selecione a região...'}</span><span style="font-size:.6rem;color:var(--t4)">▼</span></div>
|
||
${S.ociFormRegOpen?`<div class="mdrop-list" style="position:absolute;top:100%;left:0;right:0;background:var(--bg);border:1px solid var(--b3);border-radius:6px;max-height:260px;overflow:auto;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.15)">
|
||
<div style="padding:.3rem;border-bottom:1px solid var(--b2)"><input type="text" placeholder="Buscar região..." value="${S.ociFormRegFilter}" oninput="S.ociFormRegFilter=this.value;R()" style="width:100%;border:none;outline:none;font-size:.75rem;padding:.25rem .4rem;background:transparent;color:var(--t1)" onclick="event.stopPropagation()"></div>
|
||
${ddItems||'<div style="padding:.5rem;color:var(--t4);font-size:.72rem">Nenhuma região encontrada</div>'}
|
||
</div>`:''}</div>`}
|
||
function ociFormPickReg(r){S.ociFormRegVal=r;S.ociFormRegOpen=false;S.ociFormRegFilter='';R()}
|
||
function rOci(){const eo=S.editing?.type==='oci'?S.ociCfg.find(c=>c.id===S.editing.id):null;
|
||
return`<div class="cd"><div class="ct">☁️ Credenciais Registradas</div><div id="ocm"></div>
|
||
<table><thead><tr><th>Tenancy</th><th>Region</th><th>Detalhes</th><th>Ações</th></tr></thead><tbody>
|
||
${S.ociCfg.map(c=>`<tr${eo&&eo.id===c.id?' style="background:var(--bg3)"':''}><td><strong>${c.tenancy_name}</strong><br><span style="font-size:.66rem;color:var(--t4)">${c.created_at}</span></td><td><span class="tag">${c.region}</span></td>
|
||
<td style="font-family:var(--fm);font-size:.66rem;color:var(--t4);line-height:1.6"><span title="Fingerprint">🔑 ••••••••••••••••••••</span><br><span title="Tenancy OCID">🏢 ${c.tenancy_ocid}</span><br><span title="User OCID">👤 ${c.user_ocid}</span><br><span title="Compartment">📦 ${c.compartment_id||'—'}</span></td>
|
||
<td><button class="btn bs bsm" onclick="editCfg('oci','${c.id}')">Editar</button> <button class="btn bs bsm" onclick="tOci('${c.id}')">Testar</button> <button class="btn bd bsm" onclick="dOci('${c.id}')">Excluir</button></td></tr>`).join('')}</tbody></table>
|
||
${!S.ociCfg.length?'<div class="emp" style="padding:.85rem"><p>Nenhuma credencial registrada.</p></div>':''}</div>
|
||
<div class="cd"><div class="ct">${eo?'✏️ Editar Credencial':'➕ Nova Credencial'}</div>
|
||
<div class="cdesc">${eo?'Editando: <strong>'+eo.tenancy_name+'</strong>':'Adicione as credenciais da sua conta OCI para executar reports e acessar serviços.'}</div><div id="om"></div>
|
||
<div class="g2">
|
||
<div class="ig"><label>Tenancy Name</label><input type="text" id="otn" placeholder="minha-empresa" value="${eo?eo.tenancy_name:''}"></div>
|
||
<div class="ig"><label>OCID User</label><input type="password" id="ouo" placeholder="ocid1.user.oc1.." value="${eo?'':''}" autocomplete="off">${eo?'<div class="ht">Preencha para alterar. Atual: <code>'+eo.user_ocid+'</code></div>':''}</div>
|
||
<div class="ig"><label>Fingerprint</label><input type="password" id="ofp" placeholder="aa:bb:cc:dd:..." value="${eo?'':''}" autocomplete="off">${eo?'<div class="ht">Preencha para alterar. Atual: <code>'+eo.fingerprint+'</code></div>':''}</div>
|
||
<div class="ig"><label>OCID Tenancy</label><input type="password" id="oto" placeholder="ocid1.tenancy.oc1.." value="${eo?'':''}" autocomplete="off">${eo?'<div class="ht">Preencha para alterar. Atual: <code>'+eo.tenancy_ocid+'</code></div>':''}</div>
|
||
<div class="ig"><label>Region</label>${rOciRegionDd(eo)}</div>
|
||
<div class="ig"><label>Compartment OCID</label><input type="password" id="ocp" placeholder="ocid1.compartment.oc1.." value="${eo?'':''}" autocomplete="off">${eo?'<div class="ht">Preencha para alterar. Atual: <code>'+(eo.compartment_id||'—')+'</code></div>':''}</div></div>
|
||
<div class="g2"><div class="ig"><label>Private Key (.pem)</label>${eo?'<div class="ht">Deixe vazio para manter a chave atual</div>':''}<input type="file" id="osk" accept=".pem"></div>
|
||
<div class="ig"><label>Public Key (.pem)</label><input type="file" id="opk" accept=".pem"></div></div>
|
||
<div class="g2"><div class="ig"><label>Key Passphrase</label><div class="ht">Apenas se a chave privada for protegida por senha</div><input type="password" id="okp" placeholder="(opcional)"></div>
|
||
<div class="ig"></div></div>
|
||
<div style="display:flex;gap:8px"><button class="btn bp" id="obtn" onclick="sOci()">${eo?'Salvar Alterações':'Salvar Credencial'}</button>${eo?'<button class="btn bs" onclick="cancelEdit()">Cancelar</button>':''}</div></div>
|
||
`+rConfigLogs('oci')}
|
||
async function sOci(){const tn=document.getElementById('otn').value;
|
||
if(!tn)return sm('om','Preencha o Tenancy Name','e');
|
||
const fd=new FormData();fd.append('tenancy_name',tn);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',S.ociFormRegVal||'');fd.append('compartment_id',document.getElementById('ocp').value);
|
||
fd.append('key_passphrase',document.getElementById('okp').value||'');
|
||
const sk=document.getElementById('osk').files[0];
|
||
const isEdit=S.editing?.type==='oci';
|
||
if(!isEdit){
|
||
if(!document.getElementById('oto').value||!document.getElementById('ouo').value||!document.getElementById('ofp').value)return sm('om','Preencha OCID Tenancy, OCID User e Fingerprint','e');
|
||
if(!sk)return sm('om','Selecione a chave privada','e');
|
||
}
|
||
if(sk)fd.append('private_key',sk);
|
||
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','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Salvando credencial...','i');
|
||
try{const url=isEdit?'/oci/configs/'+S.editing.id:'/oci/config';const method=isEdit?'PUT':'POST';
|
||
await $api(url,{method,body:fd,headers:{}});S.editing=null;S.ociFormRegVal='';S.ociFormRegFilter='';S.ociCfg=await $api('/oci/configs');sm('om','✅ Credencial '+(isEdit?'atualizada':'salva')+' com sucesso!','s');R()}catch(e){sm('om',e.message,'e');btn.disabled=false;btn.textContent=isEdit?'Salvar Alterações':'Salvar Credencial'}}
|
||
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 ── */
|
||
function rGenAI(){const eg=S.editing?.type==='genai'?S.genaiCfg.find(g=>g.id===S.editing.id):null;
|
||
const defModel='openai.gpt-4.1';
|
||
const mOpts='<option value="_custom"'+(eg&&eg.model_ocid?' selected':'')+'>🔗 Personalizado (usar OCID)</option>'+Object.entries(S.models).map(([k,v])=>`<option value="${k}"${eg?(eg.model_id===k&&!eg.model_ocid?' selected':''):(k===defModel?' selected':'')}>${v.name} (${v.provider})</option>`).join('');
|
||
const rOpts=S.regions.map(r=>`<option value="${r}" ${eg?(r===eg.genai_region?'selected':''):(r==='us-ashburn-1'?'selected':'')}>${r}</option>`).join('');
|
||
return`<div class="cd"><div class="ct">🧠 Modelos Configurados</div>
|
||
<table><thead><tr><th>Config</th><th>Modelo</th><th>Região</th><th>Ações</th></tr></thead><tbody>
|
||
${S.genaiCfg.map(g=>{const mi=S.models[g.model_id]||{};return`<tr${eg&&eg.id===g.id?' style="background:var(--bg3)"':''}><td><strong>${g.name||'—'}</strong>${g.is_default?' ⭐':''}</td>
|
||
<td>${mi.name||(g.model_ocid?'OCID Personalizado':g.model_id)}<br><span class="tag">${mi.provider||(g.model_ocid?'custom':'?')}</span></td>
|
||
<td style="font-family:var(--fm);font-size:.7rem">${g.genai_region}</td>
|
||
<td><button class="btn bs bsm" onclick="editCfg('genai','${g.id}')">Editar</button> <button class="btn bs bsm" onclick="tGenai('${g.id}')">Testar</button> <button class="btn bd bsm" onclick="dGenai('${g.id}')">Excluir</button></td></tr>`}).join('')}</tbody></table>
|
||
${!S.genaiCfg.length?'<div class="emp" style="padding:.85rem"><p>Nenhum modelo configurado.</p></div>':''}</div>
|
||
<div class="cd"><div class="ct">💬 System Prompts — Chat Agent</div>
|
||
<div class="cdesc">Prompts de sistema para o <strong>Chat Agent</strong>. O prompt <strong>ativo</strong> é enviado como instrução do sistema em todas as mensagens. Máximo 10 prompts. Outros agents (ex.: provisionamento) terão seus próprios prompts.</div><div id="spm"></div>
|
||
<table><thead><tr><th>Nome</th><th>Status</th><th>Ações</th></tr></thead><tbody>
|
||
${(S.chatPrompts||[]).map(p=>`<tr${S.editingPrompt===p.id?' style="background:var(--bg3)"':''}><td><strong>${p.name}</strong></td>
|
||
<td>${p.is_active?'<span class="tag" style="background:var(--gnl);color:var(--gn)">Ativo</span>':'<span class="tag">Inativo</span>'}</td>
|
||
<td><button class="btn bs bsm" onclick="editPrompt('${p.id}')">Editar</button> ${!p.is_active?`<button class="btn bs bsm" onclick="activatePrompt('${p.id}')">Ativar</button>`:''} <button class="btn bd bsm" onclick="delPrompt('${p.id}')">Excluir</button></td></tr>`).join('')}</tbody></table>
|
||
${!(S.chatPrompts||[]).length?'<div class="emp" style="padding:.6rem"><p>Nenhum prompt configurado.</p></div>':''}
|
||
<div style="margin-top:10px"><div class="g3"><div class="ig" style="grid-column:1/-1"><label>${S.editingPrompt?'Editar Prompt':'Novo Prompt'}</label><input type="text" id="spname" placeholder="Nome do prompt" value="${S.editingPrompt?(S.chatPrompts||[]).find(p=>p.id===S.editingPrompt)?.name||'':''}"></div></div>
|
||
<textarea id="gsysp" rows="10" style="width:100%;font-family:var(--fm);font-size:.78rem;resize:vertical;border:1px solid var(--bd);border-radius:6px;padding:10px;background:var(--bg2);color:var(--t1);line-height:1.5" placeholder="Você é um assistente especializado em...">${S.editingPrompt?((S.chatPrompts||[]).find(x=>x.id===S.editingPrompt)||{}).content||'':''}</textarea>
|
||
<div style="display:flex;gap:8px;margin-top:8px"><button class="btn bp" onclick="savePrompt()">${S.editingPrompt?'Salvar Alterações':'Criar Prompt'}</button>${S.editingPrompt?'<button class="btn bs" onclick="S.editingPrompt=null;R()">Cancelar</button>':''}</div></div></div>
|
||
<div class="cd"><div class="ct">${eg?'✏️ Editar Modelo GenAI':'➕ Novo Modelo GenAI'}</div>
|
||
<div class="cdesc">${eg?'Editando: <strong>'+eg.name+'</strong>':'Adicione modelos personalizados que não estão no catálogo do Chat Agent, ou crie presets com credenciais específicas.'}</div><div id="gm"></div>
|
||
<div class="g3"><div class="ig"><label>Nome da Config</label><input type="text" id="gname" placeholder="Meu Modelo Custom" value="${eg?eg.name:''}"></div>
|
||
<div class="ig"><label>Credencial OCI</label><select id="goci" onchange="fillGenaiFromOci()"><option value="">Selecione...</option>
|
||
${S.ociCfg.map(c=>`<option value="${c.id}"${eg&&eg.oci_config_id===c.id?' selected':''}>${c.tenancy_name} (${c.region})</option>`).join('')}</select></div>
|
||
<div class="ig"><label>Modelo</label><select id="gmod">${mOpts}</select></div></div>
|
||
<div class="g3"><div class="ig"><label>Região GenAI</label><select id="greg">${rOpts}</select></div>
|
||
<div class="ig"><label>Compartment OCID</label><input type="text" id="gcmp" placeholder="ocid1.compartment.oc1.." value="${eg?eg.compartment_id:''}"></div>
|
||
<div class="ig"><label>Model OCID</label><div class="ht">Cole o OCID do modelo do portal OCI. Se preenchido, será usado no lugar do modelo selecionado acima.</div><input type="text" id="gocid" placeholder="ocid1.generativeaimodel.oc1.iad.amaaaaaa..." value="${eg&&eg.model_ocid?eg.model_ocid:''}" style="${eg&&eg.model_ocid?'border-color:var(--p)':''}"></div></div>
|
||
<div style="display:flex;gap:8px"><button class="btn bp" id="gbtn" onclick="sGenai()">${eg?'Salvar Alterações':'Salvar Modelo'}</button>${eg?'<button class="btn bs" onclick="cancelEdit()">Cancelar</button>':''}</div></div>
|
||
`+rConfigLogs('genai')}
|
||
async function sGenai(){const btn=document.getElementById('gbtn');btn.disabled=true;btn.textContent='Salvando...';sm('gm','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Salvando modelo...','i');
|
||
const isEdit=S.editing?.type==='genai';
|
||
const selModel=document.getElementById('gmod').value;const ocid=document.getElementById('gocid').value||null;
|
||
if(selModel==='_custom'&&!ocid){sm('gm','Informe o Model OCID para usar modelo personalizado.','e');btn.disabled=false;btn.textContent=isEdit?'Salvar Alterações':'Salvar Modelo';return}
|
||
const body={
|
||
name:document.getElementById('gname').value||'default',
|
||
oci_config_id:document.getElementById('goci').value,model_id:selModel==='_custom'?'custom':selModel,
|
||
model_ocid:ocid,
|
||
compartment_id:document.getElementById('gcmp').value,genai_region:document.getElementById('greg').value,
|
||
endpoint:null,serving_type:'ON_DEMAND',dedicated_endpoint_id:null,
|
||
is_default:isEdit?(S.genaiCfg.find(g=>g.id===S.editing.id)?.is_default||false):S.genaiCfg.length===0};
|
||
try{const url=isEdit?'/genai/configs/'+S.editing.id:'/genai/config';const method=isEdit?'PUT':'POST';
|
||
await $api(url,{method,body});S.editing=null;S.genaiCfg=await $api('/genai/configs');sm('gm','✅ Modelo '+(isEdit?'atualizado':'salvo')+' com sucesso!','s');R()}catch(e){sm('gm',e.message,'e');btn.disabled=false;btn.textContent=isEdit?'Salvar Alterações':'Salvar Modelo'}}
|
||
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<greg.options.length;i++){if(greg.options[i].value===cfg.region){greg.selectedIndex=i;break}}}
|
||
if(cfg.compartment_id)document.getElementById('gcmp').value=cfg.compartment_id;
|
||
sm('gm','Região e Compartment preenchidos da credencial <strong>'+cfg.tenancy_name+'</strong>','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 ── */
|
||
function mcpTypeFields(){const t=document.querySelector('input[name="mt"]:checked')?.value;
|
||
document.querySelectorAll('.mcp-f').forEach(el=>{el.style.display='none'});
|
||
if(t==='stdio'){document.getElementById('mf-cmd').style.display='';document.getElementById('mf-args').style.display=''}
|
||
if(t==='sse')document.getElementById('mf-url').style.display='';
|
||
if(t==='module'){document.getElementById('mf-cmd').style.display='';document.getElementById('mf-args').style.display=''}}
|
||
function rMCP(){const em=S.editing?.type==='mcp'?S.mcpSvr.find(m=>m.id===S.editing.id):null;
|
||
const adbOpts=S.adbCfg.map(a=>`<option value="${a.id}">${a.config_name}</option>`).join('');
|
||
return`<div class="cd"><div class="ct">🔌 Servidores Registrados</div>
|
||
<div class="cdesc">MCP servers disponíveis para integração com ferramentas e execução de tarefas.</div>
|
||
${S.mcpSvr.length?S.mcpSvr.map(m=>{const adb=m.linked_adb_id?S.adbCfg.find(a=>a.id===m.linked_adb_id):null;
|
||
return`<div style="border:1px solid ${em&&em.id===m.id?'var(--p)':'var(--bd)'};border-radius:var(--r);padding:1rem;margin-bottom:.75rem;background:var(--bg2)">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.65rem">
|
||
<div><strong style="font-size:.88rem">${m.name}</strong> <span class="tag" style="margin-left:.4rem">${m.server_type}</span>
|
||
<span class="badge ${m.is_active?'b-on':'b-off'}" style="margin-left:.35rem">${m.is_active?'Ativo':'Inativo'}</span></div>
|
||
<div style="display:flex;gap:.35rem"><button class="btn bs bsm" onclick="editCfg('mcp','${m.id}')">Editar</button> <button class="btn bs bsm" onclick="tgMcp('${m.id}')">${m.is_active?'Desativar':'Ativar'}</button>
|
||
<button class="btn bd bsm" onclick="dMcp('${m.id}')">Excluir</button></div></div>
|
||
${m.description?`<div style="font-size:.72rem;color:var(--t3);margin-bottom:.5rem">${m.description}</div>`:''}
|
||
<div style="display:flex;gap:1.5rem;flex-wrap:wrap;font-size:.72rem;color:var(--t4)">
|
||
${m.command?`<div><span style="color:var(--t3);font-weight:600">Comando:</span> <code style="font-family:var(--fm);font-size:.66rem">${m.command}</code></div>`:''}
|
||
${m.url?`<div><span style="color:var(--t3);font-weight:600">URL:</span> <code style="font-family:var(--fm);font-size:.66rem">${m.url}</code></div>`:''}
|
||
${m.module_path?`<div><span style="color:var(--t3);font-weight:600">Módulo:</span> <code style="font-family:var(--fm);font-size:.66rem">${m.module_path}</code></div>`:''}
|
||
<div><span style="color:var(--t3);font-weight:600">ADB:</span> ${adb?`<span class="badge b-pass">${adb.config_name}</span>`:'<span>Nenhum</span>'}</div></div>
|
||
${(()=>{const tools=Array.isArray(m.tools)?m.tools:[];return tools.length?`
|
||
<div style="margin-top:.65rem;border-top:1px solid var(--b2);padding-top:.5rem">
|
||
<div style="font-size:.74rem;font-weight:600;color:var(--t2);margin-bottom:.35rem">🔧 Tools (${tools.length})</div>
|
||
${tools.map((t,i)=>`<div style="display:flex;align-items:flex-start;gap:.5rem;padding:.3rem .5rem;background:var(--bg);border-radius:6px;margin-bottom:.25rem;font-size:.72rem">
|
||
<div style="flex:1"><strong>${typeof t==='string'?t:t.name}</strong>${typeof t==='object'&&t.description?` <span style="color:var(--t4)">— ${t.description}</span>`:''}
|
||
${typeof t==='object'&&t.input_schema&&t.input_schema.properties?`<div style="margin-top:.2rem;font-size:.64rem;color:var(--t4)">Params: ${Object.keys(t.input_schema.properties).join(', ')}</div>`:''}</div>
|
||
<button class="btn bd bsm" style="font-size:.6rem;padding:.15rem .4rem" onclick="removeMcpTool('${m.id}',${i})">✕</button></div>`).join('')}
|
||
</div>`:`<div style="margin-top:.5rem;font-size:.72rem;color:var(--t4)">Nenhuma tool registrada</div>`})()}
|
||
<div id="mcpm_${m.id}"></div>
|
||
<div style="display:flex;gap:.5rem;margin-top:.65rem;align-items:center;flex-wrap:wrap">
|
||
<button class="btn bp bsm" id="mdb_${m.id}" onclick="discoverMcpTools('${m.id}')">🔍 Descobrir Tools</button>
|
||
<button class="btn bs bsm" onclick="toggleAddTool('${m.id}')">➕ Adicionar Tool</button>
|
||
<input type="file" id="muf_${m.id}" accept=".py" style="font-size:.68rem;max-width:200px">
|
||
<button class="btn bs bsm" id="mub_${m.id}" onclick="uMcp('${m.id}')">Upload .py</button>
|
||
<select id="mlnk_${m.id}" style="font-size:.72rem;padding:.28rem .5rem;border-radius:6px;border:1px solid var(--bd)">
|
||
<option value="">Sem ADB</option>${adbOpts}</select>
|
||
<button class="btn bs bsm" id="mlb_${m.id}" onclick="lnkMcp('${m.id}')">Vincular ADB</button></div>
|
||
<div id="mctf_${m.id}" style="display:none;margin-top:.5rem;padding:.6rem;border:1px solid var(--bd);border-radius:8px;background:var(--bg)">
|
||
<div class="g2" style="gap:.5rem"><div class="ig"><label style="font-size:.7rem">Nome da Tool</label><input type="text" id="mctn_${m.id}" placeholder="list_resources" style="font-size:.74rem"></div>
|
||
<div class="ig"><label style="font-size:.7rem">Descrição</label><input type="text" id="mctd_${m.id}" placeholder="Lista recursos OCI" style="font-size:.74rem"></div></div>
|
||
<div class="ig" style="margin-top:.35rem"><label style="font-size:.7rem">Input Schema (JSON)</label><input type="text" id="mcts_${m.id}" placeholder='{"type":"object","properties":{"region":{"type":"string"}}}' style="font-size:.7rem;font-family:var(--fm)"></div>
|
||
<div style="display:flex;gap:.4rem;margin-top:.4rem"><button class="btn bp bsm" onclick="addMcpTool('${m.id}')">Salvar Tool</button><button class="btn bs bsm" onclick="toggleAddTool('${m.id}')">Cancelar</button></div>
|
||
</div></div>`}).join('')
|
||
:'<div class="emp" style="padding:.85rem"><p>Nenhum MCP server registrado.</p></div>'}</div>
|
||
<div class="cd"><div class="ct">${em?'✏️ Editar Server':'➕ Registrar Novo Server'}</div>
|
||
<div class="cdesc">${em?'Editando: <strong>'+em.name+'</strong>':'Configure um novo servidor MCP. Os campos se ajustam ao tipo selecionado.'}</div><div id="mcm"></div>
|
||
<div class="g2"><div class="ig"><label>Nome</label><input type="text" id="mn" placeholder="CIS Benchmark Server" value="${em?em.name:''}"></div>
|
||
<div class="ig"><label>Descrição</label><input type="text" id="md" placeholder="Executa checks CIS 3.0" value="${em&&em.description?em.description:''}"></div></div>
|
||
<div class="ig" style="margin-bottom:.75rem"><label>Tipo de Servidor</label>
|
||
<div style="display:flex;gap:.5rem;margin-top:.3rem">
|
||
<label style="display:flex;align-items:center;gap:.3rem;padding:.4rem .75rem;border:1px solid var(--bd);border-radius:8px;cursor:pointer;font-size:.78rem;transition:all .2s">
|
||
<input type="radio" name="mt" value="stdio" ${!em||em.server_type==='stdio'?'checked':''} onchange="mcpTypeFields()"> <span>⌨️ stdio</span></label>
|
||
<label style="display:flex;align-items:center;gap:.3rem;padding:.4rem .75rem;border:1px solid var(--bd);border-radius:8px;cursor:pointer;font-size:.78rem;transition:all .2s">
|
||
<input type="radio" name="mt" value="sse" ${em&&em.server_type==='sse'?'checked':''} onchange="mcpTypeFields()"> <span>🌐 SSE (HTTP)</span></label>
|
||
<label style="display:flex;align-items:center;gap:.3rem;padding:.4rem .75rem;border:1px solid var(--bd);border-radius:8px;cursor:pointer;font-size:.78rem;transition:all .2s">
|
||
<input type="radio" name="mt" value="module" ${em&&em.server_type==='module'?'checked':''} onchange="mcpTypeFields()"> <span>🐍 Python Module</span></label></div></div>
|
||
<div id="mf-cmd" class="mcp-f"><div class="ig"><label>Comando</label><div class="ht">Comando para iniciar o servidor</div>
|
||
<input type="text" id="mcmd" placeholder="python3 server.py" value="${em&&em.command?em.command:''}"></div></div>
|
||
<div id="mf-url" class="mcp-f" style="display:none"><div class="ig"><label>URL do Servidor</label><div class="ht">Endpoint SSE do servidor MCP</div>
|
||
<input type="text" id="murl" placeholder="http://localhost:8001/sse" value="${em&&em.url?em.url:''}"></div></div>
|
||
<div id="mf-args" class="mcp-f"><div class="ig"><label>Argumentos (JSON)</label><div class="ht">Array de argumentos passados ao comando</div>
|
||
<input type="text" id="margs" placeholder='["--config", "/path/to/config"]' value="${em&&em.args?JSON.stringify(em.args).replace(/"/g,'"'):''}"></div></div>
|
||
<div class="ig" style="margin-top:.5rem"><label>🗄️ Vincular ADB Vector</label><div class="ht">Opcional — o MCP server poderá usar este banco como ferramenta.</div>
|
||
<select id="madb"><option value="">Nenhum</option>${S.adbCfg.map(a=>'<option value="'+a.id+'"'+(em&&em.linked_adb_id===a.id?' selected':'')+'>'+a.config_name+'</option>').join('')}</select></div>
|
||
<div style="display:flex;gap:8px;margin-top:.75rem"><button class="btn bp" id="mcbtn" onclick="sMcp()">${em?'Salvar Alterações':'Registrar Server'}</button>${em?'<button class="btn bs" onclick="cancelEdit()">Cancelar</button>':''}</div></div>
|
||
`+rConfigLogs('mcp')}
|
||
async function sMcp(){const btn=document.getElementById('mcbtn');const isEdit=S.editing?.type==='mcp';
|
||
btn.disabled=true;btn.textContent='Salvando...';sm('mcm','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> '+(isEdit?'Atualizando':'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';
|
||
const body={name:document.getElementById('mn').value,description:document.getElementById('md').value,
|
||
server_type:mt,command:document.getElementById('mcmd')?.value||null,url:document.getElementById('murl')?.value||null,args,
|
||
linked_adb_id:document.getElementById('madb').value||null};
|
||
const url=isEdit?'/mcp/servers/'+S.editing.id:'/mcp/servers';const method=isEdit?'PUT':'POST';
|
||
await $api(url,{method,body});S.editing=null;
|
||
S.mcpSvr=await $api('/mcp/servers');sm('mcm','✅ '+(isEdit?'Atualizado':'Registrado')+'!','s');R()}catch(e){sm('mcm',e.message,'e');btn.disabled=false;btn.textContent=isEdit?'Salvar Alterações':'Registrar Server'}}
|
||
async function tgMcp(id){await $api('/mcp/servers/'+id+'/toggle',{method:'PUT'});S.mcpSvr=await $api('/mcp/servers');R()}
|
||
async function dMcp(id){if(!confirm('Excluir server?'))return;await $api('/mcp/servers/'+id,{method:'DELETE'});S.mcpSvr=await $api('/mcp/servers');R()}
|
||
async function uMcp(id){const fd=new FormData();const f=document.getElementById('muf_'+id)?.files[0];if(!f)return sm('mcpm_'+id,'Selecione um arquivo .py','e');
|
||
fd.append('file',f);const btn=document.getElementById('mub_'+id);btn.disabled=true;btn.textContent='Enviando...';sm('mcpm_'+id,'<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Enviando arquivo...','i');
|
||
try{await $api('/mcp/servers/'+id+'/upload',{method:'POST',body:fd,headers:{}});S.mcpSvr=await $api('/mcp/servers');sm('mcpm_'+id,'✅ Upload realizado!','s');R()}catch(e){sm('mcpm_'+id,e.message,'e');btn.disabled=false;btn.textContent='Upload .py'}}
|
||
async function lnkMcp(id){const aid=document.getElementById('mlnk_'+id)?.value||'';
|
||
const btn=document.getElementById('mlb_'+id);btn.disabled=true;btn.textContent='Vinculando...';
|
||
try{await $api('/mcp/servers/'+id+'/link-adb?adb_id='+aid,{method:'PUT'});S.mcpSvr=await $api('/mcp/servers');sm('mcpm_'+id,'✅ '+(aid?'Vinculado ao ADB!':'ADB desvinculado!'),'s');R()}catch(e){sm('mcpm_'+id,e.message,'e');btn.disabled=false;btn.textContent='Vincular ADB'}}
|
||
async function discoverMcpTools(mid){const btn=document.getElementById('mdb_'+mid);btn.disabled=true;btn.textContent='Descobrindo...';
|
||
sm('mcpm_'+mid,'<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Conectando ao MCP server para descobrir tools...','i');
|
||
try{const d=await $api('/mcp/servers/'+mid+'/discover-tools',{method:'POST'});S.mcpSvr=await $api('/mcp/servers');
|
||
sm('mcpm_'+mid,`✅ ${d.discovered} tool(s) descoberta(s), ${d.total} total`,'s');R()}catch(e){sm('mcpm_'+mid,'❌ '+e.message,'e');btn.disabled=false;btn.textContent='🔍 Descobrir Tools'}}
|
||
function toggleAddTool(mid){const el=document.getElementById('mctf_'+mid);el.style.display=el.style.display==='none'?'':'none'}
|
||
async function addMcpTool(mid){const name=document.getElementById('mctn_'+mid)?.value?.trim();const desc=document.getElementById('mctd_'+mid)?.value?.trim()||'';
|
||
const schemaStr=document.getElementById('mcts_'+mid)?.value?.trim();
|
||
if(!name)return sm('mcpm_'+mid,'Nome da tool é obrigatório','e');
|
||
let schema={};if(schemaStr)try{schema=JSON.parse(schemaStr)}catch(e){return sm('mcpm_'+mid,'Input Schema JSON inválido','e')}
|
||
const srv=S.mcpSvr.find(m=>m.id===mid);const existing=Array.isArray(srv?.tools)?srv.tools:[];
|
||
existing.push({name,description:desc,input_schema:schema});
|
||
try{await $api('/mcp/servers/'+mid+'/tools',{method:'PUT',body:{tools:existing}});S.mcpSvr=await $api('/mcp/servers');
|
||
sm('mcpm_'+mid,'✅ Tool adicionada!','s');R()}catch(e){sm('mcpm_'+mid,e.message,'e')}}
|
||
async function removeMcpTool(mid,idx){const srv=S.mcpSvr.find(m=>m.id===mid);const tools=Array.isArray(srv?.tools)?[...srv.tools]:[];
|
||
if(idx<0||idx>=tools.length)return;const name=typeof tools[idx]==='string'?tools[idx]:tools[idx].name;
|
||
if(!confirm(`Remover tool "${name}"?`))return;tools.splice(idx,1);
|
||
try{await $api('/mcp/servers/'+mid+'/tools',{method:'PUT',body:{tools}});S.mcpSvr=await $api('/mcp/servers');R()}catch(e){sm('mcpm_'+mid,e.message,'e')}}
|
||
|
||
/* ── ADB Vector ── */
|
||
function rADB(){const ea=S.editing?.type==='adb'?S.adbCfg.find(c=>c.id===S.editing.id):null;
|
||
return`<div class="cd"><div class="ct">🗄️ Conexões Registradas</div>
|
||
<table><thead><tr><th>Nome</th><th>DSN</th><th>User</th><th>Tabelas</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];const tbls=c.tables||[];return`<tr${ea&&ea.id===c.id?' style="background:var(--bg3)"':''}><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>${tbls.length?tbls.map(t=>'<span class="tag" style="font-size:.62rem">'+t.table_name+'</span>').join(' '):'<span style="color:var(--t4);font-size:.72rem">—</span>'}</td>
|
||
<td>${c.use_mtls?'✅':'—'}</td><td>${c.wallet_dir?'✅':'❌'}</td>
|
||
<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="editCfg('adb','${c.id}')">Editar</button> <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>
|
||
${!S.adbCfg.length?'<div class="emp" style="padding:.85rem"><p>Nenhuma conexão ADB registrada.</p></div>':''}</div>
|
||
<div class="cd"><div class="ct">${ea?'✏️ Editar Conexão':'➕ Nova Conexão'}</div>
|
||
<div class="cdesc">${ea?'Editando: <strong>'+ea.config_name+'</strong>':'Conexão via <code style="font-size:.72rem">python-oracledb</code> Thin mode com Wallet (mTLS) para Oracle Autonomous Database. Envie o Wallet ZIP para preencher o DSN automaticamente.'}</div><div id="am"></div>
|
||
<div class="g2"><div class="ig"><label>Nome da Conexão</label><input type="text" id="an" placeholder="Produção ADB" value="${ea?ea.config_name:''}"></div>
|
||
<div class="ig"><label>Wallet ZIP</label><div class="ht">${ea?'Envie novo wallet ou deixe vazio para manter o atual':'Envie o wallet para extrair os DSNs do tnsnames.ora'}</div>
|
||
<div style="display:flex;gap:6px;align-items:center"><input type="file" id="awf" accept=".zip" style="flex:1"><button class="btn bs bsm" id="wpbtn" type="button" onclick="parseWallet()">Analisar</button></div></div>
|
||
<div class="ig"><label>DSN (TNS Name)</label><div class="ht">Selecione ou digite o DSN. Ex: myatp_high</div>
|
||
<div id="adsn-wrap"><input type="text" id="adsn" placeholder="myatp_high" value="${ea?ea.dsn:''}"></div></div>
|
||
<div class="ig"><label>Username</label><input type="text" id="auser" placeholder="ADMIN" value="${ea?ea.username:''}"></div>
|
||
<div class="ig"><label>Password</label><input type="password" id="apw" placeholder="${ea?'(manter atual)':''}"></div>
|
||
<div class="ig"><label>Wallet Password</label><input type="password" id="awpw" placeholder="(opcional)"></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+'"'+(ea&&ea.genai_config_id===g.id?' selected':'')+'>'+(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+'"'+(ea&&ea.embedding_model_id===k?' selected':'')+'>'+v.name+' ('+v.dims+'d)</option>').join('')}</select></div></div>
|
||
<div style="display:flex;gap:8px"><button class="btn bp" id="abtn" onclick="sAdb()">${ea?'Salvar Alterações':'Salvar Conexão'}</button>${ea?'<button class="btn bs" onclick="cancelEdit()">Cancelar</button>':''}</div></div>
|
||
${ea?`<div class="cd"><div class="ct">📋 Tabelas de Vetores — ${ea.config_name}</div>
|
||
<div class="cdesc">Tabelas no ADB com dados vetorizados. A busca RAG consultará todas as tabelas ativas.</div><div id="tbm"></div>
|
||
${(ea.tables||[]).length?`<table><thead><tr><th>Tabela</th><th>Descrição</th><th>Ativa</th><th>Ações</th></tr></thead><tbody>
|
||
${(ea.tables||[]).map(t=>`<tr${!t.is_active?' style="opacity:.55"':''}>
|
||
<td><input type="text" value="${t.table_name}" id="tn_${t.id}" style="font-family:var(--fm);font-size:.72rem;text-transform:uppercase;width:100%;max-width:180px"></td>
|
||
<td><input type="text" value="${t.description||''}" id="td_${t.id}" placeholder="Descrição..." style="font-size:.72rem;width:100%"></td>
|
||
<td><button class="btn bs bsm" style="font-size:.62rem" onclick="toggleTable('${ea.id}','${t.id}',${t.is_active?0:1})">${t.is_active?'✅ Sim':'❌ Não'}</button></td>
|
||
<td style="white-space:nowrap"><button class="btn bs bsm" onclick="saveTable('${ea.id}','${t.id}')">Salvar</button> <button class="btn bd bsm" onclick="removeTable('${ea.id}','${t.id}')">Excluir</button></td>
|
||
</tr>`).join('')}</tbody></table>`:'<div class="emp" style="padding:.5rem"><p style="font-size:.74rem">Nenhuma tabela registrada.</p></div>'}
|
||
<div style="display:flex;gap:6px;margin-top:.65rem;align-items:end">
|
||
<div class="ig" style="flex:1;margin:0"><label>Nova Tabela</label><input type="text" id="newTblName" placeholder="NOME_DA_TABELA" style="text-transform:uppercase"></div>
|
||
<div class="ig" style="flex:1;margin:0"><label>Descrição</label><input type="text" id="newTblDesc" placeholder="Ex: Dados de compliance CIS"></div>
|
||
<button class="btn bs" onclick="addTable('${ea.id}')">+ Adicionar</button>
|
||
</div></div>`:''}
|
||
${S.adbCfg.length?`<div class="cd"><div class="ct">📤 Atualizar Wallet</div><div id="wm"></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="awf2" accept=".zip"></div></div>
|
||
<button class="btn bs" id="wbtn" onclick="uWallet()">Upload Wallet</button></div>`:''}
|
||
`+rConfigLogs('adb')}
|
||
async function parseWallet(){const f=document.getElementById('awf').files[0];if(!f)return sm('am','Selecione um arquivo wallet ZIP.','e');
|
||
const btn=document.getElementById('wpbtn');btn.disabled=true;btn.textContent='...';
|
||
try{const fd=new FormData();fd.append('wallet',f);const d=await $api('/adb/parse-wallet',{method:'POST',body:fd,headers:{}});
|
||
const wrap=document.getElementById('adsn-wrap');
|
||
wrap.innerHTML='<select id="adsn">'+d.dsn_names.map(n=>'<option value="'+n+'">'+n+'</option>').join('')+'</select>';
|
||
sm('am','✅ Wallet analisado! '+d.dsn_names.length+' DSN(s) encontrado(s): <strong>'+d.dsn_names.join(', ')+'</strong>. Arquivos: '+d.files.join(', '),'s');
|
||
}catch(e){sm('am',e.message,'e')}finally{btn.disabled=false;btn.textContent='Analisar'}}
|
||
async function sAdb(){const btn=document.getElementById('abtn');btn.disabled=true;btn.textContent='Salvando...';sm('am','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Salvando conexão...','i');
|
||
const isEdit=S.editing?.type==='adb';
|
||
try{const fd=new FormData();fd.append('config_name',document.getElementById('an').value);
|
||
fd.append('dsn',document.getElementById('adsn').value);fd.append('username',document.getElementById('auser').value);
|
||
fd.append('password',document.getElementById('apw').value);fd.append('wallet_password',document.getElementById('awpw').value||'');
|
||
fd.append('use_mtls','true');
|
||
fd.append('genai_config_id',document.getElementById('agcfg').value||'');
|
||
fd.append('embedding_model_id',document.getElementById('aemb').value||'cohere.embed-v4.0');
|
||
const wf=document.getElementById('awf').files[0];if(wf)fd.append('wallet',wf);
|
||
const url=isEdit?'/adb/configs/'+S.editing.id:'/adb/config';const method=isEdit?'PUT':'POST';
|
||
await $api(url,{method,body:fd,headers:{}});S.editing=null;
|
||
S.adbCfg=await $api('/adb/configs');sm('am','✅ Conexão '+(isEdit?'atualizada':'salva')+'!'+(wf?' Wallet incluído.':''),'s');R()}catch(e){sm('am',e.message,'e');btn.disabled=false;btn.textContent=isEdit?'Salvar Alterações':'Salvar Conexão'}}
|
||
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('awf2').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','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> 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 addTable(vid){const name=document.getElementById('newTblName').value.trim();if(!name)return sm('tbm','Digite o nome da tabela.','e');
|
||
const desc=document.getElementById('newTblDesc').value.trim();
|
||
try{await $api('/adb/'+vid+'/tables',{method:'POST',body:{table_name:name,description:desc}});S.adbCfg=await $api('/adb/configs');sm('tbm','✅ Tabela '+name+' registrada!','s');R()}catch(e){sm('tbm',e.message,'e')}}
|
||
async function saveTable(vid,tid){const name=document.getElementById('tn_'+tid)?.value.trim();const desc=document.getElementById('td_'+tid)?.value.trim();
|
||
if(!name)return sm('tbm','Nome da tabela é obrigatório.','e');
|
||
try{await $api('/adb/'+vid+'/tables/'+tid,{method:'PUT',body:{table_name:name.toUpperCase(),description:desc}});S.adbCfg=await $api('/adb/configs');sm('tbm','✅ Tabela atualizada.','s');R()}catch(e){sm('tbm',e.message,'e')}}
|
||
async function toggleTable(vid,tid,val){try{await $api('/adb/'+vid+'/tables/'+tid,{method:'PUT',body:{is_active:val}});S.adbCfg=await $api('/adb/configs');R()}catch(e){sm('tbm',e.message,'e')}}
|
||
async function removeTable(vid,tid){if(!confirm('Excluir esta tabela do registro?'))return;
|
||
try{await $api('/adb/'+vid+'/tables/'+tid,{method:'DELETE'});S.adbCfg=await $api('/adb/configs');sm('tbm','✅ Tabela removida.','s');R()}catch(e){sm('tbm',e.message,'e')}}
|
||
|
||
/* ── Embeddings ── */
|
||
function tblOpts(vid){const cfg=S.adbCfg.find(c=>c.id===vid);if(!cfg||!(cfg.tables||[]).length)return'<option value="">Padrão</option>';
|
||
return cfg.tables.filter(t=>t.is_active).map(t=>'<option value="'+t.table_name+'">'+t.table_name+'</option>').join('')}
|
||
function rEmbeddings(){
|
||
const adbOpts=S.adbCfg.filter(c=>c.genai_config_id).map(c=>'<option value="'+c.id+'">'+c.config_name+'</option>').join('');
|
||
const rptOpts=S.reports.filter(r=>r.status==='completed').map(r=>'<option value="'+r.id+'">'+r.tenancy_name+' ('+r.created_at?.substring(0,10)+')</option>').join('');
|
||
if(!S.adbCfg.filter(c=>c.genai_config_id).length)return`<div class="cd"><div class="emp"><div class="eic">🧬</div><p>Nenhuma conexão ADB com GenAI configurada.</p><p style="font-size:.74rem;margin-top:.35rem">Configure em <strong>ADB Vector</strong> vinculando uma Config GenAI.</p></div></div>`;
|
||
const firstVid=S.adbCfg.filter(c=>c.genai_config_id)[0]?.id||'';
|
||
return`<div class="cd"><div class="ct">📋 Embeddings Existentes</div>
|
||
<div class="cdesc">Consulte os documentos armazenados nas tabelas de embeddings do ADB.</div>
|
||
<div class="g3"><div class="ig"><label>Conexão ADB</label><select id="elsel" onchange="document.getElementById('eltbl').innerHTML=tblOpts(this.value)">${adbOpts}</select></div>
|
||
<div class="ig"><label>Tabela</label><select id="eltbl">${tblOpts(firstVid)}</select></div>
|
||
<div class="ig" style="align-self:end"><button class="btn bs" onclick="loadEmbs()">Carregar</button></div></div>
|
||
<div id="emb-list"></div></div>
|
||
<div class="cd"><div class="ct">📊 Embed Relatório CIS</div>
|
||
<div class="cdesc">Visualize os chunks gerados antes de criar os embeddings. Cada seção do relatório gera um chunk com tenancy, regiões e compartments.</div><div id="erm"></div>
|
||
<div class="g3"><div class="ig"><label>Conexão ADB</label><select id="ersel" onchange="document.getElementById('ertbl').innerHTML=tblOpts(this.value)">${adbOpts}</select></div>
|
||
<div class="ig"><label>Tabela</label><select id="ertbl">${tblOpts(firstVid)}</select></div>
|
||
<div class="ig"><label>Relatório</label><select id="errpt">${rptOpts||'<option value="">Nenhum relatório disponível</option>'}</select></div></div>
|
||
<div style="display:flex;gap:8px;margin-top:.35rem"><button class="btn bs" id="epbtn" onclick="previewChunks()">👁 Visualizar Chunks</button>
|
||
<button class="btn bp" id="erbtn" onclick="embedReport()" style="display:none">Gerar Embeddings</button></div>
|
||
<div id="chunk-preview"></div></div>
|
||
<div class="cd"><div class="ct">📄 Upload de Arquivo</div>
|
||
<div class="cdesc">Faça upload de um arquivo .txt para gerar embeddings automaticamente (chunking por parágrafos).</div><div id="efm"></div>
|
||
<div class="g3"><div class="ig"><label>Conexão ADB</label><select id="efsel" onchange="document.getElementById('eftbl').innerHTML=tblOpts(this.value)">${adbOpts}</select></div>
|
||
<div class="ig"><label>Tabela</label><select id="eftbl">${tblOpts(firstVid)}</select></div>
|
||
<div class="ig"><label>Arquivo (.txt)</label><input type="file" id="eff" accept=".txt"></div></div>
|
||
<button class="btn bp" id="efbtn" onclick="embedFile()">Upload e Gerar Embeddings</button></div>`}
|
||
|
||
async function loadEmbs(){const vid=document.getElementById('elsel').value;if(!vid)return;
|
||
const tbl=document.getElementById('eltbl')?.value||'';
|
||
try{const d=await $api('/embeddings/'+vid+'/list'+(tbl?'?table_name='+encodeURIComponent(tbl):''));
|
||
if(!d.documents.length){document.getElementById('emb-list').innerHTML='<div class="emp" style="padding:.65rem"><p>Nenhum embedding encontrado.</p></div>';return}
|
||
document.getElementById('emb-list').innerHTML='<table style="margin-top:.65rem"><thead><tr><th>ID</th><th>Source</th><th>Metadata</th><th>Data</th><th>Ações</th></tr></thead><tbody>'+
|
||
d.documents.map(e=>'<tr><td style="font-family:var(--fm);font-size:.62rem">'+e.id.substring(0,8)+'...</td><td>'+
|
||
(e.source||'—')+'</td><td style="font-size:.72rem;color:var(--t3)">'+(e.metadata||'—')+'</td><td style="font-size:.72rem;color:var(--t4)">'+
|
||
(e.created_at||'—')+'</td><td><button class="btn bd bsm" onclick="delEmb(\''+vid+'\',\''+e.id+'\')">Excluir</button></td></tr>').join('')+
|
||
'</tbody></table><div style="padding:.5rem;font-size:.72rem;color:var(--t4)">Total: '+d.total+' documentos</div>'}
|
||
catch(e){document.getElementById('emb-list').innerHTML='<div style="color:var(--rd);padding:.5rem">Erro: '+e.message+'</div>'}}
|
||
|
||
async function previewChunks(){const rid=document.getElementById('errpt').value;
|
||
if(!rid)return sm('erm','Selecione um relatório.','e');
|
||
const btn=document.getElementById('epbtn');btn.disabled=true;btn.textContent='Carregando...';
|
||
try{const d=await $api('/embeddings/preview/'+rid);
|
||
let html='<div style="margin-top:.75rem;border:1px solid var(--bd);border-radius:var(--r);overflow:hidden">';
|
||
html+='<div style="padding:.65rem .85rem;background:var(--bg2);border-bottom:1px solid var(--bd);display:flex;justify-content:space-between;align-items:center">';
|
||
html+='<div><strong style="font-size:.82rem">'+d.tenancy+'</strong>';
|
||
if(d.regions?.length)html+=' <span style="font-size:.68rem;color:var(--t4)">Regiões: '+d.regions.join(', ')+'</span>';
|
||
html+='</div><span class="tag">'+d.total_chunks+' chunks</span></div>';
|
||
if(d.compartments?.length)html+='<div style="padding:.4rem .85rem;background:var(--bg2);border-bottom:1px solid var(--bd);font-size:.68rem;color:var(--t3)">Compartments: '+d.compartments.join(', ')+'</div>';
|
||
d.chunks.forEach((c,i)=>{
|
||
const preview=c.content.length>300?c.content.substring(0,300)+'...':c.content;
|
||
html+='<details style="border-bottom:1px solid var(--bd)">';
|
||
html+='<summary style="padding:.5rem .85rem;cursor:pointer;font-size:.74rem;display:flex;justify-content:space-between;align-items:center">';
|
||
html+='<span><strong>#'+(i+1)+'</strong> '+c.source+'</span>';
|
||
html+='<span style="font-size:.64rem;color:var(--t4)">'+c.metadata+'</span></summary>';
|
||
html+='<pre style="padding:.5rem .85rem;font-size:.64rem;color:var(--t3);white-space:pre-wrap;max-height:200px;overflow:auto;background:var(--bg2);margin:0">'+preview+'</pre>';
|
||
html+='</details>'});
|
||
html+='</div>';
|
||
document.getElementById('chunk-preview').innerHTML=html;
|
||
document.getElementById('erbtn').style.display='';
|
||
sm('erm','✅ '+d.total_chunks+' chunks prontos para embedding.','s');
|
||
}catch(e){sm('erm',e.message,'e')}finally{btn.disabled=false;btn.textContent='👁 Visualizar Chunks'}}
|
||
async function embedReport(){const vid=document.getElementById('ersel').value;const rid=document.getElementById('errpt').value;
|
||
if(!vid||!rid)return sm('erm','Selecione conexão ADB e relatório.','e');
|
||
const tbl=document.getElementById('ertbl')?.value||'';
|
||
const btn=document.getElementById('erbtn');btn.disabled=true;btn.textContent='Gerando...';sm('erm','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Gerando embeddings do relatório...','i');
|
||
try{const d=await $api('/embeddings/report/'+rid,{method:'POST',body:{adb_config_id:vid,table_name:tbl||undefined}});sm('erm','✅ '+d.message,'s');btn.disabled=false;btn.textContent='Gerar Embeddings do Relatório'}catch(e){sm('erm',e.message,'e');btn.disabled=false;btn.textContent='Gerar Embeddings do Relatório'}}
|
||
|
||
async function embedFile(){const vid=document.getElementById('efsel').value;const f=document.getElementById('eff').files[0];
|
||
if(!vid||!f)return sm('efm','Selecione conexão ADB e arquivo.','e');
|
||
const tbl=document.getElementById('eftbl')?.value||'';
|
||
const fd=new FormData();fd.append('file',f);fd.append('adb_config_id',vid);if(tbl)fd.append('table_name',tbl);
|
||
const btn=document.getElementById('efbtn');btn.disabled=true;btn.textContent='Enviando...';sm('efm','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Enviando e gerando embeddings...','i');
|
||
try{const d=await $api('/embeddings/upload',{method:'POST',body:fd,headers:{}});sm('efm','✅ '+d.message,'s');btn.disabled=false;btn.textContent='Upload e Gerar Embeddings'}catch(e){sm('efm',e.message,'e');btn.disabled=false;btn.textContent='Upload e Gerar Embeddings'}}
|
||
|
||
async function delEmb(vid,docId){if(!confirm('Excluir este embedding?'))return;
|
||
const tbl=document.getElementById('eltbl')?.value||'';
|
||
try{await $api('/embeddings/'+vid+'/'+docId+(tbl?'?table_name='+encodeURIComponent(tbl):''),{method:'DELETE'});loadEmbs()}catch(e){alert('Erro: '+e.message)}}
|
||
|
||
/* ── Users ── */
|
||
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" ${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(){
|
||
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()}
|
||
|
||
/* ── MFA ── */
|
||
function rMfa(){return`<div class="cd"><div class="ct">🔐 Autenticação MFA (TOTP)</div>
|
||
<div class="cdesc">Proteja sua conta com autenticação em dois fatores via Google Authenticator ou Authy.</div><div id="mm"></div>
|
||
${S.user?.mfa_enabled?'<div class="al al-s">✅ MFA está ativado na sua conta.</div>'
|
||
:`<button class="btn bp" onclick="sMfa()">Gerar Secret</button><div id="msa" style="margin-top:.75rem"></div>`}</div>`}
|
||
async function sMfa(){try{const d=await $api('/mfa/setup',{method:'POST'});
|
||
document.getElementById('msa').innerHTML=`<div class="al al-i"><strong>Secret:</strong> <code style="font-size:.78rem">${d.secret}</code></div>
|
||
<div style="display:flex;gap:.45rem;max-width:280px;margin-top:.55rem"><input type="text" id="mvc" placeholder="000000" maxlength="6">
|
||
<button class="btn bp" onclick="vMfa()">Ativar MFA</button></div>`}catch(e){sm('mm',e.message,'e')}}
|
||
async function vMfa(){try{await $api('/mfa/verify',{method:'POST',body:{totp_code:document.getElementById('mvc').value}});
|
||
S.user.mfa_enabled=true;sm('mm','✅ MFA ativado com sucesso!','s');R()}catch(e){sm('mm',e.message,'e')}}
|
||
|
||
/* ── Audit ── */
|
||
function rAudit(){return`<div class="cd"><div class="ct">📋 Log de Auditoria</div><div id="ac"><div class="ld"><span class="spinner"></span> Carregando logs...</div></div></div>`}
|
||
async function loadAudit(){try{const logs=await $api('/audit-log?limit=50');
|
||
document.getElementById('ac').innerHTML=`<table><thead><tr><th>Data</th><th>Usuário</th><th>Ação</th><th>Recurso</th><th>IP</th></tr></thead><tbody>
|
||
${logs.map(l=>`<tr><td style="font-size:.68rem;color:var(--t4)">${l.created_at}</td><td>${l.username||'—'}</td><td><span class="tag">${l.action}</span></td>
|
||
<td style="font-size:.68rem;font-family:var(--fm);color:var(--t4)">${l.resource?(l.resource.slice(0,14)+'…'):'—'}</td><td style="font-size:.72rem">${l.ip||'—'}</td></tr>`).join('')}</tbody></table>`}catch(e){document.getElementById('ac').innerHTML='<div class="al al-e">'+e.message+'</div>'}}
|
||
|
||
/* ── Config Logs ── */
|
||
function rConfigLogs(type){return`<div class="cd"><div class="ct" style="display:flex;justify-content:space-between;align-items:center">
|
||
<span>📜 Log de Atividades</span>
|
||
<div style="display:flex;gap:.4rem;align-items:center">
|
||
<select id="cl-sev-${type}" onchange="refreshCLogs('${type}')" style="font-size:.7rem;padding:.22rem .4rem;border-radius:6px;border:1px solid var(--bd)">
|
||
<option value="">Todos</option><option value="error">Erros</option><option value="success">Sucesso</option><option value="info">Info</option></select>
|
||
<button class="btn bs bsm" onclick="refreshCLogs('${type}')">Atualizar</button></div></div>
|
||
<div id="cl-${type}"><div style="padding:.75rem;font-size:.74rem;color:var(--t4)">Carregando logs...</div></div></div>`}
|
||
|
||
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='<div class="emp" style="padding:.65rem"><p>Nenhum log registrado.</p></div>';return}
|
||
el.innerHTML='<table style="font-size:.74rem"><thead><tr><th>Data</th><th>Config</th><th>Ação</th><th>Status</th><th>Mensagem</th></tr></thead><tbody>'+
|
||
logs.map(l=>{const sc=l.severity==='error'?'rd':l.severity==='success'?'gn':'bl';
|
||
return'<tr><td style="font-size:.62rem;color:var(--t4);white-space:nowrap">'+l.created_at+'</td>'+
|
||
'<td style="font-size:.72rem">'+(l.config_name||l.config_id?.slice(0,8)||'—')+'</td>'+
|
||
'<td><span class="tag">'+l.action+'</span></td>'+
|
||
'<td><span class="badge" style="background:var(--'+sc+'l);color:var(--'+sc+')">'+l.severity+'</span></td>'+
|
||
'<td style="font-size:.72rem;max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="'+(l.message||'').replace(/"/g,'"')+'">'+(l.message?.slice(0,150)||'—')+'</td></tr>'}).join('')+
|
||
'</tbody></table>'}
|
||
catch(e){const el=document.getElementById('cl-'+type);if(el)el.innerHTML='<div class="al al-e">'+e.message+'</div>'}}
|
||
|
||
/* ── Helpers ── */
|
||
function sm(id,msg,t){const el=document.getElementById(id);if(el)el.innerHTML=`<div class="al al-${t==='s'?'s':t==='i'?'i':'e'}">${msg}</div>`}
|
||
let _lu='',_lp='';
|
||
async function hLogin(){const u=document.getElementById('lu').value,p=document.getElementById('lp').value;_lu=u;_lp=p;
|
||
try{const d=await doLogin(u,p);if(d&&d.mfa_required){document.getElementById('lf').style.display='none';document.getElementById('mfa-s').style.display='block'}}
|
||
catch(e){document.getElementById('le').innerHTML=`<div class="al al-e">${e.message}</div>`}}
|
||
async function hMfa(){try{await doLogin(_lu,_lp,document.getElementById('mfa-c').value)}catch(e){document.getElementById('le').innerHTML=`<div class="al al-e">${e.message}</div>`}}
|
||
(async()=>{await checkAuth();R()})();
|
||
</script>
|
||
</body>
|
||
</html> |