akv-manager 0.3.0 → 0.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akv-manager",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Local web console to browse and manage Azure Key Vaults — secrets, keys, certificates, expiry — plus read-only AKS monitoring (pods, logs, ingress, key-vault tracing), using your own az login. No credentials are stored.",
5
5
  "type": "commonjs",
6
6
  "main": "server.js",
package/public/index.html CHANGED
@@ -123,6 +123,24 @@
123
123
  </div>
124
124
  </div>
125
125
 
126
+ <div class="kvmeta-overlay" id="kvMetaOverlay" aria-hidden="true"></div>
127
+ <div class="kvmeta-drawer" id="kvMetaDrawer" role="dialog" aria-modal="true" aria-labelledby="kvMetaTitle" aria-hidden="true">
128
+ <header>
129
+ <div><div class="eyebrow">Key Vault details</div><div class="dt" id="kvMetaTitle"></div></div>
130
+ <button class="iconbtn" id="kvMetaClose" aria-label="Close Key Vault details">✕</button>
131
+ </header>
132
+ <div class="kvmeta-body" id="kvMetaBody"></div>
133
+ </div>
134
+
135
+ <div class="kvmeta-overlay" id="podDetailOverlay" aria-hidden="true"></div>
136
+ <div class="kvmeta-drawer poddetail-drawer" id="podDetailDrawer" role="dialog" aria-modal="true" aria-labelledby="podDetailTitle" aria-hidden="true">
137
+ <header>
138
+ <div><div class="eyebrow">Pod details</div><div class="dt" id="podDetailTitle"></div></div>
139
+ <button class="iconbtn" id="podDetailClose" aria-label="Close pod details">✕</button>
140
+ </header>
141
+ <div class="kvmeta-body" id="podDetailBody"></div>
142
+ </div>
143
+
126
144
  <div class="toast" id="toast"></div>
127
145
 
128
146
  <script>
@@ -136,7 +154,8 @@ const extractFirewallIp = (txt) => {
136
154
  let STATE = { vaults: [], subs: [], accounts: { users: [], activeSubscription: null }, account: null,
137
155
  vaultWarnings: [], vaultsAggregate: false,
138
156
  env: 'all', q: '', sort: 'name', dir: 1,
139
- kind: 'secrets', drawerVault: null, route: 'dashboard', facets: {} };
157
+ kind: 'secrets', drawerVault: null, route: 'dashboard', facets: { roles: 'kv_app' },
158
+ tagKey: '', tagValue: '' };
140
159
  let CFG = { envRules: null, title: null, version: null, protectedTags: [] };
141
160
  function tagMatch(p,k){ p=String(p).toLowerCase(); k=String(k).toLowerCase(); return p.endsWith('*')?k.startsWith(p.slice(0,-1)):k===p; }
142
161
  function isProtectedTag(k){ return (CFG.protectedTags||[]).some(p=>tagMatch(p,k)); }
