akv-manager 0.2.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/README.md CHANGED
@@ -32,6 +32,10 @@ If you're not signed in, the UI tells you to run `az login` and press Refresh.
32
32
  **secrets, keys and certificates**.
33
33
  - reveal a secret value, **create** secrets (one or in batch with a common expiry),
34
34
  **edit** values (current value pre-filled), and **batch** enable/disable/set-expiry.
35
+ - **batch delete** secrets: tick several (or *select all*) and use *Delete…* in the
36
+ batch bar — confirmed by typing the vault name. On soft-delete vaults (the default)
37
+ the secrets become recoverable until their scheduled purge date; **purging is not
38
+ exposed** in this tool. Needs `secrets/delete` (e.g. *Key Vault Secrets Officer*).
35
39
  - **Expiry** — scan every readable vault for secrets/certs that are expired or near
36
40
  expiry, plotted on a time horizon.
37
41
  - **Clusters** — read-only AKS monitoring: browse namespaces and pods, read **pod
@@ -93,6 +97,9 @@ AZBO_ENV_RULES='[["-prd-","Prod"],["-dev-","Dev"],["-tst-","Test"]]' npx akv-man
93
97
  permission problem.
94
98
  - Write actions (create/edit/enable/disable) only succeed where you have write
95
99
  permission; failures are reported per item.
100
+ - **Delete** is a distinct permission from write: an identity that can `Set` secrets
101
+ may still be refused `Delete`. Deleted secrets stop resolving immediately, so
102
+ anything reading them (AKS CSI mounts, running apps) breaks at once.
96
103
 
97
104
  ## Publishing (for maintainers)
