akv-manager 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/index.html +212 -87
- package/public/styles.css +42 -0
- package/server.js +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akv-manager",
|
|
3
|
-
"version": "0.3.
|
|
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>
|
|
@@ -1309,6 +1405,7 @@ function renderClusterRows(){
|
|
|
1309
1405
|
function openCluster(name){ navigate('cluster/'+encodeURIComponent(name)); }
|
|
1310
1406
|
|
|
1311
1407
|
function viewCluster(name){
|
|
1408
|
+
if(PDD.cluster&&PDD.cluster!==name) closePodDetails();
|
|
1312
1409
|
CL.name = name;
|
|
1313
1410
|
if(mountedCluster!==name || !document.getElementById('clBody')){
|
|
1314
1411
|
mountedCluster=name; CL.ns=''; CL.items=[]; CL.namespaces=[]; CL.logPod=null; CL.cluster=null; CL.kind='pods';
|
|
@@ -1343,6 +1440,7 @@ async function enrichClusterMeta(name){
|
|
|
1343
1440
|
`<span class="mono">${esc(c.resourceGroup||'')}</span>${c.private?'<span class="sep">·</span><span class="pill policy">private · VPN</span>':''}`;
|
|
1344
1441
|
}
|
|
1345
1442
|
function loadClusterContent(force){
|
|
1443
|
+
closePodDetails();
|
|
1346
1444
|
CL.logPod=null; CL.termPod=null; CL.filter=''; teardownTerm();
|
|
1347
1445
|
if(force){ delete CL.podsByCluster[CL.name]; delete CL.cmByCluster[CL.name]; delete CL.vaultMapByCluster[CL.name]; delete CL.ingressMapByCluster[CL.name]; } // explicit refresh re-fetches
|
|
1348
1446
|
if(CL.kind==='pods') loadPods(); else loadConfigMaps();
|
|
@@ -1363,18 +1461,16 @@ function podTier(nm){ nm=(nm||'').toLowerCase(); if(TIER_RE.cache.test(nm))retur
|
|
|
1363
1461
|
async function loadPods(){
|
|
1364
1462
|
const name=CL.name;
|
|
1365
1463
|
CL.vaultMap = CL.vaultMapByCluster[name] || {};
|
|
1366
|
-
if(CL.podsByCluster[name]){ CL.items=CL.podsByCluster[name]; renderPodsShell();
|
|
1464
|
+
if(CL.podsByCluster[name]){ CL.items=CL.podsByCluster[name]; renderPodsShell(); return; } // cached
|
|
1367
1465
|
$('#clBody').innerHTML='<div class="empty"><span class="spin"></span></div>';
|
|
1368
1466
|
const r = await api(`/api/aks/${name}/pods`);
|
|
1369
1467
|
if(STATE.route!=='cluster'||CL.name!==name||CL.kind!=='pods'||CL.logPod||CL.termPod) return;
|
|
1370
1468
|
if(!r.ok){ $('#clBody').innerHTML=clErr(r); return; }
|
|
1371
1469
|
CL.items=r.data||[]; CL.podsByCluster[name]=CL.items;
|
|
1372
1470
|
renderPodsShell();
|
|
1373
|
-
ensurePodVaultMap(name);
|
|
1374
|
-
ensurePodIngressMap(name);
|
|
1375
1471
|
}
|
|
1376
|
-
// Resolve each namespace's exposed Services → Ingress hosts once per cluster
|
|
1377
|
-
//
|
|
1472
|
+
// Resolve each namespace's exposed Services → Ingress hosts once per cluster for the
|
|
1473
|
+
// pod details drawer. Pods are matched to a Service by label⊇selector.
|
|
1378
1474
|
async function ensurePodIngressMap(name){
|
|
1379
1475
|
if(CL.ingressMapByCluster[name]){ CL.ingressMap=CL.ingressMapByCluster[name]; return; }
|
|
1380
1476
|
const pods = CL.podsByCluster[name] || CL.items || [];
|
|
@@ -1384,7 +1480,6 @@ async function ensurePodIngressMap(name){
|
|
|
1384
1480
|
CL.ingressMapByCluster[name] = (r.ok && r.data && r.data.byNamespace) ? r.data.byNamespace : {};
|
|
1385
1481
|
if(CL.name!==name) return;
|
|
1386
1482
|
CL.ingressMap = CL.ingressMapByCluster[name];
|
|
1387
|
-
if(STATE.route==='cluster' && CL.kind==='pods' && !CL.logPod && !CL.termPod) renderPodRows();
|
|
1388
1483
|
}
|
|
1389
1484
|
// Match a pod to the Ingress(es) routing to it: Services whose selector ⊆ the pod's
|
|
1390
1485
|
// labels, then Ingresses whose backend references those Services.
|
|
@@ -1399,31 +1494,18 @@ function podIngresses(p){
|
|
|
1399
1494
|
const names = new Set(podMatchedSvcNames(p));
|
|
1400
1495
|
return (nsm.ingresses||[]).filter(i=>(i.backendServices||[]).some(s=>names.has(s)));
|
|
1401
1496
|
}
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
if(!hs.length) return '<span class="faint mono">—</span>';
|
|
1406
|
-
const label = hs[0] + (hs.length>1?` +${hs.length-1}`:'');
|
|
1407
|
-
return `<button class="ingchip" title="${esc(hs.join(', '))} — click for ingress config" onclick="openPodIngress('${esc(p.namespace)}','${esc(p.name)}')">🌐 <span class="ell" style="max-width:220px;vertical-align:bottom">${esc(label)}</span></button>`;
|
|
1408
|
-
}
|
|
1409
|
-
// Ingress config panel: routing (host/path/backend:port), TLS + cert-manager, class,
|
|
1410
|
-
// forced-HTTPS, CORS and body-size — for the ingress(es) fronting this pod.
|
|
1411
|
-
function openPodIngress(ns,pod){
|
|
1412
|
-
const p = CL.items.find(x=>x.name===pod&&x.namespace===ns) || {name:pod, namespace:ns, labels:{}};
|
|
1497
|
+
// Ingress details shown inside the pod drawer: routing, TLS/cert-manager, class,
|
|
1498
|
+
// forced HTTPS, CORS, and body-size for every ingress fronting the pod.
|
|
1499
|
+
function podIngressDetails(p){
|
|
1413
1500
|
const ings = podIngresses(p);
|
|
1414
|
-
|
|
1415
|
-
$('#clBody').innerHTML = `<div class="cmctl"><button class="iconbtn" onclick="loadClusterContent()">← Pods</button>
|
|
1416
|
-
<span class="mono"><b>${esc(pod)}</b></span><span class="faint mono">${esc(ns)}</span><span class="faint mono">Ingress</span></div>
|
|
1417
|
-
<div id="pingOut"></div>`;
|
|
1418
|
-
const out=$('#pingOut'); if(!out) return;
|
|
1419
|
-
if(!ings.length){ out.innerHTML=`<div class="empty">No Ingress routes to this pod's service.</div>`; return; }
|
|
1501
|
+
if(!ings.length) return `<div class="empty" style="padding:24px">No Ingress routes to this pod's service.</div>`;
|
|
1420
1502
|
const line=(k,v)=> v?`<div class="kvline" style="padding:5px 0"><span class="nm mono">${esc(k)}</span><span class="meta mono">${v}</span></div>`:'';
|
|
1421
|
-
|
|
1503
|
+
return ings.map(i=>{
|
|
1422
1504
|
const routes=(i.routes||[]).map(r=>`<tr><td class="mono">${esc(r.host||'*')}</td><td class="mono">${esc(r.path||'/')}</td><td class="mono muted">${esc(r.pathType||'')}</td><td class="mono">${esc(r.service||'')}${r.port!=null?':'+esc(String(r.port)):''}</td></tr>`).join('');
|
|
1423
1505
|
const tls=(i.tls||[]).map(t=>`<span class="tagchip">🔒 ${esc(t.secretName||'tls')}${t.hosts&&t.hosts.length?' · '+esc(t.hosts.join(', ')):''}</span>`).join(' ');
|
|
1424
1506
|
const cm=i.certManager?[i.certManager.issuer,i.certManager.challenge,i.certManager.dnsProvider].filter(Boolean).map(esc).join(' · '):'';
|
|
1425
1507
|
const cors=i.cors?`origin <b>${esc(i.cors.origin||'*')}</b> · ${esc(i.cors.methods||'')}`:'';
|
|
1426
|
-
return `<div class="panel
|
|
1508
|
+
return `<div class="panel poddetail-ingress">
|
|
1427
1509
|
<div class="row" style="justify-content:space-between;margin-bottom:10px;gap:10px">
|
|
1428
1510
|
<b class="mono">${esc(i.name)}</b>
|
|
1429
1511
|
<div class="tagchips">${i.hosts.map(h=>`<a class="ingchip" href="https://${esc(h)}" target="_blank" rel="noopener noreferrer" title="Open https://${esc(h)}">🌐 ${esc(h)} ↗</a>`).join('')}</div>
|
|
@@ -1439,8 +1521,8 @@ function openPodIngress(ns,pod){
|
|
|
1439
1521
|
</div>`;
|
|
1440
1522
|
}).join('');
|
|
1441
1523
|
}
|
|
1442
|
-
// Resolve each namespace's Key Vault(s) once per cluster
|
|
1443
|
-
//
|
|
1524
|
+
// Resolve each namespace's Key Vault(s) once per cluster for the pod details drawer.
|
|
1525
|
+
// Best-effort: if the secret-source CRDs aren't readable the drawer explains the RBAC gap.
|
|
1444
1526
|
async function ensurePodVaultMap(name){
|
|
1445
1527
|
if(CL.vaultMapByCluster[name]){ CL.vaultMap=CL.vaultMapByCluster[name]; CL.vaultRestricted=CL.vaultRestrictedByCluster[name]||{}; return; }
|
|
1446
1528
|
const pods = CL.podsByCluster[name] || CL.items || [];
|
|
@@ -1452,20 +1534,19 @@ async function ensurePodVaultMap(name){
|
|
|
1452
1534
|
if(CL.name!==name) return;
|
|
1453
1535
|
CL.vaultMap = CL.vaultMapByCluster[name];
|
|
1454
1536
|
CL.vaultRestricted = CL.vaultRestrictedByCluster[name];
|
|
1455
|
-
if(STATE.route==='cluster' && CL.kind==='pods' && !CL.logPod && !CL.termPod) renderPodRows();
|
|
1456
1537
|
}
|
|
1457
1538
|
function podVaults(p){ return (CL.vaultMap && CL.vaultMap[p.namespace]) || []; }
|
|
1458
|
-
//
|
|
1459
|
-
//
|
|
1539
|
+
// Key Vault controls for the pod drawer. Clicking a known vault jumps straight to its
|
|
1540
|
+
// secrets; otherwise it opens the precise per-pod trace panel.
|
|
1460
1541
|
// When the namespace's External Secrets CRDs aren't readable (RBAC), say so plainly.
|
|
1461
1542
|
function kvCell(p){
|
|
1462
1543
|
const vs = podVaults(p);
|
|
1463
1544
|
if(vs.length) return `<div class="tagchips">${vs.map(name=>{ const known=findVault(name);
|
|
1464
|
-
return `<button class="kvchip${known?'':' unknown'}" title="${known?'Open '+esc(name)+' secrets':esc(name)+' — not in your visible inventory (click to trace)'}" onclick="
|
|
1545
|
+
return `<button class="kvchip${known?'':' unknown'}" title="${known?'Open '+esc(name)+' secrets':esc(name)+' — not in your visible inventory (click to trace)'}" onclick="closePodDetails();${known?`openVault('${esc(name)}')`:`openPodVaults('${esc(p.namespace)}','${esc(p.name)}')`}">🔑 ${esc(name)}</button>`;
|
|
1465
1546
|
}).join('')}</div>`;
|
|
1466
1547
|
if(CL.vaultRestricted && CL.vaultRestricted[p.namespace])
|
|
1467
|
-
return `<button class="kvchip unknown" title="Your identity can't read SecretStores on this cluster (Kubernetes RBAC). Click to see details." onclick="openPodVaults('${esc(p.namespace)}','${esc(p.name)}')">🔒 restricted</button>`;
|
|
1468
|
-
return `<button class="iconbtn" title="trace this pod's Key Vault via CSI / External Secrets / env" onclick="openPodVaults('${esc(p.namespace)}','${esc(p.name)}')">
|
|
1548
|
+
return `<button class="kvchip unknown" title="Your identity can't read SecretStores on this cluster (Kubernetes RBAC). Click to see details." onclick="closePodDetails();openPodVaults('${esc(p.namespace)}','${esc(p.name)}')">🔒 restricted</button>`;
|
|
1549
|
+
return `<button class="iconbtn" title="trace this pod's Key Vault via CSI / External Secrets / env" onclick="closePodDetails();openPodVaults('${esc(p.namespace)}','${esc(p.name)}')">Trace Key Vault</button>`;
|
|
1469
1550
|
}
|
|
1470
1551
|
function renderPodsShell(){
|
|
1471
1552
|
if(CL.pendingFilter){ CL.filter=CL.pendingFilter; CL.pendingFilter=null; } // from a project "Open →"
|
|
@@ -1492,21 +1573,16 @@ function renderPodRows(){
|
|
|
1492
1573
|
if(!CL.items.length){ $('#clList').innerHTML=`<div class="empty">No pods visible on this cluster.</div>`; return; }
|
|
1493
1574
|
if(!list.length){ $('#clList').innerHTML=`<div class="empty">No pods match your filter.</div>`; return; }
|
|
1494
1575
|
$('#clList').innerHTML = `<div class="panel"><div class="scroll"><table>
|
|
1495
|
-
<thead><tr><th>Pod</th><th>Namespace</th><th
|
|
1576
|
+
<thead><tr><th>Pod</th><th>Namespace</th><th class="pod-build">Build</th><th>Status</th><th>Age</th><th class="right">Actions</th></tr></thead>
|
|
1496
1577
|
<tbody>${list.map(p=>`<tr>
|
|
1497
1578
|
<td class="mono"><b><span class="ell" title="${esc(p.name)}">${esc(p.name)}</span></b></td>
|
|
1498
1579
|
<td class="mono muted"><span class="ell ns" title="${esc(p.namespace||'')}">${esc(p.namespace||'')}</span></td>
|
|
1499
|
-
<td class="mono"><span class="ell"
|
|
1500
|
-
<td class="mono">${esc(p.ready||'')}</td>
|
|
1580
|
+
<td class="mono pod-build"><span class="ell" title="${esc(p.build||'')}">${esc(p.build||'—')}</span></td>
|
|
1501
1581
|
<td><span class="phase ${esc(p.phase||'')}">${esc(p.phase||'?')}</span></td>
|
|
1502
|
-
<td class="right mono ${p.restarts>0?'':'muted'}">${p.restarts}</td>
|
|
1503
1582
|
<td class="mono muted">${age(p.created)}</td>
|
|
1504
|
-
<td>${ingCell(p)}</td>
|
|
1505
|
-
<td>${kvCell(p)}</td>
|
|
1506
1583
|
<td class="right" style="white-space:nowrap"><button class="iconbtn" onclick="openPodDetail('${esc(p.namespace)}','${esc(p.name)}')">Details</button>
|
|
1507
1584
|
<button class="iconbtn" onclick="showLogs('${esc(p.namespace)}','${esc(p.name)}')">Logs</button>
|
|
1508
|
-
<button class="iconbtn" onclick="openTerminal('${esc(p.namespace)}','${esc(p.name)}')">Terminal</button>
|
|
1509
|
-
<button class="iconbtn warn" title="rolling-restart the workload that owns this pod" onclick="restartPod('${esc(p.namespace)}','${esc(p.name)}',this)">Restart</button></td>
|
|
1585
|
+
<button class="iconbtn" onclick="openTerminal('${esc(p.namespace)}','${esc(p.name)}')">Terminal</button></td>
|
|
1510
1586
|
</tr>`).join('')}</tbody></table></div></div>`;
|
|
1511
1587
|
}
|
|
1512
1588
|
// Restart is the only write this app makes to a cluster. Two calls on purpose: the
|
|
@@ -1528,6 +1604,7 @@ async function restartPod(ns,pod,btn){
|
|
|
1528
1604
|
const r = await api(base,{method:'POST'});
|
|
1529
1605
|
restore();
|
|
1530
1606
|
if(!r.ok){ restartError(r, ns, pod); return; }
|
|
1607
|
+
if(PDD.pod===pod&&PDD.ns===ns) closePodDetails();
|
|
1531
1608
|
toast(`${r.data.kind} ${r.data.name} restarting`);
|
|
1532
1609
|
banner(`Rolling restart requested on ${esc(r.data.kind)} <b>${esc(r.data.name)}</b> in ${esc(ns)} — watch the Ready/Age columns as replicas cycle.`);
|
|
1533
1610
|
setTimeout(()=>banner(''),8000);
|
|
@@ -1538,6 +1615,7 @@ async function restartPod(ns,pod,btn){
|
|
|
1538
1615
|
// network path. It is NOT wired to a button: RunCommand executes as cluster-admin
|
|
1539
1616
|
// regardless of the caller's Kubernetes RBAC, which this app otherwise never does.
|
|
1540
1617
|
function restartError(r, ns, pod){
|
|
1618
|
+
if(PDD.pod===pod&&PDD.ns===ns) closePodDetails();
|
|
1541
1619
|
if(r.network){
|
|
1542
1620
|
const c=CL.cluster||{};
|
|
1543
1621
|
const cmd=`az aks command invoke -g ${c.resourceGroup||'<rg>'} -n ${CL.name} --command "kubectl rollout restart deploy -n ${ns} --selector=app=<app>"`;
|
|
@@ -1552,36 +1630,77 @@ function restartError(r, ns, pod){
|
|
|
1552
1630
|
toast(r.error||'restart failed');
|
|
1553
1631
|
if(r.error && r.error.length>60){ banner(esc(r.error)); setTimeout(()=>banner(''),10000); }
|
|
1554
1632
|
}
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
<div id="pdOut"><div class="empty"><span class="spin"></span> loading pod details…</div></div>`;
|
|
1560
|
-
const r=await api(`/api/aks/${CL.name}/${ns}/pods/${pod}/detail`);
|
|
1561
|
-
if(STATE.route!=='cluster') return; const out=$('#pdOut'); if(!out) return;
|
|
1562
|
-
if(!r.ok){ out.innerHTML=clErr(r); return; }
|
|
1563
|
-
const d=r.data; const labels=Object.entries(d.labels||{});
|
|
1564
|
-
const labelChips = labels.length
|
|
1565
|
-
? `<div class="tagchips">${labels.map(([k,v])=>`<span class="tagchip"><b>${esc(k)}</b>${v?' '+esc(v):''}</span>`).join('')}</div>`
|
|
1566
|
-
: '<span class="faint mono">no labels</span>';
|
|
1567
|
-
const cont=(d.containers||[]).map(c=>{
|
|
1633
|
+
let PDD={cluster:'',ns:'',pod:'',base:null,data:null,lastFocus:null};
|
|
1634
|
+
function renderPodDetail(p,d={}, {loading=false,ingressLoading=false,vaultLoading=false,error=''}={}){
|
|
1635
|
+
const labels=Object.entries(d.labels||{}).sort(([a],[b])=>a.localeCompare(b));
|
|
1636
|
+
const containers=(d.containers||[]).map(c=>{
|
|
1568
1637
|
const rows=(c.env||[]).map(e=>{
|
|
1569
|
-
const val
|
|
1570
|
-
:
|
|
1638
|
+
const val=e.value!=null?esc(e.value)
|
|
1639
|
+
:e.from?`<span class="faint">← ${esc(e.from.kind)} ${esc(e.from.name||'')}${e.from.key?'.'+esc(e.from.key):''}</span>`:'';
|
|
1571
1640
|
return `<tr><td class="mono"><b>${esc(e.name)}</b></td><td class="mono" style="word-break:break-all">${val}</td></tr>`;
|
|
1572
1641
|
}).join('');
|
|
1573
1642
|
const efRows=(c.envFrom||[]).map(ef=>`<tr><td class="mono faint">∗ all keys</td><td class="mono faint">← ${esc(ef.kind)} ${esc(ef.name||'')}${ef.prefix?` (prefix ${esc(ef.prefix)})`:''}${ef.kind==='secret'?' · values not shown':''}</td></tr>`).join('');
|
|
1574
|
-
const
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
<div class="
|
|
1580
|
-
<span class="meta">${c.tag?`<span class="tagchip">build ${esc(c.tag)}</span>`:''}</span></div>
|
|
1581
|
-
<div class="cmval">${esc(c.image||'')}</div>${body}</div>`;
|
|
1643
|
+
const env=rows||efRows?`<div class="panel" style="margin-top:8px"><div class="scroll" style="max-height:280px"><table>
|
|
1644
|
+
<thead><tr><th>Env var</th><th>Value / source</th></tr></thead><tbody>${rows}${efRows}</tbody></table></div></div>`
|
|
1645
|
+
:'<div class="faint mono" style="margin-top:7px">no env vars declared</div>';
|
|
1646
|
+
return `<div style="margin:16px 0"><div class="kvline" style="padding:4px 0"><span class="nm mono"><b>${esc(c.name)}</b>${c.init?' <span class="pill">init</span>':''}</span>
|
|
1647
|
+
<span class="meta">${c.tag?`<span class="tagchip">build ${esc(c.tag)}</span>`:''}</span></div>
|
|
1648
|
+
<div class="cmval">${esc(c.image||'')}</div>${env}</div>`;
|
|
1582
1649
|
}).join('');
|
|
1583
|
-
|
|
1584
|
-
|
|
1650
|
+
const overview=[
|
|
1651
|
+
['Cluster',PDD.cluster],['Namespace',p.namespace],['Status',p.phase||'?'],['Ready',p.ready||'—'],
|
|
1652
|
+
['Restarts',p.restarts??0],['Age',age(p.created)],['Created',p.created?new Date(p.created).toLocaleString():null],
|
|
1653
|
+
['Node',d.node],['Build',p.build||'—'],
|
|
1654
|
+
].filter(([,v])=>v!==null&&v!==undefined&&v!=='');
|
|
1655
|
+
$('#podDetailTitle').textContent=p.name;
|
|
1656
|
+
$('#podDetailBody').innerHTML=`
|
|
1657
|
+
<div class="poddetail-actions">
|
|
1658
|
+
<button class="btn sm ghost" onclick="closePodDetails();showLogs('${esc(p.namespace)}','${esc(p.name)}')">Logs</button>
|
|
1659
|
+
<button class="btn sm ghost" onclick="closePodDetails();openTerminal('${esc(p.namespace)}','${esc(p.name)}')">Terminal</button>
|
|
1660
|
+
<span class="push"></span>
|
|
1661
|
+
<button class="btn sm danger" title="rolling-restart the workload that owns this pod" onclick="restartPod('${esc(p.namespace)}','${esc(p.name)}',this)">Restart workload</button>
|
|
1662
|
+
</div>
|
|
1663
|
+
${error?`<div class="fwalert"><div><b>Pod details unavailable.</b><div class="hint">${esc(error)}</div></div></div>`:''}
|
|
1664
|
+
<section class="kvmeta-section"><div class="kvmeta-heading">Overview</div><div class="kvmeta-list">
|
|
1665
|
+
${overview.map(([k,v])=>`<div class="kvmeta-row"><div class="kvmeta-key">${esc(k)}</div><div class="kvmeta-value mono">${esc(v)}</div></div>`).join('')}
|
|
1666
|
+
</div></section>
|
|
1667
|
+
<section class="kvmeta-section"><div class="kvmeta-heading">Key Vault</div>
|
|
1668
|
+
${vaultLoading?'<div class="empty" style="padding:24px"><span class="spin"></span> tracing Key Vault references…</div>':kvCell(p)}
|
|
1669
|
+
</section>
|
|
1670
|
+
<section class="kvmeta-section"><div class="kvmeta-heading">Ingress</div>
|
|
1671
|
+
${ingressLoading?'<div class="empty" style="padding:24px"><span class="spin"></span> resolving ingress routes…</div>':podIngressDetails(p)}
|
|
1672
|
+
</section>
|
|
1673
|
+
<section class="kvmeta-section"><div class="kvmeta-heading">Labels · ${labels.length}</div>
|
|
1674
|
+
${labels.length?`<div class="kvmeta-tags">${labels.map(([k,v])=>`<div class="kvmeta-tag"><b>${esc(k)}</b><span>${esc(v)}</span></div>`).join('')}</div>`:'<div class="faint mono">no labels</div>'}
|
|
1675
|
+
</section>
|
|
1676
|
+
<section class="kvmeta-section"><div class="kvmeta-heading">Containers · image · environment</div>
|
|
1677
|
+
${loading?'<div class="empty" style="padding:24px"><span class="spin"></span> loading pod details…</div>':containers||'<div class="faint mono">no containers</div>'}
|
|
1678
|
+
</section>`;
|
|
1679
|
+
}
|
|
1680
|
+
async function openPodDetail(ns,pod){
|
|
1681
|
+
const p=CL.items.find(x=>x.name===pod&&x.namespace===ns)||{name:pod,namespace:ns,labels:{},restarts:0};
|
|
1682
|
+
const cluster=CL.name;
|
|
1683
|
+
PDD={cluster,ns,pod,base:p,data:null,lastFocus:document.activeElement};
|
|
1684
|
+
$('#podDetailOverlay').classList.add('show'); $('#podDetailOverlay').setAttribute('aria-hidden','false');
|
|
1685
|
+
$('#podDetailDrawer').classList.add('open'); $('#podDetailDrawer').setAttribute('aria-hidden','false');
|
|
1686
|
+
const ingressLoading=!CL.ingressMapByCluster[cluster];
|
|
1687
|
+
const vaultLoading=!CL.vaultMapByCluster[cluster];
|
|
1688
|
+
renderPodDetail(p,{}, {loading:true,ingressLoading,vaultLoading}); setTimeout(()=>$('#podDetailClose').focus(),0);
|
|
1689
|
+
let r;
|
|
1690
|
+
try{ [r]=await Promise.all([
|
|
1691
|
+
api(`/api/aks/${encodeURIComponent(cluster)}/${encodeURIComponent(ns)}/pods/${encodeURIComponent(pod)}/detail`),
|
|
1692
|
+
ensurePodIngressMap(cluster),
|
|
1693
|
+
ensurePodVaultMap(cluster),
|
|
1694
|
+
]); }catch(e){ r={ok:false,error:e.message||'Request failed'}; }
|
|
1695
|
+
if(PDD.cluster!==cluster||PDD.ns!==ns||PDD.pod!==pod)return;
|
|
1696
|
+
PDD.data=r.ok?r.data:null;
|
|
1697
|
+
renderPodDetail(p,r.ok?r.data:{},{error:r.ok?'':r.error||'Request failed'});
|
|
1698
|
+
}
|
|
1699
|
+
function closePodDetails(){
|
|
1700
|
+
if(!PDD.pod)return;
|
|
1701
|
+
$('#podDetailOverlay').classList.remove('show'); $('#podDetailOverlay').setAttribute('aria-hidden','true');
|
|
1702
|
+
$('#podDetailDrawer').classList.remove('open'); $('#podDetailDrawer').setAttribute('aria-hidden','true');
|
|
1703
|
+
const focus=PDD.lastFocus; PDD={cluster:'',ns:'',pod:'',base:null,data:null,lastFocus:null}; if(focus&&focus.focus)focus.focus();
|
|
1585
1704
|
}
|
|
1586
1705
|
function showLogs(ns,pod){
|
|
1587
1706
|
const p = CL.items.find(x=>x.name===pod&&x.namespace===ns) || {name:pod, containers:[]};
|
|
@@ -2026,10 +2145,16 @@ document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>navigate(b.data
|
|
|
2026
2145
|
$('#refresh').onclick=doRefresh;
|
|
2027
2146
|
$('#acctBtn').onclick=()=>navigate('settings');
|
|
2028
2147
|
$('#cmdkBtn').onclick=openCmdk;
|
|
2148
|
+
$('#kvMetaOverlay').onclick=closeVaultDetails;
|
|
2149
|
+
$('#kvMetaClose').onclick=closeVaultDetails;
|
|
2150
|
+
$('#podDetailOverlay').onclick=closePodDetails;
|
|
2151
|
+
$('#podDetailClose').onclick=closePodDetails;
|
|
2029
2152
|
$('#search').oninput=e=>{ STATE.q=e.target.value; if(STATE.route!=='vaults'){ navigate('vaults'); } else { renderVaultRows(); } };
|
|
2030
2153
|
$('#cmdkIn').oninput=e=>renderCmdk(e.target.value);
|
|
2031
2154
|
$('#cmdkOv').onclick=e=>{ if(e.target===$('#cmdkOv')) closeCmdk(); };
|
|
2032
2155
|
document.addEventListener('keydown',e=>{
|
|
2156
|
+
if(VMD.name){ if(e.key==='Escape')closeVaultDetails(); return; }
|
|
2157
|
+
if(PDD.pod){ if(e.key==='Escape')closePodDetails(); return; }
|
|
2033
2158
|
if((e.metaKey||e.ctrlKey) && e.key.toLowerCase()==='k'){ e.preventDefault(); CMDK.open?closeCmdk():openCmdk(); return; }
|
|
2034
2159
|
if(!CMDK.open){ if(e.key==='Escape' && STATE.route==='vault'){ navigate('vaults'); } return; }
|
|
2035
2160
|
if(e.key==='Escape'){ closeCmdk(); }
|
package/public/styles.css
CHANGED
|
@@ -342,6 +342,46 @@
|
|
|
342
342
|
.tagchip{font-family:var(--mono);font-size:10px;color:var(--muted);background:rgba(255,255,255,.04);
|
|
343
343
|
border:1px solid var(--line);border-radius:5px;padding:1px 7px;white-space:nowrap}
|
|
344
344
|
.tagchip b{color:var(--text);font-weight:500}
|
|
345
|
+
.tagdetails{display:inline-flex;align-items:center;gap:7px}
|
|
346
|
+
.tagdetails .n{font-family:var(--mono);font-size:9.5px;color:var(--azure);background:var(--azuredim);
|
|
347
|
+
border-radius:4px;padding:0 5px}
|
|
348
|
+
|
|
349
|
+
/* ---------- Key Vault metadata drawer ---------- */
|
|
350
|
+
.kvmeta-overlay{position:fixed;inset:0;z-index:75;background:rgba(5,8,14,.65);backdrop-filter:blur(2px);
|
|
351
|
+
opacity:0;pointer-events:none;transition:opacity .2s}
|
|
352
|
+
.kvmeta-overlay.show{opacity:1;pointer-events:auto}
|
|
353
|
+
.kvmeta-drawer{position:fixed;z-index:80;top:0;right:0;width:min(600px,94vw);height:100vh;
|
|
354
|
+
display:flex;flex-direction:column;background:var(--panel);border-left:1px solid var(--line);
|
|
355
|
+
box-shadow:-30px 0 80px -30px rgba(0,0,0,.9);transform:translateX(100%);
|
|
356
|
+
transition:transform .24s cubic-bezier(.2,.7,.2,1)}
|
|
357
|
+
.kvmeta-drawer.open{transform:translateX(0)}
|
|
358
|
+
.kvmeta-drawer header{padding:20px 22px;border-bottom:1px solid var(--line);display:flex;
|
|
359
|
+
align-items:flex-start;justify-content:space-between;gap:16px}
|
|
360
|
+
.kvmeta-drawer .dt{font-family:var(--mono);font-size:17px;font-weight:600;margin-top:4px;word-break:break-all}
|
|
361
|
+
.kvmeta-body{padding:20px 22px 30px;overflow:auto}
|
|
362
|
+
.kvmeta-section{margin-bottom:24px}
|
|
363
|
+
.kvmeta-section:last-child{margin-bottom:0}
|
|
364
|
+
.kvmeta-heading{font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;
|
|
365
|
+
color:var(--faint);margin-bottom:8px}
|
|
366
|
+
.kvmeta-list{border:1px solid var(--line);border-radius:var(--r-sm);overflow:hidden}
|
|
367
|
+
.kvmeta-row{display:grid;grid-template-columns:160px minmax(0,1fr);gap:14px;padding:9px 11px;
|
|
368
|
+
border-bottom:1px solid var(--line2);font-size:12px}
|
|
369
|
+
.kvmeta-row:last-child{border-bottom:0}
|
|
370
|
+
.kvmeta-key{font-family:var(--mono);font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--faint)}
|
|
371
|
+
.kvmeta-value{color:var(--text);overflow-wrap:anywhere;white-space:normal}
|
|
372
|
+
.kvmeta-tags{display:grid;gap:7px}
|
|
373
|
+
.kvmeta-tag{display:grid;grid-template-columns:minmax(130px,220px) minmax(0,1fr);gap:12px;
|
|
374
|
+
padding:9px 11px;background:var(--panel2);border:1px solid var(--line);border-radius:7px;
|
|
375
|
+
font-family:var(--mono);font-size:11px}
|
|
376
|
+
.kvmeta-tag b{font-weight:500;color:var(--text);overflow-wrap:anywhere}
|
|
377
|
+
.kvmeta-tag span{color:var(--muted);overflow-wrap:anywhere}
|
|
378
|
+
.kvmeta-actions{display:flex;gap:8px;align-items:center;margin-bottom:20px}
|
|
379
|
+
.poddetail-drawer{width:min(860px,96vw)}
|
|
380
|
+
.poddetail-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:20px}
|
|
381
|
+
.poddetail-actions .push{margin-left:auto}
|
|
382
|
+
.poddetail-ingress{padding:15px 16px;margin-bottom:10px}
|
|
383
|
+
.poddetail-ingress:last-child{margin-bottom:0}
|
|
384
|
+
@media (max-width:600px){.kvmeta-row,.kvmeta-tag{grid-template-columns:1fr;gap:4px}}
|
|
345
385
|
.kvchip{font-family:var(--mono);font-size:10.5px;color:var(--azure);background:var(--azuredim);
|
|
346
386
|
border:1px solid rgba(91,157,255,.35);border-radius:5px;padding:2px 8px;white-space:nowrap;cursor:pointer}
|
|
347
387
|
.kvchip:hover{border-color:var(--azure);background:rgba(91,157,255,.2)}
|
|
@@ -401,6 +441,8 @@
|
|
|
401
441
|
.cmkey .nm{font-size:12.5px}
|
|
402
442
|
.ell{display:inline-block;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom}
|
|
403
443
|
.ell.ns{max-width:200px}
|
|
444
|
+
th.pod-build,td.pod-build{width:80px;max-width:80px}
|
|
445
|
+
.pod-build .ell{max-width:80px}
|
|
404
446
|
.termhost{height:calc(100vh - 290px);min-height:340px;background:#070A11;border:1px solid var(--line);
|
|
405
447
|
border-radius:var(--r-sm);padding:8px 4px 8px 10px;overflow:hidden}
|
|
406
448
|
.termhost .xterm{height:100%}
|
package/server.js
CHANGED
|
@@ -460,6 +460,36 @@ app.get('/api/vaults', async (req, res) => {
|
|
|
460
460
|
}
|
|
461
461
|
});
|
|
462
462
|
|
|
463
|
+
// Full read-only management metadata for the inventory details drawer.
|
|
464
|
+
app.get('/api/vaults/:name/metadata', (req, res) => {
|
|
465
|
+
const { name } = req.params;
|
|
466
|
+
if (!isValidName(name)) return res.status(400).json({ ok: false, error: 'Invalid vault name' });
|
|
467
|
+
send(res, (async () => {
|
|
468
|
+
const v = await findVaultMeta(name);
|
|
469
|
+
if (!v) throw Object.assign(new Error('Vault not found in inventory — try Refresh first'), { status: 404, notFound: true });
|
|
470
|
+
const url = `/subscriptions/${v.subscriptionId}/resourceGroups/${v.resourceGroup}` +
|
|
471
|
+
`/providers/Microsoft.KeyVault/vaults/${name}?api-version=2023-07-01`;
|
|
472
|
+
const out = await armFetch(url, { sub: v.subscriptionId });
|
|
473
|
+
const p = out.properties || {}, acl = p.networkAcls || {};
|
|
474
|
+
return {
|
|
475
|
+
id: out.id || null, name: out.name || name, resourceGroup: v.resourceGroup,
|
|
476
|
+
subscriptionId: v.subscriptionId, location: out.location || v.location,
|
|
477
|
+
sku: (p.sku && p.sku.name) || v.sku || null, tenantId: p.tenantId || v.tenantId || null,
|
|
478
|
+
uri: p.vaultUri || v.uri || null, rbacEnabled: p.enableRbacAuthorization === true,
|
|
479
|
+
provisioningState: p.provisioningState || null,
|
|
480
|
+
softDeleteEnabled: p.enableSoftDelete !== false,
|
|
481
|
+
softDeleteRetentionDays: p.softDeleteRetentionInDays ?? null,
|
|
482
|
+
purgeProtectionEnabled: p.enablePurgeProtection === true,
|
|
483
|
+
publicNetworkAccess: p.publicNetworkAccess || null,
|
|
484
|
+
networkDefaultAction: acl.defaultAction || null, networkBypass: acl.bypass || null,
|
|
485
|
+
ipRuleCount: Array.isArray(acl.ipRules) ? acl.ipRules.length : 0,
|
|
486
|
+
vnetRuleCount: Array.isArray(acl.virtualNetworkRules) ? acl.virtualNetworkRules.length : 0,
|
|
487
|
+
privateEndpointCount: Array.isArray(p.privateEndpointConnections) ? p.privateEndpointConnections.length : 0,
|
|
488
|
+
account: v.account || null, tags: out.tags || v.tags || {},
|
|
489
|
+
};
|
|
490
|
+
})());
|
|
491
|
+
});
|
|
492
|
+
|
|
463
493
|
// Find a vault's management-plane coordinates (sub + rg) from the cached inventory.
|
|
464
494
|
async function findVaultMeta(name) {
|
|
465
495
|
const vaults = await listVaultsRaw();
|