@@ -164,16 +183,17 @@ const findVault = n => STATE.vaults.find(v=>v.name.toLowerCase()===String(n||'')
164
183
  function prettyEnv(s){
165
184
  const x=String(s).toLowerCase().trim();
166
185
  // exact short codes commonly used in `env` tags
167
- const code={dv:'Dev',dev:'Dev',d:'Dev',qa:'QA',ts:'Test',tst:'Test',tes:'Test',t:'Test',
186
+ const code={dv:'DV',dev:'DV',d:'DV',in:'IN',int:'IN',qua:'QA',qa:'QA',ts:'Test',tst:'Test',tes:'Test',t:'Test',
168
187
  st:'Stage',stg:'Stage',stag:'Stage',uat:'Stage',pp:'Stage',pr:'Prod',prd:'Prod',prod:'Prod',p:'Prod',
169
188
  sb:'Sandbox',sbx:'Sandbox',sand:'Sandbox'}[x];
170
189
  if(code) return code;
171
190
  if(/prod|prd|\blive\b/.test(x)) return 'Prod';
172
191
  if(/stag|stg|\buat\b|preprod|pre-prod|accept/.test(x)) return 'Stage';
173
- if(/\bqa\b|quality/.test(x)) return 'QA';
192
+ if(/\b(?:qa|qua)\b|quality/.test(x)) return 'QA';
174
193
  if(/sandbox|sbx|\bdemo\b/.test(x)) return 'Sandbox';
175
- if(/dev|develop/.test(x)) return 'Dev';
176
- if(/test|tst|\bint\b|integration|nonprod/.test(x)) return 'Test';
194
+ if(/dev|develop/.test(x)) return 'DV';
195
+ if(/\b(?:in|int)\b|integration/.test(x)) return 'IN';
196
+ if(/test|tst|nonprod/.test(x)) return 'Test';
177
197
  return s.charAt(0).toUpperCase()+s.slice(1);
178
198
  }
179
199
  // Prefer the vault's real `env` tag; fall back to the subscription-name heuristic.
@@ -190,16 +210,17 @@ function envLabel(name){
190
210
  const n = name || '';
191
211
  if(CFG.envRules){ for(const [pat,label] of CFG.envRules){ try{ if(new RegExp(pat,'i').test(n)) return label; }catch{} } }
192
212
  // Only label a subscription when a known environment keyword is present; otherwise '—'.
193
- return /(prod|prd|\blive\b|stag|stg|\buat\b|preprod|pre-prod|accept|\bqa\b|sandbox|sbx|\bdemo\b|\bdev\b|develop|test|tst|\bint\b|integration|nonprod)/i.test(n)
213
+ return /(prod|prd|\blive\b|stag|stg|\buat\b|preprod|pre-prod|accept|\b(?:qa|qua)\b|sandbox|sbx|\bdemo\b|\b(?:dv|dev)\b|develop|test|tst|\b(?:in|int)\b|integration|nonprod)/i.test(n)
194
214
  ? prettyEnv(n) : '—';
195
215
  }
196
216
  function envClass(env){
197
217
  const s = (env||'').toLowerCase();
198
218
  if(/prod|live/.test(s)) return 'e-prod';
199
219
  if(/stag|uat|preprod/.test(s)) return 'e-stage';
200
- if(/qa/.test(s)) return 'e-qa';
220
+ if(/qa|qua/.test(s)) return 'e-qa';
201
221
  if(/sandbox|demo/.test(s)) return 'e-sandbox';
202
- if(/dev/.test(s)) return 'e-dev';
222
+ if(/dev|^dv$/.test(s)) return 'e-dev';
223
+ if(/^(in|int)$|integration/.test(s)) return 'e-test';
203
224
  if(/test|int/.test(s)) return 'e-test';
204
225
  return 'e-other';
205
226
  }
@@ -295,6 +316,8 @@ function router(){
295
316
  const parts = parseHash();
296
317
  const route = parts[0]||'dashboard';
297
318
  STATE.route = route;
319
+ if(route!=='vaults') closeVaultDetails();
320
+ if(route!=='cluster') closePodDetails();
298
321
  if(route!=='cluster') teardownTerm(); // close any open pod shell when leaving Clusters
299
322
  if(route!=='vault') mountedVault=null;
300
323
  if(route==='vaults'){ setHead('vaults'); viewVaults(); }
@@ -381,26 +404,36 @@ function expCounts(){
381
404
  }
382
405
 
383
406
  // ---------- VAULTS (inventory) ----------
384
- const FACET_PREF = ['solution','cost-center','contact-main','contact-ccoe','owner','roles'];
407
+ const FACET_PREF = ['roles','solution'];
408
+ const FACET_EXCLUDE = new Set(['cost-center','contact-main','contact-ccoe','owner','location']);
385
409
  const isEnvKey = k => /^(env|environment)$/i.test(k);
386
410
  // Pick up to 4 tag dimensions to expose as dropdown facets: preferred keys first
387
411
  // (case-insensitive match to the real tag casing), then the most common remaining keys.
388
412
  function facetKeys(){
389
413
  const freq = {};
390
- STATE.vaults.forEach(v=>Object.keys(v.tags||{}).forEach(k=>{ if(!isEnvKey(k)) freq[k]=(freq[k]||0)+1; }));
414
+ STATE.vaults.forEach(v=>Object.keys(v.tags||{}).forEach(k=>{
415
+ if(!isEnvKey(k) && !FACET_EXCLUDE.has(k.toLowerCase())) freq[k]=(freq[k]||0)+1;
416
+ }));
391
417
  const real = Object.keys(freq);
392
418
  const matchPref = p => real.find(k=>k.toLowerCase()===p);
393
419
  const picked = [];
394
- for(const p of FACET_PREF){ const k=matchPref(p); if(k && !picked.includes(k)) picked.push(k); }
395
- for(const [k] of Object.entries(freq).sort((a,b)=>b[1]-a[1])){ if(picked.length>=4) break; if(!picked.includes(k)) picked.push(k); }
420
+ const hasPicked = k => picked.some(x=>x.toLowerCase()===k.toLowerCase());
421
+ for(const p of FACET_PREF){ if(matchPref(p) && !hasPicked(p)) picked.push(p); }
422
+ for(const [k] of Object.entries(freq).sort((a,b)=>b[1]-a[1])){ if(picked.length>=4) break; if(!hasPicked(k)) picked.push(k); }
396
423
  return picked.slice(0,4);
397
424
  }
398
- function tagVal(v,k){ const t=v.tags||{}; return t[k]!=null?String(t[k]):''; }
425
+ function tagVal(v,k){
426
+ const t=v.tags||{};
427
+ const realKey=Object.keys(t).find(x=>x.toLowerCase()===String(k).toLowerCase());
428
+ return realKey!=null?String(t[realKey]):'';
429
+ }
430
+ function hasTag(v,k){ return Object.keys(v.tags||{}).some(x=>x.toLowerCase()===String(k).toLowerCase()); }
399
431
  function filteredVaults(){
400
432
  const q = STATE.q.toLowerCase();
401
433
  let list = STATE.vaults.filter(v=>{
402
434
  if(STATE.env!=='all' && v.env!==STATE.env) return false;
403
435
  for(const [k,val] of Object.entries(STATE.facets)){ if(val && tagVal(v,k)!==val) return false; }
436
+ if(STATE.tagKey && (!hasTag(v,STATE.tagKey) || (STATE.tagValue && tagVal(v,STATE.tagKey)!==STATE.tagValue))) return false;
404
437
  if(q){
405
438
  const hay = [v.name, v.resourceGroup, v.service, ...Object.values(v.tags||{})].join(' ').toLowerCase();
406
439
  if(!hay.includes(q)) return false;
@@ -427,11 +460,23 @@ function viewVaults(){
427
460
  const vals=[...new Set(STATE.vaults.map(v=>tagVal(v,k)).filter(Boolean))].sort();
428
461
  if(!vals.length) return '';
429
462
  const sel=STATE.facets[k]||'';
430
- return `<div class="facet"><label>${esc(k)}</label>
463
+ const label=k.toLowerCase()==='roles'?'Role':k;
464
+ return `<div class="facet"><label>${esc(label)}</label>
431
465
  <select class="facetsel ${sel?'on':''}" onchange="setFacet('${esc(k)}',this.value)">
432
466
  <option value="">any</option>${vals.map(x=>`<option ${x===sel?'selected':''} value="${esc(x)}">${esc(x)}</option>`).join('')}</select></div>`;
433
467
  }).join('');
434
- const anyFacet = Object.values(STATE.facets).some(Boolean);
468
+ const tagKeyMap=new Map();
469
+ STATE.vaults.forEach(v=>Object.keys(v.tags||{}).forEach(k=>{ if(!tagKeyMap.has(k.toLowerCase())) tagKeyMap.set(k.toLowerCase(),k); }));
470
+ const tagKeys=[...tagKeyMap.values()].sort((a,b)=>a.localeCompare(b));
471
+ const tagVals=STATE.tagKey?[...new Set(STATE.vaults.map(v=>tagVal(v,STATE.tagKey)).filter(Boolean))].sort((a,b)=>a.localeCompare(b)):[];
472
+ const tagFilter=tagKeys.length?`<div class="facet"><label>Tag</label>
473
+ <select class="facetsel ${STATE.tagKey?'on':''}" onchange="setTagKey(this.value)">
474
+ <option value="">any tag</option>${tagKeys.map(k=>`<option ${k.toLowerCase()===STATE.tagKey.toLowerCase()?'selected':''} value="${esc(k)}">${esc(k)}</option>`).join('')}</select></div>
475
+ ${STATE.tagKey?`<div class="facet"><label>Value</label>
476
+ <select class="facetsel ${STATE.tagValue?'on':''}" onchange="setTagValue(this.value)">
477
+ <option value="">any value</option>${tagVals.map(x=>`<option ${x===STATE.tagValue?'selected':''} value="${esc(x)}">${esc(x)}</option>`).join('')}</select></div>`:''}`:'';
478
+ const filterBar=facets+tagFilter;
479
+ const anyFacet = Object.values(STATE.facets).some(Boolean) || !!STATE.tagKey;
435
480
  const agg = STATE.vaultsAggregate;
436
481
  const warns = STATE.vaultWarnings || [];
437
482
  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">
@@ -439,7 +484,7 @@ function viewVaults(){
439
484
  $('#route').innerHTML = `
440
485
  ${warnBar}
441
486
  <div class="chips">${chips}</div>
442
- ${facets?`<div class="facets">${facets}${anyFacet?`<button class="facetclear" onclick="clearFacets()">clear filters</button>`:''}</div>`:''}
487
+ ${filterBar?`<div class="facets">${filterBar}${anyFacet?`<button class="facetclear" onclick="clearFacets()">clear filters</button>`:''}</div>`:''}
443
488
  <div class="panel"><div class="scroll"><table>
444
489
  <thead><tr>
445
490
  <th data-sort="pin" style="width:30px"></th>
@@ -454,22 +499,73 @@ function viewVaults(){
454
499
  }
455
500
  function setEnv(e){ STATE.env=e; viewVaults(); }
456
501
  function setFacet(k,val){ if(val) STATE.facets[k]=val; else delete STATE.facets[k]; viewVaults(); }
457
- function clearFacets(){ STATE.facets={}; viewVaults(); }
502
+ function setTagKey(k){ STATE.tagKey=k; STATE.tagValue=''; viewVaults(); }
503
+ function setTagValue(v){ STATE.tagValue=v; viewVaults(); }
504
+ function clearFacets(){ STATE.facets={}; STATE.tagKey=''; STATE.tagValue=''; viewVaults(); }
505
+ let VMD={name:'',lastFocus:null};
506
+ const metaBool=v=>v==null?'—':v?'Enabled':'Disabled';
507
+ function renderVaultDetails(d,{loading=false,error=''}={}){
508
+ const rbac=d.rbacEnabled!=null?d.rbacEnabled:d.enableRbac==='true'?true:d.enableRbac==='false'?false:null;
509
+ const resourceId=d.id||((d.subscriptionId&&d.resourceGroup&&d.name)
510
+ ?`/subscriptions/${d.subscriptionId}/resourceGroups/${d.resourceGroup}/providers/Microsoft.KeyVault/vaults/${d.name}`:'');
511
+ const rows=[
512
+ ['Name',d.name],['Environment',d.env],['Service',d.service],
513
+ ['Subscription',subName(d.subscriptionId)],['Subscription ID',d.subscriptionId],
514
+ ['Resource group',d.resourceGroup],['Resource ID',resourceId],['Location',d.location],
515
+ ['SKU',d.sku],['Authorization',rbac==null?'—':rbac?'Azure RBAC':'Access policies'],
516
+ ['Vault endpoint',d.uri],['Tenant ID',d.tenantId],['Account',d.account],
517
+ ['Provisioning state',d.provisioningState],
518
+ ['Soft delete',d.softDeleteEnabled==null?null:`${metaBool(d.softDeleteEnabled)}${d.softDeleteRetentionDays!=null?` · ${d.softDeleteRetentionDays} days`:''}`],
519
+ ['Purge protection',d.purgeProtectionEnabled==null?null:metaBool(d.purgeProtectionEnabled)],
520
+ ['Public network',d.publicNetworkAccess],
521
+ ['Firewall',d.networkDefaultAction?`Default ${d.networkDefaultAction}${d.networkBypass?` · bypass ${d.networkBypass}`:''}`:null],
522
+ ['Network rules',d.ipRuleCount!=null?`${d.ipRuleCount} IP · ${d.vnetRuleCount||0} VNet · ${d.privateEndpointCount||0} private endpoint`:null],
523
+ ].filter(([,v])=>v!==null&&v!==undefined&&v!=='');
524
+ const tags=Object.entries(d.tags||{}).sort(([a],[b])=>a.localeCompare(b));
525
+ $('#kvMetaTitle').textContent=d.name||'Key Vault';
526
+ $('#kvMetaBody').innerHTML=`
527
+ <div class="kvmeta-actions"><button class="btn sm" onclick="closeVaultDetails();openVault('${esc(d.name)}')">Open vault</button>
528
+ ${loading?'<span class="faint mono"><span class="spin" style="width:12px;height:12px;border-width:1px"></span> loading extended metadata…</span>':''}</div>
529
+ ${error?`<div class="fwalert"><div><b>Extended metadata unavailable.</b><div class="hint">${esc(error)}</div></div></div>`:''}
530
+ <section class="kvmeta-section"><div class="kvmeta-heading">Metadata</div><div class="kvmeta-list">
531
+ ${rows.map(([k,v])=>`<div class="kvmeta-row"><div class="kvmeta-key">${esc(k)}</div><div class="kvmeta-value mono">${esc(v)}</div></div>`).join('')}
532
+ </div></section>
533
+ <section class="kvmeta-section"><div class="kvmeta-heading">Tags · ${tags.length}</div>
534
+ ${tags.length?`<div class="kvmeta-tags">${tags.map(([k,v])=>`<div class="kvmeta-tag"><b>${esc(k)}</b><span>${esc(v)}</span></div>`).join('')}</div>`:'<div class="empty" style="padding:24px">No tags</div>'}
535
+ </section>`;
536
+ }
537
+ async function openVaultDetails(name){
538
+ const v=findVault(name); if(!v)return;
539
+ VMD={name,lastFocus:document.activeElement};
540
+ $('#kvMetaOverlay').classList.add('show'); $('#kvMetaOverlay').setAttribute('aria-hidden','false');
541
+ $('#kvMetaDrawer').classList.add('open'); $('#kvMetaDrawer').setAttribute('aria-hidden','false');
542
+ renderVaultDetails(v,{loading:true}); setTimeout(()=>$('#kvMetaClose').focus(),0);
543
+ let r;
544
+ try{ r=await api(`/api/vaults/${encodeURIComponent(name)}/metadata`); }
545
+ catch(e){ r={ok:false,error:e.message||'Request failed'}; }
546
+ if(VMD.name!==name)return;
547
+ if(r.ok) renderVaultDetails({...v,...r.data,tags:r.data.tags||v.tags});
548
+ else renderVaultDetails(v,{error:r.error||'Request failed'});
549
+ }
550
+ function closeVaultDetails(){
551
+ if(!VMD.name)return;
552
+ $('#kvMetaOverlay').classList.remove('show'); $('#kvMetaOverlay').setAttribute('aria-hidden','true');
553
+ $('#kvMetaDrawer').classList.remove('open'); $('#kvMetaDrawer').setAttribute('aria-hidden','true');
554
+ const focus=VMD.lastFocus; VMD={name:'',lastFocus:null}; if(focus&&focus.focus)focus.focus();
555
+ }
458
556
  function renderVaultRows(){
459
557
  const list = filteredVaults();
460
558
  const agg = STATE.vaultsAggregate;
461
559
  $('#vaultCount').textContent = `${list.length} vault${list.length!==1?'s':''} shown`;
462
560
  if(!list.length){ $('#vaultsBody').innerHTML=`<tr><td colspan="${agg?10:9}" class="empty">No vaults match your filter.</td></tr>`; return; }
463
561
  $('#vaultsBody').innerHTML = list.map(v=>{
464
- const tags = Object.entries(v.tags||{}).filter(([k])=>!/^env$/i.test(k)).slice(0,2)
465
- .map(([k,val])=>`<span class="tagchip"><b>${esc(k)}</b> ${esc(val)}</span>`).join('');
466
- const more = Object.keys(v.tags||{}).filter(k=>!/^env$/i.test(k)).length-2;
562
+ const tagCount=Object.keys(v.tags||{}).length;
467
563
  return `<tr>
468
564
  <td><button class="star ${isPinned(v.name)?'on':''}" onclick="togglePin('${esc(v.name)}')" title="pin vault">★</button></td>
469
565
  <td class="mono"><b>${esc(v.name)}</b></td>
470
566
  <td><span class="tag ${envClass(v.env)}">${esc(v.env)}</span></td>
471
567
  <td class="muted">${esc(v.service||'')}</td>
472
- <td><div class="tagchips">${tags}${more>0?`<span class="tagchip">+${more}</span>`:''}</div></td>
568
+ <td><button class="iconbtn tagdetails" onclick="openVaultDetails('${esc(v.name)}')">Details <span class="n">${tagCount}</span></button></td>
473
569
  <td class="mono muted">${esc(v.resourceGroup)}</td>
474
570
  <td class="mono">${esc(v.sku||'—')}</td>
475
571
  <td>${v.enableRbac==='true'?'<span class="pill rbac">RBAC</span>':'<span class="pill policy">Policy</span>'}</td>
@@ -1309,6 +1405,7 @@ function renderClusterRows(){
1309
1405
  function openCluster(name){ navigate('cluster/'+encodeURIComponent(name)); }
1310
1406
 
1311
1407
  function viewCluster(name){
1408
+ if(PDD.cluster&&PDD.cluster!==name) closePodDetails();
1312
1409
  CL.name = name;
1313
1410
  if(mountedCluster!==name || !document.getElementById('clBody')){
1314
1411
  mountedCluster=name; CL.ns=''; CL.items=[]; CL.namespaces=[]; CL.logPod=null; CL.cluster=null; CL.kind='pods';
@@ -1343,6 +1440,7 @@ async function enrichClusterMeta(name){
1343
1440
  `<span class="mono">${esc(c.resourceGroup||'')}</span>${c.private?'<span class="sep">·</span><span class="pill policy">private · VPN</span>':''}`;
1344
1441
  }
1345
1442
  function loadClusterContent(force){
1443
+ closePodDetails();
1346
1444
  CL.logPod=null; CL.termPod=null; CL.filter=''; teardownTerm();
1347
1445
  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
1348
1446
  if(CL.kind==='pods') loadPods(); else loadConfigMaps();
@@ -1363,18 +1461,16 @@ function podTier(nm){ nm=(nm||'').toLowerCase(); if(TIER_RE.cache.test(nm))retur
1363
1461
  async function loadPods(){
1364
1462
  const name=CL.name;
1365
1463
  CL.vaultMap = CL.vaultMapByCluster[name] || {};
1366
- if(CL.podsByCluster[name]){ CL.items=CL.podsByCluster[name]; renderPodsShell(); ensurePodVaultMap(name); return; } // cached
1464
+ if(CL.podsByCluster[name]){ CL.items=CL.podsByCluster[name]; renderPodsShell(); return; } // cached
1367
1465
  $('#clBody').innerHTML='<div class="empty"><span class="spin"></span></div>';
1368
1466
  const r = await api(`/api/aks/${name}/pods`);
1369
1467
  if(STATE.route!=='cluster'||CL.name!==name||CL.kind!=='pods'||CL.logPod||CL.termPod) return;
1370
1468
  if(!r.ok){ $('#clBody').innerHTML=clErr(r); return; }
1371
1469
  CL.items=r.data||[]; CL.podsByCluster[name]=CL.items;
1372
1470
  renderPodsShell();
1373
- ensurePodVaultMap(name);
1374
- ensurePodIngressMap(name);
1375
1471
  }
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.
1472
+ // Resolve each namespace's exposed Services → Ingress hosts once per cluster for the
1473
+ // pod details drawer. Pods are matched to a Service by label⊇selector.
1378
1474
  async function ensurePodIngressMap(name){
1379
1475
  if(CL.ingressMapByCluster[name]){ CL.ingressMap=CL.ingressMapByCluster[name]; return; }
1380
1476
  const pods = CL.podsByCluster[name] || CL.items || [];
@@ -1384,7 +1480,6 @@ async function ensurePodIngressMap(name){
1384
1480
  CL.ingressMapByCluster[name] = (r.ok && r.data && r.data.byNamespace) ? r.data.byNamespace : {};
1385
1481
  if(CL.name!==name) return;
1386
1482
  CL.ingressMap = CL.ingressMapByCluster[name];
1387
- if(STATE.route==='cluster' && CL.kind==='pods' && !CL.logPod && !CL.termPod) renderPodRows();
1388
1483
  }
1389
1484
  // Match a pod to the Ingress(es) routing to it: Services whose selector ⊆ the pod's
1390
1485
  // labels, then Ingresses whose backend references those Services.
@@ -1399,31 +1494,18 @@ function podIngresses(p){
1399
1494
  const names = new Set(podMatchedSvcNames(p));
1400
1495
  return (nsm.ingresses||[]).filter(i=>(i.backendServices||[]).some(s=>names.has(s)));
1401
1496
  }
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:{}};
1497
+ // Ingress details shown inside the pod drawer: routing, TLS/cert-manager, class,
1498
+ // forced HTTPS, CORS, and body-size for every ingress fronting the pod.
1499
+ function podIngressDetails(p){
1413
1500
  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; }
1501
+ if(!ings.length) return `<div class="empty" style="padding:24px">No Ingress routes to this pod's service.</div>`;
1420
1502
  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=>{
1503
+ return ings.map(i=>{
1422
1504
  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
1505
  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
1506
  const cm=i.certManager?[i.certManager.issuer,i.certManager.challenge,i.certManager.dnsProvider].filter(Boolean).map(esc).join(' · '):'';
1425
1507
  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">
1508
+ return `<div class="panel poddetail-ingress">
1427
1509
  <div class="row" style="justify-content:space-between;margin-bottom:10px;gap:10px">
1428
1510
  <b class="mono">${esc(i.name)}</b>
1429
1511
  <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>
@@ -1439,8 +1521,8 @@ function openPodIngress(ns,pod){
1439
1521
  </div>`;
1440
1522
  }).join('');
1441
1523
  }
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.
1524
+ // Resolve each namespace's Key Vault(s) once per cluster for the pod details drawer.
1525
+ // Best-effort: if the secret-source CRDs aren't readable the drawer explains the RBAC gap.
1444
1526
  async function ensurePodVaultMap(name){
1445
1527
  if(CL.vaultMapByCluster[name]){ CL.vaultMap=CL.vaultMapByCluster[name]; CL.vaultRestricted=CL.vaultRestrictedByCluster[name]||{}; return; }
1446
1528
  const pods = CL.podsByCluster[name] || CL.items || [];
@@ -1452,20 +1534,19 @@ async function ensurePodVaultMap(name){
1452
1534
  if(CL.name!==name) return;
1453
1535
  CL.vaultMap = CL.vaultMapByCluster[name];
1454
1536
  CL.vaultRestricted = CL.vaultRestrictedByCluster[name];
1455
- if(STATE.route==='cluster' && CL.kind==='pods' && !CL.logPod && !CL.termPod) renderPodRows();
1456
1537
  }
1457
1538
  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.
1539
+ // Key Vault controls for the pod drawer. Clicking a known vault jumps straight to its
1540
+ // secrets; otherwise it opens the precise per-pod trace panel.
1460
1541
  // When the namespace's External Secrets CRDs aren't readable (RBAC), say so plainly.
1461
1542
  function kvCell(p){
1462
1543
  const vs = podVaults(p);
1463
1544
  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>`;
1545
+ return `<button class="kvchip${known?'':' unknown'}" title="${known?'Open '+esc(name)+' secrets':esc(name)+' — not in your visible inventory (click to trace)'}" onclick="closePodDetails();${known?`openVault('${esc(name)}')`:`openPodVaults('${esc(p.namespace)}','${esc(p.name)}')`}">🔑 ${esc(name)}</button>`;
1465
1546
  }).join('')}</div>`;
1466
1547
  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>`;
1548
+ return `<button class="kvchip unknown" title="Your identity can't read SecretStores on this cluster (Kubernetes RBAC). Click to see details." onclick="closePodDetails();openPodVaults('${esc(p.namespace)}','${esc(p.name)}')">🔒 restricted</button>`;
1549
+ return `<button class="iconbtn" title="trace this pod's Key Vault via CSI / External Secrets / env" onclick="closePodDetails();openPodVaults('${esc(p.namespace)}','${esc(p.name)}')">Trace Key Vault</button>`;
1469
1550
  }
1470
1551
  function renderPodsShell(){
1471
1552
  if(CL.pendingFilter){ CL.filter=CL.pendingFilter; CL.pendingFilter=null; } // from a project "Open →"
@@ -1492,21 +1573,16 @@ function renderPodRows(){
1492
1573
  if(!CL.items.length){ $('#clList').innerHTML=`<div class="empty">No pods visible on this cluster.</div>`; return; }
1493
1574
  if(!list.length){ $('#clList').innerHTML=`<div class="empty">No pods match your filter.</div>`; return; }
1494
1575
  $('#clList').innerHTML = `<div class="panel"><div class="scroll"><table>
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>
1576
+ <thead><tr><th>Pod</th><th>Namespace</th><th class="pod-build">Build</th><th>Status</th><th>Age</th><th class="right">Actions</th></tr></thead>
1496
1577
  <tbody>${list.map(p=>`<tr>
1497
1578
  <td class="mono"><b><span class="ell" title="${esc(p.name)}">${esc(p.name)}</span></b></td>
1498
1579
  <td class="mono muted"><span class="ell ns" title="${esc(p.namespace||'')}">${esc(p.namespace||'')}</span></td>
1499
- <td class="mono"><span class="ell" style="max-width:140px" title="${esc(p.build||'')}">${esc(p.build||'—')}</span></td>
1500
- <td class="mono">${esc(p.ready||'')}</td>
1580
+ <td class="mono pod-build"><span class="ell" title="${esc(p.build||'')}">${esc(p.build||'—')}</span></td>
1501
1581
  <td><span class="phase ${esc(p.phase||'')}">${esc(p.phase||'?')}</span></td>
1502
- <td class="right mono ${p.restarts>0?'':'muted'}">${p.restarts}</td>
1503
1582
  <td class="mono muted">${age(p.created)}</td>
1504
- <td>${ingCell(p)}</td>
1505
- <td>${kvCell(p)}</td>
1506
1583
  <td class="right" style="white-space:nowrap"><button class="iconbtn" onclick="openPodDetail('${esc(p.namespace)}','${esc(p.name)}')">Details</button>
1507
1584
  <button class="iconbtn" onclick="showLogs('${esc(p.namespace)}','${esc(p.name)}')">Logs</button>
1508
- <button class="iconbtn" onclick="openTerminal('${esc(p.namespace)}','${esc(p.name)}')">Terminal</button>
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>
1585
+ <button class="iconbtn" onclick="openTerminal('${esc(p.namespace)}','${esc(p.name)}')">Terminal</button></td>
1510
1586
  </tr>`).join('')}</tbody></table></div></div>`;
1511
1587
  }
1512
1588
  // Restart is the only write this app makes to a cluster. Two calls on purpose: the
@@ -1528,6 +1604,7 @@ async function restartPod(ns,pod,btn){
1528
1604
  const r = await api(base,{method:'POST'});
1529
1605
  restore();
1530
1606
  if(!r.ok){ restartError(r, ns, pod); return; }
1607
+ if(PDD.pod===pod&&PDD.ns===ns) closePodDetails();
1531
1608
  toast(`${r.data.kind} ${r.data.name} restarting`);
1532
1609
  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
1610
  setTimeout(()=>banner(''),8000);
@@ -1538,6 +1615,7 @@ async function restartPod(ns,pod,btn){
1538
1615
  // network path. It is NOT wired to a button: RunCommand executes as cluster-admin
1539
1616
  // regardless of the caller's Kubernetes RBAC, which this app otherwise never does.
1540
1617
  function restartError(r, ns, pod){
1618
+ if(PDD.pod===pod&&PDD.ns===ns) closePodDetails();
1541
1619
  if(r.network){
1542
1620
  const c=CL.cluster||{};
1543
1621
  const cmd=`az aks command invoke -g ${c.resourceGroup||'<rg>'} -n ${CL.name} --command "kubectl rollout restart deploy -n ${ns} --selector=app=<app>"`;
@@ -1552,36 +1630,77 @@ function restartError(r, ns, pod){
1552
1630
  toast(r.error||'restart failed');
1553
1631
  if(r.error && r.error.length>60){ banner(esc(r.error)); setTimeout(()=>banner(''),10000); }
1554
1632
  }
1555
- async function openPodDetail(ns,pod){
1556
- CL.logPod=null; CL.termPod=null; teardownTerm();
1557
- $('#clBody').innerHTML = `<div class="cmctl"><button class="iconbtn" onclick="loadClusterContent()">← Pods</button>
1558
- <span class="mono"><b>${esc(pod)}</b></span><span class="faint mono">${esc(ns)}</span></div>
1559
- <div id="pdOut"><div class="empty"><span class="spin"></span> loading pod details…</div></div>`;
1560
- const r=await api(`/api/aks/${CL.name}/${ns}/pods/${pod}/detail`);
1561
- if(STATE.route!=='cluster') return; const out=$('#pdOut'); if(!out) return;
1562
- if(!r.ok){ out.innerHTML=clErr(r); return; }
1563
- const d=r.data; const labels=Object.entries(d.labels||{});
1564
- const labelChips = labels.length
1565
- ? `<div class="tagchips">${labels.map(([k,v])=>`<span class="tagchip"><b>${esc(k)}</b>${v?' '+esc(v):''}</span>`).join('')}</div>`
1566
- : '<span class="faint mono">no labels</span>';
1567
- const cont=(d.containers||[]).map(c=>{
1633
+ let PDD={cluster:'',ns:'',pod:'',base:null,data:null,lastFocus:null};
1634
+ function renderPodDetail(p,d={}, {loading=false,ingressLoading=false,vaultLoading=false,error=''}={}){
1635
+ const labels=Object.entries(d.labels||{}).sort(([a],[b])=>a.localeCompare(b));
1636
+ const containers=(d.containers||[]).map(c=>{
1568
1637
  const rows=(c.env||[]).map(e=>{
1569
- const val = e.value!=null ? esc(e.value)
1570
- : e.from ? `<span class="faint">← ${esc(e.from.kind)} ${esc(e.from.name||'')}${e.from.key?'.'+esc(e.from.key):''}</span>` : '';
1638
+ const val=e.value!=null?esc(e.value)
1639
+ :e.from?`<span class="faint">← ${esc(e.from.kind)} ${esc(e.from.name||'')}${e.from.key?'.'+esc(e.from.key):''}</span>`:'';
1571
1640
  return `<tr><td class="mono"><b>${esc(e.name)}</b></td><td class="mono" style="word-break:break-all">${val}</td></tr>`;
1572
1641
  }).join('');
1573
1642
  const efRows=(c.envFrom||[]).map(ef=>`<tr><td class="mono faint">∗ all keys</td><td class="mono faint">← ${esc(ef.kind)} ${esc(ef.name||'')}${ef.prefix?` (prefix ${esc(ef.prefix)})`:''}${ef.kind==='secret'?' · values not shown':''}</td></tr>`).join('');
1574
- const body = rows||efRows
1575
- ? `<div class="panel" style="margin-top:6px"><div class="scroll" style="max-height:none"><table>
1576
- <thead><tr><th>Env var</th><th>Value / source</th></tr></thead><tbody>${rows}${efRows}</tbody></table></div></div>`
1577
- : '<div class="faint mono" style="margin-top:6px">no env vars declared</div>';
1578
- return `<div style="margin:16px 0">
1579
- <div class="kvline" style="padding:4px 0"><span class="nm mono"><b>${esc(c.name)}</b>${c.init?' <span class="pill">init</span>':''}</span>
1580
- <span class="meta">${c.tag?`<span class="tagchip">build ${esc(c.tag)}</span>`:''}</span></div>
1581
- <div class="cmval">${esc(c.image||'')}</div>${body}</div>`;
1643
+ const env=rows||efRows?`<div class="panel" style="margin-top:8px"><div class="scroll" style="max-height:280px"><table>
1644
+ <thead><tr><th>Env var</th><th>Value / source</th></tr></thead><tbody>${rows}${efRows}</tbody></table></div></div>`
1645
+ :'<div class="faint mono" style="margin-top:7px">no env vars declared</div>';
1646
+ return `<div style="margin:16px 0"><div class="kvline" style="padding:4px 0"><span class="nm mono"><b>${esc(c.name)}</b>${c.init?' <span class="pill">init</span>':''}</span>
1647
+ <span class="meta">${c.tag?`<span class="tagchip">build ${esc(c.tag)}</span>`:''}</span></div>
1648
+ <div class="cmval">${esc(c.image||'')}</div>${env}</div>`;
1582
1649
  }).join('');
1583
- out.innerHTML = `<div class="secthead">Labels (tags)</div>${labelChips}
1584
- <div class="secthead" style="margin-top:18px">Containers · image · env vars</div>${cont}`;
1650
+ const overview=[
1651
+ ['Cluster',PDD.cluster],['Namespace',p.namespace],['Status',p.phase||'?'],['Ready',p.ready||'—'],
1652
+ ['Restarts',p.restarts??0],['Age',age(p.created)],['Created',p.created?new Date(p.created).toLocaleString():null],
1653
+ ['Node',d.node],['Build',p.build||'—'],
1654
+ ].filter(([,v])=>v!==null&&v!==undefined&&v!=='');
1655
+ $('#podDetailTitle').textContent=p.name;
1656
+ $('#podDetailBody').innerHTML=`
1657
+ <div class="poddetail-actions">
1658
+ <button class="btn sm ghost" onclick="closePodDetails();showLogs('${esc(p.namespace)}','${esc(p.name)}')">Logs</button>
1659
+ <button class="btn sm ghost" onclick="closePodDetails();openTerminal('${esc(p.namespace)}','${esc(p.name)}')">Terminal</button>
1660
+ <span class="push"></span>
1661
+ <button class="btn sm danger" title="rolling-restart the workload that owns this pod" onclick="restartPod('${esc(p.namespace)}','${esc(p.name)}',this)">Restart workload</button>
1662
+ </div>
1663
+ ${error?`<div class="fwalert"><div><b>Pod details unavailable.</b><div class="hint">${esc(error)}</div></div></div>`:''}
1664
+ <section class="kvmeta-section"><div class="kvmeta-heading">Overview</div><div class="kvmeta-list">
1665
+ ${overview.map(([k,v])=>`<div class="kvmeta-row"><div class="kvmeta-key">${esc(k)}</div><div class="kvmeta-value mono">${esc(v)}</div></div>`).join('')}
1666
+ </div></section>
1667
+ <section class="kvmeta-section"><div class="kvmeta-heading">Key Vault</div>
1668
+ ${vaultLoading?'<div class="empty" style="padding:24px"><span class="spin"></span> tracing Key Vault references…</div>':kvCell(p)}
1669
+ </section>
1670
+ <section class="kvmeta-section"><div class="kvmeta-heading">Ingress</div>
1671
+ ${ingressLoading?'<div class="empty" style="padding:24px"><span class="spin"></span> resolving ingress routes…</div>':podIngressDetails(p)}
1672
+ </section>
1673
+ <section class="kvmeta-section"><div class="kvmeta-heading">Labels · ${labels.length}</div>
1674
+ ${labels.length?`<div class="kvmeta-tags">${labels.map(([k,v])=>`<div class="kvmeta-tag"><b>${esc(k)}</b><span>${esc(v)}</span></div>`).join('')}</div>`:'<div class="faint mono">no labels</div>'}
1675
+ </section>
1676
+ <section class="kvmeta-section"><div class="kvmeta-heading">Containers · image · environment</div>
1677
+ ${loading?'<div class="empty" style="padding:24px"><span class="spin"></span> loading pod details…</div>':containers||'<div class="faint mono">no containers</div>'}
1678
+ </section>`;
1679
+ }
1680
+ async function openPodDetail(ns,pod){
1681
+ const p=CL.items.find(x=>x.name===pod&&x.namespace===ns)||{name:pod,namespace:ns,labels:{},restarts:0};
1682
+ const cluster=CL.name;
1683
+ PDD={cluster,ns,pod,base:p,data:null,lastFocus:document.activeElement};
1684
+ $('#podDetailOverlay').classList.add('show'); $('#podDetailOverlay').setAttribute('aria-hidden','false');
1685
+ $('#podDetailDrawer').classList.add('open'); $('#podDetailDrawer').setAttribute('aria-hidden','false');
1686
+ const ingressLoading=!CL.ingressMapByCluster[cluster];
1687
+ const vaultLoading=!CL.vaultMapByCluster[cluster];
1688
+ renderPodDetail(p,{}, {loading:true,ingressLoading,vaultLoading}); setTimeout(()=>$('#podDetailClose').focus(),0);
1689
+ let r;
1690
+ try{ [r]=await Promise.all([
1691
+ api(`/api/aks/${encodeURIComponent(cluster)}/${encodeURIComponent(ns)}/pods/${encodeURIComponent(pod)}/detail`),
1692
+ ensurePodIngressMap(cluster),
1693
+ ensurePodVaultMap(cluster),
1694
+ ]); }catch(e){ r={ok:false,error:e.message||'Request failed'}; }
1695
+ if(PDD.cluster!==cluster||PDD.ns!==ns||PDD.pod!==pod)return;
1696
+ PDD.data=r.ok?r.data:null;
1697
+ renderPodDetail(p,r.ok?r.data:{},{error:r.ok?'':r.error||'Request failed'});
1698
+ }
1699
+ function closePodDetails(){
1700
+ if(!PDD.pod)return;
1701
+ $('#podDetailOverlay').classList.remove('show'); $('#podDetailOverlay').setAttribute('aria-hidden','true');
1702
+ $('#podDetailDrawer').classList.remove('open'); $('#podDetailDrawer').setAttribute('aria-hidden','true');
1703
+ const focus=PDD.lastFocus; PDD={cluster:'',ns:'',pod:'',base:null,data:null,lastFocus:null}; if(focus&&focus.focus)focus.focus();
1585
1704
  }
1586
1705
  function showLogs(ns,pod){
1587
1706
  const p = CL.items.find(x=>x.name===pod&&x.namespace===ns) || {name:pod, containers:[]};
@@ -2026,10 +2145,16 @@ document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>navigate(b.data
2026
2145
  $('#refresh').onclick=doRefresh;
2027
2146
  $('#acctBtn').onclick=()=>navigate('settings');
2028
2147
  $('#cmdkBtn').onclick=openCmdk;
2148
+ $('#kvMetaOverlay').onclick=closeVaultDetails;
2149
+ $('#kvMetaClose').onclick=closeVaultDetails;
2150
+ $('#podDetailOverlay').onclick=closePodDetails;
2151
+ $('#podDetailClose').onclick=closePodDetails;
2029
2152
  $('#search').oninput=e=>{ STATE.q=e.target.value; if(STATE.route!=='vaults'){ navigate('vaults'); } else { renderVaultRows(); } };
2030
2153
  $('#cmdkIn').oninput=e=>renderCmdk(e.target.value);
2031
2154
  $('#cmdkOv').onclick=e=>{ if(e.target===$('#cmdkOv')) closeCmdk(); };
2032
2155
  document.addEventListener('keydown',e=>{
2156
+ if(VMD.name){ if(e.key==='Escape')closeVaultDetails(); return; }
2157
+ if(PDD.pod){ if(e.key==='Escape')closePodDetails(); return; }
2033
2158
  if((e.metaKey||e.ctrlKey) && e.key.toLowerCase()==='k'){ e.preventDefault(); CMDK.open?closeCmdk():openCmdk(); return; }
2034
2159
  if(!CMDK.open){ if(e.key==='Escape' && STATE.route==='vault'){ navigate('vaults'); } return; }
2035
2160
  if(e.key==='Escape'){ closeCmdk(); }
package/public/styles.css CHANGED
@@ -342,6 +342,46 @@
342
342
  .tagchip{font-family:var(--mono);font-size:10px;color:var(--muted);background:rgba(255,255,255,.04);
343
343
  border:1px solid var(--line);border-radius:5px;padding:1px 7px;white-space:nowrap}
344
344
  .tagchip b{color:var(--text);font-weight:500}
345
+ .tagdetails{display:inline-flex;align-items:center;gap:7px}
346
+ .tagdetails .n{font-family:var(--mono);font-size:9.5px;color:var(--azure);background:var(--azuredim);
347
+ border-radius:4px;padding:0 5px}
348
+
349
+ /* ---------- Key Vault metadata drawer ---------- */
350
+ .kvmeta-overlay{position:fixed;inset:0;z-index:75;background:rgba(5,8,14,.65);backdrop-filter:blur(2px);
351
+ opacity:0;pointer-events:none;transition:opacity .2s}
352
+ .kvmeta-overlay.show{opacity:1;pointer-events:auto}
353
+ .kvmeta-drawer{position:fixed;z-index:80;top:0;right:0;width:min(600px,94vw);height:100vh;
354
+ display:flex;flex-direction:column;background:var(--panel);border-left:1px solid var(--line);
355
+ box-shadow:-30px 0 80px -30px rgba(0,0,0,.9);transform:translateX(100%);
356
+ transition:transform .24s cubic-bezier(.2,.7,.2,1)}
357
+ .kvmeta-drawer.open{transform:translateX(0)}
358
+ .kvmeta-drawer header{padding:20px 22px;border-bottom:1px solid var(--line);display:flex;
359
+ align-items:flex-start;justify-content:space-between;gap:16px}
360
+ .kvmeta-drawer .dt{font-family:var(--mono);font-size:17px;font-weight:600;margin-top:4px;word-break:break-all}
361
+ .kvmeta-body{padding:20px 22px 30px;overflow:auto}
362
+ .kvmeta-section{margin-bottom:24px}
363
+ .kvmeta-section:last-child{margin-bottom:0}
364
+ .kvmeta-heading{font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;
365
+ color:var(--faint);margin-bottom:8px}
366
+ .kvmeta-list{border:1px solid var(--line);border-radius:var(--r-sm);overflow:hidden}
367
+ .kvmeta-row{display:grid;grid-template-columns:160px minmax(0,1fr);gap:14px;padding:9px 11px;
368
+ border-bottom:1px solid var(--line2);font-size:12px}
369
+ .kvmeta-row:last-child{border-bottom:0}
370
+ .kvmeta-key{font-family:var(--mono);font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--faint)}
371
+ .kvmeta-value{color:var(--text);overflow-wrap:anywhere;white-space:normal}
372
+ .kvmeta-tags{display:grid;gap:7px}
373
+ .kvmeta-tag{display:grid;grid-template-columns:minmax(130px,220px) minmax(0,1fr);gap:12px;
374
+ padding:9px 11px;background:var(--panel2);border:1px solid var(--line);border-radius:7px;
375
+ font-family:var(--mono);font-size:11px}
376
+ .kvmeta-tag b{font-weight:500;color:var(--text);overflow-wrap:anywhere}
377
+ .kvmeta-tag span{color:var(--muted);overflow-wrap:anywhere}
378
+ .kvmeta-actions{display:flex;gap:8px;align-items:center;margin-bottom:20px}
379
+ .poddetail-drawer{width:min(860px,96vw)}
380
+ .poddetail-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:20px}
381
+ .poddetail-actions .push{margin-left:auto}
382
+ .poddetail-ingress{padding:15px 16px;margin-bottom:10px}
383
+ .poddetail-ingress:last-child{margin-bottom:0}
384
+ @media (max-width:600px){.kvmeta-row,.kvmeta-tag{grid-template-columns:1fr;gap:4px}}
345
385
  .kvchip{font-family:var(--mono);font-size:10.5px;color:var(--azure);background:var(--azuredim);
346
386
  border:1px solid rgba(91,157,255,.35);border-radius:5px;padding:2px 8px;white-space:nowrap;cursor:pointer}
347
387
  .kvchip:hover{border-color:var(--azure);background:rgba(91,157,255,.2)}
@@ -401,6 +441,8 @@
401
441
  .cmkey .nm{font-size:12.5px}
402
442
  .ell{display:inline-block;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom}
403
443
  .ell.ns{max-width:200px}
444
+ th.pod-build,td.pod-build{width:80px;max-width:80px}
445
+ .pod-build .ell{max-width:80px}
404
446
  .termhost{height:calc(100vh - 290px);min-height:340px;background:#070A11;border:1px solid var(--line);
405
447
  border-radius:var(--r-sm);padding:8px 4px 8px 10px;overflow:hidden}
406
448
  .termhost .xterm{height:100%}
package/server.js CHANGED
@@ -460,6 +460,36 @@ app.get('/api/vaults', async (req, res) => {
460
460
  }
461
461
  });
462
462
 
463
+ // Full read-only management metadata for the inventory details drawer.
464
+ app.get('/api/vaults/:name/metadata', (req, res) => {
465
+ const { name } = req.params;
466
+ if (!isValidName(name)) return res.status(400).json({ ok: false, error: 'Invalid vault name' });
467
+ send(res, (async () => {
468
+ const v = await findVaultMeta(name);
469
+ if (!v) throw Object.assign(new Error('Vault not found in inventory — try Refresh first'), { status: 404, notFound: true });
470
+ const url = `/subscriptions/${v.subscriptionId}/resourceGroups/${v.resourceGroup}` +
471
+ `/providers/Microsoft.KeyVault/vaults/${name}?api-version=2023-07-01`;
472
+ const out = await armFetch(url, { sub: v.subscriptionId });
473
+ const p = out.properties || {}, acl = p.networkAcls || {};
474
+ return {
475
+ id: out.id || null, name: out.name || name, resourceGroup: v.resourceGroup,
476
+ subscriptionId: v.subscriptionId, location: out.location || v.location,
477
+ sku: (p.sku && p.sku.name) || v.sku || null, tenantId: p.tenantId || v.tenantId || null,
478
+ uri: p.vaultUri || v.uri || null, rbacEnabled: p.enableRbacAuthorization === true,
479
+ provisioningState: p.provisioningState || null,
480
+ softDeleteEnabled: p.enableSoftDelete !== false,
481
+ softDeleteRetentionDays: p.softDeleteRetentionInDays ?? null,
482
+ purgeProtectionEnabled: p.enablePurgeProtection === true,
483
+ publicNetworkAccess: p.publicNetworkAccess || null,
484
+ networkDefaultAction: acl.defaultAction || null, networkBypass: acl.bypass || null,
485
+ ipRuleCount: Array.isArray(acl.ipRules) ? acl.ipRules.length : 0,
486
+ vnetRuleCount: Array.isArray(acl.virtualNetworkRules) ? acl.virtualNetworkRules.length : 0,
487
+ privateEndpointCount: Array.isArray(p.privateEndpointConnections) ? p.privateEndpointConnections.length : 0,
488
+ account: v.account || null, tags: out.tags || v.tags || {},
489
+ };
490
+ })());
491
+ });
492
+
463
493
  // Find a vault's management-plane coordinates (sub + rg) from the cached inventory.
464
494
  async function findVaultMeta(name) {
465
495
  const vaults = await listVaultsRaw();