feat: chat history, inline file editor, split terminal, OCI resource actions, and layout fixes

- Add ChatGPT-style chat history for Chat Agent and Terraform Agent (sessions, rename, delete)
- Add inline .tf file editor with Tab support and auto-sync to workspace code
- Add split-panel terminal showing Plan/Apply/Destroy output alongside files/plan/resources
- Add OCI resource actions: start/stop Compute instances and Autonomous DBs from Resources panel
- Add multi-file Terraform generation via // filename: markers
- Fix GenAI empty response handling (GPT-5.2 fallback message)
- Fix compartment select visibility, Terraform code block light theme
- Fix page layout overflow (no more scroll beyond viewport)
- Add DB migration for terraform_workspaces.compartment_id column
- Update README with new features, API endpoints, and version notes
This commit is contained in:
nogueiraguh
2026-03-08 00:15:10 -03:00
parent a05956a726
commit 8bccf9367e
3 changed files with 500 additions and 94 deletions

View File

@@ -27,11 +27,11 @@
--trans:cubic-bezier(.22,1,.36,1)
}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:var(--fs);background:var(--bg);color:var(--t1);min-height:100vh;-webkit-font-smoothing:antialiased}
body{font-family:var(--fs);background:var(--bg);color:var(--t1);height:100vh;overflow:hidden;-webkit-font-smoothing:antialiased}
::selection{background:rgba(199,70,52,.15);color:var(--t1)}
/* ── Sidebar ── */
.app{display:flex;min-height:100vh}
.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}
@@ -57,12 +57,12 @@ body{font-family:var(--fs);background:var(--bg);color:var(--t1);min-height:100vh
.lo-btn:hover{border-color:var(--rd);color:var(--rd);background:var(--rdl)}
/* ── Main Content ── */
.mc{flex:1;margin-left:260px;min-height:100vh}
.mc{flex:1;margin-left:260px;height:100vh;overflow:hidden;display:flex;flex-direction:column}
.tb{height:56px;background:rgba(255,255,255,.8);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);
border-bottom:1px solid var(--bd);display:flex;align-items:center;justify-content:space-between;padding:0 1.5rem;position:sticky;top:0;z-index:50}
.tb-t{font-weight:700;font-size:.92rem;color:var(--t1);letter-spacing:-.01em}
.tb-a{display:flex;align-items:center;gap:.6rem}
.pc{padding:1.5rem;max-width:1400px}
.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;
@@ -114,6 +114,23 @@ tbody tr:last-child td{border-bottom:none}
.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-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}
@@ -156,7 +173,7 @@ tbody tr:last-child td{border-bottom:none}
.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 input{flex:1;background:var(--bg2);border-radius:10px}
.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 ── */
@@ -244,26 +261,37 @@ tbody tr:last-child td{border-bottom:none}
.exp-cat{padding:.3rem .5rem;font-size:.6rem}
}
/* ── Terraform Agent ── */
.tf-wrap{display:flex;flex-direction:column;height:calc(100vh - 56px)}
.tf-chat{flex:1;display:flex;flex-direction:column;overflow:hidden;min-height:200px}
.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 input{flex:1}
.tf-input textarea,.tf-input input{flex:1;font-family:inherit;font-size:inherit;line-height:1.4;padding:.45rem .65rem;border:1.5px solid var(--bd);border-radius:10px;background:var(--bg2);min-height:36px;max-height:200px}
.tf-resize{height:5px;cursor:row-resize;background:var(--bd);transition:background .15s;position:relative;flex-shrink:0}
.tf-resize:hover,.tf-resize.dragging{background:#7b42bc;opacity:.7}
.tf-resize::after{content:'';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:28px;height:3px;border-top:1px solid var(--t4);border-bottom:1px solid var(--t4);opacity:.4}
.tf-resize:hover::after{border-color:#7b42bc;opacity:.8}
.tf-bottom{height:280px;display:flex;flex-direction:column;overflow:hidden;border-top:1px solid var(--bd);background:var(--bg1)}
.tf-bottom{flex:1;display:flex;flex-direction:column;overflow:hidden;border-top:1px solid var(--bd);background:var(--bg1);min-height:0}
.tf-bottom-tabs{display:flex;align-items:center;gap:0;border-bottom:1px solid var(--bd);background:var(--bg);padding:0 .5rem}
.tf-btab{padding:.4rem .75rem;font-size:.72rem;font-weight:600;cursor:pointer;border-bottom:2px solid transparent;color:var(--t3);display:flex;align-items:center;gap:.3rem;transition:all .15s}
.tf-btab:hover{color:var(--t1)}
.tf-btab.active{color:#7b42bc;border-bottom-color:#7b42bc}
.tf-btab svg{width:14px;height:14px}
.tf-bottom-actions{margin-left:auto;display:flex;align-items:center;gap:.4rem;padding-right:.3rem}
.tf-bottom-content{flex:1;overflow:auto;padding:.5rem .7rem}
.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}
.tf-bottom-content{flex:1;overflow:hidden;display:flex;flex-direction:row}
.tf-bc-left{flex:1;overflow:auto;padding:.5rem .7rem;min-width:0}
.tf-bc-right{width:50%;overflow:auto;border-left:1px solid var(--bd);flex-shrink:0;display:flex;flex-direction:column}
.tf-bc-right .tf-term-hdr{padding:.3rem .6rem;font-size:.68rem;font-weight:700;color:#cdd6f4;background:#12122a;border-bottom:1px solid #2a2a4a;display:flex;align-items:center;gap:.4rem}
.tf-bc-right .tf-term-hdr svg{width:12px;height:12px;fill:#cdd6f4}
.tf-bc-right .tf-term-body{flex:1;overflow:auto;background:#1a1a2e;padding:.5rem;font-family:var(--fm);font-size:.68rem;line-height:1.5;color:#cdd6f4;white-space:pre-wrap;word-break:break-word}
.tf-file{display:flex;align-items:center;gap:.5rem;padding:.45rem .6rem;border-radius:8px;border:1px solid var(--bd);margin-bottom:.4rem;background:var(--bg);transition:border-color .15s;cursor:pointer}
.tf-file:hover{border-color:#7b42bc}
.tf-file.editing{border-color:#7b42bc;background:#7b42bc08}
.tf-editor{border:1px solid #e2ddf5;border-radius:var(--r);overflow:hidden;margin-bottom:.5rem;background:#f8f6ff}
.tf-editor-hdr{display:flex;align-items:center;gap:.4rem;padding:.35rem .6rem;background:#efe9fc;border-bottom:1px solid #e2ddf5;font-size:.7rem;font-weight:600;color:#4a3875}
.tf-editor-hdr .tf-ed-name{flex:1;font-family:var(--fm)}
.tf-editor-hdr .btn{font-size:.62rem;padding:2px 8px}
.tf-editor-ta{width:100%;min-height:120px;max-height:400px;border:none;outline:none;resize:vertical;font-family:var(--fm);font-size:.72rem;line-height:1.6;color:#3b2e58;background:#f8f6ff;padding:.6rem .7rem;box-sizing:border-box;tab-size:2}
.tf-file-icon{width:32px;height:32px;border-radius:6px;background:linear-gradient(135deg,#7b42bc22,#5c4ee522);display:flex;align-items:center;justify-content:center;flex-shrink:0}
.tf-file-icon svg{width:16px;height:16px;fill:#7b42bc}
.tf-file-info{flex:1;min-width:0}
@@ -272,12 +300,12 @@ tbody tr:last-child td{border-bottom:none}
.tf-file-actions{display:flex;gap:.3rem}
.tf-file-btn{width:26px;height:26px;border-radius:6px;border:1px solid var(--bd);background:var(--bg);display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:.7rem;transition:all .15s}
.tf-file-btn:hover{border-color:#7b42bc;background:#7b42bc11}
.tf-code-block{background:#1e1e2e;border-radius:var(--r);overflow:hidden;margin:.6rem 0}
.tf-code-header{display:flex;align-items:center;gap:.4rem;padding:.4rem .7rem;background:#2a2a3e;font-size:.72rem;color:#cdd6f4;border-bottom:1px solid #45475a}
.tf-code-block{background:#f8f6ff;border:1px solid #e2ddf5;border-radius:var(--r);overflow:hidden;margin:.6rem 0}
.tf-code-header{display:flex;align-items:center;gap:.4rem;padding:.4rem .7rem;background:#efe9fc;font-size:.72rem;color:#4a3875;border-bottom:1px solid #e2ddf5}
.tf-code-header span{flex:1;font-weight:600}
.tf-code-header .btn{color:#cdd6f4;border-color:#45475a;background:transparent;font-size:.66rem;padding:2px 8px}
.tf-code-header .btn:hover{background:#45475a}
.tf-pre{padding:.85rem;overflow-x:auto;font-size:.74rem;line-height:1.6;color:#cdd6f4;font-family:var(--fm);margin:0;white-space:pre}
.tf-code-header .btn{color:#7b42bc;border-color:#d4c8ef;background:transparent;font-size:.66rem;padding:2px 8px}
.tf-code-header .btn:hover{background:#e2ddf5}
.tf-pre{padding:.85rem;overflow-x:auto;font-size:.74rem;line-height:1.6;color:#3b2e58;font-family:var(--fm);margin:0;white-space:pre}
.tf-plan-block{background:var(--gnl);border:1px solid rgba(22,163,74,.15);border-radius:var(--r);margin:.6rem 0;overflow:hidden}
.tf-plan-header{padding:.45rem .7rem;font-weight:700;font-size:.76rem;color:var(--gn);border-bottom:1px solid rgba(22,163,74,.12)}
.tf-plan-list{padding:.4rem .7rem}
@@ -285,7 +313,6 @@ tbody tr:last-child td{border-bottom:none}
.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-output{background:#1a1a2e;color:#cdd6f4;font-family:var(--fm);font-size:.7rem;line-height:1.5;padding:.6rem;overflow:auto;white-space:pre-wrap;word-break:break-word;border-radius:var(--r);height:100%}
.tf-badge{font-size:.58rem;padding:1px 6px;border-radius:10px;font-weight:700;display:inline-flex;align-items:center;gap:3px}
.tf-badge-draft{background:#7b42bc18;color:#7b42bc}
.tf-badge-ok{background:var(--gnl);color:var(--gn)}
@@ -298,7 +325,7 @@ tbody tr:last-child td{border-bottom:none}
.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{height:200px}
.tf-bottom{min-height:0}
}
</style>
</head>
@@ -310,7 +337,7 @@ const LOGO_W=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width=
const LOGO_R=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="52" height="52"><rect x="2" y="5" width="32" height="26" rx="13" ry="13" fill="rgba(199,70,52,0.08)" stroke="#C74634" stroke-width="1.8"/><rect x="11" y="11" width="14" height="14" rx="4" fill="#fff" stroke="#C74634" stroke-width="0.4"/><circle cx="15" cy="17" r="2" fill="#C74634"/><circle cx="21" cy="17" r="2" fill="#C74634"/><circle cx="15" cy="16.7" r="0.7" fill="#fff"/><circle cx="21" cy="16.7" r="0.7" fill="#fff"/><rect x="14" y="21" width="8" height="1.5" rx="0.75" fill="#C74634" opacity="0.6"/><line x1="18" y1="11" x2="18" y2="6" stroke="#C74634" stroke-width="1.2" stroke-linecap="round"/><circle cx="18" cy="5.5" r="1.5" fill="#C74634" opacity="0.25" stroke="#C74634" stroke-width="0.7"/><line x1="11" y1="18" x2="6" y2="18" stroke="#C74634" stroke-width="1" stroke-linecap="round" opacity="0.4"/><line x1="25" y1="18" x2="30" y2="18" stroke="#C74634" stroke-width="1" stroke-linecap="round" opacity="0.4"/><circle cx="5.5" cy="18" r="1" fill="#C74634" opacity="0.35"/><circle cx="30.5" cy="18" r="1" fill="#C74634" opacity="0.35"/></svg>`;
const S={user:null,token:null,tab:'chat',msgs:[],sid:null,reports:[],ociCfg:[],genaiCfg:[],adbCfg:[],mcpSvr:[],users:[],models:{},regions:[],embModels:{},expData:null,expCfg:'',expSelRegions:[],expRegDdOpen:false,expTree:[],expSelComp:'',expCat:'Compute',expResType:'instances',expTreeOpen:{},expLoading:false,expRegions:[],expCounts:{},editing:null,
chatModel:'',chatOci:'',chatRegion:'',chatCompartment:'',
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:'',
@@ -318,7 +345,7 @@ const S={user:null,token:null,tab:'chat',msgs:[],sid:null,reports:[],ociCfg:[],g
cisVer:null,cisCheckResult:null,cisUpdating:false,rptHistFilter:'',chatFiles:[],
tfMsgs:[],tfSid:null,tfModel:'',tfOci:'',tfRegion:'',tfCompartment:'',tfPlan:[],tfCode:'',tfPanel:'',
tfWs:null,tfWsList:[],tfPlanOut:'',tfApplyOut:'',tfDestroyOut:'',tfStatus:'draft',tfRunning:false,tfConfirmDest:false,tfMdOpen:false,
tfComps:[],tfCompLoading:false,tfFiles:[],tfBtab:'files',tfResources:null,tfResLoading:false};
tfComps:[],tfCompLoading:false,tfFiles:[],tfBtab:'files',tfEditIdx:-1,tfResources:null,tfResLoading:false};
const API='/api';
async function $api(p,o={}){const h={...(o.headers||{})};if(S.token)h['Authorization']='Bearer '+S.token;
@@ -340,9 +367,19 @@ async function loadData(){try{
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(){document.getElementById('app').innerHTML=S.user?rApp():rLogin();if(S.editing?.type==='mcp')setTimeout(mcpTypeFields,0)}
function switchTab(t){S.tab=t;R();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()}
function R(){try{
// Preserve textarea values before re-render
const chiEl=document.getElementById('chi');if(chiEl)S._chiVal=chiEl.value;
const tfiEl=document.getElementById('tfi');if(tfiEl)S._tfiVal=tfiEl.value;
document.getElementById('app').innerHTML=S.user?rApp():rLogin();
// Restore textarea values after re-render
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)}
if(S.editing?.type==='mcp')setTimeout(mcpTypeFields,0)
}catch(e){console.error('Render error:',e);document.getElementById('app').innerHTML='<div style="padding:2rem;color:red"><h3>Render Error</h3><pre>'+e.message+'</pre></div>'}}
function switchTab(t){S.tab=t;R();if(t==='chat')loadHistory('chat');if(t==='terraform')loadHistory('terraform');if(t==='audit'&&S.user.role==='admin')loadAudit();if(t==='downloads')refreshDl();if(t==='explorer'&&S.ociCfg.length&&!S.expTree.length){if(!S.expCfg)S.expCfg=S.ociCfg[0].id;expLoadTree();expLoadRegions()}
const tm={'oci-config':'oci','genai':'genai','adb':'adb','mcp':'mcp'};if(tm[t])setTimeout(()=>refreshCLogs(tm[t]),100)}
/* ── Login ── */
@@ -403,18 +440,24 @@ function rChat(){
if(S.chatPanel==='config')sideContent=rChatConfig(isDirect,toolCount);
else if(S.chatPanel==='logs')sideContent=rChatLogs();
return`<div class="ch-c">
<div class="ch-hp${S.chatHistOpen?' open':''}"><div class="ch-hp-inner">
<div class="ch-hp-hdr"><span>🕘 Histórico</span><div class="ch-hp-new" onclick="newChat()">+ Novo Chat</div></div>
<div class="ch-hp-list">${rHistList('chat',S.chatHistory,S.sid)}</div>
</div></div>
<div class="ch-side${S.chatPanel?' open':''}"><div class="ch-side-inner">${sideContent}</div></div>
<div class="ch-main">
<div class="ch-t"><label>🧠 Modelo:</label><div class="mdrop"><div class="mdrop-btn" onclick="toggleModelDrop()">${curLabel} <span class="arr">▼</span></div><div class="mdrop-dd" id="mdd"><input type="text" id="mds" placeholder="Buscar modelo..." oninput="filterModels(this.value)"><div class="mdrop-list" id="mdl">${ddItems}</div></div></div>
<div class="ch-t">
<div class="ch-icon-btn${S.chatHistOpen?' active':''}" onclick="S.chatHistOpen=!S.chatHistOpen;if(S.chatHistOpen)loadHistory('chat');R()" title="Histórico">☰</div>
<label>🧠 Modelo:</label><div class="mdrop"><div class="mdrop-btn" onclick="toggleModelDrop()">${curLabel} <span class="arr">▼</span></div><div class="mdrop-dd" id="mdd"><input type="text" id="mds" placeholder="Buscar modelo..." oninput="filterModels(this.value)"><div class="mdrop-list" id="mdl">${ddItems}</div></div></div>
${ociSel}${ragBadge}<div style="flex:1"></div>
<div class="ch-icon-btn${S.chatPanel==='config'?' active':''}" onclick="togglePanel('config')" title="Configurações">⚙️</div>
<div class="ch-icon-btn${S.chatPanel==='logs'?' active':''}" onclick="togglePanel('logs')" title="Chat Logs">📋</div>
<div class="ch-icon-btn" onclick="clrChat()" title="Limpar conversa">🗑️</div></div>
<div class="ch-icon-btn" onclick="newChat()" title="Nova conversa"></div></div>
<div class="ch-m" id="chm">${ms}</div>
${S.chatFiles.length?`<div style="display:flex;gap:.4rem;flex-wrap:wrap;padding:.4rem .85rem;background:var(--bg1);border-top:1px solid var(--bd)">${S.chatFiles.map((f,i)=>f.type==='image'?`<div style="position:relative;width:48px;height:48px"><img src="${f.preview}" style="width:48px;height:48px;object-fit:cover;border-radius:6px;border:1px solid var(--bd)"><span onclick="rmChatFile(${i})" style="position:absolute;top:-4px;right:-4px;background:var(--err);color:#fff;width:16px;height:16px;border-radius:50%;font-size:.6rem;display:flex;align-items:center;justify-content:center;cursor:pointer">&times;</span></div>`:`<div style="position:relative;display:flex;align-items:center;gap:.25rem;background:var(--bg2);border:1px solid var(--bd);border-radius:6px;padding:.25rem .5rem;font-size:.68rem"><span>📄 ${f.name}</span><span onclick="rmChatFile(${i})" style="color:var(--err);cursor:pointer;font-weight:700">&times;</span></div>`).join('')}</div>`:''}
<div class="ch-i"><input type="file" id="chf" multiple accept="image/*,.pdf,.txt,.csv,.json,.log,.xml,.yaml,.yml,.md,.py,.js,.html,.css,.sql" style="display:none" onchange="addChatFiles(this)">
<button class="btn bs" onclick="document.getElementById('chf').click()" title="Anexar arquivo" style="font-size:1rem;padding:.3rem .5rem">📎</button>
<input type="text" id="chi" placeholder="Digite sua mensagem..." onkeydown="if(event.key==='Enter')sChat()">
<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;
@@ -464,13 +507,14 @@ async function savePrompt(){const name=document.getElementById('spname').value;c
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();
async function sChat(){const el=document.getElementById('chi');const m=el.value.trim();el.style.height='auto';S._chiVal='';
if(!m&&!S.chatFiles.length)return;
if(!S.chatModel){S.msgs.push({r:'assistant',c:'⚠️ Selecione um modelo antes de enviar uma mensagem.'});R();return}
el.value='';
@@ -511,6 +555,7 @@ async function sChat(){const el=document.getElementById('chi');const m=el.value.
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{
@@ -530,6 +575,69 @@ async function pollChatResult(mid){
S.msgs=S.msgs.filter(x=>!x.thinking);S.msgs.push({r:'assistant',c:'⏰ Timeout: a resposta está demorando muito. Tente novamente.',t:tstamp()});R()}
function scCh(){setTimeout(()=>{const e=document.getElementById('chm');if(e)e.scrollTop=e.scrollHeight},50)}
async function clrChat(){if(S.sid)try{await $api('/chat/'+S.sid,{method:'DELETE'})}catch(e){}S.msgs=[];S.sid=null;R()}
function newChat(){S.msgs=[];S.sid=null;S.chatFiles=[];R()}
function newTfChat(){S.tfMsgs=[];S.tfSid=null;S.tfPlan=[];S.tfCode='';S.tfFiles=[];S.tfWs=null;S.tfPlanOut='';S.tfApplyOut='';S.tfDestroyOut='';S.tfStatus='draft';S.tfRunning=false;S.tfConfirmDest=false;S.tfEditIdx=-1;R()}
async function loadHistory(type){
try{const h=await $api('/chat/sessions?agent_type='+type+'&limit=50');
if(type==='chat')S.chatHistory=h;else S.tfHistory=h}
catch(e){if(type==='chat')S.chatHistory=[];else S.tfHistory=[]}
R();
}
async function loadSession(sid,type){
try{const d=await $api('/chat/sessions/'+sid+'/messages');
if(type==='chat'){
S.sid=sid;S.msgs=d.messages.map(m=>({r:m.role,c:m.content,t:m.created_at?.slice(11,16)||''}));R();scCh();
}else{
S.tfSid=sid;S.tfMsgs=d.messages.map(m=>({r:m.role,c:m.content}));
S.tfFiles=[];S.tfCode='';S.tfPlan=[];
const last=[...d.messages].reverse().find(m=>m.role==='assistant'&&m.content&&m.content.includes('```'));
if(last)fmTf(last.content);
try{const wsList=await $api('/terraform/workspaces');const ws=wsList.find(w=>w.session_id===sid);
if(ws){S.tfWs=ws.id;S.tfStatus=ws.status}else{S.tfWs=null;S.tfStatus='draft'}
}catch(e){S.tfWs=null;S.tfStatus='draft'}
R();
}
}catch(e){alert('Erro ao carregar sessão: '+e.message)}
}
async function delSession(sid,type){
if(!confirm('Excluir esta conversa?'))return;
try{await $api('/chat/'+sid,{method:'DELETE'})}catch(e){}
if(type==='chat'){if(S.sid===sid)newChat();S.chatHistory=S.chatHistory.filter(s=>s.id!==sid)}
else{if(S.tfSid===sid)newTfChat();S.tfHistory=S.tfHistory.filter(s=>s.id!==sid)}
R();
}
async function renameSession(sid,type){
const cur=(type==='chat'?S.chatHistory:S.tfHistory).find(s=>s.id===sid);
const t=prompt('Renomear conversa:',cur?.title||'');
if(t===null)return;
try{await $api('/chat/sessions/'+sid+'/title',{method:'PUT',body:{title:t}});
if(cur)cur.title=t;R()}catch(e){}
}
function _histDateGroup(dateStr){
if(!dateStr)return'Outras';
const d=new Date(dateStr+'Z'),now=new Date(),diff=(now-d)/86400000;
if(diff<1)return'Hoje';if(diff<2)return'Ontem';if(diff<7)return'Últimos 7 dias';if(diff<30)return'Últimos 30 dias';return'Anteriores';
}
function rHistList(type,hist,curSid){
if(!hist.length)return'<div style="text-align:center;padding:1rem .5rem;font-size:.7rem;color:var(--t4)">Nenhuma conversa</div>';
let html='';let lastGroup='';
hist.forEach(s=>{
const g=_histDateGroup(s.updated_at||s.created_at);
if(g!==lastGroup){lastGroup=g;html+=`<div class="ch-hp-grp">${g}</div>`}
const active=s.id===curSid?' active':'';
const title=s.title||'Nova conversa';
html+=`<div class="ch-hp-item${active}" onclick="loadSession('${s.id}','${type}')">
<span class="ch-hp-ttl" title="${title.replace(/"/g,'&quot;')}">${escHtml(title)}</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:'');
@@ -551,30 +659,42 @@ const TF_ICON=`<svg viewBox="0 0 16 16" width="16" height="16"><path d="M5.6 1v4
function escHtml(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
function fmTf(t){
// Extract ALL hcl blocks and build files list
// Extract ALL hcl blocks, parse // filename: comments, build separate files
const codeBlocks=[];
let r=t.replace(/```(?:hcl|terraform)\s*\n([\s\S]*?)```/g,(m,code)=>{
const trimmed=code.trim();
codeBlocks.push(trimmed);
S.tfCode=trimmed;
return '<div class="tf-code-block"><div class="tf-code-header"><span>'+TF_ICON+' main.tf</span>'+
'<button class="btn" onclick="copyTfCode()">Copy</button></div>'+
'<pre class="tf-pre"><code>'+escHtml(trimmed)+'</code></pre></div>';
// 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);
S.tfPlan=lines.map(l=>{const m2=l.match(/^[+~]\s+(\S+)\s+\((.+)\)$/);return m2?{type:m2[1],desc:m2[2]}:{type:l.replace(/^[+~]\s*/,'').trim(),desc:''}}).filter(x=>x.type);
return '<div class="tf-plan-block"><div class="tf-plan-header">'+TF_ICON+' Resource Plan — '+S.tfPlan.length+' resource(s)</div><div class="tf-plan-list">'+
S.tfPlan.map(x=>'<div class="tf-plan-item"><span class="tf-plan-add">+</span><span class="tf-plan-type">'+escHtml(x.type)+'</span><span class="tf-plan-desc">'+escHtml(x.desc)+'</span></div>').join('')+'</div></div>';
});
// Update files list
// Build files list — merge blocks with same filename, keep separate files
if(codeBlocks.length){
S.tfFiles=[{name:'main.tf',content:codeBlocks.join('\n\n'),size:codeBlocks.join('\n\n').length,type:'hcl'}];
// Detect if the code has variables -> also create variables.tf reference
const fileMap={};
codeBlocks.forEach(b=>{
if(fileMap[b.name])fileMap[b.name]+='\n\n'+b.content;
else fileMap[b.name]=b.content;
});
S.tfFiles=Object.entries(fileMap).map(([name,content])=>({name,content,size:content.length,type:'hcl'}));
// Set tfCode to combined content for backward compat
S.tfCode=S.tfFiles.map(f=>'// filename: '+f.name+'\n'+f.content).join('\n\n');
}
r=r.replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/`(.*?)`/g,'<code>$1</code>').replace(/\n/g,'<br>');
return r;
}
function tfCopyBlock(i){const b=S.tfFiles[i];if(b)navigator.clipboard.writeText(b.content).then(()=>alert('Copiado!'))}
function rTerraform(){
const ms=S.tfMsgs.length===0
@@ -593,14 +713,20 @@ function rTerraform(){
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)?`<select onchange="S.tfCompartment=this.value;tfLoadResources()" style="max-width:220px">${S.tfCompLoading?'<option>Carregando...</option>':'<option value="">Compartment...</option>'+tfFlatComps(S.tfComps,0).map(c=>`<option value="${c.id}"${S.tfCompartment===c.id?' selected':''}>${c.name}</option>`).join('')}</select>`:'';
const compSel=(S.tfComps.length||S.tfCompLoading||S.tfOci)?`<select onchange="S.tfCompartment=this.value;tfLoadResources()" style="max-width:220px">${S.tfCompLoading?'<option>Carregando...</option>':S.tfComps.length?'<option value="">Compartment...</option>'+tfFlatComps(S.tfComps,0).map(c=>`<option value="${c.id}"${S.tfCompartment===c.id?' selected':''}>${c.name}</option>`).join(''):'<option value="">Selecione Compartment...</option>'}</select>`:'';
// Status badge
const stBadge=_tfBadge(S.tfStatus);
return `<div class="tf-wrap">
<div style="display:flex;flex:1;min-height:0;overflow:hidden">
<div class="ch-hp${S.tfHistOpen?' open':''}"><div class="ch-hp-inner">
<div class="ch-hp-hdr"><span>🕘 Histórico</span><div class="ch-hp-new" onclick="newTfChat()">+ Novo Chat</div></div>
<div class="ch-hp-list">${rHistList('terraform',S.tfHistory,S.tfSid)}</div>
</div></div>
<div class="tf-chat">
<div class="tf-toolbar">
<div class="ch-icon-btn${S.tfHistOpen?' active':''}" onclick="S.tfHistOpen=!S.tfHistOpen;if(S.tfHistOpen)loadHistory('terraform');R()" title="Histórico" style="width:24px;height:24px;font-size:.85rem">☰</div>
${TF_ICON}
<div class="mdrop"><div class="mdrop-btn" onclick="S.tfMdOpen=!S.tfMdOpen;R()">${curLabel} <span class="arr">▼</span></div>
${S.tfMdOpen?`<div class="mdrop-dd open"><input type="text" id="tfmds" placeholder="Buscar..." oninput="tfFilterModels(this.value)"><div class="mdrop-list" id="tfmdl">${ddItems}</div></div>`:''}
@@ -608,15 +734,15 @@ function rTerraform(){
${ociSel}${compSel}
${stBadge}
<div style="flex:1"></div>
${rTfActions()}
<div class="tf-file-btn" onclick="tfClear()" title="Nova conversa" style="width:24px;height:24px"><svg viewBox="0 0 16 16" width="12" height="12" fill="var(--t3)"><path d="M2 3h12v1H2zm1.5-1h9l-.5-1h-8zM3 4v9.5a1.5 1.5 0 001.5 1.5h7a1.5 1.5 0 001.5-1.5V4z"/></svg></div>
<div class="tf-file-btn" onclick="newTfChat()" title="Nova conversa" style="width:24px;height:24px"><svg viewBox="0 0 16 16" width="12" height="12" fill="var(--t3)"><path d="M8 2v12M2 8h12" stroke="var(--t3)" stroke-width="2" stroke-linecap="round"/></svg></div>
</div>
<div class="tf-msgs" id="tfchm">${ms}</div>
<div class="tf-input">
<input type="text" id="tfi" placeholder="Descreva a infraestrutura desejada..." onkeydown="if(event.key==='Enter')tfSend()">
<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">
@@ -632,15 +758,15 @@ function rTerraform(){
<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-btab${S.tfBtab==='output'?' active':''}" onclick="S.tfBtab='output';R()">
<svg viewBox="0 0 16 16" fill="currentColor"><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>
Output
</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">${rTfBottomContent()}</div>
<div class="tf-bottom-content">
<div class="tf-bc-left">${rTfBottomContent()}</div>
${rTfTerminal()}
</div>
</div>
${S.tfConfirmDest?rTfConfirmDestroy():''}
</div>`;
@@ -653,13 +779,13 @@ function _tfBadge(st){
}
function rTfActions(){
if(S.tfRunning)return'<button class="btn bsm" style="background:var(--rd);color:#fff;border-color:var(--rd);font-size:.68rem;padding:2px 10px" onclick="tfCancel()">Cancel</button>';
if(S.tfRunning)return'<button class="btn bsm" style="background:var(--rd);color:#fff;border-color:var(--rd);font-size:.72rem;padding:4px 12px" onclick="tfCancel()">Cancel</button>';
const st=S.tfStatus;let btns='';
if(S.tfCode&&(st==='draft'||st==='failed'))btns+=`<button class="btn bsm" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.68rem;padding:2px 10px" onclick="tfSaveAndPlan()">${TF_ICON} Plan</button>`;
if(st==='planned'||st==='failed')btns+=`<button class="btn bsm" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.68rem;padding:2px 10px" 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:.68rem;padding:2px 10px" onclick="tfApply()">Apply</button>';
if(st==='applied')btns+=`<button class="btn bsm" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.68rem;padding:2px 10px" 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:.68rem;padding:2px 10px" onclick="S.tfConfirmDest=true;R()">Destroy</button>';
if(S.tfCode&&(st==='draft'||st==='failed'))btns+=`<button class="btn bsm" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.72rem;padding:4px 12px;gap:4px;display:inline-flex;align-items:center" onclick="tfSaveAndPlan()">${TF_ICON} Plan</button>`;
if(st==='planned'||st==='failed')btns+=`<button class="btn bsm" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.72rem;padding:4px 12px;gap:4px;display:inline-flex;align-items:center" onclick="tfSaveAndPlan()">${TF_ICON} Re-plan</button>`;
if(st==='planned')btns+='<button class="btn bsm" style="background:var(--gn);color:#fff;border-color:var(--gn);font-size:.72rem;padding:4px 12px" onclick="tfApply()">Apply</button>';
if(st==='applied')btns+=`<button class="btn bsm" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.72rem;padding:4px 12px;gap:4px;display:inline-flex;align-items:center" onclick="tfSaveAndPlan()">${TF_ICON} Re-plan</button>`;
if(st==='applied'||st==='planned')btns+='<button class="btn bsm" style="background:var(--rd);color:#fff;border-color:var(--rd);font-size:.72rem;padding:4px 12px" onclick="S.tfConfirmDest=true;R()">Destroy</button>';
return btns;
}
@@ -667,13 +793,27 @@ function rTfBottomContent(){
if(S.tfBtab==='files')return rTfFiles();
if(S.tfBtab==='plan')return rTfPlanPanel();
if(S.tfBtab==='resources')return rTfResourcesPanel();
if(S.tfBtab==='output')return rTfOutputPanel();
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>`;
// Auto-add provider.tf and terraform.tfvars as virtual files
const files=[...S.tfFiles];
if(S.tfWs||S.tfOci){
const oc=S.ociCfg.find(x=>x.id===S.tfOci);
@@ -682,18 +822,29 @@ function rTfFiles(){
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});
}
}
return files.map((f,i)=>{
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';
return`<div class="tf-file">
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">
<div class="tf-file-btn" onclick="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="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>
${!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>`}).join('');
</div>`;
if(isEdit){
html+=`<div class="tf-editor">
<div class="tf-editor-hdr"><svg viewBox="0 0 16 16" width="12" height="12" fill="#7b42bc"><path d="M12.146.854a.5.5 0 01.708 0l2.292 2.292a.5.5 0 010 .708L5.854 13.146a.5.5 0 01-.22.128l-3.5 1.166a.5.5 0 01-.632-.632l1.166-3.5a.5.5 0 01.128-.22z"/></svg><span class="tf-ed-name">${f.name}</span>${f.auto?'<span style="font-size:.58rem;color:var(--t3);font-weight:400">somente leitura</span>':'<button class="btn bs" onclick="tfSaveFile(${i})" style="background:#7b42bc;color:#fff;border-color:#7b42bc;font-size:.6rem;padding:1px 8px">Salvar</button>'}<button class="btn bs" onclick="S.tfEditIdx=-1;R()" style="font-size:.6rem;padding:1px 8px">Fechar</button></div>
<textarea class="tf-editor-ta" id="tfEdTa" ${f.auto?'readonly':''}onkeydown="if(event.key==='Tab'){event.preventDefault();const s=this.selectionStart,e=this.selectionEnd;this.value=this.value.substring(0,s)+' '+this.value.substring(e);this.selectionStart=this.selectionEnd=s+2}">${escHtml(f.content||'')}</textarea>
</div>`;
}
}
return html;
}
function rTfPlanPanel(){
@@ -704,15 +855,6 @@ function rTfPlanPanel(){
<div style="font-size:.64rem;color:var(--t3)">${escHtml(r.desc)}</div></div></div>`).join('');
}
function rTfOutputPanel(){
let out='';
if(S.tfPlanOut)out+=`<div style="font-size:.7rem;font-weight:600;color:#7b42bc;margin-bottom:.3rem">${TF_ICON} Plan Output</div><div class="tf-output">${escHtml(S.tfPlanOut)}</div>`;
if(S.tfApplyOut)out+=`<div style="font-size:.7rem;font-weight:600;color:var(--gn);margin-top:.5rem;margin-bottom:.3rem">Apply Output</div><div class="tf-output">${escHtml(S.tfApplyOut)}</div>`;
if(S.tfDestroyOut)out+=`<div style="font-size:.7rem;font-weight:600;color:var(--rd);margin-top:.5rem;margin-bottom:.3rem">Destroy Output</div><div class="tf-output">${escHtml(S.tfDestroyOut)}</div>`;
if(!out)return`<div class="tf-empty"><svg viewBox="0 0 16 16" width="36" height="36" fill="#7b42bc" opacity=".2"><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><p>Nenhuma execução ainda</p></div>`;
return out;
}
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>
@@ -735,6 +877,8 @@ function tfStartResize(e){
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);
@@ -787,12 +931,12 @@ function rTfResourcesPanel(){
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=>`${r.display_name} <span style="opacity:.6">(${r.shape})</span>`},
{key:'instances',label:'Compute',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M2 2h12v12H2z"/></svg>',fmt:r=>{const stopped=r.state==='STOPPED';const running=r.state==='RUNNING';const stC=stopped?'var(--rd)':running?'var(--gn)':'var(--t3)';return`${r.display_name} <span style="opacity:.6">(${r.shape})</span> <span style="color:${stC};font-weight:600;font-size:.62rem">${r.state}</span>${stopped?` <button class="btn bsm" style="font-size:.58rem;padding:1px 6px;background:var(--gn);color:#fff;border-color:var(--gn);margin-left:4px" onclick="event.stopPropagation();ociStartInstance('${r.id}')">▶ Start</button>`:running?` <button class="btn bsm" style="font-size:.58rem;padding:1px 6px;background:var(--rd);color:#fff;border-color:var(--rd);margin-left:4px" onclick="event.stopPropagation();ociStopInstance('${r.id}')">■ Stop</button>`:''}`}},
{key:'internet_gateways',label:'Internet GWs',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><circle cx="8" cy="8" r="6" fill="none" stroke="#7b42bc" stroke-width="1.5"/><path d="M1 8h14M8 2c-2 2-2 10 0 12m0-12c2 2 2 10 0 12"/></svg>',fmt:r=>r.display_name},
{key:'nat_gateways',label:'NAT GWs',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M3 3l5 5-5 5"/><path d="M8 8h6" stroke="#7b42bc" stroke-width="1.5"/></svg>',fmt:r=>r.display_name},
{key:'route_tables',label:'Route Tables',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M2 3h12v2H2zm0 4h8v2H2zm0 4h10v2H2z"/></svg>',fmt:r=>r.display_name},
{key:'security_lists',label:'Security Lists',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M8 1l6 3v4c0 3.5-2.5 6.5-6 7.5C4.5 14.5 2 11.5 2 8V4z"/></svg>',fmt:r=>r.display_name},
{key:'autonomous_databases',label:'Databases',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><ellipse cx="8" cy="4" rx="6" ry="2.5"/><path d="M2 4v8c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5V4"/></svg>',fmt:r=>`${r.display_name} <span style="opacity:.6">(${r.db_name}${r.is_free_tier?' Free':''})</span>`},
{key:'autonomous_databases',label:'Databases',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><ellipse cx="8" cy="4" rx="6" ry="2.5"/><path d="M2 4v8c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5V4"/></svg>',fmt:r=>{const stopped=r.state==='STOPPED';const avail=r.state==='AVAILABLE';const stC=stopped?'var(--rd)':avail?'var(--gn)':'var(--t3)';return`${r.display_name} <span style="opacity:.6">(${r.db_name}${r.is_free_tier?' Free':''})</span> <span style="color:${stC};font-weight:600;font-size:.62rem">${r.state}</span>${stopped?` <button class="btn bsm" style="font-size:.58rem;padding:1px 6px;background:var(--gn);color:#fff;border-color:var(--gn);margin-left:4px" onclick="event.stopPropagation();ociStartAdb('${r.id}')">▶ Start</button>`:avail?` <button class="btn bsm" style="font-size:.58rem;padding:1px 6px;background:var(--rd);color:#fff;border-color:var(--rd);margin-left:4px" onclick="event.stopPropagation();ociStopAdb('${r.id}')">■ Stop</button>`:''}`}},
{key:'block_volumes',label:'Block Volumes',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M3 2h10v12H3z"/></svg>',fmt:r=>`${r.display_name} <span style="opacity:.6">(${r.size_in_gbs||'?'} GB)</span>`},
{key:'load_balancers',label:'Load Balancers',icon:'<svg viewBox="0 0 16 16" width="14" height="14" fill="#7b42bc"><path d="M8 2v12M2 8h12M4 4l8 8M12 4l-8 8"/></svg>',fmt:r=>`${r.display_name} <span style="opacity:.6">(${r.shape_name||'flexible'})</span>`}
];
@@ -815,16 +959,22 @@ function rTfResourcesPanel(){
html+='</div>';
return html;
}
function _ociResOid(){return S.tfOci||(S.tfModel.startsWith('cfg:')?((S.genaiCfg.find(x=>x.id===S.tfModel.slice(4))||{}).oci_config_id||''):'')}
async function ociStartInstance(id){if(!confirm('Iniciar esta instância?'))return;try{await $api('/oci/instances/'+id+'/action',{method:'POST',body:{action:'START',oci_config_id:_ociResOid(),region:S.tfRegion||null}});alert('Instância iniciando...');tfLoadResources()}catch(e){alert('Erro: '+e.message)}}
async function ociStopInstance(id){if(!confirm('Parar esta instância?'))return;try{await $api('/oci/instances/'+id+'/action',{method:'POST',body:{action:'STOP',oci_config_id:_ociResOid(),region:S.tfRegion||null}});alert('Instância parando...');tfLoadResources()}catch(e){alert('Erro: '+e.message)}}
async function ociStartAdb(id){if(!confirm('Iniciar este Autonomous Database?'))return;try{await $api('/oci/autonomous-databases/'+id+'/action',{method:'POST',body:{action:'start',oci_config_id:_ociResOid(),region:S.tfRegion||null}});alert('Autonomous DB iniciando...');tfLoadResources()}catch(e){alert('Erro: '+e.message)}}
async function ociStopAdb(id){if(!confirm('Parar este Autonomous Database?'))return;try{await $api('/oci/autonomous-databases/'+id+'/action',{method:'POST',body:{action:'stop',oci_config_id:_ociResOid(),region:S.tfRegion||null}});alert('Autonomous DB parando...');tfLoadResources()}catch(e){alert('Erro: '+e.message)}}
function tfFilterModels(q){const list=document.getElementById('tfmdl');if(!list)return;const items=list.querySelectorAll('.itm,.grp');const ql=q.toLowerCase();let lastGrp=null,vis=false;items.forEach(el=>{if(el.classList.contains('grp')){if(lastGrp)lastGrp.style.display=vis?'':'none';lastGrp=el;vis=false}else{const show=!q||el.textContent.toLowerCase().includes(ql);el.style.display=show?'':'none';if(show)vis=true}});if(lastGrp)lastGrp.style.display=vis?'':'none'}
function tfTogglePanel(p){S.tfPanel=S.tfPanel===p?'':p;R()}
async function tfClear(){if(S.tfSid)try{await $api('/chat/'+S.tfSid,{method:'DELETE'})}catch(e){}S.tfMsgs=[];S.tfSid=null;S.tfPlan=[];S.tfCode='';S.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()}
async function tfClear(){if(S.tfSid)try{await $api('/chat/'+S.tfSid,{method:'DELETE'})}catch(e){}S.tfMsgs=[];S.tfSid=null;S.tfPlan=[];S.tfCode='';S.tfFiles=[];S.tfWs=null;S.tfPlanOut='';S.tfApplyOut='';S.tfDestroyOut='';S.tfStatus='draft';S.tfRunning=false;S.tfConfirmDest=false;S.tfComps=[];S.tfCompartment='';S.tfResources=null;S.tfResLoading=false;R()}
function tfScroll(){setTimeout(()=>{const e=document.getElementById('tfchm');if(e)e.scrollTop=e.scrollHeight},50)}
function copyTfCode(){navigator.clipboard.writeText(S.tfCode).then(()=>alert('Código copiado!'))}
function dlTfCode(){if(!S.tfCode)return;const b=new Blob([S.tfCode],{type:'text/plain'});const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='main.tf';a.click();URL.revokeObjectURL(a.href)}
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;
const el=document.getElementById('tfi');const m=el.value.trim();if(!m)return;el.style.height='auto';S._tfiVal='';
if(!S.tfModel){S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione um modelo antes de enviar.'});R();return}
if(!S.tfCompartment){S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione um compartment antes de enviar.'});R();return}
el.value='';S.tfMsgs.push({r:'user',c:m});R();tfScroll();
S.tfMsgs.push({r:'assistant',c:'⏳ _Gerando Terraform..._',thinking:true});R();tfScroll();
try{
@@ -836,6 +986,7 @@ async function tfSend(){
}
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'});R();tfScroll()}
}catch(e){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'❌ Erro: '+e.message});R()}
@@ -870,7 +1021,7 @@ async function tfSaveAndPlan(){
}
// Run plan
await $api('/terraform/workspaces/'+S.tfWs+'/plan',{method:'POST'});
S.tfStatus='planning';S.tfRunning=true;S.tfPlanOut='';S.tfPanel='output';R();
S.tfStatus='planning';S.tfRunning=true;S.tfPlanOut='';S.tfBtab='plan';R();
await tfPollExec();
}catch(e){alert('Erro: '+e.message)}
}
@@ -879,7 +1030,7 @@ 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.tfPanel='output';R();
S.tfStatus='applying';S.tfRunning=true;S.tfApplyOut='';S.tfBtab='plan';R();
await tfPollExec();
}catch(e){alert('Erro: '+e.message)}
}
@@ -891,7 +1042,7 @@ async function tfConfirmDestroy(){
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.tfPanel='output';R();
S.tfStatus='destroying';S.tfRunning=true;S.tfDestroyOut='';S.tfBtab='plan';R();
await tfPollExec();
}catch(e){alert('Erro: '+e.message)}
}
@@ -914,7 +1065,7 @@ async function tfPollExec(){
if(!['planning','applying','destroying'].includes(r.status)){S.tfRunning=false;R();return}
R();
// Auto-scroll output
const outs=document.querySelectorAll('.tf-output');outs.forEach(o=>o.scrollTop=o.scrollHeight);
const t=document.getElementById('tfTermOut');if(t)t.scrollTop=t.scrollHeight;
}catch(e){}
}
S.tfRunning=false;R();
@@ -1235,7 +1386,7 @@ function _dlFmtSize(b){if(b>=1048576)return(b/1048576).toFixed(1)+' MB';if(b>=10
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]||'var(--ac)';
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(){