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 +7 -0
- package/package.json +1 -1
- package/public/index.html +306 -89
- package/public/styles.css +52 -0
- package/server.js +157 -12
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.
|
|
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:'
|
|
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(/\
|
|
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 '
|
|
176
|
-
if(
|
|
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|\
|
|
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
|
|
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 = ['
|
|
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=>{
|
|
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
|
-
|
|
395
|
-
for(const
|
|
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){
|
|
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
|
-
|
|
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
|
|
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
|
-
${
|
|
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
|
|
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
|
|
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><
|
|
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();
|
|
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
|
|
1329
|
-
//
|
|
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
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1395
|
-
//
|
|
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
|
-
//
|
|
1411
|
-
//
|
|
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="
|
|
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)}')">
|
|
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
|
|
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"
|
|
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
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
if(
|
|
1470
|
-
|
|
1471
|
-
const
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
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
|
|
1478
|
-
:
|
|
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
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
<div class="
|
|
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
|
-
|
|
1492
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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)
|
|
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}],
|
|
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
|
|
930
|
-
|
|
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
|
|
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
|
|
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 () => {
|