akv-manager 0.1.1 → 0.2.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();
@@ -938,29 +1047,100 @@ function renderHorizon(items){
938
1047
  // ---------- SETTINGS (account + subscriptions) ----------
939
1048
  function viewSettings(){
940
1049
  const counts={}; STATE.vaults.forEach(v=>counts[v.subscriptionId]=(counts[v.subscriptionId]||0)+1);
1050
+ const acct = STATE.account || {};
1051
+ const users = (STATE.accounts && STATE.accounts.users) || [];
1052
+ const totalSubs = users.reduce((n,u)=>n+u.subscriptions.length,0);
1053
+ const expired = !!acct.needLogin;
1054
+
1055
+ // One panel per logged-in identity (az login), listing its subscriptions. Switching
1056
+ // to a subscription re-points the console at that user + tenant — see setSub().
1057
+ const userPanel = u => {
1058
+ const isCurrent = u.subscriptions.some(s=>s.active);
1059
+ const tenant = (u.subscriptions[0]||{}).tenantId || '';
1060
+ return `
1061
+ <div class="panel" style="margin-bottom:16px">
1062
+ <div class="row" style="justify-content:space-between;padding:0 2px 12px">
1063
+ <div class="row" style="gap:9px">
1064
+ <span class="dot" style="background:${isCurrent?'var(--ok)':'var(--faint)'}"></span>
1065
+ <b>${esc(u.user)}</b>
1066
+ ${isCurrent?'<span class="pill rbac">current</span>':''}
1067
+ </div>
1068
+ <span class="mono faint" style="font-size:11px">${u.subscriptions.length} subscription${u.subscriptions.length===1?'':'s'} · tenant ${esc(tenant.slice(0,8))}…</span>
1069
+ </div>
1070
+ <div class="scroll"><table>
1071
+ <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>
1072
+ <tbody>${u.subscriptions.map(s=>{
1073
+ const env = envLabel(s.name);
1074
+ return `<tr>
1075
+ <td><b>${esc(s.name)}</b></td>
1076
+ <td><span class="tag ${envClass(env)}">${esc(env)}</span></td>
1077
+ <td>${esc(s.state)}</td>
1078
+ <td>${s.active?'<span class="pill rbac">active</span>':(s.cliDefault?'<span class="pill policy">cli default</span>':'')}</td>
1079
+ <td class="right mono">${counts[s.id]||''}</td>
1080
+ <td class="mono muted">${esc(s.id)}</td>
1081
+ <td class="right">${s.active?'':`<button class="btn sm ghost" onclick="setSub('${esc(s.id)}')">Switch</button>`}</td>
1082
+ </tr>`;
1083
+ }).join('')}</tbody>
1084
+ </table></div>
1085
+ </div>`;
1086
+ };
1087
+
1088
+ const aggregate = !!acct.aggregate;
1089
+ // "All accounts": merge every logged-in user & tenant into one view. Sits above the
1090
+ // per-user panels; enabling it clears any single-account selection (see setAllAccounts).
1091
+ const allPanel = `
1092
+ <div class="panel" style="margin-bottom:16px;border-color:${aggregate?'var(--azure)':'var(--line)'}">
1093
+ <div class="row" style="justify-content:space-between;padding:2px">
1094
+ <div class="row" style="gap:9px">
1095
+ <span class="dot" style="background:${aggregate?'var(--ok)':'var(--faint)'}"></span>
1096
+ <b>All accounts</b>
1097
+ ${aggregate?'<span class="pill rbac">active</span>':''}
1098
+ <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>
1099
+ </div>
1100
+ ${aggregate?'':`<button class="btn sm" onclick="setAllAccounts()">Use all accounts</button>`}
1101
+ </div>
1102
+ </div>`;
941
1103
  $('#route').innerHTML = `
942
1104
  <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')}
1105
+ ${card('Signed in as', '', (acct.user&&acct.user.name)||'—','')}
1106
+ ${card('Tenant', aggregate?'all':((acct.tenantId||'').slice(0,8)+'…'), aggregate?'every tenant':(expired?'session expired':'active directory'),'')}
1107
+ ${card('Accounts', users.length, totalSubs+' subscription'+(totalSubs===1?'':'s'),'azure')}
946
1108
  </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>`;
1109
+ ${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">
1110
+ This account's session has expired. Switch to another account below, or run <code>az login</code> and press Refresh.</div>`:''}
1111
+ <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>
1112
+ ${allPanel}
1113
+ ${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>'}`;
1114
+ }
1115
+ // Reload the app under a new identity scope, preserving the toast/banner/refresh flow.
1116
+ async function applyScope(r, okMsg){
1117
+ STATE.account = r.data;
1118
+ updateAcctButton();
1119
+ await loadAccounts();
1120
+ await loadSubs();
1121
+ await loadVaults(true); // scope changed → force a fresh inventory
1122
+ renderSidebar();
1123
+ if(r.data.needLogin){
1124
+ 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.`);
1125
+ toast('Switched — this account needs re-login');
1126
+ } else {
1127
+ banner('');
1128
+ toast(okMsg);
1129
+ kickExpiryScan();
1130
+ }
1131
+ router(); // re-render the current page with the new scope
1132
+ }
1133
+ async function setAllAccounts(){
1134
+ toast('Loading all accounts…');
1135
+ const r = await api('/api/subscription/set',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({all:true})});
1136
+ if(!r.ok){ toast(r.error||'failed'); return; }
1137
+ await applyScope(r, 'Now showing all accounts');
959
1138
  }
960
1139
  async function setSub(id){
961
- toast('Switching subscription…');
1140
+ toast('Switching account…');
962
1141
  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');
1142
+ if(!r.ok){ toast(r.error||'failed to switch'); return; }
1143
+ await applyScope(r, 'Signed in as '+r.data.user.name);
964
1144
  }
965
1145
 
966
1146
  function card(k,v,sub,mod){
@@ -968,7 +1148,7 @@ function card(k,v,sub,mod){
968
1148
  }
969
1149
 
970
1150
  // ---------- 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:{} };
1151
+ 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
1152
  let mountedCluster = null;
973
1153
  function age(iso){ if(!iso) return ''; const s=(Date.now()-Date.parse(iso))/1000;
974
1154
  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 +1158,7 @@ function clErr(r){
978
1158
  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
1159
  return `<div class="empty">${esc(r.error||'failed')}</div>`;
980
1160
  }
981
- async function loadClustersCache(){ const r=await api('/api/aks/clusters'); if(r.ok) CL.all=r.data.clusters||[]; return CL.all; }
1161
+ 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
1162
 
983
1163
  // Clusters the user pasted, kept in this browser. Registered with the local server each
984
1164
  // load so the data-plane endpoints (which validate the cluster) accept them.
@@ -991,15 +1171,18 @@ async function registerSessionClusters(){
991
1171
  try{ await api('/api/aks/clusters',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clusters:arr})}); }catch{}
992
1172
  }
993
1173
 
994
- async function viewClusters(){
1174
+ async function viewClusters(force){
1175
+ if(!force && CL.all){ renderClustersPage(); return; } // reuse cache; refetch only on explicit refresh
995
1176
  $('#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');
1177
+ const r = await api('/api/aks/clusters'+(force?'?refresh=1':''));
998
1178
  if(STATE.route!=='clusters') return;
999
1179
  if(!r.ok){ $('#route').innerHTML = `<div class="panel">${clErr(r)}</div>`; return; }
1000
1180
  CL.all = r.data.clusters||[];
1001
1181
  CL.configuredCount = r.data.configured;
1002
- const envs=[...new Set(CL.all.map(c=>c.env).filter(Boolean))].sort();
1182
+ renderClustersPage();
1183
+ }
1184
+ function renderClustersPage(){
1185
+ const envs=[...new Set((CL.all||[]).map(c=>c.env).filter(Boolean))].sort();
1003
1186
  const envSeg = envs.length>1 ? `<div class="seg" id="clEnvSeg">
1004
1187
  <button data-e="all" class="${CL.clEnv==='all'||!CL.clEnv?'active':''}">All</button>
1005
1188
  ${envs.map(e=>`<button data-e="${esc(e)}" class="${CL.clEnv===e?'active':''}">${esc(e)}</button>`).join('')}</div>` : '';
@@ -1039,14 +1222,14 @@ async function saveClusterList(){
1039
1222
  msg.textContent='saving…';
1040
1223
  const r=await api('/api/aks/clusters',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clusters:arr})});
1041
1224
  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();
1225
+ setLsAks(raw); CL.all=null; CL.podsByCluster={}; CL.cmByCluster={}; CL.vaultMapByCluster={}; CL.ingressMapByCluster={}; toast(`Saved ${r.data.count} cluster${r.data.count!==1?'s':''}`);
1226
+ viewClusters();
1044
1227
  }
1045
1228
  function clearClusterList(){
1046
1229
  if(!confirm('Remove the clusters stored in this browser? (file / env clusters stay.)')) return;
1047
1230
  setLsAks('');
1048
1231
  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(); });
1232
+ .then(()=>{ toast('Cleared stored clusters'); CL.all=null; viewClusters(); });
1050
1233
  }
1051
1234
  function renderClusterRows(){
1052
1235
  if(!CL.all.length){
@@ -1094,7 +1277,7 @@ function viewCluster(name){
1094
1277
  <div id="clBody"><div class="empty"><span class="spin"></span> loading…</div></div>`;
1095
1278
  $('#clKind').querySelectorAll('button').forEach(b=>b.onclick=()=>{ CL.kind=b.dataset.k;
1096
1279
  $('#clKind').querySelectorAll('button').forEach(x=>x.classList.toggle('active',x===b)); loadClusterContent(); });
1097
- $('#clRefresh').onclick=()=>{ if(CL.logPod) fetchLogs(); else loadClusterContent(); };
1280
+ $('#clRefresh').onclick=()=>{ if(CL.logPod) fetchLogs(); else loadClusterContent(true); };
1098
1281
  enrichClusterMeta(name);
1099
1282
  loadClusterContent();
1100
1283
  } else {
@@ -1111,9 +1294,9 @@ async function enrichClusterMeta(name){
1111
1294
  if($('#clMeta')) $('#clMeta').innerHTML = `<span class="mono">${esc(c.name)}</span><span class="sep">·</span>`+
1112
1295
  `<span class="mono">${esc(c.resourceGroup||'')}</span>${c.private?'<span class="sep">·</span><span class="pill policy">private · VPN</span>':''}`;
1113
1296
  }
1114
- function loadClusterContent(){
1297
+ function loadClusterContent(force){
1115
1298
  CL.logPod=null; CL.termPod=null; CL.filter=''; teardownTerm();
1116
- $('#clBody').innerHTML='<div class="empty"><span class="spin"></span></div>';
1299
+ 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
1300
  if(CL.kind==='pods') loadPods(); else loadConfigMaps();
1118
1301
  }
1119
1302
  function clFilterBar(placeholder, extra){
@@ -1131,10 +1314,113 @@ const TIER_RE = {
1131
1314
  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
1315
  async function loadPods(){
1133
1316
  const name=CL.name;
1317
+ CL.vaultMap = CL.vaultMapByCluster[name] || {};
1318
+ if(CL.podsByCluster[name]){ CL.items=CL.podsByCluster[name]; renderPodsShell(); ensurePodVaultMap(name); return; } // cached
1319
+ $('#clBody').innerHTML='<div class="empty"><span class="spin"></span></div>';
1134
1320
  const r = await api(`/api/aks/${name}/pods`);
1135
1321
  if(STATE.route!=='cluster'||CL.name!==name||CL.kind!=='pods'||CL.logPod||CL.termPod) return;
1136
1322
  if(!r.ok){ $('#clBody').innerHTML=clErr(r); return; }
1137
- CL.items=r.data||[];
1323
+ CL.items=r.data||[]; CL.podsByCluster[name]=CL.items;
1324
+ renderPodsShell();
1325
+ ensurePodVaultMap(name);
1326
+ ensurePodIngressMap(name);
1327
+ }
1328
+ // Resolve each namespace's exposed Services → Ingress hosts once per cluster, then repaint
1329
+ // so the Ingress column fills in. Pods are matched to a Service by label⊇selector.
1330
+ async function ensurePodIngressMap(name){
1331
+ if(CL.ingressMapByCluster[name]){ CL.ingressMap=CL.ingressMapByCluster[name]; return; }
1332
+ const pods = CL.podsByCluster[name] || CL.items || [];
1333
+ const nss = [...new Set(pods.map(p=>p.namespace).filter(Boolean))];
1334
+ if(!nss.length){ CL.ingressMapByCluster[name]={}; CL.ingressMap={}; return; }
1335
+ const r=await api(`/api/aks/${name}/ingress-map?namespaces=${encodeURIComponent(nss.join(','))}`);
1336
+ CL.ingressMapByCluster[name] = (r.ok && r.data && r.data.byNamespace) ? r.data.byNamespace : {};
1337
+ if(CL.name!==name) return;
1338
+ CL.ingressMap = CL.ingressMapByCluster[name];
1339
+ if(STATE.route==='cluster' && CL.kind==='pods' && !CL.logPod && !CL.termPod) renderPodRows();
1340
+ }
1341
+ // Match a pod to the Ingress(es) routing to it: Services whose selector ⊆ the pod's
1342
+ // labels, then Ingresses whose backend references those Services.
1343
+ function podMatchedSvcNames(p){
1344
+ const nsm = CL.ingressMap && CL.ingressMap[p.namespace]; if(!nsm) return [];
1345
+ const labels = p.labels || {};
1346
+ return (nsm.services||[]).filter(s=>{ const sel=s.selector||{}; const keys=Object.keys(sel);
1347
+ return keys.length && keys.every(k=>labels[k]===sel[k]); }).map(s=>s.name);
1348
+ }
1349
+ function podIngresses(p){
1350
+ const nsm = CL.ingressMap && CL.ingressMap[p.namespace]; if(!nsm) return [];
1351
+ const names = new Set(podMatchedSvcNames(p));
1352
+ return (nsm.ingresses||[]).filter(i=>(i.backendServices||[]).some(s=>names.has(s)));
1353
+ }
1354
+ function podIngressHosts(p){ const hs=new Set(); podIngresses(p).forEach(i=>(i.hosts||[]).forEach(h=>hs.add(h))); return [...hs]; }
1355
+ function ingCell(p){
1356
+ const hs = podIngressHosts(p);
1357
+ if(!hs.length) return '<span class="faint mono">—</span>';
1358
+ const label = hs[0] + (hs.length>1?` +${hs.length-1}`:'');
1359
+ 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>`;
1360
+ }
1361
+ // Ingress config panel: routing (host/path/backend:port), TLS + cert-manager, class,
1362
+ // forced-HTTPS, CORS and body-size — for the ingress(es) fronting this pod.
1363
+ function openPodIngress(ns,pod){
1364
+ const p = CL.items.find(x=>x.name===pod&&x.namespace===ns) || {name:pod, namespace:ns, labels:{}};
1365
+ const ings = podIngresses(p);
1366
+ CL.logPod=null; CL.termPod=null; teardownTerm();
1367
+ $('#clBody').innerHTML = `<div class="cmctl"><button class="iconbtn" onclick="loadClusterContent()">← Pods</button>
1368
+ <span class="mono"><b>${esc(pod)}</b></span><span class="faint mono">${esc(ns)}</span><span class="faint mono">Ingress</span></div>
1369
+ <div id="pingOut"></div>`;
1370
+ const out=$('#pingOut'); if(!out) return;
1371
+ if(!ings.length){ out.innerHTML=`<div class="empty">No Ingress routes to this pod's service.</div>`; return; }
1372
+ 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>`:'';
1373
+ out.innerHTML = ings.map(i=>{
1374
+ 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('');
1375
+ const tls=(i.tls||[]).map(t=>`<span class="tagchip">🔒 ${esc(t.secretName||'tls')}${t.hosts&&t.hosts.length?' · '+esc(t.hosts.join(', ')):''}</span>`).join(' ');
1376
+ const cm=i.certManager?[i.certManager.issuer,i.certManager.challenge,i.certManager.dnsProvider].filter(Boolean).map(esc).join(' · '):'';
1377
+ const cors=i.cors?`origin <b>${esc(i.cors.origin||'*')}</b> · ${esc(i.cors.methods||'')}`:'';
1378
+ return `<div class="panel" style="margin-bottom:16px;padding:16px 18px">
1379
+ <div class="row" style="justify-content:space-between;margin-bottom:10px;gap:10px">
1380
+ <b class="mono">${esc(i.name)}</b>
1381
+ <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>
1382
+ </div>
1383
+ ${line('class', i.className?`<span class="tagchip">${esc(i.className)}</span>`:'')}
1384
+ ${line('TLS', tls)}
1385
+ ${line('cert-manager', cm)}
1386
+ ${line('HTTPS', i.sslRedirect?`forced redirect${i.allowHttp!=null?` · allow-http ${esc(i.allowHttp)}`:''}`:'')}
1387
+ ${line('CORS', cors)}
1388
+ ${line('max body', i.proxyBodySize?esc(i.proxyBodySize):'')}
1389
+ <div class="secthead" style="margin-top:12px">Routes</div>
1390
+ <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>
1391
+ </div>`;
1392
+ }).join('');
1393
+ }
1394
+ // Resolve each namespace's Key Vault(s) once per cluster, then repaint the rows so the
1395
+ // KV column fills in. Best-effort: if the secret-source CRDs aren't readable it stays empty.
1396
+ async function ensurePodVaultMap(name){
1397
+ if(CL.vaultMapByCluster[name]){ CL.vaultMap=CL.vaultMapByCluster[name]; CL.vaultRestricted=CL.vaultRestrictedByCluster[name]||{}; return; }
1398
+ const pods = CL.podsByCluster[name] || CL.items || [];
1399
+ const nss = [...new Set(pods.map(p=>p.namespace).filter(Boolean))];
1400
+ if(!nss.length){ CL.vaultMapByCluster[name]={}; CL.vaultMap={}; return; }
1401
+ const r=await api(`/api/aks/${name}/secret-vaults?namespaces=${encodeURIComponent(nss.join(','))}`);
1402
+ CL.vaultMapByCluster[name] = (r.ok && r.data && r.data.byNamespace) ? r.data.byNamespace : {};
1403
+ CL.vaultRestrictedByCluster[name] = (r.ok && r.data && r.data.restricted) ? r.data.restricted : {};
1404
+ if(CL.name!==name) return;
1405
+ CL.vaultMap = CL.vaultMapByCluster[name];
1406
+ CL.vaultRestricted = CL.vaultRestrictedByCluster[name];
1407
+ if(STATE.route==='cluster' && CL.kind==='pods' && !CL.logPod && !CL.termPod) renderPodRows();
1408
+ }
1409
+ function podVaults(p){ return (CL.vaultMap && CL.vaultMap[p.namespace]) || []; }
1410
+ // The KV cell: a chip per Key Vault serving the pod's namespace. Clicking a known vault
1411
+ // jumps straight to its secrets; otherwise it opens the precise per-pod trace panel.
1412
+ // When the namespace's External Secrets CRDs aren't readable (RBAC), say so plainly.
1413
+ function kvCell(p){
1414
+ const vs = podVaults(p);
1415
+ if(vs.length) return `<div class="tagchips">${vs.map(name=>{ const known=findVault(name);
1416
+ 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>`;
1417
+ }).join('')}</div>`;
1418
+ if(CL.vaultRestricted && CL.vaultRestricted[p.namespace])
1419
+ 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>`;
1420
+ 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>`;
1421
+ }
1422
+ function renderPodsShell(){
1423
+ if(CL.pendingFilter){ CL.filter=CL.pendingFilter; CL.pendingFilter=null; } // from a project "Open →"
1138
1424
  const tier = `<div class="seg" id="clTier">
1139
1425
  <button data-t="all" class="${CL.tier==='all'?'active':''}">All</button>
1140
1426
  <button data-t="be" class="${CL.tier==='be'?'active':''}">Backend</button>
@@ -1158,7 +1444,7 @@ function renderPodRows(){
1158
1444
  if(!CL.items.length){ $('#clList').innerHTML=`<div class="empty">No pods visible on this cluster.</div>`; return; }
1159
1445
  if(!list.length){ $('#clList').innerHTML=`<div class="empty">No pods match your filter.</div>`; return; }
1160
1446
  $('#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>
1447
+ <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
1448
  <tbody>${list.map(p=>`<tr>
1163
1449
  <td class="mono"><b><span class="ell" title="${esc(p.name)}">${esc(p.name)}</span></b></td>
1164
1450
  <td class="mono muted"><span class="ell ns" title="${esc(p.namespace||'')}">${esc(p.namespace||'')}</span></td>
@@ -1167,10 +1453,11 @@ function renderPodRows(){
1167
1453
  <td><span class="phase ${esc(p.phase||'')}">${esc(p.phase||'?')}</span></td>
1168
1454
  <td class="right mono ${p.restarts>0?'':'muted'}">${p.restarts}</td>
1169
1455
  <td class="mono muted">${age(p.created)}</td>
1456
+ <td>${ingCell(p)}</td>
1457
+ <td>${kvCell(p)}</td>
1170
1458
  <td class="right" style="white-space:nowrap"><button class="iconbtn" onclick="openPodDetail('${esc(p.namespace)}','${esc(p.name)}')">Details</button>
1171
1459
  <button class="iconbtn" onclick="showLogs('${esc(p.namespace)}','${esc(p.name)}')">Logs</button>
1172
- <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>
1460
+ <button class="iconbtn" onclick="openTerminal('${esc(p.namespace)}','${esc(p.name)}')">Terminal</button></td>
1174
1461
  </tr>`).join('')}</tbody></table></div></div>`;
1175
1462
  }
1176
1463
  async function openPodDetail(ns,pod){
@@ -1253,10 +1540,15 @@ function downloadLog(){
1253
1540
  }
1254
1541
  async function loadConfigMaps(){
1255
1542
  const name=CL.name;
1543
+ if(CL.cmByCluster[name]){ CL.items=CL.cmByCluster[name]; renderCmShell(); return; } // cached
1544
+ $('#clBody').innerHTML='<div class="empty"><span class="spin"></span></div>';
1256
1545
  const r=await api(`/api/aks/${name}/configmaps`);
1257
1546
  if(STATE.route!=='cluster'||CL.name!==name||CL.kind!=='configmaps'||CL.logPod||CL.termPod) return;
1258
1547
  if(!r.ok){ $('#clBody').innerHTML=clErr(r); return; }
1259
- CL.items=r.data||[];
1548
+ CL.items=r.data||[]; CL.cmByCluster[name]=CL.items;
1549
+ renderCmShell();
1550
+ }
1551
+ function renderCmShell(){
1260
1552
  $('#clBody').innerHTML = clFilterBar('filter configmaps by name or namespace…');
1261
1553
  $('#clFilter').oninput=e=>{ CL.filter=e.target.value; renderCmRows(); };
1262
1554
  renderCmRows();
@@ -1293,6 +1585,190 @@ async function viewCm(ns,name){
1293
1585
  }
1294
1586
  function copyCm(ckey,k){ const d=CL.cm[ckey]||{}; copy(d[k]!=null?String(d[k]):''); }
1295
1587
 
1588
+ // ---------- PROJECTS (user-defined grouping: project → envs → workloads + key vaults) ----------
1589
+ // Stored entirely in the browser (localStorage). Import/export as JSON to share.
1590
+ // Shape: { version:1, projects:[ { id, name, envs:[ { name, workloads:[{cluster,namespace,name,base,label}], vaults:[{name,label}] } ] } ] }
1591
+ let PROJ = { data:{ version:1, projects:[] } };
1592
+ 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:[]}; } }
1593
+ function saveProjects(){ try{ localStorage.setItem('akv.projects', JSON.stringify(PROJ.data)); }catch{} }
1594
+ function genId(){ return 'p'+Math.random().toString(36).slice(2,9); }
1595
+ const projById = id => PROJ.data.projects.find(p=>p.id===id);
1596
+ function podBase(n){ return (n||'').replace(/-[a-z0-9]{8,10}-[a-z0-9]{4,5}$/,'').replace(/-[0-9]+$/,''); }
1597
+ 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); }
1598
+
1599
+ function viewProjects(){
1600
+ const ps=PROJ.data.projects;
1601
+ $('#route').innerHTML = `
1602
+ <div class="cmctl" style="margin-bottom:14px">
1603
+ <button class="btn sm" onclick="newProject()">+ New project</button>
1604
+ <button class="btn ghost sm" onclick="openImport()">⬇ Import</button>
1605
+ <button class="btn ghost sm" onclick="exportAll()">⬆ Export all</button>
1606
+ <span class="faint mono" style="margin-left:auto">${ps.length} project${ps.length!==1?'s':''}</span></div>
1607
+ <div class="createp hide" id="projIo"></div>
1608
+ ${ps.length ? `<div class="vtiles">${ps.map(p=>{
1609
+ const w=p.envs.reduce((a,e)=>a+(e.workloads||[]).length,0), v=p.envs.reduce((a,e)=>a+(e.vaults||[]).length,0);
1610
+ return `<div class="vtile" onclick="navigate('project/${esc(p.id)}')">
1611
+ <span class="nm">${esc(p.name)}</span>
1612
+ <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>`;
1613
+ }).join('')}</div>`
1614
+ : `<div class="panel"><div class="empty" style="padding:42px;line-height:1.7">No projects yet.<br>
1615
+ <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>`}`;
1616
+ }
1617
+ function newProject(){ const name=prompt('Project name (e.g. Marketplace)'); if(!name||!name.trim()) return;
1618
+ const p={ id:genId(), name:name.trim(), envs:[] }; PROJ.data.projects.push(p); saveProjects(); navigate('project/'+p.id); }
1619
+ function exportAll(){ downloadJson('akv-projects.json', PROJ.data); }
1620
+ function exportProject(id){ const p=projById(id); if(!p)return; downloadJson('akv-project-'+p.name.replace(/\W+/g,'-').toLowerCase()+'.json',{version:1,projects:[p]}); }
1621
+ function openImport(){
1622
+ const el=$('#projIo');
1623
+ el.innerHTML=`<h4>Import projects</h4>
1624
+ <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>
1625
+ <div class="fld" style="align-items:flex-start"><textarea id="impJson" spellcheck="false" placeholder='{ "version":1, "projects":[ … ] }' style="min-height:180px"></textarea></div>
1626
+ <div class="actions"><button class="btn sm" onclick="doImport()">Import</button>
1627
+ <button class="btn sm ghost" onclick="document.getElementById('projIo').classList.add('hide')">Cancel</button>
1628
+ <span class="hint" id="impMsg"></span></div>`;
1629
+ el.classList.remove('hide');
1630
+ }
1631
+ function normEnv(e){ return { name:String((e&&e.name)||'env'),
1632
+ 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||'')})):[],
1633
+ vaults:Array.isArray(e&&e.vaults)?e.vaults.filter(v=>v&&v.name).map(v=>({name:String(v.name),label:String(v.label||'')})):[] }; }
1634
+ function doImport(){
1635
+ const msg=$('#impMsg'); let d;
1636
+ try{ d=JSON.parse($('#impJson').value); }catch(e){ msg.textContent='Invalid JSON: '+e.message; return; }
1637
+ const incoming = Array.isArray(d)?d:(d&&Array.isArray(d.projects)?d.projects:null);
1638
+ if(!incoming){ msg.textContent='Expected { projects:[…] } or an array of projects'; return; }
1639
+ let n=0;
1640
+ for(const p of incoming){ if(!p||!p.name) continue;
1641
+ const np={ id:p.id||genId(), name:String(p.name), envs:Array.isArray(p.envs)?p.envs.map(normEnv):[] };
1642
+ const i=PROJ.data.projects.findIndex(x=>x.id===np.id);
1643
+ if(i>=0) PROJ.data.projects[i]=np; else PROJ.data.projects.push(np); n++; }
1644
+ saveProjects(); toast(`Imported ${n} project${n!==1?'s':''}`); viewProjects();
1645
+ }
1646
+
1647
+ function viewProject(id){
1648
+ const p=projById(id);
1649
+ if(!p){ $('#route').innerHTML=`<div class="panel"><div class="empty">Project not found. <a href="#/projects">Back to Projects</a></div></div>`; return; }
1650
+ $('#route').innerHTML = `
1651
+ <div class="vhead">
1652
+ <div class="crumb"><a href="#/projects">Projects</a><span class="sep">/</span><span class="here">${esc(p.name)}</span></div>
1653
+ <div class="vmeta">
1654
+ <button class="iconbtn" onclick="renameProject('${esc(id)}')">Rename</button>
1655
+ <button class="iconbtn" onclick="exportProject('${esc(id)}')">Export</button>
1656
+ <button class="iconbtn" onclick="deleteProject('${esc(id)}')">Delete</button></div>
1657
+ </div>
1658
+ <div class="row" style="margin-bottom:14px"><button class="btn sm" onclick="addEnv('${esc(id)}')">+ Add environment</button></div>
1659
+ <div id="projBody"></div>`;
1660
+ renderProjectBody(id);
1661
+ }
1662
+ function renderProjectBody(id){
1663
+ const p=projById(id); if(!p) return;
1664
+ 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; }
1665
+ $('#projBody').innerHTML = p.envs.map((e,ei)=>`
1666
+ <div class="panel" style="margin-bottom:16px;padding:16px 18px">
1667
+ <div class="secthead2" style="margin-bottom:12px">
1668
+ <span class="tag ${envClass(e.name)}" style="font-size:11px">${esc(e.name)}</span>
1669
+ <span class="row"><button class="iconbtn" onclick="renameEnv('${esc(id)}',${ei})">Rename</button>
1670
+ <button class="iconbtn" onclick="deleteEnv('${esc(id)}',${ei})">Delete</button></span>
1671
+ </div>
1672
+ <div class="eyebrow" style="margin-bottom:8px">Workloads</div>
1673
+ ${renderEnvWorkloads(id,ei,e)}
1674
+ <button class="btn ghost sm" style="margin-top:8px" onclick="addWorkload('${esc(id)}',${ei})">+ Link workload</button>
1675
+ <div class="eyebrow" style="margin:18px 0 8px">Key Vaults</div>
1676
+ ${renderEnvVaults(id,ei,e)}
1677
+ <button class="btn ghost sm" style="margin-top:8px" onclick="addVault('${esc(id)}',${ei})">+ Link Key Vault</button>
1678
+ <div class="createp hide" id="picker-${esc(id)}-${ei}"></div>
1679
+ </div>`).join('');
1680
+ }
1681
+ function renderEnvWorkloads(id,ei,e){
1682
+ const w=e.workloads||[];
1683
+ if(!w.length) return '<div class="faint mono" style="font-size:12px">none linked</div>';
1684
+ return `<div class="vtiles">${w.map((x,xi)=>`
1685
+ <div class="vtile" style="cursor:default">
1686
+ <span class="nm">${esc(x.label||x.base||x.name)}</span>
1687
+ <span class="rg mono">${esc(x.cluster)} · ${esc(x.namespace)}</span>
1688
+ <button class="iconbtn" onclick="openWorkload('${esc(id)}',${ei},${xi})">Open →</button>
1689
+ <button class="iconbtn" onclick="rmItem('${esc(id)}',${ei},'workloads',${xi})">✕</button>
1690
+ </div>`).join('')}</div>`;
1691
+ }
1692
+ function renderEnvVaults(id,ei,e){
1693
+ const v=e.vaults||[];
1694
+ if(!v.length) return '<div class="faint mono" style="font-size:12px">none linked</div>';
1695
+ return `<div class="vtiles">${v.map((x,xi)=>{ const known=findVault(x.name);
1696
+ return `<div class="vtile" style="cursor:default">
1697
+ <span class="nm mono">${esc(x.name)}</span>
1698
+ <span class="rg mono">${esc(x.label||'')}</span>
1699
+ ${known?`<button class="iconbtn" onclick="openVault('${esc(known.name)}')">Open →</button>`:'<span class="faint mono" style="font-size:11px">not visible</span>'}
1700
+ <button class="iconbtn" onclick="rmItem('${esc(id)}',${ei},'vaults',${xi})">✕</button>
1701
+ </div>`; }).join('')}</div>`;
1702
+ }
1703
+ function openWorkload(id,ei,xi){ const x=projById(id).envs[ei].workloads[xi]; CL.pendingFilter=x.base||x.name; openCluster(x.cluster); }
1704
+ function rmItem(id,ei,key,xi){ projById(id).envs[ei][key].splice(xi,1); saveProjects(); renderProjectBody(id); }
1705
+ function addEnv(id){ const name=prompt('Environment name (e.g. dev)'); if(!name||!name.trim())return;
1706
+ projById(id).envs.push({name:name.trim(), workloads:[], vaults:[]}); saveProjects(); renderProjectBody(id); }
1707
+ 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); }
1708
+ 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); }
1709
+ 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); }
1710
+ 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'); }
1711
+ function closePicker(id,ei){ const el=document.getElementById('picker-'+id+'-'+ei); if(el){ el.classList.add('hide'); el.innerHTML=''; } }
1712
+
1713
+ function addWorkload(id,ei){
1714
+ const el=document.getElementById('picker-'+id+'-'+ei);
1715
+ const clusters=CL.all||[];
1716
+ 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; }
1717
+ el.innerHTML = `<h4>Link a workload (pod)</h4>
1718
+ <div class="fld"><label>Cluster</label>
1719
+ <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>
1720
+ <input class="search" id="pkQ" placeholder="search pods by name or namespace…" style="flex:1;min-width:0"></div>
1721
+ <div id="pkResults" class="itemwrap" style="max-height:300px;overflow:auto"></div>
1722
+ <div class="actions"><button class="btn sm ghost" onclick="closePicker('${esc(id)}',${ei})">Close</button><span class="hint" id="pkMsg"></span></div>`;
1723
+ el.classList.remove('hide');
1724
+ const run=()=>pkSearchPods(id,ei); $('#pkC').onchange=run; $('#pkQ').oninput=run; run();
1725
+ }
1726
+ async function pkSearchPods(id,ei){
1727
+ const cluster=$('#pkC').value, q=$('#pkQ').value.toLowerCase(), box=$('#pkResults');
1728
+ let pods=CL.podsByCluster[cluster];
1729
+ if(!pods){ box.innerHTML='<div class="empty"><span class="spin"></span> loading pods…</div>';
1730
+ const r=await api(`/api/aks/${cluster}/pods`); if(!r.ok){ box.innerHTML=clErr(r); return; } pods=r.data||[]; CL.podsByCluster[cluster]=pods; }
1731
+ const list=pods.filter(p=>!q||p.name.toLowerCase().includes(q)||(p.namespace||'').toLowerCase().includes(q)).slice(0,50);
1732
+ if(!list.length){ box.innerHTML='<div class="faint mono" style="font-size:12px;padding:8px">No pods match.</div>'; return; }
1733
+ box.innerHTML = list.map(p=>`<div class="kvitem"><div class="kvline">
1734
+ <span class="nm" title="${esc(p.name)}">${esc(p.name)}</span>
1735
+ <span class="meta"><span class="faint mono" style="font-size:11px">${esc(p.namespace)}</span>
1736
+ <button class="iconbtn" onclick="pickWorkload('${esc(id)}',${ei},'${esc(cluster)}','${esc(p.namespace)}','${esc(p.name)}')">Link</button></span>
1737
+ </div></div>`).join('');
1738
+ }
1739
+ function pickWorkload(id,ei,cluster,ns,name){
1740
+ const e=projById(id).envs[ei]; e.workloads=e.workloads||[];
1741
+ const base=podBase(name);
1742
+ 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'); }
1743
+ else toast('Already linked');
1744
+ renderProjectBody(id);
1745
+ }
1746
+ function addVault(id,ei){
1747
+ const el=document.getElementById('picker-'+id+'-'+ei);
1748
+ el.innerHTML = `<h4>Link a Key Vault</h4>
1749
+ <div class="fld"><label>Search</label><input class="search" id="pkVQ" placeholder="search vaults by name…" style="flex:1;min-width:0"></div>
1750
+ <div id="pkVResults" class="itemwrap" style="max-height:300px;overflow:auto"></div>
1751
+ <div class="actions"><button class="btn sm ghost" onclick="closePicker('${esc(id)}',${ei})">Close</button></div>`;
1752
+ el.classList.remove('hide');
1753
+ $('#pkVQ').oninput=()=>pkSearchVaults(id,ei); pkSearchVaults(id,ei);
1754
+ }
1755
+ function pkSearchVaults(id,ei){
1756
+ const q=$('#pkVQ').value.toLowerCase(), box=$('#pkVResults');
1757
+ const list=(STATE.vaults||[]).filter(v=>!q||v.name.toLowerCase().includes(q)||(v.service||'').toLowerCase().includes(q)).slice(0,50);
1758
+ if(!list.length){ box.innerHTML='<div class="faint mono" style="font-size:12px;padding:8px">No vaults match.</div>'; return; }
1759
+ box.innerHTML = list.map(v=>`<div class="kvitem"><div class="kvline">
1760
+ <span class="nm mono" title="${esc(v.name)}">${esc(v.name)}</span>
1761
+ <span class="meta"><span class="tag ${envClass(v.env)}">${esc(v.env)}</span>
1762
+ <button class="iconbtn" onclick="pickVault('${esc(id)}',${ei},'${esc(v.name)}')">Link</button></span>
1763
+ </div></div>`).join('');
1764
+ }
1765
+ function pickVault(id,ei,name){
1766
+ const e=projById(id).envs[ei]; e.vaults=e.vaults||[];
1767
+ if(!e.vaults.some(x=>x.name===name)){ e.vaults.push({name,label:''}); saveProjects(); toast('Key Vault linked'); }
1768
+ else toast('Already linked');
1769
+ renderProjectBody(id);
1770
+ }
1771
+
1296
1772
  // ---------- which Key Vaults does a pod use ----------
