akv-manager 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/public/index.html +540 -50
- package/public/styles.css +15 -0
- package/server.js +477 -59
package/public/index.html
CHANGED
|
@@ -72,6 +72,9 @@
|
|
|
72
72
|
<button data-view="clusters">
|
|
73
73
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 2l8 4.5v9L12 20l-8-4.5v-9L12 2z"/><path d="M12 7l4 2.2v4.6L12 16l-4-2.2V9.2L12 7z"/></svg>
|
|
74
74
|
Clusters</button>
|
|
75
|
+
<button data-view="projects">
|
|
76
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/></svg>
|
|
77
|
+
Projects</button>
|
|
75
78
|
</nav>
|
|
76
79
|
|
|
77
80
|
<div class="navlabel" id="pinLabel">Pinned</div>
|
|
@@ -126,7 +129,13 @@
|
|
|
126
129
|
const $ = s => document.querySelector(s);
|
|
127
130
|
const esc = s => String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
128
131
|
const api = async (p, opts) => { const r = await fetch(p, opts); return r.json(); };
|
|
129
|
-
|
|
132
|
+
const extractFirewallIp = (txt) => {
|
|
133
|
+
const m = /Client address:\s*([0-9]{1,3}(?:\.[0-9]{1,3}){3})/i.exec(String(txt||''));
|
|
134
|
+
return m ? m[1] : '';
|
|
135
|
+
};
|
|
136
|
+
let STATE = { vaults: [], subs: [], accounts: { users: [], activeSubscription: null }, account: null,
|
|
137
|
+
vaultWarnings: [], vaultsAggregate: false,
|
|
138
|
+
env: 'all', q: '', sort: 'name', dir: 1,
|
|
130
139
|
kind: 'secrets', drawerVault: null, route: 'dashboard', facets: {} };
|
|
131
140
|
let CFG = { envRules: null, title: null, version: null, protectedTags: [] };
|
|
132
141
|
function tagMatch(p,k){ p=String(p).toLowerCase(); k=String(k).toLowerCase(); return p.endsWith('*')?k.startsWith(p.slice(0,-1)):k===p; }
|
|
@@ -198,6 +207,7 @@ function serviceOf(rg){ return (rg||'').replace(/^rg[-_]?/i,'').replace(/[-_]?\d
|
|
|
198
207
|
|
|
199
208
|
// ---------- bootstrap ----------
|
|
200
209
|
async function boot(){
|
|
210
|
+
loadProjects();
|
|
201
211
|
const cfg = await api('/api/config'); if(cfg.ok){ CFG = {...CFG, ...cfg.data};
|
|
202
212
|
if(CFG.title){ document.title = CFG.title; const wm=document.querySelector('.brand .wm'); if(wm) wm.firstChild.textContent = CFG.title; } }
|
|
203
213
|
$('#ver').textContent = 'akv-manager v' + (CFG.version || '?');
|
|
@@ -211,16 +221,36 @@ async function boot(){
|
|
|
211
221
|
return;
|
|
212
222
|
}
|
|
213
223
|
STATE.account = acc.data;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
224
|
+
updateAcctButton();
|
|
225
|
+
await loadAccounts();
|
|
226
|
+
// Identity resolves even when the token is expired — so the app shell loads and the
|
|
227
|
+
// account switcher (Settings) stays reachable instead of dead-ending on a login wall.
|
|
228
|
+
if(acc.data.needLogin){
|
|
229
|
+
banner(`Session for <b>${esc(acc.data.user.name)}</b> has expired. <a href="#/settings">Switch account</a> or run <code>az login</code>, then press Refresh.`);
|
|
230
|
+
} else banner('');
|
|
218
231
|
await loadSubs();
|
|
219
232
|
await loadVaults();
|
|
220
233
|
renderSidebar();
|
|
221
234
|
router();
|
|
222
|
-
|
|
223
|
-
|
|
235
|
+
if(!acc.data.needLogin){
|
|
236
|
+
kickExpiryScan(); // warm the dashboard's lifecycle numbers in the background
|
|
237
|
+
registerSessionClusters().then(loadClustersCache); // register pasted clusters, then warm ⌘K
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Sidebar identity chip: name, active subscription, and a dot that goes amber when the
|
|
242
|
+
// current account's session has expired (so the user knows to switch or re-login).
|
|
243
|
+
function updateAcctButton(){
|
|
244
|
+
const a = STATE.account; if(!a) return;
|
|
245
|
+
$('#acctUser').textContent = a.user.name;
|
|
246
|
+
$('#acctSub').textContent = a.name || '';
|
|
247
|
+
$('#acctDot').style.background = a.needLogin ? 'var(--warn)' : 'var(--ok)';
|
|
248
|
+
$('#acctBtn').title = a.needLogin ? 'Session expired — click to switch account or re-login' : ('Signed in as ' + a.user.name);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function loadAccounts(){
|
|
252
|
+
const r = await api('/api/accounts');
|
|
253
|
+
STATE.accounts = r.ok ? r.data : { users: [], activeSubscription: null };
|
|
224
254
|
}
|
|
225
255
|
|
|
226
256
|
async function loadSubs(){
|
|
@@ -233,6 +263,8 @@ async function loadVaults(refresh){
|
|
|
233
263
|
const r = await api('/api/vaults'+(refresh?'?refresh=1':''));
|
|
234
264
|
if(!r.ok){ STATE.vaults=[]; return r; }
|
|
235
265
|
STATE.vaults = r.data.map(v => ({...v, env: vaultEnv(v), service: vaultService(v)}));
|
|
266
|
+
STATE.vaultWarnings = r.warnings || [];
|
|
267
|
+
STATE.vaultsAggregate = !!r.aggregate;
|
|
236
268
|
return r;
|
|
237
269
|
}
|
|
238
270
|
|
|
@@ -246,15 +278,16 @@ const META = {
|
|
|
246
278
|
vaults:['Inventory','Key Vaults','Every readable vault across your subscriptions. Filter by environment and tags; open one to manage it.'],
|
|
247
279
|
expiry:['Lifecycle','Expiring soon','Secrets and certificates that are expired or approaching expiry across every vault you can read.'],
|
|
248
280
|
clusters:['Kubernetes','Clusters','Read-only monitoring of your AKS clusters — browse pods, read logs and configmaps. Configured locally; runs as your az identity.'],
|
|
281
|
+
projects:['Workspace','Projects','Group workloads and key vaults into your own projects and environments. Stored in your browser; import/export as JSON to share.'],
|
|
249
282
|
settings:['Account','Account & subscriptions','Your Azure identity and the subscription the console is pointed at.'],
|
|
250
283
|
};
|
|
251
284
|
let mountedVault = null;
|
|
252
285
|
function setHead(view){
|
|
253
|
-
const detail = view==='vault' || view==='cluster'; // detail pages carry their own breadcrumb
|
|
286
|
+
const detail = view==='vault' || view==='cluster' || view==='project'; // detail pages carry their own breadcrumb
|
|
254
287
|
$('#headblock').style.display = detail ? 'none' : '';
|
|
255
288
|
$('#search').classList.toggle('hide', view!=='vaults' && view!=='dashboard' && view!=='expiry' && view!=='settings');
|
|
256
289
|
if(!detail && META[view]){ $('#eyebrow').textContent=META[view][0]; $('#title').textContent=META[view][1]; $('#subtitle').textContent=META[view][2]; }
|
|
257
|
-
const navActive = view==='vault'?'vaults' : view==='cluster'?'clusters' : view;
|
|
290
|
+
const navActive = view==='vault'?'vaults' : view==='cluster'?'clusters' : view==='project'?'projects' : view;
|
|
258
291
|
document.querySelectorAll('nav button').forEach(b=>b.classList.toggle('active', b.dataset.view===navActive));
|
|
259
292
|
}
|
|
260
293
|
function router(){
|
|
@@ -269,6 +302,8 @@ function router(){
|
|
|
269
302
|
else if(route==='expiry'){ setHead('expiry'); viewExpiry(); }
|
|
270
303
|
else if(route==='clusters'){ setHead('clusters'); viewClusters(); }
|
|
271
304
|
else if(route==='cluster'){ setHead('cluster'); viewCluster(parts[1], parts[2], parts[3]||'pods'); }
|
|
305
|
+
else if(route==='projects'){ setHead('projects'); viewProjects(); }
|
|
306
|
+
else if(route==='project'){ setHead('project'); viewProject(parts[1]); }
|
|
272
307
|
else if(route==='settings'){ setHead('settings'); viewSettings(); }
|
|
273
308
|
else { setHead('dashboard'); viewDashboard(); }
|
|
274
309
|
renderSidebar();
|
|
@@ -397,7 +432,12 @@ function viewVaults(){
|
|
|
397
432
|
<option value="">any</option>${vals.map(x=>`<option ${x===sel?'selected':''} value="${esc(x)}">${esc(x)}</option>`).join('')}</select></div>`;
|
|
398
433
|
}).join('');
|
|
399
434
|
const anyFacet = Object.values(STATE.facets).some(Boolean);
|
|
435
|
+
const agg = STATE.vaultsAggregate;
|
|
436
|
+
const warns = STATE.vaultWarnings || [];
|
|
437
|
+
const warnBar = warns.length ? `<div class="fwalert" style="background:linear-gradient(180deg,rgba(242,181,61,.14),rgba(242,181,61,.06));border-color:rgba(242,181,61,.5);color:#f4d089">
|
|
438
|
+
<div><b>Partial results.</b> Couldn't load ${warns.map(w=>esc(w.user)).join(', ')}${warns.some(w=>w.needLogin)?' — session expired. Run <code>az login</code> and press Refresh.':'.'}</div></div>` : '';
|
|
400
439
|
$('#route').innerHTML = `
|
|
440
|
+
${warnBar}
|
|
401
441
|
<div class="chips">${chips}</div>
|
|
402
442
|
${facets?`<div class="facets">${facets}${anyFacet?`<button class="facetclear" onclick="clearFacets()">clear filters</button>`:''}</div>`:''}
|
|
403
443
|
<div class="panel"><div class="scroll"><table>
|
|
@@ -405,7 +445,7 @@ function viewVaults(){
|
|
|
405
445
|
<th data-sort="pin" style="width:30px"></th>
|
|
406
446
|
<th data-sort="name">Vault</th><th data-sort="env">Env</th>
|
|
407
447
|
<th data-sort="service">Service</th><th>Tags</th><th data-sort="resourceGroup">Resource Group</th>
|
|
408
|
-
<th data-sort="sku">SKU</th><th>Auth</th
|
|
448
|
+
<th data-sort="sku">SKU</th><th>Auth</th>${agg?'<th>Account</th>':''}<th class="right">Actions</th>
|
|
409
449
|
</tr></thead><tbody id="vaultsBody"></tbody>
|
|
410
450
|
</table></div></div>
|
|
411
451
|
<div class="tablenote" id="vaultCount"></div>`;
|
|
@@ -417,8 +457,9 @@ function setFacet(k,val){ if(val) STATE.facets[k]=val; else delete STATE.facets[
|
|
|
417
457
|
function clearFacets(){ STATE.facets={}; viewVaults(); }
|
|
418
458
|
function renderVaultRows(){
|
|
419
459
|
const list = filteredVaults();
|
|
460
|
+
const agg = STATE.vaultsAggregate;
|
|
420
461
|
$('#vaultCount').textContent = `${list.length} vault${list.length!==1?'s':''} shown`;
|
|
421
|
-
if(!list.length){ $('#vaultsBody').innerHTML
|
|
462
|
+
if(!list.length){ $('#vaultsBody').innerHTML=`<tr><td colspan="${agg?10:9}" class="empty">No vaults match your filter.</td></tr>`; return; }
|
|
422
463
|
$('#vaultsBody').innerHTML = list.map(v=>{
|
|
423
464
|
const tags = Object.entries(v.tags||{}).filter(([k])=>!/^env$/i.test(k)).slice(0,2)
|
|
424
465
|
.map(([k,val])=>`<span class="tagchip"><b>${esc(k)}</b> ${esc(val)}</span>`).join('');
|
|
@@ -432,6 +473,7 @@ function renderVaultRows(){
|
|
|
432
473
|
<td class="mono muted">${esc(v.resourceGroup)}</td>
|
|
433
474
|
<td class="mono">${esc(v.sku||'—')}</td>
|
|
434
475
|
<td>${v.enableRbac==='true'?'<span class="pill rbac">RBAC</span>':'<span class="pill policy">Policy</span>'}</td>
|
|
476
|
+
${agg?`<td class="mono muted" title="tenant ${esc(v.tenantId||'')}">${esc(v.account||'—')}</td>`:''}
|
|
435
477
|
<td class="right">
|
|
436
478
|
<button class="iconbtn" onclick="openVault('${esc(v.name)}')">Open</button>
|
|
437
479
|
<button class="iconbtn" onclick="copy('az keyvault secret list --vault-name ${esc(v.name)} -o table')">⧉</button>
|
|
@@ -442,13 +484,21 @@ function renderVaultRows(){
|
|
|
442
484
|
let DRW = { vault:'', kind:'secrets', items:[], filter:'', sel:new Set() };
|
|
443
485
|
function openVault(name){ pushRecent(name); navigate('vault/'+encodeURIComponent(name)); }
|
|
444
486
|
const KIND_TABS = [['secrets','Secrets'],['keys','Keys'],['certificates','Certificates'],['tags','Tags']];
|
|
487
|
+
const fwIpKey = (vault) => `akv.fwAllowedIp.${String(vault||'').toLowerCase()}`;
|
|
488
|
+
function fwIpGet(vault){ try{ return sessionStorage.getItem(fwIpKey(vault)) || ''; } catch { return ''; } }
|
|
489
|
+
function fwIpSet(vault, ip){
|
|
490
|
+
try {
|
|
491
|
+
if(ip) sessionStorage.setItem(fwIpKey(vault), ip);
|
|
492
|
+
else sessionStorage.removeItem(fwIpKey(vault));
|
|
493
|
+
} catch { /* ignore */ }
|
|
494
|
+
}
|
|
445
495
|
function viewVault(name, kind){
|
|
446
496
|
const v = findVault(name) || (name?{name, subscriptionId:'', resourceGroup:'', uri:'', enableRbac:'', tags:{}}:null);
|
|
447
497
|
if(!v){ $('#route').innerHTML=`<div class="panel"><div class="empty">No vault specified. <a href="#/vaults">Back to Key Vaults</a></div></div>`; return; }
|
|
448
498
|
STATE.drawerVault=v; STATE.kind=kind;
|
|
449
499
|
if(mountedVault!==name || !document.getElementById('vbody')){
|
|
450
500
|
mountedVault=name;
|
|
451
|
-
DRW={vault:name, kind, items:[], filter:'', sel:new Set()};
|
|
501
|
+
DRW={vault:name, kind, items:[], filter:'', sel:new Set(), firewallIp:'', tempAllowedIp:fwIpGet(name)};
|
|
452
502
|
const tabs = KIND_TABS.map(([k,l])=>`<button data-kind="${k}" class="${k===kind?'active':''}" onclick="goKind('${esc(name)}','${k}')">${l}</button>`).join('');
|
|
453
503
|
$('#route').innerHTML = `
|
|
454
504
|
<div class="vhead">
|
|
@@ -483,6 +533,7 @@ function renderItemsShell(){
|
|
|
483
533
|
</div>
|
|
484
534
|
<div class="vtools-row" id="selRow"><label class="selall"><input type="checkbox" id="dSelAll"> select all</label></div>
|
|
485
535
|
</div>
|
|
536
|
+
<div id="fwCtl"></div>
|
|
486
537
|
<div class="createp hide" id="dCreate"></div>
|
|
487
538
|
<div class="batchbar hide stick" id="dBatch"></div>
|
|
488
539
|
<div class="itemwrap" id="dContent"><div class="empty"><span class="spin"></span></div></div>
|
|
@@ -491,6 +542,7 @@ function renderItemsShell(){
|
|
|
491
542
|
$('#dFilter').oninput=e=>{ DRW.filter=e.target.value; renderDrawerList(); };
|
|
492
543
|
$('#dSelAll').onchange=e=>selectAll(e.target.checked);
|
|
493
544
|
if(isSecrets){ $('#dNew').onclick=()=>openCreate('single'); $('#dNewBatch').onclick=()=>openCreate('batch'); }
|
|
545
|
+
renderFirewallControl();
|
|
494
546
|
renderCmds(STATE.drawerVault);
|
|
495
547
|
loadKind();
|
|
496
548
|
}
|
|
@@ -512,12 +564,17 @@ async function loadKind(){
|
|
|
512
564
|
if(!r.ok){
|
|
513
565
|
DRW.items=[]; $('#dBatch').classList.add('hide'); if($('#dCount'))$('#dCount').textContent='';
|
|
514
566
|
let why;
|
|
515
|
-
if(r.firewall)
|
|
567
|
+
if(r.firewall){
|
|
568
|
+
DRW.firewallIp = extractFirewallIp(r.error||'');
|
|
569
|
+
const ipText = DRW.firewallIp ? `<div class="hint" style="margin-top:8px">Detected IP: <span class="mono">${esc(DRW.firewallIp)}</span></div>` : '';
|
|
570
|
+
why = `🚫 This vault's firewall is blocking your network — your current IP isn't allowed.${ipText}<div class="row" style="margin-top:10px;justify-content:center"><button class="btn sm" onclick="allowVaultFirewallIp()">Allow this IP in Key Vault firewall</button></div><div class="hint" style="margin-top:8px">${esc(r.error||'')}</div>`;
|
|
571
|
+
}
|
|
516
572
|
else if(r.forbidden) why = `🔒 You can see this vault but have no data-plane permission on its ${DRW.kind}.`;
|
|
517
573
|
else why = esc(r.error||'failed');
|
|
518
574
|
$('#dContent').innerHTML = `<div class="empty">${why}</div>`;
|
|
519
575
|
return;
|
|
520
576
|
}
|
|
577
|
+
DRW.firewallIp = '';
|
|
521
578
|
DRW.items = (r.data||[]).map(item=>({
|
|
522
579
|
name: item.name || (item.id||'').split('/').pop(),
|
|
523
580
|
enabled: item.attributes ? item.attributes.enabled!==false : true,
|
|
@@ -526,6 +583,58 @@ async function loadKind(){
|
|
|
526
583
|
}));
|
|
527
584
|
renderDrawerList();
|
|
528
585
|
}
|
|
586
|
+
|
|
587
|
+
async function allowVaultFirewallIp(){
|
|
588
|
+
if(!DRW.vault) return;
|
|
589
|
+
const body = DRW.firewallIp ? { ip: DRW.firewallIp } : {};
|
|
590
|
+
const r = await api(`/api/vaults/${DRW.vault}/firewall/allow-ip`, {
|
|
591
|
+
method: 'POST',
|
|
592
|
+
headers: {'Content-Type':'application/json'},
|
|
593
|
+
body: JSON.stringify(body),
|
|
594
|
+
});
|
|
595
|
+
if(!r.ok){
|
|
596
|
+
toast(r.forbidden ? 'No permission to change Key Vault firewall' : (r.error||'failed to allow IP'));
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
DRW.tempAllowedIp = (r.data && r.data.ip) || DRW.firewallIp || '';
|
|
600
|
+
fwIpSet(DRW.vault, DRW.tempAllowedIp);
|
|
601
|
+
renderFirewallControl();
|
|
602
|
+
toast(r.data && r.data.alreadyAllowed ? 'IP is already allowed in firewall' : `IP ${r.data.ip} added to Key Vault firewall`);
|
|
603
|
+
await loadKind();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function renderFirewallControl(){
|
|
607
|
+
const box = $('#fwCtl');
|
|
608
|
+
if(!box) return;
|
|
609
|
+
const ip = DRW.tempAllowedIp || '';
|
|
610
|
+
if(!ip){ box.innerHTML = ''; return; }
|
|
611
|
+
box.innerHTML = `<div class="fwalert"><div><b>Firewall temporariamente liberado:</b> IP <span class="mono">${esc(ip)}</span> foi adicionado neste vault. Remova após concluir o uso.</div><button class="btn sm danger" onclick="removeVaultFirewallIp()">Remover IP do Key Vault</button></div>`;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function removeVaultFirewallIp(){
|
|
615
|
+
if(!DRW.vault || !DRW.tempAllowedIp) return;
|
|
616
|
+
const r = await api(`/api/vaults/${DRW.vault}/firewall/remove-ip`, {
|
|
617
|
+
method: 'POST',
|
|
618
|
+
headers: {'Content-Type':'application/json'},
|
|
619
|
+
body: JSON.stringify({ ip: DRW.tempAllowedIp }),
|
|
620
|
+
});
|
|
621
|
+
if(!r.ok){
|
|
622
|
+
toast(r.forbidden ? 'No permission to remove IP from Key Vault firewall' : (r.error||'failed to remove IP'));
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const removed = !!(r.data && r.data.removed);
|
|
626
|
+
if(removed){
|
|
627
|
+
toast(`IP ${DRW.tempAllowedIp} removed from Key Vault firewall`);
|
|
628
|
+
DRW.tempAllowedIp = '';
|
|
629
|
+
fwIpSet(DRW.vault, '');
|
|
630
|
+
renderFirewallControl();
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
toast('IP was not present in firewall rules');
|
|
634
|
+
DRW.tempAllowedIp = '';
|
|
635
|
+
fwIpSet(DRW.vault, '');
|
|
636
|
+
renderFirewallControl();
|
|
637
|
+
}
|
|
529
638
|
function drawerFiltered(){ const q=DRW.filter.toLowerCase(); return DRW.items.filter(i=>!q||i.name.toLowerCase().includes(q)); }
|
|
530
639
|
function renderDrawerList(){
|
|
531
640
|
const list = drawerFiltered(); const editable = isEditable();
|
|
@@ -938,29 +1047,100 @@ function renderHorizon(items){
|
|
|
938
1047
|
// ---------- SETTINGS (account + subscriptions) ----------
|
|
939
1048
|
function viewSettings(){
|
|
940
1049
|
const counts={}; STATE.vaults.forEach(v=>counts[v.subscriptionId]=(counts[v.subscriptionId]||0)+1);
|
|
1050
|
+
const acct = STATE.account || {};
|
|
1051
|
+
const users = (STATE.accounts && STATE.accounts.users) || [];
|
|
1052
|
+
const totalSubs = users.reduce((n,u)=>n+u.subscriptions.length,0);
|
|
1053
|
+
const expired = !!acct.needLogin;
|
|
1054
|
+
|
|
1055
|
+
// One panel per logged-in identity (az login), listing its subscriptions. Switching
|
|
1056
|
+
// to a subscription re-points the console at that user + tenant — see setSub().
|
|
1057
|
+
const userPanel = u => {
|
|
1058
|
+
const isCurrent = u.subscriptions.some(s=>s.active);
|
|
1059
|
+
const tenant = (u.subscriptions[0]||{}).tenantId || '';
|
|
1060
|
+
return `
|
|
1061
|
+
<div class="panel" style="margin-bottom:16px">
|
|
1062
|
+
<div class="row" style="justify-content:space-between;padding:0 2px 12px">
|
|
1063
|
+
<div class="row" style="gap:9px">
|
|
1064
|
+
<span class="dot" style="background:${isCurrent?'var(--ok)':'var(--faint)'}"></span>
|
|
1065
|
+
<b>${esc(u.user)}</b>
|
|
1066
|
+
${isCurrent?'<span class="pill rbac">current</span>':''}
|
|
1067
|
+
</div>
|
|
1068
|
+
<span class="mono faint" style="font-size:11px">${u.subscriptions.length} subscription${u.subscriptions.length===1?'':'s'} · tenant ${esc(tenant.slice(0,8))}…</span>
|
|
1069
|
+
</div>
|
|
1070
|
+
<div class="scroll"><table>
|
|
1071
|
+
<thead><tr><th>Subscription</th><th>Env</th><th>State</th><th>Active</th><th class="right">Vaults</th><th>Subscription ID</th><th></th></tr></thead>
|
|
1072
|
+
<tbody>${u.subscriptions.map(s=>{
|
|
1073
|
+
const env = envLabel(s.name);
|
|
1074
|
+
return `<tr>
|
|
1075
|
+
<td><b>${esc(s.name)}</b></td>
|
|
1076
|
+
<td><span class="tag ${envClass(env)}">${esc(env)}</span></td>
|
|
1077
|
+
<td>${esc(s.state)}</td>
|
|
1078
|
+
<td>${s.active?'<span class="pill rbac">active</span>':(s.cliDefault?'<span class="pill policy">cli default</span>':'')}</td>
|
|
1079
|
+
<td class="right mono">${counts[s.id]||''}</td>
|
|
1080
|
+
<td class="mono muted">${esc(s.id)}</td>
|
|
1081
|
+
<td class="right">${s.active?'':`<button class="btn sm ghost" onclick="setSub('${esc(s.id)}')">Switch</button>`}</td>
|
|
1082
|
+
</tr>`;
|
|
1083
|
+
}).join('')}</tbody>
|
|
1084
|
+
</table></div>
|
|
1085
|
+
</div>`;
|
|
1086
|
+
};
|
|
1087
|
+
|
|
1088
|
+
const aggregate = !!acct.aggregate;
|
|
1089
|
+
// "All accounts": merge every logged-in user & tenant into one view. Sits above the
|
|
1090
|
+
// per-user panels; enabling it clears any single-account selection (see setAllAccounts).
|
|
1091
|
+
const allPanel = `
|
|
1092
|
+
<div class="panel" style="margin-bottom:16px;border-color:${aggregate?'var(--azure)':'var(--line)'}">
|
|
1093
|
+
<div class="row" style="justify-content:space-between;padding:2px">
|
|
1094
|
+
<div class="row" style="gap:9px">
|
|
1095
|
+
<span class="dot" style="background:${aggregate?'var(--ok)':'var(--faint)'}"></span>
|
|
1096
|
+
<b>All accounts</b>
|
|
1097
|
+
${aggregate?'<span class="pill rbac">active</span>':''}
|
|
1098
|
+
<span class="mono faint" style="font-size:11px">merge every user & tenant · ${users.length} user${users.length===1?'':'s'} · ${totalSubs} sub${totalSubs===1?'':'s'}</span>
|
|
1099
|
+
</div>
|
|
1100
|
+
${aggregate?'':`<button class="btn sm" onclick="setAllAccounts()">Use all accounts</button>`}
|
|
1101
|
+
</div>
|
|
1102
|
+
</div>`;
|
|
941
1103
|
$('#route').innerHTML = `
|
|
942
1104
|
<div class="cards" style="margin-bottom:24px">
|
|
943
|
-
${card('Signed in as', '',
|
|
944
|
-
${card('Tenant', (
|
|
945
|
-
${card('
|
|
1105
|
+
${card('Signed in as', '', (acct.user&&acct.user.name)||'—','')}
|
|
1106
|
+
${card('Tenant', aggregate?'all':((acct.tenantId||'').slice(0,8)+'…'), aggregate?'every tenant':(expired?'session expired':'active directory'),'')}
|
|
1107
|
+
${card('Accounts', users.length, totalSubs+' subscription'+(totalSubs===1?'':'s'),'azure')}
|
|
946
1108
|
</div>
|
|
947
|
-
|
|
948
|
-
<
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1109
|
+
${expired?`<div style="margin-bottom:18px;padding:11px 14px;border-radius:8px;background:rgba(242,181,61,.12);border:1px solid rgba(242,181,61,.4);color:#f4d089">
|
|
1110
|
+
This account's session has expired. Switch to another account below, or run <code>az login</code> and press Refresh.</div>`:''}
|
|
1111
|
+
<div class="hint" style="margin:0 2px 12px">Multiple <code>az login</code> identities? Switch the active user/subscription — or use <b>All accounts</b> to see every tenant at once. Everything (vaults, clusters) re-loads as the chosen scope.</div>
|
|
1112
|
+
${allPanel}
|
|
1113
|
+
${users.length?users.map(userPanel).join(''):'<div class="panel"><div class="empty">No Azure accounts found. Run <code>az login</code>, then Refresh.</div></div>'}`;
|
|
1114
|
+
}
|
|
1115
|
+
// Reload the app under a new identity scope, preserving the toast/banner/refresh flow.
|
|
1116
|
+
async function applyScope(r, okMsg){
|
|
1117
|
+
STATE.account = r.data;
|
|
1118
|
+
updateAcctButton();
|
|
1119
|
+
await loadAccounts();
|
|
1120
|
+
await loadSubs();
|
|
1121
|
+
await loadVaults(true); // scope changed → force a fresh inventory
|
|
1122
|
+
renderSidebar();
|
|
1123
|
+
if(r.data.needLogin){
|
|
1124
|
+
banner(`Switched to <b>${esc(r.data.user.name)}</b>, but its session needs re-login. Run <code>${esc(r.data.loginHint||'az login')}</code>, then press Refresh.`);
|
|
1125
|
+
toast('Switched — this account needs re-login');
|
|
1126
|
+
} else {
|
|
1127
|
+
banner('');
|
|
1128
|
+
toast(okMsg);
|
|
1129
|
+
kickExpiryScan();
|
|
1130
|
+
}
|
|
1131
|
+
router(); // re-render the current page with the new scope
|
|
1132
|
+
}
|
|
1133
|
+
async function setAllAccounts(){
|
|
1134
|
+
toast('Loading all accounts…');
|
|
1135
|
+
const r = await api('/api/subscription/set',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({all:true})});
|
|
1136
|
+
if(!r.ok){ toast(r.error||'failed'); return; }
|
|
1137
|
+
await applyScope(r, 'Now showing all accounts');
|
|
959
1138
|
}
|
|
960
1139
|
async function setSub(id){
|
|
961
|
-
toast('Switching
|
|
1140
|
+
toast('Switching account…');
|
|
962
1141
|
const r = await api('/api/subscription/set',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({subscription:id})});
|
|
963
|
-
if(r.ok){
|
|
1142
|
+
if(!r.ok){ toast(r.error||'failed to switch'); return; }
|
|
1143
|
+
await applyScope(r, 'Signed in as '+r.data.user.name);
|
|
964
1144
|
}
|
|
965
1145
|
|
|
966
1146
|
function card(k,v,sub,mod){
|
|
@@ -968,7 +1148,7 @@ function card(k,v,sub,mod){
|
|
|
968
1148
|
}
|
|
969
1149
|
|
|
970
1150
|
// ---------- CLUSTERS (AKS — read-only: pods, logs, configmaps) ----------
|
|
971
|
-
let CL = { all:null, name:'', cluster:null, ns:'', kind:'pods', items:[], namespaces:[], logPod:null, filter:'', tier:'all', cm:{} };
|
|
1151
|
+
let CL = { all:null, configuredCount:0, name:'', cluster:null, ns:'', kind:'pods', items:[], namespaces:[], logPod:null, filter:'', tier:'all', cm:{}, podsByCluster:{}, cmByCluster:{}, vaultMap:{}, vaultMapByCluster:{}, vaultRestricted:{}, vaultRestrictedByCluster:{}, ingressMap:{}, ingressMapByCluster:{} };
|
|
972
1152
|
let mountedCluster = null;
|
|
973
1153
|
function age(iso){ if(!iso) return ''; const s=(Date.now()-Date.parse(iso))/1000;
|
|
974
1154
|
if(s<3600) return Math.max(1,Math.floor(s/60))+'m'; if(s<86400) return Math.floor(s/3600)+'h'; return Math.floor(s/86400)+'d'; }
|
|
@@ -978,7 +1158,7 @@ function clErr(r){
|
|
|
978
1158
|
if(r.forbidden) return `<div class="empty">🔒 Your identity has no Kubernetes-RBAC permission for this on this cluster.<div class="hint" style="margin-top:8px">${esc(r.error||'')}</div></div>`;
|
|
979
1159
|
return `<div class="empty">${esc(r.error||'failed')}</div>`;
|
|
980
1160
|
}
|
|
981
|
-
async function loadClustersCache(){ const r=await api('/api/aks/clusters'); if(r.ok) CL.all=r.data.clusters||[]; return CL.all; }
|
|
1161
|
+
async function loadClustersCache(force){ const r=await api('/api/aks/clusters'+(force?'?refresh=1':'')); if(r.ok){ CL.all=r.data.clusters||[]; CL.configuredCount=r.data.configured; } return CL.all; }
|
|
982
1162
|
|
|
983
1163
|
// Clusters the user pasted, kept in this browser. Registered with the local server each
|
|
984
1164
|
// load so the data-plane endpoints (which validate the cluster) accept them.
|
|
@@ -991,15 +1171,18 @@ async function registerSessionClusters(){
|
|
|
991
1171
|
try{ await api('/api/aks/clusters',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clusters:arr})}); }catch{}
|
|
992
1172
|
}
|
|
993
1173
|
|
|
994
|
-
async function viewClusters(){
|
|
1174
|
+
async function viewClusters(force){
|
|
1175
|
+
if(!force && CL.all){ renderClustersPage(); return; } // reuse cache; refetch only on explicit refresh
|
|
995
1176
|
$('#route').innerHTML = `<div class="panel"><div class="empty"><span class="spin"></span> loading clusters…</div></div>`;
|
|
996
|
-
await
|
|
997
|
-
const r = await api('/api/aks/clusters');
|
|
1177
|
+
const r = await api('/api/aks/clusters'+(force?'?refresh=1':''));
|
|
998
1178
|
if(STATE.route!=='clusters') return;
|
|
999
1179
|
if(!r.ok){ $('#route').innerHTML = `<div class="panel">${clErr(r)}</div>`; return; }
|
|
1000
1180
|
CL.all = r.data.clusters||[];
|
|
1001
1181
|
CL.configuredCount = r.data.configured;
|
|
1002
|
-
|
|
1182
|
+
renderClustersPage();
|
|
1183
|
+
}
|
|
1184
|
+
function renderClustersPage(){
|
|
1185
|
+
const envs=[...new Set((CL.all||[]).map(c=>c.env).filter(Boolean))].sort();
|
|
1003
1186
|
const envSeg = envs.length>1 ? `<div class="seg" id="clEnvSeg">
|
|
1004
1187
|
<button data-e="all" class="${CL.clEnv==='all'||!CL.clEnv?'active':''}">All</button>
|
|
1005
1188
|
${envs.map(e=>`<button data-e="${esc(e)}" class="${CL.clEnv===e?'active':''}">${esc(e)}</button>`).join('')}</div>` : '';
|
|
@@ -1039,14 +1222,14 @@ async function saveClusterList(){
|
|
|
1039
1222
|
msg.textContent='saving…';
|
|
1040
1223
|
const r=await api('/api/aks/clusters',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clusters:arr})});
|
|
1041
1224
|
if(!r.ok){ msg.textContent=r.error||'failed'; return; }
|
|
1042
|
-
setLsAks(raw); CL.all=null; toast(`Saved ${r.data.count} cluster${r.data.count!==1?'s':''}`);
|
|
1043
|
-
|
|
1225
|
+
setLsAks(raw); CL.all=null; CL.podsByCluster={}; CL.cmByCluster={}; CL.vaultMapByCluster={}; CL.ingressMapByCluster={}; toast(`Saved ${r.data.count} cluster${r.data.count!==1?'s':''}`);
|
|
1226
|
+
viewClusters();
|
|
1044
1227
|
}
|
|
1045
1228
|
function clearClusterList(){
|
|
1046
1229
|
if(!confirm('Remove the clusters stored in this browser? (file / env clusters stay.)')) return;
|
|
1047
1230
|
setLsAks('');
|
|
1048
1231
|
api('/api/aks/clusters',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clusters:[]})})
|
|
1049
|
-
.then(()=>{ toast('Cleared stored clusters'); CL.all=null;
|
|
1232
|
+
.then(()=>{ toast('Cleared stored clusters'); CL.all=null; viewClusters(); });
|
|
1050
1233
|
}
|
|
1051
1234
|
function renderClusterRows(){
|
|
1052
1235
|
if(!CL.all.length){
|
|
@@ -1094,7 +1277,7 @@ function viewCluster(name){
|
|
|
1094
1277
|
<div id="clBody"><div class="empty"><span class="spin"></span> loading…</div></div>`;
|
|
1095
1278
|
$('#clKind').querySelectorAll('button').forEach(b=>b.onclick=()=>{ CL.kind=b.dataset.k;
|
|
1096
1279
|
$('#clKind').querySelectorAll('button').forEach(x=>x.classList.toggle('active',x===b)); loadClusterContent(); });
|
|
1097
|
-
$('#clRefresh').onclick=()=>{ if(CL.logPod) fetchLogs(); else loadClusterContent(); };
|
|
1280
|
+
$('#clRefresh').onclick=()=>{ if(CL.logPod) fetchLogs(); else loadClusterContent(true); };
|
|
1098
1281
|
enrichClusterMeta(name);
|
|
1099
1282
|
loadClusterContent();
|
|
1100
1283
|
} else {
|
|
@@ -1111,9 +1294,9 @@ async function enrichClusterMeta(name){
|
|
|
1111
1294
|
if($('#clMeta')) $('#clMeta').innerHTML = `<span class="mono">${esc(c.name)}</span><span class="sep">·</span>`+
|
|
1112
1295
|
`<span class="mono">${esc(c.resourceGroup||'')}</span>${c.private?'<span class="sep">·</span><span class="pill policy">private · VPN</span>':''}`;
|
|
1113
1296
|
}
|
|
1114
|
-
function loadClusterContent(){
|
|
1297
|
+
function loadClusterContent(force){
|
|
1115
1298
|
CL.logPod=null; CL.termPod=null; CL.filter=''; teardownTerm();
|
|
1116
|
-
|
|
1299
|
+
if(force){ delete CL.podsByCluster[CL.name]; delete CL.cmByCluster[CL.name]; delete CL.vaultMapByCluster[CL.name]; delete CL.ingressMapByCluster[CL.name]; } // explicit refresh re-fetches
|
|
1117
1300
|
if(CL.kind==='pods') loadPods(); else loadConfigMaps();
|
|
1118
1301
|
}
|
|
1119
1302
|
function clFilterBar(placeholder, extra){
|
|
@@ -1131,10 +1314,113 @@ const TIER_RE = {
|
|
|
1131
1314
|
function podTier(nm){ nm=(nm||'').toLowerCase(); if(TIER_RE.cache.test(nm))return'cache'; if(TIER_RE.fe.test(nm))return'fe'; if(TIER_RE.be.test(nm))return'be'; return'other'; }
|
|
1132
1315
|
async function loadPods(){
|
|
1133
1316
|
const name=CL.name;
|
|
1317
|
+
CL.vaultMap = CL.vaultMapByCluster[name] || {};
|
|
1318
|
+
if(CL.podsByCluster[name]){ CL.items=CL.podsByCluster[name]; renderPodsShell(); ensurePodVaultMap(name); return; } // cached
|
|
1319
|
+
$('#clBody').innerHTML='<div class="empty"><span class="spin"></span></div>';
|
|
1134
1320
|
const r = await api(`/api/aks/${name}/pods`);
|
|
1135
1321
|
if(STATE.route!=='cluster'||CL.name!==name||CL.kind!=='pods'||CL.logPod||CL.termPod) return;
|
|
1136
1322
|
if(!r.ok){ $('#clBody').innerHTML=clErr(r); return; }
|
|
1137
|
-
CL.items=r.data||[];
|
|
1323
|
+
CL.items=r.data||[]; CL.podsByCluster[name]=CL.items;
|
|
1324
|
+
renderPodsShell();
|
|
1325
|
+
ensurePodVaultMap(name);
|
|
1326
|
+
ensurePodIngressMap(name);
|
|
1327
|
+
}
|
|
1328
|
+
// Resolve each namespace's exposed Services → Ingress hosts once per cluster, then repaint
|
|
1329
|
+
// so the Ingress column fills in. Pods are matched to a Service by label⊇selector.
|
|
1330
|
+
async function ensurePodIngressMap(name){
|
|
1331
|
+
if(CL.ingressMapByCluster[name]){ CL.ingressMap=CL.ingressMapByCluster[name]; return; }
|
|
1332
|
+
const pods = CL.podsByCluster[name] || CL.items || [];
|
|
1333
|
+
const nss = [...new Set(pods.map(p=>p.namespace).filter(Boolean))];
|
|
1334
|
+
if(!nss.length){ CL.ingressMapByCluster[name]={}; CL.ingressMap={}; return; }
|
|
1335
|
+
const r=await api(`/api/aks/${name}/ingress-map?namespaces=${encodeURIComponent(nss.join(','))}`);
|
|
1336
|
+
CL.ingressMapByCluster[name] = (r.ok && r.data && r.data.byNamespace) ? r.data.byNamespace : {};
|
|
1337
|
+
if(CL.name!==name) return;
|
|
1338
|
+
CL.ingressMap = CL.ingressMapByCluster[name];
|
|
1339
|
+
if(STATE.route==='cluster' && CL.kind==='pods' && !CL.logPod && !CL.termPod) renderPodRows();
|
|
1340
|
+
}
|
|
1341
|
+
// Match a pod to the Ingress(es) routing to it: Services whose selector ⊆ the pod's
|
|
1342
|
+
// labels, then Ingresses whose backend references those Services.
|
|
1343
|
+
function podMatchedSvcNames(p){
|
|
1344
|
+
const nsm = CL.ingressMap && CL.ingressMap[p.namespace]; if(!nsm) return [];
|
|
1345
|
+
const labels = p.labels || {};
|
|
1346
|
+
return (nsm.services||[]).filter(s=>{ const sel=s.selector||{}; const keys=Object.keys(sel);
|
|
1347
|
+
return keys.length && keys.every(k=>labels[k]===sel[k]); }).map(s=>s.name);
|
|
1348
|
+
}
|
|
1349
|
+
function podIngresses(p){
|
|
1350
|
+
const nsm = CL.ingressMap && CL.ingressMap[p.namespace]; if(!nsm) return [];
|
|
1351
|
+
const names = new Set(podMatchedSvcNames(p));
|
|
1352
|
+
return (nsm.ingresses||[]).filter(i=>(i.backendServices||[]).some(s=>names.has(s)));
|
|
1353
|
+
}
|
|
1354
|
+
function podIngressHosts(p){ const hs=new Set(); podIngresses(p).forEach(i=>(i.hosts||[]).forEach(h=>hs.add(h))); return [...hs]; }
|
|
1355
|
+
function ingCell(p){
|
|
1356
|
+
const hs = podIngressHosts(p);
|
|
1357
|
+
if(!hs.length) return '<span class="faint mono">—</span>';
|
|
1358
|
+
const label = hs[0] + (hs.length>1?` +${hs.length-1}`:'');
|
|
1359
|
+
return `<button class="ingchip" title="${esc(hs.join(', '))} — click for ingress config" onclick="openPodIngress('${esc(p.namespace)}','${esc(p.name)}')">🌐 <span class="ell" style="max-width:220px;vertical-align:bottom">${esc(label)}</span></button>`;
|
|
1360
|
+
}
|
|
1361
|
+
// Ingress config panel: routing (host/path/backend:port), TLS + cert-manager, class,
|
|
1362
|
+
// forced-HTTPS, CORS and body-size — for the ingress(es) fronting this pod.
|
|
1363
|
+
function openPodIngress(ns,pod){
|
|
1364
|
+
const p = CL.items.find(x=>x.name===pod&&x.namespace===ns) || {name:pod, namespace:ns, labels:{}};
|
|
1365
|
+
const ings = podIngresses(p);
|
|
1366
|
+
CL.logPod=null; CL.termPod=null; teardownTerm();
|
|
1367
|
+
$('#clBody').innerHTML = `<div class="cmctl"><button class="iconbtn" onclick="loadClusterContent()">← Pods</button>
|
|
1368
|
+
<span class="mono"><b>${esc(pod)}</b></span><span class="faint mono">${esc(ns)}</span><span class="faint mono">Ingress</span></div>
|
|
1369
|
+
<div id="pingOut"></div>`;
|
|
1370
|
+
const out=$('#pingOut'); if(!out) return;
|
|
1371
|
+
if(!ings.length){ out.innerHTML=`<div class="empty">No Ingress routes to this pod's service.</div>`; return; }
|
|
1372
|
+
const line=(k,v)=> v?`<div class="kvline" style="padding:5px 0"><span class="nm mono">${esc(k)}</span><span class="meta mono">${v}</span></div>`:'';
|
|
1373
|
+
out.innerHTML = ings.map(i=>{
|
|
1374
|
+
const routes=(i.routes||[]).map(r=>`<tr><td class="mono">${esc(r.host||'*')}</td><td class="mono">${esc(r.path||'/')}</td><td class="mono muted">${esc(r.pathType||'')}</td><td class="mono">${esc(r.service||'')}${r.port!=null?':'+esc(String(r.port)):''}</td></tr>`).join('');
|
|
1375
|
+
const tls=(i.tls||[]).map(t=>`<span class="tagchip">🔒 ${esc(t.secretName||'tls')}${t.hosts&&t.hosts.length?' · '+esc(t.hosts.join(', ')):''}</span>`).join(' ');
|
|
1376
|
+
const cm=i.certManager?[i.certManager.issuer,i.certManager.challenge,i.certManager.dnsProvider].filter(Boolean).map(esc).join(' · '):'';
|
|
1377
|
+
const cors=i.cors?`origin <b>${esc(i.cors.origin||'*')}</b> · ${esc(i.cors.methods||'')}`:'';
|
|
1378
|
+
return `<div class="panel" style="margin-bottom:16px;padding:16px 18px">
|
|
1379
|
+
<div class="row" style="justify-content:space-between;margin-bottom:10px;gap:10px">
|
|
1380
|
+
<b class="mono">${esc(i.name)}</b>
|
|
1381
|
+
<div class="tagchips">${i.hosts.map(h=>`<a class="ingchip" href="https://${esc(h)}" target="_blank" rel="noopener noreferrer" title="Open https://${esc(h)}">🌐 ${esc(h)} ↗</a>`).join('')}</div>
|
|
1382
|
+
</div>
|
|
1383
|
+
${line('class', i.className?`<span class="tagchip">${esc(i.className)}</span>`:'')}
|
|
1384
|
+
${line('TLS', tls)}
|
|
1385
|
+
${line('cert-manager', cm)}
|
|
1386
|
+
${line('HTTPS', i.sslRedirect?`forced redirect${i.allowHttp!=null?` · allow-http ${esc(i.allowHttp)}`:''}`:'')}
|
|
1387
|
+
${line('CORS', cors)}
|
|
1388
|
+
${line('max body', i.proxyBodySize?esc(i.proxyBodySize):'')}
|
|
1389
|
+
<div class="secthead" style="margin-top:12px">Routes</div>
|
|
1390
|
+
<div class="scroll"><table><thead><tr><th>Host</th><th>Path</th><th>Type</th><th>Backend service:port</th></tr></thead><tbody>${routes}</tbody></table></div>
|
|
1391
|
+
</div>`;
|
|
1392
|
+
}).join('');
|
|
1393
|
+
}
|
|
1394
|
+
// Resolve each namespace's Key Vault(s) once per cluster, then repaint the rows so the
|
|
1395
|
+
// KV column fills in. Best-effort: if the secret-source CRDs aren't readable it stays empty.
|
|
1396
|
+
async function ensurePodVaultMap(name){
|
|
1397
|
+
if(CL.vaultMapByCluster[name]){ CL.vaultMap=CL.vaultMapByCluster[name]; CL.vaultRestricted=CL.vaultRestrictedByCluster[name]||{}; return; }
|
|
1398
|
+
const pods = CL.podsByCluster[name] || CL.items || [];
|
|
1399
|
+
const nss = [...new Set(pods.map(p=>p.namespace).filter(Boolean))];
|
|
1400
|
+
if(!nss.length){ CL.vaultMapByCluster[name]={}; CL.vaultMap={}; return; }
|
|
1401
|
+
const r=await api(`/api/aks/${name}/secret-vaults?namespaces=${encodeURIComponent(nss.join(','))}`);
|
|
1402
|
+
CL.vaultMapByCluster[name] = (r.ok && r.data && r.data.byNamespace) ? r.data.byNamespace : {};
|
|
1403
|
+
CL.vaultRestrictedByCluster[name] = (r.ok && r.data && r.data.restricted) ? r.data.restricted : {};
|
|
1404
|
+
if(CL.name!==name) return;
|
|
1405
|
+
CL.vaultMap = CL.vaultMapByCluster[name];
|
|
1406
|
+
CL.vaultRestricted = CL.vaultRestrictedByCluster[name];
|
|
1407
|
+
if(STATE.route==='cluster' && CL.kind==='pods' && !CL.logPod && !CL.termPod) renderPodRows();
|
|
1408
|
+
}
|
|
1409
|
+
function podVaults(p){ return (CL.vaultMap && CL.vaultMap[p.namespace]) || []; }
|
|
1410
|
+
// The KV cell: a chip per Key Vault serving the pod's namespace. Clicking a known vault
|
|
1411
|
+
// jumps straight to its secrets; otherwise it opens the precise per-pod trace panel.
|
|
1412
|
+
// When the namespace's External Secrets CRDs aren't readable (RBAC), say so plainly.
|
|
1413
|
+
function kvCell(p){
|
|
1414
|
+
const vs = podVaults(p);
|
|
1415
|
+
if(vs.length) return `<div class="tagchips">${vs.map(name=>{ const known=findVault(name);
|
|
1416
|
+
return `<button class="kvchip${known?'':' unknown'}" title="${known?'Open '+esc(name)+' secrets':esc(name)+' — not in your visible inventory (click to trace)'}" onclick="${known?`openVault('${esc(name)}')`:`openPodVaults('${esc(p.namespace)}','${esc(p.name)}')`}">🔑 ${esc(name)}</button>`;
|
|
1417
|
+
}).join('')}</div>`;
|
|
1418
|
+
if(CL.vaultRestricted && CL.vaultRestricted[p.namespace])
|
|
1419
|
+
return `<button class="kvchip unknown" title="Your identity can't read SecretStores on this cluster (Kubernetes RBAC). Click to see details." onclick="openPodVaults('${esc(p.namespace)}','${esc(p.name)}')">🔒 restricted</button>`;
|
|
1420
|
+
return `<button class="iconbtn" title="trace this pod's Key Vault via CSI / External Secrets / env" onclick="openPodVaults('${esc(p.namespace)}','${esc(p.name)}')">trace</button>`;
|
|
1421
|
+
}
|
|
1422
|
+
function renderPodsShell(){
|
|
1423
|
+
if(CL.pendingFilter){ CL.filter=CL.pendingFilter; CL.pendingFilter=null; } // from a project "Open →"
|
|
1138
1424
|
const tier = `<div class="seg" id="clTier">
|
|
1139
1425
|
<button data-t="all" class="${CL.tier==='all'?'active':''}">All</button>
|
|
1140
1426
|
<button data-t="be" class="${CL.tier==='be'?'active':''}">Backend</button>
|
|
@@ -1158,7 +1444,7 @@ function renderPodRows(){
|
|
|
1158
1444
|
if(!CL.items.length){ $('#clList').innerHTML=`<div class="empty">No pods visible on this cluster.</div>`; return; }
|
|
1159
1445
|
if(!list.length){ $('#clList').innerHTML=`<div class="empty">No pods match your filter.</div>`; return; }
|
|
1160
1446
|
$('#clList').innerHTML = `<div class="panel"><div class="scroll"><table>
|
|
1161
|
-
<thead><tr><th>Pod</th><th>Namespace</th><th>Build</th><th>Ready</th><th>Status</th><th class="right">Restarts</th><th>Age</th><th class="right">Actions</th></tr></thead>
|
|
1447
|
+
<thead><tr><th>Pod</th><th>Namespace</th><th>Build</th><th>Ready</th><th>Status</th><th class="right">Restarts</th><th>Age</th><th>Ingress</th><th>Key Vault</th><th class="right">Actions</th></tr></thead>
|
|
1162
1448
|
<tbody>${list.map(p=>`<tr>
|
|
1163
1449
|
<td class="mono"><b><span class="ell" title="${esc(p.name)}">${esc(p.name)}</span></b></td>
|
|
1164
1450
|
<td class="mono muted"><span class="ell ns" title="${esc(p.namespace||'')}">${esc(p.namespace||'')}</span></td>
|
|
@@ -1167,10 +1453,11 @@ function renderPodRows(){
|
|
|
1167
1453
|
<td><span class="phase ${esc(p.phase||'')}">${esc(p.phase||'?')}</span></td>
|
|
1168
1454
|
<td class="right mono ${p.restarts>0?'':'muted'}">${p.restarts}</td>
|
|
1169
1455
|
<td class="mono muted">${age(p.created)}</td>
|
|
1456
|
+
<td>${ingCell(p)}</td>
|
|
1457
|
+
<td>${kvCell(p)}</td>
|
|
1170
1458
|
<td class="right" style="white-space:nowrap"><button class="iconbtn" onclick="openPodDetail('${esc(p.namespace)}','${esc(p.name)}')">Details</button>
|
|
1171
1459
|
<button class="iconbtn" onclick="showLogs('${esc(p.namespace)}','${esc(p.name)}')">Logs</button>
|
|
1172
|
-
<button class="iconbtn" onclick="openTerminal('${esc(p.namespace)}','${esc(p.name)}')">Terminal</button>
|
|
1173
|
-
<button class="iconbtn" onclick="openPodVaults('${esc(p.namespace)}','${esc(p.name)}')">Vaults</button></td>
|
|
1460
|
+
<button class="iconbtn" onclick="openTerminal('${esc(p.namespace)}','${esc(p.name)}')">Terminal</button></td>
|
|
1174
1461
|
</tr>`).join('')}</tbody></table></div></div>`;
|
|
1175
1462
|
}
|
|
1176
1463
|
async function openPodDetail(ns,pod){
|
|
@@ -1253,10 +1540,15 @@ function downloadLog(){
|
|
|
1253
1540
|
}
|
|
1254
1541
|
async function loadConfigMaps(){
|
|
1255
1542
|
const name=CL.name;
|
|
1543
|
+
if(CL.cmByCluster[name]){ CL.items=CL.cmByCluster[name]; renderCmShell(); return; } // cached
|
|
1544
|
+
$('#clBody').innerHTML='<div class="empty"><span class="spin"></span></div>';
|
|
1256
1545
|
const r=await api(`/api/aks/${name}/configmaps`);
|
|
1257
1546
|
if(STATE.route!=='cluster'||CL.name!==name||CL.kind!=='configmaps'||CL.logPod||CL.termPod) return;
|
|
1258
1547
|
if(!r.ok){ $('#clBody').innerHTML=clErr(r); return; }
|
|
1259
|
-
CL.items=r.data||[];
|
|
1548
|
+
CL.items=r.data||[]; CL.cmByCluster[name]=CL.items;
|
|
1549
|
+
renderCmShell();
|
|
1550
|
+
}
|
|
1551
|
+
function renderCmShell(){
|
|
1260
1552
|
$('#clBody').innerHTML = clFilterBar('filter configmaps by name or namespace…');
|
|
1261
1553
|
$('#clFilter').oninput=e=>{ CL.filter=e.target.value; renderCmRows(); };
|
|
1262
1554
|
renderCmRows();
|
|
@@ -1293,6 +1585,190 @@ async function viewCm(ns,name){
|
|
|
1293
1585
|
}
|
|
1294
1586
|
function copyCm(ckey,k){ const d=CL.cm[ckey]||{}; copy(d[k]!=null?String(d[k]):''); }
|
|
1295
1587
|
|
|
1588
|
+
// ---------- PROJECTS (user-defined grouping: project → envs → workloads + key vaults) ----------
|
|
1589
|
+
// Stored entirely in the browser (localStorage). Import/export as JSON to share.
|
|
1590
|
+
// Shape: { version:1, projects:[ { id, name, envs:[ { name, workloads:[{cluster,namespace,name,base,label}], vaults:[{name,label}] } ] } ] }
|
|
1591
|
+
let PROJ = { data:{ version:1, projects:[] } };
|
|
1592
|
+
function loadProjects(){ try{ const t=localStorage.getItem('akv.projects'); const d=t?JSON.parse(t):null; PROJ.data=(d&&Array.isArray(d.projects))?d:{version:1,projects:[]}; }catch{ PROJ.data={version:1,projects:[]}; } }
|
|
1593
|
+
function saveProjects(){ try{ localStorage.setItem('akv.projects', JSON.stringify(PROJ.data)); }catch{} }
|
|
1594
|
+
function genId(){ return 'p'+Math.random().toString(36).slice(2,9); }
|
|
1595
|
+
const projById = id => PROJ.data.projects.find(p=>p.id===id);
|
|
1596
|
+
function podBase(n){ return (n||'').replace(/-[a-z0-9]{8,10}-[a-z0-9]{4,5}$/,'').replace(/-[0-9]+$/,''); }
|
|
1597
|
+
function downloadJson(fn,obj){ const blob=new Blob([JSON.stringify(obj,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=fn; a.click(); URL.revokeObjectURL(url); }
|
|
1598
|
+
|
|
1599
|
+
function viewProjects(){
|
|
1600
|
+
const ps=PROJ.data.projects;
|
|
1601
|
+
$('#route').innerHTML = `
|
|
1602
|
+
<div class="cmctl" style="margin-bottom:14px">
|
|
1603
|
+
<button class="btn sm" onclick="newProject()">+ New project</button>
|
|
1604
|
+
<button class="btn ghost sm" onclick="openImport()">⬇ Import</button>
|
|
1605
|
+
<button class="btn ghost sm" onclick="exportAll()">⬆ Export all</button>
|
|
1606
|
+
<span class="faint mono" style="margin-left:auto">${ps.length} project${ps.length!==1?'s':''}</span></div>
|
|
1607
|
+
<div class="createp hide" id="projIo"></div>
|
|
1608
|
+
${ps.length ? `<div class="vtiles">${ps.map(p=>{
|
|
1609
|
+
const w=p.envs.reduce((a,e)=>a+(e.workloads||[]).length,0), v=p.envs.reduce((a,e)=>a+(e.vaults||[]).length,0);
|
|
1610
|
+
return `<div class="vtile" onclick="navigate('project/${esc(p.id)}')">
|
|
1611
|
+
<span class="nm">${esc(p.name)}</span>
|
|
1612
|
+
<span class="rg mono">${p.envs.length} env${p.envs.length!==1?'s':''} · ${w} workload${w!==1?'s':''} · ${v} vault${v!==1?'s':''}</span></div>`;
|
|
1613
|
+
}).join('')}</div>`
|
|
1614
|
+
: `<div class="panel"><div class="empty" style="padding:42px;line-height:1.7">No projects yet.<br>
|
|
1615
|
+
<span class="faint mono" style="font-size:12px">Create one to group workloads and key vaults by environment — or Import a shared config.</span></div></div>`}`;
|
|
1616
|
+
}
|
|
1617
|
+
function newProject(){ const name=prompt('Project name (e.g. Marketplace)'); if(!name||!name.trim()) return;
|
|
1618
|
+
const p={ id:genId(), name:name.trim(), envs:[] }; PROJ.data.projects.push(p); saveProjects(); navigate('project/'+p.id); }
|
|
1619
|
+
function exportAll(){ downloadJson('akv-projects.json', PROJ.data); }
|
|
1620
|
+
function exportProject(id){ const p=projById(id); if(!p)return; downloadJson('akv-project-'+p.name.replace(/\W+/g,'-').toLowerCase()+'.json',{version:1,projects:[p]}); }
|
|
1621
|
+
function openImport(){
|
|
1622
|
+
const el=$('#projIo');
|
|
1623
|
+
el.innerHTML=`<h4>Import projects</h4>
|
|
1624
|
+
<div class="sub" style="margin-bottom:8px">Paste a projects JSON exported from here. Imported projects are added; a project with the same id is replaced.</div>
|
|
1625
|
+
<div class="fld" style="align-items:flex-start"><textarea id="impJson" spellcheck="false" placeholder='{ "version":1, "projects":[ … ] }' style="min-height:180px"></textarea></div>
|
|
1626
|
+
<div class="actions"><button class="btn sm" onclick="doImport()">Import</button>
|
|
1627
|
+
<button class="btn sm ghost" onclick="document.getElementById('projIo').classList.add('hide')">Cancel</button>
|
|
1628
|
+
<span class="hint" id="impMsg"></span></div>`;
|
|
1629
|
+
el.classList.remove('hide');
|
|
1630
|
+
}
|
|
1631
|
+
function normEnv(e){ return { name:String((e&&e.name)||'env'),
|
|
1632
|
+
workloads:Array.isArray(e&&e.workloads)?e.workloads.filter(w=>w&&w.cluster&&w.namespace&&w.name).map(w=>({cluster:String(w.cluster),namespace:String(w.namespace),name:String(w.name),base:String(w.base||podBase(w.name)),label:String(w.label||'')})):[],
|
|
1633
|
+
vaults:Array.isArray(e&&e.vaults)?e.vaults.filter(v=>v&&v.name).map(v=>({name:String(v.name),label:String(v.label||'')})):[] }; }
|
|
1634
|
+
function doImport(){
|
|
1635
|
+
const msg=$('#impMsg'); let d;
|
|
1636
|
+
try{ d=JSON.parse($('#impJson').value); }catch(e){ msg.textContent='Invalid JSON: '+e.message; return; }
|
|
1637
|
+
const incoming = Array.isArray(d)?d:(d&&Array.isArray(d.projects)?d.projects:null);
|
|
1638
|
+
if(!incoming){ msg.textContent='Expected { projects:[…] } or an array of projects'; return; }
|
|
1639
|
+
let n=0;
|
|
1640
|
+
for(const p of incoming){ if(!p||!p.name) continue;
|
|
1641
|
+
const np={ id:p.id||genId(), name:String(p.name), envs:Array.isArray(p.envs)?p.envs.map(normEnv):[] };
|
|
1642
|
+
const i=PROJ.data.projects.findIndex(x=>x.id===np.id);
|
|
1643
|
+
if(i>=0) PROJ.data.projects[i]=np; else PROJ.data.projects.push(np); n++; }
|
|
1644
|
+
saveProjects(); toast(`Imported ${n} project${n!==1?'s':''}`); viewProjects();
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
function viewProject(id){
|
|
1648
|
+
const p=projById(id);
|
|
1649
|
+
if(!p){ $('#route').innerHTML=`<div class="panel"><div class="empty">Project not found. <a href="#/projects">Back to Projects</a></div></div>`; return; }
|
|
1650
|
+
$('#route').innerHTML = `
|
|
1651
|
+
<div class="vhead">
|
|
1652
|
+
<div class="crumb"><a href="#/projects">Projects</a><span class="sep">/</span><span class="here">${esc(p.name)}</span></div>
|
|
1653
|
+
<div class="vmeta">
|
|
1654
|
+
<button class="iconbtn" onclick="renameProject('${esc(id)}')">Rename</button>
|
|
1655
|
+
<button class="iconbtn" onclick="exportProject('${esc(id)}')">Export</button>
|
|
1656
|
+
<button class="iconbtn" onclick="deleteProject('${esc(id)}')">Delete</button></div>
|
|
1657
|
+
</div>
|
|
1658
|
+
<div class="row" style="margin-bottom:14px"><button class="btn sm" onclick="addEnv('${esc(id)}')">+ Add environment</button></div>
|
|
1659
|
+
<div id="projBody"></div>`;
|
|
1660
|
+
renderProjectBody(id);
|
|
1661
|
+
}
|
|
1662
|
+
function renderProjectBody(id){
|
|
1663
|
+
const p=projById(id); if(!p) return;
|
|
1664
|
+
if(!p.envs.length){ $('#projBody').innerHTML=`<div class="panel"><div class="empty" style="padding:34px">No environments yet — add one (e.g. dev, test, qa, prod).</div></div>`; return; }
|
|
1665
|
+
$('#projBody').innerHTML = p.envs.map((e,ei)=>`
|
|
1666
|
+
<div class="panel" style="margin-bottom:16px;padding:16px 18px">
|
|
1667
|
+
<div class="secthead2" style="margin-bottom:12px">
|
|
1668
|
+
<span class="tag ${envClass(e.name)}" style="font-size:11px">${esc(e.name)}</span>
|
|
1669
|
+
<span class="row"><button class="iconbtn" onclick="renameEnv('${esc(id)}',${ei})">Rename</button>
|
|
1670
|
+
<button class="iconbtn" onclick="deleteEnv('${esc(id)}',${ei})">Delete</button></span>
|
|
1671
|
+
</div>
|
|
1672
|
+
<div class="eyebrow" style="margin-bottom:8px">Workloads</div>
|
|
1673
|
+
${renderEnvWorkloads(id,ei,e)}
|
|
1674
|
+
<button class="btn ghost sm" style="margin-top:8px" onclick="addWorkload('${esc(id)}',${ei})">+ Link workload</button>
|
|
1675
|
+
<div class="eyebrow" style="margin:18px 0 8px">Key Vaults</div>
|
|
1676
|
+
${renderEnvVaults(id,ei,e)}
|
|
1677
|
+
<button class="btn ghost sm" style="margin-top:8px" onclick="addVault('${esc(id)}',${ei})">+ Link Key Vault</button>
|
|
1678
|
+
<div class="createp hide" id="picker-${esc(id)}-${ei}"></div>
|
|
1679
|
+
</div>`).join('');
|
|
1680
|
+
}
|
|
1681
|
+
function renderEnvWorkloads(id,ei,e){
|
|
1682
|
+
const w=e.workloads||[];
|
|
1683
|
+
if(!w.length) return '<div class="faint mono" style="font-size:12px">none linked</div>';
|
|
1684
|
+
return `<div class="vtiles">${w.map((x,xi)=>`
|
|
1685
|
+
<div class="vtile" style="cursor:default">
|
|
1686
|
+
<span class="nm">${esc(x.label||x.base||x.name)}</span>
|
|
1687
|
+
<span class="rg mono">${esc(x.cluster)} · ${esc(x.namespace)}</span>
|
|
1688
|
+
<button class="iconbtn" onclick="openWorkload('${esc(id)}',${ei},${xi})">Open →</button>
|
|
1689
|
+
<button class="iconbtn" onclick="rmItem('${esc(id)}',${ei},'workloads',${xi})">✕</button>
|
|
1690
|
+
</div>`).join('')}</div>`;
|
|
1691
|
+
}
|
|
1692
|
+
function renderEnvVaults(id,ei,e){
|
|
1693
|
+
const v=e.vaults||[];
|
|
1694
|
+
if(!v.length) return '<div class="faint mono" style="font-size:12px">none linked</div>';
|
|
1695
|
+
return `<div class="vtiles">${v.map((x,xi)=>{ const known=findVault(x.name);
|
|
1696
|
+
return `<div class="vtile" style="cursor:default">
|
|
1697
|
+
<span class="nm mono">${esc(x.name)}</span>
|
|
1698
|
+
<span class="rg mono">${esc(x.label||'')}</span>
|
|
1699
|
+
${known?`<button class="iconbtn" onclick="openVault('${esc(known.name)}')">Open →</button>`:'<span class="faint mono" style="font-size:11px">not visible</span>'}
|
|
1700
|
+
<button class="iconbtn" onclick="rmItem('${esc(id)}',${ei},'vaults',${xi})">✕</button>
|
|
1701
|
+
</div>`; }).join('')}</div>`;
|
|
1702
|
+
}
|
|
1703
|
+
function openWorkload(id,ei,xi){ const x=projById(id).envs[ei].workloads[xi]; CL.pendingFilter=x.base||x.name; openCluster(x.cluster); }
|
|
1704
|
+
function rmItem(id,ei,key,xi){ projById(id).envs[ei][key].splice(xi,1); saveProjects(); renderProjectBody(id); }
|
|
1705
|
+
function addEnv(id){ const name=prompt('Environment name (e.g. dev)'); if(!name||!name.trim())return;
|
|
1706
|
+
projById(id).envs.push({name:name.trim(), workloads:[], vaults:[]}); saveProjects(); renderProjectBody(id); }
|
|
1707
|
+
function renameEnv(id,ei){ const e=projById(id).envs[ei]; const name=prompt('Rename environment', e.name); if(name==null)return; e.name=name.trim()||e.name; saveProjects(); renderProjectBody(id); }
|
|
1708
|
+
function deleteEnv(id,ei){ const p=projById(id); if(!confirm('Delete environment "'+p.envs[ei].name+'"?'))return; p.envs.splice(ei,1); saveProjects(); renderProjectBody(id); }
|
|
1709
|
+
function renameProject(id){ const p=projById(id); const name=prompt('Rename project', p.name); if(name==null)return; p.name=name.trim()||p.name; saveProjects(); viewProject(id); }
|
|
1710
|
+
function deleteProject(id){ const p=projById(id); if(!confirm('Delete project "'+p.name+'"? This cannot be undone.'))return; PROJ.data.projects=PROJ.data.projects.filter(x=>x.id!==id); saveProjects(); navigate('projects'); }
|
|
1711
|
+
function closePicker(id,ei){ const el=document.getElementById('picker-'+id+'-'+ei); if(el){ el.classList.add('hide'); el.innerHTML=''; } }
|
|
1712
|
+
|
|
1713
|
+
function addWorkload(id,ei){
|
|
1714
|
+
const el=document.getElementById('picker-'+id+'-'+ei);
|
|
1715
|
+
const clusters=CL.all||[];
|
|
1716
|
+
if(!clusters.length){ el.innerHTML=`<div class="hint">No clusters available — add them under <a href="#/clusters">Clusters</a> first.</div>`; el.classList.remove('hide'); return; }
|
|
1717
|
+
el.innerHTML = `<h4>Link a workload (pod)</h4>
|
|
1718
|
+
<div class="fld"><label>Cluster</label>
|
|
1719
|
+
<select class="facetsel" id="pkC" style="max-width:240px">${clusters.map(c=>`<option value="${esc(c.name)}">${esc(c.label||c.name)}</option>`).join('')}</select>
|
|
1720
|
+
<input class="search" id="pkQ" placeholder="search pods by name or namespace…" style="flex:1;min-width:0"></div>
|
|
1721
|
+
<div id="pkResults" class="itemwrap" style="max-height:300px;overflow:auto"></div>
|
|
1722
|
+
<div class="actions"><button class="btn sm ghost" onclick="closePicker('${esc(id)}',${ei})">Close</button><span class="hint" id="pkMsg"></span></div>`;
|
|
1723
|
+
el.classList.remove('hide');
|
|
1724
|
+
const run=()=>pkSearchPods(id,ei); $('#pkC').onchange=run; $('#pkQ').oninput=run; run();
|
|
1725
|
+
}
|
|
1726
|
+
async function pkSearchPods(id,ei){
|
|
1727
|
+
const cluster=$('#pkC').value, q=$('#pkQ').value.toLowerCase(), box=$('#pkResults');
|
|
1728
|
+
let pods=CL.podsByCluster[cluster];
|
|
1729
|
+
if(!pods){ box.innerHTML='<div class="empty"><span class="spin"></span> loading pods…</div>';
|
|
1730
|
+
const r=await api(`/api/aks/${cluster}/pods`); if(!r.ok){ box.innerHTML=clErr(r); return; } pods=r.data||[]; CL.podsByCluster[cluster]=pods; }
|
|
1731
|
+
const list=pods.filter(p=>!q||p.name.toLowerCase().includes(q)||(p.namespace||'').toLowerCase().includes(q)).slice(0,50);
|
|
1732
|
+
if(!list.length){ box.innerHTML='<div class="faint mono" style="font-size:12px;padding:8px">No pods match.</div>'; return; }
|
|
1733
|
+
box.innerHTML = list.map(p=>`<div class="kvitem"><div class="kvline">
|
|
1734
|
+
<span class="nm" title="${esc(p.name)}">${esc(p.name)}</span>
|
|
1735
|
+
<span class="meta"><span class="faint mono" style="font-size:11px">${esc(p.namespace)}</span>
|
|
1736
|
+
<button class="iconbtn" onclick="pickWorkload('${esc(id)}',${ei},'${esc(cluster)}','${esc(p.namespace)}','${esc(p.name)}')">Link</button></span>
|
|
1737
|
+
</div></div>`).join('');
|
|
1738
|
+
}
|
|
1739
|
+
function pickWorkload(id,ei,cluster,ns,name){
|
|
1740
|
+
const e=projById(id).envs[ei]; e.workloads=e.workloads||[];
|
|
1741
|
+
const base=podBase(name);
|
|
1742
|
+
if(!e.workloads.some(x=>x.cluster===cluster&&x.namespace===ns&&x.base===base)){ e.workloads.push({cluster,namespace:ns,name,base,label:''}); saveProjects(); toast('Workload linked'); }
|
|
1743
|
+
else toast('Already linked');
|
|
1744
|
+
renderProjectBody(id);
|
|
1745
|
+
}
|
|
1746
|
+
function addVault(id,ei){
|
|
1747
|
+
const el=document.getElementById('picker-'+id+'-'+ei);
|
|
1748
|
+
el.innerHTML = `<h4>Link a Key Vault</h4>
|
|
1749
|
+
<div class="fld"><label>Search</label><input class="search" id="pkVQ" placeholder="search vaults by name…" style="flex:1;min-width:0"></div>
|
|
1750
|
+
<div id="pkVResults" class="itemwrap" style="max-height:300px;overflow:auto"></div>
|
|
1751
|
+
<div class="actions"><button class="btn sm ghost" onclick="closePicker('${esc(id)}',${ei})">Close</button></div>`;
|
|
1752
|
+
el.classList.remove('hide');
|
|
1753
|
+
$('#pkVQ').oninput=()=>pkSearchVaults(id,ei); pkSearchVaults(id,ei);
|
|
1754
|
+
}
|
|
1755
|
+
function pkSearchVaults(id,ei){
|
|
1756
|
+
const q=$('#pkVQ').value.toLowerCase(), box=$('#pkVResults');
|
|
1757
|
+
const list=(STATE.vaults||[]).filter(v=>!q||v.name.toLowerCase().includes(q)||(v.service||'').toLowerCase().includes(q)).slice(0,50);
|
|
1758
|
+
if(!list.length){ box.innerHTML='<div class="faint mono" style="font-size:12px;padding:8px">No vaults match.</div>'; return; }
|
|
1759
|
+
box.innerHTML = list.map(v=>`<div class="kvitem"><div class="kvline">
|
|
1760
|
+
<span class="nm mono" title="${esc(v.name)}">${esc(v.name)}</span>
|
|
1761
|
+
<span class="meta"><span class="tag ${envClass(v.env)}">${esc(v.env)}</span>
|
|
1762
|
+
<button class="iconbtn" onclick="pickVault('${esc(id)}',${ei},'${esc(v.name)}')">Link</button></span>
|
|
1763
|
+
</div></div>`).join('');
|
|
1764
|
+
}
|
|
1765
|
+
function pickVault(id,ei,name){
|
|
1766
|
+
const e=projById(id).envs[ei]; e.vaults=e.vaults||[];
|
|
1767
|
+
if(!e.vaults.some(x=>x.name===name)){ e.vaults.push({name,label:''}); saveProjects(); toast('Key Vault linked'); }
|
|
1768
|
+
else toast('Already linked');
|
|
1769
|
+
renderProjectBody(id);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1296
1772
|
// ---------- which Key Vaults does a pod use ----------
|
|
1297
1773
|
async function openPodVaults(ns,pod){
|
|
1298
1774
|
CL.logPod=null; CL.termPod=null; teardownTerm();
|
|
@@ -1316,8 +1792,13 @@ async function openPodVaults(ns,pod){
|
|
|
1316
1792
|
<div class="hint" style="margin-top:6px">🔒 the source vault is defined in the ExternalSecret's SecretStore — ${esc(esoUnresolved[0].reason||'not readable')}.</div></div>`
|
|
1317
1793
|
: '';
|
|
1318
1794
|
if(!vaults.length){
|
|
1319
|
-
|
|
1320
|
-
|
|
1795
|
+
const restricted = r.data.restricted;
|
|
1796
|
+
out.innerHTML=`<div class="empty">${restricted
|
|
1797
|
+
? `🔒 Your identity can't read <b>SecretStores</b> on this cluster.`
|
|
1798
|
+
: `No Key Vault could be resolved for this pod.`}
|
|
1799
|
+
<div class="hint" style="margin-top:8px">${restricted
|
|
1800
|
+
? `The Key Vault is declared in the namespace's SecretStore, but your Kubernetes RBAC doesn't allow reading <code>secretstores</code>/<code>externalsecrets</code> here. It resolves on clusters/identities where you can <code>get</code> them.`
|
|
1801
|
+
: `Checked the namespace's SecretStores & ExternalSecrets, CSI SecretProviderClasses, container env, and ${r.data.configmapsScanned.length} configmap(s). This namespace may not read from a Key Vault.`}</div>
|
|
1321
1802
|
${spcNote}${esoNote}</div>`;
|
|
1322
1803
|
return;
|
|
1323
1804
|
}
|
|
@@ -1394,6 +1875,7 @@ function cmdkActions(){
|
|
|
1394
1875
|
{type:'view', label:'Key Vaults', hint:'go', act:()=>navigate('vaults')},
|
|
1395
1876
|
{type:'view', label:'Expiry', hint:'go', act:()=>navigate('expiry')},
|
|
1396
1877
|
{type:'view', label:'Clusters', hint:'go', act:()=>navigate('clusters')},
|
|
1878
|
+
{type:'view', label:'Projects', hint:'go', act:()=>navigate('projects')},
|
|
1397
1879
|
{type:'view', label:'Account & subscriptions', hint:'go', act:()=>navigate('settings')},
|
|
1398
1880
|
{type:'action', label:'Refresh inventory', hint:'run', act:()=>{ doRefresh(); }},
|
|
1399
1881
|
];
|
|
@@ -1437,7 +1919,15 @@ function moveCur(d){ if(!CMDK.items.length)return; CMDK.cur=(CMDK.cur+d+CMDK.ite
|
|
|
1437
1919
|
const el=$('#cmdkList').querySelector('.cmdk-it.cur'); if(el) el.scrollIntoView({block:'nearest'}); }
|
|
1438
1920
|
function runCmdk(i){ const it=CMDK.items[i]; if(!it)return; closeCmdk(); it.act(); }
|
|
1439
1921
|
|
|
1440
|
-
function doRefresh(){
|
|
1922
|
+
function doRefresh(){
|
|
1923
|
+
toast('Refreshing…');
|
|
1924
|
+
CL.podsByCluster={}; CL.cmByCluster={}; CL.vaultMapByCluster={}; CL.ingressMapByCluster={}; // drop cached cluster data
|
|
1925
|
+
loadSubs().then(()=>loadVaults(true)).then(()=>{
|
|
1926
|
+
EXP.scanned=false; EXP.polling=false; kickExpiryScan();
|
|
1927
|
+
if(STATE.route==='cluster'){ loadClustersCache(true); loadClusterContent(true); }
|
|
1928
|
+
else { loadClustersCache(true).then(()=>refreshCurrent()); } // viewClusters() then renders fresh CL.all
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1441
1931
|
|
|
1442
1932
|
// ---------- events ----------
|
|
1443
1933
|
document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>navigate(b.dataset.view));
|