akv-manager 0.0.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/LICENSE +21 -0
- package/README.md +83 -0
- package/package.json +39 -0
- package/public/index.html +1001 -0
- package/server.js +552 -0
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Keyvault Console — Accounts · Subscriptions · Expiry</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
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">
|
|
10
|
+
<style>
|
|
11
|
+
:root{
|
|
12
|
+
--ink:#0A0D14; --ink2:#0C1019;
|
|
13
|
+
--panel:#121826; --panel2:#0E141F; --raise:#171F2E;
|
|
14
|
+
--line:#222C3E; --line2:#1A2233;
|
|
15
|
+
--text:#E6EBF2; --muted:#79859B; --faint:#4C586E;
|
|
16
|
+
--azure:#5B9DFF; --azure2:#3F86F0; --azuredim:rgba(91,157,255,.12);
|
|
17
|
+
--ok:#46D6B4; --warn:#F2B53D; --alert:#FF5C6C; --bad:#FF5C6C; --good:#46D6B4;
|
|
18
|
+
--violet:#A98BFF;
|
|
19
|
+
--r:12px; --r-sm:8px;
|
|
20
|
+
--shadow:0 18px 40px -22px rgba(0,0,0,.8);
|
|
21
|
+
--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
22
|
+
--sans:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,sans-serif;
|
|
23
|
+
}
|
|
24
|
+
*{box-sizing:border-box}
|
|
25
|
+
html,body{margin:0}
|
|
26
|
+
body{font-family:var(--sans);font-size:14px;line-height:1.5;color:var(--text);min-height:100vh;
|
|
27
|
+
background:
|
|
28
|
+
radial-gradient(900px 500px at 88% -8%,rgba(91,157,255,.07),transparent 60%),
|
|
29
|
+
linear-gradient(180deg,var(--ink2),var(--ink));
|
|
30
|
+
-webkit-font-smoothing:antialiased}
|
|
31
|
+
::selection{background:rgba(91,157,255,.32)}
|
|
32
|
+
a{color:var(--azure);text-decoration:none}
|
|
33
|
+
:focus-visible{outline:2px solid var(--azure);outline-offset:2px;border-radius:4px}
|
|
34
|
+
.mono{font-family:var(--mono);font-variant-numeric:tabular-nums}
|
|
35
|
+
.muted{color:var(--muted)} .faint{color:var(--faint)} .right{text-align:right}
|
|
36
|
+
.hide{display:none!important}
|
|
37
|
+
.eyebrow{font-family:var(--mono);font-size:10.5px;font-weight:500;letter-spacing:.18em;
|
|
38
|
+
text-transform:uppercase;color:var(--faint)}
|
|
39
|
+
|
|
40
|
+
.app{display:grid;grid-template-columns:236px 1fr;min-height:100vh}
|
|
41
|
+
|
|
42
|
+
/* ---------- sidebar ---------- */
|
|
43
|
+
aside{background:linear-gradient(180deg,rgba(255,255,255,.015),transparent),var(--panel2);
|
|
44
|
+
border-right:1px solid var(--line);padding:22px 14px;display:flex;flex-direction:column;
|
|
45
|
+
position:sticky;top:0;height:100vh}
|
|
46
|
+
.brand{display:flex;align-items:center;gap:11px;padding:0 6px 20px}
|
|
47
|
+
.brand .mark{width:34px;height:34px;flex:none}
|
|
48
|
+
.brand .wm{font-family:var(--mono);font-weight:600;font-size:13px;letter-spacing:.14em;color:var(--text);line-height:1.15}
|
|
49
|
+
.brand .wm small{display:block;font-size:9.5px;letter-spacing:.22em;color:var(--faint);font-weight:400;margin-top:2px}
|
|
50
|
+
.navlabel{font-family:var(--mono);font-size:9.5px;letter-spacing:.2em;color:var(--faint);
|
|
51
|
+
text-transform:uppercase;padding:14px 10px 7px}
|
|
52
|
+
nav button{display:flex;align-items:center;gap:11px;width:100%;text-align:left;background:transparent;
|
|
53
|
+
border:0;color:var(--muted);padding:9px 11px;border-radius:var(--r-sm);cursor:pointer;
|
|
54
|
+
font-family:var(--sans);font-size:13.5px;font-weight:500;position:relative;transition:.15s}
|
|
55
|
+
nav button svg{width:17px;height:17px;flex:none;opacity:.8}
|
|
56
|
+
nav button:hover{background:rgba(255,255,255,.03);color:var(--text)}
|
|
57
|
+
nav button.active{background:var(--azuredim);color:#fff}
|
|
58
|
+
nav button.active svg{opacity:1;color:var(--azure)}
|
|
59
|
+
nav button.active::before{content:"";position:absolute;left:-14px;top:7px;bottom:7px;width:3px;
|
|
60
|
+
background:var(--azure);border-radius:0 3px 3px 0}
|
|
61
|
+
.who{margin-top:auto;font-size:12px;color:var(--muted);border-top:1px solid var(--line2);
|
|
62
|
+
padding:14px 8px 4px;line-height:1.6}
|
|
63
|
+
.who b{color:var(--text);font-weight:600}
|
|
64
|
+
.who .mono{font-size:11px}
|
|
65
|
+
.dot{width:7px;height:7px;border-radius:50%;display:inline-block;vertical-align:middle}
|
|
66
|
+
|
|
67
|
+
/* ---------- main ---------- */
|
|
68
|
+
main{padding:26px 32px 60px;max-width:1560px}
|
|
69
|
+
.topbar{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin-bottom:26px}
|
|
70
|
+
h1{font-size:23px;font-weight:600;margin:4px 0 0;letter-spacing:-.01em}
|
|
71
|
+
.sub{color:var(--muted);font-size:13px;margin-top:3px;max-width:62ch}
|
|
72
|
+
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
|
73
|
+
|
|
74
|
+
.btn{font-family:var(--sans);font-weight:500;font-size:13px;border:0;border-radius:var(--r-sm);
|
|
75
|
+
padding:9px 15px;cursor:pointer;color:#06101f;background:var(--azure);transition:.15s}
|
|
76
|
+
.btn:hover{background:#75AEFF}
|
|
77
|
+
.btn:disabled{opacity:.55;cursor:default}
|
|
78
|
+
.btn.ghost{background:var(--raise);color:var(--text);border:1px solid var(--line)}
|
|
79
|
+
.btn.ghost:hover{border-color:var(--azure);background:var(--raise)}
|
|
80
|
+
.btn.sm{padding:5px 11px;font-size:12px}
|
|
81
|
+
input.search{font-family:var(--mono);background:var(--panel2);border:1px solid var(--line);color:var(--text);
|
|
82
|
+
padding:9px 13px;border-radius:var(--r-sm);min-width:320px;font-size:12.5px}
|
|
83
|
+
input.search::placeholder{color:var(--faint)}
|
|
84
|
+
input.search:focus{outline:none;border-color:var(--azure);background:var(--panel)}
|
|
85
|
+
|
|
86
|
+
/* ---------- KPI cards ---------- */
|
|
87
|
+
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:13px;margin-bottom:24px}
|
|
88
|
+
.card{background:linear-gradient(180deg,var(--panel),var(--panel2));border:1px solid var(--line);
|
|
89
|
+
border-radius:var(--r);padding:15px 17px;position:relative;overflow:hidden}
|
|
90
|
+
.card::after{content:"";position:absolute;inset:0 0 auto;height:2px;background:var(--line)}
|
|
91
|
+
.card .k{font-family:var(--mono);color:var(--muted);font-size:10.5px;text-transform:uppercase;letter-spacing:.13em}
|
|
92
|
+
.card .v{font-family:var(--mono);font-size:30px;font-weight:600;margin-top:9px;line-height:1;letter-spacing:-.02em}
|
|
93
|
+
.card .v small{font-family:var(--sans);font-size:12px;font-weight:400;color:var(--muted);letter-spacing:0}
|
|
94
|
+
.card--alert::after{background:var(--alert)} .card--alert .v{color:#FF8893}
|
|
95
|
+
.card--warn::after{background:var(--warn)} .card--warn .v{color:#FBC56B}
|
|
96
|
+
.card--azure::after{background:var(--azure)} .card--azure .v{color:#8FBEFF}
|
|
97
|
+
|
|
98
|
+
/* ---------- chips ---------- */
|
|
99
|
+
.chips{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
|
|
100
|
+
.chip{font-family:var(--mono);background:var(--panel2);border:1px solid var(--line);color:var(--muted);
|
|
101
|
+
padding:6px 12px;border-radius:7px;cursor:pointer;font-size:11.5px;display:flex;gap:8px;align-items:center;transition:.15s}
|
|
102
|
+
.chip:hover{color:var(--text);border-color:var(--line)}
|
|
103
|
+
.chip.active{border-color:var(--azure);color:#fff;background:var(--azuredim)}
|
|
104
|
+
.chip .n{background:var(--ink);border-radius:5px;padding:1px 7px;font-size:10.5px;color:var(--muted)}
|
|
105
|
+
.chip.active .n{color:var(--azure)}
|
|
106
|
+
|
|
107
|
+
/* ---------- panel + table ---------- */
|
|
108
|
+
.panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);overflow:hidden;box-shadow:var(--shadow)}
|
|
109
|
+
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
110
|
+
th,td{padding:11px 16px;text-align:left;border-bottom:1px solid var(--line2);white-space:nowrap}
|
|
111
|
+
thead th{position:sticky;top:0;z-index:2;background:var(--panel2);color:var(--muted);font-weight:500;
|
|
112
|
+
font-family:var(--mono);font-size:10.5px;text-transform:uppercase;letter-spacing:.1em;
|
|
113
|
+
cursor:pointer;user-select:none}
|
|
114
|
+
thead th:hover{color:var(--text)}
|
|
115
|
+
tbody tr{transition:background .12s}
|
|
116
|
+
tbody tr:hover{background:rgba(91,157,255,.05)}
|
|
117
|
+
tbody tr:last-child td{border-bottom:0}
|
|
118
|
+
td.mono,.mono{font-family:var(--mono);font-size:12.5px}
|
|
119
|
+
td b{font-weight:600}
|
|
120
|
+
|
|
121
|
+
.tag{display:inline-flex;align-items:center;font-family:var(--mono);font-size:10px;font-weight:500;
|
|
122
|
+
letter-spacing:.08em;text-transform:uppercase;padding:2px 7px;border-radius:5px;border:1px solid transparent}
|
|
123
|
+
.tag.e-dev{color:#7FE0C8;background:rgba(70,214,180,.10);border-color:rgba(70,214,180,.22)}
|
|
124
|
+
.tag.e-test{color:#7FD0E0;background:rgba(70,180,214,.10);border-color:rgba(70,180,214,.22)}
|
|
125
|
+
.tag.e-qa{color:#93C0FF;background:rgba(91,157,255,.12);border-color:rgba(91,157,255,.24)}
|
|
126
|
+
.tag.e-stage{color:#F4C977;background:rgba(242,181,61,.10);border-color:rgba(242,181,61,.22)}
|
|
127
|
+
.tag.e-prod{color:#FF9BA4;background:rgba(255,92,108,.10);border-color:rgba(255,92,108,.26)}
|
|
128
|
+
.tag.e-sandbox{color:#BCA8FF;background:rgba(169,139,255,.10);border-color:rgba(169,139,255,.22)}
|
|
129
|
+
.tag.e-other{color:var(--muted);background:rgba(255,255,255,.04);border-color:var(--line)}
|
|
130
|
+
|
|
131
|
+
.pill{font-family:var(--mono);font-size:10px;letter-spacing:.06em;color:var(--muted);
|
|
132
|
+
border:1px solid var(--line);border-radius:5px;padding:1px 7px;white-space:nowrap}
|
|
133
|
+
.pill.rbac{color:#7FE0C8;border-color:rgba(70,214,180,.3)}
|
|
134
|
+
.pill.policy{color:#F4C977;border-color:rgba(242,181,61,.3)}
|
|
135
|
+
.sev{font-family:var(--mono);font-size:10px;letter-spacing:.1em;padding:2px 8px;border-radius:5px;border:1px solid}
|
|
136
|
+
.sev.expired{color:#FF8893;background:rgba(255,92,108,.12);border-color:rgba(255,92,108,.32)}
|
|
137
|
+
.sev.urgent{color:#FBC56B;background:rgba(242,181,61,.12);border-color:rgba(242,181,61,.32)}
|
|
138
|
+
.sev.ok{color:var(--muted);border-color:var(--line);background:transparent}
|
|
139
|
+
|
|
140
|
+
.iconbtn{font-family:var(--mono);background:var(--raise);border:1px solid var(--line);color:var(--muted);
|
|
141
|
+
border-radius:6px;padding:4px 9px;cursor:pointer;font-size:11.5px;transition:.15s}
|
|
142
|
+
.iconbtn:hover{color:#fff;border-color:var(--azure)}
|
|
143
|
+
.empty{padding:48px 24px;text-align:center;color:var(--muted)}
|
|
144
|
+
.scroll{max-height:calc(100vh - 380px);overflow:auto}
|
|
145
|
+
.scroll::-webkit-scrollbar{width:10px;height:10px}
|
|
146
|
+
.scroll::-webkit-scrollbar-thumb{background:var(--line);border-radius:8px;border:2px solid var(--panel)}
|
|
147
|
+
.tablenote{font-family:var(--mono);font-size:11px;color:var(--faint);margin-top:10px}
|
|
148
|
+
|
|
149
|
+
/* ---------- signature: expiry horizon ---------- */
|
|
150
|
+
.horizon{padding:18px 20px;margin-bottom:22px}
|
|
151
|
+
.hz-head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:14px}
|
|
152
|
+
.hz-body{display:flex;gap:12px;height:104px}
|
|
153
|
+
.hz-expired{position:relative;width:118px;flex:none;border:1px solid rgba(255,92,108,.28);
|
|
154
|
+
background:linear-gradient(180deg,rgba(255,92,108,.12),rgba(255,92,108,.02));
|
|
155
|
+
border-radius:10px;display:flex;flex-direction:column;align-items:center;justify-content:center;overflow:hidden}
|
|
156
|
+
.hz-expired-n{font-family:var(--mono);font-size:30px;font-weight:600;color:#FF8893;line-height:1}
|
|
157
|
+
.hz-expired-l{font-family:var(--mono);font-size:9.5px;letter-spacing:.18em;color:#FF8893;margin-top:3px}
|
|
158
|
+
.hz-axis{position:relative;flex:1;border:1px solid var(--line);border-radius:10px;
|
|
159
|
+
background:linear-gradient(180deg,rgba(255,255,255,.015),transparent);overflow:hidden}
|
|
160
|
+
.hz-grid{position:absolute;top:0;bottom:0;border-left:1px dashed var(--line);}
|
|
161
|
+
.hz-grid:first-of-type{border-left-color:rgba(91,157,255,.35)}
|
|
162
|
+
.hz-grid span{position:absolute;bottom:7px;left:6px;font-family:var(--mono);font-size:9.5px;color:var(--faint);white-space:nowrap}
|
|
163
|
+
.hz-tick{position:absolute;top:12px;bottom:26px;width:2px;border-radius:2px;background:var(--ok);
|
|
164
|
+
transform:translateX(-1px);opacity:.9}
|
|
165
|
+
.hz-tick.warn{background:var(--warn)} .hz-tick.alert{background:var(--alert)}
|
|
166
|
+
.hz-tick:hover{opacity:1;width:3px;box-shadow:0 0 8px currentColor}
|
|
167
|
+
.hz-expired .hz-tick{top:auto;bottom:9px;height:16px;opacity:.7}
|
|
168
|
+
.hz-legend{display:flex;gap:16px;align-items:center}
|
|
169
|
+
.hz-legend span{font-family:var(--mono);font-size:10px;color:var(--muted);display:flex;align-items:center;gap:5px}
|
|
170
|
+
.hz-legend i{width:8px;height:8px;border-radius:2px;display:inline-block}
|
|
171
|
+
|
|
172
|
+
/* ---------- segmented + window ---------- */
|
|
173
|
+
.seg{display:inline-flex;gap:4px;background:var(--panel2);border:1px solid var(--line);
|
|
174
|
+
border-radius:var(--r-sm);padding:3px}
|
|
175
|
+
.seg button{font-family:var(--mono);background:transparent;border:0;color:var(--muted);padding:6px 12px;
|
|
176
|
+
border-radius:6px;cursor:pointer;font-size:12px;transition:.15s}
|
|
177
|
+
.seg button:hover{color:var(--text)}
|
|
178
|
+
.seg button.active{background:var(--raise);color:#fff;box-shadow:0 1px 0 rgba(0,0,0,.4)}
|
|
179
|
+
|
|
180
|
+
/* ---------- drawer ---------- */
|
|
181
|
+
.overlay{position:fixed;inset:0;background:rgba(5,8,14,.6);backdrop-filter:blur(2px);opacity:0;
|
|
182
|
+
pointer-events:none;transition:.25s;z-index:30}
|
|
183
|
+
.overlay.show{opacity:1;pointer-events:auto}
|
|
184
|
+
.drawer{position:fixed;top:0;right:0;height:100vh;width:100vw;max-width:100vw;background:var(--panel);
|
|
185
|
+
border-left:1px solid var(--line);box-shadow:-30px 0 80px -30px rgba(0,0,0,.9);
|
|
186
|
+
transform:translateX(100%);transition:transform .28s cubic-bezier(.2,.7,.2,1);z-index:40;
|
|
187
|
+
display:flex;flex-direction:column}
|
|
188
|
+
.drawer.open{transform:translateX(0)}
|
|
189
|
+
.drawer header{padding:20px 22px;border-bottom:1px solid var(--line);display:flex;
|
|
190
|
+
justify-content:space-between;align-items:flex-start;gap:14px}
|
|
191
|
+
.drawer header .dt{font-family:var(--mono);font-size:16px;font-weight:600;word-break:break-all}
|
|
192
|
+
.drawer .body{padding:18px 22px;overflow:auto}
|
|
193
|
+
.kvitem{border-bottom:1px solid var(--line2)}
|
|
194
|
+
.kvitem:last-child{border-bottom:0}
|
|
195
|
+
.kvitem.sel{background:rgba(91,157,255,.06);margin:0 -22px;padding:0 22px}
|
|
196
|
+
.kvline{display:flex;align-items:center;gap:10px;padding:10px 0}
|
|
197
|
+
.kvline .nm{flex:1 1 auto;min-width:60px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--mono);font-size:12.5px}
|
|
198
|
+
.kvline .meta{display:flex;align-items:center;gap:8px;flex:0 0 auto}
|
|
199
|
+
.kvline input[type=checkbox]{flex:0 0 auto}
|
|
200
|
+
.revealed{padding:0 0 12px;display:flex;align-items:flex-start;gap:8px}
|
|
201
|
+
.revealed code.val{display:block;flex:1;min-width:0;background:var(--ink);border:1px solid var(--line);
|
|
202
|
+
border-radius:6px;padding:8px 10px;max-height:120px;overflow:auto}
|
|
203
|
+
.cmd{background:var(--ink);border:1px solid var(--line);border-radius:var(--r-sm);padding:10px 12px;
|
|
204
|
+
margin:8px 0;font-family:var(--mono);font-size:11.5px;color:#A9D2FF;display:flex;justify-content:space-between;gap:10px;align-items:center}
|
|
205
|
+
.secthead{font-family:var(--mono);margin:24px 0 6px;color:var(--faint);font-size:10px;text-transform:uppercase;letter-spacing:.16em}
|
|
206
|
+
.loadval{font-family:var(--mono);font-size:11px;color:var(--azure);cursor:pointer;background:none;border:0;text-decoration:underline}
|
|
207
|
+
code.val{font-family:var(--mono);user-select:all;color:#A9D2FF;font-size:11.5px;word-break:break-all}
|
|
208
|
+
/* drawer edit + batch */
|
|
209
|
+
.dtools{display:flex;align-items:center;gap:12px;margin-top:14px}
|
|
210
|
+
.dtools .search{min-width:0;flex:1;padding:8px 11px}
|
|
211
|
+
.selall{font-family:var(--mono);font-size:11px;color:var(--muted);display:flex;align-items:center;gap:6px;cursor:pointer;white-space:nowrap}
|
|
212
|
+
input[type=checkbox]{accent-color:var(--azure);width:14px;height:14px;cursor:pointer}
|
|
213
|
+
.batchbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:12px;padding:10px 12px;
|
|
214
|
+
background:var(--azuredim);border:1px solid rgba(91,157,255,.4);border-radius:var(--r-sm)}
|
|
215
|
+
.batchbar .bn{font-family:var(--mono);font-size:12px;color:#cfe2ff;margin-right:2px}
|
|
216
|
+
.batchbar input[type=date]{font-family:var(--mono);font-size:12px;background:var(--ink);border:1px solid var(--line);
|
|
217
|
+
color:var(--text);border-radius:6px;padding:5px 8px;color-scheme:dark}
|
|
218
|
+
.editor{margin:0 -22px;padding:14px 22px;background:var(--panel2);border-top:1px solid var(--line2)}
|
|
219
|
+
/* create panel */
|
|
220
|
+
.createp{margin-top:12px;padding:16px;background:var(--panel2);border:1px solid var(--azure);border-radius:var(--r-sm)}
|
|
221
|
+
.createp h4{margin:0 0 12px;font-family:var(--mono);font-size:12px;color:#cfe2ff;font-weight:500;letter-spacing:.04em}
|
|
222
|
+
.createp .fld{display:flex;align-items:center;gap:10px;margin-bottom:10px}
|
|
223
|
+
.createp label{font-family:var(--mono);font-size:11px;color:var(--muted);width:72px;flex:none}
|
|
224
|
+
.createp input[type=text],.createp input[type=date],.createp textarea{font-family:var(--mono);font-size:12px;
|
|
225
|
+
background:var(--ink);border:1px solid var(--line);color:var(--text);border-radius:6px;padding:8px 10px;flex:1;width:100%;color-scheme:dark}
|
|
226
|
+
.createp textarea{min-height:120px;resize:vertical;line-height:1.6}
|
|
227
|
+
.createp .actions{display:flex;gap:8px;align-items:center;margin-top:6px}
|
|
228
|
+
.newbtns{display:flex;gap:6px;margin-left:auto}
|
|
229
|
+
.editor .fld{display:flex;align-items:center;gap:10px;margin-bottom:10px}
|
|
230
|
+
.editor label{font-family:var(--mono);font-size:11px;color:var(--muted);width:78px;flex:none}
|
|
231
|
+
.editor input[type=text],.editor input[type=date]{font-family:var(--mono);font-size:12px;background:var(--ink);
|
|
232
|
+
border:1px solid var(--line);color:var(--text);border-radius:6px;padding:7px 9px;flex:1;color-scheme:dark}
|
|
233
|
+
.editor input[type=date],.createp input[type=date]{flex:0 0 auto;width:200px}
|
|
234
|
+
.editor .hint{font-family:var(--mono);font-size:10.5px;color:var(--faint)}
|
|
235
|
+
.toggle{display:flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px;color:var(--text);cursor:pointer}
|
|
236
|
+
|
|
237
|
+
/* ---------- misc ---------- */
|
|
238
|
+
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(20px);
|
|
239
|
+
background:var(--raise);border:1px solid var(--azure);color:#fff;padding:10px 18px;
|
|
240
|
+
border-radius:var(--r-sm);opacity:0;transition:.25s;z-index:60;font-size:13px}
|
|
241
|
+
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
|
242
|
+
.banner{background:linear-gradient(180deg,rgba(255,92,108,.14),rgba(255,92,108,.04));
|
|
243
|
+
border:1px solid rgba(255,92,108,.4);color:#FFC4C9;padding:13px 16px;border-radius:var(--r);
|
|
244
|
+
margin-bottom:18px;display:none;font-size:13px}
|
|
245
|
+
.banner.show{display:block}
|
|
246
|
+
.banner code{font-family:var(--mono);background:rgba(0,0,0,.3);padding:1px 6px;border-radius:4px;color:#fff}
|
|
247
|
+
.spin{width:18px;height:18px;border:2px solid var(--line);border-top-color:var(--azure);border-radius:50%;
|
|
248
|
+
display:inline-block;animation:spin .7s linear infinite;vertical-align:-4px}
|
|
249
|
+
@keyframes spin{to{transform:rotate(360deg)}}
|
|
250
|
+
.progress{width:64%;max-width:380px;height:6px;background:var(--ink);border:1px solid var(--line);
|
|
251
|
+
border-radius:999px;margin:16px auto 0;overflow:hidden}
|
|
252
|
+
.progress .bar{height:100%;background:linear-gradient(90deg,var(--azure),var(--ok));border-radius:999px;transition:width .4s}
|
|
253
|
+
|
|
254
|
+
@media (max-width:900px){
|
|
255
|
+
.app{grid-template-columns:1fr}
|
|
256
|
+
aside{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center}
|
|
257
|
+
aside nav{display:flex;gap:4px;flex-wrap:wrap} .navlabel{display:none} .who{display:none}
|
|
258
|
+
main{padding:20px}
|
|
259
|
+
input.search{min-width:180px}
|
|
260
|
+
}
|
|
261
|
+
@media (prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;transition:none!important}}
|
|
262
|
+
</style>
|
|
263
|
+
</head>
|
|
264
|
+
<body>
|
|
265
|
+
<div class="app">
|
|
266
|
+
<aside>
|
|
267
|
+
<div class="brand">
|
|
268
|
+
<svg class="mark" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
269
|
+
<rect x="1.5" y="1.5" width="31" height="31" rx="8" fill="#0E141F" stroke="#2A3954"/>
|
|
270
|
+
<rect x="1.5" y="1.5" width="31" height="31" rx="8" stroke="url(#g)" stroke-opacity=".6"/>
|
|
271
|
+
<circle cx="17" cy="15" r="4.4" stroke="#5B9DFF" stroke-width="1.8"/>
|
|
272
|
+
<path d="M17 19.2V24" stroke="#5B9DFF" stroke-width="1.8" stroke-linecap="round"/>
|
|
273
|
+
<path d="M14.4 24h5.2" stroke="#46D6B4" stroke-width="1.8" stroke-linecap="round"/>
|
|
274
|
+
<defs><linearGradient id="g" x1="0" y1="0" x2="34" y2="34">
|
|
275
|
+
<stop stop-color="#5B9DFF"/><stop offset="1" stop-color="#46D6B4"/></linearGradient></defs>
|
|
276
|
+
</svg>
|
|
277
|
+
<div class="wm">KEYVAULT<small>OPS CONSOLE</small></div>
|
|
278
|
+
</div>
|
|
279
|
+
<div class="navlabel">Console</div>
|
|
280
|
+
<nav>
|
|
281
|
+
<button data-view="overview" class="active">
|
|
282
|
+
<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>
|
|
283
|
+
Overview</button>
|
|
284
|
+
<button data-view="subscriptions">
|
|
285
|
+
<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>
|
|
286
|
+
Subscriptions</button>
|
|
287
|
+
<button data-view="vaults">
|
|
288
|
+
<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>
|
|
289
|
+
Key Vaults</button>
|
|
290
|
+
<button data-view="expiring">
|
|
291
|
+
<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>
|
|
292
|
+
Expiry</button>
|
|
293
|
+
</nav>
|
|
294
|
+
<div class="who" id="who"><span class="spin"></span> connecting…</div>
|
|
295
|
+
</aside>
|
|
296
|
+
|
|
297
|
+
<main>
|
|
298
|
+
<div class="banner" id="banner"></div>
|
|
299
|
+
|
|
300
|
+
<div class="topbar">
|
|
301
|
+
<div>
|
|
302
|
+
<div class="eyebrow" id="eyebrow">Overview</div>
|
|
303
|
+
<h1 id="title">Overview</h1>
|
|
304
|
+
<div class="sub" id="subtitle">Live view of your Azure access via the local az CLI</div>
|
|
305
|
+
</div>
|
|
306
|
+
<div class="row">
|
|
307
|
+
<input class="search hide" id="search" placeholder="search vault · resource group · service…" />
|
|
308
|
+
<button class="btn ghost" id="refresh">↻ Refresh</button>
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
<!-- OVERVIEW -->
|
|
313
|
+
<section id="view-overview">
|
|
314
|
+
<div class="cards" id="ovCards"></div>
|
|
315
|
+
<div class="panel">
|
|
316
|
+
<div class="scroll">
|
|
317
|
+
<table>
|
|
318
|
+
<thead><tr><th>Subscription</th><th>Env</th><th>State</th><th class="right">Key Vaults</th><th>Subscription ID</th></tr></thead>
|
|
319
|
+
<tbody id="ovSubs"></tbody>
|
|
320
|
+
</table>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
</section>
|
|
324
|
+
|
|
325
|
+
<!-- SUBSCRIPTIONS -->
|
|
326
|
+
<section id="view-subscriptions" class="hide">
|
|
327
|
+
<div class="panel">
|
|
328
|
+
<div class="scroll">
|
|
329
|
+
<table>
|
|
330
|
+
<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>
|
|
331
|
+
<tbody id="subsBody"></tbody>
|
|
332
|
+
</table>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
</section>
|
|
336
|
+
|
|
337
|
+
<!-- VAULTS -->
|
|
338
|
+
<section id="view-vaults" class="hide">
|
|
339
|
+
<div class="chips" id="envChips"></div>
|
|
340
|
+
<div class="panel">
|
|
341
|
+
<div class="scroll">
|
|
342
|
+
<table>
|
|
343
|
+
<thead><tr>
|
|
344
|
+
<th data-sort="name">Vault ▾</th><th data-sort="env">Env</th>
|
|
345
|
+
<th data-sort="service">Service</th><th data-sort="resourceGroup">Resource Group</th>
|
|
346
|
+
<th data-sort="sku">SKU</th><th>Auth</th><th class="right">Actions</th>
|
|
347
|
+
</tr></thead>
|
|
348
|
+
<tbody id="vaultsBody"></tbody>
|
|
349
|
+
</table>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
<div class="tablenote" id="vaultCount"></div>
|
|
353
|
+
</section>
|
|
354
|
+
|
|
355
|
+
<!-- EXPIRING -->
|
|
356
|
+
<section id="view-expiring" class="hide">
|
|
357
|
+
<div class="row" style="margin-bottom:18px;justify-content:space-between">
|
|
358
|
+
<div class="row">
|
|
359
|
+
<span class="eyebrow">Window</span>
|
|
360
|
+
<div class="seg" id="expWindow">
|
|
361
|
+
<button data-d="7">7d</button>
|
|
362
|
+
<button data-d="30" class="active">30d</button>
|
|
363
|
+
<button data-d="60">60d</button>
|
|
364
|
+
<button data-d="90">90d</button>
|
|
365
|
+
<button data-d="365">1y</button>
|
|
366
|
+
</div>
|
|
367
|
+
<button class="btn" id="expScan">⟳ Scan vaults</button>
|
|
368
|
+
</div>
|
|
369
|
+
<span class="tablenote" id="expMeta"></span>
|
|
370
|
+
</div>
|
|
371
|
+
|
|
372
|
+
<div class="panel horizon hide" id="expHorizon"></div>
|
|
373
|
+
<div class="cards" id="expCards"></div>
|
|
374
|
+
|
|
375
|
+
<div class="panel">
|
|
376
|
+
<div class="scroll">
|
|
377
|
+
<table>
|
|
378
|
+
<thead><tr>
|
|
379
|
+
<th data-esort="daysLeft">Expires in ▾</th><th data-esort="expires">Expiry date</th>
|
|
380
|
+
<th data-esort="type">Type</th><th data-esort="name">Item</th>
|
|
381
|
+
<th data-esort="vault">Vault</th><th data-esort="env">Env</th>
|
|
382
|
+
<th>Status</th><th class="right">Actions</th>
|
|
383
|
+
</tr></thead>
|
|
384
|
+
<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>
|
|
385
|
+
</table>
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
<div class="tablenote" id="expTableNote"></div>
|
|
389
|
+
</section>
|
|
390
|
+
</main>
|
|
391
|
+
</div>
|
|
392
|
+
|
|
393
|
+
<!-- Drawer -->
|
|
394
|
+
<div class="overlay" id="overlay"></div>
|
|
395
|
+
<div class="drawer" id="drawer">
|
|
396
|
+
<header>
|
|
397
|
+
<div>
|
|
398
|
+
<div class="dt" id="dTitle">Vault</div>
|
|
399
|
+
<div class="mono muted" id="dSub" style="font-size:11.5px;margin-top:5px;word-break:break-all"></div>
|
|
400
|
+
</div>
|
|
401
|
+
<button class="iconbtn" id="dClose">✕ Close</button>
|
|
402
|
+
</header>
|
|
403
|
+
<div class="body">
|
|
404
|
+
<div class="seg" id="dSeg" style="width:100%;display:flex">
|
|
405
|
+
<button data-kind="secrets" class="active" style="flex:1">Secrets</button>
|
|
406
|
+
<button data-kind="keys" style="flex:1">Keys</button>
|
|
407
|
+
<button data-kind="certificates" style="flex:1">Certificates</button>
|
|
408
|
+
</div>
|
|
409
|
+
<div class="dtools" id="dTools">
|
|
410
|
+
<input class="search" id="dFilter" placeholder="filter by name…" autocomplete="off" />
|
|
411
|
+
<label class="selall"><input type="checkbox" id="dSelAll" /> select all</label>
|
|
412
|
+
<span class="faint mono" id="dCount"></span>
|
|
413
|
+
<span class="newbtns" id="dNewBtns">
|
|
414
|
+
<button class="btn sm" id="dNew">+ New</button>
|
|
415
|
+
<button class="btn sm ghost" id="dNewBatch">+ Batch</button>
|
|
416
|
+
</span>
|
|
417
|
+
</div>
|
|
418
|
+
<div class="createp hide" id="dCreate"></div>
|
|
419
|
+
<div class="batchbar hide" id="dBatch"></div>
|
|
420
|
+
<div id="dContent" style="margin-top:12px"><div class="empty"><span class="spin"></span></div></div>
|
|
421
|
+
<div class="secthead">CLI commands</div>
|
|
422
|
+
<div id="dCmds"></div>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
|
|
426
|
+
<div class="toast" id="toast"></div>
|
|
427
|
+
|
|
428
|
+
<script>
|
|
429
|
+
const $ = s => document.querySelector(s);
|
|
430
|
+
const api = async (p, opts) => { const r = await fetch(p, opts); return r.json(); };
|
|
431
|
+
let STATE = { vaults: [], subs: [], account: null, env: 'all', q: '', sort: 'name', dir: 1, drawerVault: null, kind: 'secrets' };
|
|
432
|
+
let CFG = { envRules: null, title: null }; // optional operator config from /api/config
|
|
433
|
+
const SUBMETA = {};
|
|
434
|
+
|
|
435
|
+
function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1600); }
|
|
436
|
+
function copy(text){ navigator.clipboard.writeText(text).then(()=>toast('Copied to clipboard')); }
|
|
437
|
+
function banner(msg){ const b=$('#banner'); if(!msg){b.classList.remove('show');return;} b.innerHTML=msg; b.classList.add('show'); }
|
|
438
|
+
function envOf(id){ return (SUBMETA[id]&&SUBMETA[id].env)||'?'; }
|
|
439
|
+
function subName(id){ return (SUBMETA[id]&&SUBMETA[id].name)||id; }
|
|
440
|
+
|
|
441
|
+
// ---------- bootstrap ----------
|
|
442
|
+
async function boot(){
|
|
443
|
+
const cfg = await api('/api/config'); if(cfg.ok){ CFG = {...CFG, ...cfg.data};
|
|
444
|
+
if(CFG.title){ document.title = CFG.title; const wm=document.querySelector('.brand .wm'); if(wm) wm.firstChild.textContent = CFG.title; } }
|
|
445
|
+
const acc = await api('/api/account');
|
|
446
|
+
if(!acc.ok){
|
|
447
|
+
$('#who').innerHTML = '<span class="dot" style="background:var(--alert)"></span> not signed in';
|
|
448
|
+
banner('Azure CLI is not signed in. Run <code>az login</code> in your terminal, then press Refresh.');
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
STATE.account = acc.data;
|
|
452
|
+
$('#who').innerHTML = `<span class="dot" style="background:var(--ok)"></span> <b>${acc.data.user.name}</b><br><span class="mono">${acc.data.name}</span><br><span class="mono faint">tenant ${(acc.data.tenantId||'').slice(0,8)}…</span>`;
|
|
453
|
+
banner('');
|
|
454
|
+
await loadSubs(); // populate the subscription→env map first
|
|
455
|
+
await loadVaults(); // …so vault/expiry rows resolve their environment
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function loadSubs(){
|
|
459
|
+
const r = await api('/api/subscriptions');
|
|
460
|
+
if(!r.ok) return;
|
|
461
|
+
STATE.subs = r.data;
|
|
462
|
+
r.data.forEach(s => SUBMETA[s.id] = { name: s.name, env: envLabel(s.name) });
|
|
463
|
+
renderOverview(); renderSubs();
|
|
464
|
+
// If the expiry view rendered before subs arrived, re-resolve its env tags now.
|
|
465
|
+
if(EXP.scanned){ EXP.items = EXP.items.map(it=>({...it, env: envOf(it.subscriptionId)})); renderExpiring(); }
|
|
466
|
+
}
|
|
467
|
+
// Infer an environment label from a subscription name. Operators can override the
|
|
468
|
+
// matching with AZBO_ENV_RULES (see /api/config); otherwise generic keywords are used.
|
|
469
|
+
function envLabel(name){
|
|
470
|
+
const n = name || '';
|
|
471
|
+
if(CFG.envRules){ for(const [pat,label] of CFG.envRules){ try{ if(new RegExp(pat,'i').test(n)) return label; }catch{} } }
|
|
472
|
+
const s = n.toLowerCase();
|
|
473
|
+
if(/prod|prd|production|\blive\b/.test(s)) return 'Prod';
|
|
474
|
+
if(/preprod|pre-prod|stag|stg|\buat\b|accept/.test(s)) return 'Stage';
|
|
475
|
+
if(/\bqa\b|quality/.test(s)) return 'QA';
|
|
476
|
+
if(/sandbox|sbx|\bdemo\b/.test(s)) return 'Sandbox';
|
|
477
|
+
if(/dev|develop/.test(s)) return 'Dev';
|
|
478
|
+
if(/test|tst|\bint\b|integration|nonprod|non-prod/.test(s)) return 'Test';
|
|
479
|
+
return '—';
|
|
480
|
+
}
|
|
481
|
+
// Map any env label to one of a fixed set of colour buckets for the tag styling.
|
|
482
|
+
function envClass(env){
|
|
483
|
+
const s = (env||'').toLowerCase();
|
|
484
|
+
if(/prod|live/.test(s)) return 'e-prod';
|
|
485
|
+
if(/stag|uat|preprod/.test(s)) return 'e-stage';
|
|
486
|
+
if(/qa/.test(s)) return 'e-qa';
|
|
487
|
+
if(/sandbox|demo/.test(s)) return 'e-sandbox';
|
|
488
|
+
if(/dev/.test(s)) return 'e-dev';
|
|
489
|
+
if(/test|int/.test(s)) return 'e-test';
|
|
490
|
+
return 'e-other';
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function loadVaults(refresh){
|
|
494
|
+
$('#vaultsBody').innerHTML = '<tr><td colspan="7" class="empty"><span class="spin"></span> querying Resource Graph…</td></tr>';
|
|
495
|
+
const r = await api('/api/vaults'+(refresh?'?refresh=1':''));
|
|
496
|
+
if(!r.ok){ $('#vaultsBody').innerHTML = `<tr><td colspan="7" class="empty">${r.error||'failed'}</td></tr>`; return; }
|
|
497
|
+
STATE.vaults = r.data.map(v => ({...v, env: envOf(v.subscriptionId), service: serviceOf(v.resourceGroup)}));
|
|
498
|
+
renderOverview(); renderEnvChips(); renderVaults();
|
|
499
|
+
}
|
|
500
|
+
// Best-effort short name from a resource group: drop a leading rg / rg- and trailing index.
|
|
501
|
+
function serviceOf(rg){
|
|
502
|
+
return rg.replace(/^rg[-_]?/i,'').replace(/[-_]?\d+$/,'') || rg;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ---------- overview ----------
|
|
506
|
+
function renderOverview(){
|
|
507
|
+
const envs = {};
|
|
508
|
+
STATE.vaults.forEach(v => envs[v.env]=(envs[v.env]||0)+1);
|
|
509
|
+
const namedEnvs = Object.entries(envs).filter(([e])=>e!=='?'&&e!=='—').sort((a,b)=>b[1]-a[1]);
|
|
510
|
+
const cards = [
|
|
511
|
+
card('Subscriptions', STATE.subs.length, STATE.subs.filter(s=>s.state==='Enabled').length+' enabled',''),
|
|
512
|
+
card('Key Vaults', STATE.vaults.length, 'across '+namedEnvs.length+' environment'+(namedEnvs.length!==1?'s':''),'azure'),
|
|
513
|
+
...namedEnvs.slice(0,3).map(([e,n])=>card(e, n, 'environment','')),
|
|
514
|
+
];
|
|
515
|
+
$('#ovCards').innerHTML = cards.join('');
|
|
516
|
+
const counts = {}; STATE.vaults.forEach(v=>counts[v.subscriptionId]=(counts[v.subscriptionId]||0)+1);
|
|
517
|
+
$('#ovSubs').innerHTML = STATE.subs.map(s=>`<tr>
|
|
518
|
+
<td><b>${s.name}</b></td><td><span class="tag ${envClass(SUBMETA[s.id].env)}">${SUBMETA[s.id].env}</span></td>
|
|
519
|
+
<td>${s.state}</td><td class="right mono">${counts[s.id]||0}</td><td class="mono muted">${s.id}</td></tr>`).join('');
|
|
520
|
+
}
|
|
521
|
+
function card(k,v,sub,mod){
|
|
522
|
+
return `<div class="card ${mod?'card--'+mod:''}"><div class="k">${k}</div><div class="v">${v} <small>${sub}</small></div></div>`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ---------- subscriptions ----------
|
|
526
|
+
function renderSubs(){
|
|
527
|
+
const counts = {}; STATE.vaults.forEach(v=>counts[v.subscriptionId]=(counts[v.subscriptionId]||0)+1);
|
|
528
|
+
$('#subsBody').innerHTML = STATE.subs.map(s=>`<tr>
|
|
529
|
+
<td><b>${s.name}</b></td>
|
|
530
|
+
<td><span class="tag ${envClass(SUBMETA[s.id].env)}">${SUBMETA[s.id].env}</span></td>
|
|
531
|
+
<td>${s.state}</td>
|
|
532
|
+
<td>${s.isDefault?'<span class="pill rbac">active</span>':''}</td>
|
|
533
|
+
<td class="right mono">${counts[s.id]||0}</td>
|
|
534
|
+
<td class="mono muted">${s.id}</td>
|
|
535
|
+
<td class="right">${s.isDefault?'':`<button class="btn sm ghost" onclick="setSub('${s.id}')">Switch</button>`}</td>
|
|
536
|
+
</tr>`).join('');
|
|
537
|
+
}
|
|
538
|
+
async function setSub(id){
|
|
539
|
+
toast('Switching subscription…');
|
|
540
|
+
const r = await api('/api/subscription/set',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({subscription:id})});
|
|
541
|
+
if(r.ok){ STATE.account=r.data; await loadSubs(); toast('Active subscription set'); } else toast(r.error||'failed');
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ---------- vaults ----------
|
|
545
|
+
function renderEnvChips(){
|
|
546
|
+
const envs = {}; STATE.vaults.forEach(v=>envs[v.env]=(envs[v.env]||0)+1);
|
|
547
|
+
const order = ['all',...Object.keys(envs).sort()];
|
|
548
|
+
$('#envChips').innerHTML = order.map(e=>{
|
|
549
|
+
const n = e==='all'?STATE.vaults.length:envs[e];
|
|
550
|
+
return `<div class="chip ${STATE.env===e?'active':''}" onclick="setEnv('${e}')">${e==='all'?'All environments':e}<span class="n">${n}</span></div>`;
|
|
551
|
+
}).join('');
|
|
552
|
+
}
|
|
553
|
+
function setEnv(e){ STATE.env=e; renderEnvChips(); renderVaults(); }
|
|
554
|
+
|
|
555
|
+
function filteredVaults(){
|
|
556
|
+
const q = STATE.q.toLowerCase();
|
|
557
|
+
let list = STATE.vaults.filter(v =>
|
|
558
|
+
(STATE.env==='all'||v.env===STATE.env) &&
|
|
559
|
+
(!q || v.name.toLowerCase().includes(q) || v.resourceGroup.toLowerCase().includes(q) || (v.service||'').toLowerCase().includes(q)));
|
|
560
|
+
const k = STATE.sort, d = STATE.dir;
|
|
561
|
+
list.sort((a,b)=>String(a[k]||'').localeCompare(String(b[k]||''))*d);
|
|
562
|
+
return list;
|
|
563
|
+
}
|
|
564
|
+
function renderVaults(){
|
|
565
|
+
const list = filteredVaults();
|
|
566
|
+
$('#vaultCount').textContent = `${list.length} vault${list.length!==1?'s':''} shown`;
|
|
567
|
+
if(!list.length){ $('#vaultsBody').innerHTML='<tr><td colspan="7" class="empty">No vaults match your filter.</td></tr>'; return; }
|
|
568
|
+
$('#vaultsBody').innerHTML = list.map(v=>`<tr>
|
|
569
|
+
<td class="mono"><b>${v.name}</b></td>
|
|
570
|
+
<td><span class="tag ${envClass(v.env)}">${v.env}</span></td>
|
|
571
|
+
<td class="muted">${v.service||''}</td>
|
|
572
|
+
<td class="mono muted">${v.resourceGroup}</td>
|
|
573
|
+
<td class="mono">${v.sku||'—'}</td>
|
|
574
|
+
<td>${v.enableRbac==='true'?'<span class="pill rbac">RBAC</span>':'<span class="pill policy">Policy</span>'}</td>
|
|
575
|
+
<td class="right">
|
|
576
|
+
<button class="iconbtn" onclick="openVault('${v.name}','${v.subscriptionId}')">Open</button>
|
|
577
|
+
<button class="iconbtn" onclick="copy('az keyvault secret list --vault-name ${v.name} -o table')">⧉</button>
|
|
578
|
+
</td></tr>`).join('');
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ---------- drawer ----------
|
|
582
|
+
let DRW = { vault:'', kind:'secrets', items:[], filter:'', sel:new Set() };
|
|
583
|
+
function openVault(name, subId){
|
|
584
|
+
const v = STATE.vaults.find(x=>x.name===name) || {name, subscriptionId:subId, resourceGroup:'', uri:''};
|
|
585
|
+
STATE.drawerVault = v; STATE.kind='secrets';
|
|
586
|
+
DRW = { vault:name, kind:'secrets', items:[], filter:'', sel:new Set() };
|
|
587
|
+
$('#dFilter').value=''; $('#dSelAll').checked=false;
|
|
588
|
+
$('#dTitle').textContent = name;
|
|
589
|
+
$('#dSub').innerHTML = `${subName(subId)} · ${v.resourceGroup||''}${v.uri?' · '+v.uri:''}`;
|
|
590
|
+
$('#dSeg').querySelectorAll('button').forEach(b=>b.classList.toggle('active',b.dataset.kind==='secrets'));
|
|
591
|
+
$('#overlay').classList.add('show'); $('#drawer').classList.add('open');
|
|
592
|
+
renderCmds(v); loadKind();
|
|
593
|
+
}
|
|
594
|
+
function closeDrawer(){ $('#overlay').classList.remove('show'); $('#drawer').classList.remove('open'); }
|
|
595
|
+
function renderCmds(v){
|
|
596
|
+
const cmds = [
|
|
597
|
+
`az keyvault secret list --vault-name ${v.name} -o table`,
|
|
598
|
+
`az keyvault key list --vault-name ${v.name} -o table`,
|
|
599
|
+
`az keyvault show --name ${v.name} -o jsonc`,
|
|
600
|
+
];
|
|
601
|
+
$('#dCmds').innerHTML = cmds.map(c=>`<div class="cmd"><span>${c}</span><button class="iconbtn" onclick='copy(${JSON.stringify(c)})'>⧉</button></div>`).join('');
|
|
602
|
+
}
|
|
603
|
+
const isEditable = () => DRW.kind==='secrets' || DRW.kind==='certificates';
|
|
604
|
+
async function loadKind(){
|
|
605
|
+
const v = STATE.drawerVault; DRW.kind = STATE.kind; DRW.sel.clear();
|
|
606
|
+
$('#dTools').style.display = '';
|
|
607
|
+
closeCreate();
|
|
608
|
+
$('#dNewBtns').style.display = STATE.kind==='secrets' ? '' : 'none';
|
|
609
|
+
$('#dContent').innerHTML = '<div class="empty"><span class="spin"></span></div>';
|
|
610
|
+
const r = await api(`/api/vaults/${v.name}/${DRW.kind}`);
|
|
611
|
+
if(!r.ok){
|
|
612
|
+
DRW.items=[]; $('#dBatch').classList.add('hide'); $('#dCount').textContent='';
|
|
613
|
+
let why;
|
|
614
|
+
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>`;
|
|
615
|
+
else if(r.forbidden) why = `🔒 You can see this vault but have no data-plane permission on its ${DRW.kind}.`;
|
|
616
|
+
else why = r.error||'failed';
|
|
617
|
+
$('#dContent').innerHTML = `<div class="empty">${why}</div>`;
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
DRW.items = (r.data||[]).map(item=>({
|
|
621
|
+
name: item.name || (item.id||'').split('/').pop(),
|
|
622
|
+
enabled: item.attributes ? item.attributes.enabled!==false : true,
|
|
623
|
+
expires: item.attributes && item.attributes.expires || null,
|
|
624
|
+
managed: item.managed===true, // cert-backed secret — controlled by its certificate
|
|
625
|
+
}));
|
|
626
|
+
renderDrawerList();
|
|
627
|
+
}
|
|
628
|
+
function drawerFiltered(){
|
|
629
|
+
const q = DRW.filter.toLowerCase();
|
|
630
|
+
return DRW.items.filter(i=>!q || i.name.toLowerCase().includes(q));
|
|
631
|
+
}
|
|
632
|
+
function renderDrawerList(){
|
|
633
|
+
const list = drawerFiltered();
|
|
634
|
+
const editable = isEditable();
|
|
635
|
+
$('#dCount').textContent = `${list.length}${list.length!==DRW.items.length?' / '+DRW.items.length:''} ${DRW.kind}`;
|
|
636
|
+
$('#dSelAll').parentElement.style.display = editable?'':'none';
|
|
637
|
+
if(!DRW.items.length){ $('#dContent').innerHTML=`<div class="empty">No ${DRW.kind} in this vault, or none visible to you.</div>`; renderBatchBar(); return; }
|
|
638
|
+
if(!list.length){ $('#dContent').innerHTML=`<div class="empty">No ${DRW.kind} match “${DRW.filter}”.</div>`; renderBatchBar(); return; }
|
|
639
|
+
$('#dContent').innerHTML = list.map(i=>{
|
|
640
|
+
const nm=i.name, sel=DRW.sel.has(nm), lock=i.managed;
|
|
641
|
+
const check = editable && !lock?`<input type="checkbox" ${sel?'checked':''} onclick="toggleSel('${nm}')">`:'';
|
|
642
|
+
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>':''}`;
|
|
643
|
+
const reveal = DRW.kind==='secrets'?`<button class="loadval" onclick="reveal('${nm}')">reveal</button>`:'';
|
|
644
|
+
const edit = editable && !lock?`<button class="iconbtn" onclick="toggleEdit('${nm}')">Edit</button>`:'';
|
|
645
|
+
return `<div class="kvitem ${sel?'sel':''}">
|
|
646
|
+
<div class="kvline">${check}<span class="nm" title="${nm}">${nm}</span><span class="meta">${pills}${reveal}${edit}</span></div>
|
|
647
|
+
<div class="revealed hide" id="rv-${nm}"></div>
|
|
648
|
+
<div class="editor hide" id="ed-${nm}"></div>
|
|
649
|
+
</div>`;
|
|
650
|
+
}).join('');
|
|
651
|
+
renderBatchBar();
|
|
652
|
+
}
|
|
653
|
+
function renderBatchBar(){
|
|
654
|
+
const bar=$('#dBatch'), n=DRW.sel.size;
|
|
655
|
+
if(!n || !isEditable() || !$('#dCreate').classList.contains('hide')){ bar.classList.add('hide'); return; }
|
|
656
|
+
bar.classList.remove('hide');
|
|
657
|
+
const secretOnly = DRW.kind==='secrets'
|
|
658
|
+
? `<input type="date" id="batchExp"><button class="iconbtn" onclick="batchApply('expiry')">Set expiry</button>
|
|
659
|
+
<button class="iconbtn" onclick="batchEditValues()">Edit values…</button>` : '';
|
|
660
|
+
bar.innerHTML = `<span class="bn">${n} selected</span>
|
|
661
|
+
<button class="iconbtn" onclick="batchApply('enable')">Enable</button>
|
|
662
|
+
<button class="iconbtn" onclick="batchApply('disable')">Disable</button>
|
|
663
|
+
${secretOnly}
|
|
664
|
+
<button class="iconbtn" onclick="clearSel()">Clear</button>`;
|
|
665
|
+
}
|
|
666
|
+
// Batch-edit the VALUES of the selected secrets, pre-filled with their current values.
|
|
667
|
+
async function batchEditValues(){
|
|
668
|
+
const names=[...DRW.sel]; if(!names.length) return;
|
|
669
|
+
const el=$('#dCreate'); $('#dBatch').classList.add('hide');
|
|
670
|
+
el.classList.remove('hide');
|
|
671
|
+
el.innerHTML = `<h4>Edit ${names.length} secret value${names.length!==1?'s':''}</h4><div class="empty"><span class="spin"></span> loading current values…</div>`;
|
|
672
|
+
const vals = await Promise.all(names.map(n=>
|
|
673
|
+
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')}))));
|
|
674
|
+
const multiline = vals.filter(v=>v.ok && v.val.includes('\n')).map(v=>v.n);
|
|
675
|
+
const usable = vals.filter(v=>v.ok && !v.val.includes('\n'));
|
|
676
|
+
const blocked = vals.filter(v=>!v.ok);
|
|
677
|
+
const lines = usable.map(v=>`${v.n} = ${v.val}`).join('\n');
|
|
678
|
+
const notes = [];
|
|
679
|
+
if(blocked.length) notes.push(`${blocked.length} value(s) couldn't be read and were skipped`);
|
|
680
|
+
if(multiline.length) notes.push(`${multiline.length} multi-line value(s) skipped — edit those individually`);
|
|
681
|
+
el.innerHTML = `<h4>Edit ${usable.length} secret value${usable.length!==1?'s':''}</h4>
|
|
682
|
+
<div class="fld" style="align-items:flex-start"><label>Pairs</label><textarea id="cBatch">${lines.replace(/</g,'<')}</textarea></div>
|
|
683
|
+
<div class="fld"><label>Expires</label><input type="date" id="cExp"><span class="hint">optional · applied to every saved secret</span></div>
|
|
684
|
+
<div class="actions"><button class="btn sm" onclick="submitCreate('batch')">Save changes</button>
|
|
685
|
+
<button class="btn sm ghost" onclick="closeCreate()">Cancel</button>
|
|
686
|
+
<span class="hint" id="cMsg">${notes.join(' · ')}</span></div>`;
|
|
687
|
+
}
|
|
688
|
+
function toggleSel(nm){ DRW.sel.has(nm)?DRW.sel.delete(nm):DRW.sel.add(nm); renderDrawerList(); }
|
|
689
|
+
function clearSel(){ DRW.sel.clear(); $('#dSelAll').checked=false; renderDrawerList(); }
|
|
690
|
+
function selectAll(on){ DRW.sel.clear(); if(on) drawerFiltered().filter(i=>!i.managed).forEach(i=>DRW.sel.add(i.name)); renderDrawerList(); }
|
|
691
|
+
function openCreate(mode){
|
|
692
|
+
const el=$('#dCreate'); DRW.sel.clear(); $('#dBatch').classList.add('hide');
|
|
693
|
+
if(mode==='single'){
|
|
694
|
+
el.innerHTML = `<h4>Create a secret</h4>
|
|
695
|
+
<div class="fld"><label>Name</label><input type="text" id="cName" placeholder="my-secret-name" autocomplete="off"></div>
|
|
696
|
+
<div class="fld"><label>Value</label><input type="text" id="cVal" placeholder="secret value" autocomplete="off"></div>
|
|
697
|
+
<div class="fld"><label>Expires</label><input type="date" id="cExp"><span class="hint">optional</span></div>
|
|
698
|
+
<div class="actions"><button class="btn sm" onclick="submitCreate('single')">Create secret</button>
|
|
699
|
+
<button class="btn sm ghost" onclick="closeCreate()">Cancel</button><span class="hint" id="cMsg"></span></div>`;
|
|
700
|
+
} else {
|
|
701
|
+
el.innerHTML = `<h4>Create secrets in batch</h4>
|
|
702
|
+
<div class="fld" style="align-items:flex-start"><label>Pairs</label>
|
|
703
|
+
<textarea id="cBatch" placeholder="one per line — name = value client-id = 1a2b3c client-secret = s3cr3t tenant-id: contoso.onmicrosoft.com"></textarea></div>
|
|
704
|
+
<div class="fld"><label>Expires</label><input type="date" id="cExp"><span class="hint">common expiry for all (optional)</span></div>
|
|
705
|
+
<div class="actions"><button class="btn sm" onclick="submitCreate('batch')">Create all</button>
|
|
706
|
+
<button class="btn sm ghost" onclick="closeCreate()">Cancel</button><span class="hint" id="cMsg"></span></div>`;
|
|
707
|
+
}
|
|
708
|
+
el.classList.remove('hide');
|
|
709
|
+
}
|
|
710
|
+
function closeCreate(){ const el=$('#dCreate'); if(el){ el.classList.add('hide'); el.innerHTML=''; } renderBatchBar(); }
|
|
711
|
+
async function submitCreate(mode){
|
|
712
|
+
const msg=$('#cMsg');
|
|
713
|
+
const expRaw=$('#cExp').value;
|
|
714
|
+
const expires = expRaw ? new Date(expRaw+'T00:00:00Z').toISOString() : undefined;
|
|
715
|
+
let items=[];
|
|
716
|
+
if(mode==='single'){
|
|
717
|
+
const name=$('#cName').value.trim(), value=$('#cVal').value;
|
|
718
|
+
if(!/^[A-Za-z0-9-]+$/.test(name)){ msg.textContent='Invalid name — use letters, digits, hyphen'; return; }
|
|
719
|
+
if(!value.length){ msg.textContent='Value is required'; return; }
|
|
720
|
+
items=[{name,value}];
|
|
721
|
+
} else {
|
|
722
|
+
const lines=$('#cBatch').value.split('\n').map(l=>l.trim()).filter(Boolean);
|
|
723
|
+
const bad=[];
|
|
724
|
+
for(const ln of lines){
|
|
725
|
+
const m=ln.match(/^([^=:]+)[=:]([\s\S]*)$/);
|
|
726
|
+
const name=m?m[1].trim():'', value=m?m[2].trim():'';
|
|
727
|
+
if(!m || !/^[A-Za-z0-9-]+$/.test(name) || !value.length){ bad.push(ln); continue; }
|
|
728
|
+
items.push({name,value});
|
|
729
|
+
}
|
|
730
|
+
if(bad.length){ msg.textContent=`${bad.length} invalid line(s) — each must be "name = value" (name: letters/digits/hyphen)`; return; }
|
|
731
|
+
if(!items.length){ msg.textContent='Add at least one name = value line'; return; }
|
|
732
|
+
}
|
|
733
|
+
msg.textContent='creating…';
|
|
734
|
+
const r=await api(`/api/vaults/${DRW.vault}/secrets-create`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({items, expires})});
|
|
735
|
+
if(!r.ok){ msg.textContent=r.error||'failed'; return; }
|
|
736
|
+
const ok=r.results.filter(x=>x.ok).length, fail=r.results.length-ok;
|
|
737
|
+
toast(`Created ${ok} secret${ok!==1?'s':''}${fail?`, ${fail} failed`:''}`);
|
|
738
|
+
if(fail){ const f=r.results.filter(x=>!x.ok);
|
|
739
|
+
banner(`Create: ${ok} ok, ${fail} failed (${f.slice(0,3).map(x=>x.name+': '+x.error).join('; ')}${fail>3?'…':''}).`);
|
|
740
|
+
setTimeout(()=>banner(''),9000); }
|
|
741
|
+
closeCreate(); setTimeout(loadKind, 350);
|
|
742
|
+
}
|
|
743
|
+
function toggleEdit(nm){
|
|
744
|
+
const el=$('#ed-'+CSS.escape(nm)); if(!el) return;
|
|
745
|
+
if(!el.classList.contains('hide')){ el.classList.add('hide'); el.innerHTML=''; return; }
|
|
746
|
+
const it=DRW.items.find(x=>x.name===nm)||{};
|
|
747
|
+
const cert = DRW.kind==='certificates';
|
|
748
|
+
const exp = it.expires?it.expires.slice(0,10):'';
|
|
749
|
+
el.innerHTML = `
|
|
750
|
+
<div class="fld"><label>Enabled</label><label class="toggle"><input type="checkbox" id="edEn-${nm}" ${it.enabled?'checked':''}> ${it.enabled?'enabled':'disabled'}</label></div>
|
|
751
|
+
${cert
|
|
752
|
+
? `<div class="fld"><label>Expires</label><span class="hint">${exp||'—'} · set by the certificate, not editable here</span></div>`
|
|
753
|
+
: `<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>
|
|
754
|
+
<div class="fld"><label>Value</label><input type="text" id="edVal-${nm}" placeholder="loading current value…"></div>
|
|
755
|
+
<div class="fld"><label></label><span class="hint">current value is pre-filled — saving re-writes only if you change it</span></div>`}
|
|
756
|
+
<div class="fld"><label></label><button class="btn sm" onclick="saveEdit('${nm}')">Save changes</button>
|
|
757
|
+
<button class="btn sm ghost" onclick="toggleEdit('${nm}')">Cancel</button>
|
|
758
|
+
<span class="hint" id="edMsg-${nm}"></span></div>`;
|
|
759
|
+
el.classList.remove('hide');
|
|
760
|
+
if(!cert){
|
|
761
|
+
const vEl=document.getElementById('edVal-'+nm);
|
|
762
|
+
api(`/api/vaults/${DRW.vault}/secrets/${nm}/value`).then(r=>{
|
|
763
|
+
const live=document.getElementById('edVal-'+nm); if(!live) return;
|
|
764
|
+
if(r.ok){ live.value=r.data.value||''; live.dataset.orig=r.data.value||''; live.placeholder='secret value'; }
|
|
765
|
+
else { live.placeholder = r.forbidden?'no permission to read current value':'could not load value'; }
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
async function saveEdit(nm){
|
|
770
|
+
const msg=$('#edMsg-'+CSS.escape(nm)); msg.textContent='saving…';
|
|
771
|
+
const v=DRW.vault, cert=DRW.kind==='certificates';
|
|
772
|
+
const enabled = $('#edEn-'+CSS.escape(nm)).checked;
|
|
773
|
+
let r;
|
|
774
|
+
if(cert){
|
|
775
|
+
r = await api(`/api/vaults/${v}/certificates/${nm}`, {method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled})});
|
|
776
|
+
} else {
|
|
777
|
+
const expEl=$('#edExp-'+CSS.escape(nm)), valEl=$('#edVal-'+CSS.escape(nm));
|
|
778
|
+
const expires = expEl.value? new Date(expEl.value+'T00:00:00Z').toISOString() : undefined;
|
|
779
|
+
const value = valEl.value;
|
|
780
|
+
const orig = valEl.dataset.orig ?? '';
|
|
781
|
+
const valueChanged = value.length>0 && value!==orig;
|
|
782
|
+
if(valueChanged){
|
|
783
|
+
r = await api(`/api/vaults/${v}/secrets/${nm}`, {method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({value, enabled, expires})});
|
|
784
|
+
} else {
|
|
785
|
+
r = await api(`/api/vaults/${v}/secrets/${nm}`, {method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled, expires})});
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
if(!r.ok){ msg.textContent = r.forbidden?'🔒 no write permission':(r.error||'failed'); return; }
|
|
789
|
+
msg.textContent='saved ✓'; toast('Updated '+nm);
|
|
790
|
+
setTimeout(loadKind, 350);
|
|
791
|
+
}
|
|
792
|
+
async function batchApply(action){
|
|
793
|
+
const names=[...DRW.sel]; if(!names.length) return;
|
|
794
|
+
let op, verb;
|
|
795
|
+
if(action==='enable'){ op={enabled:true}; verb='Enable'; }
|
|
796
|
+
else if(action==='disable'){ op={enabled:false}; verb='Disable'; }
|
|
797
|
+
else if(action==='expiry'){
|
|
798
|
+
const d=$('#batchExp').value; if(!d){ toast('Pick a date first'); return; }
|
|
799
|
+
op={expires:new Date(d+'T00:00:00Z').toISOString()}; verb='Set expiry '+d+' on';
|
|
800
|
+
}
|
|
801
|
+
if(!confirm(`${verb} ${names.length} ${DRW.kind} in ${DRW.vault}?`)) return;
|
|
802
|
+
const kind = DRW.kind==='certificates'?'certificate':'secret';
|
|
803
|
+
const items = names.map(n=>({vault:DRW.vault, kind, name:n}));
|
|
804
|
+
toast('Applying to '+names.length+'…');
|
|
805
|
+
const r = await api('/api/batch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({op, items})});
|
|
806
|
+
if(!r.ok){ toast(r.error||'batch failed'); return; }
|
|
807
|
+
const ok=r.results.filter(x=>x.ok).length, fail=r.results.length-ok;
|
|
808
|
+
toast(`${ok} updated${fail?`, ${fail} failed`:''}`);
|
|
809
|
+
if(fail){ const failed=r.results.filter(x=>!x.ok); console.warn('Batch failures:', failed);
|
|
810
|
+
banner(`Batch: ${ok} updated, ${fail} failed (${failed.slice(0,3).map(f=>f.name+': '+f.error).join('; ')}${fail>3?'…':''}). See console for all.`);
|
|
811
|
+
setTimeout(()=>banner(''),8000); }
|
|
812
|
+
DRW.sel.clear(); $('#dSelAll').checked=false;
|
|
813
|
+
setTimeout(loadKind, 350);
|
|
814
|
+
}
|
|
815
|
+
// Reveal a secret value on its own full-width line below the row (toggles).
|
|
816
|
+
async function reveal(name){
|
|
817
|
+
const box = document.getElementById('rv-'+name);
|
|
818
|
+
if(!box) return;
|
|
819
|
+
if(!box.classList.contains('hide')){ box.classList.add('hide'); box.innerHTML=''; return; }
|
|
820
|
+
box.classList.remove('hide'); box.innerHTML = '<span class="spin"></span>';
|
|
821
|
+
const r = await api(`/api/vaults/${DRW.vault}/secrets/${name}/value`);
|
|
822
|
+
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; }
|
|
823
|
+
const val = r.data.value || '';
|
|
824
|
+
box.innerHTML = `<code class="val" title="click to select">${val.replace(/</g,'<')}</code><button class="iconbtn" onclick='copy(${JSON.stringify(val)})'>⧉</button>`;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ---------- expiring ----------
|
|
828
|
+
let EXP = { days: 30, items: [], stats: null, scannedAt: 0, sort: 'daysLeft', dir: 1, scanned: false, polling: false };
|
|
829
|
+
const ROW_CAP = 500;
|
|
830
|
+
function applyStatus(s){
|
|
831
|
+
EXP.polling=false; EXP.scanned=true;
|
|
832
|
+
$('#expScan').disabled=false; $('#expScan').textContent='⟳ Re-scan';
|
|
833
|
+
EXP.items=(s.items||[]).map(it=>({...it, env: envOf(it.subscriptionId)}));
|
|
834
|
+
EXP.stats=s.stats; EXP.scannedAt=s.scannedAt;
|
|
835
|
+
const when = s.scannedAt? new Date(s.scannedAt).toLocaleTimeString():'';
|
|
836
|
+
$('#expMeta').textContent = s.stats? `${s.stats.readable} readable · ${s.stats.forbidden} denied · ${s.stats.total} vaults · scanned ${when}` : '';
|
|
837
|
+
renderExpiring();
|
|
838
|
+
}
|
|
839
|
+
// On first visit, reuse the last completed scan if one exists; only scan when there's nothing yet.
|
|
840
|
+
async function enterExpiring(){
|
|
841
|
+
if(EXP.scanned || EXP.polling) return;
|
|
842
|
+
const s = await api(`/api/expiring/status?days=${EXP.days}`);
|
|
843
|
+
if(s.ok && s.running){ EXP.polling=true; pollExpiring(); return; }
|
|
844
|
+
if(s.ok && s.scannedAt){ applyStatus(s); return; }
|
|
845
|
+
scanExpiring();
|
|
846
|
+
}
|
|
847
|
+
async function scanExpiring(){
|
|
848
|
+
if(EXP.polling) return;
|
|
849
|
+
$('#expScan').disabled = true;
|
|
850
|
+
const start = await api('/api/expiring/scan', {method:'POST'});
|
|
851
|
+
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>`; $('#expScan').disabled=false; return; }
|
|
852
|
+
EXP.polling = true;
|
|
853
|
+
pollExpiring();
|
|
854
|
+
}
|
|
855
|
+
async function pollExpiring(){
|
|
856
|
+
const s = await api(`/api/expiring/status?days=${EXP.days}`);
|
|
857
|
+
if(!s.ok){ EXP.polling=false; $('#expScan').disabled=false; return; }
|
|
858
|
+
if(s.running){
|
|
859
|
+
const pct = s.total? Math.round(s.done/s.total*100):0;
|
|
860
|
+
$('#expScan').textContent = `Scanning ${s.done}/${s.total}`;
|
|
861
|
+
$('#expHorizon').classList.add('hide');
|
|
862
|
+
$('#expBody').innerHTML = `<tr><td colspan="8" class="empty">
|
|
863
|
+
<div class="spin"></div>
|
|
864
|
+
<div style="margin-top:16px">Inspecting <b>${s.done}</b> of <b>${s.total}</b> vaults…</div>
|
|
865
|
+
<div class="progress"><div class="bar" style="width:${pct}%"></div></div>
|
|
866
|
+
<div class="faint mono" style="margin-top:10px;font-size:11px">Read-denied vaults skip instantly · only your readable vaults are inspected</div>
|
|
867
|
+
</td></tr>`;
|
|
868
|
+
setTimeout(pollExpiring, 1500);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
EXP.polling = false; EXP.scanned = true;
|
|
872
|
+
$('#expScan').disabled = false; $('#expScan').textContent = '⟳ Re-scan';
|
|
873
|
+
EXP.items = (s.items||[]).map(it=>({...it, env: envOf(it.subscriptionId)}));
|
|
874
|
+
EXP.stats = s.stats; EXP.scannedAt = s.scannedAt;
|
|
875
|
+
const when = s.scannedAt? new Date(s.scannedAt).toLocaleTimeString():'';
|
|
876
|
+
$('#expMeta').textContent = s.stats? `${s.stats.readable} readable · ${s.stats.forbidden} denied · ${s.stats.total} vaults · scanned ${when}` : '';
|
|
877
|
+
renderExpiring();
|
|
878
|
+
}
|
|
879
|
+
function expSorted(){
|
|
880
|
+
const k=EXP.sort, d=EXP.dir;
|
|
881
|
+
return [...EXP.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;});
|
|
882
|
+
}
|
|
883
|
+
function renderExpiring(){
|
|
884
|
+
const items = EXP.items;
|
|
885
|
+
const expired = items.filter(i=>i.daysLeft<0).length;
|
|
886
|
+
const soon = items.filter(i=>i.daysLeft>=0&&i.daysLeft<=7).length;
|
|
887
|
+
const certs = items.filter(i=>i.type==='certificate').length;
|
|
888
|
+
$('#expCards').innerHTML = [
|
|
889
|
+
card('Flagged', items.length, `within ${EXP.days} days`, items.length?'':''),
|
|
890
|
+
card('Expired', expired, 'needs rotation', expired?'alert':''),
|
|
891
|
+
card('≤ 7 days', soon, 'urgent', soon?'warn':''),
|
|
892
|
+
card('Certificates', certs, (items.length-certs)+' secrets', 'azure'),
|
|
893
|
+
].join('');
|
|
894
|
+
renderHorizon();
|
|
895
|
+
if(!items.length){
|
|
896
|
+
$('#expHorizon').classList.add('hide');
|
|
897
|
+
$('#expTableNote').textContent='';
|
|
898
|
+
$('#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>`;
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
const list = expSorted();
|
|
902
|
+
const shown = list.slice(0, ROW_CAP);
|
|
903
|
+
$('#expTableNote').textContent = list.length>ROW_CAP ? `Showing first ${ROW_CAP} of ${list.length} — narrow the window or sort to see the rest.` : '';
|
|
904
|
+
$('#expBody').innerHTML = shown.map(i=>{
|
|
905
|
+
const sev = i.daysLeft<0?'bad':i.daysLeft<=7?'bad':i.daysLeft<=30?'warn':'good';
|
|
906
|
+
const label = i.daysLeft<0?`expired ${-i.daysLeft}d ago`:i.daysLeft===0?'today':`${i.daysLeft} days`;
|
|
907
|
+
const status = i.daysLeft<0?'<span class="sev expired">EXPIRED</span>':
|
|
908
|
+
i.daysLeft<=7?'<span class="sev urgent">URGENT</span>':
|
|
909
|
+
'<span class="sev ok">OK</span>';
|
|
910
|
+
return `<tr>
|
|
911
|
+
<td><span class="dot" style="background:var(--${sev})"></span> <b>${label}</b></td>
|
|
912
|
+
<td class="mono muted">${(i.expires||'').slice(0,10)}</td>
|
|
913
|
+
<td class="mono">${i.type==='certificate'?'cert':'secret'}</td>
|
|
914
|
+
<td class="mono">${i.name}${i.enabled?'':' <span class="pill">disabled</span>'}</td>
|
|
915
|
+
<td class="mono muted">${i.vault}</td>
|
|
916
|
+
<td><span class="tag ${envClass(i.env)}">${i.env}</span></td>
|
|
917
|
+
<td>${status}</td>
|
|
918
|
+
<td class="right"><button class="iconbtn" onclick="openVault('${i.vault}','${i.subscriptionId}')">Open</button>
|
|
919
|
+
<button class="iconbtn" onclick="copy('az keyvault ${i.type==='certificate'?'certificate':'secret'} show --vault-name ${i.vault} --name ${i.name} -o jsonc')">⧉</button></td>
|
|
920
|
+
</tr>`;
|
|
921
|
+
}).join('');
|
|
922
|
+
}
|
|
923
|
+
// Signature: expiry horizon — a time axis where every item is a temperature-colored tick.
|
|
924
|
+
function renderHorizon(){
|
|
925
|
+
const el = $('#expHorizon');
|
|
926
|
+
const items = EXP.items;
|
|
927
|
+
if(!items.length){ el.classList.add('hide'); el.innerHTML=''; return; }
|
|
928
|
+
el.classList.remove('hide');
|
|
929
|
+
const days = EXP.days;
|
|
930
|
+
const expired = items.filter(i=>i.daysLeft<0);
|
|
931
|
+
const future = items.filter(i=>i.daysLeft>=0);
|
|
932
|
+
const markSets = {7:[0,1,3,5,7],30:[0,7,14,21,30],60:[0,15,30,45,60],90:[0,30,60,90],365:[0,90,180,270,365]};
|
|
933
|
+
const marks = markSets[days] || [0, days];
|
|
934
|
+
const grid = marks.map(m=>`<div class="hz-grid" style="left:${Math.min(99.5,m/days*100)}%"><span>${m===0?'now':'+'+m+'d'}</span></div>`).join('');
|
|
935
|
+
const ticks = future.map(i=>{
|
|
936
|
+
const left = Math.min(99.6, Math.max(.4, i.daysLeft/days*100));
|
|
937
|
+
const sev = i.daysLeft<=7?'alert':i.daysLeft<=30?'warn':'ok';
|
|
938
|
+
return `<div class="hz-tick ${sev}" style="left:${left}%" title="${i.name} · ${i.vault} · in ${i.daysLeft}d"></div>`;
|
|
939
|
+
}).join('');
|
|
940
|
+
const expTicks = expired.slice(0,140).map((i,idx,arr)=>{
|
|
941
|
+
const left = arr.length>1 ? 8 + (idx/(arr.length-1))*84 : 50;
|
|
942
|
+
return `<div class="hz-tick alert" style="left:${left}%" title="${i.name} · ${i.vault} · expired ${-i.daysLeft}d ago"></div>`;
|
|
943
|
+
}).join('');
|
|
944
|
+
el.innerHTML = `
|
|
945
|
+
<div class="hz-head">
|
|
946
|
+
<span class="eyebrow">Expiry horizon · next ${days} days</span>
|
|
947
|
+
<div class="hz-legend">
|
|
948
|
+
<span><i style="background:var(--alert)"></i>≤7d / expired</span>
|
|
949
|
+
<span><i style="background:var(--warn)"></i>≤30d</span>
|
|
950
|
+
<span><i style="background:var(--ok)"></i>beyond</span>
|
|
951
|
+
</div>
|
|
952
|
+
</div>
|
|
953
|
+
<div class="hz-body">
|
|
954
|
+
<div class="hz-expired" title="${expired.length} already expired">
|
|
955
|
+
<div class="hz-expired-n">${expired.length}</div>
|
|
956
|
+
<div class="hz-expired-l">EXPIRED</div>
|
|
957
|
+
${expTicks}
|
|
958
|
+
</div>
|
|
959
|
+
<div class="hz-axis">${grid}${ticks}</div>
|
|
960
|
+
</div>`;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// ---------- view switching ----------
|
|
964
|
+
const META = {
|
|
965
|
+
overview:['Overview','Overview','Accounts, subscriptions and vault inventory — read live from your az session.'],
|
|
966
|
+
subscriptions:['Subscriptions','Subscriptions','Switch the subscription the az CLI is currently pointed at.'],
|
|
967
|
+
vaults:['Key Vaults','Key Vaults','Browse every readable vault. Open one to inspect its secrets, keys and certificates.'],
|
|
968
|
+
expiring:['Expiry','Expiring soon','Secrets and certificates that are expired or approaching expiry, across every vault you can read.'],
|
|
969
|
+
};
|
|
970
|
+
function show(view){
|
|
971
|
+
document.querySelectorAll('nav button').forEach(b=>b.classList.toggle('active',b.dataset.view===view));
|
|
972
|
+
['overview','subscriptions','vaults','expiring'].forEach(v=>$('#view-'+v).classList.toggle('hide',v!==view));
|
|
973
|
+
$('#search').classList.toggle('hide', view!=='vaults');
|
|
974
|
+
$('#eyebrow').textContent = META[view][0];
|
|
975
|
+
$('#title').textContent = META[view][1];
|
|
976
|
+
$('#subtitle').textContent = META[view][2];
|
|
977
|
+
if(view==='expiring' && !EXP.scanned) enterExpiring();
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// ---------- events ----------
|
|
981
|
+
document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>show(b.dataset.view));
|
|
982
|
+
$('#refresh').onclick=()=>{ toast('Refreshing…'); boot(); loadVaults(true); };
|
|
983
|
+
$('#search').oninput=e=>{ STATE.q=e.target.value; renderVaults(); };
|
|
984
|
+
$('#dClose').onclick=closeDrawer; $('#overlay').onclick=closeDrawer;
|
|
985
|
+
$('#dSeg').querySelectorAll('button').forEach(b=>b.onclick=()=>{ STATE.kind=b.dataset.kind; $('#dFilter').value=''; DRW.filter=''; $('#dSelAll').checked=false; $('#dSeg').querySelectorAll('button').forEach(x=>x.classList.toggle('active',x===b)); loadKind(); });
|
|
986
|
+
$('#dFilter').oninput=e=>{ DRW.filter=e.target.value; renderDrawerList(); };
|
|
987
|
+
$('#dSelAll').onchange=e=>selectAll(e.target.checked);
|
|
988
|
+
$('#dNew').onclick=()=>openCreate('single');
|
|
989
|
+
$('#dNewBatch').onclick=()=>openCreate('batch');
|
|
990
|
+
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; renderVaults(); });
|
|
991
|
+
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(); });
|
|
992
|
+
$('#expWindow').querySelectorAll('button').forEach(b=>b.onclick=async()=>{ $('#expWindow').querySelectorAll('button').forEach(x=>x.classList.toggle('active',x===b)); EXP.days=+b.dataset.d; if(EXP.scanned && !EXP.polling){ const s=await api(`/api/expiring/status?days=${EXP.days}`); if(s.ok){ EXP.items=(s.items||[]).map(it=>({...it,env:envOf(it.subscriptionId)})); renderExpiring(); } } });
|
|
993
|
+
$('#expScan').onclick=()=>scanExpiring(true);
|
|
994
|
+
document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeDrawer(); });
|
|
995
|
+
|
|
996
|
+
const initView = new URLSearchParams(location.search).get('view');
|
|
997
|
+
if(initView && META[initView]) show(initView);
|
|
998
|
+
boot();
|
|
999
|
+
</script>
|
|
1000
|
+
</body>
|
|
1001
|
+
</html>
|