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 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.0",
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
- loadClustersCache(); // so ⌘K can jump to clusters without visiting the section first
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
- $('#route').innerHTML = `<div class="panel"><div class="empty" style="padding:42px;line-height:1.8">
991
- No AKS clusters configured or visible to you.<br>
992
- <span class="faint mono" style="font-size:12px">Set <b>AZBO_AKS_CLUSTERS</b> (JSON) or <b>AZBO_AKS_CLUSTERS_FILE</b> to a list of<br>
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
- $('#route').innerHTML = `
997
- <div class="panel"><div class="scroll"><table>
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>${CL.all.map(c=>`<tr>
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.configured?'configured':'discovered'}</span></td>
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':''} · ${r.data.configured} configured locally</div>`;
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
- // Operator-supplied cluster list — NEVER baked in. JSON via AZBO_AKS_CLUSTERS, or a
616
- // path via AZBO_AKS_CLUSTERS_FILE. Each: { name, resourceGroup, subscription, env?, label? }.
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
- const arr = JSON.parse(raw);
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) { // configured entries win + carry their label/env
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, configured: AKS_CLUSTERS.length }))));
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) {