1297
1773
  async function openPodVaults(ns,pod){
1298
1774
  CL.logPod=null; CL.termPod=null; teardownTerm();
@@ -1316,8 +1792,13 @@ async function openPodVaults(ns,pod){
1316
1792
  <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
1793
  : '';
1318
1794
  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>
1795
+ const restricted = r.data.restricted;
1796
+ out.innerHTML=`<div class="empty">${restricted
1797
+ ? `🔒 Your identity can't read <b>SecretStores</b> on this cluster.`
1798
+ : `No Key Vault could be resolved for this pod.`}
1799
+ <div class="hint" style="margin-top:8px">${restricted
1800
+ ? `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.`
1801
+ : `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
1802
  ${spcNote}${esoNote}</div>`;
1322
1803
  return;
1323
1804
  }
@@ -1394,6 +1875,7 @@ function cmdkActions(){
1394
1875
  {type:'view', label:'Key Vaults', hint:'go', act:()=>navigate('vaults')},
1395
1876
  {type:'view', label:'Expiry', hint:'go', act:()=>navigate('expiry')},
1396
1877
  {type:'view', label:'Clusters', hint:'go', act:()=>navigate('clusters')},
1878
+ {type:'view', label:'Projects', hint:'go', act:()=>navigate('projects')},
1397
1879
  {type:'view', label:'Account & subscriptions', hint:'go', act:()=>navigate('settings')},
1398
1880
  {type:'action', label:'Refresh inventory', hint:'run', act:()=>{ doRefresh(); }},
1399
1881
  ];
@@ -1437,7 +1919,15 @@ function moveCur(d){ if(!CMDK.items.length)return; CMDK.cur=(CMDK.cur+d+CMDK.ite
1437
1919
  const el=$('#cmdkList').querySelector('.cmdk-it.cur'); if(el) el.scrollIntoView({block:'nearest'}); }
1438
1920
  function runCmdk(i){ const it=CMDK.items[i]; if(!it)return; closeCmdk(); it.act(); }
1439
1921
 
1440
- function doRefresh(){ toast('Refreshing…'); loadSubs().then(()=>loadVaults(true)).then(()=>{ EXP.scanned=false; EXP.polling=false; refreshCurrent(); kickExpiryScan(); }); }
1922
+ function doRefresh(){
1923
+ toast('Refreshing…');
1924
+ CL.podsByCluster={}; CL.cmByCluster={}; CL.vaultMapByCluster={}; CL.ingressMapByCluster={}; // drop cached cluster data
1925
+ loadSubs().then(()=>loadVaults(true)).then(()=>{
1926
+ EXP.scanned=false; EXP.polling=false; kickExpiryScan();
1927
+ if(STATE.route==='cluster'){ loadClustersCache(true); loadClusterContent(true); }
1928
+ else { loadClustersCache(true).then(()=>refreshCurrent()); } // viewClusters() then renders fresh CL.all
1929
+ });
1930
+ }
1441
1931
 
1442
1932
  // ---------- events ----------
1443
1933
  document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>navigate(b.dataset.view));