web-access-cli 0.6.0 → 0.7.0

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.
@@ -1,9 +1,12 @@
1
1
  export const PANEL_PATH = "/panel";
2
2
  export const RECORDS_PATH = "/admin/api/records";
3
+ export const OVERVIEW_PATH = "/admin/api/overview";
4
+ export const STATS_RESET_PATH = "/admin/api/stats/reset";
5
+ export const CONFIG_PATH = "/admin/api/config";
6
+ export const CONFIG_RELOAD_PATH = "/admin/api/config/reload";
3
7
  /**
4
- * The panel is a self-contained document served from memory. It has no build
5
- * assets, no third-party scripts and no secrets; the records API is protected by
6
- * the same Bearer key as the tool endpoints.
8
+ * Self-contained gateway administration console served from memory.
9
+ * Zero external assets, protected by per-response CSP nonce and Bearer auth.
7
10
  */
8
11
  const PANEL_TEMPLATE = `<!doctype html>
9
12
  <html lang="zh-CN">
@@ -12,7 +15,7 @@ const PANEL_TEMPLATE = `<!doctype html>
12
15
  <meta name="viewport" content="width=device-width, initial-scale=1">
13
16
  <meta name="robots" content="noindex, nofollow">
14
17
  <meta name="color-scheme" content="light dark">
15
- <title>Web Access 网关 · 请求记录</title>
18
+ <title>Web Access 网关 · 管理后台</title>
16
19
  <style nonce="__NONCE__">
