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.
- package/README.md +102 -262
- package/README_CN.md +104 -264
- package/dist/gateway/admin.js +670 -0
- package/dist/gateway/admin.js.map +1 -0
- package/dist/gateway/panel.js +1741 -127
- package/dist/gateway/panel.js.map +1 -1
- package/dist/gateway/records.js +384 -7
- package/dist/gateway/records.js.map +1 -1
- package/dist/gateway/server.js +270 -104
- package/dist/gateway/server.js.map +1 -1
- package/dist/gateway/service.js +243 -53
- package/dist/gateway/service.js.map +1 -1
- package/dist/providers/xai.js +16 -8
- package/dist/providers/xai.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/gateway/panel.js
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
* assets,
|
|
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 网关 ·
|
|
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:
|
|
73
|
+
gap: 12px 20px;
|
|
71
74
|
align-items: center;
|
|
72
75
|
justify-content: space-between;
|
|
73
|
-
padding:
|
|
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:
|
|
82
|
-
height:
|
|
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
|
-
.
|
|
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:
|
|
147
|
+
max-width: 1240px;
|
|
117
148
|
margin: 0 auto;
|
|
118
|
-
padding:
|
|
149
|
+
padding: 22px clamp(16px, 3.5vw, 36px) 64px;
|
|
119
150
|
}
|
|
120
151
|
.btn {
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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.
|
|
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 {
|
|
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:
|
|
143
|
-
padding: 0
|
|
188
|
+
min-height: 38px;
|
|
189
|
+
padding: 0 11px;
|
|
144
190
|
border: 1px solid var(--border);
|
|
145
191
|
border-radius: 6px;
|
|
146
|
-
background: var(--
|
|
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.
|
|
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
|
-
.
|
|
164
|
-
.
|
|
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:
|
|
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"] {
|
|
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
|
|
180
|
-
min-width:
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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.
|
|
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
|
-
.
|
|
226
|
-
.record-
|
|
227
|
-
|
|
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 {
|
|
233
|
-
|
|
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 {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
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"
|
|
285
|
-
<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"
|
|
606
|
+
<button type="submit" class="btn primary" id="login-submit">进入后台</button>
|
|
291
607
|
</div>
|
|
292
|
-
<p class="hint">Key
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
<
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
<
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
<
|
|
307
|
-
<
|
|
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="
|
|
311
|
-
<
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
<
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
<
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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",
|
|
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 =
|
|
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("
|
|
1231
|
+
detail.append(element("div", "detail-label", "搜索结果列表(共 " + results.length + " 条)"));
|
|
522
1232
|
const list = element("ol", "result-list");
|
|
523
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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();
|