akv-manager 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/index.html CHANGED
@@ -72,6 +72,9 @@
72
72
  <button data-view="clusters">
73
73
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 2l8 4.5v9L12 20l-8-4.5v-9L12 2z"/><path d="M12 7l4 2.2v4.6L12 16l-4-2.2V9.2L12 7z"/></svg>
74
74
  Clusters</button>
75
+ <button data-view="projects">
76
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/></svg>
77
+ Projects</button>
75
78
  </nav>
76
79
 
77
80
  <div class="navlabel" id="pinLabel">Pinned</div>
@@ -126,7 +129,13 @@
126
129
  const $ = s => document.querySelector(s);
127
130
  const esc = s => String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
128
131
  const api = async (p, opts) => { const r = await fetch(p, opts); return r.json(); };
129
- let STATE = { vaults: [], subs: [], account: null, env: 'all', q: '', sort: 'name', dir: 1,
132
+ const extractFirewallIp = (txt) => {
133
+ const m = /Client address:\s*([0-9]{1,3}(?:\.[0-9]{1,3}){3})/i.exec(String(txt||''));
134
+ return m ? m[1] : '';
135
+ };
136
+ let STATE = { vaults: [], subs: [], accounts: { users: [], activeSubscription: null }, account: null,
137
+ vaultWarnings: [], vaultsAggregate: false,
138
+ env: 'all', q: '', sort: 'name', dir: 1,
130
139
  kind: 'secrets', drawerVault: null, route: 'dashboard', facets: {} };
131
140
  let CFG = { envRules: null, title: null, version: null, protectedTags: [] };
132
141
  function tagMatch(p,k){ p=String(p).toLowerCase(); k=String(k).toLowerCase(); return p.endsWith('*')?k.startsWith(p.slice(0,-1)):k===p; }
@@ -198,6 +207,7 @@ function serviceOf(rg){ return (rg||'').replace(/^rg[-_]?/i,'').replace(/[-_]?\d
198
207
 
199
208
  // ---------- bootstrap ----------
200
209
  async function boot(){
210
+ loadProjects();
201
211
  const cfg = await api('/api/config'); if(cfg.ok){ CFG = {...CFG, ...cfg.data};
202
212
  if(CFG.title){ document.title = CFG.title; const wm=document.querySelector('.brand .wm'); if(wm) wm.firstChild.textContent = CFG.title; } }
203
213
  $('#ver').textContent = 'akv-manager v' + (CFG.version || '?');
@@ -211,16 +221,36 @@ async function boot(){
211
221
  return;
212
222
  }
213
223
  STATE.account = acc.data;
214
- $('#acctDot').style.background='var(--ok)';
215
- $('#acctUser').textContent = acc.data.user.name;
216
- $('#acctSub').textContent = acc.data.name;
217
- banner('');
224
+ updateAcctButton();
225
+ await loadAccounts();
226
+ // Identity resolves even when the token is expired — so the app shell loads and the
227
+ // account switcher (Settings) stays reachable instead of dead-ending on a login wall.
228
+ if(acc.data.needLogin){
229
+ banner(`Session for <b>${esc(acc.data.user.name)}</b> has expired. <a href="#/settings">Switch account</a> or run <code>az login</code>, then press Refresh.`);
230
+ } else banner('');
218
231
  await loadSubs();
219
232
  await loadVaults();
220
233
  renderSidebar();
221
234
  router();
222
- kickExpiryScan(); // warm the dashboard's lifecycle numbers in the background
223
- registerSessionClusters().then(loadClustersCache); // register pasted clusters, then warm ⌘K
235
+ if(!acc.data.needLogin){
236
+ kickExpiryScan(); // warm the dashboard's lifecycle numbers in the background
237
+ registerSessionClusters().then(loadClustersCache); // register pasted clusters, then warm ⌘K
238
+ }
239
+ }
240
+
241
+ // Sidebar identity chip: name, active subscription, and a dot that goes amber when the
242
+ // current account's session has expired (so the user knows to switch or re-login).
243
+ function updateAcctButton(){
244
+ const a = STATE.account; if(!a) return;
245
+ $('#acctUser').textContent = a.user.name;
246
+ $('#acctSub').textContent = a.name || '';
247
+ $('#acctDot').style.background = a.needLogin ? 'var(--warn)' : 'var(--ok)';
248
+ $('#acctBtn').title = a.needLogin ? 'Session expired — click to switch account or re-login' : ('Signed in as ' + a.user.name);
249
+ }
250
+
251
+ async function loadAccounts(){
252
+ const r = await api('/api/accounts');
253
+ STATE.accounts = r.ok ? r.data : { users: [], activeSubscription: null };
224
254
  }
225
255
 
226
256
  async function loadSubs(){
@@ -233,6 +263,8 @@ async function loadVaults(refresh){
233
263
  const r = await api('/api/vaults'+(refresh?'?refresh=1':''));
234
264
  if(!r.ok){ STATE.vaults=[]; return r; }
235
265
  STATE.vaults = r.data.map(v => ({...v, env: vaultEnv(v), service: vaultService(v)}));
266
+ STATE.vaultWarnings = r.warnings || [];
267
+ STATE.vaultsAggregate = !!r.aggregate;
236
268
  return r;
237
269
  }
238
270
 
@@ -246,15 +278,16 @@ const META = {
246
278
  vaults:['Inventory','Key Vaults','Every readable vault across your subscriptions. Filter by environment and tags; open one to manage it.'],
247
279
  expiry:['Lifecycle','Expiring soon','Secrets and certificates that are expired or approaching expiry across every vault you can read.'],
248
280
  clusters:['Kubernetes','Clusters','Read-only monitoring of your AKS clusters — browse pods, read logs and configmaps. Configured locally; runs as your az identity.'],
281
+ projects:['Workspace','Projects','Group workloads and key vaults into your own projects and environments. Stored in your browser; import/export as JSON to share.'],
249
282
  settings:['Account','Account & subscriptions','Your Azure identity and the subscription the console is pointed at.'],
250
283
  };
251
284
  let mountedVault = null;
252
285
  function setHead(view){
253
- const detail = view==='vault' || view==='cluster'; // detail pages carry their own breadcrumb
286
+ const detail = view==='vault' || view==='cluster' || view==='project'; // detail pages carry their own breadcrumb
254
287
  $('#headblock').style.display = detail ? 'none' : '';
255
288
  $('#search').classList.toggle('hide', view!=='vaults' && view!=='dashboard' && view!=='expiry' && view!=='settings');
256
289
  if(!detail && META[view]){ $('#eyebrow').textContent=META[view][0]; $('#title').textContent=META[view][1]; $('#subtitle').textContent=META[view][2]; }
257
- const navActive = view==='vault'?'vaults' : view==='cluster'?'clusters' : view;
290
+ const navActive = view==='vault'?'vaults' : view==='cluster'?'clusters' : view==='project'?'projects' : view;
258
291
  document.querySelectorAll('nav button').forEach(b=>b.classList.toggle('active', b.dataset.view===navActive));
259
292
  }
260
293
  function router(){
@@ -269,6 +302,8 @@ function router(){
269
302
  else if(route==='expiry'){ setHead('expiry'); viewExpiry(); }
270
303
  else if(route==='clusters'){ setHead('clusters'); viewClusters(); }
271
304
  else if(route==='cluster'){ setHead('cluster'); viewCluster(parts[1], parts[2], parts[3]||'pods'); }
305
+ else if(route==='projects'){ setHead('projects'); viewProjects(); }
306
+ else if(route==='project'){ setHead('project'); viewProject(parts[1]); }
272
307
  else if(route==='settings'){ setHead('settings'); viewSettings(); }
273
308
  else { setHead('dashboard'); viewDashboard(); }
274
309
  renderSidebar();
@@ -397,7 +432,12 @@ function viewVaults(){
397
432
  <option value="">any</option>${vals.map(x=>`<option ${x===sel?'selected':''} value="${esc(x)}">${esc(x)}</option>`).join('')}</select></div>`;
398
433
  }).join('');
399
434
  const anyFacet = Object.values(STATE.facets).some(Boolean);
435
+ const agg = STATE.vaultsAggregate;
436
+ const warns = STATE.vaultWarnings || [];
437
+ const warnBar = warns.length ? `<div class="fwalert" style="background:linear-gradient(180deg,rgba(242,181,61,.14),rgba(242,181,61,.06));border-color:rgba(242,181,61,.5);color:#f4d089">
438
+ <div><b>Partial results.</b> Couldn't load ${warns.map(w=>esc(w.user)).join(', ')}${warns.some(w=>w.needLogin)?' — session expired. Run <code>az login</code> and press Refresh.':'.'}</div></div>` : '';
400
439
  $('#route').innerHTML = `
440
+ ${warnBar}
401
441
  <div class="chips">${chips}</div>
402
442
  ${facets?`<div class="facets">${facets}${anyFacet?`<button class="facetclear" onclick="clearFacets()">clear filters</button>`:''}</div>`:''}
403
443
  <div class="panel"><div class="scroll"><table>
@@ -405,7 +445,7 @@ function viewVaults(){
405
445
  <th data-sort="pin" style="width:30px"></th>
406
446
  <th data-sort="name">Vault</th><th data-sort="env">Env</th>
407
447
  <th data-sort="service">Service</th><th>Tags</th><th data-sort="resourceGroup">Resource Group</th>
408
- <th data-sort="sku">SKU</th><th>Auth</th><th class="right">Actions</th>
448
+ <th data-sort="sku">SKU</th><th>Auth</th>${agg?'<th>Account</th>':''}<th class="right">Actions</th>
409
449
  </tr></thead><tbody id="vaultsBody"></tbody>
410
450
  </table></div></div>
411
451
  <div class="tablenote" id="vaultCount"></div>`;
@@ -417,8 +457,9 @@ function setFacet(k,val){ if(val) STATE.facets[k]=val; else delete STATE.facets[
417
457
  function clearFacets(){ STATE.facets={}; viewVaults(); }
418
458
  function renderVaultRows(){
419
459
  const list = filteredVaults();
460
+ const agg = STATE.vaultsAggregate;
420
461
  $('#vaultCount').textContent = `${list.length} vault${list.length!==1?'s':''} shown`;
421
- if(!list.length){ $('#vaultsBody').innerHTML='<tr><td colspan="9" class="empty">No vaults match your filter.</td></tr>'; return; }
462
+ if(!list.length){ $('#vaultsBody').innerHTML=`<tr><td colspan="${agg?10:9}" class="empty">No vaults match your filter.</td></tr>`; return; }
422
463
  $('#vaultsBody').innerHTML = list.map(v=>{
423
464
  const tags = Object.entries(v.tags||{}).filter(([k])=>!/^env$/i.test(k)).slice(0,2)
424
465
  .map(([k,val])=>`<span class="tagchip"><b>${esc(k)}</b> ${esc(val)}</span>`).join('');
@@ -432,6 +473,7 @@ function renderVaultRows(){
432
473
  <td class="mono muted">${esc(v.resourceGroup)}</td>
433
474
  <td class="mono">${esc(v.sku||'—')}</td>
434
475
  <td>${v.enableRbac==='true'?'<span class="pill rbac">RBAC</span>':'<span class="pill policy">Policy</span>'}</td>
476
+ ${agg?`<td class="mono muted" title="tenant ${esc(v.tenantId||'')}">${esc(v.account||'—')}</td>`:''}
435
477
  <td class="right">
436
478
  <button class="iconbtn" onclick="openVault('${esc(v.name)}')">Open</button>
437
479
  <button class="iconbtn" onclick="copy('az keyvault secret list --vault-name ${esc(v.name)} -o table')">⧉</button>
@@ -442,13 +484,21 @@ function renderVaultRows(){
442
484
  let DRW = { vault:'', kind:'secrets', items:[], filter:'', sel:new Set() };
443
485
  function openVault(name){ pushRecent(name); navigate('vault/'+encodeURIComponent(name)); }
444
486
  const KIND_TABS = [['secrets','Secrets'],['keys','Keys'],['certificates','Certificates'],['tags','Tags']];
487
+ const fwIpKey = (vault) => `akv.fwAllowedIp.${String(vault||'').toLowerCase()}`;
488
+ function fwIpGet(vault){ try{ return sessionStorage.getItem(fwIpKey(vault)) || ''; } catch { return ''; } }
489
+ function fwIpSet(vault, ip){
490
+ try {
491
+ if(ip) sessionStorage.setItem(fwIpKey(vault), ip);
492
+ else sessionStorage.removeItem(fwIpKey(vault));
493
+ } catch { /* ignore */ }
494
+ }
445
495
  function viewVault(name, kind){
446
496
  const v = findVault(name) || (name?{name, subscriptionId:'', resourceGroup:'', uri:'', enableRbac:'', tags:{}}:null);
447
497
  if(!v){ $('#route').innerHTML=`<div class="panel"><div class="empty">No vault specified. <a href="#/vaults">Back to Key Vaults</a></div></div>`; return; }
448
498
  STATE.drawerVault=v; STATE.kind=kind;
449
499
  if(mountedVault!==name || !document.getElementById('vbody')){
450
500
  mountedVault=name;
451
- DRW={vault:name, kind, items:[], filter:'', sel:new Set()};
501
+ DRW={vault:name, kind, items:[], filter:'', sel:new Set(), firewallIp:'', tempAllowedIp:fwIpGet(name)};
452
502
  const tabs = KIND_TABS.map(([k,l])=>`<button data-kind="${k}" class="${k===kind?'active':''}" onclick="goKind('${esc(name)}','${k}')">${l}</button>`).join('');
453
503
  $('#route').innerHTML = `
454
504
  <div class="vhead">
@@ -483,6 +533,7 @@ function renderItemsShell(){
483
533
  </div>
484
534
  <div class="vtools-row" id="selRow"><label class="selall"><input type="checkbox" id="dSelAll"> select all</label></div>
485
535
  </div>
536
+ <div id="fwCtl"></div>
486
537
  <div class="createp hide" id="dCreate"></div>
487
538
  <div class="batchbar hide stick" id="dBatch"></div>
488
539
  <div class="itemwrap" id="dContent"><div class="empty"><span class="spin"></span></div></div>
@@ -491,6 +542,7 @@ function renderItemsShell(){
491
542
  $('#dFilter').oninput=e=>{ DRW.filter=e.target.value; renderDrawerList(); };
492
543
  $('#dSelAll').onchange=e=>selectAll(e.target.checked);
493
544
  if(isSecrets){ $('#dNew').onclick=()=>openCreate('single'); $('#dNewBatch').onclick=()=>openCreate('batch'); }
545
+ renderFirewallControl();
494
546
  renderCmds(STATE.drawerVault);
495
547
  loadKind();
496
548
  }
@@ -512,12 +564,17 @@ async function loadKind(){
512
564
  if(!r.ok){
513
565
  DRW.items=[]; $('#dBatch').classList.add('hide'); if($('#dCount'))$('#dCount').textContent='';
514
566
  let why;
515
- if(r.firewall) why = `🚫 This vault's firewall is blocking your network — your current IP isn't allowed. Connect to the corporate VPN and retry.<div class="hint" style="margin-top:8px">${esc(r.error||'')}</div>`;
567
+ if(r.firewall){
568
+ DRW.firewallIp = extractFirewallIp(r.error||'');
569
+ const ipText = DRW.firewallIp ? `<div class="hint" style="margin-top:8px">Detected IP: <span class="mono">${esc(DRW.firewallIp)}</span></div>` : '';
570
+ why = `🚫 This vault's firewall is blocking your network — your current IP isn't allowed.${ipText}<div class="row" style="margin-top:10px;justify-content:center"><button class="btn sm" onclick="allowVaultFirewallIp()">Allow this IP in Key Vault firewall</button></div><div class="hint" style="margin-top:8px">${esc(r.error||'')}</div>`;
571
+ }
516
572
  else if(r.forbidden) why = `🔒 You can see this vault but have no data-plane permission on its ${DRW.kind}.`;
517
573
  else why = esc(r.error||'failed');
518
574
  $('#dContent').innerHTML = `<div class="empty">${why}</div>`;
519
575
  return;
520
576
  }
577
+ DRW.firewallIp = '';
521
578
  DRW.items = (r.data||[]).map(item=>({
522
579
  name: item.name || (item.id||'').split('/').pop(),
523
580
  enabled: item.attributes ? item.attributes.enabled!==false : true,
@@ -526,6 +583,58 @@ async function loadKind(){
526
583
  }));
527
584
  renderDrawerList();
528
585
  }
586
+
587
+ async function allowVaultFirewallIp(){
588
+ if(!DRW.vault) return;
589
+ const body = DRW.firewallIp ? { ip: DRW.firewallIp } : {};
590
+ const r = await api(`/api/vaults/${DRW.vault}/firewall/allow-ip`, {
591
+ method: 'POST',
592
+ headers: {'Content-Type':'application/json'},
593
+ body: JSON.stringify(body),
594
+ });
595
+ if(!r.ok){
596
+ toast(r.forbidden ? 'No permission to change Key Vault firewall' : (r.error||'failed to allow IP'));
597
+ return;
598
+ }
599
+ DRW.tempAllowedIp = (r.data && r.data.ip) || DRW.firewallIp || '';
600
+ fwIpSet(DRW.vault, DRW.tempAllowedIp);
601
+ renderFirewallControl();
602
+ toast(r.data && r.data.alreadyAllowed ? 'IP is already allowed in firewall' : `IP ${r.data.ip} added to Key Vault firewall`);
603
+ await loadKind();
604
+ }
605
+
606
+ function renderFirewallControl(){
607
+ const box = $('#fwCtl');
608
+ if(!box) return;
609
+ const ip = DRW.tempAllowedIp || '';
610
+ if(!ip){ box.innerHTML = ''; return; }
611
+ box.innerHTML = `<div class="fwalert"><div><b>Firewall temporariamente liberado:</b> IP <span class="mono">${esc(ip)}</span> foi adicionado neste vault. Remova após concluir o uso.</div><button class="btn sm danger" onclick="removeVaultFirewallIp()">Remover IP do Key Vault</button></div>`;
612
+ }
613
+
614
+ async function removeVaultFirewallIp(){
615
+ if(!DRW.vault || !DRW.tempAllowedIp) return;
616
+ const r = await api(`/api/vaults/${DRW.vault}/firewall/remove-ip`, {
617
+ method: 'POST',
618
+ headers: {'Content-Type':'application/json'},
619
+ body: JSON.stringify({ ip: DRW.tempAllowedIp }),
620
+ });
621
+ if(!r.ok){
622
+ toast(r.forbidden ? 'No permission to remove IP from Key Vault firewall' : (r.error||'failed to remove IP'));
623
+ return;
624
+ }
625
+ const removed = !!(r.data && r.data.removed);
626
+ if(removed){
627
+ toast(`IP ${DRW.tempAllowedIp} removed from Key Vault firewall`);
628
+ DRW.tempAllowedIp = '';
629
+ fwIpSet(DRW.vault, '');
630
+ renderFirewallControl();
631
+ return;
632
+ }
633
+ toast('IP was not present in firewall rules');
634
+ DRW.tempAllowedIp = '';
635
+ fwIpSet(DRW.vault, '');
636
+ renderFirewallControl();
637
+ }
529
638
  function drawerFiltered(){ const q=DRW.filter.toLowerCase(); return DRW.items.filter(i=>!q||i.name.toLowerCase().includes(q)); }
530
639
  function renderDrawerList(){
531
640
  const list = drawerFiltered(); const editable = isEditable();
@@ -553,7 +662,8 @@ function renderBatchBar(){
553
662
  bar.classList.remove('hide');
554
663
  const secretOnly = DRW.kind==='secrets'
555
664
  ? `<input type="date" id="batchExp"><button class="iconbtn" onclick="batchApply('expiry')">Set expiry</button>
556
- <button class="iconbtn" onclick="batchEditValues()">Edit values…</button>` : '';
665
+ <button class="iconbtn" onclick="batchEditValues()">Edit values…</button>
666
+ <button class="iconbtn danger" onclick="confirmBatchDelete()">Delete…</button>` : '';
557
667
  bar.innerHTML = `<span class="bn">${n} selected</span>
558
668
  <button class="iconbtn" onclick="batchApply('enable')">Enable</button>
559
669
  <button class="iconbtn" onclick="batchApply('disable')">Disable</button>
@@ -562,7 +672,7 @@ function renderBatchBar(){
562
672
  }
563
673
  async function batchEditValues(){
564
674
  const names=[...DRW.sel]; if(!names.length) return;
565
- const el=$('#dCreate'); $('#dBatch').classList.add('hide'); el.classList.remove('hide');
675
+ const el=$('#dCreate'); $('#dBatch').classList.add('hide'); el.classList.remove('hide'); el.classList.remove('danger');
566
676
  el.innerHTML = `<h4>Edit ${names.length} secret value${names.length!==1?'s':''}</h4><div class="empty"><span class="spin"></span> loading current values…</div>`;
567
677
  const vals = await Promise.all(names.map(n=>
568
678
  api(`/api/vaults/${DRW.vault}/secrets/${n}/value`).then(r=>({n, ok:r.ok, val:r.ok?(r.data.value||''):'', err:r.forbidden?'no read permission':(r.error||'failed')}))));
@@ -580,11 +690,58 @@ async function batchEditValues(){
580
690
  <button class="btn sm ghost" onclick="closeCreate()">Cancel</button>
581
691
  <span class="hint" id="cMsg">${esc(notes.join(' · '))}</span></div>`;
582
692
  }
693
+ // Delete is the one batch action that cannot be undone from this UI, so it gets a
694
+ // typed confirmation (the vault name) instead of the usual one-click confirm().
695
+ function confirmBatchDelete(){
696
+ const names=[...DRW.sel]; if(!names.length) return;
697
+ const el=$('#dCreate'); $('#dBatch').classList.add('hide');
698
+ el.classList.remove('hide'); el.classList.add('danger');
699
+ const n=names.length, plural=n!==1?'s':'';
700
+ el.innerHTML = `<h4>Delete ${n} secret${plural} from ${esc(DRW.vault)}</h4>
701
+ <div class="dellist">${names.map(x=>`<div>${esc(x)}</div>`).join('')}</div>
702
+ <div class="hint" style="margin:10px 0">They stop resolving immediately — anything reading them (AKS CSI mounts, running apps) breaks now, not at the next deploy.
703
+ On a soft-delete vault they stay recoverable until their scheduled purge date; purging is not available here.</div>
704
+ <div class="fld"><label>Confirm</label><input type="text" id="delConfirm" placeholder="type the vault name: ${esc(DRW.vault)}" autocomplete="off"></div>
705
+ <div class="actions"><button class="btn sm danger" id="delGo" disabled onclick="batchDelete()">Delete ${n} secret${plural}</button>
706
+ <button class="btn sm ghost" onclick="closeCreate()">Cancel</button>
707
+ <span class="hint" id="cMsg"></span></div>`;
708
+ const inp=$('#delConfirm'), go=$('#delGo');
709
+ inp.oninput=()=>{ go.disabled = inp.value.trim().toLowerCase()!==DRW.vault.toLowerCase(); };
710
+ inp.onkeydown=e=>{ if(e.key==='Enter' && !go.disabled) batchDelete(); };
711
+ inp.focus();
712
+ }
713
+ async function batchDelete(){
714
+ const names=[...DRW.sel]; if(!names.length) return;
715
+ const go=$('#delGo'); if(go){ go.disabled=true; go.textContent='Deleting…'; }
716
+ const items = names.map(n=>({vault:DRW.vault, kind:'secret', name:n}));
717
+ const r = await api('/api/batch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({op:{delete:true}, items})});
718
+ if(!r.ok){
719
+ toast(r.error||'delete failed');
720
+ if(go){ go.disabled=false; go.textContent=`Delete ${names.length} secret${names.length!==1?'s':''}`; }
721
+ return;
722
+ }
723
+ const ok=r.results.filter(x=>x.ok).length, fail=r.results.length-ok;
724
+ toast(`${ok} deleted${fail?`, ${fail} failed`:''}`);
725
+ if(fail){
726
+ const failed=r.results.filter(x=>!x.ok); console.warn('Delete failures:', failed);
727
+ banner(`Delete: ${ok} deleted, ${fail} failed (${failed.slice(0,3).map(f=>esc(f.name+': '+f.error)).join('; ')}${fail>3?'…':''}). See console for all.`);
728
+ setTimeout(()=>banner(''),8000);
729
+ } else {
730
+ const purge=(r.results.find(x=>x.scheduledPurgeDate)||{}).scheduledPurgeDate;
731
+ if(purge){
732
+ banner(`Deleted ${ok} secret${ok!==1?'s':''} — recoverable until ${esc(String(purge).slice(0,10))} (soft-delete), then purged automatically.`);
733
+ setTimeout(()=>banner(''),8000);
734
+ }
735
+ }
736
+ closeCreate();
737
+ DRW.sel.clear(); if($('#dSelAll'))$('#dSelAll').checked=false;
738
+ setTimeout(loadKind, 350);
739
+ }
583
740
  function toggleSel(nm){ DRW.sel.has(nm)?DRW.sel.delete(nm):DRW.sel.add(nm); renderDrawerList(); }
584
741
  function clearSel(){ DRW.sel.clear(); if($('#dSelAll'))$('#dSelAll').checked=false; renderDrawerList(); }
585
742
  function selectAll(on){ DRW.sel.clear(); if(on) drawerFiltered().filter(i=>!i.managed).forEach(i=>DRW.sel.add(i.name)); renderDrawerList(); }
586
743
  function openCreate(mode){
587
- const el=$('#dCreate'); DRW.sel.clear(); $('#dBatch').classList.add('hide');
744
+ const el=$('#dCreate'); DRW.sel.clear(); $('#dBatch').classList.add('hide'); el.classList.remove('danger');
588
745
  if(mode==='single'){
589
746
  el.innerHTML = `<h4>Create a secret</h4>
590
747
  <div class="fld"><label>Name</label><input type="text" id="cName" placeholder="my-secret-name" autocomplete="off"></div>
@@ -602,7 +759,7 @@ function openCreate(mode){
602
759
  }
603
760
  el.classList.remove('hide');
604
761
  }
605
- function closeCreate(){ const el=$('#dCreate'); if(el){ el.classList.add('hide'); el.innerHTML=''; } renderBatchBar(); }
762
+ function closeCreate(){ const el=$('#dCreate'); if(el){ el.classList.add('hide'); el.classList.remove('danger'); el.innerHTML=''; } renderBatchBar(); }
606
763
  async function submitCreate(mode){
607
764
  const msg=$('#cMsg');
608
765
  const expRaw=$('#cExp').value;
@@ -938,29 +1095,100 @@ function renderHorizon(items){
938
1095
  // ---------- SETTINGS (account + subscriptions) ----------
939
1096
  function viewSettings(){
940
1097
  const counts={}; STATE.vaults.forEach(v=>counts[v.subscriptionId]=(counts[v.subscriptionId]||0)+1);
1098
+ const acct = STATE.account || {};
1099
+ const users = (STATE.accounts && STATE.accounts.users) || [];
1100
+ const totalSubs = users.reduce((n,u)=>n+u.subscriptions.length,0);
1101
+ const expired = !!acct.needLogin;
1102
+
1103
+ // One panel per logged-in identity (az login), listing its subscriptions. Switching
1104
+ // to a subscription re-points the console at that user + tenant — see setSub().
1105
+ const userPanel = u => {
1106
+ const isCurrent = u.subscriptions.some(s=>s.active);
1107
+ const tenant = (u.subscriptions[0]||{}).tenantId || '';
1108
+ return `
1109
+ <div class="panel" style="margin-bottom:16px">
1110
+ <div class="row" style="justify-content:space-between;padding:0 2px 12px">
1111
+ <div class="row" style="gap:9px">
1112
+ <span class="dot" style="background:${isCurrent?'var(--ok)':'var(--faint)'}"></span>
1113
+ <b>${esc(u.user)}</b>
1114
+ ${isCurrent?'<span class="pill rbac">current</span>':''}
1115
+ </div>
1116
+ <span class="mono faint" style="font-size:11px">${u.subscriptions.length} subscription${u.subscriptions.length===1?'':'s'} · tenant ${esc(tenant.slice(0,8))}…</span>
1117
+ </div>
1118
+ <div class="scroll"><table>
1119
+ <thead><tr><th>Subscription</th><th>Env</th><th>State</th><th>Active</th><th class="right">Vaults</th><th>Subscription ID</th><th></th></tr></thead>
1120
+ <tbody>${u.subscriptions.map(s=>{
1121
+ const env = envLabel(s.name);
1122
+ return `<tr>
1123
+ <td><b>${esc(s.name)}</b></td>
1124
+ <td><span class="tag ${envClass(env)}">${esc(env)}</span></td>
1125
+ <td>${esc(s.state)}</td>
1126
+ <td>${s.active?'<span class="pill rbac">active</span>':(s.cliDefault?'<span class="pill policy">cli default</span>':'')}</td>
1127
+ <td class="right mono">${counts[s.id]||''}</td>
1128
+ <td class="mono muted">${esc(s.id)}</td>
1129
+ <td class="right">${s.active?'':`<button class="btn sm ghost" onclick="setSub('${esc(s.id)}')">Switch</button>`}</td>
1130
+ </tr>`;
1131
+ }).join('')}</tbody>
1132
+ </table></div>
1133
+ </div>`;
1134
+ };
1135
+
1136
+ const aggregate = !!acct.aggregate;
1137
+ // "All accounts": merge every logged-in user & tenant into one view. Sits above the
1138
+ // per-user panels; enabling it clears any single-account selection (see setAllAccounts).
1139
+ const allPanel = `
1140
+ <div class="panel" style="margin-bottom:16px;border-color:${aggregate?'var(--azure)':'var(--line)'}">
1141
+ <div class="row" style="justify-content:space-between;padding:2px">
1142
+ <div class="row" style="gap:9px">
1143
+ <span class="dot" style="background:${aggregate?'var(--ok)':'var(--faint)'}"></span>
1144
+ <b>All accounts</b>
1145
+ ${aggregate?'<span class="pill rbac">active</span>':''}
1146
+ <span class="mono faint" style="font-size:11px">merge every user &amp; tenant · ${users.length} user${users.length===1?'':'s'} · ${totalSubs} sub${totalSubs===1?'':'s'}</span>
1147
+ </div>
1148
+ ${aggregate?'':`<button class="btn sm" onclick="setAllAccounts()">Use all accounts</button>`}
1149
+ </div>
1150
+ </div>`;
941
1151
  $('#route').innerHTML = `
942
1152
  <div class="cards" style="margin-bottom:24px">
943
- ${card('Signed in as', '', STATE.account?STATE.account.user.name:'—','')}
944
- ${card('Tenant', (STATE.account&&STATE.account.tenantId||'').slice(0,8)+'…','active directory','')}
945
- ${card('Subscriptions', STATE.subs.length, STATE.subs.filter(s=>s.state==='Enabled').length+' enabled','azure')}
1153
+ ${card('Signed in as', '', (acct.user&&acct.user.name)||'—','')}
1154
+ ${card('Tenant', aggregate?'all':((acct.tenantId||'').slice(0,8)+'…'), aggregate?'every tenant':(expired?'session expired':'active directory'),'')}
1155
+ ${card('Accounts', users.length, totalSubs+' subscription'+(totalSubs===1?'':'s'),'azure')}
946
1156
  </div>
947
- <div class="panel"><div class="scroll"><table>
948
- <thead><tr><th>Name</th><th>Env</th><th>State</th><th>Active</th><th class="right">Vaults</th><th>Subscription ID</th><th></th></tr></thead>
949
- <tbody>${STATE.subs.map(s=>`<tr>
950
- <td><b>${esc(s.name)}</b></td>
951
- <td><span class="tag ${envClass(SUBMETA[s.id].env)}">${esc(SUBMETA[s.id].env)}</span></td>
952
- <td>${esc(s.state)}</td>
953
- <td>${s.isDefault?'<span class="pill rbac">active</span>':''}</td>
954
- <td class="right mono">${counts[s.id]||0}</td>
955
- <td class="mono muted">${esc(s.id)}</td>
956
- <td class="right">${s.isDefault?'':`<button class="btn sm ghost" onclick="setSub('${esc(s.id)}')">Switch</button>`}</td>
957
- </tr>`).join('')}</tbody>
958
- </table></div></div>`;
1157
+ ${expired?`<div style="margin-bottom:18px;padding:11px 14px;border-radius:8px;background:rgba(242,181,61,.12);border:1px solid rgba(242,181,61,.4);color:#f4d089">
1158
+ This account's session has expired. Switch to another account below, or run <code>az login</code> and press Refresh.</div>`:''}
1159
+ <div class="hint" style="margin:0 2px 12px">Multiple <code>az login</code> identities? Switch the active user/subscription — or use <b>All accounts</b> to see every tenant at once. Everything (vaults, clusters) re-loads as the chosen scope.</div>
1160
+ ${allPanel}
1161
+ ${users.length?users.map(userPanel).join(''):'<div class="panel"><div class="empty">No Azure accounts found. Run <code>az login</code>, then Refresh.</div></div>'}`;
1162
+ }
1163
+ // Reload the app under a new identity scope, preserving the toast/banner/refresh flow.
1164
+ async function applyScope(r, okMsg){
1165
+ STATE.account = r.data;
1166
+ updateAcctButton();
1167
+ await loadAccounts();
1168
+ await loadSubs();
1169
+ await loadVaults(true); // scope changed → force a fresh inventory
1170
+ renderSidebar();
1171
+ if(r.data.needLogin){
1172
+ banner(`Switched to <b>${esc(r.data.user.name)}</b>, but its session needs re-login. Run <code>${esc(r.data.loginHint||'az login')}</code>, then press Refresh.`);
1173
+ toast('Switched — this account needs re-login');
1174
+ } else {
1175
+ banner('');
1176
+ toast(okMsg);
1177
+ kickExpiryScan();
1178
+ }
1179
+ router(); // re-render the current page with the new scope
1180
+ }
1181
+ async function setAllAccounts(){
1182
+ toast('Loading all accounts…');
1183
+ const r = await api('/api/subscription/set',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({all:true})});
1184
+ if(!r.ok){ toast(r.error||'failed'); return; }
1185
+ await applyScope(r, 'Now showing all accounts');
959
1186
  }
960
1187
  async function setSub(id){
961
- toast('Switching subscription…');
1188
+ toast('Switching account…');
962
1189
  const r = await api('/api/subscription/set',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({subscription:id})});
963
- if(r.ok){ STATE.account=r.data; $('#acctSub').textContent=r.data.name; await loadSubs(); refreshCurrent(); toast('Active subscription set'); } else toast(r.error||'failed');
1190
+ if(!r.ok){ toast(r.error||'failed to switch'); return; }
1191
+ await applyScope(r, 'Signed in as '+r.data.user.name);
964
1192
  }
965
1193
 
966
1194
  function card(k,v,sub,mod){
@@ -968,7 +1196,7 @@ function card(k,v,sub,mod){
968
1196
  }
969
1197
 
970
1198
  // ---------- CLUSTERS (AKS — read-only: pods, logs, configmaps) ----------
971
- let CL = { all:null, name:'', cluster:null, ns:'', kind:'pods', items:[], namespaces:[], logPod:null, filter:'', tier:'all', cm:{} };
1199
+ let CL = { all:null, configuredCount:0, name:'', cluster:null, ns:'', kind:'pods', items:[], namespaces:[], logPod:null, filter:'', tier:'all', cm:{}, podsByCluster:{}, cmByCluster:{}, vaultMap:{}, vaultMapByCluster:{}, vaultRestricted:{}, vaultRestrictedByCluster:{}, ingressMap:{}, ingressMapByCluster:{} };
972
1200
  let mountedCluster = null;
973
1201
  function age(iso){ if(!iso) return ''; const s=(Date.now()-Date.parse(iso))/1000;
974
1202
  if(s<3600) return Math.max(1,Math.floor(s/60))+'m'; if(s<86400) return Math.floor(s/3600)+'h'; return Math.floor(s/86400)+'d'; }
@@ -978,7 +1206,7 @@ function clErr(r){
978
1206
  if(r.forbidden) return `<div class="empty">🔒 Your identity has no Kubernetes-RBAC permission for this on this cluster.<div class="hint" style="margin-top:8px">${esc(r.error||'')}</div></div>`;
979
1207
  return `<div class="empty">${esc(r.error||'failed')}</div>`;
980
1208
  }
981
- async function loadClustersCache(){ const r=await api('/api/aks/clusters'); if(r.ok) CL.all=r.data.clusters||[]; return CL.all; }
1209
+ async function loadClustersCache(force){ const r=await api('/api/aks/clusters'+(force?'?refresh=1':'')); if(r.ok){ CL.all=r.data.clusters||[]; CL.configuredCount=r.data.configured; } return CL.all; }
982
1210
 
983
1211
  // Clusters the user pasted, kept in this browser. Registered with the local server each
984
1212
  // load so the data-plane endpoints (which validate the cluster) accept them.
@@ -991,15 +1219,18 @@ async function registerSessionClusters(){
991
1219
  try{ await api('/api/aks/clusters',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clusters:arr})}); }catch{}
992
1220
  }
993
1221
 
994
- async function viewClusters(){
1222
+ async function viewClusters(force){
1223
+ if(!force && CL.all){ renderClustersPage(); return; } // reuse cache; refetch only on explicit refresh
995
1224
  $('#route').innerHTML = `<div class="panel"><div class="empty"><span class="spin"></span> loading clusters…</div></div>`;
996
- await registerSessionClusters();
997
- const r = await api('/api/aks/clusters');
1225
+ const r = await api('/api/aks/clusters'+(force?'?refresh=1':''));
998
1226
  if(STATE.route!=='clusters') return;
999
1227
  if(!r.ok){ $('#route').innerHTML = `<div class="panel">${clErr(r)}</div>`; return; }
1000
1228
  CL.all = r.data.clusters||[];
1001
1229
  CL.configuredCount = r.data.configured;
1002
- const envs=[...new Set(CL.all.map(c=>c.env).filter(Boolean))].sort();
1230
+ renderClustersPage();
1231
+ }
1232
+ function renderClustersPage(){
1233
+ const envs=[...new Set((CL.all||[]).map(c=>c.env).filter(Boolean))].sort();
1003
1234
  const envSeg = envs.length>1 ? `<div class="seg" id="clEnvSeg">
1004
1235
  <button data-e="all" class="${CL.clEnv==='all'||!CL.clEnv?'active':''}">All</button>
1005
1236
  ${envs.map(e=>`<button data-e="${esc(e)}" class="${CL.clEnv===e?'active':''}">${esc(e)}</button>`).join('')}</div>` : '';
@@ -1039,14 +1270,14 @@ async function saveClusterList(){
1039
1270
  msg.textContent='saving…';
1040
1271
  const r=await api('/api/aks/clusters',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clusters:arr})});
1041
1272
  if(!r.ok){ msg.textContent=r.error||'failed'; return; }
1042
- setLsAks(raw); CL.all=null; toast(`Saved ${r.data.count} cluster${r.data.count!==1?'s':''}`);
1043
- loadClustersCache(); viewClusters();
1273
+ setLsAks(raw); CL.all=null; CL.podsByCluster={}; CL.cmByCluster={}; CL.vaultMapByCluster={}; CL.ingressMapByCluster={}; toast(`Saved ${r.data.count} cluster${r.data.count!==1?'s':''}`);
1274
+ viewClusters();
1044
1275
  }
1045
1276
  function clearClusterList(){
1046
1277
  if(!confirm('Remove the clusters stored in this browser? (file / env clusters stay.)')) return;
1047
1278
  setLsAks('');
1048
1279
  api('/api/aks/clusters',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clusters:[]})})
1049
- .then(()=>{ toast('Cleared stored clusters'); CL.all=null; loadClustersCache(); viewClusters(); });
1280
+ .then(()=>{ toast('Cleared stored clusters'); CL.all=null; viewClusters(); });
1050
1281
  }
1051
1282
  function renderClusterRows(){
1052
1283
  if(!CL.all.length){
@@ -1094,7 +1325,7 @@ function viewCluster(name){
1094
1325
  <div id="clBody"><div class="empty"><span class="spin"></span> loading…</div></div>`;
1095
1326
  $('#clKind').querySelectorAll('button').forEach(b=>b.onclick=()=>{ CL.kind=b.dataset.k;
1096
1327
  $('#clKind').querySelectorAll('button').forEach(x=>x.classList.toggle('active',x===b)); loadClusterContent(); });
1097
- $('#clRefresh').onclick=()=>{ if(CL.logPod) fetchLogs(); else loadClusterContent(); };
1328
+ $('#clRefresh').onclick=()=>{ if(CL.logPod) fetchLogs(); else loadClusterContent(true); };
1098
1329
  enrichClusterMeta(name);
1099
1330
  loadClusterContent();
1100
1331
  } else {
@@ -1111,9 +1342,9 @@ async function enrichClusterMeta(name){
1111
1342
  if($('#clMeta')) $('#clMeta').innerHTML = `<span class="mono">${esc(c.name)}</span><span class="sep">·</span>`+
1112
1343
  `<span class="mono">${esc(c.resourceGroup||'')}</span>${c.private?'<span class="sep">·</span><span class="pill policy">private · VPN</span>':''}`;
1113
1344
  }
1114
- function loadClusterContent(){
1345
+ function loadClusterContent(force){
1115
1346
  CL.logPod=null; CL.termPod=null; CL.filter=''; teardownTerm();
1116
- $('#clBody').innerHTML='<div class="empty"><span class="spin"></span></div>';
1347
+ if(force){ delete CL.podsByCluster[CL.name]; delete CL.cmByCluster[CL.name]; delete CL.vaultMapByCluster[CL.name]; delete CL.ingressMapByCluster[CL.name]; } // explicit refresh re-fetches
1117
1348
  if(CL.kind==='pods') loadPods(); else loadConfigMaps();
1118
1349
  }
1119
1350
  function clFilterBar(placeholder, extra){
@@ -1131,10 +1362,113 @@ const TIER_RE = {
1131
1362
  function podTier(nm){ nm=(nm||'').toLowerCase(); if(TIER_RE.cache.test(nm))return'cache'; if(TIER_RE.fe.test(nm))return'fe'; if(TIER_RE.be.test(nm))return'be'; return'other'; }
1132
1363
  async function loadPods(){
1133
1364
  const name=CL.name;
1365
+ CL.vaultMap = CL.vaultMapByCluster[name] || {};
1366
+ if(CL.podsByCluster[name]){ CL.items=CL.podsByCluster[name]; renderPodsShell(); ensurePodVaultMap(name); return; } // cached
1367
+ $('#clBody').innerHTML='<div class="empty"><span class="spin"></span></div>';
1134
1368
  const r = await api(`/api/aks/${name}/pods`);
1135
1369
  if(STATE.route!=='cluster'||CL.name!==name||CL.kind!=='pods'||CL.logPod||CL.termPod) return;
1136
1370
  if(!r.ok){ $('#clBody').innerHTML=clErr(r); return; }
1137
- CL.items=r.data||[];
1371
+ CL.items=r.data||[]; CL.podsByCluster[name]=CL.items;
1372
+ renderPodsShell();
1373
+ ensurePodVaultMap(name);
1374
+ ensurePodIngressMap(name);
1375
+ }
1376
+ // Resolve each namespace's exposed Services → Ingress hosts once per cluster, then repaint
1377
+ // so the Ingress column fills in. Pods are matched to a Service by label⊇selector.
1378
+ async function ensurePodIngressMap(name){
1379
+ if(CL.ingressMapByCluster[name]){ CL.ingressMap=CL.ingressMapByCluster[name]; return; }
1380
+ const pods = CL.podsByCluster[name] || CL.items || [];
1381
+ const nss = [...new Set(pods.map(p=>p.namespace).filter(Boolean))];
1382
+ if(!nss.length){ CL.ingressMapByCluster[name]={}; CL.ingressMap={}; return; }
1383
+ const r=await api(`/api/aks/${name}/ingress-map?namespaces=${encodeURIComponent(nss.join(','))}`);
1384
+ CL.ingressMapByCluster[name] = (r.ok && r.data && r.data.byNamespace) ? r.data.byNamespace : {};
1385
+ if(CL.name!==name) return;
1386
+ CL.ingressMap = CL.ingressMapByCluster[name];
1387
+ if(STATE.route==='cluster' && CL.kind==='pods' && !CL.logPod && !CL.termPod) renderPodRows();
1388
+ }
1389
+ // Match a pod to the Ingress(es) routing to it: Services whose selector ⊆ the pod's
1390
+ // labels, then Ingresses whose backend references those Services.
1391
+ function podMatchedSvcNames(p){
1392
+ const nsm = CL.ingressMap && CL.ingressMap[p.namespace]; if(!nsm) return [];
1393
+ const labels = p.labels || {};
1394
+ return (nsm.services||[]).filter(s=>{ const sel=s.selector||{}; const keys=Object.keys(sel);
1395
+ return keys.length && keys.every(k=>labels[k]===sel[k]); }).map(s=>s.name);
1396
+ }
1397
+ function podIngresses(p){
1398
+ const nsm = CL.ingressMap && CL.ingressMap[p.namespace]; if(!nsm) return [];
1399
+ const names = new Set(podMatchedSvcNames(p));
1400
+ return (nsm.ingresses||[]).filter(i=>(i.backendServices||[]).some(s=>names.has(s)));
1401
+ }
1402
+ function podIngressHosts(p){ const hs=new Set(); podIngresses(p).forEach(i=>(i.hosts||[]).forEach(h=>hs.add(h))); return [...hs]; }
1403
+ function ingCell(p){
1404
+ const hs = podIngressHosts(p);
1405
+ if(!hs.length) return '<span class="faint mono">—</span>';
1406
+ const label = hs[0] + (hs.length>1?` +${hs.length-1}`:'');
1407
+ return `<button class="ingchip" title="${esc(hs.join(', '))} — click for ingress config" onclick="openPodIngress('${esc(p.namespace)}','${esc(p.name)}')">🌐 <span class="ell" style="max-width:220px;vertical-align:bottom">${esc(label)}</span></button>`;
1408
+ }
1409
+ // Ingress config panel: routing (host/path/backend:port), TLS + cert-manager, class,
1410
+ // forced-HTTPS, CORS and body-size — for the ingress(es) fronting this pod.
1411
+ function openPodIngress(ns,pod){
1412
+ const p = CL.items.find(x=>x.name===pod&&x.namespace===ns) || {name:pod, namespace:ns, labels:{}};
1413
+ const ings = podIngresses(p);
1414
+ CL.logPod=null; CL.termPod=null; teardownTerm();
1415
+ $('#clBody').innerHTML = `<div class="cmctl"><button class="iconbtn" onclick="loadClusterContent()">← Pods</button>
1416
+ <span class="mono"><b>${esc(pod)}</b></span><span class="faint mono">${esc(ns)}</span><span class="faint mono">Ingress</span></div>
1417
+ <div id="pingOut"></div>`;
1418
+ const out=$('#pingOut'); if(!out) return;
1419
+ if(!ings.length){ out.innerHTML=`<div class="empty">No Ingress routes to this pod's service.</div>`; return; }
1420
+ const line=(k,v)=> v?`<div class="kvline" style="padding:5px 0"><span class="nm mono">${esc(k)}</span><span class="meta mono">${v}</span></div>`:'';
1421
+ out.innerHTML = ings.map(i=>{
1422
+ const routes=(i.routes||[]).map(r=>`<tr><td class="mono">${esc(r.host||'*')}</td><td class="mono">${esc(r.path||'/')}</td><td class="mono muted">${esc(r.pathType||'')}</td><td class="mono">${esc(r.service||'')}${r.port!=null?':'+esc(String(r.port)):''}</td></tr>`).join('');
1423
+ const tls=(i.tls||[]).map(t=>`<span class="tagchip">🔒 ${esc(t.secretName||'tls')}${t.hosts&&t.hosts.length?' · '+esc(t.hosts.join(', ')):''}</span>`).join(' ');
1424
+ const cm=i.certManager?[i.certManager.issuer,i.certManager.challenge,i.certManager.dnsProvider].filter(Boolean).map(esc).join(' · '):'';
1425
+ const cors=i.cors?`origin <b>${esc(i.cors.origin||'*')}</b> · ${esc(i.cors.methods||'')}`:'';
1426
+ return `<div class="panel" style="margin-bottom:16px;padding:16px 18px">
1427
+ <div class="row" style="justify-content:space-between;margin-bottom:10px;gap:10px">
1428
+ <b class="mono">${esc(i.name)}</b>
1429
+ <div class="tagchips">${i.hosts.map(h=>`<a class="ingchip" href="https://${esc(h)}" target="_blank" rel="noopener noreferrer" title="Open https://${esc(h)}">🌐 ${esc(h)} ↗</a>`).join('')}</div>
1430
+ </div>
1431
+ ${line('class', i.className?`<span class="tagchip">${esc(i.className)}</span>`:'')}
1432
+ ${line('TLS', tls)}
1433
+ ${line('cert-manager', cm)}
1434
+ ${line('HTTPS', i.sslRedirect?`forced redirect${i.allowHttp!=null?` · allow-http ${esc(i.allowHttp)}`:''}`:'')}
1435
+ ${line('CORS', cors)}
1436
+ ${line('max body', i.proxyBodySize?esc(i.proxyBodySize):'')}
1437
+ <div class="secthead" style="margin-top:12px">Routes</div>
1438
+ <div class="scroll"><table><thead><tr><th>Host</th><th>Path</th><th>Type</th><th>Backend service:port</th></tr></thead><tbody>${routes}</tbody></table></div>
1439
+ </div>`;
1440
+ }).join('');
1441
+ }
1442
+ // Resolve each namespace's Key Vault(s) once per cluster, then repaint the rows so the
1443
+ // KV column fills in. Best-effort: if the secret-source CRDs aren't readable it stays empty.
1444
+ async function ensurePodVaultMap(name){
1445
+ if(CL.vaultMapByCluster[name]){ CL.vaultMap=CL.vaultMapByCluster[name]; CL.vaultRestricted=CL.vaultRestrictedByCluster[name]||{}; return; }
1446
+ const pods = CL.podsByCluster[name] || CL.items || [];
1447
+ const nss = [...new Set(pods.map(p=>p.namespace).filter(Boolean))];
1448
+ if(!nss.length){ CL.vaultMapByCluster[name]={}; CL.vaultMap={}; return; }
1449
+ const r=await api(`/api/aks/${name}/secret-vaults?namespaces=${encodeURIComponent(nss.join(','))}`);
1450
+ CL.vaultMapByCluster[name] = (r.ok && r.data && r.data.byNamespace) ? r.data.byNamespace : {};
1451
+ CL.vaultRestrictedByCluster[name] = (r.ok && r.data && r.data.restricted) ? r.data.restricted : {};
1452
+ if(CL.name!==name) return;
1453
+ CL.vaultMap = CL.vaultMapByCluster[name];
1454
+ CL.vaultRestricted = CL.vaultRestrictedByCluster[name];
1455
+ if(STATE.route==='cluster' && CL.kind==='pods' && !CL.logPod && !CL.termPod) renderPodRows();
1456
+ }
1457
+ function podVaults(p){ return (CL.vaultMap && CL.vaultMap[p.namespace]) || []; }
1458
+ // The KV cell: a chip per Key Vault serving the pod's namespace. Clicking a known vault
1459
+ // jumps straight to its secrets; otherwise it opens the precise per-pod trace panel.
1460
+ // When the namespace's External Secrets CRDs aren't readable (RBAC), say so plainly.
1461
+ function kvCell(p){
1462
+ const vs = podVaults(p);
1463
+ if(vs.length) return `<div class="tagchips">${vs.map(name=>{ const known=findVault(name);
1464
+ return `<button class="kvchip${known?'':' unknown'}" title="${known?'Open '+esc(name)+' secrets':esc(name)+' — not in your visible inventory (click to trace)'}" onclick="${known?`openVault('${esc(name)}')`:`openPodVaults('${esc(p.namespace)}','${esc(p.name)}')`}">🔑 ${esc(name)}</button>`;
1465
+ }).join('')}</div>`;
1466
+ if(CL.vaultRestricted && CL.vaultRestricted[p.namespace])
1467
+ return `<button class="kvchip unknown" title="Your identity can't read SecretStores on this cluster (Kubernetes RBAC). Click to see details." onclick="openPodVaults('${esc(p.namespace)}','${esc(p.name)}')">🔒 restricted</button>`;
1468
+ return `<button class="iconbtn" title="trace this pod's Key Vault via CSI / External Secrets / env" onclick="openPodVaults('${esc(p.namespace)}','${esc(p.name)}')">trace</button>`;
1469
+ }
1470
+ function renderPodsShell(){
1471
+ if(CL.pendingFilter){ CL.filter=CL.pendingFilter; CL.pendingFilter=null; } // from a project "Open →"
1138
1472
  const tier = `<div class="seg" id="clTier">
1139
1473
  <button data-t="all" class="${CL.tier==='all'?'active':''}">All</button>
1140
1474
  <button data-t="be" class="${CL.tier==='be'?'active':''}">Backend</button>
@@ -1158,7 +1492,7 @@ function renderPodRows(){
1158
1492
  if(!CL.items.length){ $('#clList').innerHTML=`<div class="empty">No pods visible on this cluster.</div>`; return; }
1159
1493
  if(!list.length){ $('#clList').innerHTML=`<div class="empty">No pods match your filter.</div>`; return; }
1160
1494
  $('#clList').innerHTML = `<div class="panel"><div class="scroll"><table>
1161
- <thead><tr><th>Pod</th><th>Namespace</th><th>Build</th><th>Ready</th><th>Status</th><th class="right">Restarts</th><th>Age</th><th class="right">Actions</th></tr></thead>
1495
+ <thead><tr><th>Pod</th><th>Namespace</th><th>Build</th><th>Ready</th><th>Status</th><th class="right">Restarts</th><th>Age</th><th>Ingress</th><th>Key Vault</th><th class="right">Actions</th></tr></thead>
1162
1496
  <tbody>${list.map(p=>`<tr>
1163
1497
  <td class="mono"><b><span class="ell" title="${esc(p.name)}">${esc(p.name)}</span></b></td>
1164
1498
  <td class="mono muted"><span class="ell ns" title="${esc(p.namespace||'')}">${esc(p.namespace||'')}</span></td>
@@ -1167,12 +1501,57 @@ function renderPodRows(){
1167
1501
  <td><span class="phase ${esc(p.phase||'')}">${esc(p.phase||'?')}</span></td>
1168
1502
  <td class="right mono ${p.restarts>0?'':'muted'}">${p.restarts}</td>
1169
1503
  <td class="mono muted">${age(p.created)}</td>
1504
+ <td>${ingCell(p)}</td>
1505
+ <td>${kvCell(p)}</td>
1170
1506
  <td class="right" style="white-space:nowrap"><button class="iconbtn" onclick="openPodDetail('${esc(p.namespace)}','${esc(p.name)}')">Details</button>
1171
1507
  <button class="iconbtn" onclick="showLogs('${esc(p.namespace)}','${esc(p.name)}')">Logs</button>
1172
1508
  <button class="iconbtn" onclick="openTerminal('${esc(p.namespace)}','${esc(p.name)}')">Terminal</button>
1173
- <button class="iconbtn" onclick="openPodVaults('${esc(p.namespace)}','${esc(p.name)}')">Vaults</button></td>
1509
+ <button class="iconbtn warn" title="rolling-restart the workload that owns this pod" onclick="restartPod('${esc(p.namespace)}','${esc(p.name)}',this)">Restart</button></td>
1174
1510
  </tr>`).join('')}</tbody></table></div></div>`;
1175
1511
  }
1512
+ // Restart is the only write this app makes to a cluster. Two calls on purpose: the
1513
+ // first resolves the owning workload without touching it, so the confirmation names the
1514
+ // Deployment being rolled rather than the pod that was clicked.
1515
+ async function restartPod(ns,pod,btn){
1516
+ const label = btn ? btn.textContent : '';
1517
+ if(btn){ btn.disabled=true; btn.textContent='…'; }
1518
+ const restore=()=>{ if(btn){ btn.disabled=false; btn.textContent=label||'Restart'; } };
1519
+ const base=`/api/aks/${CL.name}/${ns}/pods/${pod}/restart`;
1520
+ const probe = await api(base+'?dryRun=1',{method:'POST'});
1521
+ if(!probe.ok){ restore(); restartError(probe, ns, pod); return; }
1522
+ const wl=probe.data;
1523
+ const ok = confirm(`Rolling-restart ${wl.kind} "${wl.name}" in ${ns}?\n\n`+
1524
+ `Every pod it owns is replaced, not just ${pod}. New pods start before old ones stop, `+
1525
+ `so there is normally no downtime — but this is a live workload on ${CL.name}.`);
1526
+ if(!ok){ restore(); return; }
1527
+ if(btn) btn.textContent='restarting…';
1528
+ const r = await api(base,{method:'POST'});
1529
+ restore();
1530
+ if(!r.ok){ restartError(r, ns, pod); return; }
1531
+ toast(`${r.data.kind} ${r.data.name} restarting`);
1532
+ banner(`Rolling restart requested on ${esc(r.data.kind)} <b>${esc(r.data.name)}</b> in ${esc(ns)} — watch the Ready/Age columns as replicas cycle.`);
1533
+ setTimeout(()=>banner(''),8000);
1534
+ setTimeout(()=>{ if(STATE.route==='cluster' && CL.kind==='pods') loadClusterContent(); }, 2500);
1535
+ }
1536
+ // Off-VPN the API server simply isn't reachable. Rather than dead-end, hand over the
1537
+ // `az aks command invoke` equivalent — it runs from inside the cluster, so it needs no
1538
+ // network path. It is NOT wired to a button: RunCommand executes as cluster-admin
1539
+ // regardless of the caller's Kubernetes RBAC, which this app otherwise never does.
1540
+ function restartError(r, ns, pod){
1541
+ if(r.network){
1542
+ const c=CL.cluster||{};
1543
+ const cmd=`az aks command invoke -g ${c.resourceGroup||'<rg>'} -n ${CL.name} --command "kubectl rollout restart deploy -n ${ns} --selector=app=<app>"`;
1544
+ banner(`🚫 Can't reach ${esc(CL.name)}'s API server (private — needs the VPN). From anywhere, Azure can run it inside the cluster instead:<br>`+
1545
+ `<code class="mono">${esc(cmd)}</code><br><span class="hint">Note: <code>command invoke</code> runs as cluster-admin, not as your Kubernetes identity.</span>`);
1546
+ setTimeout(()=>banner(''),16000);
1547
+ return;
1548
+ }
1549
+ if(r.forbidden){
1550
+ banner(`🔒 ${esc(r.error||'Forbidden')}`); setTimeout(()=>banner(''),10000); return;
1551
+ }
1552
+ toast(r.error||'restart failed');
1553
+ if(r.error && r.error.length>60){ banner(esc(r.error)); setTimeout(()=>banner(''),10000); }
1554
+ }
1176
1555
  async function openPodDetail(ns,pod){
1177
1556
  CL.logPod=null; CL.termPod=null; teardownTerm();
1178
1557
  $('#clBody').innerHTML = `<div class="cmctl"><button class="iconbtn" onclick="loadClusterContent()">← Pods</button>
@@ -1253,10 +1632,15 @@ function downloadLog(){
1253
1632
  }
1254
1633
  async function loadConfigMaps(){
1255
1634
  const name=CL.name;
1635
+ if(CL.cmByCluster[name]){ CL.items=CL.cmByCluster[name]; renderCmShell(); return; } // cached
1636
+ $('#clBody').innerHTML='<div class="empty"><span class="spin"></span></div>';
1256
1637
  const r=await api(`/api/aks/${name}/configmaps`);
1257
1638
  if(STATE.route!=='cluster'||CL.name!==name||CL.kind!=='configmaps'||CL.logPod||CL.termPod) return;
1258
1639
  if(!r.ok){ $('#clBody').innerHTML=clErr(r); return; }
1259
- CL.items=r.data||[];
1640
+ CL.items=r.data||[]; CL.cmByCluster[name]=CL.items;
1641
+ renderCmShell();
1642
+ }
1643
+ function renderCmShell(){
1260
1644
  $('#clBody').innerHTML = clFilterBar('filter configmaps by name or namespace…');
1261
1645
  $('#clFilter').oninput=e=>{ CL.filter=e.target.value; renderCmRows(); };
1262
1646
  renderCmRows();
@@ -1293,6 +1677,190 @@ async function viewCm(ns,name){
1293
1677
  }
1294
1678
  function copyCm(ckey,k){ const d=CL.cm[ckey]||{}; copy(d[k]!=null?String(d[k]):''); }
1295
1679
 
1680
+ // ---------- PROJECTS (user-defined grouping: project → envs → workloads + key vaults) ----------
1681
+ // Stored entirely in the browser (localStorage). Import/export as JSON to share.
1682
+ // Shape: { version:1, projects:[ { id, name, envs:[ { name, workloads:[{cluster,namespace,name,base,label}], vaults:[{name,label}] } ] } ] }
1683
+ let PROJ = { data:{ version:1, projects:[] } };
1684
+ function loadProjects(){ try{ const t=localStorage.getItem('akv.projects'); const d=t?JSON.parse(t):null; PROJ.data=(d&&Array.isArray(d.projects))?d:{version:1,projects:[]}; }catch{ PROJ.data={version:1,projects:[]}; } }
1685
+ function saveProjects(){ try{ localStorage.setItem('akv.projects', JSON.stringify(PROJ.data)); }catch{} }
1686
+ function genId(){ return 'p'+Math.random().toString(36).slice(2,9); }
1687
+ const projById = id => PROJ.data.projects.find(p=>p.id===id);
1688
+ function podBase(n){ return (n||'').replace(/-[a-z0-9]{8,10}-[a-z0-9]{4,5}$/,'').replace(/-[0-9]+$/,''); }
1689
+ function downloadJson(fn,obj){ const blob=new Blob([JSON.stringify(obj,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=fn; a.click(); URL.revokeObjectURL(url); }
1690
+
1691
+ function viewProjects(){
1692
+ const ps=PROJ.data.projects;
1693
+ $('#route').innerHTML = `
1694
+ <div class="cmctl" style="margin-bottom:14px">
1695
+ <button class="btn sm" onclick="newProject()">+ New project</button>
1696
+ <button class="btn ghost sm" onclick="openImport()">⬇ Import</button>
1697
+ <button class="btn ghost sm" onclick="exportAll()">⬆ Export all</button>
1698
+ <span class="faint mono" style="margin-left:auto">${ps.length} project${ps.length!==1?'s':''}</span></div>
1699
+ <div class="createp hide" id="projIo"></div>
1700
+ ${ps.length ? `<div class="vtiles">${ps.map(p=>{
1701
+ const w=p.envs.reduce((a,e)=>a+(e.workloads||[]).length,0), v=p.envs.reduce((a,e)=>a+(e.vaults||[]).length,0);
1702
+ return `<div class="vtile" onclick="navigate('project/${esc(p.id)}')">
1703
+ <span class="nm">${esc(p.name)}</span>
1704
+ <span class="rg mono">${p.envs.length} env${p.envs.length!==1?'s':''} · ${w} workload${w!==1?'s':''} · ${v} vault${v!==1?'s':''}</span></div>`;
1705
+ }).join('')}</div>`
1706
+ : `<div class="panel"><div class="empty" style="padding:42px;line-height:1.7">No projects yet.<br>
1707
+ <span class="faint mono" style="font-size:12px">Create one to group workloads and key vaults by environment — or Import a shared config.</span></div></div>`}`;
1708
+ }
1709
+ function newProject(){ const name=prompt('Project name (e.g. Marketplace)'); if(!name||!name.trim()) return;
1710
+ const p={ id:genId(), name:name.trim(), envs:[] }; PROJ.data.projects.push(p); saveProjects(); navigate('project/'+p.id); }
1711
+ function exportAll(){ downloadJson('akv-projects.json', PROJ.data); }
1712
+ function exportProject(id){ const p=projById(id); if(!p)return; downloadJson('akv-project-'+p.name.replace(/\W+/g,'-').toLowerCase()+'.json',{version:1,projects:[p]}); }
1713
+ function openImport(){
1714
+ const el=$('#projIo');
1715
+ el.innerHTML=`<h4>Import projects</h4>
1716
+ <div class="sub" style="margin-bottom:8px">Paste a projects JSON exported from here. Imported projects are added; a project with the same id is replaced.</div>
1717
+ <div class="fld" style="align-items:flex-start"><textarea id="impJson" spellcheck="false" placeholder='{ "version":1, "projects":[ … ] }' style="min-height:180px"></textarea></div>
1718
+ <div class="actions"><button class="btn sm" onclick="doImport()">Import</button>
1719
+ <button class="btn sm ghost" onclick="document.getElementById('projIo').classList.add('hide')">Cancel</button>
1720
+ <span class="hint" id="impMsg"></span></div>`;
1721
+ el.classList.remove('hide');
1722
+ }
1723
+ function normEnv(e){ return { name:String((e&&e.name)||'env'),
1724
+ workloads:Array.isArray(e&&e.workloads)?e.workloads.filter(w=>w&&w.cluster&&w.namespace&&w.name).map(w=>({cluster:String(w.cluster),namespace:String(w.namespace),name:String(w.name),base:String(w.base||podBase(w.name)),label:String(w.label||'')})):[],
1725
+ vaults:Array.isArray(e&&e.vaults)?e.vaults.filter(v=>v&&v.name).map(v=>({name:String(v.name),label:String(v.label||'')})):[] }; }
1726
+ function doImport(){
1727
+ const msg=$('#impMsg'); let d;
1728
+ try{ d=JSON.parse($('#impJson').value); }catch(e){ msg.textContent='Invalid JSON: '+e.message; return; }
1729
+ const incoming = Array.isArray(d)?d:(d&&Array.isArray(d.projects)?d.projects:null);
1730
+ if(!incoming){ msg.textContent='Expected { projects:[…] } or an array of projects'; return; }
1731
+ let n=0;
1732
+ for(const p of incoming){ if(!p||!p.name) continue;
1733
+ const np={ id:p.id||genId(), name:String(p.name), envs:Array.isArray(p.envs)?p.envs.map(normEnv):[] };
1734
+ const i=PROJ.data.projects.findIndex(x=>x.id===np.id);
1735
+ if(i>=0) PROJ.data.projects[i]=np; else PROJ.data.projects.push(np); n++; }
1736
+ saveProjects(); toast(`Imported ${n} project${n!==1?'s':''}`); viewProjects();
1737
+ }
1738
+
1739
+ function viewProject(id){
1740
+ const p=projById(id);
1741
+ if(!p){ $('#route').innerHTML=`<div class="panel"><div class="empty">Project not found. <a href="#/projects">Back to Projects</a></div></div>`; return; }
1742
+ $('#route').innerHTML = `
1743
+ <div class="vhead">
1744
+ <div class="crumb"><a href="#/projects">Projects</a><span class="sep">/</span><span class="here">${esc(p.name)}</span></div>
1745
+ <div class="vmeta">
1746
+ <button class="iconbtn" onclick="renameProject('${esc(id)}')">Rename</button>
1747
+ <button class="iconbtn" onclick="exportProject('${esc(id)}')">Export</button>
1748
+ <button class="iconbtn" onclick="deleteProject('${esc(id)}')">Delete</button></div>
1749
+ </div>
1750
+ <div class="row" style="margin-bottom:14px"><button class="btn sm" onclick="addEnv('${esc(id)}')">+ Add environment</button></div>
1751
+ <div id="projBody"></div>`;
1752
+ renderProjectBody(id);
1753
+ }
1754
+ function renderProjectBody(id){
1755
+ const p=projById(id); if(!p) return;
1756
+ if(!p.envs.length){ $('#projBody').innerHTML=`<div class="panel"><div class="empty" style="padding:34px">No environments yet — add one (e.g. dev, test, qa, prod).</div></div>`; return; }
1757
+ $('#projBody').innerHTML = p.envs.map((e,ei)=>`
1758
+ <div class="panel" style="margin-bottom:16px;padding:16px 18px">
1759
+ <div class="secthead2" style="margin-bottom:12px">
1760
+ <span class="tag ${envClass(e.name)}" style="font-size:11px">${esc(e.name)}</span>
1761
+ <span class="row"><button class="iconbtn" onclick="renameEnv('${esc(id)}',${ei})">Rename</button>
1762
+ <button class="iconbtn" onclick="deleteEnv('${esc(id)}',${ei})">Delete</button></span>
1763
+ </div>
1764
+ <div class="eyebrow" style="margin-bottom:8px">Workloads</div>
1765
+ ${renderEnvWorkloads(id,ei,e)}
1766
+ <button class="btn ghost sm" style="margin-top:8px" onclick="addWorkload('${esc(id)}',${ei})">+ Link workload</button>
1767
+ <div class="eyebrow" style="margin:18px 0 8px">Key Vaults</div>
1768
+ ${renderEnvVaults(id,ei,e)}
1769
+ <button class="btn ghost sm" style="margin-top:8px" onclick="addVault('${esc(id)}',${ei})">+ Link Key Vault</button>
1770
+ <div class="createp hide" id="picker-${esc(id)}-${ei}"></div>
1771
+ </div>`).join('');
1772
+ }
1773
+ function renderEnvWorkloads(id,ei,e){
1774
+ const w=e.workloads||[];
1775
+ if(!w.length) return '<div class="faint mono" style="font-size:12px">none linked</div>';
1776
+ return `<div class="vtiles">${w.map((x,xi)=>`
1777
+ <div class="vtile" style="cursor:default">
1778
+ <span class="nm">${esc(x.label||x.base||x.name)}</span>
1779
+ <span class="rg mono">${esc(x.cluster)} · ${esc(x.namespace)}</span>
1780
+ <button class="iconbtn" onclick="openWorkload('${esc(id)}',${ei},${xi})">Open →</button>
1781
+ <button class="iconbtn" onclick="rmItem('${esc(id)}',${ei},'workloads',${xi})">✕</button>
1782
+ </div>`).join('')}</div>`;
1783
+ }
1784
+ function renderEnvVaults(id,ei,e){
1785
+ const v=e.vaults||[];
1786
+ if(!v.length) return '<div class="faint mono" style="font-size:12px">none linked</div>';
1787
+ return `<div class="vtiles">${v.map((x,xi)=>{ const known=findVault(x.name);
1788
+ return `<div class="vtile" style="cursor:default">
1789
+ <span class="nm mono">${esc(x.name)}</span>
1790
+ <span class="rg mono">${esc(x.label||'')}</span>
1791
+ ${known?`<button class="iconbtn" onclick="openVault('${esc(known.name)}')">Open →</button>`:'<span class="faint mono" style="font-size:11px">not visible</span>'}
1792
+ <button class="iconbtn" onclick="rmItem('${esc(id)}',${ei},'vaults',${xi})">✕</button>
1793
+ </div>`; }).join('')}</div>`;
1794
+ }
1795
+ function openWorkload(id,ei,xi){ const x=projById(id).envs[ei].workloads[xi]; CL.pendingFilter=x.base||x.name; openCluster(x.cluster); }
1796
+ function rmItem(id,ei,key,xi){ projById(id).envs[ei][key].splice(xi,1); saveProjects(); renderProjectBody(id); }
1797
+ function addEnv(id){ const name=prompt('Environment name (e.g. dev)'); if(!name||!name.trim())return;
1798
+ projById(id).envs.push({name:name.trim(), workloads:[], vaults:[]}); saveProjects(); renderProjectBody(id); }
1799
+ function renameEnv(id,ei){ const e=projById(id).envs[ei]; const name=prompt('Rename environment', e.name); if(name==null)return; e.name=name.trim()||e.name; saveProjects(); renderProjectBody(id); }
1800
+ function deleteEnv(id,ei){ const p=projById(id); if(!confirm('Delete environment "'+p.envs[ei].name+'"?'))return; p.envs.splice(ei,1); saveProjects(); renderProjectBody(id); }
1801
+ function renameProject(id){ const p=projById(id); const name=prompt('Rename project', p.name); if(name==null)return; p.name=name.trim()||p.name; saveProjects(); viewProject(id); }
1802
+ function deleteProject(id){ const p=projById(id); if(!confirm('Delete project "'+p.name+'"? This cannot be undone.'))return; PROJ.data.projects=PROJ.data.projects.filter(x=>x.id!==id); saveProjects(); navigate('projects'); }
1803
+ function closePicker(id,ei){ const el=document.getElementById('picker-'+id+'-'+ei); if(el){ el.classList.add('hide'); el.innerHTML=''; } }
1804
+
1805
+ function addWorkload(id,ei){
1806
+ const el=document.getElementById('picker-'+id+'-'+ei);
1807
+ const clusters=CL.all||[];
1808
+ if(!clusters.length){ el.innerHTML=`<div class="hint">No clusters available — add them under <a href="#/clusters">Clusters</a> first.</div>`; el.classList.remove('hide'); return; }
1809
+ el.innerHTML = `<h4>Link a workload (pod)</h4>
1810
+ <div class="fld"><label>Cluster</label>
1811
+ <select class="facetsel" id="pkC" style="max-width:240px">${clusters.map(c=>`<option value="${esc(c.name)}">${esc(c.label||c.name)}</option>`).join('')}</select>
1812
+ <input class="search" id="pkQ" placeholder="search pods by name or namespace…" style="flex:1;min-width:0"></div>
1813
+ <div id="pkResults" class="itemwrap" style="max-height:300px;overflow:auto"></div>
1814
+ <div class="actions"><button class="btn sm ghost" onclick="closePicker('${esc(id)}',${ei})">Close</button><span class="hint" id="pkMsg"></span></div>`;
1815
+ el.classList.remove('hide');
1816
+ const run=()=>pkSearchPods(id,ei); $('#pkC').onchange=run; $('#pkQ').oninput=run; run();
1817
+ }
1818
+ async function pkSearchPods(id,ei){
1819
+ const cluster=$('#pkC').value, q=$('#pkQ').value.toLowerCase(), box=$('#pkResults');
1820
+ let pods=CL.podsByCluster[cluster];
1821
+ if(!pods){ box.innerHTML='<div class="empty"><span class="spin"></span> loading pods…</div>';
1822
+ const r=await api(`/api/aks/${cluster}/pods`); if(!r.ok){ box.innerHTML=clErr(r); return; } pods=r.data||[]; CL.podsByCluster[cluster]=pods; }
1823
+ const list=pods.filter(p=>!q||p.name.toLowerCase().includes(q)||(p.namespace||'').toLowerCase().includes(q)).slice(0,50);
1824
+ if(!list.length){ box.innerHTML='<div class="faint mono" style="font-size:12px;padding:8px">No pods match.</div>'; return; }
1825
+ box.innerHTML = list.map(p=>`<div class="kvitem"><div class="kvline">
1826
+ <span class="nm" title="${esc(p.name)}">${esc(p.name)}</span>
1827
+ <span class="meta"><span class="faint mono" style="font-size:11px">${esc(p.namespace)}</span>
1828
+ <button class="iconbtn" onclick="pickWorkload('${esc(id)}',${ei},'${esc(cluster)}','${esc(p.namespace)}','${esc(p.name)}')">Link</button></span>
1829
+ </div></div>`).join('');
1830
+ }
1831
+ function pickWorkload(id,ei,cluster,ns,name){
1832
+ const e=projById(id).envs[ei]; e.workloads=e.workloads||[];
1833
+ const base=podBase(name);
1834
+ if(!e.workloads.some(x=>x.cluster===cluster&&x.namespace===ns&&x.base===base)){ e.workloads.push({cluster,namespace:ns,name,base,label:''}); saveProjects(); toast('Workload linked'); }
1835
+ else toast('Already linked');
1836
+ renderProjectBody(id);
1837
+ }
1838
+ function addVault(id,ei){
1839
+ const el=document.getElementById('picker-'+id+'-'+ei);
1840
+ el.innerHTML = `<h4>Link a Key Vault</h4>
1841
+ <div class="fld"><label>Search</label><input class="search" id="pkVQ" placeholder="search vaults by name…" style="flex:1;min-width:0"></div>
1842
+ <div id="pkVResults" class="itemwrap" style="max-height:300px;overflow:auto"></div>
1843
+ <div class="actions"><button class="btn sm ghost" onclick="closePicker('${esc(id)}',${ei})">Close</button></div>`;
1844
+ el.classList.remove('hide');
1845
+ $('#pkVQ').oninput=()=>pkSearchVaults(id,ei); pkSearchVaults(id,ei);
1846
+ }
1847
+ function pkSearchVaults(id,ei){
1848
+ const q=$('#pkVQ').value.toLowerCase(), box=$('#pkVResults');
1849
+ const list=(STATE.vaults||[]).filter(v=>!q||v.name.toLowerCase().includes(q)||(v.service||'').toLowerCase().includes(q)).slice(0,50);
1850
+ if(!list.length){ box.innerHTML='<div class="faint mono" style="font-size:12px;padding:8px">No vaults match.</div>'; return; }
1851
+ box.innerHTML = list.map(v=>`<div class="kvitem"><div class="kvline">
1852
+ <span class="nm mono" title="${esc(v.name)}">${esc(v.name)}</span>
1853
+ <span class="meta"><span class="tag ${envClass(v.env)}">${esc(v.env)}</span>
1854
+ <button class="iconbtn" onclick="pickVault('${esc(id)}',${ei},'${esc(v.name)}')">Link</button></span>
1855
+ </div></div>`).join('');
1856
+ }
1857
+ function pickVault(id,ei,name){
1858
+ const e=projById(id).envs[ei]; e.vaults=e.vaults||[];
1859
+ if(!e.vaults.some(x=>x.name===name)){ e.vaults.push({name,label:''}); saveProjects(); toast('Key Vault linked'); }
1860
+ else toast('Already linked');
1861
+ renderProjectBody(id);
1862
+ }
1863
+
1296
1864
  // ---------- which Key Vaults does a pod use ----------
1297
1865
  async function openPodVaults(ns,pod){
1298
1866
  CL.logPod=null; CL.termPod=null; teardownTerm();
@@ -1316,8 +1884,13 @@ async function openPodVaults(ns,pod){
1316
1884
  <div class="hint" style="margin-top:6px">🔒 the source vault is defined in the ExternalSecret's SecretStore — ${esc(esoUnresolved[0].reason||'not readable')}.</div></div>`
1317
1885
  : '';
1318
1886
  if(!vaults.length){
1319
- out.innerHTML=`<div class="empty">No Key Vault could be resolved for this pod.
1320
- <div class="hint" style="margin-top:8px">Checked CSI SecretProviderClasses, External Secrets, container env, and ${r.data.configmapsScanned.length} configmap(s).</div>
1887
+ const restricted = r.data.restricted;
1888
+ out.innerHTML=`<div class="empty">${restricted
1889
+ ? `🔒 Your identity can't read <b>SecretStores</b> on this cluster.`
1890
+ : `No Key Vault could be resolved for this pod.`}
1891
+ <div class="hint" style="margin-top:8px">${restricted
1892
+ ? `The Key Vault is declared in the namespace's SecretStore, but your Kubernetes RBAC doesn't allow reading <code>secretstores</code>/<code>externalsecrets</code> here. It resolves on clusters/identities where you can <code>get</code> them.`
1893
+ : `Checked the namespace's SecretStores &amp; ExternalSecrets, CSI SecretProviderClasses, container env, and ${r.data.configmapsScanned.length} configmap(s). This namespace may not read from a Key Vault.`}</div>
1321
1894
  ${spcNote}${esoNote}</div>`;
1322
1895
  return;
1323
1896
  }
@@ -1394,6 +1967,7 @@ function cmdkActions(){
1394
1967
  {type:'view', label:'Key Vaults', hint:'go', act:()=>navigate('vaults')},
1395
1968
  {type:'view', label:'Expiry', hint:'go', act:()=>navigate('expiry')},
1396
1969
  {type:'view', label:'Clusters', hint:'go', act:()=>navigate('clusters')},
1970
+ {type:'view', label:'Projects', hint:'go', act:()=>navigate('projects')},
1397
1971
  {type:'view', label:'Account & subscriptions', hint:'go', act:()=>navigate('settings')},
1398
1972
  {type:'action', label:'Refresh inventory', hint:'run', act:()=>{ doRefresh(); }},
1399
1973
  ];
@@ -1437,7 +2011,15 @@ function moveCur(d){ if(!CMDK.items.length)return; CMDK.cur=(CMDK.cur+d+CMDK.ite
1437
2011
  const el=$('#cmdkList').querySelector('.cmdk-it.cur'); if(el) el.scrollIntoView({block:'nearest'}); }
1438
2012
  function runCmdk(i){ const it=CMDK.items[i]; if(!it)return; closeCmdk(); it.act(); }
1439
2013
 
1440
- function doRefresh(){ toast('Refreshing…'); loadSubs().then(()=>loadVaults(true)).then(()=>{ EXP.scanned=false; EXP.polling=false; refreshCurrent(); kickExpiryScan(); }); }
2014
+ function doRefresh(){
2015
+ toast('Refreshing…');
2016
+ CL.podsByCluster={}; CL.cmByCluster={}; CL.vaultMapByCluster={}; CL.ingressMapByCluster={}; // drop cached cluster data
2017
+ loadSubs().then(()=>loadVaults(true)).then(()=>{
2018
+ EXP.scanned=false; EXP.polling=false; kickExpiryScan();
2019
+ if(STATE.route==='cluster'){ loadClustersCache(true); loadClusterContent(true); }
2020
+ else { loadClustersCache(true).then(()=>refreshCurrent()); } // viewClusters() then renders fresh CL.all
2021
+ });
2022
+ }
1441
2023
 
1442
2024
  // ---------- events ----------
1443
2025
  document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>navigate(b.dataset.view));