akv-manager 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/package.json +1 -1
- package/public/index.html +78 -10
- package/server.js +34 -10
package/README.md
CHANGED
|
@@ -56,6 +56,11 @@ AZBO_AKS_CLUSTERS_FILE=./aks-clusters.json npx akv-manager
|
|
|
56
56
|
Each entry needs `name`, `resourceGroup`, `subscription` (name or id); `env` and
|
|
57
57
|
`label` are optional. The console never bundles any cluster list — it's yours to provide.
|
|
58
58
|
|
|
59
|
+
You can also **paste the list in the UI** instead of using a file: open **Clusters →
|
|
60
|
+
✎ Edit cluster list**, paste the same JSON array, and Save. It's kept in your browser's
|
|
61
|
+
`localStorage` and registered with the local server for the session — no file needed.
|
|
62
|
+
File/env clusters and pasted clusters are merged.
|
|
63
|
+
|
|
59
64
|
## Configuration (all optional, via environment variables)
|
|
60
65
|
|
|
61
66
|
| Variable | Default | Purpose |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akv-manager",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Local web console to browse and manage Azure Key Vaults — secrets, keys, certificates, expiry — using your own az login. No credentials are stored.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "server.js",
|
package/public/index.html
CHANGED
|
@@ -220,7 +220,7 @@ async function boot(){
|
|
|
220
220
|
renderSidebar();
|
|
221
221
|
router();
|
|
222
222
|
kickExpiryScan(); // warm the dashboard's lifecycle numbers in the background
|
|
223
|
-
|
|
223
|
+
registerSessionClusters().then(loadClustersCache); // register pasted clusters, then warm ⌘K
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
async function loadSubs(){
|
|
@@ -980,32 +980,100 @@ function clErr(r){
|
|
|
980
980
|
}
|
|
981
981
|
async function loadClustersCache(){ const r=await api('/api/aks/clusters'); if(r.ok) CL.all=r.data.clusters||[]; return CL.all; }
|
|
982
982
|
|
|
983
|
+
// Clusters the user pasted, kept in this browser. Registered with the local server each
|
|
984
|
+
// load so the data-plane endpoints (which validate the cluster) accept them.
|
|
985
|
+
function lsAks(){ try{ return localStorage.getItem('akv.aksClusters')||''; }catch{ return ''; } }
|
|
986
|
+
function setLsAks(t){ try{ if(t) localStorage.setItem('akv.aksClusters',t); else localStorage.removeItem('akv.aksClusters'); }catch{} }
|
|
987
|
+
async function registerSessionClusters(){
|
|
988
|
+
const txt=lsAks().trim(); if(!txt) return;
|
|
989
|
+
let arr; try{ arr=JSON.parse(txt); }catch{ return; }
|
|
990
|
+
if(!Array.isArray(arr)||!arr.length) return;
|
|
991
|
+
try{ await api('/api/aks/clusters',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clusters:arr})}); }catch{}
|
|
992
|
+
}
|
|
993
|
+
|
|
983
994
|
async function viewClusters(){
|
|
984
995
|
$('#route').innerHTML = `<div class="panel"><div class="empty"><span class="spin"></span> loading clusters…</div></div>`;
|
|
996
|
+
await registerSessionClusters();
|
|
985
997
|
const r = await api('/api/aks/clusters');
|
|
986
998
|
if(STATE.route!=='clusters') return;
|
|
987
999
|
if(!r.ok){ $('#route').innerHTML = `<div class="panel">${clErr(r)}</div>`; return; }
|
|
988
1000
|
CL.all = r.data.clusters||[];
|
|
1001
|
+
CL.configuredCount = r.data.configured;
|
|
1002
|
+
const envs=[...new Set(CL.all.map(c=>c.env).filter(Boolean))].sort();
|
|
1003
|
+
const envSeg = envs.length>1 ? `<div class="seg" id="clEnvSeg">
|
|
1004
|
+
<button data-e="all" class="${CL.clEnv==='all'||!CL.clEnv?'active':''}">All</button>
|
|
1005
|
+
${envs.map(e=>`<button data-e="${esc(e)}" class="${CL.clEnv===e?'active':''}">${esc(e)}</button>`).join('')}</div>` : '';
|
|
1006
|
+
$('#route').innerHTML = `
|
|
1007
|
+
<div class="cmctl" style="margin-bottom:12px">
|
|
1008
|
+
<input class="search" id="clqIn" placeholder="filter clusters by name, env or resource group…" value="${esc(CL.clq||'')}" autocomplete="off" style="min-width:280px">
|
|
1009
|
+
${envSeg}
|
|
1010
|
+
<button class="btn sm" id="clEdit">+ Add / edit clusters</button>
|
|
1011
|
+
<span class="faint mono" id="clqCount" style="margin-left:auto"></span></div>
|
|
1012
|
+
<div class="createp hide" id="clEditor"></div>
|
|
1013
|
+
<div id="clTable"></div>`;
|
|
1014
|
+
$('#clqIn').oninput=e=>{ CL.clq=e.target.value; renderClusterRows(); };
|
|
1015
|
+
if($('#clEnvSeg')) $('#clEnvSeg').querySelectorAll('button').forEach(b=>b.onclick=()=>{ CL.clEnv=b.dataset.e;
|
|
1016
|
+
$('#clEnvSeg').querySelectorAll('button').forEach(x=>x.classList.toggle('active',x===b)); renderClusterRows(); });
|
|
1017
|
+
$('#clEdit').onclick=()=>{ const el=$('#clEditor'); el.classList.contains('hide')?openClusterEditor():el.classList.add('hide'); };
|
|
1018
|
+
renderClusterRows();
|
|
1019
|
+
}
|
|
1020
|
+
function openClusterEditor(){
|
|
1021
|
+
const el=$('#clEditor');
|
|
1022
|
+
const stored=lsAks();
|
|
1023
|
+
const seed = stored || JSON.stringify((CL.all||[]).filter(c=>c.source==='session').map(c=>({name:c.name,resourceGroup:c.resourceGroup,subscription:c.subscription,env:c.env,label:c.label})),null,2);
|
|
1024
|
+
el.innerHTML = `<h4>Your cluster list · saved in this browser</h4>
|
|
1025
|
+
<div class="sub" style="margin-bottom:10px">Paste a JSON array of <code>{ name, resourceGroup, subscription, env?, label? }</code>. It's stored in this browser's localStorage and registered with the local server — no file needed. Clusters from <code>AZBO_AKS_CLUSTERS(_FILE)</code> still work and are merged in.</div>
|
|
1026
|
+
<div class="fld" style="align-items:flex-start"><textarea id="clJson" spellcheck="false" placeholder='[\n { "name": "my-aks", "resourceGroup": "my-rg", "subscription": "My Sub", "env": "Dev", "label": "My cluster" }\n]' style="min-height:230px">${esc(seed)}</textarea></div>
|
|
1027
|
+
<div class="actions"><button class="btn sm" onclick="saveClusterList()">Save</button>
|
|
1028
|
+
<button class="btn sm ghost" onclick="document.getElementById('clEditor').classList.add('hide')">Cancel</button>
|
|
1029
|
+
<button class="btn sm ghost" onclick="clearClusterList()">Clear stored</button>
|
|
1030
|
+
<span class="hint" id="clJsonMsg"></span></div>`;
|
|
1031
|
+
el.classList.remove('hide');
|
|
1032
|
+
}
|
|
1033
|
+
async function saveClusterList(){
|
|
1034
|
+
const msg=$('#clJsonMsg'), raw=$('#clJson').value.trim();
|
|
1035
|
+
let arr=[];
|
|
1036
|
+
if(raw){ try{ arr=JSON.parse(raw); }catch(e){ msg.textContent='Invalid JSON: '+e.message; return; } }
|
|
1037
|
+
if(!Array.isArray(arr)){ msg.textContent='Must be a JSON array'; return; }
|
|
1038
|
+
for(const c of arr){ if(!c||!c.name||!c.resourceGroup||!c.subscription){ msg.textContent='Each entry needs name, resourceGroup, subscription'; return; } }
|
|
1039
|
+
msg.textContent='saving…';
|
|
1040
|
+
const r=await api('/api/aks/clusters',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clusters:arr})});
|
|
1041
|
+
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
|
+
loadClustersCache(); viewClusters();
|
|
1044
|
+
}
|
|
1045
|
+
function clearClusterList(){
|
|
1046
|
+
if(!confirm('Remove the clusters stored in this browser? (file / env clusters stay.)')) return;
|
|
1047
|
+
setLsAks('');
|
|
1048
|
+
api('/api/aks/clusters',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clusters:[]})})
|
|
1049
|
+
.then(()=>{ toast('Cleared stored clusters'); CL.all=null; loadClustersCache(); viewClusters(); });
|
|
1050
|
+
}
|
|
1051
|
+
function renderClusterRows(){
|
|
989
1052
|
if(!CL.all.length){
|
|
990
|
-
$('#
|
|
991
|
-
|
|
992
|
-
<span class="faint mono" style="font-size:12px">
|
|
993
|
-
<code>{ name, resourceGroup, subscription, env, label }</code> and restart.</span></div></div>`;
|
|
1053
|
+
$('#clqCount').textContent='';
|
|
1054
|
+
$('#clTable').innerHTML=`<div class="panel"><div class="empty" style="padding:38px;line-height:1.8">No AKS clusters yet.<br>
|
|
1055
|
+
<span class="faint mono" style="font-size:12px">Click <b>✎ Edit cluster list</b> to paste them, or set <b>AZBO_AKS_CLUSTERS(_FILE)</b>.</span></div></div>`;
|
|
994
1056
|
return;
|
|
995
1057
|
}
|
|
996
|
-
|
|
997
|
-
|
|
1058
|
+
const q=(CL.clq||'').toLowerCase(), env=CL.clEnv||'all';
|
|
1059
|
+
const list=CL.all.filter(c=>{
|
|
1060
|
+
if(env!=='all' && c.env!==env) return false;
|
|
1061
|
+
return !q || [c.label,c.name,c.env,c.resourceGroup,c.subscription].filter(Boolean).some(s=>String(s).toLowerCase().includes(q));
|
|
1062
|
+
});
|
|
1063
|
+
$('#clqCount').textContent = `${list.length}${list.length!==CL.all.length?' / '+CL.all.length:''} cluster${CL.all.length!==1?'s':''}`;
|
|
1064
|
+
if(!list.length){ $('#clTable').innerHTML=`<div class="empty">No clusters match your filter.</div>`; return; }
|
|
1065
|
+
$('#clTable').innerHTML = `<div class="panel"><div class="scroll"><table>
|
|
998
1066
|
<thead><tr><th>Cluster</th><th>Env</th><th>Version</th><th>Network</th><th>Source</th><th class="right">Actions</th></tr></thead>
|
|
999
|
-
<tbody>${
|
|
1067
|
+
<tbody>${list.map(c=>`<tr>
|
|
1000
1068
|
<td class="mono"><b>${esc(c.label||c.name)}</b>${c.label&&c.label!==c.name?`<br><span class="faint" style="font-size:11px">${esc(c.name)}</span>`:''}</td>
|
|
1001
1069
|
<td>${c.env?`<span class="tag ${envClass(c.env)}">${esc(c.env)}</span>`:'<span class="faint">—</span>'}</td>
|
|
1002
1070
|
<td class="mono muted">${esc(c.version||'—')}</td>
|
|
1003
1071
|
<td>${c.private?'<span class="pill policy">private · VPN</span>':c.private===false?'<span class="pill rbac">public</span>':'<span class="faint mono">?</span>'}</td>
|
|
1004
|
-
<td><span class="pill">${c.
|
|
1072
|
+
<td><span class="pill">${c.source==='session'?'pasted':c.source==='file'?'file':'discovered'}</span></td>
|
|
1005
1073
|
<td class="right"><button class="iconbtn" onclick="openCluster('${esc(c.name)}')">Open</button></td>
|
|
1006
1074
|
</tr>`).join('')}</tbody>
|
|
1007
1075
|
</table></div></div>
|
|
1008
|
-
<div class="tablenote">${CL.all.length} cluster${CL.all.length!==1?'s':''} · ${
|
|
1076
|
+
<div class="tablenote">${CL.all.length} cluster${CL.all.length!==1?'s':''} · ${CL.configuredCount} configured locally</div>`;
|
|
1009
1077
|
}
|
|
1010
1078
|
function openCluster(name){ navigate('cluster/'+encodeURIComponent(name)); }
|
|
1011
1079
|
|
package/server.js
CHANGED
|
@@ -612,8 +612,19 @@ const getAksToken = () => getToken(AKS_AAD_SERVER_APP);
|
|
|
612
612
|
const K8S_NAME = /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/; // DNS-1123-ish (namespaces, pods, cms)
|
|
613
613
|
const isK8sName = (v) => typeof v === 'string' && K8S_NAME.test(v);
|
|
614
614
|
|
|
615
|
-
//
|
|
616
|
-
//
|
|
615
|
+
// Cluster list — NEVER baked in. Two sources, merged: (1) the operator's file/env
|
|
616
|
+
// (AZBO_AKS_CLUSTERS / AZBO_AKS_CLUSTERS_FILE), and (2) a list the user pastes in the
|
|
617
|
+
// UI, persisted in their browser's localStorage and registered with this local server
|
|
618
|
+
// for the session. Each entry: { name, resourceGroup, subscription, env?, label? }.
|
|
619
|
+
const RG_RE = /^[\w.()-]{1,90}$/;
|
|
620
|
+
function clusterValid(c) {
|
|
621
|
+
return c && isValidName(c.name) && RG_RE.test(c.resourceGroup || '') && SUB_RE.test(c.subscription || '');
|
|
622
|
+
}
|
|
623
|
+
function normalizeClusters(arr, source) {
|
|
624
|
+
return (Array.isArray(arr) ? arr : []).filter((c) => c && c.name && c.resourceGroup && c.subscription)
|
|
625
|
+
.map((c) => ({ name: String(c.name), resourceGroup: String(c.resourceGroup), subscription: String(c.subscription),
|
|
626
|
+
env: c.env ? String(c.env) : '', label: c.label ? String(c.label) : String(c.name), configured: true, source }));
|
|
627
|
+
}
|
|
617
628
|
const AKS_CLUSTERS = (() => {
|
|
618
629
|
let raw = process.env.AZBO_AKS_CLUSTERS;
|
|
619
630
|
if (!raw && process.env.AZBO_AKS_CLUSTERS_FILE) {
|
|
@@ -621,13 +632,10 @@ const AKS_CLUSTERS = (() => {
|
|
|
621
632
|
catch (e) { console.warn(' AZBO_AKS_CLUSTERS_FILE could not be read:', e.message); }
|
|
622
633
|
}
|
|
623
634
|
if (!raw) return [];
|
|
624
|
-
try {
|
|
625
|
-
|
|
626
|
-
return (Array.isArray(arr) ? arr : []).filter((c) => c && c.name && c.resourceGroup && c.subscription)
|
|
627
|
-
.map((c) => ({ name: c.name, resourceGroup: c.resourceGroup, subscription: c.subscription,
|
|
628
|
-
env: c.env || '', label: c.label || c.name, configured: true }));
|
|
629
|
-
} catch { console.warn(' AZBO_AKS_CLUSTERS is not valid JSON — ignoring.'); return []; }
|
|
635
|
+
try { return normalizeClusters(JSON.parse(raw), 'file'); }
|
|
636
|
+
catch { console.warn(' AZBO_AKS_CLUSTERS is not valid JSON — ignoring.'); return []; }
|
|
630
637
|
})();
|
|
638
|
+
let SESSION_CLUSTERS = []; // set by the client via POST /api/aks/clusters (from its localStorage)
|
|
631
639
|
|
|
632
640
|
async function resolveSubId(nameOrId) {
|
|
633
641
|
if (/^[0-9a-fA-F-]{36}$/.test(nameOrId)) return nameOrId;
|
|
@@ -728,7 +736,7 @@ async function aksList() {
|
|
|
728
736
|
const discovered = await discoverClusters();
|
|
729
737
|
const byKey = new Map();
|
|
730
738
|
for (const c of discovered) byKey.set(c.name.toLowerCase(), c);
|
|
731
|
-
for (const c of AKS_CLUSTERS) {
|
|
739
|
+
for (const c of [...AKS_CLUSTERS, ...SESSION_CLUSTERS]) { // configured (file then session) win + carry label/env
|
|
732
740
|
const prev = byKey.get(c.name.toLowerCase()) || {};
|
|
733
741
|
byKey.set(c.name.toLowerCase(), { ...prev, ...c });
|
|
734
742
|
}
|
|
@@ -736,7 +744,23 @@ async function aksList() {
|
|
|
736
744
|
}
|
|
737
745
|
|
|
738
746
|
app.get('/api/aks/clusters', (req, res) =>
|
|
739
|
-
send(res, aksList().then((clusters) => ({ clusters,
|
|
747
|
+
send(res, aksList().then((clusters) => ({ clusters,
|
|
748
|
+
configured: AKS_CLUSTERS.length + SESSION_CLUSTERS.length, file: AKS_CLUSTERS.length, session: SESSION_CLUSTERS.length }))));
|
|
749
|
+
|
|
750
|
+
// Register the user's pasted cluster list for this session (from their localStorage).
|
|
751
|
+
// Replaces the previous session list; validated; localhost-only like the rest of the API.
|
|
752
|
+
app.post('/api/aks/clusters', (req, res) => {
|
|
753
|
+
const { clusters } = req.body || {};
|
|
754
|
+
if (!Array.isArray(clusters)) return res.status(400).json({ ok: false, error: 'clusters must be a JSON array' });
|
|
755
|
+
if (clusters.length > 200) return res.status(400).json({ ok: false, error: 'Too many clusters (max 200)' });
|
|
756
|
+
for (const c of clusters) {
|
|
757
|
+
if (!c || !c.name || !c.resourceGroup || !c.subscription) return res.status(400).json({ ok: false, error: `Each entry needs name, resourceGroup, subscription (got: ${JSON.stringify(c).slice(0, 60)})` });
|
|
758
|
+
if (!clusterValid(c)) return res.status(400).json({ ok: false, error: `Invalid cluster entry "${c.name}" — check name / resourceGroup / subscription` });
|
|
759
|
+
}
|
|
760
|
+
SESSION_CLUSTERS = normalizeClusters(clusters, 'session');
|
|
761
|
+
aksCredCache.clear(); // coordinates may have changed
|
|
762
|
+
res.json({ ok: true, data: { count: SESSION_CLUSTERS.length } });
|
|
763
|
+
});
|
|
740
764
|
|
|
741
765
|
// Resolve a :cluster route param to a known cluster, or throw a typed 400/404.
|
|
742
766
|
async function getClusterChecked(name) {
|