akv-manager 0.0.1 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/public/index.html +663 -641
- package/public/styles.css +369 -0
- package/server.js +78 -8
package/public/index.html
CHANGED
|
@@ -3,266 +3,38 @@
|
|
|
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">
|
|
10
|
-
<
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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>
|
|
10
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
11
|
+
<script>
|
|
12
|
+
// Tailwind Play CDN, themed to the design tokens. Preflight is OFF so the
|
|
13
|
+
// hand-authored component layer in styles.css stays authoritative.
|
|
14
|
+
tailwind.config = {
|
|
15
|
+
corePlugins: { preflight: false },
|
|
16
|
+
theme: {
|
|
17
|
+
extend: {
|
|
18
|
+
colors: {
|
|
19
|
+
ink: '#0A0D14', 'ink-2': '#0C1019',
|
|
20
|
+
panel: '#121826', 'panel-2': '#0E141F', raise: '#171F2E',
|
|
21
|
+
line: '#222C3E', 'line-2': '#1A2233',
|
|
22
|
+
text: '#E6EBF2', muted: '#79859B', faint: '#4C586E',
|
|
23
|
+
azure: '#5B9DFF', azure2: '#3F86F0',
|
|
24
|
+
ok: '#46D6B4', warn: '#F2B53D', alert: '#FF5C6C', violet: '#A98BFF',
|
|
25
|
+
},
|
|
26
|
+
fontFamily: {
|
|
27
|
+
sans: ['"IBM Plex Sans"', 'system-ui', 'sans-serif'],
|
|
28
|
+
mono: ['"IBM Plex Mono"', 'ui-monospace', 'monospace'],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
</script>
|
|
34
|
+
<link rel="stylesheet" href="/styles.css">
|
|
263
35
|
</head>
|
|
264
36
|
<body>
|
|
265
|
-
<div class="
|
|
37
|
+
<div class="grid min-h-screen grid-cols-[236px_1fr] max-[900px]:grid-cols-1">
|
|
266
38
|
<aside>
|
|
267
39
|
<div class="brand">
|
|
268
40
|
<svg class="mark" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
@@ -276,150 +48,68 @@
|
|
|
276
48
|
</svg>
|
|
277
49
|
<div class="wm">KEYVAULT<small>OPS CONSOLE</small></div>
|
|
278
50
|
</div>
|
|
279
|
-
|
|
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>
|
|
280
58
|
<nav>
|
|
281
|
-
<button data-view="
|
|
59
|
+
<button data-view="dashboard" class="active">
|
|
282
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>
|
|
283
|
-
|
|
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>
|
|
61
|
+
Dashboard</button>
|
|
287
62
|
<button data-view="vaults">
|
|
288
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>
|
|
289
64
|
Key Vaults</button>
|
|
290
|
-
<button data-view="
|
|
65
|
+
<button data-view="expiry">
|
|
291
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>
|
|
292
67
|
Expiry</button>
|
|
293
68
|
</nav>
|
|
294
|
-
|
|
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>
|
|
295
83
|
</aside>
|
|
296
84
|
|
|
297
|
-
<main>
|
|
85
|
+
<main class="max-w-[1560px] px-8 pt-[26px] pb-[60px] max-[900px]:p-5">
|
|
298
86
|
<div class="banner" id="banner"></div>
|
|
299
87
|
|
|
300
|
-
<div class="
|
|
301
|
-
<div>
|
|
302
|
-
<div class="eyebrow" id="eyebrow">
|
|
303
|
-
<h1 id="title">
|
|
88
|
+
<div class="flex items-end justify-between gap-[18px] mb-[26px]">
|
|
89
|
+
<div id="headblock">
|
|
90
|
+
<div class="eyebrow" id="eyebrow">Operations</div>
|
|
91
|
+
<h1 id="title">Dashboard</h1>
|
|
304
92
|
<div class="sub" id="subtitle">Live view of your Azure access via the local az CLI</div>
|
|
305
93
|
</div>
|
|
306
|
-
<div class="row">
|
|
307
|
-
<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…" />
|
|
308
96
|
<button class="btn ghost" id="refresh">↻ Refresh</button>
|
|
309
97
|
</div>
|
|
310
98
|
</div>
|
|
311
99
|
|
|
312
|
-
|
|
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>
|
|
100
|
+
<div id="route"></div>
|
|
390
101
|
</main>
|
|
391
102
|
</div>
|
|
392
103
|
|
|
393
|
-
<!--
|
|
394
|
-
<div class="
|
|
395
|
-
<div class="
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
<
|
|
399
|
-
<
|
|
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>
|
|
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>
|
|
417
111
|
</div>
|
|
418
|
-
<div class="
|
|
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>
|
|
112
|
+
<div class="cmdk-list" id="cmdkList"></div>
|
|
423
113
|
</div>
|
|
424
114
|
</div>
|
|
425
115
|
|
|
@@ -427,32 +117,102 @@
|
|
|
427
117
|
|
|
428
118
|
<script>
|
|
429
119
|
const $ = s => document.querySelector(s);
|
|
120
|
+
const esc = s => String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
430
121
|
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,
|
|
432
|
-
|
|
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)); }
|
|
433
127
|
const SUBMETA = {};
|
|
434
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
|
+
|
|
435
140
|
function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1600); }
|
|
436
141
|
function copy(text){ navigator.clipboard.writeText(text).then(()=>toast('Copied to clipboard')); }
|
|
437
142
|
function banner(msg){ const b=$('#banner'); if(!msg){b.classList.remove('show');return;} b.innerHTML=msg; b.classList.add('show'); }
|
|
438
143
|
function envOf(id){ return (SUBMETA[id]&&SUBMETA[id].env)||'?'; }
|
|
439
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; }
|
|
440
191
|
|
|
441
192
|
// ---------- bootstrap ----------
|
|
442
193
|
async function boot(){
|
|
443
194
|
const cfg = await api('/api/config'); if(cfg.ok){ CFG = {...CFG, ...cfg.data};
|
|
444
195
|
if(CFG.title){ document.title = CFG.title; const wm=document.querySelector('.brand .wm'); if(wm) wm.firstChild.textContent = CFG.title; } }
|
|
196
|
+
$('#ver').textContent = 'akv-manager v' + (CFG.version || '?');
|
|
445
197
|
const acc = await api('/api/account');
|
|
446
198
|
if(!acc.ok){
|
|
447
|
-
$('#
|
|
448
|
-
|
|
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>` : '';
|
|
201
|
+
banner((acc.needLogin
|
|
202
|
+
? 'Azure CLI is signed out. Run <code>az login</code>, then press Refresh.'
|
|
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);
|
|
449
204
|
return;
|
|
450
205
|
}
|
|
451
206
|
STATE.account = acc.data;
|
|
452
|
-
$('#
|
|
207
|
+
$('#acctDot').style.background='var(--ok)';
|
|
208
|
+
$('#acctUser').textContent = acc.data.user.name;
|
|
209
|
+
$('#acctSub').textContent = acc.data.name;
|
|
453
210
|
banner('');
|
|
454
|
-
await loadSubs();
|
|
455
|
-
await loadVaults();
|
|
211
|
+
await loadSubs();
|
|
212
|
+
await loadVaults();
|
|
213
|
+
renderSidebar();
|
|
214
|
+
router();
|
|
215
|
+
kickExpiryScan(); // warm the dashboard's lifecycle numbers in the background
|
|
456
216
|
}
|
|
457
217
|
|
|
458
218
|
async function loadSubs(){
|
|
@@ -460,160 +220,288 @@ async function loadSubs(){
|
|
|
460
220
|
if(!r.ok) return;
|
|
461
221
|
STATE.subs = r.data;
|
|
462
222
|
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
223
|
}
|
|
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
224
|
async function loadVaults(refresh){
|
|
494
|
-
$('#vaultsBody').innerHTML = '<tr><td colspan="7" class="empty"><span class="spin"></span> querying Resource Graph…</td></tr>';
|
|
495
225
|
const r = await api('/api/vaults'+(refresh?'?refresh=1':''));
|
|
496
|
-
if(!r.ok){
|
|
497
|
-
STATE.vaults = r.data.map(v => ({...v, env:
|
|
498
|
-
|
|
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;
|
|
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;
|
|
503
229
|
}
|
|
504
230
|
|
|
505
|
-
// ----------
|
|
506
|
-
function
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
231
|
+
// ---------- router ----------
|
|
232
|
+
function parseHash(){
|
|
233
|
+
return location.hash.replace(/^#\/?/,'').split('/').filter(Boolean).map(decodeURIComponent);
|
|
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);
|
|
265
|
+
|
|
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('');
|
|
280
|
+
}
|
|
281
|
+
|
|
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>' : '';
|
|
510
295
|
const cards = [
|
|
511
|
-
card('
|
|
512
|
-
card('
|
|
513
|
-
|
|
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),
|
|
514
300
|
];
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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('');
|
|
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>`;
|
|
552
324
|
}
|
|
553
|
-
function
|
|
554
|
-
|
|
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
|
+
};
|
|
333
|
+
}
|
|
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]):''; }
|
|
555
351
|
function filteredVaults(){
|
|
556
352
|
const q = STATE.q.toLowerCase();
|
|
557
|
-
let list = STATE.vaults.filter(v
|
|
558
|
-
(STATE.env
|
|
559
|
-
(
|
|
560
|
-
|
|
561
|
-
|
|
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; });
|
|
562
365
|
return list;
|
|
563
366
|
}
|
|
564
|
-
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(){
|
|
565
406
|
const list = filteredVaults();
|
|
566
407
|
$('#vaultCount').textContent = `${list.length} vault${list.length!==1?'s':''} shown`;
|
|
567
|
-
if(!list.length){ $('#vaultsBody').innerHTML='<tr><td colspan="
|
|
568
|
-
$('#vaultsBody').innerHTML = list.map(v
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
<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>
|
|
574
421
|
<td>${v.enableRbac==='true'?'<span class="pill rbac">RBAC</span>':'<span class="pill policy">Policy</span>'}</td>
|
|
575
422
|
<td class="right">
|
|
576
|
-
<button class="iconbtn" onclick="openVault('${v.name}'
|
|
577
|
-
<button class="iconbtn" onclick="copy('az keyvault secret list --vault-name ${v.name} -o table')">⧉</button>
|
|
578
|
-
</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('');
|
|
579
426
|
}
|
|
580
427
|
|
|
581
|
-
// ----------
|
|
428
|
+
// ---------- VAULT PAGE (routed) ----------
|
|
582
429
|
let DRW = { vault:'', kind:'secrets', items:[], filter:'', sel:new Set() };
|
|
583
|
-
function openVault(name
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
+
}
|
|
595
484
|
function renderCmds(v){
|
|
596
485
|
const cmds = [
|
|
597
486
|
`az keyvault secret list --vault-name ${v.name} -o table`,
|
|
598
487
|
`az keyvault key list --vault-name ${v.name} -o table`,
|
|
599
488
|
`az keyvault show --name ${v.name} -o jsonc`,
|
|
600
489
|
];
|
|
601
|
-
$('#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('');
|
|
602
491
|
}
|
|
603
492
|
const isEditable = () => DRW.kind==='secrets' || DRW.kind==='certificates';
|
|
604
493
|
async function loadKind(){
|
|
605
494
|
const v = STATE.drawerVault; DRW.kind = STATE.kind; DRW.sel.clear();
|
|
606
|
-
$('#dTools').style.display = '';
|
|
607
495
|
closeCreate();
|
|
608
|
-
$('#dNewBtns').style.display = STATE.kind==='secrets' ? '' : 'none';
|
|
609
496
|
$('#dContent').innerHTML = '<div class="empty"><span class="spin"></span></div>';
|
|
610
497
|
const r = await api(`/api/vaults/${v.name}/${DRW.kind}`);
|
|
498
|
+
if(STATE.route!=='vault') return;
|
|
611
499
|
if(!r.ok){
|
|
612
|
-
DRW.items=[]; $('#dBatch').classList.add('hide'); $('#dCount').textContent='';
|
|
500
|
+
DRW.items=[]; $('#dBatch').classList.add('hide'); if($('#dCount'))$('#dCount').textContent='';
|
|
613
501
|
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>`;
|
|
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>`;
|
|
615
503
|
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';
|
|
504
|
+
else why = esc(r.error||'failed');
|
|
617
505
|
$('#dContent').innerHTML = `<div class="empty">${why}</div>`;
|
|
618
506
|
return;
|
|
619
507
|
}
|
|
@@ -621,37 +509,33 @@ async function loadKind(){
|
|
|
621
509
|
name: item.name || (item.id||'').split('/').pop(),
|
|
622
510
|
enabled: item.attributes ? item.attributes.enabled!==false : true,
|
|
623
511
|
expires: item.attributes && item.attributes.expires || null,
|
|
624
|
-
managed: item.managed===true,
|
|
512
|
+
managed: item.managed===true,
|
|
625
513
|
}));
|
|
626
514
|
renderDrawerList();
|
|
627
515
|
}
|
|
628
|
-
function drawerFiltered(){
|
|
629
|
-
const q = DRW.filter.toLowerCase();
|
|
630
|
-
return DRW.items.filter(i=>!q || i.name.toLowerCase().includes(q));
|
|
631
|
-
}
|
|
516
|
+
function drawerFiltered(){ const q=DRW.filter.toLowerCase(); return DRW.items.filter(i=>!q||i.name.toLowerCase().includes(q)); }
|
|
632
517
|
function renderDrawerList(){
|
|
633
|
-
const list = drawerFiltered();
|
|
634
|
-
|
|
635
|
-
$('#
|
|
636
|
-
$('#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';
|
|
637
521
|
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; }
|
|
522
|
+
if(!list.length){ $('#dContent').innerHTML=`<div class="empty">No ${DRW.kind} match “${esc(DRW.filter)}”.</div>`; renderBatchBar(); return; }
|
|
639
523
|
$('#dContent').innerHTML = list.map(i=>{
|
|
640
524
|
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}')">`:'';
|
|
525
|
+
const check = editable && !lock?`<input type="checkbox" ${sel?'checked':''} onclick="toggleSel('${esc(nm)}')">`:'';
|
|
642
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>':''}`;
|
|
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>`:'';
|
|
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>`:'';
|
|
645
529
|
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>
|
|
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>
|
|
649
533
|
</div>`;
|
|
650
534
|
}).join('');
|
|
651
535
|
renderBatchBar();
|
|
652
536
|
}
|
|
653
537
|
function renderBatchBar(){
|
|
654
|
-
const bar=$('#dBatch')
|
|
538
|
+
const bar=$('#dBatch'); if(!bar) return; const n=DRW.sel.size;
|
|
655
539
|
if(!n || !isEditable() || !$('#dCreate').classList.contains('hide')){ bar.classList.add('hide'); return; }
|
|
656
540
|
bar.classList.remove('hide');
|
|
657
541
|
const secretOnly = DRW.kind==='secrets'
|
|
@@ -663,11 +547,9 @@ function renderBatchBar(){
|
|
|
663
547
|
${secretOnly}
|
|
664
548
|
<button class="iconbtn" onclick="clearSel()">Clear</button>`;
|
|
665
549
|
}
|
|
666
|
-
// Batch-edit the VALUES of the selected secrets, pre-filled with their current values.
|
|
667
550
|
async function batchEditValues(){
|
|
668
551
|
const names=[...DRW.sel]; if(!names.length) return;
|
|
669
|
-
const el=$('#dCreate'); $('#dBatch').classList.add('hide');
|
|
670
|
-
el.classList.remove('hide');
|
|
552
|
+
const el=$('#dCreate'); $('#dBatch').classList.add('hide'); el.classList.remove('hide');
|
|
671
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>`;
|
|
672
554
|
const vals = await Promise.all(names.map(n=>
|
|
673
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')}))));
|
|
@@ -679,14 +561,14 @@ async function batchEditValues(){
|
|
|
679
561
|
if(blocked.length) notes.push(`${blocked.length} value(s) couldn't be read and were skipped`);
|
|
680
562
|
if(multiline.length) notes.push(`${multiline.length} multi-line value(s) skipped — edit those individually`);
|
|
681
563
|
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
|
|
564
|
+
<div class="fld" style="align-items:flex-start"><label>Pairs</label><textarea id="cBatch">${esc(lines)}</textarea></div>
|
|
683
565
|
<div class="fld"><label>Expires</label><input type="date" id="cExp"><span class="hint">optional · applied to every saved secret</span></div>
|
|
684
566
|
<div class="actions"><button class="btn sm" onclick="submitCreate('batch')">Save changes</button>
|
|
685
567
|
<button class="btn sm ghost" onclick="closeCreate()">Cancel</button>
|
|
686
|
-
<span class="hint" id="cMsg">${notes.join(' · ')}</span></div>`;
|
|
568
|
+
<span class="hint" id="cMsg">${esc(notes.join(' · '))}</span></div>`;
|
|
687
569
|
}
|
|
688
570
|
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(); }
|
|
571
|
+
function clearSel(){ DRW.sel.clear(); if($('#dSelAll'))$('#dSelAll').checked=false; renderDrawerList(); }
|
|
690
572
|
function selectAll(on){ DRW.sel.clear(); if(on) drawerFiltered().filter(i=>!i.managed).forEach(i=>DRW.sel.add(i.name)); renderDrawerList(); }
|
|
691
573
|
function openCreate(mode){
|
|
692
574
|
const el=$('#dCreate'); DRW.sel.clear(); $('#dBatch').classList.add('hide');
|
|
@@ -736,7 +618,7 @@ async function submitCreate(mode){
|
|
|
736
618
|
const ok=r.results.filter(x=>x.ok).length, fail=r.results.length-ok;
|
|
737
619
|
toast(`Created ${ok} secret${ok!==1?'s':''}${fail?`, ${fail} failed`:''}`);
|
|
738
620
|
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?'…':''}).`);
|
|
621
|
+
banner(`Create: ${ok} ok, ${fail} failed (${f.slice(0,3).map(x=>esc(x.name+': '+x.error)).join('; ')}${fail>3?'…':''}).`);
|
|
740
622
|
setTimeout(()=>banner(''),9000); }
|
|
741
623
|
closeCreate(); setTimeout(loadKind, 350);
|
|
742
624
|
}
|
|
@@ -747,18 +629,17 @@ function toggleEdit(nm){
|
|
|
747
629
|
const cert = DRW.kind==='certificates';
|
|
748
630
|
const exp = it.expires?it.expires.slice(0,10):'';
|
|
749
631
|
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>
|
|
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>
|
|
751
633
|
${cert
|
|
752
634
|
? `<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>
|
|
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>
|
|
755
637
|
<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>`;
|
|
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>`;
|
|
759
641
|
el.classList.remove('hide');
|
|
760
642
|
if(!cert){
|
|
761
|
-
const vEl=document.getElementById('edVal-'+nm);
|
|
762
643
|
api(`/api/vaults/${DRW.vault}/secrets/${nm}/value`).then(r=>{
|
|
763
644
|
const live=document.getElementById('edVal-'+nm); if(!live) return;
|
|
764
645
|
if(r.ok){ live.value=r.data.value||''; live.dataset.orig=r.data.value||''; live.placeholder='secret value'; }
|
|
@@ -807,123 +688,208 @@ async function batchApply(action){
|
|
|
807
688
|
const ok=r.results.filter(x=>x.ok).length, fail=r.results.length-ok;
|
|
808
689
|
toast(`${ok} updated${fail?`, ${fail} failed`:''}`);
|
|
809
690
|
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.`);
|
|
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.`);
|
|
811
692
|
setTimeout(()=>banner(''),8000); }
|
|
812
|
-
DRW.sel.clear(); $('#dSelAll').checked=false;
|
|
693
|
+
DRW.sel.clear(); if($('#dSelAll'))$('#dSelAll').checked=false;
|
|
813
694
|
setTimeout(loadKind, 350);
|
|
814
695
|
}
|
|
815
|
-
// Reveal a secret value on its own full-width line below the row (toggles).
|
|
816
696
|
async function reveal(name){
|
|
817
|
-
const box = document.getElementById('rv-'+name);
|
|
818
|
-
if(!box) return;
|
|
697
|
+
const box = document.getElementById('rv-'+name); if(!box) return;
|
|
819
698
|
if(!box.classList.contains('hide')){ box.classList.add('hide'); box.innerHTML=''; return; }
|
|
820
699
|
box.classList.remove('hide'); box.innerHTML = '<span class="spin"></span>';
|
|
821
700
|
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; }
|
|
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; }
|
|
823
702
|
const val = r.data.value || '';
|
|
824
|
-
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>`;
|
|
825
734
|
}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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');
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// ---------- EXPIRY ----------
|
|
764
|
+
let EXP = { days: 30, items: [], allItems: [], stats: null, scannedAt: 0, sort: 'daysLeft', dir: 1, scanned: false, polling: false };
|
|
829
765
|
const ROW_CAP = 500;
|
|
830
|
-
function
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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)}));
|
|
834
772
|
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
773
|
}
|
|
839
|
-
//
|
|
840
|
-
async function
|
|
774
|
+
// Kick a background scan early so the dashboard's lifecycle cards fill in.
|
|
775
|
+
async function kickExpiryScan(){
|
|
841
776
|
if(EXP.scanned || EXP.polling) return;
|
|
842
|
-
const s = await api(`/api/expiring/status?days
|
|
843
|
-
if(s.ok && s.running){ EXP.polling=true; pollExpiring(); return; }
|
|
844
|
-
if(s.ok && s.scannedAt){
|
|
845
|
-
|
|
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(); }
|
|
846
814
|
}
|
|
847
815
|
async function scanExpiring(){
|
|
848
816
|
if(EXP.polling) return;
|
|
849
|
-
$('#expScan').disabled = true;
|
|
850
817
|
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>`;
|
|
852
|
-
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;
|
|
853
820
|
pollExpiring();
|
|
854
821
|
}
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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;
|
|
858
837
|
if(s.running){
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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);
|
|
838
|
+
if($('#expScan')) $('#expScan').textContent = `Scanning ${s.done}/${s.total}`;
|
|
839
|
+
renderScanProgress();
|
|
840
|
+
setTimeout(()=>pollExpiring(silent), 1500);
|
|
869
841
|
return;
|
|
870
842
|
}
|
|
871
|
-
EXP.polling
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
renderExpiring();
|
|
878
|
-
}
|
|
879
|
-
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){
|
|
880
849
|
const k=EXP.sort, d=EXP.dir;
|
|
881
|
-
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;});
|
|
882
851
|
}
|
|
883
852
|
function renderExpiring(){
|
|
884
|
-
|
|
853
|
+
if(STATE.route!=='expiry' || !$('#expCards')) return;
|
|
854
|
+
const items = summarize(EXP.allItems, EXP.days);
|
|
885
855
|
const expired = items.filter(i=>i.daysLeft<0).length;
|
|
886
856
|
const soon = items.filter(i=>i.daysLeft>=0&&i.daysLeft<=7).length;
|
|
887
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}` : '';
|
|
888
860
|
$('#expCards').innerHTML = [
|
|
889
|
-
card('Flagged', items.length, `within ${EXP.days} days`,
|
|
861
|
+
card('Flagged', items.length, `within ${EXP.days} days`, ''),
|
|
890
862
|
card('Expired', expired, 'needs rotation', expired?'alert':''),
|
|
891
863
|
card('≤ 7 days', soon, 'urgent', soon?'warn':''),
|
|
892
864
|
card('Certificates', certs, (items.length-certs)+' secrets', 'azure'),
|
|
893
865
|
].join('');
|
|
894
|
-
renderHorizon();
|
|
866
|
+
renderHorizon(items);
|
|
895
867
|
if(!items.length){
|
|
896
|
-
$('#expHorizon').classList.add('hide');
|
|
897
|
-
$('#expTableNote').textContent='';
|
|
868
|
+
$('#expHorizon').classList.add('hide'); $('#expTableNote').textContent='';
|
|
898
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>`;
|
|
899
870
|
return;
|
|
900
871
|
}
|
|
901
|
-
const list = expSorted();
|
|
902
|
-
const shown = list.slice(0, ROW_CAP);
|
|
872
|
+
const list = expSorted(items); const shown = list.slice(0, ROW_CAP);
|
|
903
873
|
$('#expTableNote').textContent = list.length>ROW_CAP ? `Showing first ${ROW_CAP} of ${list.length} — narrow the window or sort to see the rest.` : '';
|
|
904
874
|
$('#expBody').innerHTML = shown.map(i=>{
|
|
905
875
|
const sev = i.daysLeft<0?'bad':i.daysLeft<=7?'bad':i.daysLeft<=30?'warn':'good';
|
|
906
876
|
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>';
|
|
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>';
|
|
910
878
|
return `<tr>
|
|
911
879
|
<td><span class="dot" style="background:var(--${sev})"></span> <b>${label}</b></td>
|
|
912
880
|
<td class="mono muted">${(i.expires||'').slice(0,10)}</td>
|
|
913
881
|
<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>
|
|
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>
|
|
917
885
|
<td>${status}</td>
|
|
918
|
-
<td class="right"><button class="iconbtn" onclick="openVault('${i.vault}'
|
|
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>
|
|
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>
|
|
920
888
|
</tr>`;
|
|
921
889
|
}).join('');
|
|
922
890
|
}
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
const el = $('#expHorizon');
|
|
926
|
-
const items = EXP.items;
|
|
891
|
+
function renderHorizon(items){
|
|
892
|
+
const el = $('#expHorizon'); if(!el) return;
|
|
927
893
|
if(!items.length){ el.classList.add('hide'); el.innerHTML=''; return; }
|
|
928
894
|
el.classList.remove('hide');
|
|
929
895
|
const days = EXP.days;
|
|
@@ -935,11 +901,11 @@ function renderHorizon(){
|
|
|
935
901
|
const ticks = future.map(i=>{
|
|
936
902
|
const left = Math.min(99.6, Math.max(.4, i.daysLeft/days*100));
|
|
937
903
|
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>`;
|
|
904
|
+
return `<div class="hz-tick ${sev}" style="left:${left}%" title="${esc(i.name)} · ${esc(i.vault)} · in ${i.daysLeft}d"></div>`;
|
|
939
905
|
}).join('');
|
|
940
906
|
const expTicks = expired.slice(0,140).map((i,idx,arr)=>{
|
|
941
907
|
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>`;
|
|
908
|
+
return `<div class="hz-tick alert" style="left:${left}%" title="${esc(i.name)} · ${esc(i.vault)} · expired ${-i.daysLeft}d ago"></div>`;
|
|
943
909
|
}).join('');
|
|
944
910
|
el.innerHTML = `
|
|
945
911
|
<div class="hz-head">
|
|
@@ -947,54 +913,110 @@ function renderHorizon(){
|
|
|
947
913
|
<div class="hz-legend">
|
|
948
914
|
<span><i style="background:var(--alert)"></i>≤7d / expired</span>
|
|
949
915
|
<span><i style="background:var(--warn)"></i>≤30d</span>
|
|
950
|
-
<span><i style="background:var(--ok)"></i>beyond</span>
|
|
951
|
-
</div>
|
|
916
|
+
<span><i style="background:var(--ok)"></i>beyond</span></div>
|
|
952
917
|
</div>
|
|
953
918
|
<div class="hz-body">
|
|
954
919
|
<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>
|
|
920
|
+
<div class="hz-expired-n">${expired.length}</div><div class="hz-expired-l">EXPIRED</div>${expTicks}</div>
|
|
959
921
|
<div class="hz-axis">${grid}${ticks}</div>
|
|
960
922
|
</div>`;
|
|
961
923
|
}
|
|
962
924
|
|
|
963
|
-
// ----------
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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');
|
|
951
|
+
}
|
|
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
|
+
];
|
|
978
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(); }); }
|
|
979
1002
|
|
|
980
1003
|
// ---------- events ----------
|
|
981
|
-
document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>
|
|
982
|
-
$('#refresh').onclick=
|
|
983
|
-
$('#
|
|
984
|
-
$('#
|
|
985
|
-
$('#
|
|
986
|
-
$('#
|
|
987
|
-
$('#
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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
|
+
});
|
|
995
1019
|
|
|
996
|
-
const initView = new URLSearchParams(location.search).get('view');
|
|
997
|
-
if(initView && META[initView]) show(initView);
|
|
998
1020
|
boot();
|
|
999
1021
|
</script>
|
|
1000
1022
|
</body>
|