akv-manager 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/index.html +627 -382
- package/public/styles.css +120 -0
- package/server.js +88 -9
package/public/index.html
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
-
<title>Keyvault Console —
|
|
6
|
+
<title>Keyvault Console — Operations</title>
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
@@ -48,151 +48,68 @@
|
|
|
48
48
|
</svg>
|
|
49
49
|
<div class="wm">KEYVAULT<small>OPS CONSOLE</small></div>
|
|
50
50
|
</div>
|
|
51
|
-
|
|
51
|
+
|
|
52
|
+
<button class="navpin" id="cmdkBtn" style="margin:2px 0 6px;border:1px solid var(--line);justify-content:flex-start">
|
|
53
|
+
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
|
54
|
+
<span class="pn">Search everything</span><kbd class="mono" style="font-size:9.5px;color:var(--faint)">⌘K</kbd>
|
|
55
|
+
</button>
|
|
56
|
+
|
|
57
|
+
<div class="navlabel">Operate</div>
|
|
52
58
|
<nav>
|
|
53
|
-
<button data-view="
|
|
59
|
+
<button data-view="dashboard" class="active">
|
|
54
60
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
|
55
|
-
|
|
56
|
-
<button data-view="subscriptions">
|
|
57
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 3l9 5-9 5-9-5 9-5z"/><path d="M3 13l9 5 9-5"/></svg>
|
|
58
|
-
Subscriptions</button>
|
|
61
|
+
Dashboard</button>
|
|
59
62
|
<button data-view="vaults">
|
|
60
63
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
|
|
61
64
|
Key Vaults</button>
|
|
62
|
-
<button data-view="
|
|
65
|
+
<button data-view="expiry">
|
|
63
66
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2"/></svg>
|
|
64
67
|
Expiry</button>
|
|
65
68
|
</nav>
|
|
66
|
-
|
|
67
|
-
<div class="
|
|
69
|
+
|
|
70
|
+
<div class="navlabel" id="pinLabel">Pinned</div>
|
|
71
|
+
<div class="navpins" id="navPins"></div>
|
|
72
|
+
<div class="navlabel" id="recentLabel">Recent</div>
|
|
73
|
+
<div class="navpins" id="navRecent"></div>
|
|
74
|
+
|
|
75
|
+
<div class="acct">
|
|
76
|
+
<button class="acctbtn" id="acctBtn">
|
|
77
|
+
<span class="dot" id="acctDot" style="background:var(--faint)"></span>
|
|
78
|
+
<span style="overflow:hidden"><b id="acctUser">connecting…</b><br><span class="mono faint" id="acctSub" style="font-size:10.5px"></span></span>
|
|
79
|
+
<svg class="chev" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6"/></svg>
|
|
80
|
+
</button>
|
|
81
|
+
<div class="ver mono faint" id="ver"></div>
|
|
82
|
+
</div>
|
|
68
83
|
</aside>
|
|
69
84
|
|
|
70
85
|
<main class="max-w-[1560px] px-8 pt-[26px] pb-[60px] max-[900px]:p-5">
|
|
71
86
|
<div class="banner" id="banner"></div>
|
|
72
87
|
|
|
73
88
|
<div class="flex items-end justify-between gap-[18px] mb-[26px]">
|
|
74
|
-
<div>
|
|
75
|
-
<div class="eyebrow" id="eyebrow">
|
|
76
|
-
<h1 id="title">
|
|
89
|
+
<div id="headblock">
|
|
90
|
+
<div class="eyebrow" id="eyebrow">Operations</div>
|
|
91
|
+
<h1 id="title">Dashboard</h1>
|
|
77
92
|
<div class="sub" id="subtitle">Live view of your Azure access via the local az CLI</div>
|
|
78
93
|
</div>
|
|
79
|
-
<div class="row">
|
|
80
|
-
<input class="search hide" id="search" placeholder="search vault · resource group ·
|
|
94
|
+
<div class="row" id="topactions">
|
|
95
|
+
<input class="search hide" id="search" placeholder="search vault · resource group · tag…" />
|
|
81
96
|
<button class="btn ghost" id="refresh">↻ Refresh</button>
|
|
82
97
|
</div>
|
|
83
98
|
</div>
|
|
84
99
|
|
|
85
|
-
|
|
86
|
-
<section id="view-overview">
|
|
87
|
-
<div class="cards" id="ovCards"></div>
|
|
88
|
-
<div class="panel">
|
|
89
|
-
<div class="scroll">
|
|
90
|
-
<table>
|
|
91
|
-
<thead><tr><th>Subscription</th><th>Env</th><th>State</th><th class="right">Key Vaults</th><th>Subscription ID</th></tr></thead>
|
|
92
|
-
<tbody id="ovSubs"></tbody>
|
|
93
|
-
</table>
|
|
94
|
-
</div>
|
|
95
|
-
</div>
|
|
96
|
-
</section>
|
|
97
|
-
|
|
98
|
-
<!-- SUBSCRIPTIONS -->
|
|
99
|
-
<section id="view-subscriptions" class="hide">
|
|
100
|
-
<div class="panel">
|
|
101
|
-
<div class="scroll">
|
|
102
|
-
<table>
|
|
103
|
-
<thead><tr><th>Name</th><th>Env</th><th>State</th><th>Active</th><th class="right">Vaults</th><th>Subscription ID</th><th></th></tr></thead>
|
|
104
|
-
<tbody id="subsBody"></tbody>
|
|
105
|
-
</table>
|
|
106
|
-
</div>
|
|
107
|
-
</div>
|
|
108
|
-
</section>
|
|
109
|
-
|
|
110
|
-
<!-- VAULTS -->
|
|
111
|
-
<section id="view-vaults" class="hide">
|
|
112
|
-
<div class="chips" id="envChips"></div>
|
|
113
|
-
<div class="panel">
|
|
114
|
-
<div class="scroll">
|
|
115
|
-
<table>
|
|
116
|
-
<thead><tr>
|
|
117
|
-
<th data-sort="name">Vault ▾</th><th data-sort="env">Env</th>
|
|
118
|
-
<th data-sort="service">Service</th><th data-sort="resourceGroup">Resource Group</th>
|
|
119
|
-
<th data-sort="sku">SKU</th><th>Auth</th><th class="right">Actions</th>
|
|
120
|
-
</tr></thead>
|
|
121
|
-
<tbody id="vaultsBody"></tbody>
|
|
122
|
-
</table>
|
|
123
|
-
</div>
|
|
124
|
-
</div>
|
|
125
|
-
<div class="tablenote" id="vaultCount"></div>
|
|
126
|
-
</section>
|
|
127
|
-
|
|
128
|
-
<!-- EXPIRING -->
|
|
129
|
-
<section id="view-expiring" class="hide">
|
|
130
|
-
<div class="row" style="margin-bottom:18px;justify-content:space-between">
|
|
131
|
-
<div class="row">
|
|
132
|
-
<span class="eyebrow">Window</span>
|
|
133
|
-
<div class="seg" id="expWindow">
|
|
134
|
-
<button data-d="7">7d</button>
|
|
135
|
-
<button data-d="30" class="active">30d</button>
|
|
136
|
-
<button data-d="60">60d</button>
|
|
137
|
-
<button data-d="90">90d</button>
|
|
138
|
-
<button data-d="365">1y</button>
|
|
139
|
-
</div>
|
|
140
|
-
<button class="btn" id="expScan">⟳ Scan vaults</button>
|
|
141
|
-
</div>
|
|
142
|
-
<span class="tablenote" id="expMeta"></span>
|
|
143
|
-
</div>
|
|
144
|
-
|
|
145
|
-
<div class="panel horizon hide" id="expHorizon"></div>
|
|
146
|
-
<div class="cards" id="expCards"></div>
|
|
147
|
-
|
|
148
|
-
<div class="panel">
|
|
149
|
-
<div class="scroll">
|
|
150
|
-
<table>
|
|
151
|
-
<thead><tr>
|
|
152
|
-
<th data-esort="daysLeft">Expires in ▾</th><th data-esort="expires">Expiry date</th>
|
|
153
|
-
<th data-esort="type">Type</th><th data-esort="name">Item</th>
|
|
154
|
-
<th data-esort="vault">Vault</th><th data-esort="env">Env</th>
|
|
155
|
-
<th>Status</th><th class="right">Actions</th>
|
|
156
|
-
</tr></thead>
|
|
157
|
-
<tbody id="expBody"><tr><td colspan="8" class="empty">Press <b>Scan vaults</b> to check expiry across every vault you can read.</td></tr></tbody>
|
|
158
|
-
</table>
|
|
159
|
-
</div>
|
|
160
|
-
</div>
|
|
161
|
-
<div class="tablenote" id="expTableNote"></div>
|
|
162
|
-
</section>
|
|
100
|
+
<div id="route"></div>
|
|
163
101
|
</main>
|
|
164
102
|
</div>
|
|
165
103
|
|
|
166
|
-
<!--
|
|
167
|
-
<div class="
|
|
168
|
-
<div class="
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
<
|
|
172
|
-
<
|
|
173
|
-
</div>
|
|
174
|
-
<button class="iconbtn" id="dClose">✕ Close</button>
|
|
175
|
-
</header>
|
|
176
|
-
<div class="body">
|
|
177
|
-
<div class="seg" id="dSeg" style="width:100%;display:flex">
|
|
178
|
-
<button data-kind="secrets" class="active" style="flex:1">Secrets</button>
|
|
179
|
-
<button data-kind="keys" style="flex:1">Keys</button>
|
|
180
|
-
<button data-kind="certificates" style="flex:1">Certificates</button>
|
|
104
|
+
<!-- Command palette -->
|
|
105
|
+
<div class="cmdk-ov" id="cmdkOv">
|
|
106
|
+
<div class="cmdk" role="dialog" aria-label="Command palette">
|
|
107
|
+
<div class="cmdk-in">
|
|
108
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
|
109
|
+
<input id="cmdkIn" placeholder="Jump to a vault, a view, or an action…" autocomplete="off" spellcheck="false">
|
|
110
|
+
<kbd>esc</kbd>
|
|
181
111
|
</div>
|
|
182
|
-
<div class="
|
|
183
|
-
<input class="search" id="dFilter" placeholder="filter by name…" autocomplete="off" />
|
|
184
|
-
<label class="selall"><input type="checkbox" id="dSelAll" /> select all</label>
|
|
185
|
-
<span class="faint mono" id="dCount"></span>
|
|
186
|
-
<span class="newbtns" id="dNewBtns">
|
|
187
|
-
<button class="btn sm" id="dNew">+ New</button>
|
|
188
|
-
<button class="btn sm ghost" id="dNewBatch">+ Batch</button>
|
|
189
|
-
</span>
|
|
190
|
-
</div>
|
|
191
|
-
<div class="createp hide" id="dCreate"></div>
|
|
192
|
-
<div class="batchbar hide" id="dBatch"></div>
|
|
193
|
-
<div id="dContent" style="margin-top:12px"><div class="empty"><span class="spin"></span></div></div>
|
|
194
|
-
<div class="secthead">CLI commands</div>
|
|
195
|
-
<div id="dCmds"></div>
|
|
112
|
+
<div class="cmdk-list" id="cmdkList"></div>
|
|
196
113
|
</div>
|
|
197
114
|
</div>
|
|
198
115
|
|
|
@@ -200,16 +117,77 @@
|
|
|
200
117
|
|
|
201
118
|
<script>
|
|
202
119
|
const $ = s => document.querySelector(s);
|
|
120
|
+
const esc = s => String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
203
121
|
const api = async (p, opts) => { const r = await fetch(p, opts); return r.json(); };
|
|
204
|
-
let STATE = { vaults: [], subs: [], account: null, env: 'all', q: '', sort: 'name', dir: 1,
|
|
205
|
-
|
|
122
|
+
let STATE = { vaults: [], subs: [], account: null, env: 'all', q: '', sort: 'name', dir: 1,
|
|
123
|
+
kind: 'secrets', drawerVault: null, route: 'dashboard', facets: {} };
|
|
124
|
+
let CFG = { envRules: null, title: null, version: null, protectedTags: [] };
|
|
125
|
+
function tagMatch(p,k){ p=String(p).toLowerCase(); k=String(k).toLowerCase(); return p.endsWith('*')?k.startsWith(p.slice(0,-1)):k===p; }
|
|
126
|
+
function isProtectedTag(k){ return (CFG.protectedTags||[]).some(p=>tagMatch(p,k)); }
|
|
206
127
|
const SUBMETA = {};
|
|
207
128
|
|
|
129
|
+
// ---------- local prefs (pins / recents) ----------
|
|
130
|
+
const LS = {
|
|
131
|
+
read(k){ try{ return JSON.parse(localStorage.getItem(k)||'[]'); }catch{ return []; } },
|
|
132
|
+
write(k,v){ try{ localStorage.setItem(k, JSON.stringify(v)); }catch{} },
|
|
133
|
+
};
|
|
134
|
+
const pins = () => LS.read('akv.pins');
|
|
135
|
+
const recents = () => LS.read('akv.recents');
|
|
136
|
+
const isPinned = n => pins().includes(n);
|
|
137
|
+
function togglePin(n){ const p=pins(); const i=p.indexOf(n); if(i>=0)p.splice(i,1); else p.unshift(n); LS.write('akv.pins',p); renderSidebar(); refreshCurrent(); }
|
|
138
|
+
function pushRecent(n){ const r=recents().filter(x=>x!==n); r.unshift(n); LS.write('akv.recents', r.slice(0,8)); renderSidebar(); }
|
|
139
|
+
|
|
208
140
|
function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1600); }
|
|
209
141
|
function copy(text){ navigator.clipboard.writeText(text).then(()=>toast('Copied to clipboard')); }
|
|
210
142
|
function banner(msg){ const b=$('#banner'); if(!msg){b.classList.remove('show');return;} b.innerHTML=msg; b.classList.add('show'); }
|
|
211
143
|
function envOf(id){ return (SUBMETA[id]&&SUBMETA[id].env)||'?'; }
|
|
212
144
|
function subName(id){ return (SUBMETA[id]&&SUBMETA[id].name)||id; }
|
|
145
|
+
const findVault = n => STATE.vaults.find(v=>v.name.toLowerCase()===String(n||'').toLowerCase());
|
|
146
|
+
|
|
147
|
+
// ---------- environment + service derivation (now tag-aware) ----------
|
|
148
|
+
function prettyEnv(s){
|
|
149
|
+
const x=String(s).toLowerCase().trim();
|
|
150
|
+
// exact short codes commonly used in `env` tags
|
|
151
|
+
const code={dv:'Dev',dev:'Dev',d:'Dev',qa:'QA',ts:'Test',tst:'Test',tes:'Test',t:'Test',
|
|
152
|
+
st:'Stage',stg:'Stage',stag:'Stage',uat:'Stage',pp:'Stage',pr:'Prod',prd:'Prod',prod:'Prod',p:'Prod',
|
|
153
|
+
sb:'Sandbox',sbx:'Sandbox',sand:'Sandbox'}[x];
|
|
154
|
+
if(code) return code;
|
|
155
|
+
if(/prod|prd|\blive\b/.test(x)) return 'Prod';
|
|
156
|
+
if(/stag|stg|\buat\b|preprod|pre-prod|accept/.test(x)) return 'Stage';
|
|
157
|
+
if(/\bqa\b|quality/.test(x)) return 'QA';
|
|
158
|
+
if(/sandbox|sbx|\bdemo\b/.test(x)) return 'Sandbox';
|
|
159
|
+
if(/dev|develop/.test(x)) return 'Dev';
|
|
160
|
+
if(/test|tst|\bint\b|integration|nonprod/.test(x)) return 'Test';
|
|
161
|
+
return s.charAt(0).toUpperCase()+s.slice(1);
|
|
162
|
+
}
|
|
163
|
+
// Prefer the vault's real `env` tag; fall back to the subscription-name heuristic.
|
|
164
|
+
function vaultEnv(v){
|
|
165
|
+
const t = v.tags||{};
|
|
166
|
+
const raw = t.env || t.Environment || t.environment || t.ENV || t.Env;
|
|
167
|
+
return raw ? prettyEnv(raw) : envOf(v.subscriptionId);
|
|
168
|
+
}
|
|
169
|
+
function vaultService(v){
|
|
170
|
+
const t = v.tags||{};
|
|
171
|
+
return t.solution || t.Solution || t.application || t.app || serviceOf(v.resourceGroup);
|
|
172
|
+
}
|
|
173
|
+
function envLabel(name){
|
|
174
|
+
const n = name || '';
|
|
175
|
+
if(CFG.envRules){ for(const [pat,label] of CFG.envRules){ try{ if(new RegExp(pat,'i').test(n)) return label; }catch{} } }
|
|
176
|
+
// Only label a subscription when a known environment keyword is present; otherwise '—'.
|
|
177
|
+
return /(prod|prd|\blive\b|stag|stg|\buat\b|preprod|pre-prod|accept|\bqa\b|sandbox|sbx|\bdemo\b|\bdev\b|develop|test|tst|\bint\b|integration|nonprod)/i.test(n)
|
|
178
|
+
? prettyEnv(n) : '—';
|
|
179
|
+
}
|
|
180
|
+
function envClass(env){
|
|
181
|
+
const s = (env||'').toLowerCase();
|
|
182
|
+
if(/prod|live/.test(s)) return 'e-prod';
|
|
183
|
+
if(/stag|uat|preprod/.test(s)) return 'e-stage';
|
|
184
|
+
if(/qa/.test(s)) return 'e-qa';
|
|
185
|
+
if(/sandbox|demo/.test(s)) return 'e-sandbox';
|
|
186
|
+
if(/dev/.test(s)) return 'e-dev';
|
|
187
|
+
if(/test|int/.test(s)) return 'e-test';
|
|
188
|
+
return 'e-other';
|
|
189
|
+
}
|
|
190
|
+
function serviceOf(rg){ return (rg||'').replace(/^rg[-_]?/i,'').replace(/[-_]?\d+$/,'') || rg; }
|
|
213
191
|
|
|
214
192
|
// ---------- bootstrap ----------
|
|
215
193
|
async function boot(){
|
|
@@ -218,18 +196,23 @@ async function boot(){
|
|
|
218
196
|
$('#ver').textContent = 'akv-manager v' + (CFG.version || '?');
|
|
219
197
|
const acc = await api('/api/account');
|
|
220
198
|
if(!acc.ok){
|
|
221
|
-
$('#
|
|
222
|
-
const detail = acc.error ? `<div class="hint" style="margin-top:6px">${acc.error}</div>` : '';
|
|
199
|
+
$('#acctUser').textContent = 'not connected'; $('#acctDot').style.background='var(--alert)';
|
|
200
|
+
const detail = acc.error ? `<div class="hint" style="margin-top:6px">${esc(acc.error)}</div>` : '';
|
|
223
201
|
banner((acc.needLogin
|
|
224
202
|
? 'Azure CLI is signed out. Run <code>az login</code>, then press Refresh.'
|
|
225
203
|
: 'Could not reach the Azure CLI. Make sure <code>az</code> is installed and on your PATH (on Windows it is <code>az.cmd</code>), then press Refresh.') + detail);
|
|
226
204
|
return;
|
|
227
205
|
}
|
|
228
206
|
STATE.account = acc.data;
|
|
229
|
-
$('#
|
|
207
|
+
$('#acctDot').style.background='var(--ok)';
|
|
208
|
+
$('#acctUser').textContent = acc.data.user.name;
|
|
209
|
+
$('#acctSub').textContent = acc.data.name;
|
|
230
210
|
banner('');
|
|
231
|
-
await loadSubs();
|
|
232
|
-
await loadVaults();
|
|
211
|
+
await loadSubs();
|
|
212
|
+
await loadVaults();
|
|
213
|
+
renderSidebar();
|
|
214
|
+
router();
|
|
215
|
+
kickExpiryScan(); // warm the dashboard's lifecycle numbers in the background
|
|
233
216
|
}
|
|
234
217
|
|
|
235
218
|
async function loadSubs(){
|
|
@@ -237,160 +220,288 @@ async function loadSubs(){
|
|
|
237
220
|
if(!r.ok) return;
|
|
238
221
|
STATE.subs = r.data;
|
|
239
222
|
r.data.forEach(s => SUBMETA[s.id] = { name: s.name, env: envLabel(s.name) });
|
|
240
|
-
renderOverview(); renderSubs();
|
|
241
|
-
// If the expiry view rendered before subs arrived, re-resolve its env tags now.
|
|
242
|
-
if(EXP.scanned){ EXP.items = EXP.items.map(it=>({...it, env: envOf(it.subscriptionId)})); renderExpiring(); }
|
|
243
223
|
}
|
|
244
|
-
// Infer an environment label from a subscription name. Operators can override the
|
|
245
|
-
// matching with AZBO_ENV_RULES (see /api/config); otherwise generic keywords are used.
|
|
246
|
-
function envLabel(name){
|
|
247
|
-
const n = name || '';
|
|
248
|
-
if(CFG.envRules){ for(const [pat,label] of CFG.envRules){ try{ if(new RegExp(pat,'i').test(n)) return label; }catch{} } }
|
|
249
|
-
const s = n.toLowerCase();
|
|
250
|
-
if(/prod|prd|production|\blive\b/.test(s)) return 'Prod';
|
|
251
|
-
if(/preprod|pre-prod|stag|stg|\buat\b|accept/.test(s)) return 'Stage';
|
|
252
|
-
if(/\bqa\b|quality/.test(s)) return 'QA';
|
|
253
|
-
if(/sandbox|sbx|\bdemo\b/.test(s)) return 'Sandbox';
|
|
254
|
-
if(/dev|develop/.test(s)) return 'Dev';
|
|
255
|
-
if(/test|tst|\bint\b|integration|nonprod|non-prod/.test(s)) return 'Test';
|
|
256
|
-
return '—';
|
|
257
|
-
}
|
|
258
|
-
// Map any env label to one of a fixed set of colour buckets for the tag styling.
|
|
259
|
-
function envClass(env){
|
|
260
|
-
const s = (env||'').toLowerCase();
|
|
261
|
-
if(/prod|live/.test(s)) return 'e-prod';
|
|
262
|
-
if(/stag|uat|preprod/.test(s)) return 'e-stage';
|
|
263
|
-
if(/qa/.test(s)) return 'e-qa';
|
|
264
|
-
if(/sandbox|demo/.test(s)) return 'e-sandbox';
|
|
265
|
-
if(/dev/.test(s)) return 'e-dev';
|
|
266
|
-
if(/test|int/.test(s)) return 'e-test';
|
|
267
|
-
return 'e-other';
|
|
268
|
-
}
|
|
269
|
-
|
|
270
224
|
async function loadVaults(refresh){
|
|
271
|
-
$('#vaultsBody').innerHTML = '<tr><td colspan="7" class="empty"><span class="spin"></span> querying Resource Graph…</td></tr>';
|
|
272
225
|
const r = await api('/api/vaults'+(refresh?'?refresh=1':''));
|
|
273
|
-
if(!r.ok){
|
|
274
|
-
STATE.vaults = r.data.map(v => ({...v, env:
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
// Best-effort short name from a resource group: drop a leading rg / rg- and trailing index.
|
|
278
|
-
function serviceOf(rg){
|
|
279
|
-
return rg.replace(/^rg[-_]?/i,'').replace(/[-_]?\d+$/,'') || rg;
|
|
226
|
+
if(!r.ok){ STATE.vaults=[]; return r; }
|
|
227
|
+
STATE.vaults = r.data.map(v => ({...v, env: vaultEnv(v), service: vaultService(v)}));
|
|
228
|
+
return r;
|
|
280
229
|
}
|
|
281
230
|
|
|
282
|
-
// ----------
|
|
283
|
-
function
|
|
284
|
-
|
|
285
|
-
STATE.vaults.forEach(v => envs[v.env]=(envs[v.env]||0)+1);
|
|
286
|
-
const namedEnvs = Object.entries(envs).filter(([e])=>e!=='?'&&e!=='—').sort((a,b)=>b[1]-a[1]);
|
|
287
|
-
const cards = [
|
|
288
|
-
card('Subscriptions', STATE.subs.length, STATE.subs.filter(s=>s.state==='Enabled').length+' enabled',''),
|
|
289
|
-
card('Key Vaults', STATE.vaults.length, 'across '+namedEnvs.length+' environment'+(namedEnvs.length!==1?'s':''),'azure'),
|
|
290
|
-
...namedEnvs.slice(0,3).map(([e,n])=>card(e, n, 'environment','')),
|
|
291
|
-
];
|
|
292
|
-
$('#ovCards').innerHTML = cards.join('');
|
|
293
|
-
const counts = {}; STATE.vaults.forEach(v=>counts[v.subscriptionId]=(counts[v.subscriptionId]||0)+1);
|
|
294
|
-
$('#ovSubs').innerHTML = STATE.subs.map(s=>`<tr>
|
|
295
|
-
<td><b>${s.name}</b></td><td><span class="tag ${envClass(SUBMETA[s.id].env)}">${SUBMETA[s.id].env}</span></td>
|
|
296
|
-
<td>${s.state}</td><td class="right mono">${counts[s.id]||0}</td><td class="mono muted">${s.id}</td></tr>`).join('');
|
|
297
|
-
}
|
|
298
|
-
function card(k,v,sub,mod){
|
|
299
|
-
return `<div class="card ${mod?'card--'+mod:''}"><div class="k">${k}</div><div class="v">${v} <small>${sub}</small></div></div>`;
|
|
231
|
+
// ---------- router ----------
|
|
232
|
+
function parseHash(){
|
|
233
|
+
return location.hash.replace(/^#\/?/,'').split('/').filter(Boolean).map(decodeURIComponent);
|
|
300
234
|
}
|
|
235
|
+
function navigate(path){ location.hash = '#/'+path; }
|
|
236
|
+
const META = {
|
|
237
|
+
dashboard:['Operations','Dashboard','Secrets and certificates that need attention, your pinned vaults, and access health.'],
|
|
238
|
+
vaults:['Inventory','Key Vaults','Every readable vault across your subscriptions. Filter by environment and tags; open one to manage it.'],
|
|
239
|
+
expiry:['Lifecycle','Expiring soon','Secrets and certificates that are expired or approaching expiry across every vault you can read.'],
|
|
240
|
+
settings:['Account','Account & subscriptions','Your Azure identity and the subscription the console is pointed at.'],
|
|
241
|
+
};
|
|
242
|
+
let mountedVault = null;
|
|
243
|
+
function setHead(view){
|
|
244
|
+
const onVault = view==='vault';
|
|
245
|
+
$('#headblock').style.display = onVault ? 'none' : '';
|
|
246
|
+
$('#search').classList.toggle('hide', onVault);
|
|
247
|
+
if(!onVault && META[view]){ $('#eyebrow').textContent=META[view][0]; $('#title').textContent=META[view][1]; $('#subtitle').textContent=META[view][2]; }
|
|
248
|
+
document.querySelectorAll('nav button').forEach(b=>b.classList.toggle('active', b.dataset.view===view));
|
|
249
|
+
}
|
|
250
|
+
function router(){
|
|
251
|
+
if(!STATE.account) return;
|
|
252
|
+
const parts = parseHash();
|
|
253
|
+
const route = parts[0]||'dashboard';
|
|
254
|
+
STATE.route = route;
|
|
255
|
+
if(route!=='vault') mountedVault=null;
|
|
256
|
+
if(route==='vaults'){ setHead('vaults'); viewVaults(); }
|
|
257
|
+
else if(route==='vault'){ setHead('vault'); viewVault(parts[1], parts[2]||'secrets'); }
|
|
258
|
+
else if(route==='expiry'){ setHead('expiry'); viewExpiry(); }
|
|
259
|
+
else if(route==='settings'){ setHead('settings'); viewSettings(); }
|
|
260
|
+
else { setHead('dashboard'); viewDashboard(); }
|
|
261
|
+
renderSidebar();
|
|
262
|
+
}
|
|
263
|
+
function refreshCurrent(){ if(STATE.account) router(); }
|
|
264
|
+
window.addEventListener('hashchange', router);
|
|
301
265
|
|
|
302
|
-
// ----------
|
|
303
|
-
function
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
toast('Switching subscription…');
|
|
317
|
-
const r = await api('/api/subscription/set',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({subscription:id})});
|
|
318
|
-
if(r.ok){ STATE.account=r.data; await loadSubs(); toast('Active subscription set'); } else toast(r.error||'failed');
|
|
266
|
+
// ---------- sidebar pinned / recent ----------
|
|
267
|
+
function renderSidebar(){
|
|
268
|
+
const parts = parseHash(); const curVault = parts[0]==='vault'?(parts[1]||'').toLowerCase():null;
|
|
269
|
+
const tile = n => {
|
|
270
|
+
const v = findVault(n); const env = v?envClass(v.env):'e-other';
|
|
271
|
+
return `<button class="navpin ${curVault===n.toLowerCase()?'active':''}" onclick="openVault('${esc(n)}')" title="${esc(n)}">
|
|
272
|
+
<span class="env-dot" style="background:var(--azure)"></span><span class="pn">${esc(n)}</span></button>`;
|
|
273
|
+
};
|
|
274
|
+
const p = pins();
|
|
275
|
+
$('#navPins').innerHTML = p.length ? p.map(tile).join('') : '<div class="navempty">Pin vaults you manage often</div>';
|
|
276
|
+
$('#pinLabel').style.display = '';
|
|
277
|
+
const r = recents().filter(n=>!p.includes(n));
|
|
278
|
+
$('#recentLabel').style.display = r.length? '' : 'none';
|
|
279
|
+
$('#navRecent').innerHTML = r.map(tile).join('');
|
|
319
280
|
}
|
|
320
281
|
|
|
321
|
-
// ----------
|
|
322
|
-
function
|
|
323
|
-
const
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
282
|
+
// ---------- DASHBOARD ----------
|
|
283
|
+
function viewDashboard(){
|
|
284
|
+
const sevOf = e => e.expired?'alert':(e.soon?'warn':'azure');
|
|
285
|
+
const exp = EXP.scanned ? expCounts() : null;
|
|
286
|
+
const denied = EXP.stats ? EXP.stats.forbidden : null;
|
|
287
|
+
const card = (k,v,sub,sev,onclick,icon) =>
|
|
288
|
+
`<button class="actcard sev-${sev}" onclick="${onclick}"><div class="k">${icon||''}${k}</div>
|
|
289
|
+
<div class="v">${v}</div><div class="sub">${sub}</div></button>`;
|
|
290
|
+
const clock = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
|
|
291
|
+
const lock = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>';
|
|
292
|
+
const grid = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>';
|
|
293
|
+
const scanning = EXP.polling || (!EXP.scanned);
|
|
294
|
+
const dim = scanning ? '<span class="spin" style="width:13px;height:13px;border-width:2px"></span>' : '';
|
|
295
|
+
const cards = [
|
|
296
|
+
card('Expired', exp? exp.expired : dim, 'needs rotation now', exp&&exp.expired?'alert':'ok', "navigate('expiry')", clock),
|
|
297
|
+
card('≤ 7 days', exp? exp.soon : dim, 'rotate this week', exp&&exp.soon?'warn':'ok', "navigate('expiry')", clock),
|
|
298
|
+
card('≤ 30 days', exp? exp.month : dim, 'plan ahead', exp&&exp.month?'azure':'ok', "navigate('expiry')", clock),
|
|
299
|
+
card('Key Vaults', STATE.vaults.length, `${denied!=null?denied+' you cannot read':'across '+envCount()+' environments'}`, 'azure', "navigate('vaults')", grid),
|
|
300
|
+
];
|
|
301
|
+
const tileRow = n => {
|
|
302
|
+
const v = findVault(n); if(!v) return '';
|
|
303
|
+
return `<div class="vtile" onclick="openVault('${esc(n)}')">
|
|
304
|
+
<button class="star ${isPinned(n)?'':'off'}" onclick="event.stopPropagation();togglePin('${esc(n)}')" title="pin">★</button>
|
|
305
|
+
<span class="nm">${esc(n)}</span>
|
|
306
|
+
<span class="tag ${envClass(v.env)}">${esc(v.env)}</span>
|
|
307
|
+
<span class="rg mono">${esc(v.resourceGroup)}</span></div>`;
|
|
308
|
+
};
|
|
309
|
+
const p = pins().filter(findVault), rec = recents().filter(n=>!p.includes(n)&&findVault(n));
|
|
310
|
+
$('#route').innerHTML = `
|
|
311
|
+
<div class="actcards">${cards.join('')}</div>
|
|
312
|
+
<div class="dashgrid">
|
|
313
|
+
<div>
|
|
314
|
+
<div class="secthead2"><span class="eyebrow">Pinned vaults</span>
|
|
315
|
+
<button class="iconbtn" onclick="navigate('vaults')">All vaults →</button></div>
|
|
316
|
+
<div class="vtiles">${p.length? p.map(tileRow).join('') : `<div class="empty" style="padding:30px">Open any vault and press ★ to pin it here for one-click access.</div>`}</div>
|
|
317
|
+
</div>
|
|
318
|
+
<div>
|
|
319
|
+
<div class="secthead2"><span class="eyebrow">Recently opened</span>
|
|
320
|
+
${EXP.scannedAt?`<span class="tablenote">expiry scanned ${new Date(EXP.scannedAt).toLocaleTimeString()}</span>`:''}</div>
|
|
321
|
+
<div class="vtiles">${rec.length? rec.map(tileRow).join('') : `<div class="empty" style="padding:30px">Vaults you open will appear here.</div>`}</div>
|
|
322
|
+
</div>
|
|
323
|
+
</div>`;
|
|
324
|
+
}
|
|
325
|
+
function envCount(){ const s=new Set(STATE.vaults.map(v=>v.env).filter(e=>e&&e!=='—'&&e!=='?')); return s.size; }
|
|
326
|
+
function expCounts(){
|
|
327
|
+
const items = EXP.allItems||[];
|
|
328
|
+
return {
|
|
329
|
+
expired: items.filter(i=>i.daysLeft<0).length,
|
|
330
|
+
soon: items.filter(i=>i.daysLeft>=0&&i.daysLeft<=7).length,
|
|
331
|
+
month: items.filter(i=>i.daysLeft>=0&&i.daysLeft<=30).length,
|
|
332
|
+
};
|
|
329
333
|
}
|
|
330
|
-
function setEnv(e){ STATE.env=e; renderEnvChips(); renderVaults(); }
|
|
331
334
|
|
|
335
|
+
// ---------- VAULTS (inventory) ----------
|
|
336
|
+
const FACET_PREF = ['solution','cost-center','contact-main','contact-ccoe','owner','roles'];
|
|
337
|
+
const isEnvKey = k => /^(env|environment)$/i.test(k);
|
|
338
|
+
// Pick up to 4 tag dimensions to expose as dropdown facets: preferred keys first
|
|
339
|
+
// (case-insensitive match to the real tag casing), then the most common remaining keys.
|
|
340
|
+
function facetKeys(){
|
|
341
|
+
const freq = {};
|
|
342
|
+
STATE.vaults.forEach(v=>Object.keys(v.tags||{}).forEach(k=>{ if(!isEnvKey(k)) freq[k]=(freq[k]||0)+1; }));
|
|
343
|
+
const real = Object.keys(freq);
|
|
344
|
+
const matchPref = p => real.find(k=>k.toLowerCase()===p);
|
|
345
|
+
const picked = [];
|
|
346
|
+
for(const p of FACET_PREF){ const k=matchPref(p); if(k && !picked.includes(k)) picked.push(k); }
|
|
347
|
+
for(const [k] of Object.entries(freq).sort((a,b)=>b[1]-a[1])){ if(picked.length>=4) break; if(!picked.includes(k)) picked.push(k); }
|
|
348
|
+
return picked.slice(0,4);
|
|
349
|
+
}
|
|
350
|
+
function tagVal(v,k){ const t=v.tags||{}; return t[k]!=null?String(t[k]):''; }
|
|
332
351
|
function filteredVaults(){
|
|
333
352
|
const q = STATE.q.toLowerCase();
|
|
334
|
-
let list = STATE.vaults.filter(v
|
|
335
|
-
(STATE.env
|
|
336
|
-
(
|
|
337
|
-
|
|
338
|
-
|
|
353
|
+
let list = STATE.vaults.filter(v=>{
|
|
354
|
+
if(STATE.env!=='all' && v.env!==STATE.env) return false;
|
|
355
|
+
for(const [k,val] of Object.entries(STATE.facets)){ if(val && tagVal(v,k)!==val) return false; }
|
|
356
|
+
if(q){
|
|
357
|
+
const hay = [v.name, v.resourceGroup, v.service, ...Object.values(v.tags||{})].join(' ').toLowerCase();
|
|
358
|
+
if(!hay.includes(q)) return false;
|
|
359
|
+
}
|
|
360
|
+
return true;
|
|
361
|
+
});
|
|
362
|
+
const k=STATE.sort, d=STATE.dir;
|
|
363
|
+
list.sort((a,b)=>{ if(k==='pin'){ return ((isPinned(b.name)?1:0)-(isPinned(a.name)?1:0))*d || a.name.localeCompare(b.name); }
|
|
364
|
+
return String(a[k]||'').localeCompare(String(b[k]||''))*d; });
|
|
339
365
|
return list;
|
|
340
366
|
}
|
|
341
|
-
function
|
|
367
|
+
function viewVaults(){
|
|
368
|
+
if(!STATE.vaults.length){
|
|
369
|
+
$('#route').innerHTML = `<div class="panel"><div class="empty"><span class="spin"></span> querying Resource Graph…</div></div>`;
|
|
370
|
+
loadVaults().then(()=>{ if(STATE.route==='vaults') viewVaults(); });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const envs = {}; STATE.vaults.forEach(v=>envs[v.env]=(envs[v.env]||0)+1);
|
|
374
|
+
const order = ['all',...Object.keys(envs).sort()];
|
|
375
|
+
const chips = order.map(e=>{ const n=e==='all'?STATE.vaults.length:envs[e];
|
|
376
|
+
return `<div class="chip ${STATE.env===e?'active':''}" onclick="setEnv('${esc(e)}')">${e==='all'?'All environments':esc(e)}<span class="n">${n}</span></div>`; }).join('');
|
|
377
|
+
const fkeys = facetKeys();
|
|
378
|
+
const facets = fkeys.map(k=>{
|
|
379
|
+
const vals=[...new Set(STATE.vaults.map(v=>tagVal(v,k)).filter(Boolean))].sort();
|
|
380
|
+
if(!vals.length) return '';
|
|
381
|
+
const sel=STATE.facets[k]||'';
|
|
382
|
+
return `<div class="facet"><label>${esc(k)}</label>
|
|
383
|
+
<select class="facetsel ${sel?'on':''}" onchange="setFacet('${esc(k)}',this.value)">
|
|
384
|
+
<option value="">any</option>${vals.map(x=>`<option ${x===sel?'selected':''} value="${esc(x)}">${esc(x)}</option>`).join('')}</select></div>`;
|
|
385
|
+
}).join('');
|
|
386
|
+
const anyFacet = Object.values(STATE.facets).some(Boolean);
|
|
387
|
+
$('#route').innerHTML = `
|
|
388
|
+
<div class="chips">${chips}</div>
|
|
389
|
+
${facets?`<div class="facets">${facets}${anyFacet?`<button class="facetclear" onclick="clearFacets()">clear filters</button>`:''}</div>`:''}
|
|
390
|
+
<div class="panel"><div class="scroll"><table>
|
|
391
|
+
<thead><tr>
|
|
392
|
+
<th data-sort="pin" style="width:30px"></th>
|
|
393
|
+
<th data-sort="name">Vault</th><th data-sort="env">Env</th>
|
|
394
|
+
<th data-sort="service">Service</th><th>Tags</th><th data-sort="resourceGroup">Resource Group</th>
|
|
395
|
+
<th data-sort="sku">SKU</th><th>Auth</th><th class="right">Actions</th>
|
|
396
|
+
</tr></thead><tbody id="vaultsBody"></tbody>
|
|
397
|
+
</table></div></div>
|
|
398
|
+
<div class="tablenote" id="vaultCount"></div>`;
|
|
399
|
+
document.querySelectorAll('th[data-sort]').forEach(th=>th.onclick=()=>{ const k=th.dataset.sort; STATE.dir=STATE.sort===k?-STATE.dir:1; STATE.sort=k; renderVaultRows(); });
|
|
400
|
+
renderVaultRows();
|
|
401
|
+
}
|
|
402
|
+
function setEnv(e){ STATE.env=e; viewVaults(); }
|
|
403
|
+
function setFacet(k,val){ if(val) STATE.facets[k]=val; else delete STATE.facets[k]; viewVaults(); }
|
|
404
|
+
function clearFacets(){ STATE.facets={}; viewVaults(); }
|
|
405
|
+
function renderVaultRows(){
|
|
342
406
|
const list = filteredVaults();
|
|
343
407
|
$('#vaultCount').textContent = `${list.length} vault${list.length!==1?'s':''} shown`;
|
|
344
|
-
if(!list.length){ $('#vaultsBody').innerHTML='<tr><td colspan="
|
|
345
|
-
$('#vaultsBody').innerHTML = list.map(v
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
<td class="
|
|
408
|
+
if(!list.length){ $('#vaultsBody').innerHTML='<tr><td colspan="9" class="empty">No vaults match your filter.</td></tr>'; return; }
|
|
409
|
+
$('#vaultsBody').innerHTML = list.map(v=>{
|
|
410
|
+
const tags = Object.entries(v.tags||{}).filter(([k])=>!/^env$/i.test(k)).slice(0,2)
|
|
411
|
+
.map(([k,val])=>`<span class="tagchip"><b>${esc(k)}</b> ${esc(val)}</span>`).join('');
|
|
412
|
+
const more = Object.keys(v.tags||{}).filter(k=>!/^env$/i.test(k)).length-2;
|
|
413
|
+
return `<tr>
|
|
414
|
+
<td><button class="star ${isPinned(v.name)?'on':''}" onclick="togglePin('${esc(v.name)}')" title="pin vault">★</button></td>
|
|
415
|
+
<td class="mono"><b>${esc(v.name)}</b></td>
|
|
416
|
+
<td><span class="tag ${envClass(v.env)}">${esc(v.env)}</span></td>
|
|
417
|
+
<td class="muted">${esc(v.service||'')}</td>
|
|
418
|
+
<td><div class="tagchips">${tags}${more>0?`<span class="tagchip">+${more}</span>`:''}</div></td>
|
|
419
|
+
<td class="mono muted">${esc(v.resourceGroup)}</td>
|
|
420
|
+
<td class="mono">${esc(v.sku||'—')}</td>
|
|
351
421
|
<td>${v.enableRbac==='true'?'<span class="pill rbac">RBAC</span>':'<span class="pill policy">Policy</span>'}</td>
|
|
352
422
|
<td class="right">
|
|
353
|
-
<button class="iconbtn" onclick="openVault('${v.name}'
|
|
354
|
-
<button class="iconbtn" onclick="copy('az keyvault secret list --vault-name ${v.name} -o table')">⧉</button>
|
|
355
|
-
</td></tr
|
|
423
|
+
<button class="iconbtn" onclick="openVault('${esc(v.name)}')">Open</button>
|
|
424
|
+
<button class="iconbtn" onclick="copy('az keyvault secret list --vault-name ${esc(v.name)} -o table')">⧉</button>
|
|
425
|
+
</td></tr>`;}).join('');
|
|
356
426
|
}
|
|
357
427
|
|
|
358
|
-
// ----------
|
|
428
|
+
// ---------- VAULT PAGE (routed) ----------
|
|
359
429
|
let DRW = { vault:'', kind:'secrets', items:[], filter:'', sel:new Set() };
|
|
360
|
-
function openVault(name
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
430
|
+
function openVault(name){ pushRecent(name); navigate('vault/'+encodeURIComponent(name)); }
|
|
431
|
+
const KIND_TABS = [['secrets','Secrets'],['keys','Keys'],['certificates','Certificates'],['tags','Tags']];
|
|
432
|
+
function viewVault(name, kind){
|
|
433
|
+
const v = findVault(name) || (name?{name, subscriptionId:'', resourceGroup:'', uri:'', enableRbac:'', tags:{}}:null);
|
|
434
|
+
if(!v){ $('#route').innerHTML=`<div class="panel"><div class="empty">No vault specified. <a href="#/vaults">Back to Key Vaults</a></div></div>`; return; }
|
|
435
|
+
STATE.drawerVault=v; STATE.kind=kind;
|
|
436
|
+
if(mountedVault!==name){
|
|
437
|
+
mountedVault=name;
|
|
438
|
+
DRW={vault:name, kind, items:[], filter:'', sel:new Set()};
|
|
439
|
+
const tabs = KIND_TABS.map(([k,l])=>`<button data-kind="${k}" class="${k===kind?'active':''}" onclick="goKind('${esc(name)}','${k}')">${l}</button>`).join('');
|
|
440
|
+
$('#route').innerHTML = `
|
|
441
|
+
<div class="vhead">
|
|
442
|
+
<div class="crumb"><a href="#/vaults">Key Vaults</a><span class="sep">/</span>
|
|
443
|
+
<span class="here">${esc(name)}</span>
|
|
444
|
+
<button class="star ${isPinned(name)?'on':''}" onclick="togglePin('${esc(name)}')" title="pin">★</button>
|
|
445
|
+
<span class="tag ${envClass(v.env)}">${esc(v.env||'')}</span>
|
|
446
|
+
${v.enableRbac==='true'?'<span class="pill rbac">RBAC</span>':v.enableRbac==='false'?'<span class="pill policy">Policy</span>':''}</div>
|
|
447
|
+
<div class="vmeta">
|
|
448
|
+
<span>${esc(subName(v.subscriptionId))}</span><span class="sep">·</span>
|
|
449
|
+
<span class="mono">${esc(v.resourceGroup||'')}</span>
|
|
450
|
+
${v.uri?`<span class="sep">·</span><span class="mono">${esc(v.uri)}</span><button class="iconbtn" onclick="copy('${esc(v.uri)}')">⧉</button>`:''}</div>
|
|
451
|
+
</div>
|
|
452
|
+
<div class="tabs" id="dSeg">${tabs}</div>
|
|
453
|
+
<div id="vbody"></div>`;
|
|
454
|
+
} else {
|
|
455
|
+
$('#dSeg').querySelectorAll('button').forEach(b=>b.classList.toggle('active', b.dataset.kind===kind));
|
|
456
|
+
}
|
|
457
|
+
DRW.kind=kind;
|
|
458
|
+
if(kind==='tags') renderTagsTab(v); else renderItemsShell();
|
|
459
|
+
}
|
|
460
|
+
function goKind(name, kind){ STATE.kind=kind; navigate('vault/'+encodeURIComponent(name)+'/'+kind); }
|
|
461
|
+
|
|
462
|
+
function renderItemsShell(){
|
|
463
|
+
const isSecrets = STATE.kind==='secrets';
|
|
464
|
+
$('#vbody').innerHTML = `
|
|
465
|
+
<div class="vtools">
|
|
466
|
+
<div class="vtools-row">
|
|
467
|
+
<input class="search" id="dFilter" placeholder="filter by name…" autocomplete="off" value="${esc(DRW.filter)}">
|
|
468
|
+
<span class="faint mono" id="dCount"></span>
|
|
469
|
+
${isSecrets?`<span class="vtools-actions"><button class="btn sm" id="dNew">+ New secret</button><button class="btn sm ghost" id="dNewBatch">+ Batch</button></span>`:''}
|
|
470
|
+
</div>
|
|
471
|
+
<div class="vtools-row" id="selRow"><label class="selall"><input type="checkbox" id="dSelAll"> select all</label></div>
|
|
472
|
+
</div>
|
|
473
|
+
<div class="createp hide" id="dCreate"></div>
|
|
474
|
+
<div class="batchbar hide stick" id="dBatch"></div>
|
|
475
|
+
<div class="itemwrap" id="dContent"><div class="empty"><span class="spin"></span></div></div>
|
|
476
|
+
<div class="secthead">CLI commands</div>
|
|
477
|
+
<div id="dCmds"></div>`;
|
|
478
|
+
$('#dFilter').oninput=e=>{ DRW.filter=e.target.value; renderDrawerList(); };
|
|
479
|
+
$('#dSelAll').onchange=e=>selectAll(e.target.checked);
|
|
480
|
+
if(isSecrets){ $('#dNew').onclick=()=>openCreate('single'); $('#dNewBatch').onclick=()=>openCreate('batch'); }
|
|
481
|
+
renderCmds(STATE.drawerVault);
|
|
482
|
+
loadKind();
|
|
483
|
+
}
|
|
372
484
|
function renderCmds(v){
|
|
373
485
|
const cmds = [
|
|
374
486
|
`az keyvault secret list --vault-name ${v.name} -o table`,
|
|
375
487
|
`az keyvault key list --vault-name ${v.name} -o table`,
|
|
376
488
|
`az keyvault show --name ${v.name} -o jsonc`,
|
|
377
489
|
];
|
|
378
|
-
$('#dCmds').innerHTML = cmds.map(c=>`<div class="cmd"><span>${c}</span><button class="iconbtn" onclick='copy(${JSON.stringify(c)})'>⧉</button></div>`).join('');
|
|
490
|
+
$('#dCmds').innerHTML = cmds.map(c=>`<div class="cmd"><span>${esc(c)}</span><button class="iconbtn" onclick='copy(${JSON.stringify(c)})'>⧉</button></div>`).join('');
|
|
379
491
|
}
|
|
380
492
|
const isEditable = () => DRW.kind==='secrets' || DRW.kind==='certificates';
|
|
381
493
|
async function loadKind(){
|
|
382
494
|
const v = STATE.drawerVault; DRW.kind = STATE.kind; DRW.sel.clear();
|
|
383
|
-
$('#dTools').style.display = '';
|
|
384
495
|
closeCreate();
|
|
385
|
-
$('#dNewBtns').style.display = STATE.kind==='secrets' ? '' : 'none';
|
|
386
496
|
$('#dContent').innerHTML = '<div class="empty"><span class="spin"></span></div>';
|
|
387
497
|
const r = await api(`/api/vaults/${v.name}/${DRW.kind}`);
|
|
498
|
+
if(STATE.route!=='vault') return;
|
|
388
499
|
if(!r.ok){
|
|
389
|
-
DRW.items=[]; $('#dBatch').classList.add('hide'); $('#dCount').textContent='';
|
|
500
|
+
DRW.items=[]; $('#dBatch').classList.add('hide'); if($('#dCount'))$('#dCount').textContent='';
|
|
390
501
|
let why;
|
|
391
|
-
if(r.firewall) why = `🚫 This vault's firewall is blocking your network — your current IP isn't allowed. Connect to the corporate VPN and retry.<div class="hint" style="margin-top:8px">${r.error||''}</div>`;
|
|
502
|
+
if(r.firewall) why = `🚫 This vault's firewall is blocking your network — your current IP isn't allowed. Connect to the corporate VPN and retry.<div class="hint" style="margin-top:8px">${esc(r.error||'')}</div>`;
|
|
392
503
|
else if(r.forbidden) why = `🔒 You can see this vault but have no data-plane permission on its ${DRW.kind}.`;
|
|
393
|
-
else why = r.error||'failed';
|
|
504
|
+
else why = esc(r.error||'failed');
|
|
394
505
|
$('#dContent').innerHTML = `<div class="empty">${why}</div>`;
|
|
395
506
|
return;
|
|
396
507
|
}
|
|
@@ -398,37 +509,33 @@ async function loadKind(){
|
|
|
398
509
|
name: item.name || (item.id||'').split('/').pop(),
|
|
399
510
|
enabled: item.attributes ? item.attributes.enabled!==false : true,
|
|
400
511
|
expires: item.attributes && item.attributes.expires || null,
|
|
401
|
-
managed: item.managed===true,
|
|
512
|
+
managed: item.managed===true,
|
|
402
513
|
}));
|
|
403
514
|
renderDrawerList();
|
|
404
515
|
}
|
|
405
|
-
function drawerFiltered(){
|
|
406
|
-
const q = DRW.filter.toLowerCase();
|
|
407
|
-
return DRW.items.filter(i=>!q || i.name.toLowerCase().includes(q));
|
|
408
|
-
}
|
|
516
|
+
function drawerFiltered(){ const q=DRW.filter.toLowerCase(); return DRW.items.filter(i=>!q||i.name.toLowerCase().includes(q)); }
|
|
409
517
|
function renderDrawerList(){
|
|
410
|
-
const list = drawerFiltered();
|
|
411
|
-
|
|
412
|
-
$('#
|
|
413
|
-
$('#dSelAll').parentElement.style.display = editable?'':'none';
|
|
518
|
+
const list = drawerFiltered(); const editable = isEditable();
|
|
519
|
+
if($('#dCount')) $('#dCount').textContent = `${list.length}${list.length!==DRW.items.length?' / '+DRW.items.length:''} ${DRW.kind}`;
|
|
520
|
+
if($('#selRow')) $('#selRow').style.display = editable?'':'none';
|
|
414
521
|
if(!DRW.items.length){ $('#dContent').innerHTML=`<div class="empty">No ${DRW.kind} in this vault, or none visible to you.</div>`; renderBatchBar(); return; }
|
|
415
|
-
if(!list.length){ $('#dContent').innerHTML=`<div class="empty">No ${DRW.kind} match “${DRW.filter}”.</div>`; renderBatchBar(); return; }
|
|
522
|
+
if(!list.length){ $('#dContent').innerHTML=`<div class="empty">No ${DRW.kind} match “${esc(DRW.filter)}”.</div>`; renderBatchBar(); return; }
|
|
416
523
|
$('#dContent').innerHTML = list.map(i=>{
|
|
417
524
|
const nm=i.name, sel=DRW.sel.has(nm), lock=i.managed;
|
|
418
|
-
const check = editable && !lock?`<input type="checkbox" ${sel?'checked':''} onclick="toggleSel('${nm}')">`:'';
|
|
525
|
+
const check = editable && !lock?`<input type="checkbox" ${sel?'checked':''} onclick="toggleSel('${esc(nm)}')">`:'';
|
|
419
526
|
const pills = `${i.enabled?'':'<span class="pill">disabled</span>'}${i.expires?`<span class="pill">exp ${i.expires.slice(0,10)}</span>`:''}${lock?'<span class="pill" title="managed by a certificate — edit it in the Certificates tab">managed</span>':''}`;
|
|
420
|
-
const reveal = DRW.kind==='secrets'?`<button class="loadval" onclick="reveal('${nm}')">reveal</button>`:'';
|
|
421
|
-
const edit = editable && !lock?`<button class="iconbtn" onclick="toggleEdit('${nm}')">Edit</button>`:'';
|
|
527
|
+
const reveal = DRW.kind==='secrets'?`<button class="loadval" onclick="reveal('${esc(nm)}')">reveal</button>`:'';
|
|
528
|
+
const edit = editable && !lock?`<button class="iconbtn" onclick="toggleEdit('${esc(nm)}')">Edit</button>`:'';
|
|
422
529
|
return `<div class="kvitem ${sel?'sel':''}">
|
|
423
|
-
<div class="kvline">${check}<span class="nm" title="${nm}">${nm}</span><span class="meta">${pills}${reveal}${edit}</span></div>
|
|
424
|
-
<div class="revealed hide" id="rv-${nm}"></div>
|
|
425
|
-
<div class="editor hide" id="ed-${nm}"></div>
|
|
530
|
+
<div class="kvline">${check}<span class="nm" title="${esc(nm)}">${esc(nm)}</span><span class="meta">${pills}${reveal}${edit}</span></div>
|
|
531
|
+
<div class="revealed hide" id="rv-${esc(nm)}"></div>
|
|
532
|
+
<div class="editor hide" id="ed-${esc(nm)}"></div>
|
|
426
533
|
</div>`;
|
|
427
534
|
}).join('');
|
|
428
535
|
renderBatchBar();
|
|
429
536
|
}
|
|
430
537
|
function renderBatchBar(){
|
|
431
|
-
const bar=$('#dBatch')
|
|
538
|
+
const bar=$('#dBatch'); if(!bar) return; const n=DRW.sel.size;
|
|
432
539
|
if(!n || !isEditable() || !$('#dCreate').classList.contains('hide')){ bar.classList.add('hide'); return; }
|
|
433
540
|
bar.classList.remove('hide');
|
|
434
541
|
const secretOnly = DRW.kind==='secrets'
|
|
@@ -440,11 +547,9 @@ function renderBatchBar(){
|
|
|
440
547
|
${secretOnly}
|
|
441
548
|
<button class="iconbtn" onclick="clearSel()">Clear</button>`;
|
|
442
549
|
}
|
|
443
|
-
// Batch-edit the VALUES of the selected secrets, pre-filled with their current values.
|
|
444
550
|
async function batchEditValues(){
|
|
445
551
|
const names=[...DRW.sel]; if(!names.length) return;
|
|
446
|
-
const el=$('#dCreate'); $('#dBatch').classList.add('hide');
|
|
447
|
-
el.classList.remove('hide');
|
|
552
|
+
const el=$('#dCreate'); $('#dBatch').classList.add('hide'); el.classList.remove('hide');
|
|
448
553
|
el.innerHTML = `<h4>Edit ${names.length} secret value${names.length!==1?'s':''}</h4><div class="empty"><span class="spin"></span> loading current values…</div>`;
|
|
449
554
|
const vals = await Promise.all(names.map(n=>
|
|
450
555
|
api(`/api/vaults/${DRW.vault}/secrets/${n}/value`).then(r=>({n, ok:r.ok, val:r.ok?(r.data.value||''):'', err:r.forbidden?'no read permission':(r.error||'failed')}))));
|
|
@@ -456,14 +561,14 @@ async function batchEditValues(){
|
|
|
456
561
|
if(blocked.length) notes.push(`${blocked.length} value(s) couldn't be read and were skipped`);
|
|
457
562
|
if(multiline.length) notes.push(`${multiline.length} multi-line value(s) skipped — edit those individually`);
|
|
458
563
|
el.innerHTML = `<h4>Edit ${usable.length} secret value${usable.length!==1?'s':''}</h4>
|
|
459
|
-
<div class="fld" style="align-items:flex-start"><label>Pairs</label><textarea id="cBatch">${lines
|
|
564
|
+
<div class="fld" style="align-items:flex-start"><label>Pairs</label><textarea id="cBatch">${esc(lines)}</textarea></div>
|
|
460
565
|
<div class="fld"><label>Expires</label><input type="date" id="cExp"><span class="hint">optional · applied to every saved secret</span></div>
|
|
461
566
|
<div class="actions"><button class="btn sm" onclick="submitCreate('batch')">Save changes</button>
|
|
462
567
|
<button class="btn sm ghost" onclick="closeCreate()">Cancel</button>
|
|
463
|
-
<span class="hint" id="cMsg">${notes.join(' · ')}</span></div>`;
|
|
568
|
+
<span class="hint" id="cMsg">${esc(notes.join(' · '))}</span></div>`;
|
|
464
569
|
}
|
|
465
570
|
function toggleSel(nm){ DRW.sel.has(nm)?DRW.sel.delete(nm):DRW.sel.add(nm); renderDrawerList(); }
|
|
466
|
-
function clearSel(){ DRW.sel.clear(); $('#dSelAll').checked=false; renderDrawerList(); }
|
|
571
|
+
function clearSel(){ DRW.sel.clear(); if($('#dSelAll'))$('#dSelAll').checked=false; renderDrawerList(); }
|
|
467
572
|
function selectAll(on){ DRW.sel.clear(); if(on) drawerFiltered().filter(i=>!i.managed).forEach(i=>DRW.sel.add(i.name)); renderDrawerList(); }
|
|
468
573
|
function openCreate(mode){
|
|
469
574
|
const el=$('#dCreate'); DRW.sel.clear(); $('#dBatch').classList.add('hide');
|
|
@@ -513,7 +618,7 @@ async function submitCreate(mode){
|
|
|
513
618
|
const ok=r.results.filter(x=>x.ok).length, fail=r.results.length-ok;
|
|
514
619
|
toast(`Created ${ok} secret${ok!==1?'s':''}${fail?`, ${fail} failed`:''}`);
|
|
515
620
|
if(fail){ const f=r.results.filter(x=>!x.ok);
|
|
516
|
-
banner(`Create: ${ok} ok, ${fail} failed (${f.slice(0,3).map(x=>x.name+': '+x.error).join('; ')}${fail>3?'…':''}).`);
|
|
621
|
+
banner(`Create: ${ok} ok, ${fail} failed (${f.slice(0,3).map(x=>esc(x.name+': '+x.error)).join('; ')}${fail>3?'…':''}).`);
|
|
517
622
|
setTimeout(()=>banner(''),9000); }
|
|
518
623
|
closeCreate(); setTimeout(loadKind, 350);
|
|
519
624
|
}
|
|
@@ -524,18 +629,17 @@ function toggleEdit(nm){
|
|
|
524
629
|
const cert = DRW.kind==='certificates';
|
|
525
630
|
const exp = it.expires?it.expires.slice(0,10):'';
|
|
526
631
|
el.innerHTML = `
|
|
527
|
-
<div class="fld"><label>Enabled</label><label class="toggle"><input type="checkbox" id="edEn-${nm}" ${it.enabled?'checked':''}> ${it.enabled?'enabled':'disabled'}</label></div>
|
|
632
|
+
<div class="fld"><label>Enabled</label><label class="toggle"><input type="checkbox" id="edEn-${esc(nm)}" ${it.enabled?'checked':''}> ${it.enabled?'enabled':'disabled'}</label></div>
|
|
528
633
|
${cert
|
|
529
634
|
? `<div class="fld"><label>Expires</label><span class="hint">${exp||'—'} · set by the certificate, not editable here</span></div>`
|
|
530
|
-
: `<div class="fld"><label>Expires</label><input type="date" id="edExp-${nm}" value="${exp}"><button class="iconbtn" onclick="document.getElementById('edExp-${CSS.escape(nm)}').value=''">clear</button></div>
|
|
531
|
-
<div class="fld"><label>Value</label><input type="text" id="edVal-${nm}" placeholder="loading current value…"></div>
|
|
635
|
+
: `<div class="fld"><label>Expires</label><input type="date" id="edExp-${esc(nm)}" value="${exp}"><button class="iconbtn" onclick="document.getElementById('edExp-${CSS.escape(nm)}').value=''">clear</button></div>
|
|
636
|
+
<div class="fld"><label>Value</label><input type="text" id="edVal-${esc(nm)}" placeholder="loading current value…"></div>
|
|
532
637
|
<div class="fld"><label></label><span class="hint">current value is pre-filled — saving re-writes only if you change it</span></div>`}
|
|
533
|
-
<div class="fld"><label></label><button class="btn sm" onclick="saveEdit('${nm}')">Save changes</button>
|
|
534
|
-
<button class="btn sm ghost" onclick="toggleEdit('${nm}')">Cancel</button>
|
|
535
|
-
<span class="hint" id="edMsg-${nm}"></span></div>`;
|
|
638
|
+
<div class="fld"><label></label><button class="btn sm" onclick="saveEdit('${esc(nm)}')">Save changes</button>
|
|
639
|
+
<button class="btn sm ghost" onclick="toggleEdit('${esc(nm)}')">Cancel</button>
|
|
640
|
+
<span class="hint" id="edMsg-${esc(nm)}"></span></div>`;
|
|
536
641
|
el.classList.remove('hide');
|
|
537
642
|
if(!cert){
|
|
538
|
-
const vEl=document.getElementById('edVal-'+nm);
|
|
539
643
|
api(`/api/vaults/${DRW.vault}/secrets/${nm}/value`).then(r=>{
|
|
540
644
|
const live=document.getElementById('edVal-'+nm); if(!live) return;
|
|
541
645
|
if(r.ok){ live.value=r.data.value||''; live.dataset.orig=r.data.value||''; live.placeholder='secret value'; }
|
|
@@ -584,123 +688,208 @@ async function batchApply(action){
|
|
|
584
688
|
const ok=r.results.filter(x=>x.ok).length, fail=r.results.length-ok;
|
|
585
689
|
toast(`${ok} updated${fail?`, ${fail} failed`:''}`);
|
|
586
690
|
if(fail){ const failed=r.results.filter(x=>!x.ok); console.warn('Batch failures:', failed);
|
|
587
|
-
banner(`Batch: ${ok} updated, ${fail} failed (${failed.slice(0,3).map(f=>f.name+': '+f.error).join('; ')}${fail>3?'…':''}). See console for all.`);
|
|
691
|
+
banner(`Batch: ${ok} updated, ${fail} failed (${failed.slice(0,3).map(f=>esc(f.name+': '+f.error)).join('; ')}${fail>3?'…':''}). See console for all.`);
|
|
588
692
|
setTimeout(()=>banner(''),8000); }
|
|
589
|
-
DRW.sel.clear(); $('#dSelAll').checked=false;
|
|
693
|
+
DRW.sel.clear(); if($('#dSelAll'))$('#dSelAll').checked=false;
|
|
590
694
|
setTimeout(loadKind, 350);
|
|
591
695
|
}
|
|
592
|
-
// Reveal a secret value on its own full-width line below the row (toggles).
|
|
593
696
|
async function reveal(name){
|
|
594
|
-
const box = document.getElementById('rv-'+name);
|
|
595
|
-
if(!box) return;
|
|
697
|
+
const box = document.getElementById('rv-'+name); if(!box) return;
|
|
596
698
|
if(!box.classList.contains('hide')){ box.classList.add('hide'); box.innerHTML=''; return; }
|
|
597
699
|
box.classList.remove('hide'); box.innerHTML = '<span class="spin"></span>';
|
|
598
700
|
const r = await api(`/api/vaults/${DRW.vault}/secrets/${name}/value`);
|
|
599
|
-
if(!r.ok){ box.innerHTML = `<span class="muted mono">${r.firewall?'🚫 blocked by vault firewall — connect to VPN':r.forbidden?'🔒 no permission':r.error}</span>`; return; }
|
|
701
|
+
if(!r.ok){ box.innerHTML = `<span class="muted mono">${r.firewall?'🚫 blocked by vault firewall — connect to VPN':r.forbidden?'🔒 no permission':esc(r.error)}</span>`; return; }
|
|
600
702
|
const val = r.data.value || '';
|
|
601
|
-
box.innerHTML = `<code class="val" title="click to select">${val
|
|
703
|
+
box.innerHTML = `<code class="val" title="click to select">${esc(val)}</code><button class="iconbtn" onclick='copy(${JSON.stringify(val)})'>⧉</button>`;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ---------- VAULT TAGS tab ----------
|
|
707
|
+
function renderTagsTab(v){
|
|
708
|
+
const all = Object.entries(v.tags||{});
|
|
709
|
+
const locked = all.filter(([k])=>isProtectedTag(k));
|
|
710
|
+
const editable = all.filter(([k])=>!isProtectedTag(k));
|
|
711
|
+
const lockedRow = (k,val) => `<div class="tagrow fixed" title="default tag — managed by policy, read-only here">
|
|
712
|
+
<input class="tk" value="${esc(k)}" disabled>
|
|
713
|
+
<input class="tv" value="${esc(val)}" disabled>
|
|
714
|
+
<span class="del" style="display:inline-flex;width:30px;justify-content:center;color:var(--faint)" title="protected">🔒</span></div>`;
|
|
715
|
+
const editRow = (k,val) => `<div class="tagrow">
|
|
716
|
+
<input class="tk" placeholder="tag name" value="${esc(k)}">
|
|
717
|
+
<input class="tv" placeholder="value" value="${esc(val)}">
|
|
718
|
+
<button class="iconbtn del" onclick="this.closest('.tagrow').remove()">✕</button></div>`;
|
|
719
|
+
$('#vbody').innerHTML = `
|
|
720
|
+
<div class="tagedit" id="tagEdit">
|
|
721
|
+
<div class="sub" style="margin-bottom:4px">Vault tags drive the Env label, Service column and tag filters.
|
|
722
|
+
Saving replaces the editable tag set (Azure allows up to 50).
|
|
723
|
+
${locked.length?`<b>${locked.length} default tag${locked.length!==1?'s are':' is'}</b> 🔒 locked and cannot be changed here.`:''}</div>
|
|
724
|
+
${locked.length?`<div class="secthead" style="margin:14px 0 2px">Default tags · read-only</div>
|
|
725
|
+
<div>${locked.map(([k,val])=>lockedRow(k,val)).join('')}</div>`:''}
|
|
726
|
+
<div class="secthead" style="margin:16px 0 2px">Custom tags</div>
|
|
727
|
+
<div id="tagRows">${editable.length? editable.map(([k,val])=>editRow(k,val)).join('') : editRow('','')}</div>
|
|
728
|
+
<div class="row" style="margin-top:8px">
|
|
729
|
+
<button class="btn sm ghost" onclick="addTagRow()">+ Add tag</button>
|
|
730
|
+
<button class="btn sm" id="tagSave" onclick="saveTags('${esc(v.name)}')">Save tags</button>
|
|
731
|
+
<span class="hint" id="tagMsg"></span>
|
|
732
|
+
</div>
|
|
733
|
+
</div>`;
|
|
734
|
+
}
|
|
735
|
+
function addTagRow(){
|
|
736
|
+
const wrap=$('#tagRows'); const div=document.createElement('div'); div.className='tagrow';
|
|
737
|
+
div.innerHTML=`<input class="tk" placeholder="tag name"><input class="tv" placeholder="value">
|
|
738
|
+
<button class="iconbtn del" onclick="this.closest('.tagrow').remove()">✕</button>`;
|
|
739
|
+
wrap.appendChild(div); div.querySelector('.tk').focus();
|
|
740
|
+
}
|
|
741
|
+
async function saveTags(name){
|
|
742
|
+
const msg=$('#tagMsg'); const tags={};
|
|
743
|
+
let bad=false, protectedName=null, dupe=null;
|
|
744
|
+
$('#tagRows').querySelectorAll('.tagrow').forEach(r=>{
|
|
745
|
+
const k=r.querySelector('.tk').value.trim(), val=r.querySelector('.tv').value;
|
|
746
|
+
if(!k) return;
|
|
747
|
+
if(k.length>512 || val.length>256) bad=true;
|
|
748
|
+
if(isProtectedTag(k)) protectedName=k;
|
|
749
|
+
if(k in tags) dupe=k;
|
|
750
|
+
tags[k]=val;
|
|
751
|
+
});
|
|
752
|
+
if(bad){ msg.textContent='tag name ≤512 / value ≤256 chars'; return; }
|
|
753
|
+
if(dupe){ msg.textContent=`duplicate tag name "${dupe}"`; return; }
|
|
754
|
+
if(protectedName){ msg.textContent=`"${protectedName}" is a default/protected tag name — choose another`; return; }
|
|
755
|
+
if(Object.keys(tags).length>50){ msg.textContent='too many tags (max 50)'; return; }
|
|
756
|
+
msg.textContent='saving…';
|
|
757
|
+
const r=await api(`/api/vaults/${name}/tags`,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({tags})});
|
|
758
|
+
if(!r.ok){ msg.textContent = r.forbidden?'🔒 no permission to edit tags (needs Contributor / Tag Contributor)':(r.error||'failed'); return; }
|
|
759
|
+
const v=findVault(name); if(v){ v.tags=r.data.tags||tags; v.env=vaultEnv(v); v.service=vaultService(v); STATE.drawerVault=v; }
|
|
760
|
+
msg.textContent='saved ✓'; toast('Tags updated');
|
|
602
761
|
}
|
|
603
762
|
|
|
604
|
-
// ----------
|
|
605
|
-
let EXP = { days: 30, items: [], stats: null, scannedAt: 0, sort: 'daysLeft', dir: 1, scanned: false, polling: false };
|
|
763
|
+
// ---------- EXPIRY ----------
|
|
764
|
+
let EXP = { days: 30, items: [], allItems: [], stats: null, scannedAt: 0, sort: 'daysLeft', dir: 1, scanned: false, polling: false };
|
|
606
765
|
const ROW_CAP = 500;
|
|
607
|
-
function
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
766
|
+
function summarize(items, days){
|
|
767
|
+
const now=Date.now(), horizon=now+days*86400000;
|
|
768
|
+
return items.filter(i=>Date.parse(i.expires)<=horizon).sort((a,b)=>a.daysLeft-b.daysLeft);
|
|
769
|
+
}
|
|
770
|
+
function applyStatusData(s){
|
|
771
|
+
EXP.allItems = (s.allItems||s.items||[]).map(it=>({...it, env:(findVault(it.vault)?.env)||envOf(it.subscriptionId)}));
|
|
611
772
|
EXP.stats=s.stats; EXP.scannedAt=s.scannedAt;
|
|
612
|
-
const when = s.scannedAt? new Date(s.scannedAt).toLocaleTimeString():'';
|
|
613
|
-
$('#expMeta').textContent = s.stats? `${s.stats.readable} readable · ${s.stats.forbidden} denied · ${s.stats.total} vaults · scanned ${when}` : '';
|
|
614
|
-
renderExpiring();
|
|
615
773
|
}
|
|
616
|
-
//
|
|
617
|
-
async function
|
|
774
|
+
// Kick a background scan early so the dashboard's lifecycle cards fill in.
|
|
775
|
+
async function kickExpiryScan(){
|
|
618
776
|
if(EXP.scanned || EXP.polling) return;
|
|
619
|
-
const s = await api(`/api/expiring/status?days
|
|
620
|
-
if(s.ok && s.running){ EXP.polling=true; pollExpiring(); return; }
|
|
621
|
-
if(s.ok && s.scannedAt){
|
|
622
|
-
|
|
777
|
+
const s = await api(`/api/expiring/status?days=365`);
|
|
778
|
+
if(s.ok && s.running){ EXP.polling=true; pollExpiring(true); return; }
|
|
779
|
+
if(s.ok && s.scannedAt){ EXP.scanned=true; applyStatusData({...s, allItems:s.items}); refreshCurrent(); return; }
|
|
780
|
+
const start = await api('/api/expiring/scan',{method:'POST'});
|
|
781
|
+
if(start.ok){ EXP.polling=true; pollExpiring(true); }
|
|
782
|
+
}
|
|
783
|
+
function viewExpiry(){
|
|
784
|
+
$('#route').innerHTML = `
|
|
785
|
+
<div class="row" style="margin-bottom:18px;justify-content:space-between">
|
|
786
|
+
<div class="row">
|
|
787
|
+
<span class="eyebrow">Window</span>
|
|
788
|
+
<div class="seg" id="expWindow">
|
|
789
|
+
<button data-d="7">7d</button><button data-d="30" class="active">30d</button>
|
|
790
|
+
<button data-d="60">60d</button><button data-d="90">90d</button><button data-d="365">1y</button></div>
|
|
791
|
+
<button class="btn" id="expScan">⟳ Scan vaults</button>
|
|
792
|
+
</div>
|
|
793
|
+
<span class="tablenote" id="expMeta"></span>
|
|
794
|
+
</div>
|
|
795
|
+
<div class="panel horizon hide" id="expHorizon"></div>
|
|
796
|
+
<div class="cards" id="expCards"></div>
|
|
797
|
+
<div class="panel"><div class="scroll"><table>
|
|
798
|
+
<thead><tr>
|
|
799
|
+
<th data-esort="daysLeft">Expires in</th><th data-esort="expires">Expiry date</th>
|
|
800
|
+
<th data-esort="type">Type</th><th data-esort="name">Item</th>
|
|
801
|
+
<th data-esort="vault">Vault</th><th data-esort="env">Env</th>
|
|
802
|
+
<th>Status</th><th class="right">Actions</th>
|
|
803
|
+
</tr></thead>
|
|
804
|
+
<tbody id="expBody"><tr><td colspan="8" class="empty">Press <b>Scan vaults</b> to check expiry across every vault you can read.</td></tr></tbody>
|
|
805
|
+
</table></div></div>
|
|
806
|
+
<div class="tablenote" id="expTableNote"></div>`;
|
|
807
|
+
$('#expWindow').querySelectorAll('button').forEach(b=>{ b.classList.toggle('active',+b.dataset.d===EXP.days);
|
|
808
|
+
b.onclick=()=>{ $('#expWindow').querySelectorAll('button').forEach(x=>x.classList.toggle('active',x===b)); EXP.days=+b.dataset.d; renderExpiring(); }; });
|
|
809
|
+
$('#expScan').onclick=()=>scanExpiring();
|
|
810
|
+
document.querySelectorAll('th[data-esort]').forEach(th=>th.onclick=()=>{ const k=th.dataset.esort; EXP.dir=EXP.sort===k?-EXP.dir:1; EXP.sort=k; renderExpiring(); });
|
|
811
|
+
if(EXP.polling){ $('#expScan').disabled=true; $('#expScan').textContent=`Scanning ${EXP._done||0}/${EXP._total||'…'}`; renderScanProgress(); }
|
|
812
|
+
else if(!EXP.scanned){ scanExpiring(); }
|
|
813
|
+
else { $('#expScan').textContent='⟳ Re-scan'; renderExpiring(); }
|
|
623
814
|
}
|
|
624
815
|
async function scanExpiring(){
|
|
625
816
|
if(EXP.polling) return;
|
|
626
|
-
$('#expScan').disabled = true;
|
|
627
817
|
const start = await api('/api/expiring/scan', {method:'POST'});
|
|
628
|
-
if(!start.ok){ $('#expBody').innerHTML = `<tr><td colspan="8" class="empty">${start.needLogin?'Not signed in — run az login, then Refresh.':(start.error||'failed')}</td></tr>`;
|
|
629
|
-
EXP.polling = true;
|
|
818
|
+
if(!start.ok){ if($('#expBody'))$('#expBody').innerHTML = `<tr><td colspan="8" class="empty">${start.needLogin?'Not signed in — run az login, then Refresh.':esc(start.error||'failed')}</td></tr>`; return; }
|
|
819
|
+
EXP.polling = true; if($('#expScan'))$('#expScan').disabled=true;
|
|
630
820
|
pollExpiring();
|
|
631
821
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
822
|
+
function renderScanProgress(){
|
|
823
|
+
if(STATE.route!=='expiry' || !$('#expBody')) return;
|
|
824
|
+
const pct = EXP._total? Math.round(EXP._done/EXP._total*100):0;
|
|
825
|
+
$('#expHorizon').classList.add('hide');
|
|
826
|
+
$('#expBody').innerHTML = `<tr><td colspan="8" class="empty">
|
|
827
|
+
<div class="spin"></div>
|
|
828
|
+
<div style="margin-top:16px">Inspecting <b>${EXP._done||0}</b> of <b>${EXP._total||'…'}</b> vaults…</div>
|
|
829
|
+
<div class="progress"><div class="bar" style="width:${pct}%"></div></div>
|
|
830
|
+
<div class="faint mono" style="margin-top:10px;font-size:11px">Read-denied vaults skip instantly · only your readable vaults are inspected</div>
|
|
831
|
+
</td></tr>`;
|
|
832
|
+
}
|
|
833
|
+
async function pollExpiring(silent){
|
|
834
|
+
const s = await api(`/api/expiring/status?days=365`);
|
|
835
|
+
if(!s.ok){ EXP.polling=false; if($('#expScan')){$('#expScan').disabled=false;} return; }
|
|
836
|
+
EXP._done=s.done; EXP._total=s.total;
|
|
635
837
|
if(s.running){
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
$('#expBody').innerHTML = `<tr><td colspan="8" class="empty">
|
|
640
|
-
<div class="spin"></div>
|
|
641
|
-
<div style="margin-top:16px">Inspecting <b>${s.done}</b> of <b>${s.total}</b> vaults…</div>
|
|
642
|
-
<div class="progress"><div class="bar" style="width:${pct}%"></div></div>
|
|
643
|
-
<div class="faint mono" style="margin-top:10px;font-size:11px">Read-denied vaults skip instantly · only your readable vaults are inspected</div>
|
|
644
|
-
</td></tr>`;
|
|
645
|
-
setTimeout(pollExpiring, 1500);
|
|
838
|
+
if($('#expScan')) $('#expScan').textContent = `Scanning ${s.done}/${s.total}`;
|
|
839
|
+
renderScanProgress();
|
|
840
|
+
setTimeout(()=>pollExpiring(silent), 1500);
|
|
646
841
|
return;
|
|
647
842
|
}
|
|
648
|
-
EXP.polling
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
renderExpiring();
|
|
655
|
-
}
|
|
656
|
-
function expSorted(){
|
|
843
|
+
EXP.polling=false; EXP.scanned=true;
|
|
844
|
+
applyStatusData({...s, allItems:s.items});
|
|
845
|
+
if($('#expScan')){ $('#expScan').disabled=false; $('#expScan').textContent='⟳ Re-scan'; }
|
|
846
|
+
refreshCurrent();
|
|
847
|
+
}
|
|
848
|
+
function expSorted(items){
|
|
657
849
|
const k=EXP.sort, d=EXP.dir;
|
|
658
|
-
return [...
|
|
850
|
+
return [...items].sort((a,b)=>{const av=k==='daysLeft'?a[k]:String(a[k]||''),bv=k==='daysLeft'?b[k]:String(b[k]||'');return (av>bv?1:av<bv?-1:0)*d;});
|
|
659
851
|
}
|
|
660
852
|
function renderExpiring(){
|
|
661
|
-
|
|
853
|
+
if(STATE.route!=='expiry' || !$('#expCards')) return;
|
|
854
|
+
const items = summarize(EXP.allItems, EXP.days);
|
|
662
855
|
const expired = items.filter(i=>i.daysLeft<0).length;
|
|
663
856
|
const soon = items.filter(i=>i.daysLeft>=0&&i.daysLeft<=7).length;
|
|
664
857
|
const certs = items.filter(i=>i.type==='certificate').length;
|
|
858
|
+
const when = EXP.scannedAt? new Date(EXP.scannedAt).toLocaleTimeString():'';
|
|
859
|
+
$('#expMeta').textContent = EXP.stats? `${EXP.stats.readable} readable · ${EXP.stats.forbidden} denied · ${EXP.stats.total} vaults · scanned ${when}` : '';
|
|
665
860
|
$('#expCards').innerHTML = [
|
|
666
|
-
card('Flagged', items.length, `within ${EXP.days} days`,
|
|
861
|
+
card('Flagged', items.length, `within ${EXP.days} days`, ''),
|
|
667
862
|
card('Expired', expired, 'needs rotation', expired?'alert':''),
|
|
668
863
|
card('≤ 7 days', soon, 'urgent', soon?'warn':''),
|
|
669
864
|
card('Certificates', certs, (items.length-certs)+' secrets', 'azure'),
|
|
670
865
|
].join('');
|
|
671
|
-
renderHorizon();
|
|
866
|
+
renderHorizon(items);
|
|
672
867
|
if(!items.length){
|
|
673
|
-
$('#expHorizon').classList.add('hide');
|
|
674
|
-
$('#expTableNote').textContent='';
|
|
868
|
+
$('#expHorizon').classList.add('hide'); $('#expTableNote').textContent='';
|
|
675
869
|
$('#expBody').innerHTML = `<tr><td colspan="8" class="empty">✓ Nothing expiring within ${EXP.days} days across the ${EXP.stats?EXP.stats.readable:0} vault(s) you can read.</td></tr>`;
|
|
676
870
|
return;
|
|
677
871
|
}
|
|
678
|
-
const list = expSorted();
|
|
679
|
-
const shown = list.slice(0, ROW_CAP);
|
|
872
|
+
const list = expSorted(items); const shown = list.slice(0, ROW_CAP);
|
|
680
873
|
$('#expTableNote').textContent = list.length>ROW_CAP ? `Showing first ${ROW_CAP} of ${list.length} — narrow the window or sort to see the rest.` : '';
|
|
681
874
|
$('#expBody').innerHTML = shown.map(i=>{
|
|
682
875
|
const sev = i.daysLeft<0?'bad':i.daysLeft<=7?'bad':i.daysLeft<=30?'warn':'good';
|
|
683
876
|
const label = i.daysLeft<0?`expired ${-i.daysLeft}d ago`:i.daysLeft===0?'today':`${i.daysLeft} days`;
|
|
684
|
-
const status = i.daysLeft<0?'<span class="sev expired">EXPIRED</span>':
|
|
685
|
-
i.daysLeft<=7?'<span class="sev urgent">URGENT</span>':
|
|
686
|
-
'<span class="sev ok">OK</span>';
|
|
877
|
+
const status = i.daysLeft<0?'<span class="sev expired">EXPIRED</span>':i.daysLeft<=7?'<span class="sev urgent">URGENT</span>':'<span class="sev ok">OK</span>';
|
|
687
878
|
return `<tr>
|
|
688
879
|
<td><span class="dot" style="background:var(--${sev})"></span> <b>${label}</b></td>
|
|
689
880
|
<td class="mono muted">${(i.expires||'').slice(0,10)}</td>
|
|
690
881
|
<td class="mono">${i.type==='certificate'?'cert':'secret'}</td>
|
|
691
|
-
<td class="mono">${i.name}${i.enabled?'':' <span class="pill">disabled</span>'}</td>
|
|
692
|
-
<td class="mono muted">${i.vault}</td>
|
|
693
|
-
<td><span class="tag ${envClass(i.env)}">${i.env}</span></td>
|
|
882
|
+
<td class="mono">${esc(i.name)}${i.enabled?'':' <span class="pill">disabled</span>'}</td>
|
|
883
|
+
<td class="mono muted">${esc(i.vault)}</td>
|
|
884
|
+
<td><span class="tag ${envClass(i.env)}">${esc(i.env)}</span></td>
|
|
694
885
|
<td>${status}</td>
|
|
695
|
-
<td class="right"><button class="iconbtn" onclick="openVault('${i.vault}'
|
|
696
|
-
<button class="iconbtn" onclick="copy('az keyvault ${i.type==='certificate'?'certificate':'secret'} show --vault-name ${i.vault} --name ${i.name} -o jsonc')">⧉</button></td>
|
|
886
|
+
<td class="right"><button class="iconbtn" onclick="openVault('${esc(i.vault)}')">Open</button>
|
|
887
|
+
<button class="iconbtn" onclick="copy('az keyvault ${i.type==='certificate'?'certificate':'secret'} show --vault-name ${esc(i.vault)} --name ${esc(i.name)} -o jsonc')">⧉</button></td>
|
|
697
888
|
</tr>`;
|
|
698
889
|
}).join('');
|
|
699
890
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
const el = $('#expHorizon');
|
|
703
|
-
const items = EXP.items;
|
|
891
|
+
function renderHorizon(items){
|
|
892
|
+
const el = $('#expHorizon'); if(!el) return;
|
|
704
893
|
if(!items.length){ el.classList.add('hide'); el.innerHTML=''; return; }
|
|
705
894
|
el.classList.remove('hide');
|
|
706
895
|
const days = EXP.days;
|
|
@@ -712,11 +901,11 @@ function renderHorizon(){
|
|
|
712
901
|
const ticks = future.map(i=>{
|
|
713
902
|
const left = Math.min(99.6, Math.max(.4, i.daysLeft/days*100));
|
|
714
903
|
const sev = i.daysLeft<=7?'alert':i.daysLeft<=30?'warn':'ok';
|
|
715
|
-
return `<div class="hz-tick ${sev}" style="left:${left}%" title="${i.name} · ${i.vault} · in ${i.daysLeft}d"></div>`;
|
|
904
|
+
return `<div class="hz-tick ${sev}" style="left:${left}%" title="${esc(i.name)} · ${esc(i.vault)} · in ${i.daysLeft}d"></div>`;
|
|
716
905
|
}).join('');
|
|
717
906
|
const expTicks = expired.slice(0,140).map((i,idx,arr)=>{
|
|
718
907
|
const left = arr.length>1 ? 8 + (idx/(arr.length-1))*84 : 50;
|
|
719
|
-
return `<div class="hz-tick alert" style="left:${left}%" title="${i.name} · ${i.vault} · expired ${-i.daysLeft}d ago"></div>`;
|
|
908
|
+
return `<div class="hz-tick alert" style="left:${left}%" title="${esc(i.name)} · ${esc(i.vault)} · expired ${-i.daysLeft}d ago"></div>`;
|
|
720
909
|
}).join('');
|
|
721
910
|
el.innerHTML = `
|
|
722
911
|
<div class="hz-head">
|
|
@@ -724,54 +913,110 @@ function renderHorizon(){
|
|
|
724
913
|
<div class="hz-legend">
|
|
725
914
|
<span><i style="background:var(--alert)"></i>≤7d / expired</span>
|
|
726
915
|
<span><i style="background:var(--warn)"></i>≤30d</span>
|
|
727
|
-
<span><i style="background:var(--ok)"></i>beyond</span>
|
|
728
|
-
</div>
|
|
916
|
+
<span><i style="background:var(--ok)"></i>beyond</span></div>
|
|
729
917
|
</div>
|
|
730
918
|
<div class="hz-body">
|
|
731
919
|
<div class="hz-expired" title="${expired.length} already expired">
|
|
732
|
-
<div class="hz-expired-n">${expired.length}</div>
|
|
733
|
-
<div class="hz-expired-l">EXPIRED</div>
|
|
734
|
-
${expTicks}
|
|
735
|
-
</div>
|
|
920
|
+
<div class="hz-expired-n">${expired.length}</div><div class="hz-expired-l">EXPIRED</div>${expTicks}</div>
|
|
736
921
|
<div class="hz-axis">${grid}${ticks}</div>
|
|
737
922
|
</div>`;
|
|
738
923
|
}
|
|
739
924
|
|
|
740
|
-
// ----------
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
925
|
+
// ---------- SETTINGS (account + subscriptions) ----------
|
|
926
|
+
function viewSettings(){
|
|
927
|
+
const counts={}; STATE.vaults.forEach(v=>counts[v.subscriptionId]=(counts[v.subscriptionId]||0)+1);
|
|
928
|
+
$('#route').innerHTML = `
|
|
929
|
+
<div class="cards" style="margin-bottom:24px">
|
|
930
|
+
${card('Signed in as', '', STATE.account?STATE.account.user.name:'—','')}
|
|
931
|
+
${card('Tenant', (STATE.account&&STATE.account.tenantId||'').slice(0,8)+'…','active directory','')}
|
|
932
|
+
${card('Subscriptions', STATE.subs.length, STATE.subs.filter(s=>s.state==='Enabled').length+' enabled','azure')}
|
|
933
|
+
</div>
|
|
934
|
+
<div class="panel"><div class="scroll"><table>
|
|
935
|
+
<thead><tr><th>Name</th><th>Env</th><th>State</th><th>Active</th><th class="right">Vaults</th><th>Subscription ID</th><th></th></tr></thead>
|
|
936
|
+
<tbody>${STATE.subs.map(s=>`<tr>
|
|
937
|
+
<td><b>${esc(s.name)}</b></td>
|
|
938
|
+
<td><span class="tag ${envClass(SUBMETA[s.id].env)}">${esc(SUBMETA[s.id].env)}</span></td>
|
|
939
|
+
<td>${esc(s.state)}</td>
|
|
940
|
+
<td>${s.isDefault?'<span class="pill rbac">active</span>':''}</td>
|
|
941
|
+
<td class="right mono">${counts[s.id]||0}</td>
|
|
942
|
+
<td class="mono muted">${esc(s.id)}</td>
|
|
943
|
+
<td class="right">${s.isDefault?'':`<button class="btn sm ghost" onclick="setSub('${esc(s.id)}')">Switch</button>`}</td>
|
|
944
|
+
</tr>`).join('')}</tbody>
|
|
945
|
+
</table></div></div>`;
|
|
946
|
+
}
|
|
947
|
+
async function setSub(id){
|
|
948
|
+
toast('Switching subscription…');
|
|
949
|
+
const r = await api('/api/subscription/set',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({subscription:id})});
|
|
950
|
+
if(r.ok){ STATE.account=r.data; $('#acctSub').textContent=r.data.name; await loadSubs(); refreshCurrent(); toast('Active subscription set'); } else toast(r.error||'failed');
|
|
755
951
|
}
|
|
756
952
|
|
|
953
|
+
function card(k,v,sub,mod){
|
|
954
|
+
return `<div class="card ${mod?'card--'+mod:''}"><div class="k">${esc(k)}</div><div class="v">${v} <small>${esc(sub)}</small></div></div>`;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// ---------- command palette ----------
|
|
958
|
+
let CMDK = { open:false, items:[], cur:0 };
|
|
959
|
+
function cmdkActions(){
|
|
960
|
+
return [
|
|
961
|
+
{type:'view', label:'Dashboard', hint:'go', act:()=>navigate('dashboard')},
|
|
962
|
+
{type:'view', label:'Key Vaults', hint:'go', act:()=>navigate('vaults')},
|
|
963
|
+
{type:'view', label:'Expiry', hint:'go', act:()=>navigate('expiry')},
|
|
964
|
+
{type:'view', label:'Account & subscriptions', hint:'go', act:()=>navigate('settings')},
|
|
965
|
+
{type:'action', label:'Refresh inventory', hint:'run', act:()=>{ doRefresh(); }},
|
|
966
|
+
];
|
|
967
|
+
}
|
|
968
|
+
function openCmdk(){ CMDK.open=true; $('#cmdkOv').classList.add('show'); const i=$('#cmdkIn'); i.value=''; renderCmdk(''); i.focus(); }
|
|
969
|
+
function closeCmdk(){ CMDK.open=false; $('#cmdkOv').classList.remove('show'); }
|
|
970
|
+
function renderCmdk(q){
|
|
971
|
+
q=q.toLowerCase().trim();
|
|
972
|
+
const acts = cmdkActions().filter(a=>!q||a.label.toLowerCase().includes(q));
|
|
973
|
+
const vaults = STATE.vaults
|
|
974
|
+
.filter(v=>!q || v.name.toLowerCase().includes(q) || (v.service||'').toLowerCase().includes(q) || Object.values(v.tags||{}).join(' ').toLowerCase().includes(q))
|
|
975
|
+
.slice(0,40)
|
|
976
|
+
.map(v=>({type:'vault', label:v.name, sub:v.env, env:v.env, act:()=>openVault(v.name)}));
|
|
977
|
+
CMDK.items = [...acts, ...vaults]; CMDK.cur=0;
|
|
978
|
+
const list=$('#cmdkList');
|
|
979
|
+
if(!CMDK.items.length){ list.innerHTML='<div class="cmdk-empty">No matches.</div>'; return; }
|
|
980
|
+
const vIco='<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>';
|
|
981
|
+
const gIco='<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M5 6h14M5 18h14"/></svg>';
|
|
982
|
+
let html=''; let lastType='';
|
|
983
|
+
CMDK.items.forEach((it,idx)=>{
|
|
984
|
+
if(it.type!==lastType){ html+=`<div class="cmdk-sec">${it.type==='vault'?'Vaults':it.type==='view'?'Go to':'Actions'}</div>`; lastType=it.type; }
|
|
985
|
+
html+=`<div class="cmdk-it ${idx===CMDK.cur?'cur':''}" data-i="${idx}">
|
|
986
|
+
${it.type==='vault'?vIco:gIco}
|
|
987
|
+
<span class="lbl">${esc(it.label)}${it.sub?`<small>${esc(it.sub)}</small>`:''}</span>
|
|
988
|
+
<span class="hint">${it.hint?esc(it.hint):'open'}</span></div>`;
|
|
989
|
+
});
|
|
990
|
+
list.innerHTML=html;
|
|
991
|
+
list.querySelectorAll('.cmdk-it').forEach(el=>{
|
|
992
|
+
el.onmousemove=()=>{ CMDK.cur=+el.dataset.i; markCur(); };
|
|
993
|
+
el.onclick=()=>runCmdk(+el.dataset.i);
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
function markCur(){ $('#cmdkList').querySelectorAll('.cmdk-it').forEach(el=>el.classList.toggle('cur',+el.dataset.i===CMDK.cur)); }
|
|
997
|
+
function moveCur(d){ if(!CMDK.items.length)return; CMDK.cur=(CMDK.cur+d+CMDK.items.length)%CMDK.items.length; markCur();
|
|
998
|
+
const el=$('#cmdkList').querySelector('.cmdk-it.cur'); if(el) el.scrollIntoView({block:'nearest'}); }
|
|
999
|
+
function runCmdk(i){ const it=CMDK.items[i]; if(!it)return; closeCmdk(); it.act(); }
|
|
1000
|
+
|
|
1001
|
+
function doRefresh(){ toast('Refreshing…'); loadSubs().then(()=>loadVaults(true)).then(()=>{ EXP.scanned=false; EXP.polling=false; refreshCurrent(); kickExpiryScan(); }); }
|
|
1002
|
+
|
|
757
1003
|
// ---------- events ----------
|
|
758
|
-
document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>
|
|
759
|
-
$('#refresh').onclick=
|
|
760
|
-
$('#
|
|
761
|
-
$('#
|
|
762
|
-
$('#
|
|
763
|
-
$('#
|
|
764
|
-
$('#
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
1004
|
+
document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>navigate(b.dataset.view));
|
|
1005
|
+
$('#refresh').onclick=doRefresh;
|
|
1006
|
+
$('#acctBtn').onclick=()=>navigate('settings');
|
|
1007
|
+
$('#cmdkBtn').onclick=openCmdk;
|
|
1008
|
+
$('#search').oninput=e=>{ STATE.q=e.target.value; if(STATE.route!=='vaults'){ navigate('vaults'); } else { renderVaultRows(); } };
|
|
1009
|
+
$('#cmdkIn').oninput=e=>renderCmdk(e.target.value);
|
|
1010
|
+
$('#cmdkOv').onclick=e=>{ if(e.target===$('#cmdkOv')) closeCmdk(); };
|
|
1011
|
+
document.addEventListener('keydown',e=>{
|
|
1012
|
+
if((e.metaKey||e.ctrlKey) && e.key.toLowerCase()==='k'){ e.preventDefault(); CMDK.open?closeCmdk():openCmdk(); return; }
|
|
1013
|
+
if(!CMDK.open){ if(e.key==='Escape' && STATE.route==='vault'){ navigate('vaults'); } return; }
|
|
1014
|
+
if(e.key==='Escape'){ closeCmdk(); }
|
|
1015
|
+
else if(e.key==='ArrowDown'){ e.preventDefault(); moveCur(1); }
|
|
1016
|
+
else if(e.key==='ArrowUp'){ e.preventDefault(); moveCur(-1); }
|
|
1017
|
+
else if(e.key==='Enter'){ e.preventDefault(); runCmdk(CMDK.cur); }
|
|
1018
|
+
});
|
|
772
1019
|
|
|
773
|
-
const initView = new URLSearchParams(location.search).get('view');
|
|
774
|
-
if(initView && META[initView]) show(initView);
|
|
775
1020
|
boot();
|
|
776
1021
|
</script>
|
|
777
1022
|
</body>
|