feat: Terraform resource reference from provider schema, provider alias fixes, and timestamps

- Add gen_tf_reference.py to generate OCI Terraform provider resource catalog (~937 resources) from `terraform providers schema -json`
- Auto-inject categorized resource reference into Terraform Agent system prompt to prevent invalid resource type names
- Build-time reference generation in Dockerfile, refreshable via POST /api/terraform/refresh-reference
- Provider alias detection with brace-matching parser for multi-region deployments
- Clean old .tf files before writing new ones on Re-plan
- Message timestamps in terraform chat and history
- Partial DOM rendering to eliminate screen flickering
- Layout overflow fix (no scroll beyond viewport)
This commit is contained in:
nogueiraguh
2026-03-08 01:00:47 -03:00
parent 8bccf9367e
commit 19e481275d
5 changed files with 327 additions and 28 deletions

View File

@@ -129,6 +129,7 @@ tbody tr:last-child td{border-bottom:none}
.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 ── */
@@ -370,13 +371,34 @@ async function loadData(){try{
loadHistory('chat');
}catch(e){console.error(e)}}
function R(){try{
// Preserve textarea values before re-render
// Preserve inputs
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 edEl=document.getElementById('tfEdTa');if(edEl)S._tfEdVal=edEl.value;
// Preserve scroll positions
const scrollIds=['chm','tfchm','tfBottom','tfTermOut','pg'];
const scrollMap={};
scrollIds.forEach(id=>{const el=document.getElementById(id);if(el)scrollMap[id]=el.scrollTop});
const bcLeft=document.querySelector('.tf-bc-left');if(bcLeft)scrollMap._bcLeft=bcLeft.scrollTop;
const appEl=document.getElementById('app');
if(!S.user){appEl.innerHTML=rLogin();return}
// Partial update: only update page content + sidebar
const pgEl=document.getElementById('pg');
if(pgEl){
pgEl.innerHTML=rPg();
const sbEl=document.querySelector('.nav');if(sbEl){const tmp=document.createElement('div');tmp.innerHTML=rSb();const nn=tmp.querySelector('.nav');if(nn)sbEl.innerHTML=nn.innerHTML}
}else{
appEl.innerHTML=rApp();
}
// Restore inputs
const chi2=document.getElementById('chi');if(chi2&&S._chiVal){chi2.value=S._chiVal;autoGrow(chi2)}
const tfi2=document.getElementById('tfi');if(tfi2&&S._tfiVal){tfi2.value=S._tfiVal;autoGrow(tfi2)}
const ed2=document.getElementById('tfEdTa');if(ed2&&S._tfEdVal)ed2.value=S._tfEdVal;
// Restore scroll positions
requestAnimationFrame(()=>{
Object.entries(scrollMap).forEach(([id,top])=>{if(id==='_bcLeft'){const el=document.querySelector('.tf-bc-left');if(el)el.scrollTop=top}else{const el=document.getElementById(id);if(el)el.scrollTop=top}});
});
if(S.editing?.type==='mcp')setTimeout(mcpTypeFields,0)
}catch(e){console.error('Render error:',e);document.getElementById('app').innerHTML='<div style="padding:2rem;color:red"><h3>Render Error</h3><pre>'+e.message+'</pre></div>'}}
function switchTab(t){S.tab=t;R();if(t==='chat')loadHistory('chat');if(t==='terraform')loadHistory('terraform');if(t==='audit'&&S.user.role==='admin')loadAudit();if(t==='downloads')refreshDl();if(t==='explorer'&&S.ociCfg.length&&!S.expTree.length){if(!S.expCfg)S.expCfg=S.ociCfg[0].id;expLoadTree();expLoadRegions()}
@@ -396,7 +418,7 @@ function rLogin(){return`<div class="lp"><div class="lc fi">
<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 fi" id="pg">${rPg()}</div></div></div>`}
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(){
@@ -589,7 +611,7 @@ async function loadSession(sid,type){
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.tfSid=sid;S.tfMsgs=d.messages.map(m=>({r:m.role,c:m.content,t:m.created_at?.slice(11,16)||''}));
S.tfFiles=[];S.tfCode='';S.tfPlan=[];
const last=[...d.messages].reverse().find(m=>m.role==='assistant'&&m.content&&m.content.includes('```'));
if(last)fmTf(last.content);
@@ -628,8 +650,10 @@ function rHistList(type,hist,curSid){
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,'&quot;')}">${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>
@@ -699,7 +723,7 @@ function tfCopyBlock(i){const b=S.tfFiles[i];if(b)navigator.clipboard.writeText(
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></div>`).join('');
: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})}
@@ -728,8 +752,8 @@ function rTerraform(){
<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>`:''}
<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}
@@ -746,15 +770,15 @@ function rTerraform(){
<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="S.tfBtab='files';R()">
<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="S.tfBtab='plan';R()">
<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="S.tfBtab='resources';if(!S.tfResources&&S.tfCompartment)tfLoadResources();R()">
<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>
@@ -964,6 +988,16 @@ async function ociStartInstance(id){if(!confirm('Iniciar esta instância?'))retu
async function ociStopInstance(id){if(!confirm('Parar esta instância?'))return;try{await $api('/oci/instances/'+id+'/action',{method:'POST',body:{action:'STOP',oci_config_id:_ociResOid(),region:S.tfRegion||null}});alert('Instância parando...');tfLoadResources()}catch(e){alert('Erro: '+e.message)}}
async function ociStartAdb(id){if(!confirm('Iniciar este Autonomous Database?'))return;try{await $api('/oci/autonomous-databases/'+id+'/action',{method:'POST',body:{action:'start',oci_config_id:_ociResOid(),region:S.tfRegion||null}});alert('Autonomous DB iniciando...');tfLoadResources()}catch(e){alert('Erro: '+e.message)}}
async function ociStopAdb(id){if(!confirm('Parar este Autonomous Database?'))return;try{await $api('/oci/autonomous-databases/'+id+'/action',{method:'POST',body:{action:'stop',oci_config_id:_ociResOid(),region:S.tfRegion||null}});alert('Autonomous DB parando...');tfLoadResources()}catch(e){alert('Erro: '+e.message)}}
function tfSwitchBtab(tab){
S.tfBtab=tab;S.tfEditIdx=-1;
if(tab==='resources'&&!S.tfResources&&S.tfCompartment)tfLoadResources();
// Update only bottom panel content + tab active states
const left=document.querySelector('.tf-bc-left');if(left)left.innerHTML=rTfBottomContent();
const tabs=document.querySelectorAll('.tf-btab');tabs.forEach(t=>{const isActive=t.textContent.trim().toLowerCase().startsWith(tab);t.classList.toggle('active',isActive)});
// Update actions
const acts=document.querySelector('.tf-bottom-actions');if(acts){acts.innerHTML=(S.tfFiles.length?'<button class="btn bs bsm" onclick="tfDlAll()" style="font-size:.66rem;padding:2px 8px">Download All</button>':'')+rTfActions()}
}
function tfToggleModelDrop(){const dd=document.getElementById('tfmdd');dd.classList.toggle('open');S.tfMdOpen=dd.classList.contains('open');if(S.tfMdOpen){const inp=document.getElementById('tfmds');if(inp){inp.value='';inp.focus();tfFilterModels('')}}}
function tfFilterModels(q){const list=document.getElementById('tfmdl');if(!list)return;const items=list.querySelectorAll('.itm,.grp');const ql=q.toLowerCase();let lastGrp=null,vis=false;items.forEach(el=>{if(el.classList.contains('grp')){if(lastGrp)lastGrp.style.display=vis?'':'none';lastGrp=el;vis=false}else{const show=!q||el.textContent.toLowerCase().includes(ql);el.style.display=show?'':'none';if(show)vis=true}});if(lastGrp)lastGrp.style.display=vis?'':'none'}
function tfTogglePanel(p){S.tfPanel=S.tfPanel===p?'':p;R()}
async function tfClear(){if(S.tfSid)try{await $api('/chat/'+S.tfSid,{method:'DELETE'})}catch(e){}S.tfMsgs=[];S.tfSid=null;S.tfPlan=[];S.tfCode='';S.tfFiles=[];S.tfWs=null;S.tfPlanOut='';S.tfApplyOut='';S.tfDestroyOut='';S.tfStatus='draft';S.tfRunning=false;S.tfConfirmDest=false;S.tfComps=[];S.tfCompartment='';S.tfResources=null;S.tfResLoading=false;R()}
@@ -973,35 +1007,35 @@ function dlTfCode(){if(!S.tfFiles.length)return;if(S.tfFiles.length===1){const f
async function tfSend(){
const el=document.getElementById('tfi');const m=el.value.trim();if(!m)return;el.style.height='auto';S._tfiVal='';
if(!S.tfModel){S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione um modelo antes de enviar.'});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();
if(!S.tfModel){S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione um modelo antes de enviar.',t:tstamp()});R();return}
if(!S.tfCompartment){S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione um compartment antes de enviar.',t:tstamp()});R();return}
el.value='';S.tfMsgs.push({r:'user',c:m,t:tstamp()});R();tfScroll();
S.tfMsgs.push({r:'assistant',c:'⏳ _Gerando Terraform..._',thinking:true});R();tfScroll();
try{
const body={message:m,session_id:S.tfSid,use_tools:false};
if(S.tfModel.startsWith('cfg:'))body.genai_config_id=S.tfModel.slice(4);
else{
if(!S.tfOci){S.tfMsgs.pop();S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione uma credencial OCI.'});R();return}
if(!S.tfOci){S.tfMsgs.pop();S.tfMsgs.push({r:'assistant',c:'⚠️ Selecione uma credencial OCI.',t:tstamp()});R();return}
body.model_id=S.tfModel;body.oci_config_id=S.tfOci;body.genai_region=S.tfRegion;body.compartment_id=S.tfCompartment;
}
const d=await $api('/terraform/chat',{method:'POST',body});
S.tfSid=d.session_id;
if(S.tfHistOpen)loadHistory('terraform');
if(d.status==='processing'&&d.message_id){await tfPollResult(d.message_id)}
else{S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:d.response||d.content||'Sem resposta'});R();tfScroll()}
}catch(e){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'❌ Erro: '+e.message});R()}
else{S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:d.response||d.content||'Sem resposta',t:tstamp()});R();tfScroll()}
}catch(e){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'❌ Erro: '+e.message,t:tstamp()});R()}
}
async function tfPollResult(mid){
for(let i=0;i<600;i++){
await new Promise(r=>setTimeout(r,i<10?1000:3000));
try{const r=await $api('/chat/'+mid+'/status');
if(r.status==='done'){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:r.content});R();tfScroll();
if(r.status==='done'){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:r.content,t:tstamp()});R();tfScroll();
if(r.content&&r.content.includes('```plan')){S.tfPanel='plan';R()}return}
if(r.status==='failed'){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'❌ '+r.content});R();tfScroll();return}
if(r.status==='failed'){S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'❌ '+r.content,t:tstamp()});R();tfScroll();return}
}catch(e){}
}
S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'⏰ Timeout.'});R()
S.tfMsgs=S.tfMsgs.filter(x=>!x.thinking);S.tfMsgs.push({r:'assistant',c:'⏰ Timeout.',t:tstamp()});R()
}
async function tfSaveAndPlan(){