feat: region safety guard, rollback button, SSH key support, split-panel layout
- Block terraform plan if model omits variable "region" (prevents wrong-region provisioning) - Rollback button on apply failure with ROLLBACK confirmation modal - SSH public key field in OCI configs, auto-injected into terraform.tfvars - Split-panel layout: terminal right (40%), files/plan/resources below chat (60%) - reasoning_effort uppercase fix for OCI SDK - Plan button persists after destroy status
This commit is contained in:
@@ -375,11 +375,15 @@ html .spinner,html [class*="kpi-bar-fill"],html canvas{transition:none!important
|
||||
.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-wrap{display:flex;flex-direction:row;height:calc(100vh - 56px - 3rem);overflow:hidden}
|
||||
.tf-left{flex:6;display:flex;flex-direction:column;overflow:hidden;min-width:0}
|
||||
.tf-chat{flex:7;display:flex;flex-direction:column;overflow:hidden;min-height:0}
|
||||
.tf-output-panel{flex:4;display:flex;flex-direction:column;overflow:hidden;border-left:1px solid var(--bd);min-width: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}
|
||||
.tfp-act{display:inline-flex;align-items:center;gap:.25rem;padding:.2rem .5rem;font-size:.6rem;border-radius:5px;border:1px solid var(--bd);background:var(--bg2);color:var(--t2);cursor:pointer;transition:all .15s;font-family:inherit;line-height:1.3}
|
||||
.tfp-acts{display:flex;gap:.35rem;margin-top:.5rem;padding-top:.45rem;border-top:1px solid var(--bd)}
|
||||
.tfp-act{display:inline-flex;align-items:center;gap:.2rem;padding:.2rem .45rem;font-size:.62rem;border-radius:5px;border:1px solid var(--bd);background:var(--bg2);color:var(--t2);cursor:pointer;transition:all .15s;font-family:inherit;line-height:1.3}
|
||||
.tfp-act svg{width:11px;height:11px}
|
||||
.tfp-act:hover{background:var(--bg3);border-color:var(--t4)}
|
||||
.tfp-act-tf{background:#7b42bc18;border-color:#7b42bc55;color:#7b42bc}
|
||||
.tfp-act-tf:hover{background:#7b42bc30;border-color:#7b42bc}
|
||||
@@ -389,19 +393,17 @@ html .spinner,html [class*="kpi-bar-fill"],html canvas{transition:none!important
|
||||
.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{flex:3;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-bottom-content{flex:1;overflow:auto;padding:.5rem .7rem}
|
||||
.tf-output-panel .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-output-panel .tf-term-hdr svg{width:12px;height:12px;fill:var(--t2)}
|
||||
.tf-output-panel .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)}
|
||||
@@ -455,6 +457,9 @@ html .spinner,html [class*="kpi-bar-fill"],html canvas{transition:none!important
|
||||
.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-wrap{flex-direction:column}
|
||||
.tf-left{flex:none;height:60%}
|
||||
.tf-output-panel{flex:none;height:40%;border-left:none;border-top:1px solid var(--bd)}
|
||||
.tf-bottom{min-height:0}
|
||||
}
|
||||
</style>
|
||||
@@ -507,6 +512,7 @@ const IC={
|
||||
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"/>'),
|
||||
fix:_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"/>'),
|
||||
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"/>'),
|
||||
@@ -533,7 +539,7 @@ const S={user:null,token:null,tab:'chat',msgs:[],sid:null,reports:[],ociCfg:[],g
|
||||
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,
|
||||
tfWs:null,tfWsList:[],tfPlanOut:'',tfApplyOut:'',tfDestroyOut:'',tfStatus:'draft',tfRunning:false,tfHadApply:false,tfConfirmDest:false,tfConfirmRollback:false,tfMdOpen:false,
|
||||
tfComps:[],tfCompLoading:false,tfFiles:[],tfBtab:'files',tfEditIdx:-1,tfResources:null,tfResLoading:false,tfRefStatus:'',tfBottomH:null,
|
||||
tfpMsgs:[],tfpLoading:false,tfpCopied:false,tfpModel:'',tfpMdOpen:false,tfpOci:'',tfpRegion:'',tfpCompartment:'',
|
||||
tfpHistOpen:false,tfpHistory:[],tfpSid:null};
|
||||
@@ -571,7 +577,7 @@ function R(){try{
|
||||
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 bcLeft=document.querySelector('.tf-bottom-content');if(bcLeft)scrollMap._bcContent=bcLeft.scrollTop;
|
||||
|
||||
const appEl=document.getElementById('app');
|
||||
if(!S.user){appEl.innerHTML=rLogin();return}
|
||||
@@ -589,7 +595,7 @@ function R(){try{
|
||||
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}});
|
||||
Object.entries(scrollMap).forEach(([id,top])=>{if(id==='_bcContent'){const el=document.querySelector('.tf-bottom-content');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'}
|
||||
});
|
||||
@@ -809,7 +815,7 @@ async function pollChatResult(mid){
|
||||
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 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.tfHadApply=false;S.tfConfirmDest=false;S.tfConfirmRollback=false;S.tfEditIdx=-1;R()}
|
||||
|
||||
function tfToggleMenu(){
|
||||
const m=document.getElementById('tfMenu');
|
||||
@@ -884,6 +890,7 @@ async function loadSession(sid,type){
|
||||
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(S.tfApplyOut)S.tfHadApply=true;
|
||||
if(detail.tf_code){
|
||||
S.tfCode=detail.tf_code;
|
||||
const parts=detail.tf_code.split(/^\/\/\s*filename:\s*/m).filter(Boolean);
|
||||
@@ -1130,9 +1137,11 @@ function fmTf(t){
|
||||
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
|
||||
// Only auto-split on FIRST generation (no existing files) — during corrections, trust the model's named files
|
||||
const hasExistingFiles=S.tfFiles.length>0;
|
||||
const allNamed=codeBlocks.every(b=>b.name!=='main.tf'||codeBlocks.length===1);
|
||||
let didAutoSplit=false;
|
||||
if(codeBlocks.length<=2){
|
||||
if(codeBlocks.length<=2 && !hasExistingFiles){
|
||||
const totalContent=codeBlocks.map(b=>b.content).join('\n\n');
|
||||
if(totalContent.length>2000){
|
||||
const split=_splitTfMonolith(totalContent);
|
||||
@@ -1196,9 +1205,9 @@ function rTfPrompt(){
|
||||
const msHtml=msgs.length===0
|
||||
?`<div class="tf-empty">${IC.edit}<p>Descreva a infraestrutura OCI que você precisa</p><p style="font-size:.68rem;opacity:.6">O agente vai gerar um prompt estruturado e otimizado para o Terraform Agent.</p>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.75rem;justify-content:center">${TFP_EXAMPLES.map(e=>`<button class="btn bs bsm" onclick="document.getElementById('tfpi').value='${e.replace(/'/g,"\\'")}';document.getElementById('tfpi').focus()" style="font-size:.66rem;white-space:normal;text-align:left;max-width:280px;line-height:1.35">${e}</button>`).join('')}</div></div>`
|
||||
:msgs.map((m,i)=>`<div class="cm cm-${m.r}"><div class="cb">${fm(m.c)}</div>${m._raw?`<div style="display:flex;gap:.3rem;margin-top:.35rem;padding-top:.3rem;border-top:1px solid var(--bd)">
|
||||
<button class="tfp-act" onclick="tfpCopy(${i})">${m._copied?IC.ok+' Copiado':IC.clip+' Copiar'}</button>
|
||||
<button class="tfp-act tfp-act-tf" onclick="tfpSendToTf(${i})">${TF_IC} Terraform</button></div>`:''}</div>`).join('');
|
||||
:msgs.map((m,i)=>`<div class="cm cm-${m.r}"><div class="cb">${fm(m.c)}${m._raw?`
|
||||
<div class="tfp-acts"><button class="tfp-act" onclick="tfpCopy(${i})">${m._copied?IC.ok+' Copiado':IC.clip+' Copiar'}</button>
|
||||
<button class="tfp-act tfp-act-tf" onclick="tfpSendToTf(${i})">${TF_IC} Terraform</button></div>`:''}</div></div>`).join('');
|
||||
// Model dropdown — only recommended models, grouped by provider
|
||||
const tfpAllowed=['openai.gpt-4.1','openai.o3','openai.o4-mini','openai.gpt-5.1','openai.gpt-5.2','google.gemini-2.5-pro','google.gemini-2.5-flash'];
|
||||
const tfpProvs={};tfpAllowed.forEach(mid=>{const mi=S.models[mid];if(mi){const p=mi.provider||'other';if(!tfpProvs[p])tfpProvs[p]=[];tfpProvs[p].push({id:mid,name:mi.name})}});
|
||||
@@ -1266,7 +1275,15 @@ async function _tfpResolveGenai(){
|
||||
return{oci_config_id:oc.id,model_id:'openai.gpt-4.1',genai_region:oc.region,compartment_id:oc.compartment_id}}
|
||||
function tfpCopy(idx){
|
||||
const m=S.tfpMsgs[idx];if(!m||!m._raw)return;
|
||||
navigator.clipboard.writeText(m._raw).then(()=>{m._copied=true;R();setTimeout(()=>{m._copied=false;R()},2000)})}
|
||||
navigator.clipboard.writeText(m._raw).then(()=>{
|
||||
// Show toast instead of re-rendering
|
||||
const toast=document.createElement('div');
|
||||
toast.textContent='Prompt copiado!';
|
||||
toast.style.cssText='position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#2ecc71;color:#fff;padding:6px 16px;border-radius:6px;font-size:.75rem;z-index:9999;opacity:0;transition:opacity .2s';
|
||||
document.body.appendChild(toast);
|
||||
requestAnimationFrame(()=>toast.style.opacity='1');
|
||||
setTimeout(()=>{toast.style.opacity='0';setTimeout(()=>toast.remove(),200)},1500);
|
||||
})}
|
||||
function tfpSendToTf(idx){
|
||||
const m=S.tfpMsgs[idx];if(!m||!m._raw)return;
|
||||
S.tab='terraform';
|
||||
@@ -1296,7 +1313,8 @@ function rTerraform(){
|
||||
const stBadge=_tfBadge(S.tfStatus);
|
||||
|
||||
return `<div class="tf-wrap">
|
||||
<div style="display:flex;flex:1;min-height:0;overflow:hidden">
|
||||
<div class="tf-left">
|
||||
<div style="display:flex;flex:7;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>
|
||||
@@ -1353,12 +1371,14 @@ function rTerraform(){
|
||||
${rTfActions()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tf-bottom-content">
|
||||
<div class="tf-bc-left">${rTfBottomContent()}</div>
|
||||
${rTfTerminal()}
|
||||
</div>
|
||||
<div class="tf-bottom-content">${rTfBottomContent()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tf-output-panel">
|
||||
${rTfTerminal()}
|
||||
</div>
|
||||
${S.tfConfirmDest?rTfConfirmDestroy():''}
|
||||
${S.tfConfirmRollback?rTfConfirmRollback():''}
|
||||
${S.tfPromptOpen?rTfPromptModal():''}
|
||||
</div>`;
|
||||
}
|
||||
@@ -1381,7 +1401,9 @@ 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:.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(S.tfCode&&(st==='draft'||st==='failed'||st==='destroyed'))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==='failed'&&S.tfPlanOut&&S.tfFiles.length&&S.tfModel)btns+=`<button class="btn bsm" style="background:#e67e22;color:#fff;border-color:#e67e22;font-size:.72rem;padding:4px 12px;gap:4px;display:inline-flex;align-items:center" onclick="tfAutoFix()">${IC.fix} Auto-corrigir</button>`;
|
||||
if(st==='failed'&&(S.tfApplyOut||S.tfHadApply))btns+='<button class="btn bsm" style="background:var(--rd);color:#fff;border-color:var(--rd);font-size:.72rem;padding:4px 12px;gap:4px;display:inline-flex;align-items:center" onclick="S.tfConfirmRollback=true;R()">↩ Rollback</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>`;
|
||||
@@ -1406,10 +1428,8 @@ function rTfTerminal(){
|
||||
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>`;
|
||||
return`<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>`;
|
||||
}
|
||||
|
||||
function rTfFiles(){
|
||||
@@ -1468,6 +1488,31 @@ function rTfConfirmDestroy(){
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
function rTfConfirmRollback(){
|
||||
return`<div class="tf-modal-overlay" onclick="S.tfConfirmRollback=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="#e67e22"><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 Rollback</h3>
|
||||
<p style="font-size:.8rem;margin:.5rem 0">O apply falhou parcialmente. O rollback irá <strong>destruir todos os recursos</strong> que foram criados antes do erro.</p>
|
||||
<p style="font-size:.74rem;color:var(--t3);margin-bottom:.8rem">Digite <strong>ROLLBACK</strong> para confirmar:</p>
|
||||
<input type="text" id="tfRollbackInput" placeholder="ROLLBACK" style="margin-bottom:.6rem">
|
||||
<div style="display:flex;gap:.5rem;justify-content:flex-end">
|
||||
<button class="btn bs" onclick="S.tfConfirmRollback=false;R()">Cancelar</button>
|
||||
<button class="btn bd" onclick="tfDoRollback()">Rollback</button>
|
||||
</div>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
async function tfDoRollback(){
|
||||
const inp=document.getElementById('tfRollbackInput');
|
||||
if(!inp||inp.value!=='ROLLBACK'){alert('Digite ROLLBACK para confirmar.');return}
|
||||
S.tfConfirmRollback=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 ao iniciar rollback: '+e.message)}
|
||||
}
|
||||
|
||||
function tfStartResize(e){
|
||||
e.preventDefault();const el=document.getElementById('tfBottom');if(!el)return;
|
||||
const startY=e.clientY;const startH=el.offsetHeight;
|
||||
@@ -1595,7 +1640,7 @@ 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 left=document.querySelector('.tf-bottom-content');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()}
|
||||
@@ -1606,7 +1651,7 @@ 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()
|
||||
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.tfHadApply=false;S.tfConfirmDest=false;S.tfConfirmRollback=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!'))}
|
||||
@@ -1623,7 +1668,7 @@ async function tfSend(){
|
||||
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```';
|
||||
finalMsg+='\n\n---\nARQUIVOS TERRAFORM ATUAIS NO WORKSPACE:\n```hcl\n'+filesCtx+'\n```\n\n⚠️ REGRA OBRIGATÓRIA: Gere APENAS os arquivos que precisam de correção. NÃO regenere arquivos sem erro. Se o erro está em um arquivo específico, retorne SOMENTE esse arquivo com `// filename:`. Arquivos não mencionados na sua resposta serão MANTIDOS como estão.';
|
||||
}
|
||||
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```'}
|
||||
@@ -1679,7 +1724,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.tfBtab='plan';R();
|
||||
S.tfStatus='applying';S.tfRunning=true;S.tfHadApply=true;S.tfApplyOut='';S.tfBtab='plan';R();
|
||||
await tfPollExec();
|
||||
}catch(e){alert('Erro: '+e.message)}
|
||||
}
|
||||
@@ -1702,6 +1747,37 @@ async function tfCancel(){
|
||||
S.tfRunning=false;S.tfStatus='failed';R();
|
||||
}
|
||||
|
||||
async function tfAutoFix(){
|
||||
if(!S.tfPlanOut||!S.tfFiles.length||!S.tfModel)return;
|
||||
// Extract errors from plan output
|
||||
const errLines=S.tfPlanOut.split('\n').filter(l=>/^Error:|^\s+on\s+\S+\.tf\s+line|^\s+\d+:|has not been declared|is not expected|Missing required|Unsupported|Invalid|Duplicate|undeclared/i.test(l));
|
||||
const errCtx=errLines.length?errLines.join('\n'):S.tfPlanOut.slice(-2000);
|
||||
// Build auto-fix message
|
||||
const filesCtx=S.tfFiles.map(f=>'// filename: '+f.name+'\n'+f.content).join('\n\n');
|
||||
const fixMsg='O terraform plan falhou com os seguintes erros. Corrija SOMENTE os arquivos afetados.\n\n---\nERROS DO PLAN:\n```\n'+errCtx+'\n```\n\n---\nARQUIVOS TERRAFORM ATUAIS NO WORKSPACE:\n```hcl\n'+filesCtx+'\n```\n\n⚠️ REGRA OBRIGATÓRIA: Gere APENAS os arquivos que precisam de correção com `// filename:`. Arquivos não mencionados serão MANTIDOS como estão.';
|
||||
// Show in chat
|
||||
S.tfMsgs.push({r:'user',c:'🔧 Auto-corrigir erros do plan',t:tstamp()});
|
||||
S.tfMsgs.push({r:'assistant',c:IC.wait+' _Analisando erros e corrigindo..._',thinking:true});R();tfScroll();
|
||||
try{
|
||||
const body={message:fixMsg,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=S.tfMsgs.filter(x=>!x.thinking);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(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||'Sem resposta',t:tstamp()});R();tfScroll()}
|
||||
// Auto re-plan after fix
|
||||
if(S.tfFiles.length&&S.tfCode){
|
||||
S.tfMsgs.push({r:'assistant',c:IC.wait+' _Re-executando plan..._',thinking:true});R();
|
||||
await tfSaveAndPlan();
|
||||
S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);R()
|
||||
}
|
||||
}catch(e){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:IC.err+' Erro auto-fix: '+e.message,t:tstamp()});R()}
|
||||
}
|
||||
|
||||
async function tfPollExec(){
|
||||
for(let i=0;i<1800;i++){
|
||||
await new Promise(r=>setTimeout(r,2000));
|
||||
@@ -1712,7 +1788,7 @@ async function tfPollExec(){
|
||||
S.tfDestroyOut=r.destroy_output||S.tfDestroyOut;
|
||||
S.tfStatus=r.status;
|
||||
const done=!['planning','applying','destroying'].includes(r.status);
|
||||
if(done)S.tfRunning=false;
|
||||
if(done){S.tfRunning=false;if(r.status==='destroyed')S.tfHadApply=false;}
|
||||
// Update only the terminal body without full re-render
|
||||
const t=document.getElementById('tfTermOut');
|
||||
if(t){
|
||||
@@ -2369,7 +2445,7 @@ function rOci(){const eo=S.editing?.type==='oci'?S.ociCfg.find(c=>c.id===S.editi
|
||||
<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 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><br><span title="SSH Public Key">${IC.key} SSH: ${c.ssh_public_key_preview||'<em>não configurada</em>'}</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>
|
||||
@@ -2384,7 +2460,7 @@ ${!S.ociCfg.length?'<div class="emp" style="padding:.85rem"><p>Nenhuma credencia
|
||||
<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 class="ig"><label>SSH Public Key</label><div class="ht">Chave SSH para acesso a instâncias compute (ex: ssh-rsa AAAA...)</div><textarea id="osshk" rows="2" placeholder="ssh-rsa AAAAB3NzaC1yc2E... (opcional)" style="font-size:.72rem;font-family:var(--fm);resize:vertical">${eo&&eo.ssh_public_key?eo.ssh_public_key:''}</textarea>${eo&&eo.ssh_public_key_preview?'<div class="ht">Atual: <code>'+eo.ssh_public_key_preview+'</code></div>':''}</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;
|
||||
@@ -2393,6 +2469,7 @@ async function sOci(){const tn=document.getElementById('otn').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||'');
|
||||
fd.append('ssh_public_key',document.getElementById('osshk').value||'');
|
||||
const sk=document.getElementById('osk').files[0];
|
||||
const isEdit=S.editing?.type==='oci';
|
||||
if(!isEdit){
|
||||
|
||||
Reference in New Issue
Block a user