@openqa/cli 2.1.1 → 2.1.3

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.
@@ -5,672 +5,860 @@ function getEnvHTML() {
5
5
  <head>
6
6
  <meta charset="UTF-8">
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
8
- <title>Environment Variables - OpenQA</title>
8
+ <title>OpenQA \u2014 Environment</title>
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet">
9
11
  <style>
10
- * { margin: 0; padding: 0; box-sizing: border-box; }
11
-
12
+ :root {
13
+ --bg: #080b10;
14
+ --surface: #0d1117;
15
+ --panel: #111720;
16
+ --border: rgba(255,255,255,0.06);
17
+ --border-hi: rgba(255,255,255,0.12);
18
+ --accent: #f97316;
19
+ --accent-lo: rgba(249,115,22,0.08);
20
+ --accent-md: rgba(249,115,22,0.18);
21
+ --green: #22c55e;
22
+ --green-lo: rgba(34,197,94,0.08);
23
+ --red: #ef4444;
24
+ --red-lo: rgba(239,68,68,0.08);
25
+ --amber: #f59e0b;
26
+ --amber-lo: rgba(245,158,11,0.08);
27
+ --blue: #38bdf8;
28
+ --blue-lo: rgba(56,189,248,0.08);
29
+ --text-1: #f1f5f9;
30
+ --text-2: #8b98a8;
31
+ --text-3: #4b5563;
32
+ --mono: 'DM Mono', monospace;
33
+ --sans: 'Syne', sans-serif;
34
+ --radius: 10px;
35
+ --radius-lg: 16px;
36
+ }
37
+
38
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
39
+
12
40
  body {
13
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
14
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
41
+ font-family: var(--sans);
42
+ background: var(--bg);
43
+ color: var(--text-1);
15
44
  min-height: 100vh;
16
- padding: 20px;
45
+ overflow-x: hidden;
17
46
  }
18
47
 
19
- .container {
20
- max-width: 1200px;
21
- margin: 0 auto;
48
+ /* \u2500\u2500 Layout \u2500\u2500 */
49
+ .shell {
50
+ display: grid;
51
+ grid-template-columns: 220px 1fr;
52
+ min-height: 100vh;
22
53
  }
23
54
 
24
- .header {
25
- background: rgba(255, 255, 255, 0.95);
26
- backdrop-filter: blur(10px);
27
- padding: 20px 30px;
28
- border-radius: 12px;
29
- margin-bottom: 20px;
30
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
55
+ /* \u2500\u2500 Sidebar \u2500\u2500 */
56
+ aside {
57
+ background: var(--surface);
58
+ border-right: 1px solid var(--border);
31
59
  display: flex;
32
- justify-content: space-between;
33
- align-items: center;
60
+ flex-direction: column;
61
+ padding: 28px 0;
62
+ position: sticky;
63
+ top: 0;
64
+ height: 100vh;
34
65
  }
35
66
 
36
- .header h1 {
37
- font-size: 24px;
38
- color: #1a202c;
67
+ .logo {
39
68
  display: flex;
40
69
  align-items: center;
41
70
  gap: 10px;
71
+ padding: 0 24px 32px;
72
+ border-bottom: 1px solid var(--border);
73
+ margin-bottom: 12px;
42
74
  }
43
75
 
44
- .header-actions {
45
- display: flex;
46
- gap: 10px;
76
+ .logo-mark {
77
+ width: 34px; height: 34px;
78
+ background: var(--accent);
79
+ border-radius: 8px;
80
+ display: grid;
81
+ place-items: center;
82
+ font-size: 17px;
83
+ font-weight: 800;
84
+ color: #fff;
47
85
  }
48
86
 
49
- .btn {
50
- padding: 10px 20px;
51
- border: none;
52
- border-radius: 8px;
87
+ .logo-name { font-weight: 800; font-size: 18px; letter-spacing: -0.5px; }
88
+ .logo-version { font-family: var(--mono); font-size: 10px; color: var(--text-3); }
89
+
90
+ .nav-section { padding: 8px 12px; flex: 1; overflow-y: auto; }
91
+
92
+ .nav-label {
93
+ font-family: var(--mono);
94
+ font-size: 10px;
95
+ color: var(--text-3);
96
+ letter-spacing: 1.5px;
97
+ text-transform: uppercase;
98
+ padding: 0 12px;
99
+ margin: 16px 0 6px;
100
+ }
101
+
102
+ .nav-item {
103
+ display: flex;
104
+ align-items: center;
105
+ gap: 10px;
106
+ padding: 9px 12px;
107
+ border-radius: var(--radius);
108
+ color: var(--text-2);
109
+ text-decoration: none;
53
110
  font-size: 14px;
54
111
  font-weight: 600;
112
+ transition: all 0.15s ease;
55
113
  cursor: pointer;
56
- transition: all 0.2s;
57
- text-decoration: none;
58
- display: inline-flex;
59
- align-items: center;
60
- gap: 8px;
61
114
  }
115
+ .nav-item:hover { color: var(--text-1); background: var(--panel); }
116
+ .nav-item.active { color: var(--accent); background: var(--accent-lo); }
117
+ .nav-item .icon { font-size: 15px; width: 20px; text-align: center; }
62
118
 
63
- .btn-primary {
64
- background: #667eea;
65
- color: white;
119
+ .sidebar-footer {
120
+ padding: 16px 24px;
121
+ border-top: 1px solid var(--border);
66
122
  }
67
123
 
68
- .btn-primary:hover {
69
- background: #5568d3;
70
- transform: translateY(-1px);
71
- }
124
+ /* \u2500\u2500 Main \u2500\u2500 */
125
+ main { display: flex; flex-direction: column; min-height: 100vh; overflow-y: auto; }
72
126
 
73
- .btn-secondary {
74
- background: #e2e8f0;
75
- color: #4a5568;
127
+ .topbar {
128
+ display: flex;
129
+ align-items: center;
130
+ justify-content: space-between;
131
+ padding: 20px 32px;
132
+ border-bottom: 1px solid var(--border);
133
+ background: var(--surface);
134
+ position: sticky;
135
+ top: 0;
136
+ z-index: 10;
76
137
  }
77
138
 
78
- .btn-secondary:hover {
79
- background: #cbd5e0;
80
- }
139
+ .page-title { font-size: 15px; font-weight: 700; letter-spacing: -0.2px; }
140
+ .page-sub { font-family: var(--mono); font-size: 11px; color: var(--text-3); margin-top: 2px; }
81
141
 
82
- .btn-success {
83
- background: #48bb78;
84
- color: white;
85
- }
142
+ .topbar-actions { display: flex; align-items: center; gap: 10px; }
86
143
 
87
- .btn-success:hover {
88
- background: #38a169;
144
+ .btn {
145
+ font-family: var(--sans);
146
+ font-weight: 700;
147
+ font-size: 12px;
148
+ padding: 8px 16px;
149
+ border-radius: 8px;
150
+ border: none;
151
+ cursor: pointer;
152
+ transition: all 0.15s ease;
153
+ display: inline-flex;
154
+ align-items: center;
155
+ gap: 6px;
156
+ text-decoration: none;
89
157
  }
158
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; }
90
159
 
91
- .btn:disabled {
92
- opacity: 0.5;
93
- cursor: not-allowed;
160
+ .btn-ghost {
161
+ background: var(--panel);
162
+ color: var(--text-2);
163
+ border: 1px solid var(--border);
94
164
  }
165
+ .btn-ghost:hover { border-color: var(--border-hi); color: var(--text-1); }
95
166
 
96
- .content {
97
- background: rgba(255, 255, 255, 0.95);
98
- backdrop-filter: blur(10px);
99
- padding: 30px;
100
- border-radius: 12px;
101
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
167
+ .btn-primary {
168
+ background: var(--accent);
169
+ color: #fff;
102
170
  }
171
+ .btn-primary:hover:not(:disabled) { background: #ea580c; box-shadow: 0 0 20px rgba(249,115,22,0.35); }
172
+
173
+ /* \u2500\u2500 Content \u2500\u2500 */
174
+ .content { padding: 28px 32px; display: flex; flex-direction: column; gap: 24px; }
103
175
 
104
- .tabs {
176
+ /* \u2500\u2500 Tabs (category selector) \u2500\u2500 */
177
+ .tab-bar {
105
178
  display: flex;
106
- gap: 10px;
107
- margin-bottom: 30px;
108
- border-bottom: 2px solid #e2e8f0;
109
- padding-bottom: 10px;
179
+ gap: 4px;
180
+ background: var(--surface);
181
+ border: 1px solid var(--border);
182
+ border-radius: 10px;
183
+ padding: 4px;
184
+ flex-wrap: wrap;
110
185
  }
111
186
 
112
- .tab {
113
- padding: 10px 20px;
187
+ .tab-btn {
188
+ padding: 7px 14px;
189
+ background: transparent;
114
190
  border: none;
115
- background: none;
116
- font-size: 14px;
191
+ border-radius: 7px;
192
+ color: var(--text-3);
193
+ font-family: var(--sans);
194
+ font-size: 12px;
117
195
  font-weight: 600;
118
- color: #718096;
119
196
  cursor: pointer;
120
- border-bottom: 3px solid transparent;
121
- transition: all 0.2s;
122
- }
123
-
124
- .tab.active {
125
- color: #667eea;
126
- border-bottom-color: #667eea;
197
+ transition: all 0.15s ease;
198
+ white-space: nowrap;
199
+ display: flex;
200
+ align-items: center;
201
+ gap: 5px;
127
202
  }
128
-
129
- .tab:hover {
130
- color: #667eea;
203
+ .tab-btn:hover { color: var(--text-2); }
204
+ .tab-btn.active {
205
+ background: var(--panel);
206
+ color: var(--text-1);
207
+ border: 1px solid var(--border-hi);
131
208
  }
132
-
133
- .category-section {
134
- display: none;
209
+ .tab-btn .tab-dot {
210
+ width: 6px; height: 6px;
211
+ border-radius: 50%;
212
+ background: var(--text-3);
135
213
  }
214
+ .tab-btn.has-required .tab-dot { background: var(--amber); }
215
+ .tab-btn.active .tab-dot { background: var(--accent); }
136
216
 
137
- .category-section.active {
138
- display: block;
139
- }
217
+ /* \u2500\u2500 Section \u2500\u2500 */
218
+ .section { display: none; flex-direction: column; gap: 16px; }
219
+ .section.active { display: flex; }
140
220
 
141
- .category-header {
221
+ .section-header {
142
222
  display: flex;
143
- justify-content: space-between;
144
223
  align-items: center;
145
- margin-bottom: 20px;
146
- }
147
-
148
- .category-title {
149
- font-size: 18px;
150
- font-weight: 600;
151
- color: #2d3748;
152
- }
153
-
154
- .env-grid {
155
- display: grid;
156
- gap: 20px;
224
+ gap: 12px;
225
+ margin-bottom: 4px;
157
226
  }
158
-
159
- .env-item {
160
- border: 1px solid #e2e8f0;
227
+ .section-icon {
228
+ width: 36px; height: 36px;
229
+ background: var(--accent-lo);
230
+ border: 1px solid var(--accent-md);
161
231
  border-radius: 8px;
162
- padding: 20px;
163
- transition: all 0.2s;
232
+ display: grid;
233
+ place-items: center;
234
+ font-size: 16px;
164
235
  }
236
+ .section-title { font-size: 15px; font-weight: 700; }
237
+ .section-desc { font-family: var(--mono); font-size: 11px; color: var(--text-3); margin-top: 2px; }
165
238
 
166
- .env-item:hover {
167
- border-color: #cbd5e0;
168
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
239
+ /* \u2500\u2500 Env card \u2500\u2500 */
240
+ .env-card {
241
+ background: var(--panel);
242
+ border: 1px solid var(--border);
243
+ border-radius: var(--radius-lg);
244
+ padding: 20px 24px;
245
+ transition: border-color 0.15s;
169
246
  }
247
+ .env-card:hover { border-color: var(--border-hi); }
248
+ .env-card.has-value { border-color: rgba(249,115,22,0.15); }
170
249
 
171
- .env-item-header {
250
+ .env-card-head {
172
251
  display: flex;
173
252
  justify-content: space-between;
174
253
  align-items: flex-start;
175
- margin-bottom: 10px;
254
+ margin-bottom: 6px;
176
255
  }
177
256
 
178
- .env-label {
179
- font-weight: 600;
180
- color: #2d3748;
181
- font-size: 14px;
257
+ .env-key {
258
+ font-family: var(--mono);
259
+ font-size: 13px;
260
+ font-weight: 500;
261
+ color: var(--text-1);
182
262
  display: flex;
183
263
  align-items: center;
184
264
  gap: 8px;
185
265
  }
186
266
 
187
- .required-badge {
188
- background: #fc8181;
189
- color: white;
190
- font-size: 10px;
191
- padding: 2px 6px;
267
+ .badge-required {
268
+ font-family: var(--sans);
269
+ font-size: 9px;
270
+ font-weight: 700;
271
+ letter-spacing: 0.08em;
272
+ text-transform: uppercase;
273
+ background: rgba(239,68,68,0.15);
274
+ color: var(--red);
275
+ border: 1px solid rgba(239,68,68,0.25);
192
276
  border-radius: 4px;
277
+ padding: 2px 6px;
278
+ }
279
+ .badge-sensitive {
280
+ font-size: 9px;
193
281
  font-weight: 700;
282
+ font-family: var(--sans);
283
+ letter-spacing: 0.08em;
284
+ text-transform: uppercase;
285
+ background: var(--amber-lo);
286
+ color: var(--amber);
287
+ border: 1px solid rgba(245,158,11,0.2);
288
+ border-radius: 4px;
289
+ padding: 2px 6px;
194
290
  }
195
291
 
196
- .env-description {
197
- font-size: 13px;
198
- color: #718096;
199
- margin-bottom: 10px;
292
+ .env-desc {
293
+ font-family: var(--mono);
294
+ font-size: 11px;
295
+ color: var(--text-3);
296
+ margin-bottom: 14px;
297
+ line-height: 1.5;
200
298
  }
201
299
 
202
- .env-input-group {
300
+ .env-input-row {
203
301
  display: flex;
204
- gap: 10px;
302
+ gap: 8px;
205
303
  align-items: center;
206
304
  }
207
305
 
208
- .env-input {
306
+ .env-input, .env-select {
209
307
  flex: 1;
210
- padding: 10px 12px;
211
- border: 1px solid #e2e8f0;
212
- border-radius: 6px;
213
- font-size: 14px;
214
- font-family: 'Monaco', 'Courier New', monospace;
215
- transition: all 0.2s;
216
- }
217
-
218
- .env-input:focus {
308
+ background: var(--surface);
309
+ border: 1px solid var(--border-hi);
310
+ border-radius: 8px;
311
+ padding: 10px 14px;
312
+ font-family: var(--mono);
313
+ font-size: 13px;
314
+ color: var(--text-1);
219
315
  outline: none;
220
- border-color: #667eea;
221
- box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
316
+ transition: border-color 0.15s, box-shadow 0.15s;
317
+ appearance: none;
222
318
  }
223
-
224
- .env-input.error {
225
- border-color: #fc8181;
319
+ .env-input:focus, .env-select:focus {
320
+ border-color: var(--accent);
321
+ box-shadow: 0 0 0 3px rgba(249,115,22,0.12);
226
322
  }
323
+ .env-input.changed { border-color: rgba(249,115,22,0.5); }
324
+ .env-input.invalid { border-color: var(--red); }
227
325
 
228
- .env-actions {
229
- display: flex;
230
- gap: 5px;
231
- }
326
+ .env-select option { background: var(--panel); }
232
327
 
233
- .icon-btn {
234
- padding: 8px;
235
- border: none;
236
- background: #e2e8f0;
237
- border-radius: 6px;
328
+ .env-action-btn {
329
+ width: 36px; height: 36px;
330
+ border-radius: 8px;
331
+ border: 1px solid var(--border-hi);
332
+ background: var(--surface);
333
+ color: var(--text-2);
238
334
  cursor: pointer;
239
- transition: all 0.2s;
240
- font-size: 16px;
241
- }
242
-
243
- .icon-btn:hover {
244
- background: #cbd5e0;
245
- }
246
-
247
- .icon-btn.test {
248
- background: #bee3f8;
249
- color: #2c5282;
250
- }
251
-
252
- .icon-btn.test:hover {
253
- background: #90cdf4;
254
- }
255
-
256
- .icon-btn.generate {
257
- background: #c6f6d5;
258
- color: #22543d;
259
- }
260
-
261
- .icon-btn.generate:hover {
262
- background: #9ae6b4;
335
+ display: grid;
336
+ place-items: center;
337
+ font-size: 14px;
338
+ transition: all 0.15s;
339
+ flex-shrink: 0;
263
340
  }
341
+ .env-action-btn:hover { background: var(--panel); color: var(--text-1); border-color: var(--border-hi); }
342
+ .env-action-btn.test-btn:hover { background: var(--blue-lo); color: var(--blue); border-color: rgba(56,189,248,0.25); }
343
+ .env-action-btn.gen-btn:hover { background: var(--green-lo); color: var(--green); border-color: rgba(34,197,94,0.25); }
264
344
 
265
- .error-message {
266
- color: #e53e3e;
267
- font-size: 12px;
268
- margin-top: 5px;
345
+ .env-feedback {
346
+ font-family: var(--mono);
347
+ font-size: 11px;
348
+ margin-top: 8px;
349
+ min-height: 16px;
269
350
  }
351
+ .env-feedback.error { color: var(--red); }
352
+ .env-feedback.success { color: var(--green); }
270
353
 
271
- .success-message {
272
- color: #38a169;
273
- font-size: 12px;
274
- margin-top: 5px;
354
+ /* \u2500\u2500 Toast \u2500\u2500 */
355
+ .toast-zone {
356
+ position: fixed;
357
+ bottom: 24px;
358
+ right: 24px;
359
+ display: flex;
360
+ flex-direction: column;
361
+ gap: 8px;
362
+ z-index: 100;
275
363
  }
276
364
 
277
- .alert {
278
- padding: 15px 20px;
279
- border-radius: 8px;
280
- margin-bottom: 20px;
365
+ .toast {
366
+ padding: 12px 18px;
367
+ border-radius: 10px;
368
+ font-size: 13px;
369
+ font-weight: 600;
281
370
  display: flex;
282
371
  align-items: center;
283
372
  gap: 10px;
373
+ animation: slideIn 0.2s ease;
374
+ max-width: 380px;
284
375
  }
376
+ .toast.success { background: var(--panel); border: 1px solid rgba(34,197,94,0.3); color: var(--green); }
377
+ .toast.error { background: var(--panel); border: 1px solid rgba(239,68,68,0.3); color: var(--red); }
378
+ .toast.warning { background: var(--panel); border: 1px solid rgba(245,158,11,0.3); color: var(--amber); }
379
+ .toast.info { background: var(--panel); border: 1px solid rgba(56,189,248,0.3); color: var(--blue); }
285
380
 
286
- .alert-warning {
287
- background: #fef5e7;
288
- border-left: 4px solid #f59e0b;
289
- color: #92400e;
290
- }
291
-
292
- .alert-info {
293
- background: #eff6ff;
294
- border-left: 4px solid #3b82f6;
295
- color: #1e40af;
381
+ @keyframes slideIn {
382
+ from { opacity: 0; transform: translateY(8px); }
383
+ to { opacity: 1; transform: translateY(0); }
296
384
  }
297
385
 
298
- .alert-success {
299
- background: #f0fdf4;
300
- border-left: 4px solid #10b981;
301
- color: #065f46;
386
+ /* \u2500\u2500 Modal (test result) \u2500\u2500 */
387
+ .modal-backdrop {
388
+ display: none;
389
+ position: fixed; inset: 0;
390
+ background: rgba(0,0,0,0.6);
391
+ z-index: 200;
392
+ align-items: center;
393
+ justify-content: center;
302
394
  }
395
+ .modal-backdrop.open { display: flex; }
303
396
 
304
- .loading {
305
- text-align: center;
306
- padding: 40px;
307
- color: #718096;
397
+ .modal {
398
+ background: var(--surface);
399
+ border: 1px solid var(--border-hi);
400
+ border-radius: var(--radius-lg);
401
+ padding: 28px;
402
+ width: 420px;
403
+ max-width: 90vw;
404
+ box-shadow: 0 24px 64px rgba(0,0,0,0.5);
405
+ }
406
+ .modal-title { font-size: 15px; font-weight: 700; margin-bottom: 16px; }
407
+ .modal-body { margin-bottom: 20px; }
408
+ .modal-result {
409
+ padding: 14px;
410
+ border-radius: 8px;
411
+ font-family: var(--mono);
412
+ font-size: 12px;
308
413
  }
414
+ .modal-result.ok { background: var(--green-lo); border: 1px solid rgba(34,197,94,0.2); color: var(--green); }
415
+ .modal-result.fail { background: var(--red-lo); border: 1px solid rgba(239,68,68,0.2); color: var(--red); }
416
+ .modal-footer { display: flex; justify-content: flex-end; }
309
417
 
418
+ /* \u2500\u2500 Spinner \u2500\u2500 */
310
419
  .spinner {
311
- border: 3px solid #e2e8f0;
312
- border-top: 3px solid #667eea;
420
+ width: 36px; height: 36px;
421
+ border: 3px solid var(--border);
422
+ border-top-color: var(--accent);
313
423
  border-radius: 50%;
314
- width: 40px;
315
- height: 40px;
316
- animation: spin 1s linear infinite;
317
- margin: 0 auto 20px;
424
+ animation: spin 0.8s linear infinite;
425
+ margin: 0 auto 16px;
318
426
  }
427
+ @keyframes spin { to { transform: rotate(360deg); } }
319
428
 
320
- @keyframes spin {
321
- 0% { transform: rotate(0deg); }
322
- 100% { transform: rotate(360deg); }
429
+ .loading-state {
430
+ text-align: center;
431
+ padding: 60px 0;
432
+ color: var(--text-3);
433
+ font-family: var(--mono);
434
+ font-size: 12px;
323
435
  }
324
436
 
325
- .modal {
437
+ /* \u2500\u2500 Restart banner \u2500\u2500 */
438
+ .restart-banner {
326
439
  display: none;
327
- position: fixed;
328
- top: 0;
329
- left: 0;
330
- right: 0;
331
- bottom: 0;
332
- background: rgba(0, 0, 0, 0.5);
333
- z-index: 1000;
334
- align-items: center;
335
- justify-content: center;
336
- }
337
-
338
- .modal.show {
339
- display: flex;
340
- }
341
-
342
- .modal-content {
343
- background: white;
344
- padding: 30px;
345
- border-radius: 12px;
346
- max-width: 500px;
347
- width: 90%;
348
- box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
349
- }
350
-
351
- .modal-header {
352
- font-size: 20px;
440
+ background: var(--amber-lo);
441
+ border: 1px solid rgba(245,158,11,0.25);
442
+ border-radius: 10px;
443
+ padding: 12px 18px;
444
+ font-size: 13px;
445
+ color: var(--amber);
353
446
  font-weight: 600;
354
- margin-bottom: 15px;
355
- color: #2d3748;
356
- }
357
-
358
- .modal-body {
359
- margin-bottom: 20px;
360
- color: #4a5568;
361
- }
362
-
363
- .modal-footer {
364
- display: flex;
447
+ align-items: center;
365
448
  gap: 10px;
366
- justify-content: flex-end;
367
449
  }
450
+ .restart-banner.show { display: flex; }
368
451
  </style>
369
452
  </head>
370
453
  <body>
371
- <div class="container">
372
- <div class="header">
373
- <h1>
374
- <span>\u2699\uFE0F</span>
454
+ <div class="shell">
455
+
456
+ <!-- Sidebar -->
457
+ <aside>
458
+ <div class="logo">
459
+ <div class="logo-mark">Q</div>
460
+ <div>
461
+ <div class="logo-name">OpenQA</div>
462
+ <div class="logo-version">v1.3.4</div>
463
+ </div>
464
+ </div>
465
+
466
+ <div class="nav-section">
467
+ <div class="nav-label">Overview</div>
468
+ <a class="nav-item" href="/">
469
+ <span class="icon">\u{1F4CA}</span> Dashboard
470
+ </a>
471
+ <a class="nav-item" href="/sessions">
472
+ <span class="icon">\u{1F9EA}</span> Sessions
473
+ </a>
474
+ <a class="nav-item" href="/issues">
475
+ <span class="icon">\u{1F41B}</span> Issues
476
+ </a>
477
+
478
+ <div class="nav-label">Testing</div>
479
+ <a class="nav-item" href="/tests">
480
+ <span class="icon">\u26A1</span> Tests
481
+ </a>
482
+ <a class="nav-item" href="/coverage">
483
+ <span class="icon">\u{1F4C8}</span> Coverage
484
+ </a>
485
+ <a class="nav-item" href="/kanban">
486
+ <span class="icon">\u{1F4CB}</span> Kanban
487
+ </a>
488
+
489
+ <div class="nav-label">System</div>
490
+ <a class="nav-item" href="/config">
491
+ <span class="icon">\u2699\uFE0F</span> Config
492
+ </a>
493
+ <a class="nav-item active" href="/config/env">
494
+ <span class="icon">\u{1F527}</span> Environment
495
+ </a>
496
+ <a class="nav-item" href="/logs">
497
+ <span class="icon">\u{1F4DC}</span> Logs
498
+ </a>
499
+ </div>
500
+
501
+ <div class="sidebar-footer">
502
+ <div style="font-family:var(--mono);font-size:11px;color:var(--text-3);">
375
503
  Environment Variables
376
- </h1>
377
- <div class="header-actions">
378
- <a href="/config" class="btn btn-secondary">\u2190 Back to Config</a>
379
- <button id="saveBtn" class="btn btn-success" disabled>\u{1F4BE} Save Changes</button>
504
+ </div>
505
+ </div>
506
+ </aside>
507
+
508
+ <!-- Main -->
509
+ <main>
510
+ <div class="topbar">
511
+ <div>
512
+ <div class="page-title">Environment Variables</div>
513
+ <div class="page-sub">Configure runtime variables for OpenQA</div>
514
+ </div>
515
+ <div class="topbar-actions">
516
+ <a class="btn btn-ghost" href="/config">\u2190 Back to Config</a>
517
+ <button id="saveBtn" class="btn btn-primary" disabled>
518
+ \u{1F4BE} Save Changes
519
+ </button>
380
520
  </div>
381
521
  </div>
382
522
 
383
523
  <div class="content">
384
- <div id="loading" class="loading">
524
+
525
+ <!-- Restart banner -->
526
+ <div class="restart-banner" id="restartBanner">
527
+ \u26A0\uFE0F Some changes require a server restart to take effect.
528
+ </div>
529
+
530
+ <!-- Loading -->
531
+ <div class="loading-state" id="loadingState">
385
532
  <div class="spinner"></div>
386
- <div>Loading environment variables...</div>
533
+ Loading environment variables\u2026
387
534
  </div>
388
535
 
389
- <div id="main" style="display: none;">
390
- <div id="alerts"></div>
391
-
392
- <div class="tabs">
393
- <button class="tab active" data-category="llm">\u{1F916} LLM</button>
394
- <button class="tab" data-category="security">\u{1F512} Security</button>
395
- <button class="tab" data-category="target">\u{1F3AF} Target App</button>
396
- <button class="tab" data-category="github">\u{1F419} GitHub</button>
397
- <button class="tab" data-category="web">\u{1F310} Web Server</button>
398
- <button class="tab" data-category="agent">\u{1F916} Agent</button>
399
- <button class="tab" data-category="database">\u{1F4BE} Database</button>
400
- <button class="tab" data-category="notifications">\u{1F514} Notifications</button>
401
- </div>
536
+ <!-- Main content (hidden while loading) -->
537
+ <div id="mainContent" style="display:none;flex-direction:column;gap:24px;">
538
+
539
+ <!-- Tab bar -->
540
+ <div class="tab-bar" id="tabBar"></div>
541
+
542
+ <!-- Sections -->
543
+ <div id="sections"></div>
402
544
 
403
- <div id="categories"></div>
404
545
  </div>
405
546
  </div>
547
+ </main>
548
+ </div>
549
+
550
+ <!-- Test result modal -->
551
+ <div class="modal-backdrop" id="testModal">
552
+ <div class="modal">
553
+ <div class="modal-title">Connection Test</div>
554
+ <div class="modal-body">
555
+ <div class="modal-result" id="testResultBox">\u2026</div>
556
+ </div>
557
+ <div class="modal-footer">
558
+ <button class="btn btn-ghost" onclick="closeModal()">Close</button>
559
+ </div>
406
560
  </div>
561
+ </div>
562
+
563
+ <!-- Toast zone -->
564
+ <div class="toast-zone" id="toastZone"></div>
565
+
566
+ <script>
567
+ /* \u2500\u2500 State \u2500\u2500 */
568
+ let envVars = [];
569
+ let changed = {};
570
+ let hasRequiredMissing = false;
571
+
572
+ const TABS = [
573
+ { id: 'llm', label: '\u{1F916} LLM', desc: 'Language model provider & API keys' },
574
+ { id: 'security', label: '\u{1F512} Security', desc: 'Authentication & JWT configuration' },
575
+ { id: 'target', label: '\u{1F3AF} Target App', desc: 'Application under test settings' },
576
+ { id: 'github', label: '\u{1F419} GitHub', desc: 'Repository & CI/CD integration' },
577
+ { id: 'web', label: '\u{1F310} Web Server', desc: 'HTTP host, port & CORS settings' },
578
+ { id: 'agent', label: '\u{1F916} Agent', desc: 'Autonomous agent behaviour' },
579
+ { id: 'database', label: '\u{1F4BE} Database', desc: 'Persistence & storage' },
580
+ { id: 'notifications', label: '\u{1F514} Notifications', desc: 'Slack & Discord webhooks' },
581
+ ];
582
+
583
+ /* \u2500\u2500 Init \u2500\u2500 */
584
+ async function init() {
585
+ try {
586
+ const res = await fetch('/api/env');
587
+ if (!res.ok) { toast('error', 'Failed to load environment variables (status ' + res.status + ')'); return; }
588
+ const data = await res.json();
589
+ envVars = data.variables || [];
590
+ renderAll();
591
+ document.getElementById('loadingState').style.display = 'none';
592
+ const mc = document.getElementById('mainContent');
593
+ mc.style.display = 'flex';
594
+ } catch (e) {
595
+ toast('error', 'Network error \u2014 ' + e.message);
596
+ }
597
+ }
407
598
 
408
- <!-- Test Result Modal -->
409
- <div id="testModal" class="modal">
410
- <div class="modal-content">
411
- <div class="modal-header">Test Result</div>
412
- <div class="modal-body" id="testResult"></div>
413
- <div class="modal-footer">
414
- <button class="btn btn-secondary" onclick="closeTestModal()">Close</button>
599
+ /* \u2500\u2500 Render \u2500\u2500 */
600
+ function renderAll() {
601
+ renderTabBar();
602
+ renderSections();
603
+ activateTab(TABS[0].id);
604
+ }
605
+
606
+ function renderTabBar() {
607
+ const bar = document.getElementById('tabBar');
608
+ bar.innerHTML = TABS.map(t => {
609
+ const vars = envVars.filter(v => v.category === t.id);
610
+ const hasRequired = vars.some(v => v.required);
611
+ return \`<button class="tab-btn\${hasRequired ? ' has-required' : ''}" data-tab="\${t.id}" onclick="activateTab('\${t.id}')">
612
+ <span class="tab-dot"></span>
613
+ \${t.label}
614
+ </button>\`;
615
+ }).join('');
616
+ }
617
+
618
+ function renderSections() {
619
+ const container = document.getElementById('sections');
620
+ container.innerHTML = TABS.map(t => {
621
+ const vars = envVars.filter(v => v.category === t.id);
622
+ return \`<div class="section" id="section-\${t.id}">
623
+ <div class="section-header">
624
+ <div class="section-icon">\${t.label.split(' ')[0]}</div>
625
+ <div>
626
+ <div class="section-title">\${t.label.slice(t.label.indexOf(' ')+1)}</div>
627
+ <div class="section-desc">\${t.desc}</div>
628
+ </div>
629
+ </div>
630
+ \${vars.map(renderCard).join('')}
631
+ \${vars.length === 0 ? '<div style="color:var(--text-3);font-family:var(--mono);font-size:12px;padding:20px 0">No variables in this category.</div>' : ''}
632
+ </div>\`;
633
+ }).join('');
634
+ }
635
+
636
+ function renderCard(v) {
637
+ const displayVal = v.displayValue || '';
638
+ const isSensitive = v.sensitive;
639
+ const inputType = (v.type === 'password' && !changed[v.key]) ? 'password' : 'text';
640
+
641
+ let inputHTML = '';
642
+ if (v.type === 'select' || v.type === 'boolean') {
643
+ const opts = v.type === 'boolean'
644
+ ? [{ val: 'true', lbl: 'true' }, { val: 'false', lbl: 'false' }]
645
+ : (v.options || []).map(o => ({ val: o, lbl: o }));
646
+ inputHTML = \`<select class="env-select" data-key="\${v.key}" onchange="handleChange(this)">
647
+ <option value="">\u2014 Select \u2014</option>
648
+ \${opts.map(o => \`<option value="\${o.val}" \${displayVal === o.val ? 'selected' : ''}>\${o.lbl}</option>\`).join('')}
649
+ </select>\`;
650
+ } else {
651
+ inputHTML = \`<input
652
+ type="\${inputType}"
653
+ class="env-input"
654
+ data-key="\${v.key}"
655
+ value="\${escHtml(displayVal)}"
656
+ placeholder="\${escHtml(v.placeholder || '')}"
657
+ oninput="handleChange(this)"
658
+ autocomplete="off"
659
+ />\`;
660
+ }
661
+
662
+ const testBtn = v.testable
663
+ ? \`<button class="env-action-btn test-btn" onclick="testVar('\${v.key}')" title="Test connection">\u{1F9EA}</button>\`
664
+ : '';
665
+
666
+ const genBtn = v.key === 'OPENQA_JWT_SECRET'
667
+ ? \`<button class="env-action-btn gen-btn" onclick="generateSecret('\${v.key}')" title="Generate secret">\u{1F511}</button>\`
668
+ : '';
669
+
670
+ const toggleBtn = (v.type === 'password' || isSensitive)
671
+ ? \`<button class="env-action-btn" onclick="toggleVis('\${v.key}')" title="Toggle visibility" id="vis-\${v.key}">\u{1F441}</button>\`
672
+ : '';
673
+
674
+ return \`<div class="env-card\${displayVal ? ' has-value' : ''}" id="card-\${v.key}">
675
+ <div class="env-card-head">
676
+ <div class="env-key">
677
+ \${v.key}
678
+ \${v.required ? '<span class="badge-required">Required</span>' : ''}
679
+ \${isSensitive ? '<span class="badge-sensitive">Sensitive</span>' : ''}
415
680
  </div>
416
681
  </div>
417
- </div>
682
+ <div class="env-desc">\${v.description}</div>
683
+ <div class="env-input-row">
684
+ \${inputHTML}
685
+ \${toggleBtn}
686
+ \${testBtn}
687
+ \${genBtn}
688
+ </div>
689
+ <div class="env-feedback" id="fb-\${v.key}"></div>
690
+ </div>\`;
691
+ }
418
692
 
419
- <script>
420
- let envVariables = [];
421
- let changedVariables = {};
422
- let restartRequired = false;
423
-
424
- // Load environment variables
425
- async function loadEnvVariables() {
426
- try {
427
- const response = await fetch('/api/env');
428
- if (!response.ok) throw new Error('Failed to load variables');
429
-
430
- const data = await response.json();
431
- envVariables = data.variables;
432
-
433
- renderCategories();
434
- document.getElementById('loading').style.display = 'none';
435
- document.getElementById('main').style.display = 'block';
436
- } catch (error) {
437
- showAlert('error', 'Failed to load environment variables: ' + error.message);
438
- }
439
- }
693
+ /* \u2500\u2500 Tab switching \u2500\u2500 */
694
+ function activateTab(id) {
695
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === id));
696
+ document.querySelectorAll('.section').forEach(s => s.classList.toggle('active', s.id === 'section-' + id));
697
+ }
440
698
 
441
- // Render categories
442
- function renderCategories() {
443
- const container = document.getElementById('categories');
444
- const categories = [...new Set(envVariables.map(v => v.category))];
445
-
446
- categories.forEach((category, index) => {
447
- const section = document.createElement('div');
448
- section.className = 'category-section' + (index === 0 ? ' active' : '');
449
- section.dataset.category = category;
450
-
451
- const vars = envVariables.filter(v => v.category === category);
452
-
453
- section.innerHTML = \`
454
- <div class="category-header">
455
- <div class="category-title">\${getCategoryTitle(category)}</div>
456
- </div>
457
- <div class="env-grid">
458
- \${vars.map(v => renderEnvItem(v)).join('')}
459
- </div>
460
- \`;
461
-
462
- container.appendChild(section);
463
- });
464
- }
465
-
466
- // Render single env item
467
- function renderEnvItem(envVar) {
468
- const inputType = envVar.type === 'password' ? 'password' : 'text';
469
- const value = envVar.displayValue || '';
470
-
471
- return \`
472
- <div class="env-item" data-key="\${envVar.key}">
473
- <div class="env-item-header">
474
- <div class="env-label">
475
- \${envVar.key}
476
- \${envVar.required ? '<span class="required-badge">REQUIRED</span>' : ''}
477
- </div>
478
- </div>
479
- <div class="env-description">\${envVar.description}</div>
480
- <div class="env-input-group">
481
- \${envVar.type === 'select' ?
482
- \`<select class="env-input" data-key="\${envVar.key}" onchange="handleChange(this)">
483
- <option value="">-- Select --</option>
484
- \${envVar.options.map(opt =>
485
- \`<option value="\${opt}" \${value === opt ? 'selected' : ''}>\${opt}</option>\`
486
- ).join('')}
487
- </select>\` :
488
- envVar.type === 'boolean' ?
489
- \`<select class="env-input" data-key="\${envVar.key}" onchange="handleChange(this)">
490
- <option value="">-- Select --</option>
491
- <option value="true" \${value === 'true' ? 'selected' : ''}>true</option>
492
- <option value="false" \${value === 'false' ? 'selected' : ''}>false</option>
493
- </select>\` :
494
- \`<input
495
- type="\${inputType}"
496
- class="env-input"
497
- data-key="\${envVar.key}"
498
- value="\${value}"
499
- placeholder="\${envVar.placeholder || ''}"
500
- onchange="handleChange(this)"
501
- />\`
502
- }
503
- <div class="env-actions">
504
- \${envVar.testable ? \`<button class="icon-btn test" onclick="testVariable('\${envVar.key}')" title="Test">\u{1F9EA}</button>\` : ''}
505
- \${envVar.key === 'OPENQA_JWT_SECRET' ? \`<button class="icon-btn generate" onclick="generateSecret('\${envVar.key}')" title="Generate">\u{1F511}</button>\` : ''}
506
- </div>
507
- </div>
508
- <div class="error-message" id="error-\${envVar.key}"></div>
509
- <div class="success-message" id="success-\${envVar.key}"></div>
510
- </div>
511
- \`;
512
- }
513
-
514
- // Handle input change
515
- function handleChange(input) {
516
- const key = input.dataset.key;
517
- const value = input.value;
518
-
519
- changedVariables[key] = value;
520
- document.getElementById('saveBtn').disabled = false;
521
-
522
- // Clear messages
523
- document.getElementById(\`error-\${key}\`).textContent = '';
524
- document.getElementById(\`success-\${key}\`).textContent = '';
525
- }
526
-
527
- // Save changes
528
- async function saveChanges() {
529
- const saveBtn = document.getElementById('saveBtn');
530
- saveBtn.disabled = true;
531
- saveBtn.textContent = '\u{1F4BE} Saving...';
532
-
533
- try {
534
- const response = await fetch('/api/env/bulk', {
535
- method: 'POST',
536
- headers: { 'Content-Type': 'application/json' },
537
- body: JSON.stringify({ variables: changedVariables }),
538
- });
539
-
540
- if (!response.ok) {
541
- const error = await response.json();
542
- throw new Error(error.error || 'Failed to save');
543
- }
544
-
545
- const result = await response.json();
546
- restartRequired = result.restartRequired;
547
-
548
- showAlert('success', \`\u2705 Saved \${result.updated} variable(s) successfully!\` +
549
- (restartRequired ? ' \u26A0\uFE0F Restart required for changes to take effect.' : ''));
550
-
551
- changedVariables = {};
552
- saveBtn.textContent = '\u{1F4BE} Save Changes';
553
-
554
- // Reload to show updated values
555
- setTimeout(() => location.reload(), 2000);
556
- } catch (error) {
557
- showAlert('error', 'Failed to save: ' + error.message);
558
- saveBtn.disabled = false;
559
- saveBtn.textContent = '\u{1F4BE} Save Changes';
560
- }
561
- }
699
+ /* \u2500\u2500 Input handling \u2500\u2500 */
700
+ function handleChange(el) {
701
+ const key = el.dataset.key;
702
+ const val = el.value;
703
+ changed[key] = val;
704
+ el.classList.add('changed');
705
+ el.classList.remove('invalid');
706
+ clearFeedback(key);
707
+ document.getElementById('saveBtn').disabled = false;
708
+ }
562
709
 
563
- // Test variable
564
- async function testVariable(key) {
565
- const input = document.querySelector(\`[data-key="\${key}"]\`);
566
- const value = input.value;
567
-
568
- if (!value) {
569
- showAlert('warning', 'Please enter a value first');
570
- return;
571
- }
572
-
573
- try {
574
- const response = await fetch(\`/api/env/test/\${key}\`, {
575
- method: 'POST',
576
- headers: { 'Content-Type': 'application/json' },
577
- body: JSON.stringify({ value }),
578
- });
579
-
580
- const result = await response.json();
581
- showTestResult(result);
582
- } catch (error) {
583
- showTestResult({ success: false, message: 'Test failed: ' + error.message });
584
- }
585
- }
710
+ /* \u2500\u2500 Toggle password visibility \u2500\u2500 */
711
+ function toggleVis(key) {
712
+ const inp = document.querySelector('[data-key="' + key + '"]');
713
+ if (!inp || inp.tagName !== 'INPUT') return;
714
+ inp.type = inp.type === 'password' ? 'text' : 'password';
715
+ }
586
716
 
587
- // Generate secret
588
- async function generateSecret(key) {
589
- try {
590
- const response = await fetch(\`/api/env/generate/\${key}\`, {
591
- method: 'POST',
592
- });
593
-
594
- if (!response.ok) throw new Error('Failed to generate');
595
-
596
- const result = await response.json();
597
- const input = document.querySelector(\`[data-key="\${key}"]\`);
598
- input.value = result.value;
599
- handleChange(input);
600
-
601
- document.getElementById(\`success-\${key}\`).textContent = '\u2705 Secret generated!';
602
- } catch (error) {
603
- document.getElementById(\`error-\${key}\`).textContent = 'Failed to generate: ' + error.message;
604
- }
605
- }
717
+ /* \u2500\u2500 Save \u2500\u2500 */
718
+ async function saveChanges() {
719
+ if (!Object.keys(changed).length) return;
606
720
 
607
- // Show test result
608
- function showTestResult(result) {
609
- const modal = document.getElementById('testModal');
610
- const resultDiv = document.getElementById('testResult');
611
-
612
- resultDiv.innerHTML = \`
613
- <div class="alert \${result.success ? 'alert-success' : 'alert-warning'}">
614
- \${result.success ? '\u2705' : '\u274C'} \${result.message}
615
- </div>
616
- \`;
617
-
618
- modal.classList.add('show');
619
- }
721
+ const btn = document.getElementById('saveBtn');
722
+ btn.disabled = true;
723
+ btn.textContent = '\u23F3 Saving\u2026';
620
724
 
621
- function closeTestModal() {
622
- document.getElementById('testModal').classList.remove('show');
623
- }
725
+ try {
726
+ const res = await fetch('/api/env/bulk', {
727
+ method: 'POST',
728
+ headers: { 'Content-Type': 'application/json' },
729
+ body: JSON.stringify({ variables: changed }),
730
+ credentials: 'include',
731
+ });
624
732
 
625
- // Show alert
626
- function showAlert(type, message) {
627
- const alerts = document.getElementById('alerts');
628
- const alertClass = type === 'error' ? 'alert-warning' :
629
- type === 'success' ? 'alert-success' : 'alert-info';
630
-
631
- alerts.innerHTML = \`
632
- <div class="alert \${alertClass}">
633
- \${message}
634
- </div>
635
- \`;
636
-
637
- setTimeout(() => alerts.innerHTML = '', 5000);
638
- }
639
-
640
- // Get category title
641
- function getCategoryTitle(category) {
642
- const titles = {
643
- llm: '\u{1F916} LLM Configuration',
644
- security: '\u{1F512} Security Settings',
645
- target: '\u{1F3AF} Target Application',
646
- github: '\u{1F419} GitHub Integration',
647
- web: '\u{1F310} Web Server',
648
- agent: '\u{1F916} Agent Configuration',
649
- database: '\u{1F4BE} Database',
650
- notifications: '\u{1F514} Notifications',
651
- };
652
- return titles[category] || category;
653
- }
654
-
655
- // Tab switching
656
- document.addEventListener('click', (e) => {
657
- if (e.target.classList.contains('tab')) {
658
- const category = e.target.dataset.category;
659
-
660
- document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
661
- e.target.classList.add('active');
662
-
663
- document.querySelectorAll('.category-section').forEach(s => s.classList.remove('active'));
664
- document.querySelector(\`[data-category="\${category}"]\`).classList.add('active');
733
+ const body = await res.json().catch(() => ({}));
734
+
735
+ if (!res.ok) {
736
+ const errStr = body.errors
737
+ ? Object.entries(body.errors).map(([k, v]) => k + ': ' + v).join('; ')
738
+ : body.error || 'Failed to save';
739
+ // Show per-field errors
740
+ if (body.errors) {
741
+ for (const [k, msg] of Object.entries(body.errors)) {
742
+ setFeedback(k, 'error', msg);
743
+ const inp = document.querySelector('[data-key="' + k + '"]');
744
+ if (inp) inp.classList.add('invalid');
745
+ }
665
746
  }
747
+ toast('error', errStr);
748
+ btn.disabled = false;
749
+ btn.innerHTML = '\u{1F4BE} Save Changes';
750
+ return;
751
+ }
752
+
753
+ toast('success', '\u2705 Saved ' + body.updated + ' variable(s)');
754
+ if (body.restartRequired) {
755
+ document.getElementById('restartBanner').classList.add('show');
756
+ }
757
+
758
+ changed = {};
759
+ btn.innerHTML = '\u{1F4BE} Save Changes';
760
+ // Reload to reflect masked values
761
+ setTimeout(() => location.reload(), 1200);
762
+ } catch (e) {
763
+ toast('error', 'Network error \u2014 ' + e.message);
764
+ btn.disabled = false;
765
+ btn.innerHTML = '\u{1F4BE} Save Changes';
766
+ }
767
+ }
768
+
769
+ /* \u2500\u2500 Test variable \u2500\u2500 */
770
+ async function testVar(key) {
771
+ const inp = document.querySelector('[data-key="' + key + '"]');
772
+ const val = inp ? inp.value : '';
773
+ if (!val) { toast('warning', 'Enter a value first'); return; }
774
+
775
+ setFeedback(key, '', '');
776
+ const btn = document.querySelector('[onclick="testVar(\\''+key+'\\')"]');
777
+ if (btn) { btn.textContent = '\u23F3'; btn.disabled = true; }
778
+
779
+ try {
780
+ const res = await fetch('/api/env/test/' + key, {
781
+ method: 'POST',
782
+ headers: { 'Content-Type': 'application/json' },
783
+ body: JSON.stringify({ value: val }),
784
+ credentials: 'include',
666
785
  });
786
+ const result = await res.json();
787
+ openModal(result.success, result.message);
788
+ setFeedback(key, result.success ? 'success' : 'error', result.success ? '\u2713 Connected' : '\u2717 ' + result.message);
789
+ } catch (e) {
790
+ openModal(false, 'Network error: ' + e.message);
791
+ } finally {
792
+ if (btn) { btn.textContent = '\u{1F9EA}'; btn.disabled = false; }
793
+ }
794
+ }
795
+
796
+ /* \u2500\u2500 Generate secret \u2500\u2500 */
797
+ async function generateSecret(key) {
798
+ try {
799
+ const res = await fetch('/api/env/generate/' + key, {
800
+ method: 'POST', credentials: 'include'
801
+ });
802
+ if (!res.ok) throw new Error('Failed to generate');
803
+ const { value } = await res.json();
804
+ const inp = document.querySelector('[data-key="' + key + '"]');
805
+ if (inp) {
806
+ inp.type = 'text';
807
+ inp.value = value;
808
+ handleChange(inp);
809
+ }
810
+ setFeedback(key, 'success', '\u2713 Secret generated \u2014 save to persist');
811
+ } catch (e) {
812
+ setFeedback(key, 'error', e.message);
813
+ }
814
+ }
815
+
816
+ /* \u2500\u2500 Modal \u2500\u2500 */
817
+ function openModal(ok, msg) {
818
+ const box = document.getElementById('testResultBox');
819
+ box.className = 'modal-result ' + (ok ? 'ok' : 'fail');
820
+ box.textContent = (ok ? '\u2713 ' : '\u2717 ') + msg;
821
+ document.getElementById('testModal').classList.add('open');
822
+ }
823
+ function closeModal() {
824
+ document.getElementById('testModal').classList.remove('open');
825
+ }
826
+
827
+ /* \u2500\u2500 Toast \u2500\u2500 */
828
+ function toast(type, msg) {
829
+ const zone = document.getElementById('toastZone');
830
+ const el = document.createElement('div');
831
+ el.className = 'toast ' + type;
832
+ el.textContent = msg;
833
+ zone.appendChild(el);
834
+ setTimeout(() => el.remove(), 4500);
835
+ }
836
+
837
+ /* \u2500\u2500 Feedback \u2500\u2500 */
838
+ function setFeedback(key, type, msg) {
839
+ const el = document.getElementById('fb-' + key);
840
+ if (!el) return;
841
+ el.className = 'env-feedback' + (type ? ' ' + type : '');
842
+ el.textContent = msg;
843
+ }
844
+ function clearFeedback(key) { setFeedback(key, '', ''); }
845
+
846
+ /* \u2500\u2500 Helpers \u2500\u2500 */
847
+ function escHtml(s) {
848
+ return String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
849
+ }
850
+
851
+ /* \u2500\u2500 Wire save button \u2500\u2500 */
852
+ document.getElementById('saveBtn').addEventListener('click', saveChanges);
667
853
 
668
- // Save button
669
- document.getElementById('saveBtn').addEventListener('click', saveChanges);
854
+ /* \u2500\u2500 Close modal on backdrop click \u2500\u2500 */
855
+ document.getElementById('testModal').addEventListener('click', function(e) {
856
+ if (e.target === this) closeModal();
857
+ });
670
858
 
671
- // Load on page load
672
- loadEnvVariables();
673
- </script>
859
+ /* \u2500\u2500 Boot \u2500\u2500 */
860
+ init();
861
+ </script>
674
862
  </body>
675
863
  </html>`;
676
864
  }