- Add Consult Embeddings sub-menu with chat-like Q&A interface - Fix VECTOR_DISTANCE FLOAT32/FLOAT64 mismatch (array 'd' → 'f') - Decode base64 compartment_id in _resolve_embed_config - Sidebar sub-item navigation for Embeddings hierarchy - Fallback to raw document display when no GenAI config available
2868 lines
258 KiB
HTML
2868 lines
258 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,html.light {
|
|
--bg:#f4f2ef;--bg1:#ffffff;--bg2:#faf9f7;--bg3:#efeee9;--bg4:#e5e3dd;
|
|
--bd:#ddd9d2;--bdf:#c5c0b8;
|
|
--t1:#1c1917;--t2:#44403c;--t3:#78716c;--t4:#a8a29e;
|
|
--ac:#c74634;--ach:#b13d2e;--acd:#9a3427;--acl:rgba(199,70,52,.06);--acl2:rgba(199,70,52,.1);
|
|
--gn:#16a34a;--gnl:rgba(22,163,74,.08);--rd:#dc2626;--rdl:rgba(220,38,38,.06);
|
|
--bl:#2563eb;--bll:rgba(37,99,235,.06);--yl:#ca8a04;--yll:rgba(202,138,4,.06);
|
|
--pp:#7c3aed;--ppl:rgba(124,58,237,.06);
|
|
--ok:#16a34a;--w:#ca8a04;--err:#dc2626;
|
|
--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);
|
|
--tb-bg:rgba(255,255,255,.8);
|
|
--fm:'JetBrains Mono',monospace;
|
|
--fs:'Plus Jakarta Sans',-apple-system,system-ui,sans-serif;
|
|
--trans:cubic-bezier(.22,1,.36,1)
|
|
}
|
|
html.dark {
|
|
--bg:#0d0f14;--bg1:#151820;--bg2:#1a1e28;--bg3:#222733;--bg4:#2a3040;
|
|
--bd:#2a3040;--bdf:#3a4258;
|
|
--t1:#f0f0f4;--t2:#c8cad0;--t3:#8b8fa3;--t4:#5a5e6e;
|
|
--ac:#c74634;--ach:#d9523f;--acd:#a83428;--acl:rgba(199,70,52,.1);--acl2:rgba(199,70,52,.16);
|
|
--gn:#34c759;--gnl:rgba(52,199,89,.1);--rd:#ff453a;--rdl:rgba(255,69,58,.1);
|
|
--bl:#0a84ff;--bll:rgba(10,132,255,.1);--yl:#ffd60a;--yll:rgba(255,214,10,.1);
|
|
--pp:#bf5af2;--ppl:rgba(191,90,242,.1);
|
|
--ok:#34c759;--w:#ffd60a;--err:#ff453a;
|
|
--tx2:#8b8fa3;--p:#f0f0f4;--pl:rgba(199,70,52,.1);
|
|
--b2:#2a3040;--b3:#3a4258;
|
|
--sh1:0 1px 3px rgba(0,0,0,.3),0 1px 2px rgba(0,0,0,.2);
|
|
--sh2:0 4px 8px rgba(0,0,0,.3),0 2px 4px rgba(0,0,0,.2);
|
|
--sh3:0 10px 20px rgba(0,0,0,.35),0 4px 8px rgba(0,0,0,.2);
|
|
--shh:0 20px 30px rgba(0,0,0,.4),0 8px 12px rgba(0,0,0,.25);
|
|
--tb-bg:rgba(21,24,32,.85);
|
|
}
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{font-family:var(--fs);background:var(--bg);color:var(--t1);height:100vh;overflow:hidden;-webkit-font-smoothing:antialiased}
|
|
::selection{background:rgba(199,70,52,.2);color:var(--t1)}
|
|
|
|
/* ── 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:var(--tb-bg);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)}
|
|
.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)}
|
|
|
|
/* ── KPI Cards ── */
|
|
.kpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.75rem;margin-bottom:.85rem}
|
|
.kpi{background:var(--bg1);border:1px solid var(--bd);border-radius:var(--rl);padding:1rem 1.15rem;box-shadow:var(--sh1);transition:all .3s var(--trans);position:relative;overflow:hidden}
|
|
.kpi:hover{box-shadow:var(--sh2);transform:translateY(-2px)}
|
|
.kpi::after{content:'';position:absolute;bottom:0;left:0;right:0;height:3px;border-radius:0 0 var(--rl) var(--rl)}
|
|
.kpi.kpi-score::after{background:var(--ac)}.kpi.kpi-pass::after{background:var(--gn)}.kpi.kpi-fail::after{background:var(--rd)}.kpi.kpi-warn::after{background:var(--yl)}.kpi.kpi-info::after{background:var(--bl)}
|
|
.kpi-label{font-size:.68rem;font-weight:600;color:var(--t3);text-transform:uppercase;letter-spacing:.06em;display:flex;align-items:center;gap:.35rem;margin-bottom:.45rem}
|
|
.kpi-val{font-size:1.65rem;font-weight:800;letter-spacing:-.03em;line-height:1;color:var(--t1);font-variant-numeric:tabular-nums}
|
|
.kpi-val.v-ok{color:var(--gn)}.kpi-val.v-err{color:var(--rd)}.kpi-val.v-warn{color:var(--yl)}.kpi-val.v-info{color:var(--bl)}.kpi-val.v-ac{color:var(--ac)}
|
|
.kpi-sub{font-size:.66rem;color:var(--t4);margin-top:.3rem;font-family:var(--fm);font-variant-numeric:tabular-nums}
|
|
.kpi-bar{height:6px;background:var(--bg3);border-radius:3px;margin-top:.55rem;overflow:hidden}
|
|
.kpi-bar-fill{height:100%;border-radius:3px;transition:width .6s ease}
|
|
.kpi-sections{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:.5rem;margin-top:.55rem}
|
|
.kpi-sec{background:var(--bg2);border:1px solid var(--bd);border-radius:10px;padding:.6rem .75rem;transition:all .2s}
|
|
.kpi-sec:hover{border-color:var(--bdf);box-shadow:var(--sh1)}
|
|
.kpi-sec-name{font-size:.68rem;font-weight:700;color:var(--t2);margin-bottom:.3rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.kpi-sec-row{display:flex;gap:.5rem;font-size:.64rem;font-family:var(--fm)}
|
|
.kpi-sec-row .s-ok{color:var(--gn)}.kpi-sec-row .s-fail{color:var(--rd)}.kpi-sec-row .s-rev{color:var(--yl)}
|
|
|
|
/* ── 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:var(--t4);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:var(--bg1);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,.12) 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,.08) 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-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 .eic svg{width:48px;height:48px}
|
|
.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(8px)}to{opacity:1;transform:translateY(0)}}
|
|
@keyframes fiScale{from{opacity:0;transform:scale(.96) translateY(6px)}to{opacity:1;transform:scale(1) translateY(0)}}
|
|
@keyframes slideIn{from{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:translateX(0)}}
|
|
@keyframes slideUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
|
|
@keyframes alertIn{from{opacity:0;transform:translateY(-6px) scale(.98)}to{opacity:1;transform:translateY(0) scale(1)}}
|
|
@keyframes shimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}
|
|
@keyframes pu{0%,100%{opacity:1}50%{opacity:.4}}
|
|
@keyframes spin{to{transform:rotate(360deg)}}
|
|
@keyframes pulseGlow{0%,100%{box-shadow:0 0 0 0 rgba(199,70,52,.15)}50%{box-shadow:0 0 0 6px rgba(199,70,52,0)}}
|
|
.fi{animation:fi .35s var(--trans) both}
|
|
.ld{animation:pu 1.2s ease infinite;color:var(--t4);font-size:.82rem;text-align:center;padding:1.5rem}
|
|
.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}
|
|
|
|
/* Page content fade-in — only on tab switch (class .tab-enter added by switchTab, removed after animation) */
|
|
.tab-enter>.cd,.tab-enter>.kpi-row,.tab-enter>.exp-wrap,.tab-enter>.ch-c,.tab-enter>div{animation:fi .4s var(--trans) both}
|
|
.tab-enter>.cd:nth-child(2),.tab-enter>div:nth-child(2){animation-delay:.05s}
|
|
.tab-enter>.cd:nth-child(3),.tab-enter>div:nth-child(3){animation-delay:.1s}
|
|
.tab-enter>.cd:nth-child(4),.tab-enter>div:nth-child(4){animation-delay:.15s}
|
|
|
|
/* Alert slide-in */
|
|
.al{padding:.65rem .85rem;border-radius:10px;font-size:.78rem;margin-bottom:.75rem;font-weight:500;animation:alertIn .3s var(--trans) both}
|
|
|
|
/* KPI card stagger — only on tab switch */
|
|
.tab-enter .kpi{animation:fiScale .4s var(--trans) both}
|
|
.tab-enter .kpi:nth-child(2){animation-delay:.06s}
|
|
.tab-enter .kpi:nth-child(3){animation-delay:.12s}
|
|
.tab-enter .kpi:nth-child(4){animation-delay:.18s}
|
|
|
|
/* Enhanced card hover */
|
|
.cd{transition:box-shadow .3s var(--trans),transform .3s var(--trans),border-color .3s var(--trans)}
|
|
.cd:hover{box-shadow:var(--sh2);transform:translateY(-1px);border-color:var(--bdf)}
|
|
|
|
/* Button press effect */
|
|
.btn{transition:all .2s var(--trans);position:relative;overflow:hidden}
|
|
.btn:active:not(:disabled){transform:scale(.97)}
|
|
.bp:active:not(:disabled){transform:scale(.97);box-shadow:0 1px 4px rgba(199,70,52,.2)}
|
|
|
|
/* Skeleton loading */
|
|
.skel{background:linear-gradient(90deg,var(--bg3) 25%,var(--bg4) 50%,var(--bg3) 75%);background-size:200% 100%;animation:shimmer 1.5s ease infinite;border-radius:8px}
|
|
|
|
/* Badge hover */
|
|
.badge{transition:transform .15s var(--trans),box-shadow .15s var(--trans)}
|
|
.badge:hover{transform:scale(1.05)}
|
|
|
|
/* Table row slide-in — only on tab switch */
|
|
.tab-enter tbody tr{animation:fi .3s var(--trans) both}
|
|
.tab-enter tbody tr:nth-child(2){animation-delay:.03s}
|
|
.tab-enter tbody tr:nth-child(3){animation-delay:.06s}
|
|
.tab-enter tbody tr:nth-child(4){animation-delay:.09s}
|
|
.tab-enter tbody tr:nth-child(5){animation-delay:.12s}
|
|
|
|
/* Explorer card stagger — only on tab switch */
|
|
.tab-enter .exp-c{animation:fiScale .3s var(--trans) both}
|
|
.tab-enter .exp-c:nth-child(2){animation-delay:.04s}
|
|
.tab-enter .exp-c:nth-child(3){animation-delay:.08s}
|
|
.tab-enter .exp-c:nth-child(4){animation-delay:.12s}
|
|
.tab-enter .exp-c:nth-child(5){animation-delay:.16s}
|
|
.tab-enter .exp-c:nth-child(6){animation-delay:.2s}
|
|
|
|
/* Nav item hover glow */
|
|
.ni:hover{background:var(--bg2);color:var(--t2);transform:translateX(2px)}
|
|
.ni.on{animation:slideIn .3s var(--trans) both}
|
|
|
|
/* Smooth theme transition */
|
|
html{transition:background .4s ease,color .3s ease}
|
|
html *{transition-property:background-color,border-color,color,box-shadow,opacity;transition-duration:.25s;transition-timing-function:var(--trans)}
|
|
html input,html select,html textarea,html button,html .btn,html .cd,html .kpi,html .exp-c,html .ni,html .badge,html .lo-btn,html .ch-hp,html .ch-side{transition-duration:.25s}
|
|
html .spinner,html [class*="kpi-bar-fill"],html canvas{transition:none!important}
|
|
|
|
/* Chat bubble animation */
|
|
.ch-msg{animation:fi .3s var(--trans) both}
|
|
|
|
/* Dropdown animation */
|
|
.mdrop-dd,.mdrop-list{animation:fiScale .2s var(--trans) both}
|
|
|
|
/* Empty state subtle bounce */
|
|
.emp .eic{animation:fi .5s var(--trans) both}
|
|
|
|
/* Active tab glow */
|
|
.exp-cat.active,.exp-tab.active{transition:all .2s var(--trans)}
|
|
|
|
/* Theme toggle spin */
|
|
.lo-btn svg{transition:transform .4s var(--trans)}
|
|
.lo-btn:hover svg{transform:rotate(25deg)}
|
|
|
|
/* ── 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:var(--pp);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:var(--pp);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:var(--pp);border-bottom-color:var(--pp)}
|
|
.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:var(--t2);background:var(--bg);border-bottom:1px solid var(--bd);display:flex;align-items:center;gap:.4rem}
|
|
.tf-bc-right .tf-term-hdr svg{width:12px;height:12px;fill:var(--t2)}
|
|
.tf-bc-right .tf-term-body{flex:1;overflow:auto;background:var(--bg);padding:.5rem;font-family:var(--fm);font-size:.68rem;line-height:1.5;color:var(--t2);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:var(--pp)}
|
|
.tf-file.editing{border-color:var(--pp);background:rgba(191,90,242,.06)}
|
|
.tf-editor{border:1px solid var(--bd);border-radius:var(--r);overflow:hidden;margin-bottom:.5rem;background:var(--bg2)}
|
|
.tf-editor-hdr{display:flex;align-items:center;gap:.4rem;padding:.35rem .6rem;background:var(--bg3);border-bottom:1px solid var(--bd);font-size:.7rem;font-weight:600;color:var(--pp)}
|
|
.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:var(--t1);background:var(--bg2);padding:.6rem .7rem;box-sizing:border-box;tab-size:2}
|
|
.tf-file-icon{width:32px;height:32px;border-radius:6px;background:linear-gradient(135deg,rgba(191,90,242,.15),rgba(191,90,242,.08));display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
|
.tf-file-icon svg{width:16px;height:16px;fill:var(--pp)}
|
|
.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:var(--pp);background:rgba(191,90,242,.07)}
|
|
.tf-code-block{background:var(--bg2);border:1px solid var(--bd);border-radius:var(--r);overflow:hidden;margin:.6rem 0}
|
|
.tf-code-header{display:flex;align-items:center;gap:.4rem;padding:.4rem .7rem;background:var(--bg3);font-size:.72rem;color:var(--pp);border-bottom:1px solid var(--bd)}
|
|
.tf-code-header span{flex:1;font-weight:600}
|
|
.tf-code-header .btn{color:var(--pp);border-color:var(--bd);background:transparent;font-size:.66rem;padding:2px 8px}
|
|
.tf-code-header .btn:hover{background:var(--bg4)}
|
|
.tf-pre{padding:.85rem;overflow-x:auto;font-size:.74rem;line-height:1.6;color:var(--t2);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:rgba(191,90,242,.15);color:var(--pp)}
|
|
.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-menu{position:absolute;top:100%;right:0;background:var(--bg);border:1px solid var(--bd);border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.12);min-width:220px;z-index:50;padding:.3rem 0;margin-top:4px}
|
|
.tf-menu-item{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;font-size:.72rem;color:var(--t1);cursor:pointer;transition:background .12s}
|
|
.tf-menu-item:hover{background:var(--bg2)}
|
|
.tf-menu-item svg{flex-shrink:0;color:var(--t3)}
|
|
.tf-menu-sub{font-size:.6rem;color:var(--t3);margin-left:auto;white-space:nowrap}
|
|
.tf-menu-item.loading{opacity:.6;pointer-events:none}
|
|
.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.wide{max-width:720px}
|
|
.tf-prompt-ta{width:100%;min-height:320px;max-height:60vh;background:var(--bg2);color:var(--t1);border:1px solid var(--bd);border-radius:6px;padding:.6rem;font-family:monospace;font-size:.72rem;line-height:1.5;resize:vertical;box-sizing:border-box}
|
|
.tf-prompt-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:.6rem}
|
|
.tf-prompt-header h3{margin:0;color:var(--ac);font-size:.82rem}
|
|
.tf-prompt-saved{font-size:.62rem;color:var(--t3);transition:opacity .3s}
|
|
.tf-prompt-name{background:var(--bg2);color:var(--t1);border:1px solid var(--bd);border-radius:4px;padding:.25rem .4rem;font-size:.72rem;width:100%;margin-bottom:.5rem;box-sizing:border-box}
|
|
.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>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
|
</head>
|
|
<body>
|
|
<div id="app"></div>
|
|
<script>
|
|
const V='1.1';
|
|
// Theme: restore from localStorage, default to light
|
|
(function(){const t=localStorage.getItem('theme')||'light';document.documentElement.className=t})();
|
|
function toggleTheme(){const h=document.documentElement;const isDark=h.classList.contains('dark');h.className=isDark?'light':'dark';localStorage.setItem('theme',isDark?'light':'dark');R()}
|
|
// SVG Icon system (Lucide-based, 16x16, currentColor)
|
|
const _i=(d,s=16)=>`<svg viewBox="0 0 24 24" width="${s}" height="${s}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-3px;flex-shrink:0">${d}</svg>`;
|
|
const IC={
|
|
chat:_i('<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>'),
|
|
search:_i('<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>'),
|
|
chart:_i('<path d="M18 20V10M12 20V4M6 20v-6"/>'),
|
|
folder:_i('<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>'),
|
|
cloud:_i('<path d="M18 10h-1.26A8 8 0 109 20h9a5 5 0 000-10z"/>'),
|
|
brain:_i('<path d="M12 5a3 3 0 10-5.997.125 4 4 0 00-2.526 5.77 4 4 0 00.556 6.588A4 4 0 1012 18Z"/><path d="M12 5a3 3 0 115.997.125 4 4 0 012.526 5.77 4 4 0 01-.556 6.588A4 4 0 1112 18Z"/><path d="M12 5v13M9.5 8.5L12 12l2.5-3.5M8 14.5l4-2.5 4 2.5"/>'),
|
|
plug:_i('<path d="M12 22v-5M9 7V2M15 7V2M5 7h14v4a7 7 0 01-14 0V7z"/>'),
|
|
db:_i('<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>'),
|
|
dna:_i('<path d="M2 15c6.667-6 13.333-6 20 0M2 9c6.667 6 13.333 6 20 0"/><path d="M7 21V3M17 21V3"/>'),
|
|
users:_i('<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>'),
|
|
lock:_i('<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/>'),
|
|
log:_i('<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>'),
|
|
ok:_i('<path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>'),
|
|
err:_i('<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>'),
|
|
warn:_i('<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>'),
|
|
wait:_i('<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>'),
|
|
timeout:_i('<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/><path d="M3 12h1M20 12h1"/>'),
|
|
settings:_i('<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>'),
|
|
edit:_i('<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>'),
|
|
plus:_i('<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>'),
|
|
upload:_i('<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>'),
|
|
download:_i('<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>'),
|
|
bot:_i('<rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4M2 13h2M20 13h2"/><line x1="9" y1="15" x2="9" y2="15.01"/><line x1="15" y1="15" x2="15" y2="15.01"/>'),
|
|
wrench:_i('<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/>'),
|
|
clock:_i('<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>'),
|
|
history:_i('<path d="M3 3v5h5"/><path d="M3.05 13A9 9 0 106 5.3L3 8"/>'),
|
|
file:_i('<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/>'),
|
|
spark:_i('<path d="M12 2L9 12l8-4-14 2 5 10 3-10-8 4 14-2z"/>'),
|
|
clip:_i('<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.49"/>'),
|
|
key:_i('<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.78 7.78 5.5 5.5 0 017.78-7.78zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>'),
|
|
building:_i('<rect x="4" y="2" width="16" height="20" rx="2" ry="2"/><path d="M9 22v-4h6v4M8 6h.01M16 6h.01M12 6h.01M12 10h.01M12 14h.01M16 10h.01M16 14h.01M8 10h.01M8 14h.01"/>'),
|
|
user:_i('<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/>'),
|
|
pkg:_i('<path d="M16.5 9.4l-9-5.19M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>'),
|
|
globe:_i('<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>'),
|
|
python:_i('<path d="M12 2C6.5 2 6 4.5 6 4.5V7h6v1H4.5S2 7.5 2 12s2 5 2 5h2v-3s0-2 2-2h6s2 0 2-2V5s0-3-4-3zm-2 2a1 1 0 110 2 1 1 0 010-2z"/><path d="M12 22c5.5 0 6-2.5 6-2.5V17h-6v-1h7.5s2.5.5 2.5-4-2-5-2-5h-2v3s0 2-2 2h-6s-2 0-2 2v5s0 3 4 3zm2-2a1 1 0 110-2 1 1 0 010 2z"/>'),
|
|
terminal:_i('<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>'),
|
|
rocket:_i('<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 00-2.91-.09z"/><path d="M12 15l-3-3a22 22 0 012-3.95A12.88 12.88 0 0122 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 01-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>'),
|
|
refresh:_i('<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>'),
|
|
shield:_i('<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>'),
|
|
eye:_i('<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>'),
|
|
scroll:_i('<path d="M8 21h12a2 2 0 002-2v-2H10v2a2 2 0 01-2 2zm0 0a2 2 0 01-2-2V5a2 2 0 012-2h12v14"/>'),
|
|
link:_i('<path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/>'),
|
|
sun:_i('<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>'),
|
|
moon:_i('<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>'),
|
|
power:_i('<path d="M18.36 6.64a9 9 0 11-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/>'),
|
|
zap:_i('<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>'),
|
|
barChart:_i('<line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/>'),
|
|
computer:_i('<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>'),
|
|
hdd:_i('<line x1="22" y1="12" x2="2" y2="12"/><path d="M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/><line x1="6" y1="16" x2="6.01" y2="16"/><line x1="10" y1="16" x2="10.01" y2="16"/>'),
|
|
container:_i('<path d="M22 7L12 2 2 7v10l10 5 10-5V7z"/><path d="M2 7l10 5 10-5M12 22V12"/>'),
|
|
activity:_i('<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>'),
|
|
}
|
|
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:{},expTreeW:null,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:'',dlSectionFilter:'',
|
|
cisVer:null,cisCheckResult:null,cisUpdating:false,rptHistFilter:'',rptKpi:null,rptKpiLoading:false,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,tfRefStatus:'',tfBottomH:null};
|
|
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();
|
|
const _pg=document.getElementById('pg');if(_pg){const _pc=_pg.querySelector('.pc');if(_pc){_pc.classList.add('tab-enter');setTimeout(()=>_pc.classList.remove('tab-enter'),500)}}
|
|
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.tfBottomH){const bel=document.getElementById('tfBottom');if(bel){bel.style.height=S.tfBottomH+'px';bel.style.flex='none'}}
|
|
if(S.expTreeW){const tel=document.getElementById('exp-tree');if(tel)tel.style.width=S.expTreeW+'px'}
|
|
});
|
|
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();
|
|
const pg=document.getElementById('pg');if(pg){const pc=pg.querySelector('.pc');if(pc){pc.classList.add('tab-enter');setTimeout(()=>pc.classList.remove('tab-enter'),500)}}
|
|
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',IC.chat,'Chat Agent'],['terraform',TF_IC,'Terraform'],['explorer',IC.search,'OCI Explorer'],['report',IC.chart,'Reports'],['downloads',IC.folder,'Downloads']];
|
|
const ctabs=[['oci-config',IC.cloud,'Credenciais OCI'],['genai',IC.brain,'GenAI Config'],['mcp',IC.plug,'MCP Servers'],['adb',IC.db,'ADB Vector'],['embeddings',IC.dna,'Embeddings'],['emb-consult',IC.search,'Consultar Embeddings','sub']];
|
|
const atabs=[['users',IC.users,'Usuários'],['mfa',IC.lock,'MFA'],['audit',IC.log,'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]}')" ${t[3]==='sub'?'style="padding-left:2.2rem;font-size:.78rem;opacity:.9"':''}><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="toggleTheme()" title="Alternar tema">${document.documentElement.classList.contains('dark')?IC.sun:IC.moon}</div>
|
|
<div class="lo-btn" onclick="doLogout()" title="Sair">⏻</div></div></div></div>`}
|
|
|
|
function rTb(){const t={'chat':IC.chat+' AI Agent Chat','terraform':TF_IC+' Terraform Agent','explorer':IC.search+' OCI Account Explorer','report':IC.chart+' Compliance Reports','downloads':IC.folder+' Downloads','oci-config':IC.cloud+' Credenciais OCI','genai':IC.brain+' OCI Generative AI','mcp':IC.plug+' MCP Servers','adb':IC.db+' Autonomous DB Vector','embeddings':IC.dna+' Embeddings','emb-consult':IC.search+' Consultar Embeddings','users':IC.users+' Gerenciar Usuários','mfa':IC.lock+' Autenticação MFA','audit':IC.log+' 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'emb-consult':return rEmbConsultPage();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">${IC.bot}</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.is_active)&&(S.genaiCfg.length||S.ociCfg.length)?'<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>${IC.history} 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>${IC.brain} 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">${IC.settings}</div>
|
|
<div class="ch-icon-btn${S.chatPanel==='logs'?' active':''}" onclick="togglePanel('logs')" title="Chat Logs">${IC.log}</div>
|
|
<div class="ch-icon-btn" onclick="newChat()" title="Nova conversa">${IC.spark}</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>${IC.file} ${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">${IC.clip}</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">${IC.settings} 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"> ${IC.plug} 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">${IC.log} 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',IC.ok+' 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:IC.warn+' 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':'')+IC.clip+' '+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:IC.wait+' _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:IC.warn+' 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:IC.warn+' 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'+IC.wrench+' **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:IC.err+' 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:IC.err+' '+r.content,t:tstamp()});R();scCh();return}
|
|
}catch(e){}}
|
|
S.msgs=S.msgs.filter(x=>!x.thinking);S.msgs.push({r:'assistant',c:IC.timeout+' 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()}
|
|
|
|
function tfToggleMenu(){
|
|
const m=document.getElementById('tfMenu');
|
|
if(!m)return;
|
|
const show=m.style.display==='none';
|
|
m.style.display=show?'block':'none';
|
|
if(show){const h=e=>{if(!m.contains(e.target)&&!e.target.closest('[onclick*="tfToggleMenu"]')){m.style.display='none';document.removeEventListener('click',h)}};setTimeout(()=>document.addEventListener('click',h),0)}
|
|
}
|
|
let _tfPromptSaveTimer=null;
|
|
async function tfOpenPrompt(){
|
|
document.getElementById('tfMenu').style.display='none';
|
|
try{
|
|
const prompts=await $api('/prompts/terraform');
|
|
const active=prompts.find(p=>p.is_active)||prompts[0];
|
|
if(!active){alert('Nenhum prompt encontrado.');return}
|
|
S.tfPrompt={id:active.id,name:active.name,content:active.content};
|
|
S.tfPromptOpen=true;R();
|
|
}catch(e){alert('Erro ao carregar prompt: '+e.message)}
|
|
}
|
|
function tfPromptChanged(field,value){
|
|
if(!S.tfPrompt)return;
|
|
S.tfPrompt[field]=value;
|
|
const el=document.getElementById('tfPromptSaved');
|
|
if(el){el.textContent='Salvando...';el.style.opacity='1'}
|
|
clearTimeout(_tfPromptSaveTimer);
|
|
_tfPromptSaveTimer=setTimeout(async()=>{
|
|
try{
|
|
await $api('/prompts/'+S.tfPrompt.id,{method:'PUT',body:{name:S.tfPrompt.name,content:S.tfPrompt.content,is_active:true}});
|
|
const el=document.getElementById('tfPromptSaved');
|
|
if(el){el.textContent='Salvo ✓';el.style.opacity='1';setTimeout(()=>{if(el)el.style.opacity='.4'},2000)}
|
|
}catch(e){const el=document.getElementById('tfPromptSaved');if(el){el.textContent='Erro ao salvar';el.style.opacity='1'}}
|
|
},800);
|
|
}
|
|
function tfClosePrompt(){S.tfPromptOpen=false;R()}
|
|
|
|
async function tfRefreshRef(){
|
|
const item=document.querySelector('.tf-menu-item');
|
|
const sub=document.getElementById('tfRefStatus');
|
|
if(item)item.classList.add('loading');
|
|
if(sub)sub.textContent='Atualizando...';
|
|
try{
|
|
const r=await $api('/terraform/refresh-reference',{method:'POST'});
|
|
if(r.ok){
|
|
S.tfRefStatus=r.lines+' linhas — '+r.updated_at;
|
|
if(sub)sub.textContent=S.tfRefStatus;
|
|
alert('Referência Terraform atualizada com sucesso!\n'+r.lines+' linhas de recursos OCI.');
|
|
}else{
|
|
alert('Erro ao atualizar: '+(r.error||'Erro desconhecido'));
|
|
if(sub)sub.textContent='Erro';
|
|
}
|
|
}catch(e){alert('Erro: '+e.message);if(sub)sub.textContent='Erro'}
|
|
finally{if(item)item.classList.remove('loading');const m=document.getElementById('tfMenu');if(m)m.style.display='none'}
|
|
}
|
|
|
|
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=[];S.tfPlanOut='';S.tfApplyOut='';S.tfDestroyOut='';
|
|
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;
|
|
const detail=await $api('/terraform/workspaces/'+ws.id);
|
|
S.tfPlanOut=detail.plan_output||'';S.tfApplyOut=detail.apply_output||'';S.tfDestroyOut=detail.destroy_output||'';
|
|
if(detail.tf_code){
|
|
S.tfCode=detail.tf_code;
|
|
const parts=detail.tf_code.split(/^\/\/\s*filename:\s*/m).filter(Boolean);
|
|
let md='';
|
|
if(parts.length>1){
|
|
md=parts.map(p=>{const nl=p.indexOf('\n');const fn=p.substring(0,nl).trim();const code=p.substring(nl+1).trim();return '```hcl\n// filename: '+fn+'\n'+code+'\n```'}).join('\n\n');
|
|
}else{md='```hcl\n'+detail.tf_code+'\n```'}
|
|
const lastMsg=[...d.messages].reverse().find(m=>m.role==='assistant'&&m.content&&(m.content.includes('```plan')||/Resource Plan/.test(m.content)));
|
|
if(lastMsg){
|
|
const pm=lastMsg.content.match(/```plan\s*\n([\s\S]*?)```/);
|
|
if(pm){md+='\n\n```plan\n'+pm[1]+'```'}
|
|
else{
|
|
const fm=lastMsg.content.match(/(\*{0,2}Resource Plan\*{0,2})\s*\n((?:[+~][\s\S]*?)(?=\n\n|\n##|\n\*\*|$))/);
|
|
if(fm)md+='\n\n'+fm[0];
|
|
}
|
|
}
|
|
fmTf(md);
|
|
}
|
|
if(detail.oci_config_id){
|
|
S.tfOci=detail.oci_config_id;
|
|
const oc=S.ociCfg.find(x=>x.id===detail.oci_config_id);
|
|
if(oc)S.tfRegion=oc.region;
|
|
}
|
|
if(detail.compartment_id)S.tfCompartment=detail.compartment_id;
|
|
if(S.tfPlanOut)S.tfBtab='plan';
|
|
else if(S.tfFiles.length)S.tfBtab='files';
|
|
if(S.tfOci){tfLoadComps();if(S.tfCompartment)tfLoadResources()}
|
|
}else{
|
|
S.tfWs=null;S.tfStatus='draft';
|
|
const last=[...d.messages].reverse().find(m=>m.role==='assistant'&&m.content&&m.content.includes('```'));
|
|
if(last)fmTf(last.content);
|
|
}
|
|
}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 _fixSingleLineBlocks(content){
|
|
// Expand ANY single-line HCL block with multiple args into multi-line format.
|
|
// Matches both top-level (variable, resource) AND nested blocks (ingress_security_rules, route_rules, etc.)
|
|
// Does NOT match map assignments (tags = { ... }) because of the "=" before "{"
|
|
// Pattern: identifier ["quoted"]* { key=val, key=val } (no "=" between identifier and "{")
|
|
return content.replace(/^(\s*[a-z]\w*(?:\s+"[^"]*")*)\s*\{\s*(.+?)\s*\}\s*$/gm, (match, header, body) => {
|
|
// Skip if body doesn't have multiple key=value pairs
|
|
let args = body.split(/,\s*/);
|
|
if(args.length < 2){
|
|
args = [];
|
|
const re = /(\w+)\s*=\s*(?:"[^"]*"|\S+)/g;
|
|
let m2;
|
|
while((m2 = re.exec(body)) !== null) args.push(m2[0]);
|
|
}
|
|
if(args.length < 2) return match;
|
|
const indent = header.match(/^(\s*)/)[1];
|
|
return header.trimEnd() + ' {\n' + args.map(a => indent + ' ' + a.trim()).join('\n') + '\n' + indent + '}';
|
|
});
|
|
}
|
|
|
|
function _splitTfMonolith(content){
|
|
// Split a monolithic HCL file into multiple logical files by parsing top-level blocks.
|
|
// Returns array of {name, content} or null if splitting not needed.
|
|
content = _fixSingleLineBlocks(content);
|
|
const lines=content.split('\n');
|
|
const topBlocks=[];
|
|
let preamble=[]; // comments/blanks before next block
|
|
let accum=[];
|
|
let inBlock=false;
|
|
let bDepth=0;
|
|
let curHeader='';
|
|
|
|
for(let i=0;i<lines.length;i++){
|
|
const line=lines[i];
|
|
// Count braces outside of quoted strings (simple heuristic: ignore braces inside "...")
|
|
const stripped=line.replace(/"[^"]*"/g,'');
|
|
const open=(stripped.match(/\{/g)||[]).length;
|
|
const close=(stripped.match(/\}/g)||[]).length;
|
|
|
|
if(!inBlock){
|
|
// Match top-level block declarations only (not attribute assignments like "provider = oci.x")
|
|
const hdr=line.match(/^\s*(variable|output|resource|data|provider|module)\s+"/) || line.match(/^\s*(locals|terraform)\s*\{/);
|
|
if(hdr){
|
|
// Start of a new block — attach preamble (comments above)
|
|
inBlock=true;
|
|
curHeader=line;
|
|
accum=preamble.concat([line]);
|
|
preamble=[];
|
|
bDepth=open-close;
|
|
if(bDepth<=0) bDepth=0;
|
|
// Single-line block (opens and closes on same line)
|
|
if(bDepth===0 && open>0){
|
|
topBlocks.push({header:curHeader,body:accum.join('\n')});
|
|
inBlock=false; accum=[];
|
|
}
|
|
} else {
|
|
// Comment, blank, or stray line between blocks — save as preamble for next block
|
|
preamble.push(line);
|
|
}
|
|
} else {
|
|
accum.push(line);
|
|
bDepth+=open-close;
|
|
if(bDepth<=0){
|
|
bDepth=0;
|
|
topBlocks.push({header:curHeader,body:accum.join('\n')});
|
|
inBlock=false; accum=[]; curHeader='';
|
|
}
|
|
}
|
|
}
|
|
// Flush remaining
|
|
if(accum.length) topBlocks.push({header:curHeader||'',body:accum.join('\n')});
|
|
|
|
if(topBlocks.length<3) return null;
|
|
|
|
// Categorize each block by its header
|
|
const cats={variables:[],outputs:[],networking:[],compute:[],database:[],
|
|
firewall:[],loadbalancer:[],storage:[],iam:[],drg:[],data:[],providers:[],other:[]};
|
|
for(const b of topBlocks){
|
|
const h=b.header.trim();
|
|
if(h.startsWith('variable ')) cats.variables.push(b.body);
|
|
else if(h.startsWith('output ')) cats.outputs.push(b.body);
|
|
else if(h.startsWith('provider ')) cats.providers.push(b.body);
|
|
else if(/resource\s+"oci_core_(drg|remote_peering|drg_route|drg_attachment)/.test(h)) cats.drg.push(b.body);
|
|
else if(/resource\s+"oci_core_(vcn|subnet|internet_gateway|nat_gateway|service_gateway|route_table|security_list|network_security_group|dhcp_options|local_peering_gateway|virtual_circuit)/.test(h)) cats.networking.push(b.body);
|
|
else if(/resource\s+"oci_core_instance/.test(h)) cats.compute.push(b.body);
|
|
else if(/resource\s+"oci_(database|nosql|mysql|psql)/.test(h) || /resource\s+"oci_core_autonomous/.test(h)) cats.database.push(b.body);
|
|
else if(/resource\s+"oci_network_firewall/.test(h)) cats.firewall.push(b.body);
|
|
else if(/resource\s+"oci_(load_balancer|network_load_balancer)/.test(h)) cats.loadbalancer.push(b.body);
|
|
else if(/resource\s+"oci_(objectstorage|file_storage)/.test(h)) cats.storage.push(b.body);
|
|
else if(/resource\s+"oci_identity/.test(h)) cats.iam.push(b.body);
|
|
else if(h.startsWith('data ')) cats.data.push(b.body);
|
|
else cats.other.push(b.body);
|
|
}
|
|
|
|
// Build result files in logical order
|
|
const result=[];
|
|
const add=(name,arr)=>{if(arr.length) result.push({name,content:arr.join('\n\n')})};
|
|
add('variables.tf', cats.variables);
|
|
add('providers.tf', cats.providers);
|
|
add('networking.tf', cats.networking);
|
|
add('drg.tf', cats.drg);
|
|
add('compute.tf', cats.compute);
|
|
add('database.tf', cats.database);
|
|
add('firewall.tf', cats.firewall);
|
|
add('loadbalancer.tf', cats.loadbalancer);
|
|
add('storage.tf', cats.storage);
|
|
add('iam.tf', cats.iam);
|
|
add('data.tf', cats.data);
|
|
add('main.tf', cats.other);
|
|
add('outputs.tf', cats.outputs);
|
|
|
|
return result.length>=3 ? result : null;
|
|
}
|
|
|
|
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=_fixSingleLineBlocks(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 _renderTfPlanBlock();
|
|
});
|
|
// Also match free-form "Resource Plan" / "**Resource Plan**" followed by +/~ lines (model often skips ```plan block)
|
|
if(!S.tfPlan.length){
|
|
r=r.replace(/(\*{0,2}Resource Plan\*{0,2})\s*\n((?:[+~][\s\S]*?)(?=\n\n|\n##|\n\*\*|$))/g,(m,hdr,plan)=>{
|
|
const lines=plan.trim().split('\n').filter(l=>/^[+~]/.test(l.trim()));
|
|
S.tfPlan=lines.map(l=>{const clean=l.trim().replace(/^[+~]\s*/,'').replace(/\s{2,}/g,' ').trim();return{type:clean,desc:''}}).filter(x=>x.type);
|
|
return _renderTfPlanBlock();
|
|
});
|
|
}
|
|
function _renderTfPlanBlock(){
|
|
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>'+(x.desc?'<span class="tf-plan-desc">'+escHtml(x.desc)+'</span>':'')+'</div>').join('')+'</div></div>';
|
|
}
|
|
// Auto-split: if model returned 1-2 large blocks, split into multiple files
|
|
// Runs on BOTH first generation and corrections — monolithic responses always get split
|
|
let didAutoSplit=false;
|
|
if(codeBlocks.length<=2){
|
|
const totalContent=codeBlocks.map(b=>b.content).join('\n\n');
|
|
if(totalContent.length>2000){
|
|
const split=_splitTfMonolith(totalContent);
|
|
if(split){
|
|
codeBlocks.length=0;
|
|
split.forEach(f=>codeBlocks.push(f));
|
|
didAutoSplit=true;
|
|
// Rebuild HTML with split files
|
|
const splitHtml=split.map((f,i)=>
|
|
'<div class="tf-code-block"><div class="tf-code-header"><span>'+TF_ICON+' '+escHtml(f.name)+'</span>'+
|
|
'<button class="btn" onclick="tfCopyBlock('+i+')">Copy</button></div>'+
|
|
'<pre class="tf-pre"><code>'+escHtml(f.content)+'</code></pre></div>'
|
|
).join('');
|
|
// Replace the original single code block HTML
|
|
r=r.replace(/<div class="tf-code-block">[\s\S]*?<\/pre><\/div>/g,'');
|
|
r=splitHtml+r;
|
|
console.log('[TF] Auto-split monolithic file into '+split.length+' files');
|
|
}
|
|
}
|
|
}
|
|
// Build files list
|
|
if(codeBlocks.length){
|
|
const newMap={};
|
|
codeBlocks.forEach(b=>{
|
|
if(newMap[b.name])newMap[b.name]+='\n\n'+b.content;
|
|
else newMap[b.name]=b.content;
|
|
});
|
|
// If auto-split happened, REPLACE all files (monolithic = full regeneration, not partial correction)
|
|
// If model returned multiple named files (no auto-split), use merge logic for corrections
|
|
if(didAutoSplit || !S.tfFiles.length){
|
|
S.tfFiles=Object.entries(newMap).map(([name,content])=>({name,content,size:content.length,type:'hcl'}));
|
|
}else{
|
|
const merged=[];
|
|
const usedNew=new Set();
|
|
for(const f of S.tfFiles){
|
|
if(newMap[f.name]!=null){merged.push({name:f.name,content:newMap[f.name],size:newMap[f.name].length,type:'hcl'});usedNew.add(f.name)}
|
|
else{merged.push(f)}
|
|
}
|
|
for(const[name,content] of Object.entries(newMap)){
|
|
if(!usedNew.has(name))merged.push({name,content,size:content.length,type:'hcl'})
|
|
}
|
|
S.tfFiles=merged;
|
|
}
|
|
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>${IC.history} 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 style="position:relative">
|
|
<div class="tf-file-btn" onclick="tfToggleMenu()" title="Menu" style="width:24px;height:24px;cursor:pointer"><svg viewBox="0 0 16 16" width="14" height="14" fill="var(--t3)"><path d="M8 4.5a1.5 1.5 0 110-3 1.5 1.5 0 010 3zm0 5a1.5 1.5 0 110-3 1.5 1.5 0 010 3zm0 5a1.5 1.5 0 110-3 1.5 1.5 0 010 3z"/></svg></div>
|
|
<div class="tf-menu" id="tfMenu" style="display:none">
|
|
<div class="tf-menu-item" onclick="tfOpenPrompt()">
|
|
<svg viewBox="0 0 16 16" width="13" height="13" fill="currentColor"><path d="M13.5 2.5l-1-1L3 11l-1 3 3-1L14.5 3.5l-1-1zM2.5 13.5l.7-2.1 1.4 1.4-2.1.7z"/></svg>
|
|
<span>Editar Prompt</span>
|
|
</div>
|
|
<div class="tf-menu-item" onclick="tfRefreshRef()">
|
|
<svg viewBox="0 0 16 16" width="13" height="13" fill="currentColor"><path d="M8 1a7 7 0 106.33 4h-2.1A5 5 0 108 13a5 5 0 004.24-2.36l1.52 1.02A7 7 0 018 1z"/><path d="M11 1v4h4" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
|
|
<span>Atualizar Referência TF</span>
|
|
<span class="tf-menu-sub" id="tfRefStatus">${S.tfRefStatus||''}</span>
|
|
</div>
|
|
</div>
|
|
</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():''}
|
|
${S.tfPromptOpen?rTfPromptModal():''}
|
|
</div>`;
|
|
}
|
|
function rTfPromptModal(){
|
|
const p=S.tfPrompt||{};
|
|
return`<div class="tf-modal-overlay" onclick="tfClosePrompt()"><div class="tf-modal wide" onclick="event.stopPropagation()">
|
|
<div class="tf-prompt-header"><h3>Editar Prompt — Terraform Agent</h3><span id="tfPromptSaved" class="tf-prompt-saved" style="opacity:.4">Salvo ✓</span></div>
|
|
<input class="tf-prompt-name" value="${(p.name||'').replace(/"/g,'"')}" placeholder="Nome do prompt" oninput="tfPromptChanged('name',this.value)">
|
|
<textarea class="tf-prompt-ta" oninput="tfPromptChanged('content',this.value)">${p.content||''}</textarea>
|
|
<div style="text-align:right;margin-top:.6rem"><button class="btn bs" onclick="tfClosePrompt()">Fechar</button></div>
|
|
</div></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:var(--pp);color:#fff;border-color:var(--pp);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:var(--pp);color:#fff;border-color:var(--pp);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:var(--pp);color:#fff;border-color:var(--pp);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:var(--pp);color:#fff;border-color:var(--pp);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;const h=Math.max(100,Math.min(startH+dh,window.innerHeight-200));el.style.height=h+'px';el.style.flex='none';S.tfBottomH=h}
|
|
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>`}},
|
|
{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>`}},
|
|
{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;
|
|
}
|
|
async function ociInstanceAction(id,action){
|
|
const label=action==='START'?'Iniciar':'Parar';
|
|
if(!confirm(label+' esta instância?'))return;
|
|
try{await $api('/oci/instances/'+id+'/action',{method:'POST',body:{action,oci_config_id:S.expCfg,region:null}});expPollState(action==='START'?'RUNNING':'STOPPED')}catch(e){alert('Erro: '+e.message)}
|
|
}
|
|
async function ociAdbAction(id,action){
|
|
const label=action==='start'?'Iniciar':'Parar';
|
|
if(!confirm(label+' este Autonomous Database?'))return;
|
|
try{await $api('/oci/autonomous-databases/'+id+'/action',{method:'POST',body:{action,oci_config_id:S.expCfg,region:null}});expPollState(action==='start'?'AVAILABLE':'STOPPED')}catch(e){alert('Erro: '+e.message)}
|
|
}
|
|
async function ociAdbUpdateNet(id){
|
|
try{
|
|
const ipResp=await $api('/oci/my-ip');
|
|
let ip=ipResp.ip||'';
|
|
ip=prompt('Adicionar IP à ACL do Autonomous Database.\nSeu IP detectado:',ip);
|
|
if(!ip)return;
|
|
await $api('/oci/autonomous-databases/'+id+'/update-network-access',{method:'POST',body:{ip,oci_config_id:S.expCfg}});
|
|
alert('IP '+ip+' adicionado à ACL com sucesso.');
|
|
expLoadRes();
|
|
}catch(e){alert('Erro: '+e.message)}
|
|
}
|
|
function expPollState(target){
|
|
let tries=0;const max=12;
|
|
const poll=async()=>{
|
|
await expLoadRes();
|
|
const items=S.expData||[];
|
|
const reached=items.some(i=>i.lifecycle_state===target);
|
|
if(reached||++tries>=max)return;
|
|
setTimeout(poll,5000);
|
|
};
|
|
setTimeout(poll,3000);
|
|
}
|
|
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.tfWs)try{await $api('/terraform/workspaces/'+S.tfWs,{method:'DELETE'})}catch(e){}
|
|
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:IC.warn+' Selecione um modelo antes de enviar.',t:tstamp()});R();return}
|
|
if(!S.tfCompartment){S.tfMsgs.push({r:'assistant',c:IC.warn+' Selecione um compartment antes de enviar.',t:tstamp()});R();return}
|
|
el.value='';S.tfMsgs.push({r:'user',c:m,t:tstamp()});R();tfScroll(); // show original msg without injected files
|
|
S.tfMsgs.push({r:'assistant',c:IC.wait+' _Gerando Terraform..._',thinking:true});R();tfScroll();
|
|
try{
|
|
// Inject existing files context when user is correcting/fixing
|
|
let finalMsg=m;
|
|
if(S.tfFiles.length>0){
|
|
const filesCtx=S.tfFiles.map(f=>'// filename: '+f.name+'\n'+f.content).join('\n\n');
|
|
finalMsg+='\n\n---\nARQUIVOS TERRAFORM ATUAIS NO WORKSPACE (corrija estes arquivos):\n```hcl\n'+filesCtx+'\n```';
|
|
}
|
|
if(S.tfPlanOut){finalMsg+='\n\n---\nÚLTIMO OUTPUT DO TERRAFORM PLAN:\n```\n'+S.tfPlanOut.slice(-3000)+'\n```'}
|
|
if(S.tfApplyOut){finalMsg+='\n\n---\nÚLTIMO OUTPUT DO TERRAFORM APPLY:\n```\n'+S.tfApplyOut.slice(-3000)+'\n```'}
|
|
const body={message:finalMsg,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:IC.warn+' 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:IC.err+' 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:IC.err+' '+r.content,t:tstamp()});R();tfScroll();return}
|
|
}catch(e){}
|
|
}
|
|
S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:IC.timeout+' 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.tfApplyOut='';S.tfDestroyOut='';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<1800;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;
|
|
const done=!['planning','applying','destroying'].includes(r.status);
|
|
if(done)S.tfRunning=false;
|
|
// Update only the terminal body without full re-render
|
|
const t=document.getElementById('tfTermOut');
|
|
if(t){
|
|
const content=S.tfDestroyOut||S.tfApplyOut||S.tfPlanOut||'';
|
|
t.innerHTML=content?escHtml(content):'<span style="opacity:.4">Aguardando execução...</span>';
|
|
t.scrollTop=t.scrollHeight;
|
|
}
|
|
if(done){R();return}
|
|
}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">${IC.cloud}</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:IC.computer,Rede:IC.globe,Storage:IC.hdd,Database:IC.db,Containers:IC.container,Serverless:IC.zap,Observabilidade:IC.activity,Segurança:IC.shield,IAM:IC.user};
|
|
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">${IC.folder} 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>${IC.globe} ${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';S.expTreeW=w};
|
|
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==='nsgs')return rExpNsgs(d);
|
|
if(S.expResType==='route_tables')return rExpRouteTables(d);
|
|
const isInst=S.expResType==='instances';
|
|
const isAdb=S.expResType==='databases';
|
|
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','whitelisted_ips'];
|
|
const aclHtml=isAdb&&i.whitelisted_ips&&i.whitelisted_ips.length?'<br><span style="color:var(--ac);font-size:.66rem;font-weight:600">ACL:</span> <span class="em" style="font-size:.66rem">'+i.whitelisted_ips.join(', ')+'</span>':'';
|
|
let actionBtn='';
|
|
if(isInst&&i.lifecycle_state==='RUNNING')actionBtn=' <button class="btn bsm" style="font-size:.6rem;padding:1px 8px;background:var(--rd);color:#fff;border-color:var(--rd);margin-left:6px" onclick="event.stopPropagation();ociInstanceAction(\''+i.id+'\',\'STOP\')">■ Stop</button>';
|
|
else if(isInst&&i.lifecycle_state==='STOPPED')actionBtn=' <button class="btn bsm" style="font-size:.6rem;padding:1px 8px;background:var(--gn);color:#fff;border-color:var(--gn);margin-left:6px" onclick="event.stopPropagation();ociInstanceAction(\''+i.id+'\',\'START\')">▶ Start</button>';
|
|
else if(isAdb&&i.lifecycle_state==='AVAILABLE')actionBtn=' <button class="btn bsm" style="font-size:.6rem;padding:1px 8px;background:var(--rd);color:#fff;border-color:var(--rd);margin-left:6px" onclick="event.stopPropagation();ociAdbAction(\''+i.id+'\',\'stop\')">■ Stop</button> <button class="btn bsm" style="font-size:.6rem;padding:1px 8px;background:var(--ac);color:#fff;border-color:var(--ac);margin-left:2px" onclick="event.stopPropagation();ociAdbUpdateNet(\''+i.id+'\')">'+IC.shield+' Update Network Access</button>';
|
|
else if(isAdb&&i.lifecycle_state==='STOPPED')actionBtn=' <button class="btn bsm" style="font-size:.6rem;padding:1px 8px;background:var(--gn);color:#fff;border-color:var(--gn);margin-left:6px" onclick="event.stopPropagation();ociAdbAction(\''+i.id+'\',\'start\')">▶ Start</button> <button class="btn bsm" style="font-size:.6rem;padding:1px 8px;background:var(--ac);color:#fff;border-color:var(--ac);margin-left:2px" onclick="event.stopPropagation();ociAdbUpdateNet(\''+i.id+'\')">'+IC.shield+' Update Network Access</button>';
|
|
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>`:''}${actionBtn}${aclHtml}<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];
|
|
const showForm=S._slAddForm===sl.id;
|
|
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">
|
|
<div style="display:flex;justify-content:flex-end;margin-bottom:.4rem"><button class="btn bp bsm" onclick="event.stopPropagation();S._slAddForm=S._slAddForm==='${sl.id}'?null:'${sl.id}';R()" style="font-size:.62rem">${showForm?'✕ Cancelar':'+ Adicionar Regra'}</button></div>
|
|
${showForm?`<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:.6rem;margin-bottom:.6rem" onclick="event.stopPropagation()">
|
|
<div style="font-size:.7rem;font-weight:600;color:var(--t2);margin-bottom:.4rem">${IC.shield} Nova Regra</div>
|
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:.4rem">
|
|
<div class="ig"><label style="font-size:.62rem">Direção</label><select id="slDir" style="font-size:.72rem"><option value="ingress">Ingress</option><option value="egress">Egress</option></select></div>
|
|
<div class="ig"><label style="font-size:.62rem">Protocolo</label><select id="slProto" style="font-size:.72rem"><option value="6">TCP</option><option value="17">UDP</option><option value="1">ICMP</option><option value="all">ALL</option></select></div>
|
|
<div class="ig"><label style="font-size:.62rem">Stateless</label><select id="slStateless" style="font-size:.72rem"><option value="false">Não</option><option value="true">Sim</option></select></div>
|
|
<div class="ig"><label style="font-size:.62rem">Origem/Destino (CIDR)</label><input id="slCidr" style="font-size:.72rem" placeholder="0.0.0.0/0" value="0.0.0.0/0"></div>
|
|
<div class="ig"><label style="font-size:.62rem">Porta Início</label><input id="slPortMin" type="number" style="font-size:.72rem" placeholder="ex: 443"></div>
|
|
<div class="ig"><label style="font-size:.62rem">Porta Fim</label><input id="slPortMax" type="number" style="font-size:.72rem" placeholder="ex: 443"></div>
|
|
</div>
|
|
<div class="ig" style="margin-top:.3rem"><label style="font-size:.62rem">Descrição</label><input id="slDesc" style="font-size:.72rem" placeholder="Descrição da regra"></div>
|
|
<div style="display:flex;gap:.3rem;margin-top:.5rem;justify-content:flex-end"><button class="btn bp bsm" onclick="slAddRule('${sl.id}')" style="font-size:.64rem">Salvar Regra</button>
|
|
<button class="btn bs bsm" onclick="slAddMyIp('${sl.id}')" style="font-size:.64rem" title="Detecta seu IP público e adiciona como regra">${IC.globe} Meu IP</button></div>
|
|
<div id="slFormMsg" style="margin-top:.3rem"></div>
|
|
</div>`:''}
|
|
${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><th style="padding:.25rem .4rem;width:32px"></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><td style="padding:.25rem .2rem"><button class="btn bd bsm" style="font-size:.55rem;padding:1px 5px" onclick="event.stopPropagation();slRemoveRule('${sl.id}','ingress',${r.rule_index})" title="Remover regra">✕</button></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><th style="padding:.25rem .4rem;width:32px"></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><td style="padding:.25rem .2rem"><button class="btn bd bsm" style="font-size:.55rem;padding:1px 5px" onclick="event.stopPropagation();slRemoveRule('${sl.id}','egress',${r.rule_index})" title="Remover regra">✕</button></td></tr>`).join('')}
|
|
</tbody></table>`:''}</div>`:''}</div>`}).join('')}`}
|
|
async function slAddRule(slId){
|
|
const dir=document.getElementById('slDir').value;
|
|
const proto=document.getElementById('slProto').value;
|
|
const cidr=document.getElementById('slCidr').value.trim();
|
|
const portMin=document.getElementById('slPortMin').value;
|
|
const portMax=document.getElementById('slPortMax').value;
|
|
const desc=document.getElementById('slDesc').value;
|
|
const stateless=document.getElementById('slStateless').value==='true';
|
|
if(!cidr){sm('slFormMsg','Informe o CIDR.','e');return}
|
|
const region=S.expSelRegions.length===1?S.expSelRegions[0]:null;
|
|
const body={direction:dir,protocol:proto,source_dest:cidr,description:desc,is_stateless:stateless,region:region||undefined};
|
|
if(proto!=='all'&&proto!=='1'&&portMin)Object.assign(body,{port_min:parseInt(portMin),port_max:parseInt(portMax||portMin)});
|
|
try{const d=await $api('/oci/explore/'+S.expCfg+'/security_lists/'+slId+'/rules',{method:'POST',body});
|
|
sm('slFormMsg',IC.ok+' '+d.message,'s');S._slAddForm=null;await expLoadRes()}catch(e){sm('slFormMsg',e.message,'e')}}
|
|
async function slRemoveRule(slId,direction,ruleIndex){
|
|
if(!confirm('Remover esta regra da Security List?'))return;
|
|
const region=S.expSelRegions.length===1?S.expSelRegions[0]:null;
|
|
try{await $api('/oci/explore/'+S.expCfg+'/security_lists/'+slId+'/rules',{method:'DELETE',body:{direction,rule_index:ruleIndex,region:region||undefined}});await expLoadRes()}catch(e){alert('Erro: '+e.message)}}
|
|
async function slAddMyIp(slId){
|
|
try{const resp=await fetch('https://api.ipify.org?format=json');const data=await resp.json();const myIp=data.ip+'/32';
|
|
document.getElementById('slCidr').value=myIp;
|
|
document.getElementById('slDesc').value='Acesso temporário - '+myIp;
|
|
sm('slFormMsg',IC.ok+' IP detectado: '+myIp+' — ajuste porta e clique Salvar','s')}catch(e){sm('slFormMsg','Não foi possível detectar o IP: '+e.message,'e')}}
|
|
function rExpNsgs(d){
|
|
return`<div style="font-size:.74rem;color:var(--t4);margin-bottom:.5rem">${d.length} NSG(s)</div>${d.map((nsg,idx)=>{
|
|
const rules=nsg.rules||[];
|
|
const ingress=rules.filter(r=>r.direction==='INGRESS');
|
|
const egress=rules.filter(r=>r.direction==='EGRESS');
|
|
const expanded=S._expExpanded&&S._expExpanded['nsg'+idx];
|
|
const showForm=S._nsgAddForm===nsg.id;
|
|
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['nsg'+${idx}]=!S._expExpanded['nsg'+${idx}];R()">
|
|
<div><strong style="display:inline">${nsg.display_name}</strong> <span class="badge ${nsg.lifecycle_state==='AVAILABLE'?'b-pass':'b-pend'}">${nsg.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: ${nsg.id}</div>
|
|
${expanded?`<div style="margin-top:.5rem">
|
|
<div style="display:flex;justify-content:flex-end;margin-bottom:.4rem"><button class="btn bp bsm" onclick="event.stopPropagation();S._nsgAddForm=S._nsgAddForm==='${nsg.id}'?null:'${nsg.id}';R()" style="font-size:.62rem">${showForm?'✕ Cancelar':'+ Adicionar Regra'}</button></div>
|
|
${showForm?`<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:.6rem;margin-bottom:.6rem" onclick="event.stopPropagation()">
|
|
<div style="font-size:.7rem;font-weight:600;color:var(--t2);margin-bottom:.4rem">${IC.shield} Nova Regra</div>
|
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:.4rem">
|
|
<div class="ig"><label style="font-size:.62rem">Direção</label><select id="nsgDir" style="font-size:.72rem"><option value="INGRESS">Ingress</option><option value="EGRESS">Egress</option></select></div>
|
|
<div class="ig"><label style="font-size:.62rem">Protocolo</label><select id="nsgProto" style="font-size:.72rem"><option value="6">TCP</option><option value="17">UDP</option><option value="1">ICMP</option><option value="all">ALL</option></select></div>
|
|
<div class="ig"><label style="font-size:.62rem">Stateless</label><select id="nsgStateless" style="font-size:.72rem"><option value="false">Não</option><option value="true">Sim</option></select></div>
|
|
<div class="ig"><label style="font-size:.62rem">Origem/Destino (CIDR)</label><input id="nsgCidr" style="font-size:.72rem" placeholder="0.0.0.0/0" value="0.0.0.0/0"></div>
|
|
<div class="ig"><label style="font-size:.62rem">Porta Início</label><input id="nsgPortMin" type="number" style="font-size:.72rem" placeholder="ex: 443"></div>
|
|
<div class="ig"><label style="font-size:.62rem">Porta Fim</label><input id="nsgPortMax" type="number" style="font-size:.72rem" placeholder="ex: 443"></div>
|
|
</div>
|
|
<div class="ig" style="margin-top:.3rem"><label style="font-size:.62rem">Descrição</label><input id="nsgDesc" style="font-size:.72rem" placeholder="Descrição da regra"></div>
|
|
<div style="display:flex;gap:.3rem;margin-top:.5rem;justify-content:flex-end"><button class="btn bp bsm" onclick="nsgAddRule('${nsg.id}')" style="font-size:.64rem">Salvar Regra</button>
|
|
<button class="btn bs bsm" onclick="nsgAddMyIp('${nsg.id}')" style="font-size:.64rem" title="Detecta seu IP público e adiciona como regra">${IC.globe} Meu IP</button></div>
|
|
<div id="nsgFormMsg" style="margin-top:.3rem"></div>
|
|
</div>`:''}
|
|
${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><th style="padding:.25rem .4rem;width:32px"></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.is_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><td style="padding:.25rem .2rem"><button class="btn bd bsm" style="font-size:.55rem;padding:1px 5px" onclick="event.stopPropagation();nsgRemoveRule('${nsg.id}','${r.id}')" title="Remover regra">✕</button></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><th style="padding:.25rem .4rem;width:32px"></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.is_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><td style="padding:.25rem .2rem"><button class="btn bd bsm" style="font-size:.55rem;padding:1px 5px" onclick="event.stopPropagation();nsgRemoveRule('${nsg.id}','${r.id}')" title="Remover regra">✕</button></td></tr>`).join('')}
|
|
</tbody></table>`:''}</div>`:''}</div>`}).join('')}`}
|
|
async function nsgAddRule(nsgId){
|
|
const dir=document.getElementById('nsgDir').value;
|
|
const proto=document.getElementById('nsgProto').value;
|
|
const cidr=document.getElementById('nsgCidr').value.trim();
|
|
const portMin=document.getElementById('nsgPortMin').value;
|
|
const portMax=document.getElementById('nsgPortMax').value;
|
|
const desc=document.getElementById('nsgDesc').value;
|
|
const stateless=document.getElementById('nsgStateless').value==='true';
|
|
if(!cidr){sm('nsgFormMsg','Informe o CIDR.','e');return}
|
|
const region=S.expSelRegions.length===1?S.expSelRegions[0]:null;
|
|
const body={direction:dir,protocol:proto,source_dest:cidr,source_dest_type:'CIDR_BLOCK',description:desc,is_stateless:stateless,region:region||undefined};
|
|
if(proto!=='all'&&proto!=='1'&&portMin)Object.assign(body,{port_min:parseInt(portMin),port_max:parseInt(portMax||portMin)});
|
|
try{const d=await $api('/oci/explore/'+S.expCfg+'/nsgs/'+nsgId+'/rules',{method:'POST',body});
|
|
sm('nsgFormMsg',IC.ok+' '+d.message,'s');S._nsgAddForm=null;await expLoadRes()}catch(e){sm('nsgFormMsg',e.message,'e')}}
|
|
async function nsgRemoveRule(nsgId,ruleId){
|
|
if(!confirm('Remover esta regra do NSG?'))return;
|
|
const region=S.expSelRegions.length===1?S.expSelRegions[0]:null;
|
|
const q=region?'?region='+region:'';
|
|
try{await $api('/oci/explore/'+S.expCfg+'/nsgs/'+nsgId+'/rules/'+ruleId+q,{method:'DELETE'});await expLoadRes()}catch(e){alert('Erro: '+e.message)}}
|
|
async function nsgAddMyIp(nsgId){
|
|
try{const resp=await fetch('https://api.ipify.org?format=json');const data=await resp.json();const myIp=data.ip+'/32';
|
|
document.getElementById('nsgCidr').value=myIp;
|
|
document.getElementById('nsgDesc').value='Acesso temporário - '+myIp;
|
|
sm('nsgFormMsg',IC.ok+' IP detectado: '+myIp+' — ajuste porta e clique Salvar','s')}catch(e){sm('nsgFormMsg','Não foi possível detectar o IP: '+e.message,'e')}}
|
|
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">${IC.chart} Compliance Reports</div><div class="emp"><div class="eic">${IC.file}</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;if(!S.rptKpi&&!S.rptKpiLoading)loadRptKpi(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>
|
|
${rKpi()}<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">${IC.rocket} 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">${IC.wait} 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">${IC.settings} 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';loadRptKpi(id);R()}
|
|
async function loadRptKpi(rid){S.rptKpiLoading=true;S.rptKpi=null;R();try{S.rptKpi=await $api('/reports/'+rid+'/summary')}catch(e){S.rptKpi=null}S.rptKpiLoading=false;R()}
|
|
let _kpiCharts={};
|
|
function _destroyKpiCharts(){Object.values(_kpiCharts).forEach(c=>{try{c.destroy()}catch(e){}});_kpiCharts={}}
|
|
function _initKpiCharts(){
|
|
const k=S.rptKpi;if(!k||!k.sections)return;
|
|
const cs=getComputedStyle(document.documentElement);
|
|
const gn=cs.getPropertyValue('--gn').trim();
|
|
const rd=cs.getPropertyValue('--rd').trim();
|
|
const bl=cs.getPropertyValue('--bl').trim();
|
|
const t3=cs.getPropertyValue('--t3').trim();
|
|
const t4=cs.getPropertyValue('--t4').trim();
|
|
const bg3=cs.getPropertyValue('--bg3').trim();
|
|
const fm=cs.getPropertyValue('--fm').trim();
|
|
// Donut
|
|
const dc=document.getElementById('kpi-donut');
|
|
if(dc){_kpiCharts.donut=new Chart(dc,{type:'doughnut',data:{labels:['Compliant','Non-Compliant'],datasets:[{data:[k.passed,k.failed],backgroundColor:[gn,rd],borderWidth:0,hoverOffset:6}]},options:{responsive:true,maintainAspectRatio:false,cutout:'72%',plugins:{legend:{display:false},tooltip:{backgroundColor:'rgba(0,0,0,.8)',titleFont:{family:fm,size:11},bodyFont:{family:fm,size:11},padding:10,cornerRadius:8}}}})}
|
|
// Horizontal bar
|
|
const secs=Object.entries(k.sections).sort((a,b)=>b[1].failed-a[1].failed);
|
|
const bc=document.getElementById('kpi-bar');
|
|
if(bc&&secs.length){_kpiCharts.bar=new Chart(bc,{type:'bar',data:{labels:secs.map(([n])=>n.length>22?n.slice(0,20)+'…':n),datasets:[{label:'Compliant',data:secs.map(([,s])=>s.passed),backgroundColor:gn,borderRadius:4,barPercentage:.7},{label:'Non-Compliant',data:secs.map(([,s])=>s.failed),backgroundColor:rd,borderRadius:4,barPercentage:.7}]},options:{indexAxis:'y',responsive:true,maintainAspectRatio:false,scales:{x:{stacked:true,grid:{color:bg3},ticks:{font:{family:fm,size:10},color:t4}},y:{stacked:true,grid:{display:false},ticks:{font:{family:fm,size:11},color:t3}}},plugins:{legend:{position:'top',labels:{font:{family:fm,size:11},color:t3,boxWidth:12,boxHeight:12,borderRadius:3,useBorderRadius:true,padding:12}},tooltip:{backgroundColor:'rgba(0,0,0,.8)',titleFont:{family:fm,size:11},bodyFont:{family:fm,size:11},padding:10,cornerRadius:8}}}})}
|
|
}
|
|
function _svgGauge(score){
|
|
const r=58,cx=65,cy=65,sw=12;
|
|
const circ=Math.PI*r;
|
|
const fill=circ*score/100;
|
|
const col=score>=80?'var(--gn)':score>=60?'var(--yl)':'var(--rd)';
|
|
const ang=-90+(score/100*180);
|
|
const rad=ang*Math.PI/180;
|
|
const nx=cx+Math.cos(rad)*(r-1);
|
|
const ny=cy+Math.sin(rad)*(r-1);
|
|
return`<svg viewBox="0 0 130 80" width="180" height="110" style="display:block;margin:0 auto">
|
|
<defs><linearGradient id="gg" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="var(--rd)"/><stop offset="50%" stop-color="var(--yl)"/><stop offset="100%" stop-color="var(--gn)"/></linearGradient></defs>
|
|
<path d="M ${cx-r} ${cy} A ${r} ${r} 0 0 1 ${cx+r} ${cy}" fill="none" stroke="var(--bg3)" stroke-width="${sw}" stroke-linecap="round"/>
|
|
<path d="M ${cx-r} ${cy} A ${r} ${r} 0 0 1 ${cx+r} ${cy}" fill="none" stroke="url(#gg)" stroke-width="${sw}" stroke-linecap="round" stroke-dasharray="${circ}" stroke-dashoffset="${circ-fill}" style="transition:stroke-dashoffset .8s ease"/>
|
|
<circle cx="${nx}" cy="${ny}" r="4" fill="${col}" stroke="var(--bg1)" stroke-width="2"/>
|
|
<text x="${cx}" y="${cy-8}" text-anchor="middle" fill="var(--t1)" font-size="22" font-weight="800" font-family="var(--fs)">${score}%</text>
|
|
<text x="${cx}" y="${cy+6}" text-anchor="middle" fill="var(--t4)" font-size="7" font-weight="600" font-family="var(--fm)">COMPLIANCE</text>
|
|
</svg>`}
|
|
function rKpi(){
|
|
if(S.rptKpiLoading)return'<div class="kpi-row"><div class="kpi" style="grid-column:1/-1;text-align:center;padding:1.2rem"><span class="spinner"></span> Carregando métricas...</div></div>';
|
|
const k=S.rptKpi;if(!k)return'';
|
|
const scoreColor=k.score>=80?'v-ok':k.score>=60?'v-warn':'v-err';
|
|
const secs=k.sections?Object.entries(k.sections).sort((a,b)=>b[1].failed-a[1].failed):[];
|
|
const barH=Math.max(180,secs.length*38+60);
|
|
setTimeout(()=>{_destroyKpiCharts();_initKpiCharts()},60);
|
|
return`<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:.75rem;margin-bottom:.85rem">
|
|
<div class="kpi kpi-score" style="display:flex;flex-direction:column;align-items:center;justify-content:center;padding:1rem .5rem .7rem">${_svgGauge(k.score)}<div class="kpi-sub" style="margin-top:.2rem;text-align:center">${k.total} checks · Level ${k.level}</div></div>
|
|
<div class="kpi kpi-pass"><div class="kpi-label">${IC.ok} Compliant</div><div class="kpi-val v-ok">${k.passed}</div><div class="kpi-sub">${k.total?Math.round(k.passed/k.total*100):0}% dos checks</div></div>
|
|
<div class="kpi kpi-fail"><div class="kpi-label">${IC.err} Non-Compliant</div><div class="kpi-val v-err">${k.failed}</div><div class="kpi-sub">${k.total_findings||0} findings</div></div>
|
|
<div class="kpi kpi-info"><div class="kpi-label">${IC.search} Total Avaliado</div><div class="kpi-val">${k.total}</div><div class="kpi-sub">${(k.total_compliant||0)+(k.total_findings||0)} itens analisados</div></div></div>
|
|
${secs.length?`<div style="display:grid;grid-template-columns:220px 1fr;gap:.75rem;margin-bottom:.85rem">
|
|
<div class="cd" style="margin-bottom:0;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:1.2rem">
|
|
<div class="ct" style="margin-bottom:.65rem;width:100%">${IC.chart} Distribuição</div>
|
|
<div style="position:relative;width:160px;height:160px"><canvas id="kpi-donut"></canvas>
|
|
<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center"><div style="font-size:1.3rem;font-weight:800;color:var(--t1)">${k.score}%</div><div style="font-size:.62rem;color:var(--t4)">score</div></div></div>
|
|
<div style="display:flex;gap:1rem;margin-top:.75rem;font-size:.68rem;font-family:var(--fm)">
|
|
<span style="display:flex;align-items:center;gap:.3rem"><span style="width:10px;height:10px;border-radius:3px;background:var(--gn);display:inline-block"></span> ${k.passed} ok</span>
|
|
<span style="display:flex;align-items:center;gap:.3rem"><span style="width:10px;height:10px;border-radius:3px;background:var(--rd);display:inline-block"></span> ${k.failed} fail</span></div></div>
|
|
<div class="cd" style="margin-bottom:0;padding:1.2rem"><div class="ct">${IC.barChart} Findings por Seção</div><div style="height:${barH}px"><canvas id="kpi-bar"></canvas></div></div>
|
|
</div>`:''}`}
|
|
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.rptKpi=null;S.rptRselVal='';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">${IC.history} 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()">${IC.refresh} Atualizar</button></div>
|
|
${!filtered.length?'<div class="cd"><div class="emp"><div class="eic">${IC.folder}</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 _dlSection(name){
|
|
// Extract CIS section from filename: cis_Networking_2-1.csv → Networking
|
|
const m=name.match(/^(?:cis|obp)_([A-Za-z_]+?)_\d/);
|
|
if(m)return m[1].replace(/_/g,' ');
|
|
if(name.includes('summary'))return 'Summary';
|
|
if(name.includes('error'))return 'Error';
|
|
return 'Other';
|
|
}
|
|
const DL_SECTION_TABLE={
|
|
'Identity and Access Management':'identityandaccess',
|
|
'Networking':'networking',
|
|
'Compute':'computeinstances',
|
|
'Logging and Monitoring':'loggingandmonitoring',
|
|
'Storage Object Storage':'objectstorage',
|
|
'Storage Block Volumes':'storageblockvolume',
|
|
'Storage File Storage Service':'filestorageservice',
|
|
'Asset Management':'assetmanagement',
|
|
'Summary':'summaryreportcsvvector'
|
|
};
|
|
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 secIcons={'Identity and Access Management':IC.lock,'Networking':IC.globe,'Compute':IC.server,'Logging and Monitoring':IC.log,'Storage Object Storage':IC.db,'Asset Management':IC.pkg,'Summary':IC.chart,'Error':IC.err,'Other':IC.file};
|
|
// Group files by section
|
|
const bySection={};files.forEach(f=>{const s=_dlSection(f.file_name);if(!bySection[s])bySection[s]=[];bySection[s].push(f)});
|
|
const sectionOrder=Object.keys(bySection).sort((a,b)=>{if(a==='Summary')return -1;if(b==='Summary')return 1;if(a==='Other')return 1;if(b==='Other')return -1;return a.localeCompare(b)});
|
|
const hasAdb=S.adbCfg.length>0&&(S.genaiCfg.length>0||S.ociCfg.length>0);
|
|
let h='<div style="border-top:1px solid var(--bd);padding:.65rem .8rem;background:var(--bg2)">';
|
|
// Header with total + embed all button
|
|
h+=`<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.6rem">`;
|
|
h+=`<span style="font-size:.72rem;color:var(--t3)">${files.length} arquivo(s) em ${sectionOrder.length} seções</span>`;
|
|
if(hasAdb)h+=`<button class="btn bp bsm" onclick="dlEmbedSection('${rid}',null)" style="font-size:.62rem">${IC.dna} Embed Tudo</button>`;
|
|
h+=`</div>`;
|
|
sectionOrder.forEach(sec=>{
|
|
const sf=bySection[sec];
|
|
const icon=secIcons[sec]||IC.file;
|
|
// Section header with embed button
|
|
h+=`<div style="display:flex;justify-content:space-between;align-items:center;margin-top:.6rem;margin-bottom:.35rem;padding-bottom:.25rem;border-bottom:1px solid var(--bd)">`;
|
|
h+=`<div style="display:flex;align-items:center;gap:.4rem"><span style="opacity:.7">${icon}</span><span style="font-size:.72rem;font-weight:700;color:var(--t1)">${sec}</span><span style="font-size:.6rem;color:var(--t4);font-weight:400">(${sf.length})</span></div>`;
|
|
if(hasAdb)h+=`<button class="btn bs bsm" onclick="dlEmbedSection('${rid}','${sec.replace(/'/g,"\\'")}')" style="font-size:.58rem;padding:.15rem .45rem" title="Gerar embedding desta seção">${IC.dna} Embed</button>`;
|
|
h+=`</div>`;
|
|
// File grid
|
|
h+=`<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:.4rem;margin-bottom:.3rem">`;
|
|
sf.forEach(f=>{
|
|
const ext=f.file_name.split('.').pop().toLowerCase();
|
|
const colors={html:'#e44d26',csv:'#217346',json:'#f5a623',xlsx:'#217346',txt:'#6b7280',pdf:'#dc2626'};
|
|
const ac=colors[ext]||'var(--ac)';
|
|
h+=`<a href="${API}/reports/${rid}/files/${f.id}/download?token=${S.token}" target="_blank" class="dl-card" style="display:flex;align-items:center;gap:.5rem;padding:.5rem .6rem;text-decoration:none;color:inherit;background:var(--bg);border:1px solid var(--bd);border-radius:8px;transition:all .15s;cursor:pointer" onmouseover="this.style.borderColor='${ac}';this.style.boxShadow='0 2px 8px ${ac}18'" onmouseout="this.style.borderColor='var(--bd)';this.style.boxShadow='none'">
|
|
${_dlFileIcon(f.file_name)}
|
|
<div style="flex:1;min-width:0;overflow:hidden"><div style="font-size:.7rem;font-weight:500;color:var(--t1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${f.file_name}">${f.file_name}</div>
|
|
<div style="font-size:.58rem;color:var(--t4);margin-top:1px">${_dlFmtSize(f.file_size)}</div></div>
|
|
<svg viewBox="0 0 16 16" width="12" height="12" style="flex-shrink:0;opacity:.3"><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="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/></svg></a>`});
|
|
h+=`</div>`;
|
|
});
|
|
h+='</div>';return h}
|
|
async function dlEmbedSection(rid,section){
|
|
if(!S.adbCfg.length){alert('Nenhuma conexão ADB configurada.');return}
|
|
if(!S.genaiCfg.length&&!S.ociCfg.length){alert('Nenhuma credencial OCI configurada.');return}
|
|
const vid=S.adbCfg.find(c=>c.is_active)?.id||S.adbCfg[0].id;
|
|
const cfgName=S.adbCfg.find(c=>c.id===vid)?.config_name||'ADB';
|
|
const report=S.reports.find(r=>r.id===rid);
|
|
const reportDate=report?.created_at?new Date(report.created_at).toLocaleDateString('pt-BR'):'';
|
|
const tenancy=report?.tenancy_name||'';
|
|
const label=section||'todas as seções';
|
|
const tableName=section?DL_SECTION_TABLE[section]:null;
|
|
const tableInfo=tableName?`\nTabela: ${tableName}`:'(cada seção → tabela correspondente)';
|
|
const purge=confirm(`Embed "${label}"\nConexão: ${cfgName}${tableInfo}\nReport: ${tenancy} — ${reportDate}\n\nDeseja LIMPAR os dados antigos antes de embedar?\n\nOK = Limpar antigos + embedar novos\nCancelar = Apenas adicionar (sem limpar)`);
|
|
// Either way, proceed with embedding. purge=true means clean first.
|
|
if(section&&tableName){
|
|
try{
|
|
if(purge)await $api('/embeddings/'+vid+'/purge',{method:'POST',body:{table_name:tableName,tenancy}});
|
|
const d=await $api('/embeddings/report/'+rid,{method:'POST',body:{adb_config_id:vid,section,table_name:tableName}});
|
|
alert(d.message);
|
|
}catch(e){alert('Erro: '+e.message)}
|
|
}else if(!section){
|
|
const files=S.reportFiles[rid]||[];
|
|
const sections=[...new Set(files.map(f=>_dlSection(f.file_name)))].filter(s=>DL_SECTION_TABLE[s]);
|
|
let ok=0,fail=0;
|
|
for(const sec of sections){
|
|
const tbl=DL_SECTION_TABLE[sec];
|
|
try{
|
|
if(purge)await $api('/embeddings/'+vid+'/purge',{method:'POST',body:{table_name:tbl,tenancy}});
|
|
await $api('/embeddings/report/'+rid,{method:'POST',body:{adb_config_id:vid,section:sec,table_name:tbl}});ok++;
|
|
}catch(e){fail++}
|
|
}
|
|
alert(`Embedding: ${ok} seções enviadas${fail?' ('+fail+' erros)':''}`);
|
|
}else{
|
|
try{
|
|
if(purge)await $api('/embeddings/'+vid+'/purge',{method:'POST',body:{table_name:section,tenancy}});
|
|
const d=await $api('/embeddings/report/'+rid,{method:'POST',body:{adb_config_id:vid,section}});
|
|
alert(d.message);
|
|
}catch(e){alert('Erro: '+e.message)}
|
|
}}
|
|
function dlRpt(id,f){window.open(API+'/reports/'+id+'/download?fmt='+f+'&token='+S.token,'_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" style="display:flex;align-items:center;justify-content:space-between;padding:.6rem 1rem">
|
|
<div style="font-size:.78rem;font-weight:600;color:var(--t2)">${IC.settings} Configuração do Sistema</div>
|
|
<div style="display:flex;gap:.4rem;align-items:center">
|
|
<button class="btn bp bsm" onclick="cfgExport()">${IC.upload} Exportar</button>
|
|
<button class="btn bs bsm" onclick="document.getElementById('cfgImportFile').click()">${IC.download} Importar</button>
|
|
<input type="file" id="cfgImportFile" accept=".json" style="display:none" onchange="cfgImport(this)">
|
|
</div></div>
|
|
<div id="cfgMsg"></div>
|
|
<div class="cd"><div class="ct">${IC.cloud} 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">${IC.key} ••••••••••••••••••••</span><br><span title="Tenancy OCID">${IC.building} ${c.tenancy_ocid}</span><br><span title="User OCID">${IC.user} ${c.user_ocid}</span><br><span title="Compartment">${IC.pkg} ${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?IC.edit+' Editar Credencial':IC.plus+' 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',IC.ok+' 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 cfgExport(){
|
|
try{
|
|
const resp=await fetch(API+'/config/export',{headers:S.token?{'Authorization':'Bearer '+S.token}:{}});
|
|
if(!resp.ok)throw new Error('Erro ao exportar');
|
|
const blob=await resp.blob();
|
|
const url=URL.createObjectURL(blob);
|
|
const a=document.createElement('a');a.href=url;
|
|
a.download=resp.headers.get('content-disposition')?.match(/filename="(.+)"/)?.[1]||'config-export.json';
|
|
a.click();URL.revokeObjectURL(url);
|
|
sm('cfgMsg',IC.ok+' Configuração exportada com sucesso!','s');
|
|
}catch(e){sm('cfgMsg',IC.err+' '+e.message,'e')}
|
|
}
|
|
async function cfgImport(input){
|
|
const file=input.files[0];if(!file)return;
|
|
try{
|
|
const fd=new FormData();fd.append('file',file);
|
|
const resp=await fetch(API+'/config/import',{method:'POST',headers:S.token?{'Authorization':'Bearer '+S.token}:{},body:fd});
|
|
if(!resp.ok){const err=await resp.json();throw new Error(err.detail||'Erro ao importar')}
|
|
const d=await resp.json();
|
|
const parts=[];
|
|
if(d.oci_configs)parts.push(d.oci_configs+' OCI configs');
|
|
if(d.genai_configs)parts.push(d.genai_configs+' GenAI configs');
|
|
if(d.mcp_servers)parts.push(d.mcp_servers+' MCP servers');
|
|
if(d.system_prompts)parts.push(d.system_prompts+' prompts');
|
|
if(d.app_settings)parts.push(d.app_settings+' settings');
|
|
if(d.adb_vector_configs)parts.push(d.adb_vector_configs+' ADB configs');
|
|
if(d.skipped)parts.push(d.skipped+' ignorados (já existem)');
|
|
sm('cfgMsg',IC.ok+' Importado: '+parts.join(', '),'s');
|
|
[S.ociCfg,S.genaiCfg,S.mcpSvr]=await Promise.all([$api('/oci/configs'),$api('/genai/configs'),$api('/mcp/servers')]);
|
|
R();
|
|
}catch(e){sm('cfgMsg',IC.err+' '+e.message,'e')}
|
|
finally{input.value=''}
|
|
}
|
|
async function tOci(id){try{const d=await $api('/oci/test/'+id,{method:'POST'});sm('ocm',d.status==='success'?IC.ok+' Conexão OK!':IC.err+' '+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':'')+'>'+IC.link+' 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">${IC.brain} 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">${IC.chat} 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?IC.edit+' Editar Modelo GenAI':IC.plus+' 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',IC.ok+' 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'?IC.ok+' '+d.message+(d.response?' — '+d.response:''):IC.err+' '+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">${IC.plug} 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">${IC.wrench} 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}')">${IC.search} Descobrir Tools</button>
|
|
<button class="btn bs bsm" onclick="toggleAddTool('${m.id}')">${IC.plus} 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?IC.edit+' Editar Server':IC.plus+' 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>${IC.terminal} 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>${IC.globe} 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>${IC.python} 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>${IC.db} 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',IC.ok+' '+(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,IC.ok+' 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,IC.ok+' '+(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,IC.ok+` ${d.discovered} tool(s) descoberta(s), ${d.total} total`,'s');R()}catch(e){sm('mcpm_'+mid,IC.err+' '+e.message,'e');btn.disabled=false;btn.textContent=IC.search+' 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,IC.ok+' 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">${IC.db} 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?IC.ok:'—'}</td><td>${c.wallet_dir?IC.ok:IC.err}</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 bs bsm" onclick="checkTables('${c.id}')">${IC.db} Verificar</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>
|
|
${ea?`<div class="cd"><div class="ct">${IC.edit} Editar Conexão — ${ea.config_name}</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.config_name}"></div>
|
|
<div class="ig"><label>Wallet ZIP</label><div class="ht">Envie novo wallet ou deixe vazio para manter o atual</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.dsn}"></div></div>
|
|
<div class="ig"><label>Username</label><input type="text" id="auser" placeholder="ADMIN" value="${ea.username}"></div>
|
|
<div class="ig"><label>Password</label><input type="password" id="apw" placeholder="(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.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.embedding_model_id===k?' selected':'')+'>'+v.name+' ('+v.dims+'d)</option>').join('')}</select></div></div>
|
|
<div style="display:flex;gap:8px;margin-bottom:1rem"><button class="btn bp" id="abtn" onclick="sAdb()">Salvar Alterações</button><button class="btn bs" onclick="cancelEdit()">Cancelar</button></div>
|
|
<div style="border-top:1px solid var(--br);padding-top:.85rem">
|
|
<div style="font-weight:600;font-size:.78rem;margin-bottom:.5rem">${IC.log} Tabelas de Vetores</div>
|
|
<div class="cdesc" style="margin-bottom:.6rem">Registre aqui as tabelas que já existem no ADB. 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;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?IC.ok+' Sim':IC.err+' 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>Nome da Tabela Existente</label><input type="text" id="newTblName" placeholder="Ex: networking"></div>
|
|
<div class="ig" style="flex:1;margin:0"><label>Descrição</label><input type="text" id="newTblDesc" placeholder="Ex: Dados de networking do CIS"></div>
|
|
<button class="btn bs" onclick="addTable('${ea.id}')">+ Registrar</button>
|
|
</div></div></div>`
|
|
:`<div class="cd"><div class="ct">${IC.plus} Nova Conexão</div>
|
|
<div class="cdesc">Conexão via <code style="font-size:.72rem">python-oracledb</code> Thin mode com Wallet (mTLS) para Oracle Autonomous Database. 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=""></div>
|
|
<div class="ig"><label>Wallet ZIP</label><div class="ht">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=""></div></div>
|
|
<div class="ig"><label>Username</label><input type="text" id="auser" placeholder="ADMIN" value=""></div>
|
|
<div class="ig"><label>Password</label><input type="password" id="apw" placeholder=""></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+'">'+(g.name||g.model_id)+' · '+g.genai_region+'</option>').join('')}</select></div>
|
|
<div class="ig"><label>Modelo de Embedding</label><div class="ht">Modelo OCI GenAI para gerar vetores.</div>
|
|
<select id="aemb">${Object.entries(S.embModels).map(([k,v])=>'<option value="'+k+'">'+v.name+' ('+v.dims+'d)</option>').join('')}</select></div></div>
|
|
<div style="display:flex;gap:8px"><button class="btn bp" id="abtn" onclick="sAdb()">Salvar Conexão</button></div></div>`}
|
|
${S.adbCfg.length?`<div class="cd"><div class="ct">${IC.upload} 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',IC.ok+' 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',IC.ok+' 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'?IC.ok+' '+d.message:IC.err+' '+d.message,d.status==='success'?'s':'e');refreshCLogs('adb')}catch(e){sm('am',e.message,'e')}}
|
|
async function checkTables(id){sm('am','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Verificando tabelas no ADB...','i');
|
|
try{const d=await $api('/adb/'+id+'/tables/check',{method:'POST'});
|
|
if(d.status==='error'){sm('am',IC.err+' '+d.message,'e');return}
|
|
let html='<strong>Schema: '+d.schema_user+'</strong> ('+d.adb_tables_total+' tabelas no ADB)<br><br>';
|
|
html+='<table style="width:100%;font-size:.74rem"><thead><tr><th>Tabela Registrada</th><th>Status</th><th>Detalhes</th></tr></thead><tbody>';
|
|
d.registered.forEach(r=>{const st=r.status==='ok'?'<span style="color:var(--gn)">'+IC.ok+' Existe</span>':r.status==='not_found'?'<span style="color:var(--rd)">'+IC.err+' Não encontrada</span>':'<span style="color:var(--rd)">'+IC.err+' Erro</span>';
|
|
html+='<tr><td><strong>'+r.table+'</strong></td><td>'+st+'</td><td>'+(r.rows!==undefined?r.rows+' registros':(r.message||''))+'</td></tr>'});
|
|
html+='</tbody></table>';
|
|
if(d.unregistered_in_adb&&d.unregistered_in_adb.length){html+='<br><details><summary style="cursor:pointer;font-size:.72rem;color:var(--t3)">Tabelas no ADB não registradas ('+d.unregistered_in_adb.length+')</summary><div style="font-size:.68rem;color:var(--t4);margin-top:.3rem">'+d.unregistered_in_adb.join(', ')+'</div></details>'}
|
|
sm('am',html,'i')}catch(e){sm('am',IC.err+' '+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',IC.ok+' 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',IC.ok+' 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,description:desc}});S.adbCfg=await $api('/adb/configs');sm('tbm',IC.ok+' 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',IC.ok+' 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 tblOptsAll(vid){const cfg=S.adbCfg.find(c=>c.id===vid);if(!cfg||!(cfg.tables||[]).length)return'<option value="">Todas as tabelas</option>';
|
|
return'<option value="">Todas as tabelas</option>'+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.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.length)return`<div class="cd"><div class="emp"><div class="eic">${IC.dna}</div><p>Nenhuma conexão ADB configurada.</p><p style="font-size:.74rem;margin-top:.35rem">Configure em <strong>ADB Vector</strong>.</p></div></div>`;
|
|
if(!S.genaiCfg.length&&!S.ociCfg.length)return`<div class="cd"><div class="emp"><div class="eic">${IC.dna}</div><p>Nenhuma credencial OCI configurada.</p><p style="font-size:.74rem;margin-top:.35rem">Configure em <strong>Config → OCI</strong>.</p></div></div>`;
|
|
const firstVid=S.adbCfg.find(c=>c.is_active)?.id||S.adbCfg[0]?.id||'';
|
|
return`<div class="cd"><div class="ct">${IC.log} 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">${IC.shield} CIS Recommendations</div>
|
|
<div class="cdesc">Envie a versão mais recente do PDF para manter as recomendações CIS Oracle Cloud atualizadas na base vetorial.</div><div id="crm"></div>
|
|
<div class="g3"><div class="ig"><label>Conexão ADB</label><select id="crsel">${adbOpts}</select></div>
|
|
<div class="ig"><label>Arquivo PDF</label><input type="file" id="crf" accept=".pdf,.txt"></div>
|
|
<div class="ig" style="align-self:end"><button class="btn bp" id="crbtn" onclick="embedCisRecom()">${IC.upload} Upload CIS Recommendations</button></div></div></div>
|
|
<div class="cd"><div class="ct">${IC.scroll} Base de Conhecimento</div>
|
|
<div class="cdesc">Envie documentos para alimentar a base de conhecimento da equipe. Os arquivos serão vetorizados e disponibilizados para consulta via chat.</div><div id="efm"></div>
|
|
<div class="g3"><div class="ig"><label>Conexão ADB</label><select id="efsel">${adbOpts}</select></div></div>
|
|
<div style="display:flex;gap:.75rem;align-items:start;flex-wrap:wrap;margin-top:.5rem">
|
|
<div style="flex:1;min-width:240px">
|
|
<label style="font-size:.72rem;font-weight:600;display:block;margin-bottom:.35rem">Arquivo (.txt, .pdf, .csv, .json, .md)</label>
|
|
<input type="file" id="eff" accept=".txt,.pdf,.csv,.json,.md">
|
|
<button class="btn bp" id="efbtn" onclick="embedFile()" style="margin-top:.5rem;width:100%">Upload Arquivo</button>
|
|
</div>
|
|
<div style="width:1px;background:var(--brd);align-self:stretch;margin:0 .25rem"></div>
|
|
<div style="flex:1;min-width:240px">
|
|
<label style="font-size:.72rem;font-weight:600;display:block;margin-bottom:.35rem">URL (página web ou PDF)</label>
|
|
<input type="text" id="efurl" placeholder="https://docs.oracle.com/..." style="width:100%;padding:.45rem .6rem;border:1px solid var(--brd);border-radius:var(--r);background:var(--bg2);color:var(--t1);font-size:.78rem">
|
|
<button class="btn bp" id="efurlbtn" onclick="embedUrl()" style="margin-top:.5rem;width:100%">Importar URL</button>
|
|
</div></div></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='<div style="overflow-x:auto"><table style="margin-top:.65rem;table-layout:fixed;width:100%"><thead><tr><th style="width:80px">ID</th><th>Metadata</th></tr></thead><tbody>'+
|
|
d.documents.map(e=>{let meta=e.metadata||'—';if(typeof meta==='object')meta=JSON.stringify(meta);if(meta.length>200)meta=meta.substring(0,200)+'…';
|
|
return'<tr><td style="font-family:var(--fm);font-size:.62rem;word-break:break-all">'+e.id.substring(0,12)+'</td>'+
|
|
'<td style="font-size:.68rem;color:var(--t3);word-break:break-word;white-space:pre-wrap;max-width:0;overflow:hidden">'+meta+'</td></tr>'}).join('')+
|
|
'</tbody></table></div><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',IC.ok+' '+d.total_chunks+' chunks prontos para embedding.','s');
|
|
}catch(e){sm('erm',e.message,'e')}finally{btn.disabled=false;btn.textContent=IC.eye+' 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',IC.ok+' '+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 embedCisRecom(){const vid=document.getElementById('crsel').value;const f=document.getElementById('crf').files[0];
|
|
if(!vid||!f)return sm('crm','Selecione conexão ADB e arquivo.','e');
|
|
const fd=new FormData();fd.append('file',f);fd.append('adb_config_id',vid);fd.append('table_name','cisrecom');
|
|
const btn=document.getElementById('crbtn');btn.disabled=true;btn.textContent='Enviando...';sm('crm','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Processando PDF e gerando embeddings...','i');
|
|
try{const d=await $api('/embeddings/upload',{method:'POST',body:fd,headers:{}});sm('crm',IC.ok+' '+d.message+' → tabela CISRECOM','s');btn.disabled=false;btn.textContent=IC.upload+' Upload CIS Recommendations'}catch(e){sm('crm',e.message,'e');btn.disabled=false;btn.textContent=IC.upload+' Upload CIS Recommendations'}}
|
|
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='engineerknowledgebase';
|
|
const fd=new FormData();fd.append('file',f);fd.append('adb_config_id',vid);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',IC.ok+' '+d.message,'s');btn.disabled=false;btn.textContent='Upload Arquivo'}catch(e){sm('efm',e.message,'e');btn.disabled=false;btn.textContent='Upload Arquivo'}}
|
|
async function embedUrl(){const vid=document.getElementById('efsel').value;const url=document.getElementById('efurl').value.trim();
|
|
if(!vid||!url)return sm('efm','Selecione conexão ADB e informe a URL.','e');
|
|
const fd=new FormData();fd.append('adb_config_id',vid);fd.append('table_name','engineerknowledgebase');fd.append('url',url);
|
|
const btn=document.getElementById('efurlbtn');btn.disabled=true;btn.textContent='Importando...';sm('efm','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Buscando conteúdo e gerando embeddings...','i');
|
|
try{const d=await $api('/embeddings/upload-url',{method:'POST',body:fd,headers:{}});sm('efm',IC.ok+' '+d.message,'s');btn.disabled=false;btn.textContent='Importar URL';document.getElementById('efurl').value=''}catch(e){sm('efm',e.message,'e');btn.disabled=false;btn.textContent='Importar URL'}}
|
|
|
|
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)}}
|
|
|
|
/* ── Consult Embeddings ── */
|
|
if(!S.consultHistory)S.consultHistory=[];
|
|
function rEmbConsultPage(){
|
|
if(!S.adbCfg.length)return`<div class="cd"><div class="emp"><div class="eic">${IC.search}</div><p>Nenhuma conexão ADB configurada.</p><p style="font-size:.74rem;margin-top:.35rem">Configure em <strong>ADB Vector</strong>.</p></div></div>`;
|
|
const firstVid=S.adbCfg.find(c=>c.is_active)?.id||S.adbCfg[0]?.id||'';
|
|
return rEmbConsult(firstVid)}
|
|
function rEmbConsult(firstVid){
|
|
const msgs=S.consultHistory.map(m=>`<div class="cm cm-${m.r}"><div class="cb">${m.r==='bot'?fm(m.c):m.c}</div></div>`).join('');
|
|
return`<div class="cd" style="display:flex;flex-direction:column;height:calc(100vh - 180px)">
|
|
<div class="ct">${IC.search} Consultar Embeddings</div>
|
|
<div class="cdesc">Faça perguntas sobre os dados armazenados nas bases vetoriais. O modelo de IA consulta os embeddings e retorna uma resposta contextualizada.</div>
|
|
<div style="display:flex;gap:.5rem;margin-bottom:.75rem;flex-wrap:wrap">
|
|
<div class="ig" style="flex:1;min-width:180px"><label style="font-size:.72rem">Tabela (opcional)</label><select id="ecTbl" style="font-size:.78rem">${tblOptsAll(firstVid)}</select></div>
|
|
<div class="ig" style="width:80px"><label style="font-size:.72rem">Top-K</label><input type="number" id="ecTopK" value="10" min="1" max="30" style="font-size:.78rem;text-align:center"></div>
|
|
</div>
|
|
<div style="flex:1;overflow-y:auto;border:1px solid var(--brd);border-radius:var(--r);padding:.75rem;background:var(--bg2);margin-bottom:.75rem" id="ecMsgs">
|
|
${msgs||`<div class="emp"><div class="eic">${IC.search}</div><p>Faça uma consulta para pesquisar nos embeddings.</p><p style="font-size:.74rem;margin-top:.35rem">Ex: "findings para CIS 1.1", "recomendações de networking", "como corrigir MFA"</p></div>`}
|
|
</div>
|
|
<div style="display:flex;gap:.5rem">
|
|
<input type="text" id="ecQuery" placeholder="Digite sua consulta..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();consultEmb()}" style="flex:1;padding:.55rem .75rem;border:1px solid var(--brd);border-radius:var(--r);background:var(--bg1);color:var(--t1);font-size:.82rem">
|
|
<button class="btn bp" id="ecBtn" onclick="consultEmb()" style="white-space:nowrap">${IC.search} Consultar</button>
|
|
</div></div>`}
|
|
|
|
async function consultEmb(){
|
|
const q=document.getElementById('ecQuery').value.trim();if(!q)return;
|
|
const tbl=document.getElementById('ecTbl')?.value||'';
|
|
const topK=parseInt(document.getElementById('ecTopK')?.value)||10;
|
|
S.consultHistory.push({r:'user',c:q});
|
|
document.getElementById('ecQuery').value='';
|
|
const msgEl=document.getElementById('ecMsgs');
|
|
msgEl.innerHTML=S.consultHistory.map(m=>`<div class="cm cm-${m.r}"><div class="cb">${m.r==='bot'?fm(m.c):m.c}</div></div>`).join('')+
|
|
'<div class="cm cm-bot"><div class="cb"><span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Consultando bases vetoriais...</div></div>';
|
|
msgEl.scrollTop=msgEl.scrollHeight;
|
|
const btn=document.getElementById('ecBtn');btn.disabled=true;
|
|
try{
|
|
const d=await $api('/embeddings/consult',{method:'POST',body:{query:q,table_name:tbl,top_k:topK}});
|
|
let resp=d.answer||'Sem resposta do modelo.';
|
|
if(d.documents?.length){resp+='\n\n---\n**Fontes consultadas ('+d.documents.length+' documentos, '+d.total+' total):**\n';
|
|
d.documents.forEach((doc,i)=>{resp+='\n'+(i+1)+'. **'+doc.source+'** (distância: '+doc.distance+')'});
|
|
}
|
|
S.consultHistory.push({r:'bot',c:resp});
|
|
}catch(e){S.consultHistory.push({r:'bot',c:'Erro: '+e.message})}
|
|
btn.disabled=false;
|
|
msgEl.innerHTML=S.consultHistory.map(m=>`<div class="cm cm-${m.r}"><div class="cb">${m.r==='bot'?fm(m.c):m.c}</div></div>`).join('');
|
|
msgEl.scrollTop=msgEl.scrollHeight;
|
|
}
|
|
|
|
/* ── Users ── */
|
|
function rUsers(){return`<div class="cd"><div class="ct">${IC.users} 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">${IC.plus} 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', IC.err+' Nome e sobrenome são obrigatórios', 'e');
|
|
return;
|
|
}
|
|
if (!username) {
|
|
sm('um2', IC.err+' Usuário (login) é obrigatório', 'e');
|
|
return;
|
|
}
|
|
if (!password) {
|
|
sm('um2', IC.err+' 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', IC.ok+' 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">${IC.lock} 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">'+IC.ok+' 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',IC.ok+' MFA ativado com sucesso!','s');R()}catch(e){sm('mm',e.message,'e')}}
|
|
|
|
/* ── Audit ── */
|
|
function rAudit(){return`<div class="cd"><div class="ct">${IC.log} 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>${IC.scroll} 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> |