98
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akv-manager",
3
- "version": "0.2.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>
@@ -662,7 +758,8 @@ function renderBatchBar(){
662
758
  bar.classList.remove('hide');
663
759
  const secretOnly = DRW.kind==='secrets'
664
760
  ? `<input type="date" id="batchExp"><button class="iconbtn" onclick="batchApply('expiry')">Set expiry</button>
665
- <button class="iconbtn" onclick="batchEditValues()">Edit values…</button>` : '';
761
+ <button class="iconbtn" onclick="batchEditValues()">Edit values…</button>
762
+ <button class="iconbtn danger" onclick="confirmBatchDelete()">Delete…</button>` : '';
666
763
  bar.innerHTML = `<span class="bn">${n} selected</span>
667
764
  <button class="iconbtn" onclick="batchApply('enable')">Enable</button>
668
765
  <button class="iconbtn" onclick="batchApply('disable')">Disable</button>
@@ -671,7 +768,7 @@ function renderBatchBar(){
671
768
  }
672
769
  async function batchEditValues(){
673
770
  const names=[...DRW.sel]; if(!names.length) return;
674
- const el=$('#dCreate'); $('#dBatch').classList.add('hide'); el.classList.remove('hide');
771
+ const el=$('#dCreate'); $('#dBatch').classList.add('hide'); el.classList.remove('hide'); el.classList.remove('danger');
675
772
  el.innerHTML = `<h4>Edit ${names.length} secret value${names.length!==1?'s':''}</h4><div class="empty"><span class="spin"></span> loading current values…</div>`;
676
773
  const vals = await Promise.all(names.map(n=>
677
774
  api(`/api/vaults/${DRW.vault}/secrets/${n}/value`).then(r=>({n, ok:r.ok, val:r.ok?(r.data.value||''):'', err:r.forbidden?'no read permission':(r.error||'failed')}))));
@@ -689,11 +786,58 @@ async function batchEditValues(){
689
786
  <button class="btn sm ghost" onclick="closeCreate()">Cancel</button>
690
787
  <span class="hint" id="cMsg">${esc(notes.join(' · '))}</span></div>`;
691
788
  }
789
+ // Delete is the one batch action that cannot be undone from this UI, so it gets a
790
+ // typed confirmation (the vault name) instead of the usual one-click confirm().
791
+ function confirmBatchDelete(){
792
+ const names=[...DRW.sel]; if(!names.length) return;
793
+ const el=$('#dCreate'); $('#dBatch').classList.add('hide');
794
+ el.classList.remove('hide'); el.classList.add('danger');
795
+ const n=names.length, plural=n!==1?'s':'';
796
+ el.innerHTML = `<h4>Delete ${n} secret${plural} from ${esc(DRW.vault)}</h4>
797
+ <div class="dellist">${names.map(x=>`<div>${esc(x)}</div>`).join('')}</div>
798
+ <div class="hint" style="margin:10px 0">They stop resolving immediately — anything reading them (AKS CSI mounts, running apps) breaks now, not at the next deploy.
799
+ On a soft-delete vault they stay recoverable until their scheduled purge date; purging is not available here.</div>
800
+ <div class="fld"><label>Confirm</label><input type="text" id="delConfirm" placeholder="type the vault name: ${esc(DRW.vault)}" autocomplete="off"></div>
801
+ <div class="actions"><button class="btn sm danger" id="delGo" disabled onclick="batchDelete()">Delete ${n} secret${plural}</button>
802
+ <button class="btn sm ghost" onclick="closeCreate()">Cancel</button>
803
+ <span class="hint" id="cMsg"></span></div>`;
804
+ const inp=$('#delConfirm'), go=$('#delGo');
805
+ inp.oninput=()=>{ go.disabled = inp.value.trim().toLowerCase()!==DRW.vault.toLowerCase(); };
806
+ inp.onkeydown=e=>{ if(e.key==='Enter' && !go.disabled) batchDelete(); };
807
+ inp.focus();
808
+ }
809
+ async function batchDelete(){
810
+ const names=[...DRW.sel]; if(!names.length) return;
811
+ const go=$('#delGo'); if(go){ go.disabled=true; go.textContent='Deleting…'; }
812
+ const items = names.map(n=>({vault:DRW.vault, kind:'secret', name:n}));
813
+ const r = await api('/api/batch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({op:{delete:true}, items})});
814
+ if(!r.ok){
815
+ toast(r.error||'delete failed');
816
+ if(go){ go.disabled=false; go.textContent=`Delete ${names.length} secret${names.length!==1?'s':''}`; }
817
+ return;
818
+ }
819
+ const ok=r.results.filter(x=>x.ok).length, fail=r.results.length-ok;
820
+ toast(`${ok} deleted${fail?`, ${fail} failed`:''}`);
821
+ if(fail){
822
+ const failed=r.results.filter(x=>!x.ok); console.warn('Delete failures:', failed);
823
+ banner(`Delete: ${ok} deleted, ${fail} failed (${failed.slice(0,3).map(f=>esc(f.name+': '+f.error)).join('; ')}${fail>3?'…':''}). See console for all.`);
824
+ setTimeout(()=>banner(''),8000);
825
+ } else {
826
+ const purge=(r.results.find(x=>x.scheduledPurgeDate)||{}).scheduledPurgeDate;
827
+ if(purge){
828
+ banner(`Deleted ${ok} secret${ok!==1?'s':''} — recoverable until ${esc(String(purge).slice(0,10))} (soft-delete), then purged automatically.`);
829
+ setTimeout(()=>banner(''),8000);
830
+ }
831
+ }
832
+ closeCreate();
833
+ DRW.sel.clear(); if($('#dSelAll'))$('#dSelAll').checked=false;
834
+ setTimeout(loadKind, 350);
835
+ }
692
836
  function toggleSel(nm){ DRW.sel.has(nm)?DRW.sel.delete(nm):DRW.sel.add(nm); renderDrawerList(); }
693
837
  function clearSel(){ DRW.sel.clear(); if($('#dSelAll'))$('#dSelAll').checked=false; renderDrawerList(); }
694
838
  function selectAll(on){ DRW.sel.clear(); if(on) drawerFiltered().filter(i=>!i.managed).forEach(i=>DRW.sel.add(i.name)); renderDrawerList(); }
695
839
  function openCreate(mode){
696
- const el=$('#dCreate'); DRW.sel.clear(); $('#dBatch').classList.add('hide');
840
+ const el=$('#dCreate'); DRW.sel.clear(); $('#dBatch').classList.add('hide'); el.classList.remove('danger');
697
841
  if(mode==='single'){
698
842
  el.innerHTML = `<h4>Create a secret</h4>
699
843
  <div class="fld"><label>Name</label><input type="text" id="cName" placeholder="my-secret-name" autocomplete="off"></div>
@@ -711,7 +855,7 @@ function openCreate(mode){
711
855
  }
712
856
  el.classList.remove('hide');
713
857
  }
714
- function closeCreate(){ const el=$('#dCreate'); if(el){ el.classList.add('hide'); el.innerHTML=''; } renderBatchBar(); }
858
+ function closeCreate(){ const el=$('#dCreate'); if(el){ el.classList.add('hide'); el.classList.remove('danger'); el.innerHTML=''; } renderBatchBar(); }
715
859
  async function submitCreate(mode){
716
860
  const msg=$('#cMsg');
717
861
  const expRaw=$('#cExp').value;
@@ -1261,6 +1405,7 @@ function renderClusterRows(){
1261
1405
  function openCluster(name){ navigate('cluster/'+encodeURIComponent(name)); }
1262
1406
 
1263
1407
  function viewCluster(name){
1408
+ if(PDD.cluster&&PDD.cluster!==name) closePodDetails();
1264
1409
  CL.name = name;
1265
1410
  if(mountedCluster!==name || !document.getElementById('clBody')){
1266
1411
  mountedCluster=name; CL.ns=''; CL.items=[]; CL.namespaces=[]; CL.logPod=null; CL.cluster=null; CL.kind='pods';
@@ -1295,6 +1440,7 @@ async function enrichClusterMeta(name){
1295
1440
  `<span class="mono">${esc(c.resourceGroup||'')}</span>${c.private?'<span class="sep">·</span><span class="pill policy">private · VPN</span>':''}`;
1296
1441
  }
1297
1442
  function loadClusterContent(force){
1443
+ closePodDetails();
1298
1444
  CL.logPod=null; CL.termPod=null; CL.filter=''; teardownTerm();
1299
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
1300
1446
  if(CL.kind==='pods') loadPods(); else loadConfigMaps();
@@ -1315,18 +1461,16 @@ function podTier(nm){ nm=(nm||'').toLowerCase(); if(TIER_RE.cache.test(nm))retur
1315
1461
  async function loadPods(){
1316
1462
  const name=CL.name;
1317
1463
  CL.vaultMap = CL.vaultMapByCluster[name] || {};
1318
- 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
1319
1465
  $('#clBody').innerHTML='<div class="empty"><span class="spin"></span></div>';
1320
1466
  const r = await api(`/api/aks/${name}/pods`);
1321
1467
  if(STATE.route!=='cluster'||CL.name!==name||CL.kind!=='pods'||CL.logPod||CL.termPod) return;
1322
1468
  if(!r.ok){ $('#clBody').innerHTML=clErr(r); return; }
1323
1469
  CL.items=r.data||[]; CL.podsByCluster[name]=CL.items;
1324
1470
  renderPodsShell();
1325
- ensurePodVaultMap(name);
1326
- ensurePodIngressMap(name);
1327
1471
  }
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.
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.
1330
1474
  async function ensurePodIngressMap(name){
1331
1475
  if(CL.ingressMapByCluster[name]){ CL.ingressMap=CL.ingressMapByCluster[name]; return; }
1332
1476
  const pods = CL.podsByCluster[name] || CL.items || [];
@@ -1336,7 +1480,6 @@ async function ensurePodIngressMap(name){
1336
1480
  CL.ingressMapByCluster[name] = (r.ok && r.data && r.data.byNamespace) ? r.data.byNamespace : {};
1337
1481
  if(CL.name!==name) return;
1338
1482
  CL.ingressMap = CL.ingressMapByCluster[name];
1339
- if(STATE.route==='cluster' && CL.kind==='pods' && !CL.logPod && !CL.termPod) renderPodRows();
1340
1483
  }
1341
1484
  // Match a pod to the Ingress(es) routing to it: Services whose selector ⊆ the pod's
1342
1485
  // labels, then Ingresses whose backend references those Services.
@@ -1351,31 +1494,18 @@ function podIngresses(p){
1351
1494
  const names = new Set(podMatchedSvcNames(p));
1352
1495
  return (nsm.ingresses||[]).filter(i=>(i.backendServices||[]).some(s=>names.has(s)));
1353
1496
  }
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:{}};
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){
1365
1500
  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; }
1501
+ if(!ings.length) return `<div class="empty" style="padding:24px">No Ingress routes to this pod's service.</div>`;
1372
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>`:'';
1373
- out.innerHTML = ings.map(i=>{
1503
+ return ings.map(i=>{
1374
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('');
1375
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(' ');
1376
1506
  const cm=i.certManager?[i.certManager.issuer,i.certManager.challenge,i.certManager.dnsProvider].filter(Boolean).map(esc).join(' · '):'';
1377
1507
  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">
1508
+ return `<div class="panel poddetail-ingress">
1379
1509
  <div class="row" style="justify-content:space-between;margin-bottom:10px;gap:10px">
1380
1510
  <b class="mono">${esc(i.name)}</b>
1381
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>
@@ -1391,8 +1521,8 @@ function openPodIngress(ns,pod){
1391
1521
  </div>`;
1392
1522
  }).join('');
1393
1523
  }
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.
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.
1396
1526
  async function ensurePodVaultMap(name){
1397
1527
  if(CL.vaultMapByCluster[name]){ CL.vaultMap=CL.vaultMapByCluster[name]; CL.vaultRestricted=CL.vaultRestrictedByCluster[name]||{}; return; }
1398
1528
  const pods = CL.podsByCluster[name] || CL.items || [];
@@ -1404,20 +1534,19 @@ async function ensurePodVaultMap(name){
1404
1534
  if(CL.name!==name) return;
1405
1535
  CL.vaultMap = CL.vaultMapByCluster[name];
1406
1536
  CL.vaultRestricted = CL.vaultRestrictedByCluster[name];
1407
- if(STATE.route==='cluster' && CL.kind==='pods' && !CL.logPod && !CL.termPod) renderPodRows();
1408
1537
  }
1409
1538
  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.
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.
1412
1541
  // When the namespace's External Secrets CRDs aren't readable (RBAC), say so plainly.
1413
1542
  function kvCell(p){
1414
1543
  const vs = podVaults(p);
1415
1544
  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>`;
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>`;
1417
1546
  }).join('')}</div>`;
1418
1547
  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>`;
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>`;
1421
1550
  }
1422
1551
  function renderPodsShell(){
1423
1552
  if(CL.pendingFilter){ CL.filter=CL.pendingFilter; CL.pendingFilter=null; } // from a project "Open →"
@@ -1444,52 +1573,134 @@ function renderPodRows(){
1444
1573
  if(!CL.items.length){ $('#clList').innerHTML=`<div class="empty">No pods visible on this cluster.</div>`; return; }
1445
1574
  if(!list.length){ $('#clList').innerHTML=`<div class="empty">No pods match your filter.</div>`; return; }
1446
1575
  $('#clList').innerHTML = `<div class="panel"><div class="scroll"><table>
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>
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>
1448
1577
  <tbody>${list.map(p=>`<tr>
1449
1578
  <td class="mono"><b><span class="ell" title="${esc(p.name)}">${esc(p.name)}</span></b></td>
1450
1579
  <td class="mono muted"><span class="ell ns" title="${esc(p.namespace||'')}">${esc(p.namespace||'')}</span></td>
1451
- <td class="mono"><span class="ell" style="max-width:140px" title="${esc(p.build||'')}">${esc(p.build||'—')}</span></td>
1452
- <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>
1453
1581
  <td><span class="phase ${esc(p.phase||'')}">${esc(p.phase||'?')}</span></td>
1454
- <td class="right mono ${p.restarts>0?'':'muted'}">${p.restarts}</td>
1455
1582
  <td class="mono muted">${age(p.created)}</td>
1456
- <td>${ingCell(p)}</td>
1457
- <td>${kvCell(p)}</td>
1458
1583
  <td class="right" style="white-space:nowrap"><button class="iconbtn" onclick="openPodDetail('${esc(p.namespace)}','${esc(p.name)}')">Details</button>
1459
1584
  <button class="iconbtn" onclick="showLogs('${esc(p.namespace)}','${esc(p.name)}')">Logs</button>
1460
1585
  <button class="iconbtn" onclick="openTerminal('${esc(p.namespace)}','${esc(p.name)}')">Terminal</button></td>
1461
1586
  </tr>`).join('')}</tbody></table></div></div>`;
1462
1587
  }
1463
- async function openPodDetail(ns,pod){
1464
- CL.logPod=null; CL.termPod=null; teardownTerm();
1465
- $('#clBody').innerHTML = `<div class="cmctl"><button class="iconbtn" onclick="loadClusterContent()">← Pods</button>
1466
- <span class="mono"><b>${esc(pod)}</b></span><span class="faint mono">${esc(ns)}</span></div>
1467
- <div id="pdOut"><div class="empty"><span class="spin"></span> loading pod details…</div></div>`;
1468
- const r=await api(`/api/aks/${CL.name}/${ns}/pods/${pod}/detail`);
1469
- if(STATE.route!=='cluster') return; const out=$('#pdOut'); if(!out) return;
1470
- if(!r.ok){ out.innerHTML=clErr(r); return; }
1471
- const d=r.data; const labels=Object.entries(d.labels||{});
1472
- const labelChips = labels.length
1473
- ? `<div class="tagchips">${labels.map(([k,v])=>`<span class="tagchip"><b>${esc(k)}</b>${v?' '+esc(v):''}</span>`).join('')}</div>`
1474
- : '<span class="faint mono">no labels</span>';
1475
- const cont=(d.containers||[]).map(c=>{
1588
+ // Restart is the only write this app makes to a cluster. Two calls on purpose: the
1589
+ // first resolves the owning workload without touching it, so the confirmation names the
1590
+ // Deployment being rolled rather than the pod that was clicked.
1591
+ async function restartPod(ns,pod,btn){
1592
+ const label = btn ? btn.textContent : '';
1593
+ if(btn){ btn.disabled=true; btn.textContent='…'; }
1594
+ const restore=()=>{ if(btn){ btn.disabled=false; btn.textContent=label||'Restart'; } };
1595
+ const base=`/api/aks/${CL.name}/${ns}/pods/${pod}/restart`;
1596
+ const probe = await api(base+'?dryRun=1',{method:'POST'});
1597
+ if(!probe.ok){ restore(); restartError(probe, ns, pod); return; }
1598
+ const wl=probe.data;
1599
+ const ok = confirm(`Rolling-restart ${wl.kind} "${wl.name}" in ${ns}?\n\n`+
1600
+ `Every pod it owns is replaced, not just ${pod}. New pods start before old ones stop, `+
1601
+ `so there is normally no downtime — but this is a live workload on ${CL.name}.`);
1602
+ if(!ok){ restore(); return; }
1603
+ if(btn) btn.textContent='restarting…';
1604
+ const r = await api(base,{method:'POST'});
1605
+ restore();
1606
+ if(!r.ok){ restartError(r, ns, pod); return; }
1607
+ if(PDD.pod===pod&&PDD.ns===ns) closePodDetails();
1608
+ toast(`${r.data.kind} ${r.data.name} restarting`);
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.`);
1610
+ setTimeout(()=>banner(''),8000);
1611
+ setTimeout(()=>{ if(STATE.route==='cluster' && CL.kind==='pods') loadClusterContent(); }, 2500);
1612
+ }
1613
+ // Off-VPN the API server simply isn't reachable. Rather than dead-end, hand over the
1614
+ // `az aks command invoke` equivalent — it runs from inside the cluster, so it needs no
1615
+ // network path. It is NOT wired to a button: RunCommand executes as cluster-admin
1616
+ // regardless of the caller's Kubernetes RBAC, which this app otherwise never does.
1617
+ function restartError(r, ns, pod){
1618
+ if(PDD.pod===pod&&PDD.ns===ns) closePodDetails();
1619
+ if(r.network){
1620
+ const c=CL.cluster||{};
1621
+ const cmd=`az aks command invoke -g ${c.resourceGroup||'<rg>'} -n ${CL.name} --command "kubectl rollout restart deploy -n ${ns} --selector=app=<app>"`;
1622
+ banner(`🚫 Can't reach ${esc(CL.name)}'s API server (private — needs the VPN). From anywhere, Azure can run it inside the cluster instead:<br>`+
1623
+ `<code class="mono">${esc(cmd)}</code><br><span class="hint">Note: <code>command invoke</code> runs as cluster-admin, not as your Kubernetes identity.</span>`);
1624
+ setTimeout(()=>banner(''),16000);
1625
+ return;
1626
+ }
1627
+ if(r.forbidden){
1628
+ banner(`🔒 ${esc(r.error||'Forbidden')}`); setTimeout(()=>banner(''),10000); return;
1629
+ }
1630
+ toast(r.error||'restart failed');
1631
+ if(r.error && r.error.length>60){ banner(esc(r.error)); setTimeout(()=>banner(''),10000); }
1632
+ }
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=>{
1476
1637
  const rows=(c.env||[]).map(e=>{
1477
- const val = e.value!=null ? esc(e.value)
1478
- : 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>`:'';
1479
1640
  return `<tr><td class="mono"><b>${esc(e.name)}</b></td><td class="mono" style="word-break:break-all">${val}</td></tr>`;
1480
1641
  }).join('');
1481
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('');
1482
- const body = rows||efRows
1483
- ? `<div class="panel" style="margin-top:6px"><div class="scroll" style="max-height:none"><table>
1484
- <thead><tr><th>Env var</th><th>Value / source</th></tr></thead><tbody>${rows}${efRows}</tbody></table></div></div>`
1485
- : '<div class="faint mono" style="margin-top:6px">no env vars declared</div>';
1486
- return `<div style="margin:16px 0">
1487
- <div class="kvline" style="padding:4px 0"><span class="nm mono"><b>${esc(c.name)}</b>${c.init?' <span class="pill">init</span>':''}</span>
1488
- <span class="meta">${c.tag?`<span class="tagchip">build ${esc(c.tag)}</span>`:''}</span></div>
1489
- <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>`;
1490
1649
  }).join('');
1491
- out.innerHTML = `<div class="secthead">Labels (tags)</div>${labelChips}
1492
- <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();
1493
1704
  }
1494
1705
  function showLogs(ns,pod){
1495
1706
  const p = CL.items.find(x=>x.name===pod&&x.namespace===ns) || {name:pod, containers:[]};
@@ -1934,10 +2145,16 @@ document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>navigate(b.data
1934
2145
  $('#refresh').onclick=doRefresh;
1935
2146
  $('#acctBtn').onclick=()=>navigate('settings');
1936
2147
  $('#cmdkBtn').onclick=openCmdk;
2148
+ $('#kvMetaOverlay').onclick=closeVaultDetails;
2149
+ $('#kvMetaClose').onclick=closeVaultDetails;
2150
+ $('#podDetailOverlay').onclick=closePodDetails;
2151
+ $('#podDetailClose').onclick=closePodDetails;
1937
2152
  $('#search').oninput=e=>{ STATE.q=e.target.value; if(STATE.route!=='vaults'){ navigate('vaults'); } else { renderVaultRows(); } };
1938
2153
  $('#cmdkIn').oninput=e=>renderCmdk(e.target.value);
1939
2154
  $('#cmdkOv').onclick=e=>{ if(e.target===$('#cmdkOv')) closeCmdk(); };
1940
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; }
1941
2158
  if((e.metaKey||e.ctrlKey) && e.key.toLowerCase()==='k'){ e.preventDefault(); CMDK.open?closeCmdk():openCmdk(); return; }
1942
2159
  if(!CMDK.open){ if(e.key==='Escape' && STATE.route==='vault'){ navigate('vaults'); } return; }
1943
2160
  if(e.key==='Escape'){ closeCmdk(); }
package/public/styles.css CHANGED
@@ -221,6 +221,16 @@
221
221
  background:var(--ink);border:1px solid var(--line);color:var(--text);border-radius:6px;padding:8px 10px;flex:1;width:100%;color-scheme:dark}
222
222
  .createp textarea{min-height:120px;resize:vertical;line-height:1.6}
223
223
  .createp .actions{display:flex;gap:8px;align-items:center;margin-top:6px}
224
+ /* destructive variant of the same panel */
225
+ .createp.danger{border-color:rgba(255,92,108,.6);
226
+ background:linear-gradient(180deg,rgba(255,92,108,.08),var(--panel2))}
227
+ .createp.danger h4{color:#ffd9dd}
228
+ .createp .dellist{font-family:var(--mono);font-size:12px;color:var(--text);max-height:180px;overflow:auto;
229
+ background:var(--ink);border:1px solid var(--line);border-radius:6px;padding:8px 10px;line-height:1.7}
230
+ .iconbtn.warn{color:#FBC56B;border-color:rgba(242,181,61,.4)}
231
+ .iconbtn.warn:hover{color:#06101f;background:var(--warn);border-color:var(--warn)}
232
+ .iconbtn.danger{color:#FF8893;border-color:rgba(255,92,108,.45)}
233
+ .iconbtn.danger:hover{color:#fff;background:var(--alert);border-color:var(--alert)}
224
234
  .newbtns{display:flex;gap:6px;margin-left:auto}
225
235
  .editor .fld{display:flex;align-items:center;gap:10px;margin-bottom:10px}
226
236
  .editor label{font-family:var(--mono);font-size:11px;color:var(--muted);width:78px;flex:none}
@@ -332,6 +342,46 @@
332
342
  .tagchip{font-family:var(--mono);font-size:10px;color:var(--muted);background:rgba(255,255,255,.04);
333
343
  border:1px solid var(--line);border-radius:5px;padding:1px 7px;white-space:nowrap}
334
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}}
335
385
  .kvchip{font-family:var(--mono);font-size:10.5px;color:var(--azure);background:var(--azuredim);
336
386
  border:1px solid rgba(91,157,255,.35);border-radius:5px;padding:2px 8px;white-space:nowrap;cursor:pointer}
337
387
  .kvchip:hover{border-color:var(--azure);background:rgba(91,157,255,.2)}
@@ -391,6 +441,8 @@
391
441
  .cmkey .nm{font-size:12.5px}
392
442
  .ell{display:inline-block;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom}
393
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}
394
446
  .termhost{height:calc(100vh - 290px);min-height:340px;background:#070A11;border:1px solid var(--line);
395
447
  border-radius:var(--r-sm);padding:8px 4px 8px 10px;overflow:hidden}
396
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();
@@ -744,10 +774,14 @@ async function kvWrite(vaultName, method, pathQuery, body) {
744
774
  const timer = setTimeout(() => ctrl.abort(), 20000);
745
775
  let resp;
746
776
  try {
747
- resp = await fetch(`https://${vaultName.toLowerCase()}.vault.azure.net${pathQuery}`, {
748
- method, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
749
- body: JSON.stringify(body), signal: ctrl.signal,
750
- });
777
+ // DELETE carries no body — sending one (or a JSON content-type) is what makes
778
+ // some proxies reject the request, so build the init conditionally.
779
+ const init = { method, headers: { Authorization: `Bearer ${token}` }, signal: ctrl.signal };
780
+ if (body !== undefined) {
781
+ init.headers['Content-Type'] = 'application/json';
782
+ init.body = JSON.stringify(body);
783
+ }
784
+ resp = await fetch(`https://${vaultName.toLowerCase()}.vault.azure.net${pathQuery}`, init);
751
785
  } catch (e) {
752
786
  throw Object.assign(new Error(e.name === 'AbortError' ? 'Vault request timed out' : e.message), {});
753
787
  } finally { clearTimeout(timer); }
@@ -782,6 +816,22 @@ app.put('/api/vaults/:name/secrets/:secret', (req, res) => {
782
816
  send(res, kvWrite(name, 'PUT', `/secrets/${secret}?api-version=${KV_API}`, body));
783
817
  });
784
818
 
819
+ // Delete a secret. Soft-delete vaults keep it recoverable until its scheduled purge;
820
+ // purging is deliberately NOT exposed here — it is irreversible.
821
+ app.delete('/api/vaults/:name/secrets/:secret', async (req, res) => {
822
+ const { name, secret } = req.params;
823
+ if (!isValidName(name) || !isValidName(secret)) return res.status(400).json({ ok: false, error: 'Invalid name' });
824
+ try {
825
+ const d = await kvWrite(name, 'DELETE', `/secrets/${secret}?api-version=${KV_API}`);
826
+ res.json({ ok: true, data: { name: secret, ...deletedInfo(d) } });
827
+ } catch (e) {
828
+ res.status(e.needLogin ? 401 : e.forbidden ? 403 : e.notFound ? 404 : 500).json({
829
+ ok: false, error: writeReason(e, 'delete'),
830
+ needLogin: !!e.needLogin, forbidden: !!e.forbidden, firewall: !!e.firewall, notFound: !!e.notFound,
831
+ });
832
+ }
833
+ });
834
+
785
835
  // Update a certificate's attributes (enabled only — expiry is intrinsic to the cert).
786
836
  app.patch('/api/vaults/:name/certificates/:cert', (req, res) => {
787
837
  const { name, cert } = req.params;
@@ -794,12 +844,27 @@ app.patch('/api/vaults/:name/certificates/:cert', (req, res) => {
794
844
  // Turn a write failure into a precise, actionable reason. A 403 from Key Vault is
795
845
  // either a network/firewall block (fix: VPN) or a real data-plane permission gap
796
846
  // (fix: grant Set on secrets) — never conflate the two as a bare "forbidden".
797
- function writeReason(e) {
847
+ function writeReason(e, action = 'write') {
798
848
  if (e.firewall) return 'blocked by the vault firewall — your IP is not allowed (connect to the corporate VPN)';
799
- if (e.forbidden) return 'no write permission — your identity can read but not Set secrets on this vault';
849
+ if (e.forbidden) {
850
+ return action === 'delete'
851
+ ? 'no delete permission — your identity can read but not Delete secrets on this vault (needs secrets/delete, e.g. the Key Vault Secrets Officer role)'
852
+ : 'no write permission — your identity can read but not Set secrets on this vault';
853
+ }
800
854
  return e.message || 'failed';
801
855
  }
802
856
 
857
+ // A Key Vault DELETE returns a DeletedSecretBundle. On a soft-delete vault (the
858
+ // default, and mandatory since 2020) the secret is NOT erased — it moves to a
859
+ // recoverable state and Azure purges it on `scheduledPurgeDate` (unix seconds).
860
+ function deletedInfo(d) {
861
+ const ts = d && d.scheduledPurgeDate;
862
+ return {
863
+ recoverable: !!(d && d.recoveryId),
864
+ scheduledPurgeDate: typeof ts === 'number' ? new Date(ts * 1000).toISOString() : (ts || null),
865
+ };
866
+ }
867
+
803
868
  // Create one or many secrets in a vault. items: [{name, value}], optional common expires.
804
869
  app.post('/api/vaults/:name/secrets-create', async (req, res) => {
805
870
  const { name } = req.params;
@@ -822,16 +887,26 @@ app.post('/api/vaults/:name/secrets-create', async (req, res) => {
822
887
  res.json({ ok: true, results });
823
888
  });
824
889
 
825
- // Batch update across one or many vaults. items: [{vault, kind, name}], op: {enabled?, expires?}
890
+ // Batch update across one or many vaults. items: [{vault, kind, name}],
891
+ // op: {enabled?, expires?} or {delete: true} (secrets only).
826
892
  app.post('/api/batch', async (req, res) => {
827
893
  const { op = {}, items } = req.body || {};
828
894
  if (!Array.isArray(items) || !items.length) return res.status(400).json({ ok: false, error: 'No items selected' });
829
895
  if (items.length > 500) return res.status(400).json({ ok: false, error: 'Too many items (max 500 per batch)' });
896
+ const isDelete = op.delete === true;
830
897
  const attrs = buildAttrs(op);
831
898
  const results = await pool(items, async (it) => {
832
899
  const r = { vault: it.vault, name: it.name, kind: it.kind };
833
900
  try {
834
901
  if (!isValidName(it.vault) || !isValidName(it.name)) throw new Error('Invalid name');
902
+ if (isDelete) {
903
+ // Certificates and keys are out of scope on purpose: deleting a certificate
904
+ // also drops its managed secret/key pair, which is not what a "delete these
905
+ // rows" gesture in the secrets list should ever do.
906
+ if (it.kind !== 'secret') throw new Error('Only secrets can be deleted here');
907
+ const d = await kvWrite(it.vault, 'DELETE', `/secrets/${it.name}?api-version=${KV_API}`);
908
+ return { ...r, ok: true, ...deletedInfo(d) };
909
+ }
835
910
  if (it.kind === 'certificate') {
836
911
  if (op.expires) throw new Error("A certificate's expiry is fixed by the certificate itself");
837
912
  const a = {}; if (typeof op.enabled === 'boolean') a.enabled = op.enabled;
@@ -842,7 +917,7 @@ app.post('/api/batch', async (req, res) => {
842
917
  await kvWrite(it.vault, 'PATCH', `/secrets/${it.name}?api-version=${KV_API}`, { attributes: attrs });
843
918
  }
844
919
  return { ...r, ok: true };
845
- } catch (e) { return { ...r, ok: false, error: writeReason(e), firewall: !!e.firewall, forbidden: !!e.forbidden }; }
920
+ } catch (e) { return { ...r, ok: false, error: writeReason(e, isDelete ? 'delete' : 'write'), firewall: !!e.firewall, forbidden: !!e.forbidden }; }
846
921
  }, 12);
847
922
  res.json({ ok: true, results });
848
923
  });
@@ -926,15 +1001,24 @@ function imageTag(img) {
926
1001
  return c >= 0 ? repoTag.slice(c + 1) : '';
927
1002
  }
928
1003
 
929
- // One GET against a cluster's Kubernetes API. raw=true returns text (for pod logs).
930
- function k8sGet(cred, pathQuery, { raw = false, timeout = 25000 } = {}) {
1004
+ // One request against a cluster's Kubernetes API. raw=true returns text (for pod logs).
1005
+ // Everything read-only goes through k8sGet; the single mutating caller is the workload
1006
+ // restart, which PATCHes a controller's pod template. The AAD token is the user's own,
1007
+ // so Kubernetes RBAC still decides what is allowed — this grants nothing they lack.
1008
+ function k8sReq(cred, method, pathQuery, { raw = false, timeout = 25000, body, contentType } = {}) {
931
1009
  return new Promise((resolve, reject) => {
932
1010
  getAksToken(cred.sub).then((token) => {
933
1011
  const u = new URL(cred.server + pathQuery);
1012
+ const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
1013
+ const headers = { Authorization: `Bearer ${token}`, Accept: raw ? '*/*' : 'application/json' };
1014
+ if (payload) {
1015
+ headers['Content-Type'] = contentType || 'application/json';
1016
+ headers['Content-Length'] = payload.length;
1017
+ }
934
1018
  const req = https.request({
935
- hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method: 'GET',
1019
+ hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method,
936
1020
  // '*/*' for raw so the log subresource returns text/plain without tripping content negotiation
937
- headers: { Authorization: `Bearer ${token}`, Accept: raw ? '*/*' : 'application/json' },
1021
+ headers,
938
1022
  ca: cred.ca, timeout,
939
1023
  }, (res) => {
940
1024
  let data = ''; res.setEncoding('utf8');
@@ -957,10 +1041,12 @@ function k8sGet(cred, pathQuery, { raw = false, timeout = 25000 } = {}) {
957
1041
  : e.message), { network }));
958
1042
  });
959
1043
  req.on('timeout', () => req.destroy(Object.assign(new Error('Cluster API request timed out — is the VPN connected?'), { network: true })));
1044
+ if (payload) req.write(payload);
960
1045
  req.end();
961
1046
  }).catch(reject);
962
1047
  });
963
1048
  }
1049
+ const k8sGet = (cred, pathQuery, opts) => k8sReq(cred, 'GET', pathQuery, opts);
964
1050
 
965
1051
  // Discover clusters you have ARM read on (best-effort) and merge with the configured list.
966
1052
  const AKS_QUERY =
@@ -1087,6 +1173,65 @@ app.get('/api/aks/:cluster/:ns/pods/:pod/detail', (req, res) => {
1087
1173
  })());
1088
1174
  });
1089
1175
 
1176
+ // ---- AKS: restart a workload (the one mutating cluster operation) -----------
1177
+ // A pod is never restarted directly: killing a pod is a hard stop of one replica, and
1178
+ // for a bare (uncontrolled) pod it is unrecoverable. Instead we walk up to the pod's
1179
+ // controller and stamp its pod template with `kubectl.kubernetes.io/restartedAt` —
1180
+ // byte-for-byte what `kubectl rollout restart` does, so the controller rolls replicas
1181
+ // gracefully. Needs Kubernetes RBAC `patch` on that controller in that namespace.
1182
+ const RESTARTABLE = { Deployment: 'deployments', StatefulSet: 'statefulsets', DaemonSet: 'daemonsets' };
1183
+
1184
+ async function resolveWorkload(cred, ns, pod) {
1185
+ const p = await k8sGet(cred, `/api/v1/namespaces/${ns}/pods/${pod}`);
1186
+ const owner = ((p.metadata || {}).ownerReferences || []).find((o) => o.controller) ||
1187
+ ((p.metadata || {}).ownerReferences || [])[0];
1188
+ if (!owner) {
1189
+ throw Object.assign(new Error(
1190
+ 'This pod has no controller (it is a bare pod). Nothing would recreate it, so restarting is not offered.'), { status: 409 });
1191
+ }
1192
+ if (owner.kind === 'ReplicaSet') {
1193
+ const rs = await k8sGet(cred, `/apis/apps/v1/namespaces/${ns}/replicasets/${owner.name}`);
1194
+ const dep = ((rs.metadata || {}).ownerReferences || []).find((o) => o.kind === 'Deployment');
1195
+ if (!dep) {
1196
+ throw Object.assign(new Error(
1197
+ `This pod is owned by ReplicaSet "${owner.name}" with no Deployment above it — a bare ReplicaSet does not roll its pods on a template change.`), { status: 409 });
1198
+ }
1199
+ return { kind: 'Deployment', name: dep.name, plural: 'deployments' };
1200
+ }
1201
+ const plural = RESTARTABLE[owner.kind];
1202
+ if (!plural) {
1203
+ throw Object.assign(new Error(
1204
+ `This pod is owned by a ${owner.kind} ("${owner.name}"), which has no rolling-restart semantics.`), { status: 409 });
1205
+ }
1206
+ return { kind: owner.kind, name: owner.name, plural };
1207
+ }
1208
+
1209
+ // dryRun=1 resolves the owning workload WITHOUT touching it, so the UI can name the
1210
+ // thing in its confirmation instead of asking the user to trust a pod name.
1211
+ app.post('/api/aks/:cluster/:ns/pods/:pod/restart', (req, res) => {
1212
+ const { ns, pod } = req.params;
1213
+ if (!isK8sName(ns) || !isK8sName(pod)) return res.status(400).json({ ok: false, error: 'Invalid namespace/pod' });
1214
+ const dryRun = req.query.dryRun === '1' || req.query.dryRun === 'true';
1215
+ send(res, (async () => {
1216
+ const cred = await aksCredential(await getClusterChecked(req.params.cluster));
1217
+ const wl = await resolveWorkload(cred, ns, pod);
1218
+ if (dryRun) return { ...wl, namespace: ns, dryRun: true };
1219
+ const restartedAt = new Date().toISOString();
1220
+ const patch = { spec: { template: { metadata: { annotations: { 'kubectl.kubernetes.io/restartedAt': restartedAt } } } } };
1221
+ try {
1222
+ await k8sReq(cred, 'PATCH', `/apis/apps/v1/namespaces/${ns}/${wl.plural}/${wl.name}`, {
1223
+ body: patch, contentType: 'application/strategic-merge-patch+json',
1224
+ });
1225
+ } catch (e) {
1226
+ if (e.forbidden) {
1227
+ e.message = `no permission to patch ${wl.plural} in "${ns}" (Kubernetes RBAC) — ${e.message}`;
1228
+ }
1229
+ throw e;
1230
+ }
1231
+ return { ...wl, namespace: ns, restartedAt };
1232
+ })());
1233
+ });
1234
+
1090
1235
  // Cluster-wide configmap list (all namespaces).
1091
1236
  app.get('/api/aks/:cluster/configmaps', (req, res) =>
1092
1237
  send(res, (async () => {