17
20
  :root {
18
21
  color-scheme: light dark;
@@ -60,17 +63,17 @@ body {
60
63
  font: 14px/1.55 var(--sans);
61
64
  -webkit-font-smoothing: antialiased;
62
65
  }
63
- button, input { font: inherit; color: inherit; }
66
+ button, input, select, textarea { font: inherit; color: inherit; }
64
67
  a { color: var(--accent); text-underline-offset: 2px; }
65
- h1, h2, p { margin-top: 0; }
68
+ h1, h2, h3, p { margin-top: 0; }
66
69
  [hidden] { display: none !important; }
67
70
  .topbar {
68
71
  display: flex;
69
72
  flex-wrap: wrap;
70
- gap: 14px 24px;
73
+ gap: 12px 20px;
71
74
  align-items: center;
72
75
  justify-content: space-between;
73
- padding: 16px clamp(16px, 4vw, 40px);
76
+ padding: 14px clamp(16px, 3.5vw, 36px);
74
77
  border-bottom: 1px solid var(--border);
75
78
  background: var(--surface);
76
79
  }
@@ -78,8 +81,8 @@ h1, h2, p { margin-top: 0; }
78
81
  .brand-mark {
79
82
  display: grid;
80
83
  place-items: center;
81
- width: 38px;
82
- height: 38px;
84
+ width: 36px;
85
+ height: 36px;
83
86
  border: 1px solid var(--border);
84
87
  border-radius: 7px;
85
88
  background: var(--surface-strong);
@@ -89,7 +92,35 @@ h1, h2, p { margin-top: 0; }
89
92
  }
90
93
  h1 { margin: 0; font-size: 16px; letter-spacing: 0.01em; }
91
94
  .brand p { margin: 1px 0 0; color: var(--muted); font-size: 12px; }
92
- .topbar-actions { display: flex; align-items: center; gap: 10px; }
95
+ .nav-tabs {
96
+ display: flex;
97
+ gap: 4px;
98
+ padding: 3px;
99
+ border: 1px solid var(--border);
100
+ border-radius: 8px;
101
+ background: var(--bg);
102
+ }
103
+ .nav-tab {
104
+ min-height: 34px;
105
+ padding: 0 14px;
106
+ border: 0;
107
+ border-radius: 6px;
108
+ background: transparent;
109
+ color: var(--muted);
110
+ font-size: 13px;
111
+ font-weight: 500;
112
+ cursor: pointer;
113
+ white-space: nowrap;
114
+ }
115
+ .nav-tab:hover { color: var(--text); }
116
+ .nav-tab[aria-selected="true"] {
117
+ background: var(--surface);
118
+ color: var(--accent);
119
+ font-weight: 600;
120
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
121
+ }
122
+ .nav-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
123
+ .topbar-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
93
124
  .status-pill {
94
125
  display: inline-flex;
95
126
  align-items: center;
@@ -113,39 +144,58 @@ h1 { margin: 0; font-size: 16px; letter-spacing: 0.01em; }
113
144
  .status-pill[data-state="not-ready"] .dot { background: var(--warn); }
114
145
  .status-pill[data-state="offline"] .dot { background: var(--err); }
115
146
  main {
116
- max-width: 1180px;
147
+ max-width: 1240px;
117
148
  margin: 0 auto;
118
- padding: 24px clamp(16px, 4vw, 40px) 64px;
149
+ padding: 22px clamp(16px, 3.5vw, 36px) 64px;
119
150
  }
120
151
  .btn {
121
- min-height: 40px;
122
- padding: 0 14px;
152
+ display: inline-flex;
153
+ align-items: center;
154
+ justify-content: center;
155
+ gap: 6px;
156
+ min-height: 38px;
157
+ padding: 0 13px;
123
158
  border: 1px solid var(--border);
124
159
  border-radius: 6px;
125
160
  background: var(--surface);
161
+ font-size: 13px;
126
162
  cursor: pointer;
127
163
  white-space: nowrap;
128
164
  }
129
- .btn:hover { background: var(--surface-strong); }
165
+ .btn.sm { min-height: 30px; padding: 0 9px; font-size: 12px; }
166
+ .btn:hover:not(:disabled) { background: var(--surface-strong); }
130
167
  .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
131
- .btn:disabled { opacity: 0.55; cursor: not-allowed; }
168
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
132
169
  .btn.primary { border-color: var(--accent); background: var(--accent); color: var(--bg); font-weight: 600; }
170
+ .btn.primary:hover:not(:disabled) { opacity: 0.92; background: var(--accent); }
171
+ .btn.danger { border-color: var(--err); color: var(--err); }
172
+ .btn.danger:hover:not(:disabled) { background: var(--err-soft); }
133
173
  .btn.ghost { background: transparent; }
134
- .login { max-width: 540px; margin: 7vh auto 0; padding: clamp(20px, 4vw, 32px); border: 1px solid var(--border); border-radius: 9px; background: var(--surface); }
174
+ .login {
175
+ max-width: 540px;
176
+ margin: 7vh auto 0;
177
+ padding: clamp(20px, 4vw, 32px);
178
+ border: 1px solid var(--border);
179
+ border-radius: 9px;
180
+ background: var(--surface);
181
+ }
135
182
  .login h2 { margin-bottom: 8px; font-size: 19px; }
136
183
  .login > p { color: var(--muted); margin-bottom: 20px; }
137
184
  .login label { display: block; margin-bottom: 6px; font-size: 12px; font-weight: 600; }
138
185
  .key-row { display: flex; gap: 8px; }
139
- .key-row input {
140
- flex: 1;
186
+ .key-row input, .input, .select {
141
187
  min-width: 0;
142
- min-height: 40px;
143
- padding: 0 12px;
188
+ min-height: 38px;
189
+ padding: 0 11px;
144
190
  border: 1px solid var(--border);
145
191
  border-radius: 6px;
146
- background: var(--bg);
192
+ background: var(--surface);
193
+ }
194
+ .key-row input { flex: 1; background: var(--bg); }
195
+ .key-row input:focus-visible, .input:focus-visible, .select:focus-visible {
196
+ outline: 2px solid var(--accent);
197
+ outline-offset: 1px;
147
198
  }
148
- .key-row input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
149
199
  .hint { margin: 10px 0 0; color: var(--muted); font-size: 12px; }
150
200
  .form-error { margin: 12px 0 0; color: var(--err); font-size: 13px; }
151
201
  .stats {
@@ -158,12 +208,25 @@ main {
158
208
  }
159
209
  .stat { padding: 13px 16px; border-left: 1px solid var(--border); min-width: 0; }
160
210
  .stat:first-child { border-left: 0; }
161
- .stat-label { display: block; color: var(--muted); font-size: 11px; letter-spacing: 0.05em; }
211
+ .stat-label { display: block; color: var(--muted); font-size: 11px; letter-spacing: 0.04em; }
162
212
  .stat strong { display: block; margin-top: 3px; font: 600 20px/1.25 var(--mono); }
163
- .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; margin: 18px 0 12px; }
164
- .segmented { display: inline-flex; overflow: hidden; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); }
213
+ .stat-sub { display: block; margin-top: 3px; color: var(--muted); font-size: 11px; font-family: var(--mono); }
214
+ .toolbar {
215
+ display: flex;
216
+ flex-wrap: wrap;
217
+ align-items: center;
218
+ gap: 8px 10px;
219
+ margin: 16px 0 12px;
220
+ }
221
+ .segmented {
222
+ display: inline-flex;
223
+ overflow: hidden;
224
+ border: 1px solid var(--border);
225
+ border-radius: 6px;
226
+ background: var(--surface);
227
+ }
165
228
  .segmented button {
166
- min-height: 38px;
229
+ min-height: 36px;
167
230
  padding: 0 12px;
168
231
  border: 0;
169
232
  border-left: 1px solid var(--border);
@@ -173,23 +236,59 @@ main {
173
236
  cursor: pointer;
174
237
  }
175
238
  .segmented button:first-child { border-left: 0; }
176
- .segmented button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
239
+ .segmented button[aria-pressed="true"] {
240
+ background: var(--accent-soft);
241
+ color: var(--accent);
242
+ font-weight: 600;
243
+ }
177
244
  .segmented button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
178
245
  #filter-text {
179
- flex: 1 1 220px;
180
- min-width: 160px;
181
- min-height: 40px;
182
- padding: 0 12px;
183
- border: 1px solid var(--border);
246
+ flex: 1 1 200px;
247
+ min-width: 150px;
248
+ }
249
+ .switch {
250
+ display: inline-flex;
251
+ align-items: center;
252
+ gap: 7px;
253
+ min-height: 38px;
254
+ padding: 0 4px;
255
+ color: var(--muted);
256
+ font-size: 12px;
257
+ cursor: pointer;
258
+ white-space: nowrap;
259
+ }
260
+ .switch input { accent-color: var(--accent); width: 15px; height: 15px; }
261
+ .list-head {
262
+ display: flex;
263
+ justify-content: space-between;
264
+ align-items: center;
265
+ flex-wrap: wrap;
266
+ gap: 8px 12px;
267
+ margin: 4px 0 8px;
268
+ color: var(--muted);
269
+ font-size: 12px;
270
+ }
271
+ .banner {
272
+ margin: 0 0 12px;
273
+ padding: 9px 12px;
274
+ border: 1px solid var(--err);
275
+ border-left-width: 3px;
184
276
  border-radius: 6px;
277
+ background: var(--err-soft);
278
+ color: var(--err);
279
+ font-size: 13px;
280
+ }
281
+ .banner.ok { border-color: var(--ok); background: var(--ok-soft); color: var(--ok); }
282
+ .banner.warn { border-color: var(--warn); background: var(--warn-soft); color: var(--warn); }
283
+ .records {
284
+ margin: 0;
285
+ padding: 0;
286
+ overflow: hidden;
287
+ border: 1px solid var(--border);
288
+ border-radius: 8px;
185
289
  background: var(--surface);
290
+ list-style: none;
186
291
  }
187
- #filter-text:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
188
- .switch { display: inline-flex; align-items: center; gap: 7px; min-height: 40px; padding: 0 4px; color: var(--muted); font-size: 12px; cursor: pointer; white-space: nowrap; }
189
- .switch input { accent-color: var(--accent); width: 15px; height: 15px; }
190
- .list-head { display: flex; justify-content: space-between; gap: 12px; margin: 4px 0 8px; color: var(--muted); font-size: 12px; }
191
- .banner { margin: 0 0 12px; padding: 9px 12px; border: 1px solid var(--err); border-left-width: 3px; border-radius: 6px; background: var(--err-soft); color: var(--err); font-size: 13px; }
192
- .records { margin: 0; padding: 0; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); list-style: none; }
193
292
  .record + .record { border-top: 1px solid var(--border); }
194
293
  .record-head {
195
294
  display: flex;
@@ -210,11 +309,12 @@ main {
210
309
  .badge {
211
310
  display: inline-flex;
212
311
  flex: 0 0 auto;
312
+ align-items: center;
213
313
  justify-content: center;
214
314
  padding: 1px 6px;
215
315
  border: 1px solid transparent;
216
316
  border-radius: 4px;
217
- font: 600 11px/1.7 var(--mono);
317
+ font: 600 11px/1.65 var(--mono);
218
318
  white-space: nowrap;
219
319
  }
220
320
  .route-search { background: var(--accent-soft); color: var(--accent); }
@@ -222,30 +322,237 @@ main {
222
322
  .status-ok { background: var(--ok-soft); color: var(--ok); }
223
323
  .status-warn { background: var(--warn-soft); color: var(--warn); }
224
324
  .status-error { background: var(--err-soft); color: var(--err); }
225
- .record-summary { flex: 1 1 260px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
226
- .record-provider { flex: 0 1 auto; max-width: 110px; overflow: hidden; color: var(--muted); font: 12px var(--mono); text-overflow: ellipsis; white-space: nowrap; }
227
- .record-duration, .record-results { flex: 0 0 auto; color: var(--muted); font: 12px var(--mono); text-align: right; white-space: nowrap; }
325
+ .badge-muted { border-color: var(--border); background: var(--bg); color: var(--muted); font-weight: 500; }
326
+ .record-summary {
327
+ flex: 1 1 240px;
328
+ min-width: 0;
329
+ overflow: hidden;
330
+ text-overflow: ellipsis;
331
+ white-space: nowrap;
332
+ }
333
+ .record-provider {
334
+ flex: 0 1 auto;
335
+ max-width: 130px;
336
+ overflow: hidden;
337
+ color: var(--muted);
338
+ font: 12px var(--mono);
339
+ text-overflow: ellipsis;
340
+ white-space: nowrap;
341
+ }
342
+ .record-duration, .record-results {
343
+ flex: 0 0 auto;
344
+ color: var(--muted);
345
+ font: 12px var(--mono);
346
+ text-align: right;
347
+ white-space: nowrap;
348
+ }
228
349
  .record-duration { min-width: 62px; }
229
350
  .record-results { min-width: 48px; }
230
351
  .chevron { flex: 0 0 auto; margin-left: auto; color: var(--muted); transition: transform 0.15s ease; }
231
352
  .record.expanded .chevron { transform: rotate(90deg); }
232
- .record-detail { padding: 13px 16px 16px; border-top: 1px dashed var(--border); background: var(--bg); }
233
- .record-meta { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 5px 16px; margin: 0; }
353
+ .record-detail {
354
+ padding: 14px 16px 18px;
355
+ border-top: 1px dashed var(--border);
356
+ background: var(--bg);
357
+ }
358
+ .record-meta {
359
+ display: grid;
360
+ grid-template-columns: max-content minmax(0, 1fr);
361
+ gap: 5px 16px;
362
+ margin: 0;
363
+ }
234
364
  .record-meta dt { color: var(--muted); font-size: 12px; }
235
365
  .record-meta dd { margin: 0; overflow-wrap: anywhere; font: 12px/1.5 var(--mono); }
236
- .detail-label { margin: 16px 0 6px; color: var(--muted); font-size: 11px; letter-spacing: 0.05em; }
237
- .result-list { display: grid; gap: 7px; margin: 0; padding-left: 20px; }
238
- .result-title { display: block; overflow-wrap: anywhere; }
239
- .result-url { display: block; color: var(--muted); font: 11px/1.45 var(--mono); overflow-wrap: anywhere; }
240
- .empty { margin-top: 0; padding: 42px 20px; border: 1px dashed var(--border); border-radius: 8px; background: var(--surface); color: var(--muted); text-align: center; }
366
+ .detail-label {
367
+ display: flex;
368
+ align-items: center;
369
+ justify-content: space-between;
370
+ gap: 10px;
371
+ margin: 16px 0 8px;
372
+ color: var(--muted);
373
+ font-size: 11px;
374
+ font-weight: 600;
375
+ letter-spacing: 0.04em;
376
+ }
377
+ .attempts-list {
378
+ display: grid;
379
+ gap: 6px;
380
+ margin: 0;
381
+ padding: 0;
382
+ list-style: none;
383
+ }
384
+ .attempt-row {
385
+ display: flex;
386
+ flex-wrap: wrap;
387
+ align-items: center;
388
+ gap: 6px 10px;
389
+ padding: 6px 10px;
390
+ border: 1px solid var(--border);
391
+ border-radius: 6px;
392
+ background: var(--surface);
393
+ font-size: 12px;
394
+ }
395
+ .attempt-provider { font-family: var(--mono); font-weight: 600; }
396
+ .attempt-duration { color: var(--muted); font-family: var(--mono); }
397
+ .attempt-err { color: var(--err); font-family: var(--mono); overflow-wrap: anywhere; }
398
+ .content-preview-box {
399
+ margin-top: 6px;
400
+ border: 1px solid var(--border);
401
+ border-radius: 6px;
402
+ background: var(--surface);
403
+ overflow: hidden;
404
+ }
405
+ .content-preview-pre {
406
+ margin: 0;
407
+ padding: 10px 12px;
408
+ max-height: 280px;
409
+ overflow: auto;
410
+ font: 12px/1.55 var(--mono);
411
+ white-space: pre-wrap;
412
+ overflow-wrap: anywhere;
413
+ }
414
+ .result-list { display: grid; gap: 10px; margin: 0; padding-left: 20px; }
415
+ .result-item { padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); }
416
+ .result-title { display: block; font-weight: 600; overflow-wrap: anywhere; }
417
+ .result-url { display: block; margin-top: 2px; color: var(--muted); font: 11px/1.45 var(--mono); overflow-wrap: anywhere; }
418
+ .result-snippet { margin: 5px 0 0; color: var(--text); font-size: 12.5px; overflow-wrap: anywhere; }
419
+ .result-preview-details { margin-top: 6px; font-size: 12px; }
420
+ .result-preview-details summary { cursor: pointer; color: var(--accent); font-family: var(--mono); }
421
+ .empty {
422
+ margin-top: 0;
423
+ padding: 42px 20px;
424
+ border: 1px dashed var(--border);
425
+ border-radius: 8px;
426
+ background: var(--surface);
427
+ color: var(--muted);
428
+ text-align: center;
429
+ }
241
430
  .empty strong { display: block; margin-bottom: 4px; color: var(--text); font-size: 14px; }
242
431
  .footnote { margin: 14px 0 0; color: var(--muted); font-size: 12px; }
432
+ .grid-2 {
433
+ display: grid;
434
+ grid-template-columns: repeat(2, minmax(0, 1fr));
435
+ gap: 16px;
436
+ margin-top: 16px;
437
+ }
438
+ .card {
439
+ padding: 16px;
440
+ border: 1px solid var(--border);
441
+ border-radius: 8px;
442
+ background: var(--surface);
443
+ min-width: 0;
444
+ }
445
+ .card-head {
446
+ display: flex;
447
+ align-items: center;
448
+ justify-content: space-between;
449
+ gap: 10px;
450
+ margin-bottom: 12px;
451
+ }
452
+ .card-head h3 { margin: 0; font-size: 15px; }
453
+ .route-chain {
454
+ display: flex;
455
+ flex-wrap: wrap;
456
+ gap: 6px;
457
+ align-items: center;
458
+ margin: 8px 0;
459
+ }
460
+ .route-node {
461
+ display: inline-flex;
462
+ align-items: center;
463
+ gap: 6px;
464
+ padding: 4px 9px;
465
+ border: 1px solid var(--border);
466
+ border-radius: 6px;
467
+ background: var(--bg);
468
+ font: 12px var(--mono);
469
+ }
470
+ .route-node.ready { border-color: var(--ok); background: var(--ok-soft); color: var(--ok); }
471
+ .route-node.unready { border-color: var(--warn); background: var(--warn-soft); color: var(--warn); }
472
+ .table-wrap {
473
+ margin-top: 16px;
474
+ overflow-x: auto;
475
+ border: 1px solid var(--border);
476
+ border-radius: 8px;
477
+ background: var(--surface);
478
+ }
479
+ .data-table {
480
+ width: 100%;
481
+ border-collapse: collapse;
482
+ font-size: 13px;
483
+ text-align: left;
484
+ }
485
+ .data-table th, .data-table td {
486
+ padding: 10px 12px;
487
+ border-bottom: 1px solid var(--border);
488
+ vertical-align: middle;
489
+ }
490
+ .data-table th {
491
+ background: var(--surface-strong);
492
+ color: var(--muted);
493
+ font-size: 11px;
494
+ font-weight: 600;
495
+ letter-spacing: 0.04em;
496
+ white-space: nowrap;
497
+ }
498
+ .data-table tr:last-child td { border-bottom: 0; }
499
+ .mono { font-family: var(--mono); }
500
+ .field-grid {
501
+ display: grid;
502
+ grid-template-columns: repeat(2, minmax(0, 1fr));
503
+ gap: 10px 12px;
504
+ margin-bottom: 14px;
505
+ }
506
+ .field label {
507
+ display: block;
508
+ margin-bottom: 4px;
509
+ color: var(--muted);
510
+ font-size: 11.5px;
511
+ font-weight: 600;
512
+ }
513
+ .field input, .field select { width: 100%; }
514
+ .bucket-title {
515
+ margin: 12px 0 6px;
516
+ color: var(--muted);
517
+ font-size: 11.5px;
518
+ font-weight: 600;
519
+ }
520
+ .route-editor-list {
521
+ display: grid;
522
+ gap: 6px;
523
+ margin: 0;
524
+ padding: 0;
525
+ list-style: none;
526
+ }
527
+ .route-editor-item {
528
+ display: flex;
529
+ align-items: center;
530
+ justify-content: space-between;
531
+ gap: 8px;
532
+ padding: 6px 10px;
533
+ border: 1px solid var(--border);
534
+ border-radius: 6px;
535
+ background: var(--bg);
536
+ font-size: 12.5px;
537
+ }
538
+ .route-editor-actions { display: flex; gap: 4px; flex-wrap: wrap; }
539
+ .editor-drawer {
540
+ margin: 14px 0;
541
+ padding: 16px;
542
+ border: 2px solid var(--accent);
543
+ border-radius: 8px;
544
+ background: var(--surface);
545
+ }
546
+ @media (max-width: 860px) {
547
+ .grid-2 { grid-template-columns: 1fr; }
548
+ }
243
549
  @media (max-width: 680px) {
244
550
  .stats { grid-template-columns: 1fr 1fr; }
245
551
  .stat { border-top: 1px solid var(--border); }
246
552
  .stat:nth-child(-n+2) { border-top: 0; }
247
553
  .stat:nth-child(odd) { border-left: 0; }
248
554
  .stat:nth-child(even) { border-left: 1px solid var(--border); }
555
+ .field-grid { grid-template-columns: 1fr; }
249
556
  .record-head { row-gap: 3px; }
250
557
  .record-summary { order: 1; flex-basis: 100%; white-space: normal; }
251
558
  .record-provider { order: 2; max-width: none; }
@@ -268,10 +575,19 @@ main {
268
575
  <span class="brand-mark" aria-hidden="true">WA</span>
269
576
  <div>
270
577
  <h1>Web Access 网关</h1>
271
- <p>请求记录 · 最近 __RETENTION__ 条 · 重启后清空</p>
578
+ <p>管理后台 · 最近 __RETENTION__ 条记录 · 版本 __VERSION__</p>
272
579
  </div>
273
580
  </div>
581
+ <div class="nav-tabs" id="nav-tabs" role="tablist" aria-label="后台功能模块" hidden>
582
+ <button type="button" class="nav-tab" role="tab" data-tab="records" aria-selected="true">请求记录</button>
583
+ <button type="button" class="nav-tab" role="tab" data-tab="overview" aria-selected="false">路由与统计</button>
584
+ <button type="button" class="nav-tab" role="tab" data-tab="config" aria-selected="false">配置管理</button>
585
+ </div>
274
586
  <div class="topbar-actions">
587
+ <span class="status-pill" id="persist-pill" data-state="not-ready" hidden>
588
+ <span class="dot" aria-hidden="true"></span>
589
+ <span id="persist-text">仅内存生效</span>
590
+ </span>
275
591
  <span class="status-pill" id="ready-pill" data-state="checking" role="status">
276
592
  <span class="dot" aria-hidden="true"></span>
277
593
  <span id="ready-text">检查中</span>
@@ -281,54 +597,321 @@ main {
281
597
  </header>
282
598
  <main>
283
599
  <section class="login" id="login-view" hidden aria-labelledby="login-title">
284
- <h2 id="login-title">查看请求记录</h2>
285
- <p>这个面板只读取本机内存中的最近请求。请输入网关 API Key 继续。</p>
600
+ <h2 id="login-title">登录网关管理后台</h2>
601
+ <p>查看请求追踪链路、Provider 诊断统计与在线编排配置。请输入网关 API Key 继续。</p>
286
602
  <form id="login-form">
287
603
  <label for="key-input">网关 API Key</label>
288
604
  <div class="key-row">
289
605
  <input id="key-input" name="key" type="password" autocomplete="off" spellcheck="false" required minlength="__MIN_KEY_LENGTH__" placeholder="至少 __MIN_KEY_LENGTH__ 个字符">
290
- <button type="submit" class="btn primary" id="login-submit">进入面板</button>
606
+ <button type="submit" class="btn primary" id="login-submit">进入后台</button>
291
607
  </div>
292
- <p class="hint">Key 只保存在当前标签页的 sessionStorage,不写入 URL;关闭标签页后清除。也可用 <code>#apikey=…</code> 链接打开,脚本会立即从地址栏移除它。</p>
608
+ <p class="hint">Key 仅保存在当前标签页的 sessionStorage;也支持用 <code>#apikey=…</code> 书签打开,脚本会立即从地址栏剥离。</p>
293
609
  <p class="form-error" id="login-error" role="alert" hidden></p>
294
610
  </form>
295
611
  </section>
296
- <section id="panel-view" hidden aria-labelledby="panel-title">
297
- <h2 id="panel-title" hidden>请求记录</h2>
298
- <div class="stats" aria-label="概览">
299
- <div class="stat"><span class="stat-label">已加载请求</span><strong id="stat-total">0</strong></div>
300
- <div class="stat"><span class="stat-label">成功</span><strong id="stat-ok">0</strong></div>
301
- <div class="stat"><span class="stat-label">失败</span><strong id="stat-bad">0</strong></div>
302
- <div class="stat"><span class="stat-label">平均耗时</span><strong id="stat-avg">—</strong></div>
303
- </div>
304
- <div class="toolbar">
305
- <div class="segmented" role="group" aria-label="按请求类型筛选">
306
- <button type="button" data-route="all" aria-pressed="true">全部</button>
307
- <button type="button" data-route="search" aria-pressed="false">搜索</button>
308
- <button type="button" data-route="fetch" aria-pressed="false">抓取</button>
612
+
613
+ <div id="panel-view" hidden>
614
+ <p class="banner" id="banner" role="alert" hidden></p>
615
+
616
+ <!-- Tab 1: 请求记录 -->
617
+ <section id="tab-records" role="tabpanel" aria-labelledby="panel-title">
618
+ <h2 id="panel-title" hidden>请求记录</h2>
619
+ <div class="stats" aria-label="概览">
620
+ <div class="stat"><span class="stat-label">已加载请求</span><strong id="stat-total">0</strong></div>
621
+ <div class="stat"><span class="stat-label">成功</span><strong id="stat-ok">0</strong></div>
622
+ <div class="stat"><span class="stat-label">失败</span><strong id="stat-bad">0</strong></div>
623
+ <div class="stat"><span class="stat-label">平均耗时</span><strong id="stat-avg">—</strong></div>
309
624
  </div>
310
- <div class="segmented" role="group" aria-label="按结果状态筛选">
311
- <button type="button" data-status="all" aria-pressed="true">全部状态</button>
312
- <button type="button" data-status="ok" aria-pressed="false">成功</button>
313
- <button type="button" data-status="error" aria-pressed="false">失败</button>
625
+ <div class="toolbar">
626
+ <div class="segmented" role="group" aria-label="按请求类型筛选">
627
+ <button type="button" data-route="all" aria-pressed="true">全部</button>
628
+ <button type="button" data-route="search" aria-pressed="false">搜索</button>
629
+ <button type="button" data-route="fetch" aria-pressed="false">抓取</button>
630
+ </div>
631
+ <div class="segmented" role="group" aria-label="按结果状态筛选">
632
+ <button type="button" data-status="all" aria-pressed="true">全部状态</button>
633
+ <button type="button" data-status="ok" aria-pressed="false">成功</button>
634
+ <button type="button" data-status="error" aria-pressed="false">失败</button>
635
+ </div>
636
+ <select id="filter-provider" class="select" aria-label="按 Provider 筛选">
637
+ <option value="">全部 Provider</option>
638
+ </select>
639
+ <input id="filter-text" class="input" type="search" placeholder="筛选 query、URL、provider、错误码、摘要" aria-label="文本筛选">
640
+ <label class="switch"><input type="checkbox" id="auto-refresh" checked>自动刷新</label>
641
+ <button type="button" class="btn" id="refresh">刷新</button>
642
+ <button type="button" class="btn" id="export-records">导出 JSON</button>
643
+ <button type="button" class="btn danger" id="clear-records">清空记录</button>
314
644
  </div>
315
- <input id="filter-text" type="search" placeholder="筛选 query、URL、provider、错误码" aria-label="文本筛选">
316
- <label class="switch"><input type="checkbox" id="auto-refresh" checked>自动刷新</label>
317
- <button type="button" class="btn" id="refresh">刷新</button>
318
- </div>
319
- <div class="list-head">
320
- <span id="record-count">已加载 0 条</span>
321
- <span>/v1/tools/search · /v1/tools/fetch</span>
322
- </div>
323
- <p class="banner" id="banner" role="alert" hidden></p>
324
- <div class="empty" id="empty" hidden>
325
- <strong id="empty-title">还没有请求记录</strong>
326
- <span id="empty-text">调用一次 /v1/tools/search 或 /v1/tools/fetch 后,这里会显示时间和结果。</span>
327
- </div>
328
- <ul class="records" id="records" aria-label="请求记录"></ul>
329
- <p class="footnote">记录只保存在容器内存中,不写入磁盘,也不记录响应正文;重启网关后清空。面板版本 __VERSION__。</p>
330
- </section>
331
- <noscript><p class="banner">面板需要 JavaScript 才能读取请求记录。</p></noscript>
645
+ <div class="list-head">
646
+ <span id="record-count">已加载 0 条</span>
647
+ <span>/v1/tools/search · /v1/tools/fetch</span>
648
+ </div>
649
+ <div class="empty" id="empty" hidden>
650
+ <strong id="empty-title">还没有请求记录</strong>
651
+ <span id="empty-text">调用一次 /v1/tools/search 或 /v1/tools/fetch 后,这里会显示完整链路、耗时与结果预览。</span>
652
+ </div>
653
+ <ul class="records" id="records" aria-label="请求记录"></ul>
654
+ <p class="footnote">记录保存在网关进程内存中(上限 __RETENTION__ 条),含尝试链路、搜索摘要与截断正文预览;重启网关或点击清空后释放。</p>
655
+ </section>
656
+
657
+ <!-- Tab 2: 路由与统计看板 -->
658
+ <section id="tab-overview" role="tabpanel" hidden>
659
+ <div class="stats" aria-label="运行时累计指标">
660
+ <div class="stat">
661
+ <span class="stat-label">网关累计请求</span>
662
+ <strong id="ov-total-req">0</strong>
663
+ <span class="stat-sub" id="ov-uptime">运行时间 —</span>
664
+ </div>
665
+ <div class="stat">
666
+ <span class="stat-label">Search 成功 / 总数</span>
667
+ <strong id="ov-search-stat">0 / 0</strong>
668
+ <span class="stat-sub" id="ov-doctor-search">路由检查 —</span>
669
+ </div>
670
+ <div class="stat">
671
+ <span class="stat-label">Fetch 成功 / 总数</span>
672
+ <strong id="ov-fetch-stat">0 / 0</strong>
673
+ <span class="stat-sub" id="ov-doctor-extract">路由检查 —</span>
674
+ </div>
675
+ <div class="stat">
676
+ <span class="stat-label">正文补抓成功 / 尝试</span>
677
+ <strong id="ov-backfill-stat">0 / 0</strong>
678
+ <span class="stat-sub" id="ov-config-mode">配置文件 —</span>
679
+ </div>
680
+ </div>
681
+
682
+ <div class="toolbar">
683
+ <div class="segmented" role="group" aria-label="筛选 Provider 列表">
684
+ <button type="button" data-pfilter="all" aria-pressed="true">全部实例</button>
685
+ <button type="button" data-pfilter="routed" aria-pressed="false">仅路由启用</button>
686
+ <button type="button" data-pfilter="ready" aria-pressed="false">仅已就绪</button>
687
+ </div>
688
+ <div style="margin-left:auto;display:flex;gap:8px;flex-wrap:wrap">
689
+ <button type="button" class="btn" id="refresh-overview">刷新状态</button>
690
+ <button type="button" class="btn" id="reset-stats">重置累计统计</button>
691
+ <button type="button" class="btn primary" id="jump-config">编排路由与配置</button>
692
+ </div>
693
+ </div>
694
+
695
+ <div class="grid-2">
696
+ <div class="card">
697
+ <div class="card-head">
698
+ <h3>Search 路由链</h3>
699
+ <span class="badge badge-muted" id="ov-search-params">—</span>
700
+ </div>
701
+ <div class="bucket-title">主路由 (Primary)</div>
702
+ <div class="route-chain" id="ov-search-primary"></div>
703
+ <div class="bucket-title">兜底路由 (Fallback)</div>
704
+ <div class="route-chain" id="ov-search-fallback"></div>
705
+ </div>
706
+ <div class="card">
707
+ <div class="card-head">
708
+ <h3>Extract 路由链</h3>
709
+ <span class="badge badge-muted" id="ov-extract-params">—</span>
710
+ </div>
711
+ <div class="bucket-title">主路由 (Primary)</div>
712
+ <div class="route-chain" id="ov-extract-primary"></div>
713
+ <div class="bucket-title">兜底路由 (Fallback)</div>
714
+ <div class="route-chain" id="ov-extract-fallback"></div>
715
+ </div>
716
+ </div>
717
+
718
+ <div class="table-wrap">
719
+ <table class="data-table" aria-label="Provider 诊断与调用统计">
720
+ <thead>
721
+ <tr>
722
+ <th>实例 ID / 类型</th>
723
+ <th>路由角色</th>
724
+ <th>就绪状态</th>
725
+ <th>凭据与端点来源</th>
726
+ <th>Search (成功/尝试 · 均耗时)</th>
727
+ <th>Extract (成功/尝试 · 均耗时)</th>
728
+ <th>最近状态 / 错误码</th>
729
+ <th>操作</th>
730
+ </tr>
731
+ </thead>
732
+ <tbody id="provider-stats-tbody"></tbody>
733
+ </table>
734
+ </div>
735
+ </section>
736
+
737
+ <!-- Tab 3: 配置管理 -->
738
+ <section id="tab-config" role="tabpanel" hidden>
739
+ <div class="card">
740
+ <div class="card-head" style="margin-bottom:6px">
741
+ <div>
742
+ <h3>配置与路由在线管理</h3>
743
+ <p class="hint" id="cfg-file-label" style="margin-top:2px">配置文件:—</p>
744
+ </div>
745
+ <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
746
+ <span class="badge status-warn" id="cfg-dirty-badge" hidden>有未保存修改</span>
747
+ <button type="button" class="btn" id="reload-config">从磁盘重载</button>
748
+ <button type="button" class="btn" id="reset-config-draft">重置草稿</button>
749
+ <button type="button" class="btn primary" id="save-config">保存并应用配置</button>
750
+ </div>
751
+ </div>
752
+ <p class="banner" id="cfg-feedback" role="status" hidden style="margin-top:10px;margin-bottom:0"></p>
753
+ </div>
754
+
755
+ <div class="grid-2">
756
+ <div class="card">
757
+ <div class="card-head">
758
+ <h3>Search 路由与预算参数</h3>
759
+ </div>
760
+ <div class="field-grid">
761
+ <div class="field">
762
+ <label for="cfg-s-limit">默认返回条数 (limit, 1-20)</label>
763
+ <input id="cfg-s-limit" class="input" type="number" min="1" max="20">
764
+ </div>
765
+ <div class="field">
766
+ <label for="cfg-s-timeout">总超时 (timeoutMs)</label>
767
+ <input id="cfg-s-timeout" class="input" type="number" min="100" step="500">
768
+ </div>
769
+ <div class="field">
770
+ <label for="cfg-s-attempt">单 Provider 超时 (attemptTimeoutMs)</label>
771
+ <input id="cfg-s-attempt" class="input" type="number" min="100" step="500">
772
+ </div>
773
+ <div class="field">
774
+ <label for="cfg-s-bytes">最大响应字节 (maxResponseBytes)</label>
775
+ <input id="cfg-s-bytes" class="input" type="number" min="1024" step="1024">
776
+ </div>
777
+ </div>
778
+ <div class="bucket-title">主路由 (按顺序回退尝试)</div>
779
+ <ul class="route-editor-list" id="cfg-s-primary"></ul>
780
+ <div class="bucket-title">兜底路由 (主路由全部失败后尝试)</div>
781
+ <ul class="route-editor-list" id="cfg-s-fallback"></ul>
782
+ <div class="bucket-title">未启用 Search 实例</div>
783
+ <ul class="route-editor-list" id="cfg-s-available"></ul>
784
+ </div>
785
+
786
+ <div class="card">
787
+ <div class="card-head">
788
+ <h3>Extract 路由与预算参数</h3>
789
+ </div>
790
+ <div class="field-grid">
791
+ <div class="field">
792
+ <label for="cfg-e-minchars">最小正文字符 (minContentCharacters)</label>
793
+ <input id="cfg-e-minchars" class="input" type="number" min="1" step="10">
794
+ </div>
795
+ <div class="field">
796
+ <label for="cfg-e-timeout">总超时 (timeoutMs)</label>
797
+ <input id="cfg-e-timeout" class="input" type="number" min="100" step="500">
798
+ </div>
799
+ <div class="field">
800
+ <label for="cfg-e-attempt">单 Provider 超时 (attemptTimeoutMs)</label>
801
+ <input id="cfg-e-attempt" class="input" type="number" min="100" step="500">
802
+ </div>
803
+ <div class="field">
804
+ <label for="cfg-e-bytes">最大响应字节 (maxResponseBytes)</label>
805
+ <input id="cfg-e-bytes" class="input" type="number" min="1024" step="1024">
806
+ </div>
807
+ </div>
808
+ <div class="bucket-title">主路由 (按顺序回退尝试)</div>
809
+ <ul class="route-editor-list" id="cfg-e-primary"></ul>
810
+ <div class="bucket-title">兜底路由 (主路由全部失败后尝试)</div>
811
+ <ul class="route-editor-list" id="cfg-e-fallback"></ul>
812
+ <div class="bucket-title">未启用 Extract 实例</div>
813
+ <ul class="route-editor-list" id="cfg-e-available"></ul>
814
+ </div>
815
+ </div>
816
+
817
+ <div class="card" style="margin-top:16px">
818
+ <div class="card-head">
819
+ <div>
820
+ <h3>Provider 实例与凭据配置</h3>
821
+ <p class="hint" style="margin-top:2px">明文 API Key 永不在接口响应中回显;修改后请点击顶部「保存并应用配置」写回。</p>
822
+ </div>
823
+ <button type="button" class="btn" id="add-provider-btn">+ 新增自定义实例</button>
824
+ </div>
825
+
826
+ <div class="editor-drawer" id="provider-editor" hidden>
827
+ <div class="card-head">
828
+ <h3 id="pe-title">编辑 Provider 实例</h3>
829
+ <button type="button" class="btn sm ghost" id="pe-cancel">关闭</button>
830
+ </div>
831
+ <div class="field-grid">
832
+ <div class="field">
833
+ <label for="pe-id">实例 ID (小写唯一标识)</label>
834
+ <input id="pe-id" class="input mono" type="text" placeholder="例如 tavily-backup">
835
+ </div>
836
+ <div class="field">
837
+ <label for="pe-type">Provider 协议类型</label>
838
+ <select id="pe-type" class="select mono"></select>
839
+ </div>
840
+ <div class="field">
841
+ <label for="pe-key-action">配置文件 API Key 操作</label>
842
+ <select id="pe-key-action" class="select">
843
+ <option value="keep">保持现有配置不变</option>
844
+ <option value="set">写入新 API Key</option>
845
+ <option value="clear">清除配置文件中的 API Key</option>
846
+ </select>
847
+ </div>
848
+ <div class="field" id="pe-key-field" hidden>
849
+ <label for="pe-key">新 API Key (仅单向写入,不回显)</label>
850
+ <input id="pe-key" class="input mono" type="password" autocomplete="off" spellcheck="false" placeholder="输入新的 API Key">
851
+ </div>
852
+ <div class="field">
853
+ <label for="pe-key-env">API Key 环境变量名 (apiKeyEnv)</label>
854
+ <input id="pe-key-env" class="input mono" type="text" placeholder="可选,如 CUSTOM_TAVILY_KEY">
855
+ </div>
856
+ <div class="field">
857
+ <label for="pe-base-url">自定义 Base URL (baseUrl)</label>
858
+ <input id="pe-base-url" class="input mono" type="url" placeholder="留空使用协议默认端点">
859
+ </div>
860
+ <div class="field">
861
+ <label for="pe-base-url-env">Base URL 环境变量名 (baseUrlEnv)</label>
862
+ <input id="pe-base-url-env" class="input mono" type="text" placeholder="可选,如 CUSTOM_BASE_URL">
863
+ </div>
864
+ <div class="field" id="pe-filter-wrap" hidden>
865
+ <label for="pe-filter-mode">域名过滤模式 (searchFilterMode)</label>
866
+ <select id="pe-filter-mode" class="select">
867
+ <option value="strict">strict (严格拒绝不支持过滤参数)</option>
868
+ <option value="best_effort">best_effort (本地后置过滤)</option>
869
+ </select>
870
+ </div>
871
+ <div class="field" id="pe-authjson-wrap" hidden>
872
+ <label for="pe-auth-json">xAI 认证文件路径 (authJson)</label>
873
+ <input id="pe-auth-json" class="input mono" type="text" placeholder="可选外部 OAuth JSON 路径">
874
+ </div>
875
+ <div class="field" id="pe-authjson-env-wrap" hidden>
876
+ <label for="pe-auth-json-env">xAI 认证文件环境变量 (authJsonEnv)</label>
877
+ <input id="pe-auth-json-env" class="input mono" type="text" placeholder="如 XAI_AUTH_JSON">
878
+ </div>
879
+ <div class="field" id="pe-model-wrap" hidden>
880
+ <label for="pe-model">模型覆盖 (model)</label>
881
+ <input id="pe-model" class="input mono" type="text" placeholder="如 grok-4.6">
882
+ </div>
883
+ <div class="field" id="pe-model-env-wrap" hidden>
884
+ <label for="pe-model-env">模型环境变量 (modelEnv)</label>
885
+ <input id="pe-model-env" class="input mono" type="text" placeholder="可选模型环境变量">
886
+ </div>
887
+ </div>
888
+ <p class="form-error" id="pe-error" hidden></p>
889
+ <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:10px">
890
+ <button type="button" class="btn danger sm" id="pe-delete" hidden>移除自定义实例</button>
891
+ <button type="button" class="btn sm" id="pe-apply">暂存到配置草稿</button>
892
+ </div>
893
+ </div>
894
+
895
+ <div class="table-wrap" style="margin-top:8px">
896
+ <table class="data-table" aria-label="Provider 配置清单">
897
+ <thead>
898
+ <tr>
899
+ <th>实例 ID</th>
900
+ <th>协议类型</th>
901
+ <th>支持能力</th>
902
+ <th>当前凭据来源</th>
903
+ <th>生效端点 / 附加项</th>
904
+ <th>草稿状态</th>
905
+ <th>操作</th>
906
+ </tr>
907
+ </thead>
908
+ <tbody id="cfg-providers-tbody"></tbody>
909
+ </table>
910
+ </div>
911
+ </div>
912
+ </section>
913
+ </div>
914
+ <noscript><p class="banner">面板需要 JavaScript 才能管理网关。</p></noscript>
332
915
  </main>
333
916
  <script nonce="__NONCE__">
334
917
  (() => {
@@ -340,12 +923,22 @@ main {
340
923
  const byId = (id) => document.getElementById(id);
341
924
  const state = {
342
925
  key: "",
926
+ tab: "records",
343
927
  records: [],
928
+ overview: null,
344
929
  route: "all",
345
930
  status: "all",
931
+ providerFilter: "",
346
932
  text: "",
933
+ pfilter: "all",
347
934
  loading: false,
348
935
  timer: 0,
936
+ draft: null,
937
+ dirty: false,
938
+ editingProviderId: null,
939
+ editingIsNew: false,
940
+ expandedIds: new Set(),
941
+ expandedPreviews: new Set(),
349
942
  };
350
943
 
351
944
  function readStoredKey() {
@@ -360,7 +953,7 @@ main {
360
953
  try {
361
954
  sessionStorage.setItem(STORAGE_KEY, key);
362
955
  } catch {
363
- // Storage can be unavailable in hardened browser contexts.
956
+ // Ignore storage errors in restricted contexts.
364
957
  }
365
958
  }
366
959
 
@@ -368,13 +961,10 @@ main {
368
961
  try {
369
962
  sessionStorage.removeItem(STORAGE_KEY);
370
963
  } catch {
371
- // Ignore storage failures.
964
+ // Ignore storage errors.
372
965
  }
373
966
  }
374
967
 
375
- // Optional "#apikey=..." bootstrap for bookmarks. The fragment never reaches
376
- // the server or Referer, and it is stripped from the address bar and history
377
- // before any request is made, so a copied URL cannot leak the key later.
378
968
  function takeKeyFromHash() {
379
969
  const raw = location.hash.startsWith("#") ? location.hash.slice(1) : "";
380
970
  if (!raw) return "";
@@ -422,6 +1012,7 @@ main {
422
1012
  }
423
1013
 
424
1014
  function formatTime(value) {
1015
+ if (!value) return "—";
425
1016
  const date = new Date(value);
426
1017
  if (Number.isNaN(date.getTime())) return "—";
427
1018
  return date.toLocaleString("zh-CN", {
@@ -440,6 +1031,17 @@ main {
440
1031
  return (value / 1000).toFixed(1) + " s";
441
1032
  }
442
1033
 
1034
+ function formatUptime(ms) {
1035
+ if (!Number.isFinite(ms) || ms < 0) return "—";
1036
+ const sec = Math.floor(ms / 1000);
1037
+ if (sec < 60) return sec + " 秒";
1038
+ const min = Math.floor(sec / 60);
1039
+ if (min < 60) return min + " 分钟";
1040
+ const hr = Math.floor(min / 60);
1041
+ const remMin = min % 60;
1042
+ return hr + " 小时 " + remMin + " 分";
1043
+ }
1044
+
443
1045
  function statusKind(status) {
444
1046
  if (status >= 200 && status < 300) return "ok";
445
1047
  if (status >= 500) return "error";
@@ -452,12 +1054,19 @@ main {
452
1054
  return route;
453
1055
  }
454
1056
 
1057
+ function credSourceLabel(source) {
1058
+ if (source === "env") return "环境变量";
1059
+ if (source === "config") return "配置文件";
1060
+ if (source === "none") return "免凭据";
1061
+ return "未配置";
1062
+ }
1063
+
455
1064
  function safeHref(value) {
456
1065
  try {
457
1066
  const url = new URL(value);
458
1067
  if (url.protocol === "http:" || url.protocol === "https:") return url.href;
459
1068
  } catch {
460
- // Not an absolute URL; render it as text.
1069
+ // Render non-HTTP URLs as plain text.
461
1070
  }
462
1071
  return null;
463
1072
  }
@@ -466,10 +1075,42 @@ main {
466
1075
  list.append(element("dt", null, label), element("dd", null, value));
467
1076
  }
468
1077
 
469
- function renderRecord(record) {
1078
+ function renderAttemptRow(attempt, idx) {
1079
+ const row = element("li", "attempt-row");
1080
+ const badgeKind = attempt.status === "ok" ? "status-ok" : attempt.status === "skipped" ? "status-warn" : "status-error";
1081
+ const badgeLabel = attempt.status === "ok" ? "成功" : attempt.status === "skipped" ? "跳过" : "失败";
1082
+ row.append(
1083
+ element("span", "badge " + badgeKind, "#" + (idx + 1) + " " + badgeLabel),
1084
+ element("span", "attempt-provider", attempt.provider + (attempt.providerType && attempt.providerType !== attempt.provider ? " (" + attempt.providerType + ")" : "")),
1085
+ element("span", "attempt-duration", formatDuration(attempt.durationMs)),
1086
+ );
1087
+ if (attempt.errorCode || attempt.httpStatus) {
1088
+ const codeStr = [attempt.errorCode, attempt.httpStatus ? "HTTP " + attempt.httpStatus : ""].filter(Boolean).join(" · ");
1089
+ row.append(element("span", "badge badge-muted", codeStr));
1090
+ }
1091
+ if (attempt.errorMessage) {
1092
+ row.append(element("span", "attempt-err", attempt.errorMessage));
1093
+ }
1094
+ return row;
1095
+ }
1096
+
1097
+ function recordKey(record, fallbackIdx) {
1098
+ if (record && record.id) return String(record.id);
1099
+ return (
1100
+ String(record.at || "") +
1101
+ ":" +
1102
+ String(record.route || "") +
1103
+ ":" +
1104
+ String(record.query || record.url || record.title || fallbackIdx || "")
1105
+ );
1106
+ }
1107
+
1108
+ function renderRecord(record, recKey) {
470
1109
  const kind = statusKind(record.status);
471
- const item = element("li", "record");
1110
+ const isExpanded = state.expandedIds.has(recKey);
1111
+ const item = element("li", "record" + (isExpanded ? " expanded" : ""));
472
1112
  item.dataset.status = kind;
1113
+ item.dataset.recordKey = recKey;
473
1114
 
474
1115
  const summaryText =
475
1116
  record.query ||
@@ -483,11 +1124,19 @@ main {
483
1124
 
484
1125
  const head = element("button", "record-head");
485
1126
  head.type = "button";
486
- head.setAttribute("aria-expanded", "false");
1127
+ head.setAttribute("aria-expanded", String(isExpanded));
487
1128
  head.append(
488
1129
  element("span", "record-time", formatTime(record.at)),
489
1130
  element("span", "badge route-" + record.route, routeLabel(record.route)),
490
1131
  element("span", "badge status-" + kind, String(record.status)),
1132
+ );
1133
+ if (record.needContent) {
1134
+ head.append(element("span", "badge badge-muted", "含补抓"));
1135
+ }
1136
+ if (Array.isArray(record.attempts) && record.attempts.length > 1) {
1137
+ head.append(element("span", "badge status-warn", record.attempts.length + " 次尝试"));
1138
+ }
1139
+ head.append(
491
1140
  summary,
492
1141
  element("span", "record-provider", record.provider || "—"),
493
1142
  element("span", "record-duration", formatDuration(record.durationMs)),
@@ -502,12 +1151,12 @@ main {
502
1151
  head.append(chevron);
503
1152
 
504
1153
  const detail = element("div", "record-detail");
505
- detail.hidden = true;
1154
+ detail.hidden = !isExpanded;
506
1155
  const meta = element("dl", "record-meta");
507
1156
  appendMeta(meta, "请求 ID", record.id || "—");
508
1157
  appendMeta(meta, "时间", formatTime(record.at));
509
1158
  appendMeta(meta, "耗时", formatDuration(record.durationMs));
510
- appendMeta(meta, "Provider", record.provider || "—");
1159
+ appendMeta(meta, "最终 Provider", record.provider || "—");
511
1160
  if (record.code) appendMeta(meta, "错误码", record.code);
512
1161
  if (record.route === "search" && record.needContent !== undefined) {
513
1162
  appendMeta(meta, "count", record.count === undefined ? "10(默认)" : String(record.count));
@@ -516,11 +1165,72 @@ main {
516
1165
  if (record.title) appendMeta(meta, "页面标题", record.title);
517
1166
  detail.append(meta);
518
1167
 
1168
+ const attempts = Array.isArray(record.attempts) ? record.attempts : [];
1169
+ if (attempts.length > 0) {
1170
+ detail.append(element("div", "detail-label", "Provider 尝试链路(共 " + attempts.length + " 步)"));
1171
+ const attList = element("ol", "attempts-list");
1172
+ attempts.forEach((att, idx) => attList.append(renderAttemptRow(att, idx)));
1173
+ detail.append(attList);
1174
+ }
1175
+
1176
+ if (record.contentBackfill) {
1177
+ const bf = record.contentBackfill;
1178
+ detail.append(
1179
+ element(
1180
+ "div",
1181
+ "detail-label",
1182
+ "正文补抓统计(请求 " + bf.requestedCount + " · 尝试 " + bf.attemptedCount + " · 成功 " + bf.succeededCount + " · 耗时 " + formatDuration(bf.durationMs) + ")",
1183
+ ),
1184
+ );
1185
+ if (Array.isArray(bf.items) && bf.items.length > 0) {
1186
+ const bfList = element("ol", "attempts-list");
1187
+ bf.items.forEach((bfItem, idx) => {
1188
+ const row = element("li", "attempt-row");
1189
+ const bKind = bfItem.status === "ok" ? "status-ok" : bfItem.status === "skipped" ? "status-warn" : "status-error";
1190
+ const bLabel = bfItem.status === "ok" ? "补抓成功" : bfItem.status === "skipped" ? "跳过" : bfItem.status === "aborted" ? "中断" : "补抓失败";
1191
+ row.append(
1192
+ element("span", "badge " + bKind, "#" + (idx + 1) + " " + bLabel),
1193
+ element("span", "attempt-provider", bfItem.provider || "—"),
1194
+ element("span", "attempt-duration", formatDuration(bfItem.durationMs)),
1195
+ );
1196
+ if (bfItem.contentChars !== undefined) {
1197
+ row.append(element("span", "badge badge-muted", bfItem.contentChars + " 字符"));
1198
+ }
1199
+ row.append(element("span", "result-url", bfItem.url));
1200
+ bfList.append(row);
1201
+ });
1202
+ detail.append(bfList);
1203
+ }
1204
+ }
1205
+
1206
+ if (record.contentPreview) {
1207
+ const labelRow = element("div", "detail-label");
1208
+ const charText = record.contentChars
1209
+ ? "抓取正文预览(" + record.contentChars + " 字符" + (record.contentTruncated ? ",已截断预览" : "") + ")"
1210
+ : "抓取正文预览";
1211
+ labelRow.append(element("span", null, charText));
1212
+ const copyBtn = element("button", "btn sm", "复制预览");
1213
+ copyBtn.type = "button";
1214
+ copyBtn.addEventListener("click", () => {
1215
+ if (navigator.clipboard) {
1216
+ void navigator.clipboard.writeText(record.contentPreview).then(() => {
1217
+ copyBtn.textContent = "已复制";
1218
+ setTimeout(() => { copyBtn.textContent = "复制预览"; }, 1200);
1219
+ });
1220
+ }
1221
+ });
1222
+ labelRow.append(copyBtn);
1223
+ detail.append(labelRow);
1224
+ const box = element("div", "content-preview-box");
1225
+ box.append(element("pre", "content-preview-pre", record.contentPreview));
1226
+ detail.append(box);
1227
+ }
1228
+
519
1229
  const results = Array.isArray(record.results) ? record.results : [];
520
1230
  if (results.length > 0) {
521
- detail.append(element("p", "detail-label", "结果标题(前 " + results.length + " 条)"));
1231
+ detail.append(element("div", "detail-label", "搜索结果列表(共 " + results.length + " 条)"));
522
1232
  const list = element("ol", "result-list");
523
- for (const result of results) {
1233
+ results.forEach((result, rIdx) => {
524
1234
  const row = element("li", "result-item");
525
1235
  const href = safeHref(result.url);
526
1236
  if (href) {
@@ -533,16 +1243,39 @@ main {
533
1243
  row.append(element("span", "result-title", result.title || "(无标题)"));
534
1244
  }
535
1245
  row.append(element("span", "result-url", result.url || ""));
1246
+ if (result.snippet) {
1247
+ row.append(element("p", "result-snippet", result.snippet));
1248
+ }
1249
+ if (result.contentPreview) {
1250
+ const previewKey = recKey + "#r" + rIdx;
1251
+ const detailsNode = element("details", "result-preview-details");
1252
+ if (state.expandedPreviews.has(previewKey)) {
1253
+ detailsNode.open = true;
1254
+ }
1255
+ detailsNode.addEventListener("toggle", () => {
1256
+ if (detailsNode.open) state.expandedPreviews.add(previewKey);
1257
+ else state.expandedPreviews.delete(previewKey);
1258
+ });
1259
+ const sumText = "查看补抓正文预览" + (result.contentChars ? " (" + result.contentChars + " 字符" + (result.contentTruncated ? ",已截断" : "") + ")" : "");
1260
+ detailsNode.append(element("summary", null, sumText));
1261
+ const box = element("div", "content-preview-box");
1262
+ box.append(element("pre", "content-preview-pre", result.contentPreview));
1263
+ detailsNode.append(box);
1264
+ row.append(detailsNode);
1265
+ }
536
1266
  list.append(row);
537
- }
1267
+ });
538
1268
  detail.append(list);
539
1269
  }
540
1270
 
541
1271
  head.addEventListener("click", () => {
542
1272
  const expanded = head.getAttribute("aria-expanded") === "true";
543
- head.setAttribute("aria-expanded", String(!expanded));
544
- detail.hidden = expanded;
545
- item.classList.toggle("expanded", !expanded);
1273
+ const next = !expanded;
1274
+ head.setAttribute("aria-expanded", String(next));
1275
+ detail.hidden = !next;
1276
+ item.classList.toggle("expanded", next);
1277
+ if (next) state.expandedIds.add(recKey);
1278
+ else state.expandedIds.delete(recKey);
546
1279
  });
547
1280
  item.append(head, detail);
548
1281
  return item;
@@ -553,13 +1286,56 @@ main {
553
1286
  const ok = record.status >= 200 && record.status < 300;
554
1287
  if (state.status === "ok" && !ok) return false;
555
1288
  if (state.status === "error" && ok) return false;
1289
+ if (state.providerFilter) {
1290
+ const matchedMain = record.provider === state.providerFilter;
1291
+ const matchedAttempt = Array.isArray(record.attempts) && record.attempts.some((a) => a.provider === state.providerFilter);
1292
+ if (!matchedMain && !matchedAttempt) return false;
1293
+ }
556
1294
  const needle = state.text.trim().toLowerCase();
557
1295
  if (!needle) return true;
558
1296
  const parts = [record.query, record.url, record.provider, record.code, record.title];
559
- for (const result of record.results || []) parts.push(result.title, result.url);
1297
+ for (const result of record.results || []) parts.push(result.title, result.url, result.snippet);
1298
+ for (const att of record.attempts || []) parts.push(att.provider, att.errorCode, att.errorMessage);
560
1299
  return parts.filter(Boolean).join(" ").toLowerCase().includes(needle);
561
1300
  }
562
1301
 
1302
+ function updateProviderFilterDropdown() {
1303
+ const select = byId("filter-provider");
1304
+ const prev = state.providerFilter;
1305
+ const names = new Set();
1306
+ for (const rec of state.records) {
1307
+ if (rec.provider) names.add(rec.provider);
1308
+ for (const att of rec.attempts || []) {
1309
+ if (att.provider) names.add(att.provider);
1310
+ }
1311
+ }
1312
+ if (state.overview && Array.isArray(state.overview.providers)) {
1313
+ for (const p of state.overview.providers) {
1314
+ if (p.routeRole.search !== "disabled" || p.routeRole.extract !== "disabled") {
1315
+ names.add(p.id);
1316
+ }
1317
+ }
1318
+ }
1319
+ const sorted = Array.from(names).sort();
1320
+ const currentValues = Array.from(select.options).map((o) => o.value);
1321
+ const nextValues = ["", ...sorted];
1322
+ const sameOptions =
1323
+ currentValues.length === nextValues.length &&
1324
+ currentValues.every((val, i) => val === nextValues[i]);
1325
+ if (sameOptions) {
1326
+ if (select.value !== prev) select.value = prev;
1327
+ return;
1328
+ }
1329
+ select.replaceChildren(element("option", null, "全部 Provider"));
1330
+ select.firstChild.value = "";
1331
+ for (const id of sorted) {
1332
+ const opt = element("option", null, id);
1333
+ opt.value = id;
1334
+ if (id === prev) opt.selected = true;
1335
+ select.append(opt);
1336
+ }
1337
+ }
1338
+
563
1339
  function renderStats() {
564
1340
  let ok = 0;
565
1341
  let bad = 0;
@@ -579,24 +1355,758 @@ main {
579
1355
  byId("stat-avg").textContent = counted > 0 ? formatDuration(totalDuration / counted) : "—";
580
1356
  }
581
1357
 
582
- function render() {
1358
+ function renderRecordsTab() {
583
1359
  renderStats();
1360
+ updateProviderFilterDropdown();
584
1361
  const list = byId("records");
585
- list.replaceChildren();
586
1362
  const visible = state.records.filter(matches);
587
- for (const record of visible) list.append(renderRecord(record));
1363
+ const existingNodes = new Map();
1364
+ for (const child of Array.from(list.children)) {
1365
+ const k = child.dataset && child.dataset.recordKey;
1366
+ if (k) existingNodes.set(k, child);
1367
+ }
1368
+ const keepKeys = new Set();
1369
+ const nextNodes = visible.map((record, idx) => {
1370
+ const k = recordKey(record, idx);
1371
+ keepKeys.add(k);
1372
+ const existing = existingNodes.get(k);
1373
+ if (existing) {
1374
+ const isExp = state.expandedIds.has(k);
1375
+ existing.classList.toggle("expanded", isExp);
1376
+ const h = existing.querySelector(".record-head");
1377
+ const d = existing.querySelector(".record-detail");
1378
+ if (h) h.setAttribute("aria-expanded", String(isExp));
1379
+ if (d) d.hidden = !isExp;
1380
+ return existing;
1381
+ }
1382
+ return renderRecord(record, k);
1383
+ });
1384
+ for (const child of Array.from(list.children)) {
1385
+ const k = child.dataset && child.dataset.recordKey;
1386
+ if (!k || !keepKeys.has(k)) {
1387
+ child.remove();
1388
+ }
1389
+ }
1390
+ for (let i = 0; i < nextNodes.length; i += 1) {
1391
+ const node = nextNodes[i];
1392
+ const currentAtIdx = list.children[i];
1393
+ if (currentAtIdx !== node) {
1394
+ list.insertBefore(node, currentAtIdx || null);
1395
+ }
1396
+ }
588
1397
  byId("empty").hidden = visible.length > 0;
589
1398
  if (visible.length === 0) {
590
1399
  byId("empty-title").textContent = state.records.length > 0 ? "没有匹配的记录" : "还没有请求记录";
591
1400
  byId("empty-text").textContent = state.records.length > 0
592
- ? "调整类型、状态或文本筛选后再试。"
593
- : "调用一次 /v1/tools/search 或 /v1/tools/fetch 后,这里会显示时间和结果。";
1401
+ ? "调整类型、状态、Provider 或文本筛选后再试。"
1402
+ : "调用一次 /v1/tools/search 或 /v1/tools/fetch 后,这里会显示完整链路、耗时与结果预览。";
594
1403
  }
595
1404
  byId("record-count").textContent = visible.length === state.records.length
596
1405
  ? "已加载 " + state.records.length + " 条"
597
1406
  : "已加载 " + state.records.length + " 条,筛选后 " + visible.length + " 条";
598
1407
  }
599
1408
 
1409
+ function renderRouteChainNodes(container, ids, providerMap, capability) {
1410
+ container.replaceChildren();
1411
+ if (!ids || ids.length === 0) {
1412
+ container.append(element("span", "hint", "(未配置)"));
1413
+ return;
1414
+ }
1415
+ ids.forEach((id, idx) => {
1416
+ if (idx > 0) container.append(element("span", "hint", "→"));
1417
+ const p = providerMap.get(id);
1418
+ const ready = p ? (capability === "search" ? p.configured.searchReady : p.configured.extractReady) : false;
1419
+ const node = element("span", "route-node " + (ready ? "ready" : "unready"));
1420
+ node.append(
1421
+ element("strong", null, "#" + (idx + 1) + " " + id),
1422
+ element("span", null, ready ? "就绪" : "未就绪"),
1423
+ );
1424
+ container.append(node);
1425
+ });
1426
+ }
1427
+
1428
+ function renderOverviewTab() {
1429
+ const ov = state.overview;
1430
+ if (!ov) return;
1431
+
1432
+ const persistPill = byId("persist-pill");
1433
+ if (!ov.gateway.configPersisted) {
1434
+ persistPill.hidden = false;
1435
+ byId("persist-text").textContent = "只读配置·仅内存生效";
1436
+ } else {
1437
+ persistPill.hidden = true;
1438
+ }
1439
+
1440
+ const st = ov.stats;
1441
+ byId("ov-total-req").textContent = String(st.totalRequests);
1442
+ byId("ov-uptime").textContent = "运行时间 " + formatUptime(ov.gateway.uptimeMs);
1443
+ byId("ov-search-stat").textContent = st.searchSuccesses + " / " + st.searchRequests;
1444
+ byId("ov-doctor-search").textContent = ov.doctor.routes.search ? "Search 路由就绪" : "Search 路由缺可用实例";
1445
+ byId("ov-fetch-stat").textContent = st.fetchSuccesses + " / " + st.fetchRequests;
1446
+ byId("ov-doctor-extract").textContent = ov.doctor.routes.extract ? "Extract 路由就绪" : "Extract 路由缺可用实例";
1447
+ byId("ov-backfill-stat").textContent = st.backfillItemsSucceeded + " / " + (st.backfillItemsSucceeded + st.backfillItemsFailed);
1448
+ byId("ov-config-mode").textContent = ov.gateway.configPersisted ? "已同步配置文件" : "仅内存生效 (只读挂载)";
1449
+
1450
+ byId("ov-search-params").textContent =
1451
+ "limit=" + ov.routes.search.limit + " · timeout=" + formatDuration(ov.routes.search.timeoutMs) + " · attempt=" + formatDuration(ov.routes.search.attemptTimeoutMs);
1452
+ byId("ov-extract-params").textContent =
1453
+ "minChars=" + ov.routes.extract.minContentCharacters + " · timeout=" + formatDuration(ov.routes.extract.timeoutMs) + " · attempt=" + formatDuration(ov.routes.extract.attemptTimeoutMs);
1454
+
1455
+ const providerMap = new Map(ov.providers.map((p) => [p.id, p]));
1456
+ renderRouteChainNodes(byId("ov-search-primary"), ov.routes.search.providers, providerMap, "search");
1457
+ renderRouteChainNodes(byId("ov-search-fallback"), ov.routes.search.providers_fallback, providerMap, "search");
1458
+ renderRouteChainNodes(byId("ov-extract-primary"), ov.routes.extract.providers, providerMap, "extract");
1459
+ renderRouteChainNodes(byId("ov-extract-fallback"), ov.routes.extract.providers_fallback, providerMap, "extract");
1460
+
1461
+ const tbody = byId("provider-stats-tbody");
1462
+ tbody.replaceChildren();
1463
+ const filtered = ov.providers.filter((p) => {
1464
+ if (state.pfilter === "routed") {
1465
+ return p.routeRole.search !== "disabled" || p.routeRole.extract !== "disabled";
1466
+ }
1467
+ if (state.pfilter === "ready") {
1468
+ return p.configured.searchReady || p.configured.extractReady;
1469
+ }
1470
+ return true;
1471
+ });
1472
+
1473
+ for (const p of filtered) {
1474
+ const tr = element("tr");
1475
+ const tdId = element("td");
1476
+ tdId.append(
1477
+ element("strong", "mono", p.id),
1478
+ element("span", "badge badge-muted", p.type),
1479
+ );
1480
+ if (!p.isBuiltin) {
1481
+ tdId.append(element("span", "badge route-search", "自定义"));
1482
+ }
1483
+
1484
+ const tdRole = element("td");
1485
+ const roles = [];
1486
+ if (p.routeRole.search !== "disabled") {
1487
+ roles.push("S:" + (p.routeRole.search === "primary" ? "主#" : "备#") + (p.routeRole.searchIndex + 1));
1488
+ }
1489
+ if (p.routeRole.extract !== "disabled") {
1490
+ roles.push("E:" + (p.routeRole.extract === "primary" ? "主#" : "备#") + (p.routeRole.extractIndex + 1));
1491
+ }
1492
+ tdRole.textContent = roles.length > 0 ? roles.join(" · ") : "未启用";
1493
+ tdRole.className = "mono";
1494
+
1495
+ const tdReady = element("td");
1496
+ if (p.capabilities.includes("search")) {
1497
+ tdReady.append(
1498
+ element("span", "badge " + (p.configured.searchReady ? "status-ok" : "status-warn"), p.configured.searchReady ? "Search就绪" : "Search未配"),
1499
+ );
1500
+ }
1501
+ if (p.capabilities.includes("extract")) {
1502
+ tdReady.append(
1503
+ element("span", "badge " + (p.configured.extractReady ? "status-ok" : "status-warn"), p.configured.extractReady ? "Extract就绪" : "Extract未配"),
1504
+ );
1505
+ }
1506
+
1507
+ const tdCred = element("td");
1508
+ tdCred.append(
1509
+ element("span", "badge badge-muted", credSourceLabel(p.credentialSource)),
1510
+ );
1511
+ if (p.config.effectiveBaseUrl) {
1512
+ const urlSpan = element("div", "result-url", p.config.effectiveBaseUrl);
1513
+ tdCred.append(urlSpan);
1514
+ }
1515
+
1516
+ const sStat = p.stats.search;
1517
+ const tdSearch = element("td", "mono");
1518
+ tdSearch.textContent = p.capabilities.includes("search")
1519
+ ? sStat.successes + "/" + sStat.attempts + " · " + (sStat.attempts > 0 ? formatDuration(sStat.avgDurationMs) : "—")
1520
+ : "—";
1521
+
1522
+ const eStat = p.stats.extract;
1523
+ const tdExtract = element("td", "mono");
1524
+ tdExtract.textContent = p.capabilities.includes("extract")
1525
+ ? eStat.successes + "/" + eStat.attempts + " · " + (eStat.attempts > 0 ? formatDuration(eStat.avgDurationMs) : "—")
1526
+ : "—";
1527
+
1528
+ const tdLast = element("td", "mono");
1529
+ const errEntries = Object.entries({ ...sStat.errorCodes, ...eStat.errorCodes });
1530
+ if (errEntries.length > 0) {
1531
+ tdLast.textContent = errEntries.map(([k, v]) => k + ":" + v).join(", ");
1532
+ } else if (sStat.lastUsedAt || eStat.lastUsedAt) {
1533
+ tdLast.textContent = "正常 · " + formatTime(Math.max(sStat.lastUsedAt || 0, eStat.lastUsedAt || 0));
1534
+ } else {
1535
+ tdLast.textContent = "暂无调用";
1536
+ }
1537
+
1538
+ const tdAct = element("td");
1539
+ const editBtn = element("button", "btn sm", "配置");
1540
+ editBtn.type = "button";
1541
+ editBtn.addEventListener("click", () => {
1542
+ switchTab("config");
1543
+ openProviderEditor(p.id, false);
1544
+ });
1545
+ tdAct.append(editBtn);
1546
+
1547
+ tr.append(tdId, tdRole, tdReady, tdCred, tdSearch, tdExtract, tdLast, tdAct);
1548
+ tbody.append(tr);
1549
+ }
1550
+ }
1551
+
1552
+ function cloneOverviewToDraft(ov) {
1553
+ return {
1554
+ search: {
1555
+ providers: [...ov.routes.search.providers],
1556
+ providers_fallback: [...ov.routes.search.providers_fallback],
1557
+ limit: ov.routes.search.limit,
1558
+ timeoutMs: ov.routes.search.timeoutMs,
1559
+ attemptTimeoutMs: ov.routes.search.attemptTimeoutMs,
1560
+ maxResponseBytes: ov.routes.search.maxResponseBytes,
1561
+ },
1562
+ extract: {
1563
+ providers: [...ov.routes.extract.providers],
1564
+ providers_fallback: [...ov.routes.extract.providers_fallback],
1565
+ timeoutMs: ov.routes.extract.timeoutMs,
1566
+ attemptTimeoutMs: ov.routes.extract.attemptTimeoutMs,
1567
+ minContentCharacters: ov.routes.extract.minContentCharacters,
1568
+ maxResponseBytes: ov.routes.extract.maxResponseBytes,
1569
+ },
1570
+ providerPatches: {},
1571
+ removedProviders: [],
1572
+ };
1573
+ }
1574
+
1575
+ function setDirty(dirty) {
1576
+ state.dirty = dirty;
1577
+ byId("cfg-dirty-badge").hidden = !dirty;
1578
+ }
1579
+
1580
+ function getEffectiveProvidersList() {
1581
+ const ov = state.overview;
1582
+ if (!ov || !state.draft) return [];
1583
+ const removed = new Set(state.draft.removedProviders);
1584
+ const map = new Map();
1585
+ for (const p of ov.providers) {
1586
+ if (!removed.has(p.id)) {
1587
+ map.set(p.id, { ...p, draftPatch: state.draft.providerPatches[p.id] || null });
1588
+ }
1589
+ }
1590
+ for (const [id, patch] of Object.entries(state.draft.providerPatches)) {
1591
+ if (!map.has(id) && !removed.has(id)) {
1592
+ const typeInfo = ov.providerTypes.find((t) => t.type === patch.type);
1593
+ map.set(id, {
1594
+ id,
1595
+ type: patch.type,
1596
+ isBuiltin: false,
1597
+ capabilities: typeInfo ? typeInfo.capabilities : ["search"],
1598
+ configured: {
1599
+ credential: patch.apiKeyAction === "set" || Boolean(patch.apiKeyEnv),
1600
+ baseUrl: true,
1601
+ adapter: true,
1602
+ searchReady: patch.apiKeyAction === "set" || Boolean(patch.apiKeyEnv),
1603
+ extractReady: patch.apiKeyAction === "set" || Boolean(patch.apiKeyEnv),
1604
+ },
1605
+ credentialSource: patch.apiKeyAction === "set" ? "config" : patch.apiKeyEnv ? "env" : "missing",
1606
+ config: {
1607
+ hasConfigApiKey: patch.apiKeyAction === "set",
1608
+ apiKeyEnv: patch.apiKeyEnv || undefined,
1609
+ configBaseUrl: patch.baseUrl || undefined,
1610
+ effectiveBaseUrl: patch.baseUrl || null,
1611
+ baseUrlEnv: patch.baseUrlEnv || undefined,
1612
+ authJson: patch.authJson || undefined,
1613
+ authJsonEnv: patch.authJsonEnv || undefined,
1614
+ model: patch.model || undefined,
1615
+ modelEnv: patch.modelEnv || undefined,
1616
+ searchFilterMode: patch.searchFilterMode || null,
1617
+ headerNames: [],
1618
+ },
1619
+ draftPatch: patch,
1620
+ });
1621
+ }
1622
+ }
1623
+ return Array.from(map.values());
1624
+ }
1625
+
1626
+ function moveItem(arr, fromIdx, toIdx) {
1627
+ if (toIdx < 0 || toIdx >= arr.length) return;
1628
+ const [moved] = arr.splice(fromIdx, 1);
1629
+ arr.splice(toIdx, 0, moved);
1630
+ setDirty(true);
1631
+ renderConfigTab();
1632
+ }
1633
+
1634
+ function renderRouteBucket(container, list, otherList, capability, kind, providerMap) {
1635
+ container.replaceChildren();
1636
+ if (list.length === 0) {
1637
+ const emptyLi = element("li", "route-editor-item");
1638
+ emptyLi.append(element("span", "hint", "(空)"));
1639
+ container.append(emptyLi);
1640
+ return;
1641
+ }
1642
+ list.forEach((id, idx) => {
1643
+ const p = providerMap.get(id);
1644
+ const ready = p ? (capability === "search" ? p.configured.searchReady : p.configured.extractReady) : false;
1645
+ const li = element("li", "route-editor-item");
1646
+ const left = element("div", null);
1647
+ left.style.display = "flex";
1648
+ left.style.alignItems = "center";
1649
+ left.style.gap = "8px";
1650
+ left.append(
1651
+ element("strong", "mono", "#" + (idx + 1) + " " + id),
1652
+ element("span", "badge " + (ready ? "status-ok" : "status-warn"), ready ? "就绪" : "缺凭据"),
1653
+ );
1654
+ const actions = element("div", "route-editor-actions");
1655
+ const upBtn = element("button", "btn sm", "↑");
1656
+ upBtn.type = "button";
1657
+ upBtn.disabled = idx === 0;
1658
+ upBtn.addEventListener("click", () => moveItem(list, idx, idx - 1));
1659
+
1660
+ const downBtn = element("button", "btn sm", "↓");
1661
+ downBtn.type = "button";
1662
+ downBtn.disabled = idx === list.length - 1;
1663
+ downBtn.addEventListener("click", () => moveItem(list, idx, idx + 1));
1664
+
1665
+ const swapBtn = element("button", "btn sm", kind === "primary" ? "转兜底" : "转主路由");
1666
+ swapBtn.type = "button";
1667
+ swapBtn.addEventListener("click", () => {
1668
+ list.splice(idx, 1);
1669
+ if (!otherList.includes(id)) otherList.push(id);
1670
+ setDirty(true);
1671
+ renderConfigTab();
1672
+ });
1673
+
1674
+ const delBtn = element("button", "btn sm danger", "移除");
1675
+ delBtn.type = "button";
1676
+ delBtn.addEventListener("click", () => {
1677
+ list.splice(idx, 1);
1678
+ setDirty(true);
1679
+ renderConfigTab();
1680
+ });
1681
+
1682
+ actions.append(upBtn, downBtn, swapBtn, delBtn);
1683
+ li.append(left, actions);
1684
+ container.append(li);
1685
+ });
1686
+ }
1687
+
1688
+ function renderAvailableBucket(container, primaryList, fallbackList, capability, allProviders) {
1689
+ container.replaceChildren();
1690
+ const used = new Set([...primaryList, ...fallbackList]);
1691
+ const candidates = allProviders.filter((p) => p.capabilities.includes(capability) && !used.has(p.id));
1692
+ if (candidates.length === 0) {
1693
+ const emptyLi = element("li", "route-editor-item");
1694
+ emptyLi.append(element("span", "hint", "所有支持该能力的实例均已编入路由"));
1695
+ container.append(emptyLi);
1696
+ return;
1697
+ }
1698
+ for (const p of candidates) {
1699
+ const ready = capability === "search" ? p.configured.searchReady : p.configured.extractReady;
1700
+ const li = element("li", "route-editor-item");
1701
+ const left = element("div", null);
1702
+ left.style.display = "flex";
1703
+ left.style.alignItems = "center";
1704
+ left.style.gap = "8px";
1705
+ left.append(
1706
+ element("span", "mono", p.id),
1707
+ element("span", "badge " + (ready ? "status-ok" : "status-warn"), ready ? "就绪" : "缺凭据"),
1708
+ );
1709
+ const actions = element("div", "route-editor-actions");
1710
+ const addPrimary = element("button", "btn sm", "+ 主路由");
1711
+ addPrimary.type = "button";
1712
+ addPrimary.addEventListener("click", () => {
1713
+ primaryList.push(p.id);
1714
+ setDirty(true);
1715
+ renderConfigTab();
1716
+ });
1717
+ const addFallback = element("button", "btn sm", "+ 兜底");
1718
+ addFallback.type = "button";
1719
+ addFallback.addEventListener("click", () => {
1720
+ fallbackList.push(p.id);
1721
+ setDirty(true);
1722
+ renderConfigTab();
1723
+ });
1724
+ actions.append(addPrimary, addFallback);
1725
+ li.append(left, actions);
1726
+ container.append(li);
1727
+ }
1728
+ }
1729
+
1730
+ function renderConfigTab() {
1731
+ const ov = state.overview;
1732
+ if (!ov) return;
1733
+ if (!state.draft) {
1734
+ state.draft = cloneOverviewToDraft(ov);
1735
+ setDirty(false);
1736
+ }
1737
+
1738
+ const modeDesc = ov.gateway.configPersisted
1739
+ ? "可写并同步磁盘"
1740
+ : "只读挂载(修改在内存立即生效)";
1741
+ byId("cfg-file-label").textContent = "配置文件:" + ov.gateway.configPath + " · " + modeDesc;
1742
+
1743
+ const d = state.draft;
1744
+ byId("cfg-s-limit").value = String(d.search.limit);
1745
+ byId("cfg-s-timeout").value = String(d.search.timeoutMs);
1746
+ byId("cfg-s-attempt").value = String(d.search.attemptTimeoutMs);
1747
+ byId("cfg-s-bytes").value = String(d.search.maxResponseBytes);
1748
+
1749
+ byId("cfg-e-minchars").value = String(d.extract.minContentCharacters);
1750
+ byId("cfg-e-timeout").value = String(d.extract.timeoutMs);
1751
+ byId("cfg-e-attempt").value = String(d.extract.attemptTimeoutMs);
1752
+ byId("cfg-e-bytes").value = String(d.extract.maxResponseBytes);
1753
+
1754
+ const allProviders = getEffectiveProvidersList();
1755
+ const providerMap = new Map(allProviders.map((p) => [p.id, p]));
1756
+
1757
+ renderRouteBucket(byId("cfg-s-primary"), d.search.providers, d.search.providers_fallback, "search", "primary", providerMap);
1758
+ renderRouteBucket(byId("cfg-s-fallback"), d.search.providers_fallback, d.search.providers, "search", "fallback", providerMap);
1759
+ renderAvailableBucket(byId("cfg-s-available"), d.search.providers, d.search.providers_fallback, "search", allProviders);
1760
+
1761
+ renderRouteBucket(byId("cfg-e-primary"), d.extract.providers, d.extract.providers_fallback, "extract", "primary", providerMap);
1762
+ renderRouteBucket(byId("cfg-e-fallback"), d.extract.providers_fallback, d.extract.providers, "extract", "fallback", providerMap);
1763
+ renderAvailableBucket(byId("cfg-e-available"), d.extract.providers, d.extract.providers_fallback, "extract", allProviders);
1764
+
1765
+ const tbody = byId("cfg-providers-tbody");
1766
+ tbody.replaceChildren();
1767
+ for (const p of allProviders) {
1768
+ const tr = element("tr");
1769
+ const tdId = element("td", "mono", p.id);
1770
+ const tdType = element("td", "mono", p.type);
1771
+ const tdCaps = element("td", "mono", p.capabilities.join(", "));
1772
+ const tdCred = element("td");
1773
+ tdCred.append(
1774
+ element("span", "badge " + (p.credentialSource !== "missing" ? "status-ok" : "status-warn"), credSourceLabel(p.credentialSource)),
1775
+ );
1776
+ if (p.config.apiKeyEnv) {
1777
+ tdCred.append(element("span", "badge badge-muted", "env:" + p.config.apiKeyEnv));
1778
+ }
1779
+
1780
+ const tdEnd = element("td", "mono");
1781
+ tdEnd.textContent = p.config.effectiveBaseUrl || "—";
1782
+ if (p.config.searchFilterMode) {
1783
+ tdEnd.append(element("span", "badge badge-muted", "filter:" + p.config.searchFilterMode));
1784
+ }
1785
+
1786
+ const tdDraft = element("td");
1787
+ if (p.draftPatch) {
1788
+ tdDraft.append(element("span", "badge status-warn", "待保存修改"));
1789
+ } else {
1790
+ tdDraft.append(element("span", "hint", "已同步"));
1791
+ }
1792
+
1793
+ const tdAct = element("td");
1794
+ const editBtn = element("button", "btn sm", "编辑实例");
1795
+ editBtn.type = "button";
1796
+ editBtn.addEventListener("click", () => openProviderEditor(p.id, false));
1797
+ tdAct.append(editBtn);
1798
+
1799
+ tr.append(tdId, tdType, tdCaps, tdCred, tdEnd, tdDraft, tdAct);
1800
+ tbody.append(tr);
1801
+ }
1802
+ }
1803
+
1804
+ function updateProviderEditorTypeFields(type) {
1805
+ const ov = state.overview;
1806
+ const typeInfo = ov && ov.providerTypes ? ov.providerTypes.find((t) => t.type === type) : null;
1807
+ const supportsFilter = typeInfo ? typeInfo.supportsFilterMode : (type === "anysearch" || type === "xcrawl");
1808
+ const isXai = typeInfo ? typeInfo.isXai : (type === "xai_x_search" || type === "xai_web_search");
1809
+ byId("pe-filter-wrap").hidden = !supportsFilter;
1810
+ byId("pe-authjson-wrap").hidden = !isXai;
1811
+ byId("pe-authjson-env-wrap").hidden = !isXai;
1812
+ byId("pe-model-wrap").hidden = !isXai;
1813
+ byId("pe-model-env-wrap").hidden = !isXai;
1814
+ }
1815
+
1816
+ function openProviderEditor(id, isNew) {
1817
+ const ov = state.overview;
1818
+ if (!ov || !state.draft) return;
1819
+ state.editingProviderId = id;
1820
+ state.editingIsNew = isNew;
1821
+ byId("pe-error").hidden = true;
1822
+
1823
+ const typeSelect = byId("pe-type");
1824
+ typeSelect.replaceChildren();
1825
+ for (const pt of ov.providerTypes) {
1826
+ const opt = element("option", null, pt.type + " (" + pt.capabilities.join("/") + ")");
1827
+ opt.value = pt.type;
1828
+ typeSelect.append(opt);
1829
+ }
1830
+
1831
+ const allProviders = getEffectiveProvidersList();
1832
+ const existing = !isNew ? allProviders.find((p) => p.id === id) : null;
1833
+ const patch = (!isNew && state.draft.providerPatches[id]) || null;
1834
+
1835
+ byId("pe-title").textContent = isNew ? "新增自定义 Provider 实例" : "编辑实例:" + id;
1836
+ const idInput = byId("pe-id");
1837
+ idInput.value = isNew ? "" : id;
1838
+ idInput.readOnly = !isNew;
1839
+
1840
+ const selectedType = patch ? patch.type : existing ? existing.type : ov.providerTypes[0].type;
1841
+ typeSelect.value = selectedType;
1842
+ typeSelect.disabled = Boolean(existing && existing.isBuiltin);
1843
+
1844
+ const keyAction = patch ? (patch.apiKeyAction || "keep") : "keep";
1845
+ byId("pe-key-action").value = keyAction;
1846
+ byId("pe-key-field").hidden = keyAction !== "set";
1847
+ byId("pe-key").value = patch && patch.apiKey ? patch.apiKey : "";
1848
+
1849
+ byId("pe-key-env").value = patch && patch.apiKeyEnv !== undefined
1850
+ ? (patch.apiKeyEnv || "")
1851
+ : (existing && existing.config.apiKeyEnv) || "";
1852
+ byId("pe-base-url").value = patch && patch.baseUrl !== undefined
1853
+ ? (patch.baseUrl || "")
1854
+ : (existing && existing.config.configBaseUrl) || "";
1855
+ byId("pe-base-url-env").value = patch && patch.baseUrlEnv !== undefined
1856
+ ? (patch.baseUrlEnv || "")
1857
+ : (existing && existing.config.baseUrlEnv) || "";
1858
+ byId("pe-filter-mode").value = patch && patch.searchFilterMode
1859
+ ? patch.searchFilterMode
1860
+ : (existing && existing.config.searchFilterMode) || "strict";
1861
+ byId("pe-auth-json").value = patch && patch.authJson !== undefined
1862
+ ? (patch.authJson || "")
1863
+ : (existing && existing.config.authJson) || "";
1864
+ byId("pe-auth-json-env").value = patch && patch.authJsonEnv !== undefined
1865
+ ? (patch.authJsonEnv || "")
1866
+ : (existing && existing.config.authJsonEnv) || "";
1867
+ byId("pe-model").value = patch && patch.model !== undefined
1868
+ ? (patch.model || "")
1869
+ : (existing && existing.config.model) || "";
1870
+ byId("pe-model-env").value = patch && patch.modelEnv !== undefined
1871
+ ? (patch.modelEnv || "")
1872
+ : (existing && existing.config.modelEnv) || "";
1873
+
1874
+ updateProviderEditorTypeFields(selectedType);
1875
+ byId("pe-delete").hidden = isNew || Boolean(existing && existing.isBuiltin);
1876
+ byId("provider-editor").hidden = false;
1877
+ idInput.focus();
1878
+ }
1879
+
1880
+ function applyProviderEditorToDraft() {
1881
+ const rawId = byId("pe-id").value.trim().toLowerCase();
1882
+ const errNode = byId("pe-error");
1883
+ if (!rawId || rawId === "auto" || /\\s/.test(rawId)) {
1884
+ errNode.textContent = "实例 ID 不能为空、不能包含空白且不能为 auto。";
1885
+ errNode.hidden = false;
1886
+ return;
1887
+ }
1888
+ const type = byId("pe-type").value;
1889
+ const keyAction = byId("pe-key-action").value;
1890
+ const keyVal = byId("pe-key").value.trim();
1891
+ if (keyAction === "set" && !keyVal) {
1892
+ errNode.textContent = "选择写入新 API Key 时,必须填写非空 Key。";
1893
+ errNode.hidden = false;
1894
+ return;
1895
+ }
1896
+ const patch = {
1897
+ id: rawId,
1898
+ type,
1899
+ apiKeyAction: keyAction,
1900
+ ...(keyAction === "set" ? { apiKey: keyVal } : {}),
1901
+ apiKeyEnv: byId("pe-key-env").value.trim() || null,
1902
+ baseUrl: byId("pe-base-url").value.trim() || null,
1903
+ baseUrlEnv: byId("pe-base-url-env").value.trim() || null,
1904
+ };
1905
+ if (!byId("pe-filter-wrap").hidden) {
1906
+ patch.searchFilterMode = byId("pe-filter-mode").value;
1907
+ }
1908
+ if (!byId("pe-authjson-wrap").hidden) {
1909
+ patch.authJson = byId("pe-auth-json").value.trim() || null;
1910
+ patch.authJsonEnv = byId("pe-auth-json-env").value.trim() || null;
1911
+ patch.model = byId("pe-model").value.trim() || null;
1912
+ patch.modelEnv = byId("pe-model-env").value.trim() || null;
1913
+ }
1914
+ state.draft.providerPatches[rawId] = patch;
1915
+ state.draft.removedProviders = state.draft.removedProviders.filter((item) => item !== rawId);
1916
+ byId("provider-editor").hidden = true;
1917
+ setDirty(true);
1918
+ renderConfigTab();
1919
+ showConfigFeedback("ok", "实例 " + rawId + " 已暂存到配置草稿,点击顶部「保存并应用配置」正式生效。");
1920
+ }
1921
+
1922
+ function deleteCustomProviderFromDraft() {
1923
+ const id = state.editingProviderId;
1924
+ if (!id || !state.draft) return;
1925
+ delete state.draft.providerPatches[id];
1926
+ if (!state.draft.removedProviders.includes(id)) {
1927
+ state.draft.removedProviders.push(id);
1928
+ }
1929
+ state.draft.search.providers = state.draft.search.providers.filter((x) => x !== id);
1930
+ state.draft.search.providers_fallback = state.draft.search.providers_fallback.filter((x) => x !== id);
1931
+ state.draft.extract.providers = state.draft.extract.providers.filter((x) => x !== id);
1932
+ state.draft.extract.providers_fallback = state.draft.extract.providers_fallback.filter((x) => x !== id);
1933
+ byId("provider-editor").hidden = true;
1934
+ setDirty(true);
1935
+ renderConfigTab();
1936
+ showConfigFeedback("warn", "已从草稿移除自定义实例 " + id + ",请点击「保存并应用配置」完成提交。");
1937
+ }
1938
+
1939
+ function showConfigFeedback(kind, message) {
1940
+ const node = byId("cfg-feedback");
1941
+ node.className = "banner" + (kind === "ok" ? " ok" : kind === "warn" ? " warn" : "");
1942
+ node.textContent = message;
1943
+ node.hidden = false;
1944
+ }
1945
+
1946
+ function readNumberInputsToDraft() {
1947
+ if (!state.draft) return;
1948
+ const sLimit = Number(byId("cfg-s-limit").value);
1949
+ const sTimeout = Number(byId("cfg-s-timeout").value);
1950
+ const sAttempt = Number(byId("cfg-s-attempt").value);
1951
+ const sBytes = Number(byId("cfg-s-bytes").value);
1952
+ if (Number.isInteger(sLimit) && sLimit >= 1) state.draft.search.limit = sLimit;
1953
+ if (Number.isInteger(sTimeout) && sTimeout >= 1) state.draft.search.timeoutMs = sTimeout;
1954
+ if (Number.isInteger(sAttempt) && sAttempt >= 1) state.draft.search.attemptTimeoutMs = sAttempt;
1955
+ if (Number.isInteger(sBytes) && sBytes >= 1024) state.draft.search.maxResponseBytes = sBytes;
1956
+
1957
+ const eMinChars = Number(byId("cfg-e-minchars").value);
1958
+ const eTimeout = Number(byId("cfg-e-timeout").value);
1959
+ const eAttempt = Number(byId("cfg-e-attempt").value);
1960
+ const eBytes = Number(byId("cfg-e-bytes").value);
1961
+ if (Number.isInteger(eMinChars) && eMinChars >= 1) state.draft.extract.minContentCharacters = eMinChars;
1962
+ if (Number.isInteger(eTimeout) && eTimeout >= 1) state.draft.extract.timeoutMs = eTimeout;
1963
+ if (Number.isInteger(eAttempt) && eAttempt >= 1) state.draft.extract.attemptTimeoutMs = eAttempt;
1964
+ if (Number.isInteger(eBytes) && eBytes >= 1024) state.draft.extract.maxResponseBytes = eBytes;
1965
+ }
1966
+
1967
+ async function saveConfig() {
1968
+ if (!state.key || !state.draft) return;
1969
+ readNumberInputsToDraft();
1970
+ const btn = byId("save-config");
1971
+ btn.disabled = true;
1972
+ btn.textContent = "保存中…";
1973
+ try {
1974
+ const payload = {
1975
+ search: state.draft.search,
1976
+ extract: state.draft.extract,
1977
+ providers: Object.values(state.draft.providerPatches),
1978
+ removeProviders: state.draft.removedProviders,
1979
+ };
1980
+ const response = await fetch("/admin/api/config", {
1981
+ method: "PUT",
1982
+ headers: {
1983
+ Authorization: "Bearer " + state.key,
1984
+ "Content-Type": "application/json",
1985
+ },
1986
+ body: JSON.stringify(payload),
1987
+ });
1988
+ if (response.status === 401) {
1989
+ handleUnauthorized();
1990
+ return;
1991
+ }
1992
+ const data = await response.json();
1993
+ if (!response.ok) {
1994
+ const msg = data && data.error && data.error.message ? data.error.message : "保存失败 (HTTP " + response.status + ")";
1995
+ showConfigFeedback("error", msg);
1996
+ return;
1997
+ }
1998
+ state.overview = data.overview;
1999
+ state.draft = cloneOverviewToDraft(state.overview);
2000
+ setDirty(false);
2001
+ renderOverviewTab();
2002
+ renderConfigTab();
2003
+ void refreshReady();
2004
+ if (data.persisted) {
2005
+ showConfigFeedback("ok", "配置已更新并持久化写回配置文件。");
2006
+ } else {
2007
+ showConfigFeedback("warn", data.persistWarning || "配置已在内存生效(只读挂载未写入磁盘)。");
2008
+ }
2009
+ } catch (error) {
2010
+ const detail = error instanceof Error ? error.message : String(error);
2011
+ showConfigFeedback("error", "保存请求异常:" + detail);
2012
+ } finally {
2013
+ btn.disabled = false;
2014
+ btn.textContent = "保存并应用配置";
2015
+ }
2016
+ }
2017
+
2018
+ async function reloadConfigFromDisk() {
2019
+ if (!state.key) return;
2020
+ const btn = byId("reload-config");
2021
+ btn.disabled = true;
2022
+ try {
2023
+ const response = await fetch("/admin/api/config/reload", {
2024
+ method: "POST",
2025
+ headers: { Authorization: "Bearer " + state.key },
2026
+ });
2027
+ if (response.status === 401) {
2028
+ handleUnauthorized();
2029
+ return;
2030
+ }
2031
+ const data = await response.json();
2032
+ if (!response.ok) {
2033
+ const msg = data && data.error && data.error.message ? data.error.message : "重载失败";
2034
+ showConfigFeedback("error", msg);
2035
+ return;
2036
+ }
2037
+ state.overview = data.overview;
2038
+ state.draft = cloneOverviewToDraft(state.overview);
2039
+ setDirty(false);
2040
+ renderOverviewTab();
2041
+ renderConfigTab();
2042
+ void refreshReady();
2043
+ showConfigFeedback("ok", "已从磁盘重新加载最新配置。");
2044
+ } catch (error) {
2045
+ const detail = error instanceof Error ? error.message : String(error);
2046
+ showConfigFeedback("error", "重载失败:" + detail);
2047
+ } finally {
2048
+ btn.disabled = false;
2049
+ }
2050
+ }
2051
+
2052
+ async function clearRecords() {
2053
+ if (!state.key) return;
2054
+ const btn = byId("clear-records");
2055
+ btn.disabled = true;
2056
+ try {
2057
+ const response = await fetch("/admin/api/records", {
2058
+ method: "DELETE",
2059
+ headers: { Authorization: "Bearer " + state.key },
2060
+ });
2061
+ if (response.status === 401) {
2062
+ handleUnauthorized();
2063
+ return;
2064
+ }
2065
+ if (response.ok) {
2066
+ state.records = [];
2067
+ state.expandedIds.clear();
2068
+ state.expandedPreviews.clear();
2069
+ renderRecordsTab();
2070
+ }
2071
+ } finally {
2072
+ btn.disabled = false;
2073
+ }
2074
+ }
2075
+
2076
+ async function resetStats() {
2077
+ if (!state.key) return;
2078
+ const btn = byId("reset-stats");
2079
+ btn.disabled = true;
2080
+ try {
2081
+ const response = await fetch("/admin/api/stats/reset", {
2082
+ method: "POST",
2083
+ headers: { Authorization: "Bearer " + state.key },
2084
+ });
2085
+ if (response.status === 401) {
2086
+ handleUnauthorized();
2087
+ return;
2088
+ }
2089
+ if (response.ok) {
2090
+ await loadOverview();
2091
+ }
2092
+ } finally {
2093
+ btn.disabled = false;
2094
+ }
2095
+ }
2096
+
2097
+ function exportRecordsJson() {
2098
+ const visible = state.records.filter(matches);
2099
+ const blob = new Blob([JSON.stringify(visible, null, 2)], { type: "application/json" });
2100
+ const url = URL.createObjectURL(blob);
2101
+ const link = document.createElement("a");
2102
+ link.href = url;
2103
+ link.download = "gateway-records-" + new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-") + ".json";
2104
+ document.body.append(link);
2105
+ link.click();
2106
+ link.remove();
2107
+ URL.revokeObjectURL(url);
2108
+ }
2109
+
600
2110
  function showBanner(message) {
601
2111
  const banner = byId("banner");
602
2112
  if (banner.textContent !== message) banner.textContent = message;
@@ -607,6 +2117,19 @@ main {
607
2117
  byId("banner").hidden = true;
608
2118
  }
609
2119
 
2120
+ function switchTab(tab) {
2121
+ state.tab = tab;
2122
+ document.querySelectorAll(".nav-tab").forEach((btn) => {
2123
+ btn.setAttribute("aria-selected", String(btn.dataset.tab === tab));
2124
+ });
2125
+ byId("tab-records").hidden = tab !== "records";
2126
+ byId("tab-overview").hidden = tab !== "overview";
2127
+ byId("tab-config").hidden = tab !== "config";
2128
+ if (tab === "overview" || tab === "config") {
2129
+ void loadOverview();
2130
+ }
2131
+ }
2132
+
610
2133
  function schedule() {
611
2134
  if (state.timer) {
612
2135
  clearInterval(state.timer);
@@ -614,16 +2137,42 @@ main {
614
2137
  }
615
2138
  if (byId("auto-refresh").checked) {
616
2139
  state.timer = setInterval(() => {
617
- if (!document.hidden && state.key) void load();
2140
+ if (!document.hidden && state.key) {
2141
+ if (state.tab === "records") void load();
2142
+ else if (state.tab === "overview") void loadOverview();
2143
+ }
618
2144
  }, REFRESH_MS);
619
2145
  }
620
2146
  }
621
2147
 
622
- async function load() {
2148
+ async function loadOverview() {
2149
+ if (!state.key) return;
2150
+ try {
2151
+ const response = await fetch("/admin/api/overview", {
2152
+ headers: { Authorization: "Bearer " + state.key },
2153
+ cache: "no-store",
2154
+ });
2155
+ if (response.status === 401) {
2156
+ handleUnauthorized();
2157
+ return;
2158
+ }
2159
+ if (!response.ok) return;
2160
+ state.overview = await response.json();
2161
+ renderOverviewTab();
2162
+ if (!state.dirty) {
2163
+ state.draft = cloneOverviewToDraft(state.overview);
2164
+ renderConfigTab();
2165
+ }
2166
+ } catch {
2167
+ // Ignore transient overview errors during background refresh.
2168
+ }
2169
+ }
2170
+
2171
+ async function load(manual = false) {
623
2172
  if (!state.key || state.loading) return;
624
2173
  state.loading = true;
625
2174
  const button = byId("refresh");
626
- button.disabled = true;
2175
+ if (manual) button.disabled = true;
627
2176
  try {
628
2177
  const response = await fetch("/admin/api/records?limit=" + RETENTION, {
629
2178
  headers: { Authorization: "Bearer " + state.key },
@@ -637,21 +2186,24 @@ main {
637
2186
  const payload = await response.json();
638
2187
  state.records = Array.isArray(payload.records) ? payload.records : [];
639
2188
  hideBanner();
640
- render();
2189
+ renderRecordsTab();
641
2190
  void refreshReady();
2191
+ if (!state.overview) void loadOverview();
642
2192
  } catch (error) {
643
2193
  const detail = error instanceof Error ? error.message : String(error);
644
2194
  showBanner("读取请求记录失败:" + detail + "。面板会继续自动重试。");
645
2195
  } finally {
646
2196
  state.loading = false;
647
- button.disabled = false;
2197
+ if (manual) button.disabled = false;
648
2198
  }
649
2199
  }
650
2200
 
651
2201
  function showLogin(message) {
652
2202
  byId("panel-view").hidden = true;
2203
+ byId("nav-tabs").hidden = true;
653
2204
  byId("login-view").hidden = false;
654
2205
  byId("logout").hidden = true;
2206
+ byId("persist-pill").hidden = true;
655
2207
  byId("key-input").value = "";
656
2208
  byId("login-error").hidden = !message;
657
2209
  if (message) byId("login-error").textContent = message;
@@ -661,6 +2213,7 @@ main {
661
2213
  function showPanel() {
662
2214
  byId("login-view").hidden = true;
663
2215
  byId("panel-view").hidden = false;
2216
+ byId("nav-tabs").hidden = false;
664
2217
  byId("logout").hidden = false;
665
2218
  byId("login-error").hidden = true;
666
2219
  schedule();
@@ -670,7 +2223,11 @@ main {
670
2223
  clearStoredKey();
671
2224
  state.key = "";
672
2225
  state.records = [];
673
- render();
2226
+ state.expandedIds.clear();
2227
+ state.expandedPreviews.clear();
2228
+ state.overview = null;
2229
+ state.draft = null;
2230
+ renderRecordsTab();
674
2231
  showLogin("Key 已失效,请重新输入。");
675
2232
  }
676
2233
 
@@ -706,13 +2263,13 @@ main {
706
2263
  state.key = key;
707
2264
  storeKey(key);
708
2265
  showPanel();
709
- await load();
2266
+ await Promise.all([load(), loadOverview()]);
710
2267
  } catch {
711
2268
  byId("login-error").textContent = "无法连接网关,请检查网络后重试。";
712
2269
  byId("login-error").hidden = false;
713
2270
  } finally {
714
2271
  button.disabled = false;
715
- button.textContent = "进入面板";
2272
+ button.textContent = "进入后台";
716
2273
  }
717
2274
  }
718
2275
 
@@ -720,20 +2277,33 @@ main {
720
2277
  clearStoredKey();
721
2278
  state.key = "";
722
2279
  state.records = [];
2280
+ state.expandedIds.clear();
2281
+ state.expandedPreviews.clear();
2282
+ state.overview = null;
2283
+ state.draft = null;
723
2284
  state.text = "";
724
2285
  byId("filter-text").value = "";
725
- render();
2286
+ renderRecordsTab();
726
2287
  showLogin("");
727
2288
  }
728
2289
 
729
2290
  function bind() {
730
2291
  byId("login-form").addEventListener("submit", (event) => void onLogin(event));
731
2292
  byId("logout").addEventListener("click", logout);
732
- byId("refresh").addEventListener("click", () => void load());
2293
+ byId("refresh").addEventListener("click", () => void load(true));
2294
+ byId("export-records").addEventListener("click", exportRecordsJson);
2295
+ byId("clear-records").addEventListener("click", () => void clearRecords());
733
2296
  byId("auto-refresh").addEventListener("change", schedule);
734
2297
  byId("filter-text").addEventListener("input", () => {
735
2298
  state.text = byId("filter-text").value;
736
- render();
2299
+ renderRecordsTab();
2300
+ });
2301
+ byId("filter-provider").addEventListener("change", () => {
2302
+ state.providerFilter = byId("filter-provider").value;
2303
+ renderRecordsTab();
2304
+ });
2305
+ document.querySelectorAll(".nav-tab").forEach((btn) => {
2306
+ btn.addEventListener("click", () => switchTab(btn.dataset.tab));
737
2307
  });
738
2308
  document.querySelectorAll("[data-route]").forEach((button) => {
739
2309
  button.addEventListener("click", () => {
@@ -741,7 +2311,7 @@ main {
741
2311
  document.querySelectorAll("[data-route]").forEach((peer) => {
742
2312
  peer.setAttribute("aria-pressed", String(peer === button));
743
2313
  });
744
- render();
2314
+ renderRecordsTab();
745
2315
  });
746
2316
  });
747
2317
  document.querySelectorAll("[data-status]").forEach((button) => {
@@ -750,11 +2320,54 @@ main {
750
2320
  document.querySelectorAll("[data-status]").forEach((peer) => {
751
2321
  peer.setAttribute("aria-pressed", String(peer === button));
752
2322
  });
753
- render();
2323
+ renderRecordsTab();
754
2324
  });
755
2325
  });
2326
+ document.querySelectorAll("[data-pfilter]").forEach((button) => {
2327
+ button.addEventListener("click", () => {
2328
+ state.pfilter = button.dataset.pfilter;
2329
+ document.querySelectorAll("[data-pfilter]").forEach((peer) => {
2330
+ peer.setAttribute("aria-pressed", String(peer === button));
2331
+ });
2332
+ renderOverviewTab();
2333
+ });
2334
+ });
2335
+ byId("refresh-overview").addEventListener("click", () => void loadOverview());
2336
+ byId("reset-stats").addEventListener("click", () => void resetStats());
2337
+ byId("jump-config").addEventListener("click", () => switchTab("config"));
2338
+
2339
+ ["cfg-s-limit", "cfg-s-timeout", "cfg-s-attempt", "cfg-s-bytes", "cfg-e-minchars", "cfg-e-timeout", "cfg-e-attempt", "cfg-e-bytes"].forEach((id) => {
2340
+ byId(id).addEventListener("input", () => {
2341
+ readNumberInputsToDraft();
2342
+ setDirty(true);
2343
+ });
2344
+ });
2345
+
2346
+ byId("save-config").addEventListener("click", () => void saveConfig());
2347
+ byId("reload-config").addEventListener("click", () => void reloadConfigFromDisk());
2348
+ byId("reset-config-draft").addEventListener("click", () => {
2349
+ if (state.overview) {
2350
+ state.draft = cloneOverviewToDraft(state.overview);
2351
+ setDirty(false);
2352
+ byId("provider-editor").hidden = true;
2353
+ byId("cfg-feedback").hidden = true;
2354
+ renderConfigTab();
2355
+ }
2356
+ });
2357
+ byId("add-provider-btn").addEventListener("click", () => openProviderEditor("", true));
2358
+ byId("pe-cancel").addEventListener("click", () => { byId("provider-editor").hidden = true; });
2359
+ byId("pe-type").addEventListener("change", () => updateProviderEditorTypeFields(byId("pe-type").value));
2360
+ byId("pe-key-action").addEventListener("change", () => {
2361
+ byId("pe-key-field").hidden = byId("pe-key-action").value !== "set";
2362
+ });
2363
+ byId("pe-apply").addEventListener("click", applyProviderEditorToDraft);
2364
+ byId("pe-delete").addEventListener("click", deleteCustomProviderFromDraft);
2365
+
756
2366
  document.addEventListener("visibilitychange", () => {
757
- if (!document.hidden && state.key) void load();
2367
+ if (!document.hidden && state.key) {
2368
+ if (state.tab === "records") void load();
2369
+ else if (state.tab === "overview") void loadOverview();
2370
+ }
758
2371
  });
759
2372
  }
760
2373
 
@@ -775,6 +2388,7 @@ main {
775
2388
  if (hashKey) storeKey(state.key);
776
2389
  showPanel();
777
2390
  void load();
2391
+ void loadOverview();
778
2392
  }
779
2393
 
780
2394
  boot();