@viudes/windsurf-api 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +148 -0
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/bin/windsurf-api.js +5 -0
- package/config.example.json +21 -0
- package/dist/app/cli.js +334 -0
- package/dist/app/index.js +286 -0
- package/dist/dashboard/data/contributors.json +337 -0
- package/dist/dashboard/i18n/en.json +1106 -0
- package/dist/dashboard/i18n/zh-CN.json +1106 -0
- package/dist/dashboard/index-sketch.html +3564 -0
- package/dist/dashboard/index.html +6327 -0
- package/install-ls.sh +174 -0
- package/package.json +69 -0
|
@@ -0,0 +1,3564 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN" data-theme="light">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>WindsurfAPI · Console</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght,SOFT,WONK@9..144,300..900,0..100,0..1&family=Caveat:wght@400..700&family=Kalam:wght@300;400;700&family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
10
|
+
<style>
|
|
11
|
+
/* ─────────────────────────────────────────────
|
|
12
|
+
WindsurfAPI · Wireframe Console
|
|
13
|
+
Tiempos-ish serif × handwritten sketch
|
|
14
|
+
───────────────────────────────────────────── */
|
|
15
|
+
|
|
16
|
+
:root[data-theme="light"] {
|
|
17
|
+
--paper: #f4efe6;
|
|
18
|
+
--paper-2: #ece5d6;
|
|
19
|
+
--paper-3: #e2d9c4;
|
|
20
|
+
--ink: #161412;
|
|
21
|
+
--ink-2: #2b2722;
|
|
22
|
+
--ink-3: #4a4339;
|
|
23
|
+
--ink-mute: #7a6f5f;
|
|
24
|
+
--ink-dim: #a39780;
|
|
25
|
+
--rule: #2b2722;
|
|
26
|
+
--rule-soft: #5b5247;
|
|
27
|
+
--rule-faint: #b8ad95;
|
|
28
|
+
--accent: #c33b1f; /* red ink */
|
|
29
|
+
--accent-soft: rgba(195,59,31,.08);
|
|
30
|
+
--highlight: #f4d35e; /* highlighter yellow */
|
|
31
|
+
--good: #3a7a3a;
|
|
32
|
+
--warn: #b87a1a;
|
|
33
|
+
--error: #b53224;
|
|
34
|
+
--info: #2a5a8c;
|
|
35
|
+
--grain: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.92' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 .12 0 0 0 0 .1 0 0 0 0 .08 0 0 0 .08 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
|
|
36
|
+
}
|
|
37
|
+
:root[data-theme="dark"] {
|
|
38
|
+
--paper: #14130f;
|
|
39
|
+
--paper-2: #1a1815;
|
|
40
|
+
--paper-3: #221f1a;
|
|
41
|
+
--ink: #f0e8d8;
|
|
42
|
+
--ink-2: #e1d8c5;
|
|
43
|
+
--ink-3: #c8bda5;
|
|
44
|
+
--ink-mute: #8a8068;
|
|
45
|
+
--ink-dim: #5e5645;
|
|
46
|
+
--rule: #d6cbb1;
|
|
47
|
+
--rule-soft: #8a8068;
|
|
48
|
+
--rule-faint: #4a4232;
|
|
49
|
+
--accent: #ff6b4a;
|
|
50
|
+
--accent-soft: rgba(255,107,74,.12);
|
|
51
|
+
--highlight: #d4a72c;
|
|
52
|
+
--good: #7fc77f;
|
|
53
|
+
--warn: #e0a85a;
|
|
54
|
+
--error: #ff7a6a;
|
|
55
|
+
--info: #80b3e6;
|
|
56
|
+
--grain: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.92' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 .9 0 0 0 0 .85 0 0 0 0 .75 0 0 0 .05 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
60
|
+
html, body { height: 100%; }
|
|
61
|
+
body {
|
|
62
|
+
font-family: 'Inter', -apple-system, 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
|
|
63
|
+
background: var(--paper);
|
|
64
|
+
background-image: var(--grain);
|
|
65
|
+
color: var(--ink);
|
|
66
|
+
font-size: 14px;
|
|
67
|
+
line-height: 1.55;
|
|
68
|
+
-webkit-font-smoothing: antialiased;
|
|
69
|
+
display: flex;
|
|
70
|
+
min-height: 100vh;
|
|
71
|
+
letter-spacing: .005em;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* Selection */
|
|
75
|
+
::selection { background: var(--highlight); color: var(--ink); }
|
|
76
|
+
|
|
77
|
+
/* Scrollbar */
|
|
78
|
+
::-webkit-scrollbar { width: 10px; height: 10px; }
|
|
79
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
80
|
+
::-webkit-scrollbar-thumb { background: var(--rule-faint); border-radius: 0; border: 3px solid var(--paper); }
|
|
81
|
+
::-webkit-scrollbar-thumb:hover { background: var(--rule-soft); }
|
|
82
|
+
|
|
83
|
+
a { color: var(--accent); text-decoration: underline; text-decoration-style: wavy; text-underline-offset: 3px; text-decoration-thickness: 1px; }
|
|
84
|
+
a:hover { text-decoration-style: solid; }
|
|
85
|
+
|
|
86
|
+
code, .mono {
|
|
87
|
+
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
|
88
|
+
font-size: 12px;
|
|
89
|
+
font-feature-settings: 'tnum' 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ─── Type system ──────────────────────────── */
|
|
93
|
+
.serif { font-family: 'Fraunces', 'Tiempos Headline', Georgia, serif; font-variation-settings: 'opsz' 36, 'SOFT' 50, 'WONK' 0; letter-spacing: -.018em; }
|
|
94
|
+
.serif-italic { font-family: 'Fraunces', Georgia, serif; font-style: italic; font-variation-settings: 'opsz' 36, 'SOFT' 100, 'WONK' 1; }
|
|
95
|
+
.handwritten { font-family: 'Caveat', 'Kalam', cursive; letter-spacing: .01em; }
|
|
96
|
+
.label-hand { font-family: 'Kalam', 'Caveat', cursive; font-weight: 400; letter-spacing: .02em; }
|
|
97
|
+
|
|
98
|
+
/* ─── Hand-drawn box (SVG-rendered border) ─── */
|
|
99
|
+
.box {
|
|
100
|
+
position: relative;
|
|
101
|
+
background: var(--paper-2);
|
|
102
|
+
padding: 22px 24px;
|
|
103
|
+
}
|
|
104
|
+
.box::before {
|
|
105
|
+
content: '';
|
|
106
|
+
position: absolute; inset: 0;
|
|
107
|
+
background-image: var(--box-svg);
|
|
108
|
+
background-repeat: no-repeat;
|
|
109
|
+
background-size: 100% 100%;
|
|
110
|
+
pointer-events: none;
|
|
111
|
+
z-index: 1;
|
|
112
|
+
}
|
|
113
|
+
.box > * { position: relative; z-index: 2; }
|
|
114
|
+
|
|
115
|
+
/* Reusable wobbly border drawn by inline SVG via data-uri.
|
|
116
|
+
Light & dark themes pick the right ink via currentColor on the svg. */
|
|
117
|
+
|
|
118
|
+
/* Simpler: dashed wobble borders using SVG filter `feTurbulence` displacement on a rect. */
|
|
119
|
+
.wbox {
|
|
120
|
+
position: relative;
|
|
121
|
+
background: var(--paper-2);
|
|
122
|
+
}
|
|
123
|
+
.wbox-fill { background: var(--paper-2); }
|
|
124
|
+
.wbox > svg.wbox-bg {
|
|
125
|
+
position: absolute; inset: 0; width: 100%; height: 100%;
|
|
126
|
+
pointer-events: none;
|
|
127
|
+
display: block;
|
|
128
|
+
}
|
|
129
|
+
.wbox > .wbox-inner {
|
|
130
|
+
position: relative;
|
|
131
|
+
padding: 22px 24px;
|
|
132
|
+
z-index: 2;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* ─── Sidebar ──────────────────────────────── */
|
|
136
|
+
.sidebar {
|
|
137
|
+
width: 240px;
|
|
138
|
+
flex-shrink: 0;
|
|
139
|
+
background: var(--paper);
|
|
140
|
+
border-right: 1px solid var(--rule-faint);
|
|
141
|
+
position: fixed;
|
|
142
|
+
top: 0; left: 0; bottom: 0;
|
|
143
|
+
display: flex;
|
|
144
|
+
flex-direction: column;
|
|
145
|
+
z-index: 10;
|
|
146
|
+
background-image:
|
|
147
|
+
linear-gradient(to right, transparent calc(100% - 1px), var(--rule-faint) calc(100% - 1px)),
|
|
148
|
+
var(--grain);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.sidebar .brand {
|
|
152
|
+
padding: 24px 22px 18px;
|
|
153
|
+
position: relative;
|
|
154
|
+
}
|
|
155
|
+
.sidebar .brand-row {
|
|
156
|
+
display: flex; align-items: baseline; gap: 8px;
|
|
157
|
+
}
|
|
158
|
+
.sidebar .brand-mark {
|
|
159
|
+
font-family: 'Fraunces', serif;
|
|
160
|
+
font-weight: 600;
|
|
161
|
+
font-size: 28px;
|
|
162
|
+
font-variation-settings: 'opsz' 144, 'SOFT' 100, 'WONK' 1;
|
|
163
|
+
letter-spacing: -.04em;
|
|
164
|
+
line-height: 1;
|
|
165
|
+
}
|
|
166
|
+
.sidebar .brand-mark .amp { color: var(--accent); font-style: italic; }
|
|
167
|
+
.sidebar .brand-tag {
|
|
168
|
+
font-family: 'Caveat', cursive;
|
|
169
|
+
font-size: 18px;
|
|
170
|
+
color: var(--ink-mute);
|
|
171
|
+
margin-left: 4px;
|
|
172
|
+
transform: rotate(-3deg);
|
|
173
|
+
display: inline-block;
|
|
174
|
+
}
|
|
175
|
+
.sidebar .brand-rule {
|
|
176
|
+
height: 12px; margin-top: 14px;
|
|
177
|
+
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 12' preserveAspectRatio='none'><path d='M2 6 Q 18 2, 35 7 T 70 5 T 105 8 T 140 4 T 175 7 T 198 6' fill='none' stroke='currentColor' stroke-width='1.4' stroke-linecap='round'/></svg>");
|
|
178
|
+
background-repeat: no-repeat;
|
|
179
|
+
background-size: 100% 100%;
|
|
180
|
+
color: var(--rule-soft);
|
|
181
|
+
filter: contrast(1);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.sidebar nav { flex: 1; padding: 8px 14px; overflow-y: auto; }
|
|
185
|
+
.sidebar .nav-group { margin-bottom: 22px; }
|
|
186
|
+
.sidebar .nav-group-label {
|
|
187
|
+
font-family: 'Caveat', cursive;
|
|
188
|
+
font-size: 18px;
|
|
189
|
+
color: var(--ink-mute);
|
|
190
|
+
padding: 4px 6px 8px;
|
|
191
|
+
display: flex;
|
|
192
|
+
align-items: center;
|
|
193
|
+
gap: 6px;
|
|
194
|
+
}
|
|
195
|
+
.sidebar .nav-group-label::after {
|
|
196
|
+
content: '';
|
|
197
|
+
flex: 1;
|
|
198
|
+
height: 4px;
|
|
199
|
+
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 4' preserveAspectRatio='none'><path d='M2 2 Q 10 0, 20 2 T 40 2 T 60 2 T 80 2 T 98 2' fill='none' stroke='currentColor' stroke-width='1' stroke-linecap='round'/></svg>");
|
|
200
|
+
background-repeat: no-repeat;
|
|
201
|
+
background-size: 100% 100%;
|
|
202
|
+
color: var(--rule-faint);
|
|
203
|
+
margin-left: 4px;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.sidebar nav a {
|
|
207
|
+
display: flex;
|
|
208
|
+
align-items: center;
|
|
209
|
+
gap: 10px;
|
|
210
|
+
padding: 8px 10px;
|
|
211
|
+
color: var(--ink-2);
|
|
212
|
+
font-size: 14px;
|
|
213
|
+
font-weight: 500;
|
|
214
|
+
text-decoration: none;
|
|
215
|
+
position: relative;
|
|
216
|
+
border-radius: 0;
|
|
217
|
+
transition: color .15s, background .15s;
|
|
218
|
+
margin-bottom: 1px;
|
|
219
|
+
}
|
|
220
|
+
.sidebar nav a:hover {
|
|
221
|
+
color: var(--ink);
|
|
222
|
+
background: var(--paper-2);
|
|
223
|
+
}
|
|
224
|
+
.sidebar nav a.active {
|
|
225
|
+
color: var(--ink);
|
|
226
|
+
background: var(--paper-3);
|
|
227
|
+
}
|
|
228
|
+
.sidebar nav a.active::before {
|
|
229
|
+
content: '→';
|
|
230
|
+
position: absolute;
|
|
231
|
+
left: -6px;
|
|
232
|
+
font-family: 'Caveat', cursive;
|
|
233
|
+
font-weight: 700;
|
|
234
|
+
font-size: 22px;
|
|
235
|
+
color: var(--accent);
|
|
236
|
+
line-height: 1;
|
|
237
|
+
}
|
|
238
|
+
.sidebar nav a svg { width: 16px; height: 16px; stroke-width: 1.5; flex-shrink: 0; opacity: .8; }
|
|
239
|
+
.sidebar nav a .nav-pill {
|
|
240
|
+
margin-left: auto;
|
|
241
|
+
font-family: 'JetBrains Mono', monospace;
|
|
242
|
+
font-size: 10px;
|
|
243
|
+
padding: 1px 6px;
|
|
244
|
+
border: 1px solid var(--rule-faint);
|
|
245
|
+
color: var(--ink-mute);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.sidebar .footer {
|
|
249
|
+
padding: 14px 22px;
|
|
250
|
+
border-top: 1px solid var(--rule-faint);
|
|
251
|
+
display: flex;
|
|
252
|
+
align-items: center;
|
|
253
|
+
gap: 8px;
|
|
254
|
+
font-size: 11px;
|
|
255
|
+
color: var(--ink-mute);
|
|
256
|
+
}
|
|
257
|
+
.sidebar .footer .ver { font-family: 'JetBrains Mono', monospace; }
|
|
258
|
+
.theme-toggle, .lang-toggle {
|
|
259
|
+
background: transparent;
|
|
260
|
+
border: 1px solid var(--rule-faint);
|
|
261
|
+
color: var(--ink-2);
|
|
262
|
+
padding: 4px 8px;
|
|
263
|
+
font-family: 'JetBrains Mono', monospace;
|
|
264
|
+
font-size: 11px;
|
|
265
|
+
cursor: pointer;
|
|
266
|
+
transition: all .15s;
|
|
267
|
+
}
|
|
268
|
+
.theme-toggle:hover, .lang-toggle:hover {
|
|
269
|
+
border-color: var(--ink);
|
|
270
|
+
color: var(--ink);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/* ─── Main ─────────────────────────────────── */
|
|
274
|
+
.main {
|
|
275
|
+
margin-left: 240px;
|
|
276
|
+
flex: 1;
|
|
277
|
+
padding: 32px 40px 80px;
|
|
278
|
+
min-height: 100vh;
|
|
279
|
+
max-width: 1500px;
|
|
280
|
+
position: relative;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/* Decorative margin line (notebook feel) */
|
|
284
|
+
.main::before {
|
|
285
|
+
content: '';
|
|
286
|
+
position: absolute;
|
|
287
|
+
left: 26px;
|
|
288
|
+
top: 0; bottom: 0;
|
|
289
|
+
width: 1px;
|
|
290
|
+
background: linear-gradient(to bottom, transparent, var(--rule-faint) 6%, var(--rule-faint) 94%, transparent);
|
|
291
|
+
opacity: .5;
|
|
292
|
+
pointer-events: none;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.page-header {
|
|
296
|
+
display: flex;
|
|
297
|
+
align-items: flex-end;
|
|
298
|
+
justify-content: space-between;
|
|
299
|
+
gap: 24px;
|
|
300
|
+
flex-wrap: wrap;
|
|
301
|
+
margin-bottom: 28px;
|
|
302
|
+
padding-bottom: 18px;
|
|
303
|
+
border-bottom: 1.5px solid var(--ink);
|
|
304
|
+
position: relative;
|
|
305
|
+
}
|
|
306
|
+
.page-header::after {
|
|
307
|
+
/* second wobble line for "double rule" feel */
|
|
308
|
+
content: '';
|
|
309
|
+
position: absolute;
|
|
310
|
+
left: 0; right: 0; bottom: -5px;
|
|
311
|
+
height: 6px;
|
|
312
|
+
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 6' preserveAspectRatio='none'><path d='M2 3 Q 30 1, 60 3 T 120 3 T 180 3 T 240 3 T 300 3 T 360 3 T 398 3' fill='none' stroke='currentColor' stroke-width='1' stroke-linecap='round'/></svg>");
|
|
313
|
+
background-repeat: no-repeat;
|
|
314
|
+
background-size: 100% 100%;
|
|
315
|
+
color: var(--rule-soft);
|
|
316
|
+
pointer-events: none;
|
|
317
|
+
}
|
|
318
|
+
.page-num {
|
|
319
|
+
font-family: 'Caveat', cursive;
|
|
320
|
+
font-size: 22px;
|
|
321
|
+
color: var(--accent);
|
|
322
|
+
display: inline-block;
|
|
323
|
+
transform: rotate(-4deg);
|
|
324
|
+
margin-right: 8px;
|
|
325
|
+
}
|
|
326
|
+
.page-title {
|
|
327
|
+
font-family: 'Fraunces', serif;
|
|
328
|
+
font-variation-settings: 'opsz' 96, 'SOFT' 80, 'WONK' 1;
|
|
329
|
+
font-weight: 500;
|
|
330
|
+
font-size: 44px;
|
|
331
|
+
letter-spacing: -.025em;
|
|
332
|
+
line-height: 1.05;
|
|
333
|
+
color: var(--ink);
|
|
334
|
+
}
|
|
335
|
+
.page-title .em {
|
|
336
|
+
font-style: italic;
|
|
337
|
+
color: var(--accent);
|
|
338
|
+
font-variation-settings: 'opsz' 96, 'SOFT' 100, 'WONK' 1;
|
|
339
|
+
}
|
|
340
|
+
.page-subtitle {
|
|
341
|
+
font-family: 'Caveat', cursive;
|
|
342
|
+
font-size: 20px;
|
|
343
|
+
color: var(--ink-mute);
|
|
344
|
+
margin-top: 6px;
|
|
345
|
+
}
|
|
346
|
+
.page-meta {
|
|
347
|
+
font-family: 'JetBrains Mono', monospace;
|
|
348
|
+
font-size: 11px;
|
|
349
|
+
color: var(--ink-mute);
|
|
350
|
+
text-align: right;
|
|
351
|
+
}
|
|
352
|
+
.page-meta b { color: var(--ink); font-weight: 500; }
|
|
353
|
+
.page-meta .stamp {
|
|
354
|
+
display: inline-block;
|
|
355
|
+
border: 1.5px solid var(--accent);
|
|
356
|
+
color: var(--accent);
|
|
357
|
+
padding: 2px 10px;
|
|
358
|
+
font-family: 'Caveat', cursive;
|
|
359
|
+
font-size: 16px;
|
|
360
|
+
letter-spacing: .12em;
|
|
361
|
+
text-transform: uppercase;
|
|
362
|
+
transform: rotate(-2deg);
|
|
363
|
+
margin-top: 6px;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
.panel { display: none; animation: fadeIn .25s ease; }
|
|
367
|
+
.panel.active { display: block; }
|
|
368
|
+
@keyframes fadeIn {
|
|
369
|
+
from { opacity: 0; transform: translateY(6px); }
|
|
370
|
+
to { opacity: 1; transform: translateY(0); }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/* ─── Section ──────────────────────────────── */
|
|
374
|
+
.section {
|
|
375
|
+
position: relative;
|
|
376
|
+
margin-bottom: 28px;
|
|
377
|
+
}
|
|
378
|
+
.section-head {
|
|
379
|
+
display: flex;
|
|
380
|
+
align-items: baseline;
|
|
381
|
+
justify-content: space-between;
|
|
382
|
+
gap: 16px;
|
|
383
|
+
flex-wrap: wrap;
|
|
384
|
+
margin-bottom: 14px;
|
|
385
|
+
padding-bottom: 8px;
|
|
386
|
+
border-bottom: 1px dashed var(--rule-soft);
|
|
387
|
+
}
|
|
388
|
+
.section-title {
|
|
389
|
+
font-family: 'Fraunces', serif;
|
|
390
|
+
font-variation-settings: 'opsz' 60, 'SOFT' 100, 'WONK' 1;
|
|
391
|
+
font-weight: 500;
|
|
392
|
+
font-size: 22px;
|
|
393
|
+
letter-spacing: -.015em;
|
|
394
|
+
color: var(--ink);
|
|
395
|
+
display: flex;
|
|
396
|
+
align-items: baseline;
|
|
397
|
+
gap: 10px;
|
|
398
|
+
}
|
|
399
|
+
.section-title .num {
|
|
400
|
+
font-family: 'Caveat', cursive;
|
|
401
|
+
font-style: normal;
|
|
402
|
+
color: var(--accent);
|
|
403
|
+
font-size: 18px;
|
|
404
|
+
border: 1.5px solid var(--accent);
|
|
405
|
+
border-radius: 50%;
|
|
406
|
+
width: 26px; height: 26px;
|
|
407
|
+
display: inline-flex;
|
|
408
|
+
align-items: center;
|
|
409
|
+
justify-content: center;
|
|
410
|
+
flex-shrink: 0;
|
|
411
|
+
}
|
|
412
|
+
.section-desc {
|
|
413
|
+
font-size: 13px;
|
|
414
|
+
color: var(--ink-mute);
|
|
415
|
+
margin-top: 4px;
|
|
416
|
+
max-width: 60ch;
|
|
417
|
+
}
|
|
418
|
+
.section-actions {
|
|
419
|
+
display: flex;
|
|
420
|
+
gap: 8px;
|
|
421
|
+
flex-wrap: wrap;
|
|
422
|
+
align-items: center;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/* Sketch frame around content */
|
|
426
|
+
.frame {
|
|
427
|
+
position: relative;
|
|
428
|
+
background: var(--paper-2);
|
|
429
|
+
padding: 20px 22px;
|
|
430
|
+
}
|
|
431
|
+
.frame::before {
|
|
432
|
+
content: '';
|
|
433
|
+
position: absolute;
|
|
434
|
+
inset: 0;
|
|
435
|
+
border: 1.5px solid var(--ink);
|
|
436
|
+
pointer-events: none;
|
|
437
|
+
/* Slight rotation feels off — instead use a doubled shadow border */
|
|
438
|
+
}
|
|
439
|
+
.frame::after {
|
|
440
|
+
content: '';
|
|
441
|
+
position: absolute;
|
|
442
|
+
inset: 4px -4px -4px 4px;
|
|
443
|
+
border: 1px solid var(--rule-soft);
|
|
444
|
+
pointer-events: none;
|
|
445
|
+
z-index: 0;
|
|
446
|
+
}
|
|
447
|
+
.frame > * { position: relative; z-index: 1; }
|
|
448
|
+
|
|
449
|
+
.frame.tight { padding: 0; }
|
|
450
|
+
.frame-head {
|
|
451
|
+
padding: 14px 20px;
|
|
452
|
+
border-bottom: 1px dashed var(--rule-soft);
|
|
453
|
+
background: var(--paper-3);
|
|
454
|
+
display: flex;
|
|
455
|
+
align-items: center;
|
|
456
|
+
justify-content: space-between;
|
|
457
|
+
gap: 12px;
|
|
458
|
+
flex-wrap: wrap;
|
|
459
|
+
}
|
|
460
|
+
.frame-head .ft {
|
|
461
|
+
font-family: 'Fraunces', serif;
|
|
462
|
+
font-weight: 500;
|
|
463
|
+
font-size: 16px;
|
|
464
|
+
font-variation-settings: 'opsz' 36, 'WONK' 1;
|
|
465
|
+
}
|
|
466
|
+
.frame-body { padding: 18px 20px; }
|
|
467
|
+
.frame-body.tight { padding: 0; }
|
|
468
|
+
|
|
469
|
+
/* ─── Cards / Stats grid ───────────────────── */
|
|
470
|
+
.metrics-grid {
|
|
471
|
+
display: grid;
|
|
472
|
+
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
473
|
+
gap: 0;
|
|
474
|
+
margin-bottom: 28px;
|
|
475
|
+
border: 1.5px solid var(--ink);
|
|
476
|
+
background: var(--paper-2);
|
|
477
|
+
}
|
|
478
|
+
.metric {
|
|
479
|
+
padding: 18px 20px;
|
|
480
|
+
border-right: 1px dashed var(--rule-soft);
|
|
481
|
+
border-bottom: 1px dashed var(--rule-soft);
|
|
482
|
+
position: relative;
|
|
483
|
+
background: var(--paper-2);
|
|
484
|
+
transition: background .15s;
|
|
485
|
+
}
|
|
486
|
+
.metric:hover { background: var(--paper-3); }
|
|
487
|
+
.metric .m-label {
|
|
488
|
+
font-family: 'Caveat', cursive;
|
|
489
|
+
font-size: 17px;
|
|
490
|
+
color: var(--ink-mute);
|
|
491
|
+
display: flex;
|
|
492
|
+
align-items: center;
|
|
493
|
+
gap: 8px;
|
|
494
|
+
margin-bottom: 4px;
|
|
495
|
+
}
|
|
496
|
+
.metric .m-icon {
|
|
497
|
+
width: 18px; height: 18px;
|
|
498
|
+
opacity: .7;
|
|
499
|
+
}
|
|
500
|
+
.metric .m-value {
|
|
501
|
+
font-family: 'Fraunces', serif;
|
|
502
|
+
font-variation-settings: 'opsz' 96, 'SOFT' 100, 'WONK' 1;
|
|
503
|
+
font-weight: 500;
|
|
504
|
+
font-size: 38px;
|
|
505
|
+
letter-spacing: -.03em;
|
|
506
|
+
line-height: 1.1;
|
|
507
|
+
font-feature-settings: 'tnum' 1, 'lnum' 1;
|
|
508
|
+
}
|
|
509
|
+
.metric .m-value.italic { font-style: italic; color: var(--accent); }
|
|
510
|
+
.metric .m-sub {
|
|
511
|
+
font-size: 12px;
|
|
512
|
+
color: var(--ink-mute);
|
|
513
|
+
margin-top: 6px;
|
|
514
|
+
font-family: 'JetBrains Mono', monospace;
|
|
515
|
+
}
|
|
516
|
+
.metric .m-spark {
|
|
517
|
+
position: absolute;
|
|
518
|
+
right: 14px; top: 16px;
|
|
519
|
+
width: 64px; height: 26px;
|
|
520
|
+
opacity: .7;
|
|
521
|
+
}
|
|
522
|
+
.metric.accent .m-value { color: var(--accent); font-style: italic; }
|
|
523
|
+
.metric.good .m-value { color: var(--good); }
|
|
524
|
+
.metric.warn .m-value { color: var(--warn); }
|
|
525
|
+
.metric.error .m-value { color: var(--error); }
|
|
526
|
+
|
|
527
|
+
/* ─── Buttons ──────────────────────────────── */
|
|
528
|
+
.btn {
|
|
529
|
+
display: inline-flex;
|
|
530
|
+
align-items: center;
|
|
531
|
+
justify-content: center;
|
|
532
|
+
gap: 6px;
|
|
533
|
+
padding: 7px 14px;
|
|
534
|
+
font-size: 13px;
|
|
535
|
+
font-weight: 500;
|
|
536
|
+
font-family: inherit;
|
|
537
|
+
background: var(--paper);
|
|
538
|
+
color: var(--ink);
|
|
539
|
+
border: 1.5px solid var(--ink);
|
|
540
|
+
cursor: pointer;
|
|
541
|
+
transition: all .12s;
|
|
542
|
+
white-space: nowrap;
|
|
543
|
+
line-height: 1.2;
|
|
544
|
+
position: relative;
|
|
545
|
+
border-radius: 0;
|
|
546
|
+
}
|
|
547
|
+
.btn::after {
|
|
548
|
+
/* sketchy double shadow */
|
|
549
|
+
content: '';
|
|
550
|
+
position: absolute;
|
|
551
|
+
inset: 2px -3px -3px 2px;
|
|
552
|
+
border: 1px solid var(--rule-soft);
|
|
553
|
+
pointer-events: none;
|
|
554
|
+
z-index: -1;
|
|
555
|
+
transition: inset .12s;
|
|
556
|
+
}
|
|
557
|
+
.btn:hover:not(:disabled) {
|
|
558
|
+
background: var(--ink);
|
|
559
|
+
color: var(--paper);
|
|
560
|
+
}
|
|
561
|
+
.btn:hover:not(:disabled)::after { inset: 3px -4px -4px 3px; }
|
|
562
|
+
.btn:active:not(:disabled) { transform: translate(1px, 1px); }
|
|
563
|
+
.btn:active:not(:disabled)::after { inset: 1px -2px -2px 1px; }
|
|
564
|
+
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
|
565
|
+
.btn svg { width: 14px; height: 14px; stroke-width: 1.5; }
|
|
566
|
+
|
|
567
|
+
.btn-primary {
|
|
568
|
+
background: var(--accent);
|
|
569
|
+
color: #fff;
|
|
570
|
+
border-color: var(--accent);
|
|
571
|
+
}
|
|
572
|
+
.btn-primary:hover:not(:disabled) { background: var(--ink); border-color: var(--ink); }
|
|
573
|
+
|
|
574
|
+
.btn-ghost {
|
|
575
|
+
background: transparent;
|
|
576
|
+
border-color: var(--rule-faint);
|
|
577
|
+
color: var(--ink-2);
|
|
578
|
+
}
|
|
579
|
+
.btn-ghost:hover:not(:disabled) {
|
|
580
|
+
background: var(--paper-3);
|
|
581
|
+
color: var(--ink);
|
|
582
|
+
border-color: var(--ink);
|
|
583
|
+
}
|
|
584
|
+
.btn-ghost::after { display: none; }
|
|
585
|
+
|
|
586
|
+
.btn-danger { background: var(--error); border-color: var(--error); color: #fff; }
|
|
587
|
+
.btn-danger:hover:not(:disabled) { background: var(--ink); border-color: var(--ink); }
|
|
588
|
+
|
|
589
|
+
.btn-good { background: var(--good); border-color: var(--good); color: #fff; }
|
|
590
|
+
.btn-good:hover:not(:disabled) { background: var(--ink); border-color: var(--ink); }
|
|
591
|
+
|
|
592
|
+
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
|
593
|
+
.btn-xs { padding: 2px 8px; font-size: 11px; }
|
|
594
|
+
.btn-xs::after, .btn-sm::after { inset: 1px -2px -2px 1px; }
|
|
595
|
+
.btn-icon { padding: 6px; }
|
|
596
|
+
|
|
597
|
+
.btn-group { display: inline-flex; gap: 8px; flex-wrap: wrap; }
|
|
598
|
+
|
|
599
|
+
.btn.active, .btn.active:hover {
|
|
600
|
+
background: var(--ink); color: var(--paper);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/* ─── Form Controls ────────────────────────── */
|
|
604
|
+
.input, .select, .textarea {
|
|
605
|
+
display: block;
|
|
606
|
+
width: 100%;
|
|
607
|
+
padding: 8px 12px;
|
|
608
|
+
font-size: 13px;
|
|
609
|
+
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
|
610
|
+
background: var(--paper);
|
|
611
|
+
border: 1.5px solid var(--rule-soft);
|
|
612
|
+
border-bottom-style: solid;
|
|
613
|
+
color: var(--ink);
|
|
614
|
+
outline: none;
|
|
615
|
+
transition: all .12s;
|
|
616
|
+
line-height: 1.4;
|
|
617
|
+
border-radius: 0;
|
|
618
|
+
}
|
|
619
|
+
.input:hover, .select:hover, .textarea:hover { border-color: var(--ink-3); }
|
|
620
|
+
.input:focus, .select:focus, .textarea:focus {
|
|
621
|
+
border-color: var(--accent);
|
|
622
|
+
background: var(--paper-2);
|
|
623
|
+
}
|
|
624
|
+
.input::placeholder, .textarea::placeholder { color: var(--ink-dim); font-style: italic; font-family: 'Caveat', cursive; font-size: 16px; }
|
|
625
|
+
|
|
626
|
+
/* underline-only variant (more sketch-y) */
|
|
627
|
+
.input.underline {
|
|
628
|
+
border: none;
|
|
629
|
+
border-bottom: 1.5px solid var(--rule-soft);
|
|
630
|
+
background: transparent;
|
|
631
|
+
padding: 6px 2px;
|
|
632
|
+
}
|
|
633
|
+
.input.underline:focus { border-bottom-color: var(--accent); background: transparent; }
|
|
634
|
+
|
|
635
|
+
.select {
|
|
636
|
+
appearance: none;
|
|
637
|
+
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%237a6f5f' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
|
|
638
|
+
background-repeat: no-repeat;
|
|
639
|
+
background-position: right 10px center;
|
|
640
|
+
padding-right: 32px;
|
|
641
|
+
cursor: pointer;
|
|
642
|
+
}
|
|
643
|
+
.select option { background: var(--paper); color: var(--ink); }
|
|
644
|
+
|
|
645
|
+
.field { display: flex; flex-direction: column; gap: 6px; flex: 1; min-width: 0; }
|
|
646
|
+
.field-label {
|
|
647
|
+
font-family: 'Caveat', cursive;
|
|
648
|
+
font-size: 17px;
|
|
649
|
+
color: var(--ink-2);
|
|
650
|
+
font-weight: 500;
|
|
651
|
+
display: flex;
|
|
652
|
+
align-items: center;
|
|
653
|
+
gap: 6px;
|
|
654
|
+
}
|
|
655
|
+
.field-label::before {
|
|
656
|
+
content: '↳';
|
|
657
|
+
color: var(--accent);
|
|
658
|
+
opacity: .7;
|
|
659
|
+
}
|
|
660
|
+
.field-hint { font-size: 11px; color: var(--ink-mute); font-style: italic; }
|
|
661
|
+
.field-row {
|
|
662
|
+
display: grid;
|
|
663
|
+
gap: 14px;
|
|
664
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
665
|
+
align-items: end;
|
|
666
|
+
}
|
|
667
|
+
.field-row-actions { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
|
|
668
|
+
|
|
669
|
+
/* Checkbox — hand-drawn box */
|
|
670
|
+
.checkbox {
|
|
671
|
+
display: inline-flex;
|
|
672
|
+
align-items: center;
|
|
673
|
+
gap: 8px;
|
|
674
|
+
cursor: pointer;
|
|
675
|
+
font-size: 13px;
|
|
676
|
+
color: var(--ink-2);
|
|
677
|
+
user-select: none;
|
|
678
|
+
}
|
|
679
|
+
.checkbox input { position: absolute; opacity: 0; pointer-events: none; }
|
|
680
|
+
.checkbox .box-mark {
|
|
681
|
+
width: 16px; height: 16px;
|
|
682
|
+
border: 1.5px solid var(--ink-3);
|
|
683
|
+
display: flex;
|
|
684
|
+
align-items: center;
|
|
685
|
+
justify-content: center;
|
|
686
|
+
background: var(--paper);
|
|
687
|
+
flex-shrink: 0;
|
|
688
|
+
position: relative;
|
|
689
|
+
}
|
|
690
|
+
.checkbox input:checked + .box-mark::after {
|
|
691
|
+
content: '✓';
|
|
692
|
+
font-family: 'Caveat', cursive;
|
|
693
|
+
font-weight: 700;
|
|
694
|
+
font-size: 18px;
|
|
695
|
+
color: var(--accent);
|
|
696
|
+
line-height: 1;
|
|
697
|
+
transform: translate(0, -1px);
|
|
698
|
+
}
|
|
699
|
+
.checkbox:hover .box-mark { border-color: var(--accent); }
|
|
700
|
+
|
|
701
|
+
/* Switch — toggle drawn as flipping ink dot */
|
|
702
|
+
.switch {
|
|
703
|
+
position: relative;
|
|
704
|
+
display: inline-flex;
|
|
705
|
+
align-items: center;
|
|
706
|
+
width: 46px; height: 22px;
|
|
707
|
+
flex-shrink: 0;
|
|
708
|
+
cursor: pointer;
|
|
709
|
+
}
|
|
710
|
+
.switch input { opacity: 0; width: 0; height: 0; }
|
|
711
|
+
.switch-slider {
|
|
712
|
+
position: absolute; inset: 0;
|
|
713
|
+
background: var(--paper);
|
|
714
|
+
border: 1.5px solid var(--ink-3);
|
|
715
|
+
transition: .2s;
|
|
716
|
+
}
|
|
717
|
+
.switch-slider::before {
|
|
718
|
+
content: ''; position: absolute;
|
|
719
|
+
height: 14px; width: 14px;
|
|
720
|
+
left: 2px; top: 2px;
|
|
721
|
+
background: var(--ink-3);
|
|
722
|
+
transition: .2s;
|
|
723
|
+
}
|
|
724
|
+
.switch input:checked + .switch-slider {
|
|
725
|
+
background: var(--accent-soft);
|
|
726
|
+
border-color: var(--accent);
|
|
727
|
+
}
|
|
728
|
+
.switch input:checked + .switch-slider::before {
|
|
729
|
+
transform: translateX(22px);
|
|
730
|
+
background: var(--accent);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/* Segmented (tabs) */
|
|
734
|
+
.segmented {
|
|
735
|
+
display: inline-flex;
|
|
736
|
+
border: 1.5px solid var(--ink);
|
|
737
|
+
background: var(--paper);
|
|
738
|
+
}
|
|
739
|
+
.segmented label {
|
|
740
|
+
padding: 6px 14px;
|
|
741
|
+
font-size: 12px;
|
|
742
|
+
font-family: inherit;
|
|
743
|
+
font-weight: 500;
|
|
744
|
+
color: var(--ink-2);
|
|
745
|
+
cursor: pointer;
|
|
746
|
+
border-right: 1px solid var(--rule-soft);
|
|
747
|
+
position: relative;
|
|
748
|
+
}
|
|
749
|
+
.segmented label:last-child { border-right: none; }
|
|
750
|
+
.segmented label input { position: absolute; opacity: 0; pointer-events: none; }
|
|
751
|
+
.segmented label:has(input:checked) {
|
|
752
|
+
background: var(--ink);
|
|
753
|
+
color: var(--paper);
|
|
754
|
+
}
|
|
755
|
+
.segmented label:hover:not(:has(input:checked)) { background: var(--paper-3); }
|
|
756
|
+
|
|
757
|
+
/* ─── Tables ───────────────────────────────── */
|
|
758
|
+
.table-wrap {
|
|
759
|
+
overflow-x: auto;
|
|
760
|
+
background: var(--paper);
|
|
761
|
+
}
|
|
762
|
+
table {
|
|
763
|
+
width: 100%;
|
|
764
|
+
border-collapse: collapse;
|
|
765
|
+
font-size: 13px;
|
|
766
|
+
}
|
|
767
|
+
thead th {
|
|
768
|
+
text-align: left;
|
|
769
|
+
padding: 10px 14px;
|
|
770
|
+
font-family: 'Caveat', cursive;
|
|
771
|
+
font-size: 18px;
|
|
772
|
+
font-weight: 400;
|
|
773
|
+
color: var(--ink);
|
|
774
|
+
background: var(--paper-3);
|
|
775
|
+
border-bottom: 1.5px solid var(--ink);
|
|
776
|
+
white-space: nowrap;
|
|
777
|
+
letter-spacing: .01em;
|
|
778
|
+
}
|
|
779
|
+
tbody td {
|
|
780
|
+
padding: 11px 14px;
|
|
781
|
+
border-bottom: 1px dashed var(--rule-soft);
|
|
782
|
+
vertical-align: middle;
|
|
783
|
+
}
|
|
784
|
+
tbody tr:last-child td { border-bottom: none; }
|
|
785
|
+
tbody tr { transition: background .1s; }
|
|
786
|
+
tbody tr:hover td { background: var(--paper-3); }
|
|
787
|
+
.empty-row td {
|
|
788
|
+
text-align: center;
|
|
789
|
+
color: var(--ink-mute);
|
|
790
|
+
padding: 36px 16px;
|
|
791
|
+
font-family: 'Caveat', cursive;
|
|
792
|
+
font-size: 18px;
|
|
793
|
+
font-style: italic;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/* Expand chevron */
|
|
797
|
+
.expand-chev {
|
|
798
|
+
display: inline-flex;
|
|
799
|
+
align-items: center;
|
|
800
|
+
justify-content: center;
|
|
801
|
+
width: 20px; height: 20px;
|
|
802
|
+
border: 1px dashed var(--rule-soft);
|
|
803
|
+
background: transparent;
|
|
804
|
+
color: var(--ink-mute);
|
|
805
|
+
cursor: pointer;
|
|
806
|
+
transition: all .15s;
|
|
807
|
+
font-family: 'Caveat', cursive;
|
|
808
|
+
font-size: 14px;
|
|
809
|
+
margin-right: 6px;
|
|
810
|
+
vertical-align: middle;
|
|
811
|
+
}
|
|
812
|
+
.expand-chev:hover { color: var(--accent); border-color: var(--accent); }
|
|
813
|
+
.expand-chev.open { color: var(--accent); border-style: solid; transform: rotate(90deg); }
|
|
814
|
+
|
|
815
|
+
.detail-row > td {
|
|
816
|
+
padding: 0 !important;
|
|
817
|
+
background: var(--paper-3) !important;
|
|
818
|
+
border-bottom: 1.5px solid var(--ink) !important;
|
|
819
|
+
}
|
|
820
|
+
.detail-wrap { padding: 22px 28px; }
|
|
821
|
+
.detail-grid {
|
|
822
|
+
display: grid;
|
|
823
|
+
grid-template-columns: 1fr 1fr;
|
|
824
|
+
gap: 18px;
|
|
825
|
+
margin-bottom: 14px;
|
|
826
|
+
}
|
|
827
|
+
@media (max-width: 1100px) { .detail-grid { grid-template-columns: 1fr; } }
|
|
828
|
+
.detail-card {
|
|
829
|
+
background: var(--paper-2);
|
|
830
|
+
border: 1.5px solid var(--ink);
|
|
831
|
+
padding: 14px 16px;
|
|
832
|
+
position: relative;
|
|
833
|
+
}
|
|
834
|
+
.detail-card::after {
|
|
835
|
+
content: '';
|
|
836
|
+
position: absolute;
|
|
837
|
+
inset: 3px -3px -3px 3px;
|
|
838
|
+
border: 1px solid var(--rule-soft);
|
|
839
|
+
z-index: -1;
|
|
840
|
+
}
|
|
841
|
+
.detail-card-head {
|
|
842
|
+
display: flex;
|
|
843
|
+
align-items: baseline;
|
|
844
|
+
gap: 8px;
|
|
845
|
+
font-family: 'Fraunces', serif;
|
|
846
|
+
font-variation-settings: 'opsz' 36, 'WONK' 1;
|
|
847
|
+
font-style: italic;
|
|
848
|
+
font-weight: 500;
|
|
849
|
+
font-size: 16px;
|
|
850
|
+
color: var(--ink);
|
|
851
|
+
margin-bottom: 12px;
|
|
852
|
+
padding-bottom: 6px;
|
|
853
|
+
border-bottom: 1px dashed var(--rule-soft);
|
|
854
|
+
}
|
|
855
|
+
.detail-card-head .num-circle {
|
|
856
|
+
font-family: 'Caveat', cursive;
|
|
857
|
+
font-style: normal;
|
|
858
|
+
color: var(--accent);
|
|
859
|
+
font-size: 14px;
|
|
860
|
+
border: 1.5px solid var(--accent);
|
|
861
|
+
border-radius: 50%;
|
|
862
|
+
width: 22px; height: 22px;
|
|
863
|
+
display: inline-flex;
|
|
864
|
+
align-items: center;
|
|
865
|
+
justify-content: center;
|
|
866
|
+
}
|
|
867
|
+
.detail-kv {
|
|
868
|
+
display: grid;
|
|
869
|
+
grid-template-columns: 100px 1fr;
|
|
870
|
+
gap: 5px 14px;
|
|
871
|
+
font-size: 13px;
|
|
872
|
+
line-height: 1.6;
|
|
873
|
+
}
|
|
874
|
+
.detail-kv > .k { color: var(--ink-mute); font-family: 'Caveat', cursive; font-size: 15px; }
|
|
875
|
+
.detail-kv > .v { color: var(--ink); font-feature-settings: 'tnum' 1; }
|
|
876
|
+
.detail-kv code { font-size: 11px; padding: 1px 5px; background: var(--paper); border: 1px solid var(--rule-faint); }
|
|
877
|
+
|
|
878
|
+
/* Progress bar — pen scratch */
|
|
879
|
+
.bar {
|
|
880
|
+
height: 10px;
|
|
881
|
+
border: 1.5px solid var(--ink);
|
|
882
|
+
background: var(--paper);
|
|
883
|
+
position: relative;
|
|
884
|
+
overflow: hidden;
|
|
885
|
+
}
|
|
886
|
+
.bar-fill {
|
|
887
|
+
height: 100%;
|
|
888
|
+
background-image: repeating-linear-gradient(45deg, var(--accent), var(--accent) 4px, var(--accent-soft) 4px, var(--accent-soft) 7px);
|
|
889
|
+
transition: width .6s cubic-bezier(.2,.8,.2,1);
|
|
890
|
+
}
|
|
891
|
+
.bar-fill.good {
|
|
892
|
+
background-image: repeating-linear-gradient(45deg, var(--good), var(--good) 4px, transparent 4px, transparent 7px);
|
|
893
|
+
}
|
|
894
|
+
.bar-fill.warn {
|
|
895
|
+
background-image: repeating-linear-gradient(45deg, var(--warn), var(--warn) 4px, transparent 4px, transparent 7px);
|
|
896
|
+
}
|
|
897
|
+
.bar-fill.error {
|
|
898
|
+
background-image: repeating-linear-gradient(45deg, var(--error), var(--error) 4px, transparent 4px, transparent 7px);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
.detail-bar {
|
|
902
|
+
display: grid;
|
|
903
|
+
grid-template-columns: 60px 1fr 50px;
|
|
904
|
+
align-items: center;
|
|
905
|
+
gap: 10px;
|
|
906
|
+
margin-bottom: 8px;
|
|
907
|
+
font-size: 12px;
|
|
908
|
+
}
|
|
909
|
+
.detail-bar .b-label { color: var(--ink-mute); font-family: 'Caveat', cursive; font-size: 15px; }
|
|
910
|
+
.detail-bar .b-pct { font-family: 'JetBrains Mono', monospace; font-size: 11px; text-align: right; font-weight: 600; }
|
|
911
|
+
.detail-bar .b-reset {
|
|
912
|
+
grid-column: 2 / 4;
|
|
913
|
+
font-size: 10.5px;
|
|
914
|
+
color: var(--ink-mute);
|
|
915
|
+
font-family: 'JetBrains Mono', monospace;
|
|
916
|
+
margin-top: 2px;
|
|
917
|
+
font-style: italic;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/* ─── Badges ───────────────────────────────── */
|
|
921
|
+
.badge {
|
|
922
|
+
display: inline-flex;
|
|
923
|
+
align-items: center;
|
|
924
|
+
gap: 4px;
|
|
925
|
+
padding: 1px 8px;
|
|
926
|
+
font-family: 'Caveat', cursive;
|
|
927
|
+
font-size: 14px;
|
|
928
|
+
font-weight: 500;
|
|
929
|
+
line-height: 1.5;
|
|
930
|
+
border: 1.2px solid currentColor;
|
|
931
|
+
background: var(--paper);
|
|
932
|
+
border-radius: 0;
|
|
933
|
+
}
|
|
934
|
+
.badge::before {
|
|
935
|
+
content: '';
|
|
936
|
+
width: 6px; height: 6px;
|
|
937
|
+
border-radius: 50%;
|
|
938
|
+
background: currentColor;
|
|
939
|
+
flex-shrink: 0;
|
|
940
|
+
}
|
|
941
|
+
.badge.active, .badge.running, .badge.success { color: var(--good); }
|
|
942
|
+
.badge.error, .badge.stopped { color: var(--error); }
|
|
943
|
+
.badge.disabled, .badge.muted { color: var(--ink-mute); }
|
|
944
|
+
.badge.warn { color: var(--warn); }
|
|
945
|
+
.badge.info, .badge.pro { color: var(--info); }
|
|
946
|
+
.badge.no-dot::before { display: none; }
|
|
947
|
+
|
|
948
|
+
/* Tier */
|
|
949
|
+
.tier {
|
|
950
|
+
display: inline-flex;
|
|
951
|
+
align-items: center;
|
|
952
|
+
padding: 1px 10px;
|
|
953
|
+
font-family: 'Fraunces', serif;
|
|
954
|
+
font-variation-settings: 'WONK' 1;
|
|
955
|
+
font-weight: 600;
|
|
956
|
+
font-size: 11px;
|
|
957
|
+
text-transform: uppercase;
|
|
958
|
+
letter-spacing: .12em;
|
|
959
|
+
cursor: pointer;
|
|
960
|
+
}
|
|
961
|
+
.tier.pro { background: var(--ink); color: var(--paper); }
|
|
962
|
+
.tier.free { background: transparent; color: var(--info); border: 1.2px solid var(--info); }
|
|
963
|
+
.tier.expired { background: transparent; color: var(--error); border: 1.2px solid var(--error); }
|
|
964
|
+
.tier.unknown { background: transparent; color: var(--ink-mute); border: 1.2px dashed var(--rule-soft); }
|
|
965
|
+
|
|
966
|
+
/* ─── Login overlay ────────────────────────── */
|
|
967
|
+
.login-overlay {
|
|
968
|
+
position: fixed;
|
|
969
|
+
inset: 0;
|
|
970
|
+
background: var(--paper);
|
|
971
|
+
background-image: var(--grain);
|
|
972
|
+
display: flex;
|
|
973
|
+
align-items: center;
|
|
974
|
+
justify-content: center;
|
|
975
|
+
z-index: 200;
|
|
976
|
+
}
|
|
977
|
+
.login-box {
|
|
978
|
+
background: var(--paper-2);
|
|
979
|
+
border: 2px solid var(--ink);
|
|
980
|
+
padding: 40px 36px;
|
|
981
|
+
width: 400px;
|
|
982
|
+
position: relative;
|
|
983
|
+
}
|
|
984
|
+
.login-box::after {
|
|
985
|
+
content: '';
|
|
986
|
+
position: absolute;
|
|
987
|
+
inset: 6px -6px -6px 6px;
|
|
988
|
+
border: 1px solid var(--rule-soft);
|
|
989
|
+
z-index: -1;
|
|
990
|
+
}
|
|
991
|
+
.login-box .login-mark {
|
|
992
|
+
font-family: 'Fraunces', serif;
|
|
993
|
+
font-variation-settings: 'opsz' 144, 'SOFT' 100, 'WONK' 1;
|
|
994
|
+
font-weight: 500;
|
|
995
|
+
font-size: 56px;
|
|
996
|
+
letter-spacing: -.04em;
|
|
997
|
+
text-align: center;
|
|
998
|
+
margin-bottom: 4px;
|
|
999
|
+
line-height: 1;
|
|
1000
|
+
}
|
|
1001
|
+
.login-box .login-mark .em { font-style: italic; color: var(--accent); }
|
|
1002
|
+
.login-box .login-tag {
|
|
1003
|
+
font-family: 'Caveat', cursive;
|
|
1004
|
+
font-size: 22px;
|
|
1005
|
+
color: var(--ink-mute);
|
|
1006
|
+
text-align: center;
|
|
1007
|
+
margin-bottom: 30px;
|
|
1008
|
+
transform: rotate(-2deg);
|
|
1009
|
+
}
|
|
1010
|
+
.login-box .input { margin-bottom: 16px; }
|
|
1011
|
+
.login-box .btn { width: 100%; padding: 11px; font-weight: 600; font-size: 14px; }
|
|
1012
|
+
|
|
1013
|
+
/* ─── Toast ────────────────────────────────── */
|
|
1014
|
+
.toast-stack {
|
|
1015
|
+
position: fixed;
|
|
1016
|
+
bottom: 24px; right: 24px;
|
|
1017
|
+
display: flex;
|
|
1018
|
+
flex-direction: column-reverse;
|
|
1019
|
+
gap: 10px;
|
|
1020
|
+
z-index: 9999;
|
|
1021
|
+
pointer-events: none;
|
|
1022
|
+
}
|
|
1023
|
+
.toast {
|
|
1024
|
+
padding: 12px 16px;
|
|
1025
|
+
background: var(--paper);
|
|
1026
|
+
border: 1.5px solid var(--ink);
|
|
1027
|
+
font-size: 13px;
|
|
1028
|
+
max-width: 380px;
|
|
1029
|
+
display: flex;
|
|
1030
|
+
align-items: center;
|
|
1031
|
+
gap: 10px;
|
|
1032
|
+
animation: slideIn .25s ease;
|
|
1033
|
+
pointer-events: auto;
|
|
1034
|
+
position: relative;
|
|
1035
|
+
font-family: inherit;
|
|
1036
|
+
}
|
|
1037
|
+
.toast::after {
|
|
1038
|
+
content: '';
|
|
1039
|
+
position: absolute;
|
|
1040
|
+
inset: 3px -3px -3px 3px;
|
|
1041
|
+
border: 1px solid var(--rule-soft);
|
|
1042
|
+
z-index: -1;
|
|
1043
|
+
}
|
|
1044
|
+
.toast.success { border-left: 4px solid var(--good); }
|
|
1045
|
+
.toast.error { border-left: 4px solid var(--error); }
|
|
1046
|
+
.toast.info { border-left: 4px solid var(--info); }
|
|
1047
|
+
.toast .toast-icon {
|
|
1048
|
+
font-family: 'Caveat', cursive;
|
|
1049
|
+
font-size: 22px;
|
|
1050
|
+
font-weight: 700;
|
|
1051
|
+
flex-shrink: 0;
|
|
1052
|
+
}
|
|
1053
|
+
.toast.success .toast-icon { color: var(--good); }
|
|
1054
|
+
.toast.error .toast-icon { color: var(--error); }
|
|
1055
|
+
.toast.info .toast-icon { color: var(--info); }
|
|
1056
|
+
@keyframes slideIn {
|
|
1057
|
+
from { opacity: 0; transform: translateX(20px); }
|
|
1058
|
+
to { opacity: 1; transform: translateX(0); }
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/* ─── Modal ────────────────────────────────── */
|
|
1062
|
+
.modal-overlay {
|
|
1063
|
+
position: fixed; inset: 0;
|
|
1064
|
+
background: rgba(0,0,0,.4);
|
|
1065
|
+
backdrop-filter: blur(3px);
|
|
1066
|
+
display: flex;
|
|
1067
|
+
align-items: center;
|
|
1068
|
+
justify-content: center;
|
|
1069
|
+
z-index: 100;
|
|
1070
|
+
animation: fadeIn .15s ease;
|
|
1071
|
+
}
|
|
1072
|
+
.modal {
|
|
1073
|
+
background: var(--paper);
|
|
1074
|
+
border: 2px solid var(--ink);
|
|
1075
|
+
width: 90%;
|
|
1076
|
+
max-width: 460px;
|
|
1077
|
+
position: relative;
|
|
1078
|
+
animation: modalIn .2s cubic-bezier(.16,1,.3,1);
|
|
1079
|
+
}
|
|
1080
|
+
.modal::after {
|
|
1081
|
+
content: '';
|
|
1082
|
+
position: absolute;
|
|
1083
|
+
inset: 6px -6px -6px 6px;
|
|
1084
|
+
border: 1px solid var(--rule-soft);
|
|
1085
|
+
z-index: -1;
|
|
1086
|
+
}
|
|
1087
|
+
@keyframes modalIn {
|
|
1088
|
+
from { opacity: 0; transform: translateY(-12px); }
|
|
1089
|
+
to { opacity: 1; transform: translateY(0); }
|
|
1090
|
+
}
|
|
1091
|
+
.modal-header { padding: 22px 26px 10px; }
|
|
1092
|
+
.modal-title {
|
|
1093
|
+
font-family: 'Fraunces', serif;
|
|
1094
|
+
font-variation-settings: 'opsz' 36, 'WONK' 1;
|
|
1095
|
+
font-weight: 500;
|
|
1096
|
+
font-size: 22px;
|
|
1097
|
+
}
|
|
1098
|
+
.modal-desc {
|
|
1099
|
+
font-family: 'Caveat', cursive;
|
|
1100
|
+
font-size: 17px;
|
|
1101
|
+
color: var(--ink-mute);
|
|
1102
|
+
margin-top: 4px;
|
|
1103
|
+
}
|
|
1104
|
+
.modal-body { padding: 12px 26px 22px; }
|
|
1105
|
+
.modal-footer {
|
|
1106
|
+
padding: 14px 26px;
|
|
1107
|
+
border-top: 1px dashed var(--rule-soft);
|
|
1108
|
+
display: flex;
|
|
1109
|
+
justify-content: flex-end;
|
|
1110
|
+
gap: 10px;
|
|
1111
|
+
background: var(--paper-2);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/* ─── Logs / mono blocks ───────────────────── */
|
|
1115
|
+
.log-container {
|
|
1116
|
+
background: var(--paper-2);
|
|
1117
|
+
border: 1.5px solid var(--ink);
|
|
1118
|
+
height: 540px;
|
|
1119
|
+
overflow-y: auto;
|
|
1120
|
+
font-family: 'JetBrains Mono', monospace;
|
|
1121
|
+
font-size: 12px;
|
|
1122
|
+
line-height: 1.65;
|
|
1123
|
+
position: relative;
|
|
1124
|
+
}
|
|
1125
|
+
.log-container::after {
|
|
1126
|
+
content: '';
|
|
1127
|
+
position: absolute;
|
|
1128
|
+
inset: 4px -4px -4px 4px;
|
|
1129
|
+
border: 1px solid var(--rule-soft);
|
|
1130
|
+
pointer-events: none;
|
|
1131
|
+
}
|
|
1132
|
+
.log-entry {
|
|
1133
|
+
padding: 3px 16px;
|
|
1134
|
+
border-bottom: 1px dotted var(--rule-faint);
|
|
1135
|
+
display: flex;
|
|
1136
|
+
gap: 10px;
|
|
1137
|
+
align-items: baseline;
|
|
1138
|
+
word-break: break-all;
|
|
1139
|
+
}
|
|
1140
|
+
.log-entry:hover { background: var(--paper-3); }
|
|
1141
|
+
.log-entry .ts { color: var(--ink-mute); flex-shrink: 0; font-size: 11px; }
|
|
1142
|
+
.log-entry .lvl {
|
|
1143
|
+
font-weight: 700;
|
|
1144
|
+
flex-shrink: 0;
|
|
1145
|
+
width: 50px;
|
|
1146
|
+
}
|
|
1147
|
+
.log-entry.debug .lvl { color: var(--ink-mute); }
|
|
1148
|
+
.log-entry.info .lvl { color: var(--info); }
|
|
1149
|
+
.log-entry.warn .lvl { color: var(--warn); }
|
|
1150
|
+
.log-entry.error .lvl { color: var(--error); }
|
|
1151
|
+
.log-entry.error { background: var(--accent-soft); }
|
|
1152
|
+
|
|
1153
|
+
.log-controls {
|
|
1154
|
+
display: flex;
|
|
1155
|
+
gap: 10px;
|
|
1156
|
+
align-items: center;
|
|
1157
|
+
flex-wrap: wrap;
|
|
1158
|
+
margin-bottom: 14px;
|
|
1159
|
+
}
|
|
1160
|
+
.log-controls .input, .log-controls .select { width: auto; }
|
|
1161
|
+
.log-controls .search { flex: 1; min-width: 200px; }
|
|
1162
|
+
|
|
1163
|
+
/* ─── Chart container (hand-drawn axes) ────── */
|
|
1164
|
+
.chart-card {
|
|
1165
|
+
background: var(--paper-2);
|
|
1166
|
+
border: 1.5px solid var(--ink);
|
|
1167
|
+
padding: 22px;
|
|
1168
|
+
margin-bottom: 24px;
|
|
1169
|
+
position: relative;
|
|
1170
|
+
}
|
|
1171
|
+
.chart-card::after {
|
|
1172
|
+
content: '';
|
|
1173
|
+
position: absolute;
|
|
1174
|
+
inset: 4px -4px -4px 4px;
|
|
1175
|
+
border: 1px solid var(--rule-soft);
|
|
1176
|
+
z-index: -1;
|
|
1177
|
+
}
|
|
1178
|
+
.chart-canvas-wrap {
|
|
1179
|
+
position: relative;
|
|
1180
|
+
width: 100%;
|
|
1181
|
+
height: 280px;
|
|
1182
|
+
margin-top: 14px;
|
|
1183
|
+
}
|
|
1184
|
+
.chart-canvas-wrap canvas { width: 100%; height: 100%; display: block; }
|
|
1185
|
+
.chart-legend {
|
|
1186
|
+
display: flex;
|
|
1187
|
+
gap: 18px;
|
|
1188
|
+
flex-wrap: wrap;
|
|
1189
|
+
justify-content: center;
|
|
1190
|
+
margin-top: 14px;
|
|
1191
|
+
padding-top: 12px;
|
|
1192
|
+
border-top: 1px dashed var(--rule-soft);
|
|
1193
|
+
font-family: 'Caveat', cursive;
|
|
1194
|
+
font-size: 16px;
|
|
1195
|
+
}
|
|
1196
|
+
.chart-legend-item {
|
|
1197
|
+
display: flex; align-items: center; gap: 6px;
|
|
1198
|
+
cursor: pointer;
|
|
1199
|
+
}
|
|
1200
|
+
.chart-legend-swatch { width: 18px; height: 4px; }
|
|
1201
|
+
|
|
1202
|
+
/* ─── LS pool cards ────────────────────────── */
|
|
1203
|
+
.ls-pool {
|
|
1204
|
+
display: grid;
|
|
1205
|
+
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
1206
|
+
gap: 0;
|
|
1207
|
+
border: 1.5px solid var(--ink);
|
|
1208
|
+
}
|
|
1209
|
+
.ls-inst {
|
|
1210
|
+
padding: 14px 16px;
|
|
1211
|
+
background: var(--paper-2);
|
|
1212
|
+
border-right: 1px dashed var(--rule-soft);
|
|
1213
|
+
border-bottom: 1px dashed var(--rule-soft);
|
|
1214
|
+
font-size: 12px;
|
|
1215
|
+
}
|
|
1216
|
+
.ls-inst .ls-key {
|
|
1217
|
+
font-family: 'Fraunces', serif;
|
|
1218
|
+
font-variation-settings: 'WONK' 1;
|
|
1219
|
+
font-weight: 500;
|
|
1220
|
+
font-size: 15px;
|
|
1221
|
+
display: flex;
|
|
1222
|
+
align-items: center;
|
|
1223
|
+
gap: 8px;
|
|
1224
|
+
margin-bottom: 6px;
|
|
1225
|
+
}
|
|
1226
|
+
.ls-inst .ls-dot {
|
|
1227
|
+
width: 8px; height: 8px;
|
|
1228
|
+
border-radius: 50%;
|
|
1229
|
+
background: var(--good);
|
|
1230
|
+
flex-shrink: 0;
|
|
1231
|
+
}
|
|
1232
|
+
.ls-inst .ls-dot.pending { background: var(--warn); }
|
|
1233
|
+
.ls-inst .ls-meta { color: var(--ink-mute); font-family: 'JetBrains Mono', monospace; font-size: 11px; margin-top: 2px; }
|
|
1234
|
+
|
|
1235
|
+
/* Model chip */
|
|
1236
|
+
.model-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0; }
|
|
1237
|
+
.model-chip {
|
|
1238
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
1239
|
+
padding: 4px 10px;
|
|
1240
|
+
font-family: 'JetBrains Mono', monospace;
|
|
1241
|
+
font-size: 11.5px;
|
|
1242
|
+
background: var(--paper);
|
|
1243
|
+
border: 1.2px solid var(--rule-soft);
|
|
1244
|
+
color: var(--ink-2);
|
|
1245
|
+
cursor: pointer;
|
|
1246
|
+
transition: all .12s;
|
|
1247
|
+
}
|
|
1248
|
+
.model-chip:hover { border-color: var(--ink); color: var(--ink); }
|
|
1249
|
+
.model-chip.selected { background: var(--ink); color: var(--paper); border-color: var(--ink); }
|
|
1250
|
+
.model-chip.blocked { opacity: .5; text-decoration: line-through; }
|
|
1251
|
+
.model-chip .remove { color: var(--accent); font-weight: 700; cursor: pointer; padding: 0 2px; }
|
|
1252
|
+
.provider-group { margin-bottom: 14px; }
|
|
1253
|
+
.provider-label {
|
|
1254
|
+
font-family: 'Caveat', cursive;
|
|
1255
|
+
font-size: 17px;
|
|
1256
|
+
color: var(--ink-mute);
|
|
1257
|
+
margin-bottom: 6px;
|
|
1258
|
+
display: flex; align-items: center; gap: 6px;
|
|
1259
|
+
}
|
|
1260
|
+
.provider-label::after {
|
|
1261
|
+
content: ''; flex: 1; height: 1px; background: var(--rule-faint); margin-left: 4px;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/* Bars panel */
|
|
1265
|
+
.bars-row {
|
|
1266
|
+
display: flex;
|
|
1267
|
+
align-items: flex-end;
|
|
1268
|
+
gap: 4px;
|
|
1269
|
+
height: 180px;
|
|
1270
|
+
padding: 0 10px;
|
|
1271
|
+
border-bottom: 1.5px solid var(--ink);
|
|
1272
|
+
border-left: 1.5px solid var(--ink);
|
|
1273
|
+
position: relative;
|
|
1274
|
+
}
|
|
1275
|
+
.bars-row::before {
|
|
1276
|
+
content: '';
|
|
1277
|
+
position: absolute;
|
|
1278
|
+
bottom: -1.5px; left: 0; right: 0;
|
|
1279
|
+
height: 6px;
|
|
1280
|
+
pointer-events: none;
|
|
1281
|
+
}
|
|
1282
|
+
.bar-wrap {
|
|
1283
|
+
flex: 1;
|
|
1284
|
+
display: flex;
|
|
1285
|
+
flex-direction: column;
|
|
1286
|
+
align-items: center;
|
|
1287
|
+
height: 100%;
|
|
1288
|
+
justify-content: flex-end;
|
|
1289
|
+
position: relative;
|
|
1290
|
+
}
|
|
1291
|
+
.b-bar {
|
|
1292
|
+
width: 100%;
|
|
1293
|
+
min-width: 6px;
|
|
1294
|
+
max-width: 28px;
|
|
1295
|
+
background-image: repeating-linear-gradient(45deg, var(--ink), var(--ink) 3px, transparent 3px, transparent 6px);
|
|
1296
|
+
transition: all .3s;
|
|
1297
|
+
cursor: pointer;
|
|
1298
|
+
border: 1.5px solid var(--ink);
|
|
1299
|
+
border-bottom: none;
|
|
1300
|
+
}
|
|
1301
|
+
.b-bar.has-errors {
|
|
1302
|
+
background-image: repeating-linear-gradient(45deg, var(--error), var(--error) 3px, transparent 3px, transparent 6px);
|
|
1303
|
+
border-color: var(--error);
|
|
1304
|
+
}
|
|
1305
|
+
.bar-wrap:hover .b-bar { filter: brightness(1.1); }
|
|
1306
|
+
|
|
1307
|
+
/* Capability list */
|
|
1308
|
+
.cap-list { display: inline-flex; flex-wrap: wrap; gap: 4px; }
|
|
1309
|
+
.cap-item {
|
|
1310
|
+
font-size: 10px;
|
|
1311
|
+
padding: 1px 7px;
|
|
1312
|
+
font-family: 'JetBrains Mono', monospace;
|
|
1313
|
+
font-weight: 600;
|
|
1314
|
+
border: 1px solid currentColor;
|
|
1315
|
+
}
|
|
1316
|
+
.cap-item.ok { color: var(--good); }
|
|
1317
|
+
.cap-item.fail { color: var(--error); text-decoration: line-through; }
|
|
1318
|
+
|
|
1319
|
+
/* Spinner */
|
|
1320
|
+
.spinner {
|
|
1321
|
+
display: inline-block;
|
|
1322
|
+
width: 14px; height: 14px;
|
|
1323
|
+
border: 2px solid var(--rule-faint);
|
|
1324
|
+
border-top-color: var(--accent);
|
|
1325
|
+
border-radius: 50%;
|
|
1326
|
+
animation: spin .7s linear infinite;
|
|
1327
|
+
vertical-align: middle;
|
|
1328
|
+
}
|
|
1329
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
1330
|
+
|
|
1331
|
+
/* Utilities */
|
|
1332
|
+
.hidden { display: none !important; }
|
|
1333
|
+
.text-mute { color: var(--ink-mute); }
|
|
1334
|
+
.text-dim { color: var(--ink-dim); }
|
|
1335
|
+
.text-sm { font-size: 12px; }
|
|
1336
|
+
.text-xs { font-size: 11px; }
|
|
1337
|
+
.nowrap { white-space: nowrap; }
|
|
1338
|
+
.break-all { word-break: break-all; }
|
|
1339
|
+
.flex { display: flex; }
|
|
1340
|
+
.gap-2 { gap: 8px; } .gap-3 { gap: 12px; } .gap-4 { gap: 16px; }
|
|
1341
|
+
.mt-2 { margin-top: 8px; } .mt-3 { margin-top: 12px; } .mt-4 { margin-top: 16px; }
|
|
1342
|
+
.italic { font-style: italic; }
|
|
1343
|
+
|
|
1344
|
+
/* ─── Responsive ───────────────────────────── */
|
|
1345
|
+
@media (max-width: 900px) {
|
|
1346
|
+
.sidebar { width: 60px; }
|
|
1347
|
+
.sidebar .brand-tag, .sidebar .brand-rule, .sidebar nav a span:not(.nav-pill), .sidebar .nav-group-label { display: none; }
|
|
1348
|
+
.sidebar .brand { padding: 18px 0; text-align: center; }
|
|
1349
|
+
.sidebar .brand-mark { font-size: 26px; }
|
|
1350
|
+
.sidebar nav a { justify-content: center; padding: 10px; }
|
|
1351
|
+
.sidebar .footer { flex-direction: column; gap: 6px; padding: 12px; }
|
|
1352
|
+
.main { margin-left: 60px; padding: 22px 18px 60px; }
|
|
1353
|
+
.page-title { font-size: 32px; }
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/* Annotation */
|
|
1357
|
+
.anno {
|
|
1358
|
+
font-family: 'Caveat', cursive;
|
|
1359
|
+
font-size: 17px;
|
|
1360
|
+
color: var(--ink-mute);
|
|
1361
|
+
display: inline-flex;
|
|
1362
|
+
align-items: center;
|
|
1363
|
+
gap: 6px;
|
|
1364
|
+
}
|
|
1365
|
+
.anno::before {
|
|
1366
|
+
content: '✱';
|
|
1367
|
+
color: var(--accent);
|
|
1368
|
+
font-size: 14px;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/* "Sticker" — diagonal label */
|
|
1372
|
+
.sticker {
|
|
1373
|
+
display: inline-block;
|
|
1374
|
+
font-family: 'Caveat', cursive;
|
|
1375
|
+
font-size: 18px;
|
|
1376
|
+
padding: 2px 12px;
|
|
1377
|
+
border: 1.5px solid var(--accent);
|
|
1378
|
+
color: var(--accent);
|
|
1379
|
+
transform: rotate(-2.5deg);
|
|
1380
|
+
background: var(--paper);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/* Avatar circle (login history) */
|
|
1384
|
+
.avatar-letter {
|
|
1385
|
+
display: inline-flex;
|
|
1386
|
+
align-items: center;
|
|
1387
|
+
justify-content: center;
|
|
1388
|
+
width: 28px; height: 28px;
|
|
1389
|
+
border: 1.5px solid var(--ink);
|
|
1390
|
+
background: var(--paper);
|
|
1391
|
+
font-family: 'Fraunces', serif;
|
|
1392
|
+
font-variation-settings: 'WONK' 1;
|
|
1393
|
+
font-weight: 500;
|
|
1394
|
+
font-size: 14px;
|
|
1395
|
+
border-radius: 50%;
|
|
1396
|
+
color: var(--ink);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/* Decorative arrow */
|
|
1400
|
+
.draw-arrow {
|
|
1401
|
+
display: inline-block;
|
|
1402
|
+
width: 22px;
|
|
1403
|
+
height: 12px;
|
|
1404
|
+
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 12' fill='none' stroke='currentColor' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'><path d='M2 6 Q 8 4, 14 6 T 20 6'/><path d='M16 2 L 20 6 L 16 10'/></svg>");
|
|
1405
|
+
background-repeat: no-repeat;
|
|
1406
|
+
color: currentColor;
|
|
1407
|
+
}
|
|
1408
|
+
</style>
|
|
1409
|
+
<script type="module">
|
|
1410
|
+
import { initializeApp } from 'https://www.gstatic.com/firebasejs/11.6.0/firebase-app.js';
|
|
1411
|
+
import { getAuth, signInWithPopup, GoogleAuthProvider, GithubAuthProvider } from 'https://www.gstatic.com/firebasejs/11.6.0/firebase-auth.js';
|
|
1412
|
+
const _fbApp = initializeApp({
|
|
1413
|
+
apiKey: 'AIzaSyDsOl-1XpT5err0Tcnx8FFod1H8gVGIycY',
|
|
1414
|
+
authDomain: 'exa2-fb170.firebaseapp.com',
|
|
1415
|
+
projectId: 'exa2-fb170',
|
|
1416
|
+
});
|
|
1417
|
+
const _fbAuth = getAuth(_fbApp);
|
|
1418
|
+
window._firebaseOAuth = async function(provider) {
|
|
1419
|
+
const p = provider === 'github' ? new GithubAuthProvider() : new GoogleAuthProvider();
|
|
1420
|
+
if (provider === 'google') p.addScope('email');
|
|
1421
|
+
const result = await signInWithPopup(_fbAuth, p);
|
|
1422
|
+
const idToken = await result.user.getIdToken();
|
|
1423
|
+
return {
|
|
1424
|
+
idToken,
|
|
1425
|
+
refreshToken: result.user.stsTokenManager?.refreshToken || '',
|
|
1426
|
+
email: result.user.email || '',
|
|
1427
|
+
provider,
|
|
1428
|
+
};
|
|
1429
|
+
};
|
|
1430
|
+
</script>
|
|
1431
|
+
</head>
|
|
1432
|
+
<body>
|
|
1433
|
+
|
|
1434
|
+
<!-- ════════ Sidebar ════════ -->
|
|
1435
|
+
<aside class="sidebar">
|
|
1436
|
+
<div class="brand">
|
|
1437
|
+
<div class="brand-row">
|
|
1438
|
+
<div class="brand-mark">W<span class="amp">&</span>API</div>
|
|
1439
|
+
</div>
|
|
1440
|
+
<div class="brand-tag">a console for the curious</div>
|
|
1441
|
+
<div class="brand-rule"></div>
|
|
1442
|
+
</div>
|
|
1443
|
+
<nav>
|
|
1444
|
+
<div class="nav-group">
|
|
1445
|
+
<div class="nav-group-label" data-zh="概览" data-en="overview">概览</div>
|
|
1446
|
+
<a href="#overview" class="active" data-panel="overview">
|
|
1447
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
|
1448
|
+
<span data-zh="仪表盘" data-en="Dashboard">仪表盘</span>
|
|
1449
|
+
</a>
|
|
1450
|
+
<a href="#stats" data-panel="stats">
|
|
1451
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
|
1452
|
+
<span data-zh="统计分析" data-en="Statistics">统计分析</span>
|
|
1453
|
+
</a>
|
|
1454
|
+
</div>
|
|
1455
|
+
<div class="nav-group">
|
|
1456
|
+
<div class="nav-group-label" data-zh="账号" data-en="accounts">账号</div>
|
|
1457
|
+
<a href="#windsurf-login" data-panel="windsurf-login">
|
|
1458
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
|
|
1459
|
+
<span data-zh="登录取号" data-en="Login & Acquire">登录取号</span>
|
|
1460
|
+
</a>
|
|
1461
|
+
<a href="#accounts" data-panel="accounts">
|
|
1462
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
|
1463
|
+
<span data-zh="账号管理" data-en="Account Manager">账号管理</span>
|
|
1464
|
+
<span class="nav-pill" id="nav-accounts-count">·</span>
|
|
1465
|
+
</a>
|
|
1466
|
+
<a href="#bans" data-panel="bans">
|
|
1467
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
|
1468
|
+
<span data-zh="异常监测" data-en="Anomalies">异常监测</span>
|
|
1469
|
+
</a>
|
|
1470
|
+
</div>
|
|
1471
|
+
<div class="nav-group">
|
|
1472
|
+
<div class="nav-group-label" data-zh="系统" data-en="system">系统</div>
|
|
1473
|
+
<a href="#models" data-panel="models">
|
|
1474
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
|
1475
|
+
<span data-zh="模型控制" data-en="Models">模型控制</span>
|
|
1476
|
+
</a>
|
|
1477
|
+
<a href="#proxy" data-panel="proxy">
|
|
1478
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 23l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
|
1479
|
+
<span data-zh="代理配置" data-en="Proxy">代理配置</span>
|
|
1480
|
+
</a>
|
|
1481
|
+
<a href="#logs" data-panel="logs">
|
|
1482
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
|
1483
|
+
<span data-zh="运行日志" data-en="Logs">运行日志</span>
|
|
1484
|
+
</a>
|
|
1485
|
+
<a href="#experimental" data-panel="experimental">
|
|
1486
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M10 2v7.31"/><path d="M14 9.3V2"/><path d="M8.5 2h7"/><path d="M14 9.3a6.5 6.5 0 1 1-4 0"/></svg>
|
|
1487
|
+
<span data-zh="实验性功能" data-en="Experimental">实验性功能</span>
|
|
1488
|
+
</a>
|
|
1489
|
+
</div>
|
|
1490
|
+
<div class="nav-group">
|
|
1491
|
+
<div class="nav-group-label" data-zh="关于" data-en="about">关于</div>
|
|
1492
|
+
<a href="#credits" data-panel="credits">
|
|
1493
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
|
|
1494
|
+
<span data-zh="致谢" data-en="Credits">致谢</span>
|
|
1495
|
+
</a>
|
|
1496
|
+
</div>
|
|
1497
|
+
</nav>
|
|
1498
|
+
<div class="footer">
|
|
1499
|
+
<button class="theme-toggle" onclick="App.toggleTheme()" title="theme"><span id="theme-ind"><svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></span></button>
|
|
1500
|
+
<button class="lang-toggle" onclick="App.toggleLang()" title="language"><span id="lang-ind">中</span> / EN</button>
|
|
1501
|
+
<button class="lang-toggle" onclick="App.switchSkin('modern')" title="switch to default skin">默认皮</button>
|
|
1502
|
+
<span class="ver" id="sidebar-ver">v1.2.0</span>
|
|
1503
|
+
</div>
|
|
1504
|
+
</aside>
|
|
1505
|
+
|
|
1506
|
+
<!-- ════════ Main ════════ -->
|
|
1507
|
+
<main class="main">
|
|
1508
|
+
|
|
1509
|
+
<!-- ─── Overview ─── -->
|
|
1510
|
+
<section class="panel active" id="p-overview">
|
|
1511
|
+
<header class="page-header">
|
|
1512
|
+
<div>
|
|
1513
|
+
<div><span class="page-num">№ 01</span><span class="anno" data-zh="今天怎么样?" data-en="How are we today?">今天怎么样?</span></div>
|
|
1514
|
+
<h1 class="page-title"><em class="em">Dash</em>board</h1>
|
|
1515
|
+
<div class="page-subtitle" data-zh="系统运行状态与关键指标" data-en="Runtime state & key metrics">系统运行状态与关键指标</div>
|
|
1516
|
+
</div>
|
|
1517
|
+
<div class="page-meta">
|
|
1518
|
+
<div><span data-zh="时间" data-en="now">now</span> · <b id="hdr-now">—</b></div>
|
|
1519
|
+
<div class="stamp" id="hdr-stamp">DRAFT</div>
|
|
1520
|
+
<div class="btn-group" style="margin-top:10px;justify-content:flex-end">
|
|
1521
|
+
<button class="btn btn-ghost btn-sm" id="btn-check-update" onclick="App.checkUpdate()" data-zh="检查更新" data-en="Check update">检查更新</button>
|
|
1522
|
+
<button class="btn btn-primary btn-sm hidden" id="btn-apply-update" onclick="App.applyUpdate()" data-zh="一键更新" data-en="Update now">一键更新</button>
|
|
1523
|
+
</div>
|
|
1524
|
+
</div>
|
|
1525
|
+
</header>
|
|
1526
|
+
|
|
1527
|
+
<div id="update-status" class="frame hidden" style="padding:14px;margin-bottom:18px"></div>
|
|
1528
|
+
|
|
1529
|
+
<div class="metrics-grid" id="overview-cards"></div>
|
|
1530
|
+
|
|
1531
|
+
<div class="section">
|
|
1532
|
+
<div class="section-head">
|
|
1533
|
+
<div>
|
|
1534
|
+
<div class="section-title"><span class="num">i</span><span data-zh="语言服务器" data-en="Language Server">语言服务器</span></div>
|
|
1535
|
+
<div class="section-desc" data-zh="多实例 Cascade language-server 池状态" data-en="Cascade language-server pool status">多实例 Cascade language-server 池状态</div>
|
|
1536
|
+
</div>
|
|
1537
|
+
<div class="section-actions">
|
|
1538
|
+
<button class="btn btn-ghost btn-sm" onclick="App.restartLs()" data-zh="重启" data-en="Restart"><svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> 重启</button>
|
|
1539
|
+
</div>
|
|
1540
|
+
</div>
|
|
1541
|
+
<div class="frame tight"><div class="frame-body" id="ls-status"></div></div>
|
|
1542
|
+
</div>
|
|
1543
|
+
</section>
|
|
1544
|
+
|
|
1545
|
+
<!-- ─── Stats ─── -->
|
|
1546
|
+
<section class="panel" id="p-stats">
|
|
1547
|
+
<header class="page-header">
|
|
1548
|
+
<div>
|
|
1549
|
+
<div><span class="page-num">№ 02</span><span class="anno" data-zh="数据告诉我们什么?" data-en="What's the data say?">数据告诉我们什么?</span></div>
|
|
1550
|
+
<h1 class="page-title"><em class="em">Stat</em>istics</h1>
|
|
1551
|
+
<div class="page-subtitle" data-zh="请求量 · 成功率 · 模型分布" data-en="Volume · success rate · model split">请求量 · 成功率 · 模型分布</div>
|
|
1552
|
+
</div>
|
|
1553
|
+
<div class="page-meta">
|
|
1554
|
+
<span class="sticker" data-zh="实时" data-en="LIVE">LIVE</span>
|
|
1555
|
+
<button class="btn btn-ghost btn-sm" onclick="App.resetStats()" data-zh="清空统计" data-en="Reset stats" style="margin-left:10px">清空统计</button>
|
|
1556
|
+
</div>
|
|
1557
|
+
</header>
|
|
1558
|
+
|
|
1559
|
+
<div class="metrics-grid" id="stats-cards"></div>
|
|
1560
|
+
|
|
1561
|
+
<div class="chart-card">
|
|
1562
|
+
<div class="section-head" style="border:none;margin:0 0 4px;padding:0">
|
|
1563
|
+
<div>
|
|
1564
|
+
<div class="section-title"><span class="num">~</span><span data-zh="请求量趋势" data-en="Request volume">请求量趋势</span></div>
|
|
1565
|
+
<div class="section-desc" data-zh="按小时聚合,最近 24h" data-en="Hourly buckets, last 24h">按小时聚合,最近 24h</div>
|
|
1566
|
+
</div>
|
|
1567
|
+
<div class="segmented" id="stats-range">
|
|
1568
|
+
<label><input type="radio" name="range" value="24h" checked onchange="App.loadStats()">24h</label>
|
|
1569
|
+
<label><input type="radio" name="range" value="7d" onchange="App.loadStats()">7d</label>
|
|
1570
|
+
<label><input type="radio" name="range" value="30d" onchange="App.loadStats()">30d</label>
|
|
1571
|
+
</div>
|
|
1572
|
+
</div>
|
|
1573
|
+
<div class="bars-row" id="stats-bars"></div>
|
|
1574
|
+
<div class="chart-legend">
|
|
1575
|
+
<div class="chart-legend-item"><span class="chart-legend-swatch" style="background:var(--ink)"></span>requests</div>
|
|
1576
|
+
<div class="chart-legend-item"><span class="chart-legend-swatch" style="background:var(--error)"></span>errors</div>
|
|
1577
|
+
</div>
|
|
1578
|
+
</div>
|
|
1579
|
+
|
|
1580
|
+
<div class="section">
|
|
1581
|
+
<div class="section-head">
|
|
1582
|
+
<div>
|
|
1583
|
+
<div class="section-title"><span class="num">M</span><span data-zh="模型使用统计" data-en="Model breakdown">模型使用统计</span></div>
|
|
1584
|
+
<div class="section-desc" data-zh="按模型聚合 · 请求量 · 成功率 · 平均耗时与 p50 / p95" data-en="Per-model · volume · success · latency p50 / p95">按模型聚合 · 请求量 · 成功率 · 平均耗时与 p50 / p95</div>
|
|
1585
|
+
</div>
|
|
1586
|
+
</div>
|
|
1587
|
+
<div class="frame tight">
|
|
1588
|
+
<div class="frame-body tight">
|
|
1589
|
+
<div class="table-wrap">
|
|
1590
|
+
<table id="model-stats-table">
|
|
1591
|
+
<thead><tr><th>Model</th><th>Requests</th><th>Success</th><th>Errors</th><th>Rate</th><th>Avg</th><th>p50</th><th>p95</th></tr></thead>
|
|
1592
|
+
<tbody></tbody>
|
|
1593
|
+
</table>
|
|
1594
|
+
</div>
|
|
1595
|
+
</div>
|
|
1596
|
+
</div>
|
|
1597
|
+
</div>
|
|
1598
|
+
|
|
1599
|
+
<div class="section">
|
|
1600
|
+
<div class="section-head">
|
|
1601
|
+
<div>
|
|
1602
|
+
<div class="section-title"><span class="num">A</span><span data-zh="账号维度统计" data-en="Per-account stats">账号维度统计</span></div>
|
|
1603
|
+
<div class="section-desc" data-zh="每个账号的请求量与成功率" data-en="Volume & success rate per account">每个账号的请求量与成功率</div>
|
|
1604
|
+
</div>
|
|
1605
|
+
</div>
|
|
1606
|
+
<div class="frame tight">
|
|
1607
|
+
<div class="frame-body tight">
|
|
1608
|
+
<div class="table-wrap">
|
|
1609
|
+
<table id="account-stats-table">
|
|
1610
|
+
<thead><tr><th>Account</th><th>Requests</th><th>Success</th><th>Errors</th><th>Rate</th></tr></thead>
|
|
1611
|
+
<tbody></tbody>
|
|
1612
|
+
</table>
|
|
1613
|
+
</div>
|
|
1614
|
+
</div>
|
|
1615
|
+
</div>
|
|
1616
|
+
</div>
|
|
1617
|
+
</section>
|
|
1618
|
+
|
|
1619
|
+
<!-- ─── Windsurf Login ─── -->
|
|
1620
|
+
<section class="panel" id="p-windsurf-login">
|
|
1621
|
+
<header class="page-header">
|
|
1622
|
+
<div>
|
|
1623
|
+
<div><span class="page-num">№ 03</span><span class="anno" data-zh="先取个号?" data-en="Grab a key?">先取个号?</span></div>
|
|
1624
|
+
<h1 class="page-title"><em class="em">Acquire</em> a Key</h1>
|
|
1625
|
+
<div class="page-subtitle" data-zh="Google · GitHub · 邮箱密码 三种方式" data-en="Google · GitHub · email/password">Google · GitHub · 邮箱密码 三种方式</div>
|
|
1626
|
+
</div>
|
|
1627
|
+
</header>
|
|
1628
|
+
|
|
1629
|
+
<div class="section">
|
|
1630
|
+
<div class="section-head">
|
|
1631
|
+
<div>
|
|
1632
|
+
<div class="section-title"><span class="num">1</span><span data-zh="快捷登录(推荐)" data-en="OAuth (recommended)">快捷登录(推荐)</span></div>
|
|
1633
|
+
<div class="section-desc" data-zh="Google / GitHub 直接登录 Windsurf,无需密码" data-en="Sign in with Google or GitHub — no password.">Google / GitHub 直接登录 Windsurf,无需密码</div>
|
|
1634
|
+
</div>
|
|
1635
|
+
</div>
|
|
1636
|
+
<div class="frame">
|
|
1637
|
+
<div style="display:flex;gap:14px;flex-wrap:wrap">
|
|
1638
|
+
<button class="btn" onclick="App.oauthLogin('google')" id="oauth-google-btn" style="padding:10px 22px;font-weight:600;gap:10px">
|
|
1639
|
+
<svg width="18" height="18" viewBox="0 0 48 48"><path fill="#FFC107" d="M43.6 20.1H42V20H24v8h11.3C33.9 33.1 29.3 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.2 8 3l5.7-5.7C34 6 29.3 4 24 4 13 4 4 13 4 24s9 20 20 20 20-9 20-20c0-1.3-.2-2.7-.4-3.9z"/><path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.5 15.5 18.8 12 24 12c3.1 0 5.8 1.2 8 3l5.7-5.7C34 6 29.3 4 24 4 16.3 4 9.7 8.3 6.3 14.7z"/><path fill="#4CAF50" d="M24 44c5.2 0 9.9-2 13.4-5.2l-6.2-5.2C29.2 35.1 26.7 36 24 36c-5.2 0-9.6-3.6-11.2-8.5l-6.5 5C9.5 39.6 16.2 44 24 44z"/><path fill="#1976D2" d="M43.6 20.1H42V20H24v8h11.3c-.8 2.2-2.2 4.1-4.1 5.6l6.2 5.2C37 39.2 44 34 44 24c0-1.3-.2-2.7-.4-3.9z"/></svg>
|
|
1640
|
+
Google
|
|
1641
|
+
</button>
|
|
1642
|
+
<button class="btn" onclick="App.oauthLogin('github')" id="oauth-github-btn" style="padding:10px 22px;font-weight:600;gap:10px">
|
|
1643
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.44 9.8 8.2 11.38.6.11.82-.26.82-.58v-2.03c-3.34.73-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.08-.74.08-.73.08-.73 1.2.08 1.84 1.23 1.84 1.23 1.07 1.83 2.81 1.3 3.5 1 .1-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.13-.3-.54-1.52.12-3.18 0 0 1-.32 3.3 1.23a11.5 11.5 0 016.02 0c2.28-1.55 3.29-1.23 3.29-1.23.66 1.66.25 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.61-2.8 5.63-5.48 5.92.43.37.82 1.1.82 2.22v3.29c0 .32.21.7.82.58A11.99 11.99 0 0024 12C24 5.37 18.63 0 12 0z"/></svg>
|
|
1644
|
+
GitHub
|
|
1645
|
+
</button>
|
|
1646
|
+
</div>
|
|
1647
|
+
<div id="oauth-status" style="margin-top:14px;font-family:'Caveat',cursive;font-size:18px;color:var(--ink-mute)"></div>
|
|
1648
|
+
</div>
|
|
1649
|
+
</div>
|
|
1650
|
+
|
|
1651
|
+
<div class="section">
|
|
1652
|
+
<div class="section-head">
|
|
1653
|
+
<div>
|
|
1654
|
+
<div class="section-title"><span class="num">2</span><span data-zh="邮箱密码登录" data-en="Email + password (registered)">邮箱密码登录</span></div>
|
|
1655
|
+
<div class="section-desc" data-zh="仅限邮箱+密码注册的账号 第三方登录请用上面的按钮;支持单个登录或按「邮箱 密码」格式批量导入" data-en="Email/password registered accounts only — for OAuth use the buttons above. Single login or batch import with «email password» per line.">仅限邮箱+密码注册的账号 第三方登录请用上面的按钮;支持单个登录或按「邮箱 密码」格式批量导入</div>
|
|
1656
|
+
</div>
|
|
1657
|
+
</div>
|
|
1658
|
+
<div class="frame">
|
|
1659
|
+
<div class="field-row">
|
|
1660
|
+
<div class="field"><label class="field-label">Email</label><input class="input" id="wl-email" placeholder="you@example.com"></div>
|
|
1661
|
+
<div class="field"><label class="field-label">Password</label><input class="input" id="wl-password" type="password" placeholder="••••••••"></div>
|
|
1662
|
+
</div>
|
|
1663
|
+
<div class="field-row-actions">
|
|
1664
|
+
<label class="checkbox"><input type="checkbox" id="wl-auto-add" checked><span class="box-mark"></span><span data-zh="登录成功后自动加入账号池" data-en="Auto-add to pool on success">登录成功后自动加入账号池</span></label>
|
|
1665
|
+
<div style="flex:1"></div>
|
|
1666
|
+
<button class="btn btn-primary" id="wl-btn" onclick="App.windsurfLogin()" data-zh="登录取号" data-en="Acquire">登录取号 <svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></button>
|
|
1667
|
+
</div>
|
|
1668
|
+
</div>
|
|
1669
|
+
</div>
|
|
1670
|
+
|
|
1671
|
+
<div class="section">
|
|
1672
|
+
<div class="section-head">
|
|
1673
|
+
<div>
|
|
1674
|
+
<div class="section-title"><span class="num">3</span><span data-zh="批量导入" data-en="Batch import">批量导入</span></div>
|
|
1675
|
+
<div class="section-desc" data-zh="每行一组:email password [proxy]。proxy 可省略;格式 type://[user:pass@]host:port,例 socks5://127.0.0.1:7890 或 http://u:p@1.2.3.4:8080。空白行忽略。" data-en="One per line: email password [proxy]. Proxy is optional; format type://[user:pass@]host:port (e.g. socks5://127.0.0.1:7890 or http://u:p@1.2.3.4:8080). Blank lines ignored.">每行一组:email password [proxy]。proxy 可省略;格式 type://[user:pass@]host:port,例 socks5://127.0.0.1:7890 或 http://u:p@1.2.3.4:8080。空白行忽略。</div>
|
|
1676
|
+
</div>
|
|
1677
|
+
<button class="btn btn-ghost btn-sm" onclick="App.pasteWindsurfBatch()"><svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/></svg> paste</button>
|
|
1678
|
+
</div>
|
|
1679
|
+
<div class="frame">
|
|
1680
|
+
<textarea class="textarea input" id="wl-batch-input" rows="6" style="font-family:'JetBrains Mono',monospace;resize:vertical;width:100%" placeholder="alice@example.com hunter2 bob@example.com letme1n"></textarea>
|
|
1681
|
+
<div class="field-row-actions">
|
|
1682
|
+
<button class="btn btn-primary" id="wl-batch-btn" onclick="App.windsurfLoginBatch()" data-zh="批量导入" data-en="Batch import">批量导入</button>
|
|
1683
|
+
</div>
|
|
1684
|
+
</div>
|
|
1685
|
+
</div>
|
|
1686
|
+
|
|
1687
|
+
<div id="wl-result" class="frame hidden" style="margin-bottom:24px"></div>
|
|
1688
|
+
|
|
1689
|
+
<div class="section">
|
|
1690
|
+
<div class="section-head">
|
|
1691
|
+
<div><div class="section-title"><span class="num">H</span><span data-zh="登录历史" data-en="Login history">登录历史</span></div></div>
|
|
1692
|
+
</div>
|
|
1693
|
+
<div class="frame tight"><div class="frame-body tight">
|
|
1694
|
+
<div class="table-wrap">
|
|
1695
|
+
<table id="wl-history-table">
|
|
1696
|
+
<thead><tr><th>Time</th><th>Email</th><th>Status</th><th>Proxy</th><th></th></tr></thead>
|
|
1697
|
+
<tbody></tbody>
|
|
1698
|
+
</table>
|
|
1699
|
+
</div>
|
|
1700
|
+
</div></div>
|
|
1701
|
+
</div>
|
|
1702
|
+
|
|
1703
|
+
<div class="section" id="wl-proxy-section">
|
|
1704
|
+
<div class="section-head">
|
|
1705
|
+
<div>
|
|
1706
|
+
<div class="section-title"><span class="num">P</span><span data-zh="登录代理(可选)" data-en="Login proxy (optional)">登录代理(可选)</span></div>
|
|
1707
|
+
<div class="section-desc" data-zh="为本次登录指定代理;留空则使用全局代理设置。代理生效后账号的后续聊天请求也会经此代理出站" data-en="Specify a proxy for this login; leave blank to use the global proxy. Once set, the account's later chat requests will egress through this proxy too.">为本次登录指定代理;留空则使用全局代理设置。代理生效后账号的后续聊天请求也会经此代理出站</div>
|
|
1708
|
+
</div>
|
|
1709
|
+
</div>
|
|
1710
|
+
<div class="frame">
|
|
1711
|
+
<div class="field-row">
|
|
1712
|
+
<div class="field" style="max-width:120px"><label class="field-label">Type</label><select class="select" id="wl-proxy-type"><option value="http">HTTP</option><option value="https">HTTPS</option><option value="socks5">SOCKS5</option></select></div>
|
|
1713
|
+
<div class="field" style="flex:2"><label class="field-label">Host</label><input class="input" id="wl-proxy-host" placeholder="留空 = 使用全局"></div>
|
|
1714
|
+
<div class="field" style="max-width:110px"><label class="field-label">Port</label><input class="input" id="wl-proxy-port" placeholder="8080" type="number"></div>
|
|
1715
|
+
<div class="field"><label class="field-label">User</label><input class="input" id="wl-proxy-user" placeholder="optional"></div>
|
|
1716
|
+
<div class="field"><label class="field-label">Pass</label><input class="input" id="wl-proxy-pass" type="password" placeholder="optional"></div>
|
|
1717
|
+
</div>
|
|
1718
|
+
<div class="field-row-actions" style="margin-top:8px">
|
|
1719
|
+
<button class="btn btn-ghost btn-sm" id="wl-proxy-test-btn" onclick="App.testLoginProxy()" data-zh="测试代理" data-en="Test proxy">测试代理</button>
|
|
1720
|
+
<span id="wl-proxy-test-result" class="text-sm text-mute" style="margin-left:10px"></span>
|
|
1721
|
+
</div>
|
|
1722
|
+
</div>
|
|
1723
|
+
</div>
|
|
1724
|
+
</section>
|
|
1725
|
+
|
|
1726
|
+
<!-- ─── Accounts ─── -->
|
|
1727
|
+
<section class="panel" id="p-accounts">
|
|
1728
|
+
<header class="page-header">
|
|
1729
|
+
<div>
|
|
1730
|
+
<div><span class="page-num">№ 04</span><span class="anno" data-zh="谁在工作?" data-en="Who's working?">谁在工作?</span></div>
|
|
1731
|
+
<h1 class="page-title"><em class="em">Account</em> Manager</h1>
|
|
1732
|
+
<div class="page-subtitle" data-zh="点击 <svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="9 18 15 12 9 6"/></svg> 展开看每个账号的额度 / 计划 / 模型清单" data-en="Click <svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="9 18 15 12 9 6"/></svg> to expand quota · plan · models">点击 <svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="9 18 15 12 9 6"/></svg> 展开看每个账号的额度 / 计划 / 模型清单</div>
|
|
1733
|
+
</div>
|
|
1734
|
+
<div class="page-meta">
|
|
1735
|
+
<div class="btn-group" style="justify-content:flex-end">
|
|
1736
|
+
<button class="btn btn-ghost btn-sm" onclick="App.probeAll()"><svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> probe all</button>
|
|
1737
|
+
<button class="btn btn-ghost btn-sm" onclick="App.refreshAllCredits()"><svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> refresh credits</button>
|
|
1738
|
+
</div>
|
|
1739
|
+
</div>
|
|
1740
|
+
</header>
|
|
1741
|
+
|
|
1742
|
+
<div id="ls-pool-card"></div>
|
|
1743
|
+
|
|
1744
|
+
<!-- v2.0.57 Fix 5 — drought mode banner. Hidden until loadDrought()
|
|
1745
|
+
flips it. Hand-drawn warning frame to fit the sketch theme. -->
|
|
1746
|
+
<div id="drought-banner" style="display:none;margin:10px 0 16px;padding:14px 18px;border:1.5px dashed #d97706;background:#fff7e6;border-radius:8px;color:#874d00">
|
|
1747
|
+
<strong data-zh="配额预警 ·" data-en="Quota drought ·">配额预警 ·</strong>
|
|
1748
|
+
<span id="drought-message" data-zh="所有账号本周配额都低于阈值,建议补账号或等待重置" data-en="every account's weekly quota is under the threshold; add accounts or wait for reset">所有账号本周配额都低于阈值,建议补账号或等待重置</span>
|
|
1749
|
+
<span id="drought-detail" class="text-mute" style="margin-left:8px;font-size:12px"></span>
|
|
1750
|
+
</div>
|
|
1751
|
+
|
|
1752
|
+
<div class="section">
|
|
1753
|
+
<div class="section-head">
|
|
1754
|
+
<div><div class="section-title"><span class="num">+</span><span data-zh="添加账号" data-en="Add an account">添加账号</span></div>
|
|
1755
|
+
<div class="section-desc" data-zh="粘贴 API Key 或 Token(来自 windsurf.com/show-auth-token)" data-en="Paste an API Key or auth token (from windsurf.com/show-auth-token)">粘贴 API Key 或 Token</div>
|
|
1756
|
+
</div>
|
|
1757
|
+
</div>
|
|
1758
|
+
<div class="frame">
|
|
1759
|
+
<div class="field-row">
|
|
1760
|
+
<div class="field"><label class="field-label">Type</label><select class="select" id="acc-type"><option value="api_key">API Key</option><option value="token">Token</option></select></div>
|
|
1761
|
+
<div class="field" style="flex:3"><label class="field-label">Key / Token</label><input class="input" id="acc-key" placeholder="paste here"></div>
|
|
1762
|
+
<div class="field"><label class="field-label">Label (optional)</label><input class="input" id="acc-label" placeholder="my-laptop"></div>
|
|
1763
|
+
</div>
|
|
1764
|
+
<div class="field-row-actions">
|
|
1765
|
+
<button class="btn btn-primary" onclick="App.addAccount()" data-zh="加入账号池" data-en="Add to pool">+ 加入账号池</button>
|
|
1766
|
+
</div>
|
|
1767
|
+
</div>
|
|
1768
|
+
</div>
|
|
1769
|
+
|
|
1770
|
+
<div class="section">
|
|
1771
|
+
<div class="section-head">
|
|
1772
|
+
<div><div class="section-title"><span class="num">▤</span><span data-zh="账号池" data-en="Account pool">账号池</span></div></div>
|
|
1773
|
+
</div>
|
|
1774
|
+
<div class="frame tight"><div class="frame-body tight">
|
|
1775
|
+
<div class="table-wrap">
|
|
1776
|
+
<table id="accounts-table">
|
|
1777
|
+
<thead><tr>
|
|
1778
|
+
<th>ID</th><th>Email</th><th>Tier</th><th>RPM</th><th>Credits</th><th>Models</th><th>Status</th><th>Errs</th><th>Last used</th><th>Key</th><th></th>
|
|
1779
|
+
</tr></thead>
|
|
1780
|
+
<tbody></tbody>
|
|
1781
|
+
</table>
|
|
1782
|
+
</div>
|
|
1783
|
+
</div></div>
|
|
1784
|
+
</div>
|
|
1785
|
+
</section>
|
|
1786
|
+
|
|
1787
|
+
<!-- ─── Bans ─── -->
|
|
1788
|
+
<section class="panel" id="p-bans">
|
|
1789
|
+
<header class="page-header">
|
|
1790
|
+
<div>
|
|
1791
|
+
<div><span class="page-num">№ 05</span><span class="anno" data-zh="出问题了吗?" data-en="Anything broken?">出问题了吗?</span></div>
|
|
1792
|
+
<h1 class="page-title"><em class="em">Anom</em>alies</h1>
|
|
1793
|
+
<div class="page-subtitle" data-zh="错误账号与异常状态" data-en="Errored accounts & abnormal states">错误账号与异常状态</div>
|
|
1794
|
+
</div>
|
|
1795
|
+
</header>
|
|
1796
|
+
<div class="metrics-grid" id="ban-cards"></div>
|
|
1797
|
+
<div class="section">
|
|
1798
|
+
<div class="section-head">
|
|
1799
|
+
<div><div class="section-title"><span class="num">!</span><span data-zh="账号健康状况" data-en="Account health">账号健康状况</span></div></div>
|
|
1800
|
+
</div>
|
|
1801
|
+
<div class="frame tight"><div class="frame-body tight">
|
|
1802
|
+
<div class="table-wrap">
|
|
1803
|
+
<table id="ban-table">
|
|
1804
|
+
<thead><tr><th>Account</th><th>Status</th><th>Errors</th><th>Last used</th><th>Action</th></tr></thead>
|
|
1805
|
+
<tbody></tbody>
|
|
1806
|
+
</table>
|
|
1807
|
+
</div>
|
|
1808
|
+
</div></div>
|
|
1809
|
+
</div>
|
|
1810
|
+
</section>
|
|
1811
|
+
|
|
1812
|
+
<!-- ─── Models ─── -->
|
|
1813
|
+
<section class="panel" id="p-models">
|
|
1814
|
+
<header class="page-header">
|
|
1815
|
+
<div>
|
|
1816
|
+
<div><span class="page-num">№ 06</span><span class="anno" data-zh="哪些可以叫?" data-en="Which models?">哪些可以叫?</span></div>
|
|
1817
|
+
<h1 class="page-title"><em class="em">Model</em> Control</h1>
|
|
1818
|
+
<div class="page-subtitle" data-zh="允许 / 禁用名单 + 模型目录" data-en="Allow / block list & model catalog">允许 / 禁用名单 + 模型目录</div>
|
|
1819
|
+
</div>
|
|
1820
|
+
</header>
|
|
1821
|
+
|
|
1822
|
+
<div class="section">
|
|
1823
|
+
<div class="section-head">
|
|
1824
|
+
<div><div class="section-title"><span class="num">∀</span><span data-zh="访问模式" data-en="Access mode">访问模式</span></div></div>
|
|
1825
|
+
</div>
|
|
1826
|
+
<div class="frame">
|
|
1827
|
+
<div class="segmented" style="margin-bottom:16px">
|
|
1828
|
+
<label><input type="radio" name="model-mode" value="all" onchange="App.setModelMode('all')" checked>全部</label>
|
|
1829
|
+
<label><input type="radio" name="model-mode" value="allowlist" onchange="App.setModelMode('allowlist')">白名单</label>
|
|
1830
|
+
<label><input type="radio" name="model-mode" value="blocklist" onchange="App.setModelMode('blocklist')">黑名单</label>
|
|
1831
|
+
</div>
|
|
1832
|
+
<div id="model-list-section" class="hidden">
|
|
1833
|
+
<div class="anno" id="model-list-title">当前清单</div>
|
|
1834
|
+
<div id="model-list-current" style="margin:6px 0 16px"></div>
|
|
1835
|
+
<div class="field-row" style="margin-bottom:12px">
|
|
1836
|
+
<div class="field"><label class="field-label">Search</label><input class="input" id="model-search" placeholder="qwen, claude, gpt …" oninput="App.filterModels()"></div>
|
|
1837
|
+
<div class="field"><label class="field-label">Provider</label><select class="select" id="model-provider-filter" onchange="App.filterModels()"><option value="">All providers</option></select></div>
|
|
1838
|
+
</div>
|
|
1839
|
+
<div id="model-chips-container" style="max-height:50vh;overflow:auto;padding-right:8px"></div>
|
|
1840
|
+
<div class="field-hint" id="model-list-hint" style="margin-top:8px"></div>
|
|
1841
|
+
</div>
|
|
1842
|
+
</div>
|
|
1843
|
+
</div>
|
|
1844
|
+
</section>
|
|
1845
|
+
|
|
1846
|
+
<!-- ─── Proxy ─── -->
|
|
1847
|
+
<section class="panel" id="p-proxy">
|
|
1848
|
+
<header class="page-header">
|
|
1849
|
+
<div>
|
|
1850
|
+
<div><span class="page-num">№ 07</span><span class="anno" data-zh="走哪条路?" data-en="Which route?">走哪条路?</span></div>
|
|
1851
|
+
<h1 class="page-title"><em class="em">Proxy</em> Routing</h1>
|
|
1852
|
+
<div class="page-subtitle" data-zh="全局代理 + 每账号代理" data-en="Global proxy & per-account overrides">全局代理 + 每账号代理</div>
|
|
1853
|
+
</div>
|
|
1854
|
+
</header>
|
|
1855
|
+
<div class="section">
|
|
1856
|
+
<div class="section-head">
|
|
1857
|
+
<div><div class="section-title"><span class="num">G</span><span data-zh="全局代理" data-en="Global proxy">全局代理</span></div>
|
|
1858
|
+
<div class="section-desc" id="proxy-current">未配置</div>
|
|
1859
|
+
</div>
|
|
1860
|
+
</div>
|
|
1861
|
+
<div class="frame">
|
|
1862
|
+
<div class="field-row">
|
|
1863
|
+
<div class="field"><label class="field-label">Type</label><select class="select" id="proxy-type"><option>http</option><option>https</option><option>socks5</option></select></div>
|
|
1864
|
+
<div class="field"><label class="field-label">Host</label><input class="input" id="proxy-host" placeholder="proxy.host"></div>
|
|
1865
|
+
<div class="field"><label class="field-label">Port</label><input class="input" id="proxy-port" placeholder="8080" type="number"></div>
|
|
1866
|
+
<div class="field"><label class="field-label">Username</label><input class="input" id="proxy-user" placeholder="optional"></div>
|
|
1867
|
+
<div class="field"><label class="field-label">Password</label><input class="input" id="proxy-pass" type="password" placeholder="optional"></div>
|
|
1868
|
+
</div>
|
|
1869
|
+
<div class="field-row-actions">
|
|
1870
|
+
<button class="btn btn-primary" onclick="App.saveGlobalProxy()" data-zh="保存" data-en="Save">保存</button>
|
|
1871
|
+
<button class="btn btn-ghost" onclick="App.clearGlobalProxy()" data-zh="清除" data-en="Clear">清除</button>
|
|
1872
|
+
</div>
|
|
1873
|
+
</div>
|
|
1874
|
+
</div>
|
|
1875
|
+
<div class="section">
|
|
1876
|
+
<div class="section-head">
|
|
1877
|
+
<div><div class="section-title"><span class="num">a</span><span data-zh="每账号代理" data-en="Per-account">每账号代理</span></div></div>
|
|
1878
|
+
</div>
|
|
1879
|
+
<div class="frame tight"><div class="frame-body tight">
|
|
1880
|
+
<div class="table-wrap">
|
|
1881
|
+
<table id="proxy-accounts-table">
|
|
1882
|
+
<thead><tr><th>Account</th><th>Proxy</th><th>Action</th></tr></thead>
|
|
1883
|
+
<tbody></tbody>
|
|
1884
|
+
</table>
|
|
1885
|
+
</div>
|
|
1886
|
+
</div></div>
|
|
1887
|
+
</div>
|
|
1888
|
+
</section>
|
|
1889
|
+
|
|
1890
|
+
<!-- ─── Logs ─── -->
|
|
1891
|
+
<section class="panel" id="p-logs">
|
|
1892
|
+
<header class="page-header">
|
|
1893
|
+
<div>
|
|
1894
|
+
<div><span class="page-num">№ 08</span><span class="anno" data-zh="刚才发生了什么?" data-en="What happened?">刚才发生了什么?</span></div>
|
|
1895
|
+
<h1 class="page-title"><em class="em">Run</em> Logs</h1>
|
|
1896
|
+
<div class="page-subtitle" data-zh="实时流式日志 · SSE" data-en="Live stream · SSE">实时流式日志 · SSE</div>
|
|
1897
|
+
</div>
|
|
1898
|
+
</header>
|
|
1899
|
+
<div class="log-controls">
|
|
1900
|
+
<select class="select" id="log-level" onchange="App.applyLogFilter()">
|
|
1901
|
+
<option value="">all levels</option>
|
|
1902
|
+
<option value="debug">debug</option>
|
|
1903
|
+
<option value="info">info</option>
|
|
1904
|
+
<option value="warn">warn</option>
|
|
1905
|
+
<option value="error">error</option>
|
|
1906
|
+
</select>
|
|
1907
|
+
<input class="input search" id="log-search" placeholder="search …" oninput="App.applyLogFilter()">
|
|
1908
|
+
<label class="checkbox"><input type="checkbox" id="log-tail" checked><span class="box-mark"></span>auto-scroll</label>
|
|
1909
|
+
<button class="btn btn-ghost btn-sm" onclick="App.clearLogs()">clear</button>
|
|
1910
|
+
</div>
|
|
1911
|
+
<div class="log-container" id="log-container"></div>
|
|
1912
|
+
</section>
|
|
1913
|
+
|
|
1914
|
+
<!-- ─── Experimental ─── -->
|
|
1915
|
+
<section class="panel" id="p-experimental">
|
|
1916
|
+
<header class="page-header">
|
|
1917
|
+
<div>
|
|
1918
|
+
<div><span class="page-num">№ 09</span><span class="anno" data-zh="不一定稳哦" data-en="Beware: unstable">不一定稳哦</span></div>
|
|
1919
|
+
<h1 class="page-title"><em class="em">Exp</em>erimental</h1>
|
|
1920
|
+
<div class="page-subtitle" data-zh="尚未稳定 — 出问题随时关闭" data-en="Toggle off any time">尚未稳定 — 出问题随时关闭</div>
|
|
1921
|
+
</div>
|
|
1922
|
+
</header>
|
|
1923
|
+
<div class="section">
|
|
1924
|
+
<div class="section-head">
|
|
1925
|
+
<div>
|
|
1926
|
+
<div class="section-title"><span class="num">~</span><span data-zh="Cascade 对话复用" data-en="Cascade reuse">Cascade 对话复用</span></div>
|
|
1927
|
+
<div class="section-desc" data-zh="多轮对话复用 cascade_id,让服务端维持上下文缓存。" data-en="Reuse cascade_id across turns to hit server-side context cache.">多轮对话复用 cascade_id,让服务端维持上下文缓存。</div>
|
|
1928
|
+
</div>
|
|
1929
|
+
</div>
|
|
1930
|
+
<div class="frame">
|
|
1931
|
+
<div style="display:flex;align-items:center;gap:14px;margin-bottom:18px">
|
|
1932
|
+
<label class="switch"><input type="checkbox" id="exp-cascade-reuse" onchange="App.toggleExperimental('cascadeConversationReuse', this.checked)"><span class="switch-slider"></span></label>
|
|
1933
|
+
<div>
|
|
1934
|
+
<div style="font-weight:500" data-zh="启用 Cascade 对话复用" data-en="Enable cascade reuse">启用 Cascade 对话复用</div>
|
|
1935
|
+
<div class="text-sm text-mute" data-zh="默认关闭。开启后立即生效。" data-en="Off by default. Takes effect immediately.">默认关闭。开启后立即生效。</div>
|
|
1936
|
+
</div>
|
|
1937
|
+
</div>
|
|
1938
|
+
<!-- v2.0.58 — drought-mode premium gate toggle -->
|
|
1939
|
+
<div style="display:flex;align-items:center;gap:14px;margin-bottom:18px;padding-top:14px;border-top:1px dashed var(--rule-faint)">
|
|
1940
|
+
<label class="switch"><input type="checkbox" id="exp-drought-restrict" onchange="App.toggleExperimental('droughtRestrictPremium', this.checked)"><span class="switch-slider"></span></label>
|
|
1941
|
+
<div>
|
|
1942
|
+
<div style="font-weight:500" data-zh="drought 时屏蔽 premium 模型" data-en="Block premium models during drought">drought 时屏蔽 premium 模型</div>
|
|
1943
|
+
<div class="text-sm text-mute" data-zh="所有账号本周配额都 < 5% 时对 premium 模型请求返 503,避免烧最后配额。免费层模型(gemini-2.5-flash 等)保持可用。" data-en="Refuse premium models with 503 when every account is below 5% weekly so the last quota isn't burned on a 429. Free-tier models still go through.">所有账号本周配额都 < 5% 时对 premium 模型请求返 503,避免烧最后配额。</div>
|
|
1944
|
+
</div>
|
|
1945
|
+
</div>
|
|
1946
|
+
<div class="metrics-grid" id="exp-pool-cards" style="margin:0;border:1.5px solid var(--rule-faint)"></div>
|
|
1947
|
+
<div class="field-row-actions">
|
|
1948
|
+
<button class="btn btn-ghost btn-sm" onclick="App.clearConversationPool()">clear pool</button>
|
|
1949
|
+
<button class="btn btn-ghost btn-sm" onclick="App.loadExperimental()"><svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> refresh</button>
|
|
1950
|
+
</div>
|
|
1951
|
+
</div>
|
|
1952
|
+
</div>
|
|
1953
|
+
|
|
1954
|
+
<div class="section" style="margin-top:24px">
|
|
1955
|
+
<div class="section-head">
|
|
1956
|
+
<div>
|
|
1957
|
+
<div class="section-title"><span class="num">~</span><span data-zh="系统提示词" data-en="System prompts">系统提示词</span></div>
|
|
1958
|
+
<div class="section-desc" data-zh="工具注入、对话模式等内置提示词。改完点 save;reset 恢复默认。" data-en="Built-in prompt templates. Edit and save; reset to restore defaults.">工具注入、对话模式等内置提示词。改完点 save;reset 恢复默认。</div>
|
|
1959
|
+
</div>
|
|
1960
|
+
</div>
|
|
1961
|
+
<div class="frame">
|
|
1962
|
+
<div id="system-prompts-editor" style="display:grid;gap:14px"></div>
|
|
1963
|
+
</div>
|
|
1964
|
+
</div>
|
|
1965
|
+
|
|
1966
|
+
<div class="section" style="margin-top:24px">
|
|
1967
|
+
<div class="section-head">
|
|
1968
|
+
<div>
|
|
1969
|
+
<div class="section-title"><span class="num">~</span><span data-zh="凭证管理" data-en="Credentials">凭证管理</span></div>
|
|
1970
|
+
<div class="section-desc" data-zh="运行时改 API_KEY 和 Dashboard 密码,写入 runtime-config.json 立即生效,不重启容器。改完之后下一次请求要用新值。" data-en="Rotate API_KEY and dashboard password from this panel; persisted to runtime-config.json. New value takes effect on the next request.">运行时改 API_KEY 和 Dashboard 密码,写入 runtime-config.json 立即生效,不重启容器。改完之后下一次请求要用新值。</div>
|
|
1971
|
+
</div>
|
|
1972
|
+
</div>
|
|
1973
|
+
<div class="frame" style="display:grid;gap:14px">
|
|
1974
|
+
<div style="display:grid;gap:8px">
|
|
1975
|
+
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
1976
|
+
<strong>API_KEY</strong>
|
|
1977
|
+
<span class="text-mute" id="credentials-apikey-source"></span>
|
|
1978
|
+
<span style="font-family:monospace" id="credentials-apikey-masked"></span>
|
|
1979
|
+
</div>
|
|
1980
|
+
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
|
1981
|
+
<input type="password" class="input" id="credentials-apikey-input" placeholder="新 API_KEY(≥ 8 字符;留空清除运行时覆盖)" style="flex:1;min-width:240px">
|
|
1982
|
+
<button class="btn btn-ghost btn-sm" onclick="App.toggleApiKeyVisibility()" data-zh="显隐" data-en="show/hide">显隐</button>
|
|
1983
|
+
<button class="btn btn-sm" onclick="App.saveCredential('apiKey')" data-zh="保存" data-en="save">保存</button>
|
|
1984
|
+
</div>
|
|
1985
|
+
<div class="text-sm text-mute" data-zh="改完之后所有 chat 客户端要换成新 KEY;旧 KEY 立即失效。" data-en="All chat clients must use the new KEY after save; the old KEY stops working immediately.">改完之后所有 chat 客户端要换成新 KEY;旧 KEY 立即失效。</div>
|
|
1986
|
+
</div>
|
|
1987
|
+
<div style="display:grid;gap:8px">
|
|
1988
|
+
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
1989
|
+
<strong data-zh="Dashboard 密码" data-en="Dashboard password">Dashboard 密码</strong>
|
|
1990
|
+
<span class="text-mute" id="credentials-dashboardpw-source"></span>
|
|
1991
|
+
<span id="credentials-dashboardpw-status"></span>
|
|
1992
|
+
</div>
|
|
1993
|
+
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
|
1994
|
+
<input type="password" class="input" id="credentials-dashboardpw-input" placeholder="新 Dashboard 密码(≥ 8 字符;留空清除运行时覆盖)" style="flex:1;min-width:240px">
|
|
1995
|
+
<button class="btn btn-ghost btn-sm" onclick="App.toggleDashboardPwVisibility()" data-zh="显隐" data-en="show/hide">显隐</button>
|
|
1996
|
+
<button class="btn btn-sm" onclick="App.saveCredential('dashboardPassword')" data-zh="保存" data-en="save">保存</button>
|
|
1997
|
+
</div>
|
|
1998
|
+
<div class="text-sm text-mute" data-zh="scrypt 派生哈希存盘;改完会自动登出,需用新密码重登。" data-en="scrypt-derived; you'll be signed out and need to re-login with the new password.">scrypt 派生哈希存盘;改完会自动登出,需用新密码重登。</div>
|
|
1999
|
+
</div>
|
|
2000
|
+
<div class="text-sm text-mute" data-zh="同一 IP 连续 5 次 dashboard 登录失败会被锁 30 分钟(v2.0.56 起)。" data-en="Same IP gets locked for 30 minutes after 5 failed dashboard logins (since v2.0.56).">同一 IP 连续 5 次 dashboard 登录失败会被锁 30 分钟(v2.0.56 起)。</div>
|
|
2001
|
+
</div>
|
|
2002
|
+
</div>
|
|
2003
|
+
|
|
2004
|
+
<div class="section" style="margin-top:24px">
|
|
2005
|
+
<div class="section-head">
|
|
2006
|
+
<div>
|
|
2007
|
+
<div class="section-title"><span class="num">~</span><span data-zh="界面风格" data-en="Console skin">界面风格</span></div>
|
|
2008
|
+
<div class="section-desc" data-zh="切回默认现代风皮肤。选择后立即写 cookie 并刷新。" data-en="Switch back to the default modern skin. Selecting writes a cookie and reloads.">切回默认现代风皮肤。选择后立即写 cookie 并刷新。</div>
|
|
2009
|
+
</div>
|
|
2010
|
+
</div>
|
|
2011
|
+
<div class="frame">
|
|
2012
|
+
<div style="display:flex;align-items:center;gap:14px">
|
|
2013
|
+
<select id="skin-select" class="select" style="max-width:240px" onchange="App.switchSkin(this.value)">
|
|
2014
|
+
<option value="modern" data-zh="默认现代风" data-en="Default (modern)">默认现代风</option>
|
|
2015
|
+
<option value="sketch" data-zh="手绘草稿风(当前)" data-en="Sketch (current)">手绘草稿风(当前)</option>
|
|
2016
|
+
</select>
|
|
2017
|
+
<span class="text-sm text-mute" data-zh="当前为草稿风" data-en="Currently sketch skin">当前为草稿风</span>
|
|
2018
|
+
</div>
|
|
2019
|
+
</div>
|
|
2020
|
+
</div>
|
|
2021
|
+
</section>
|
|
2022
|
+
|
|
2023
|
+
<!-- ─── Credits ─── -->
|
|
2024
|
+
<section class="panel" id="p-credits">
|
|
2025
|
+
<header class="page-header">
|
|
2026
|
+
<div>
|
|
2027
|
+
<div><span class="page-num">№ 10</span><span class="anno" data-zh="谢谢你们" data-en="Thank you">谢谢你们</span></div>
|
|
2028
|
+
<h1 class="page-title"><em class="em">Cre</em>dits</h1>
|
|
2029
|
+
<div class="page-subtitle" data-zh="为这个项目提交 PR / 审计代码 / 修复 bug 的朋友们" data-en="People who shipped PRs · audited code · fixed bugs">为这个项目提交 PR / 审计代码 / 修复 bug 的朋友们</div>
|
|
2030
|
+
</div>
|
|
2031
|
+
</header>
|
|
2032
|
+
<div class="section">
|
|
2033
|
+
<div class="frame" id="credits-body">
|
|
2034
|
+
<div class="text-mute italic">loading …</div>
|
|
2035
|
+
</div>
|
|
2036
|
+
</div>
|
|
2037
|
+
<div class="section">
|
|
2038
|
+
<div class="section-head">
|
|
2039
|
+
<div><div class="section-title"><span class="num"><svg class="ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg></span><span data-zh="想加入这份名单?" data-en="Want to join this list?">想加入这份名单?</span></div></div>
|
|
2040
|
+
</div>
|
|
2041
|
+
<div class="frame">
|
|
2042
|
+
<p>欢迎到 GitHub 提 issue 或 PR — 修 bug、加模型、改 UI、发现安全问题都会被记录在这里。</p>
|
|
2043
|
+
<div class="field-row-actions">
|
|
2044
|
+
<a class="btn" target="_blank" rel="noopener" href="https://github.com/dwgx/WindsurfAPI/issues">提 issue</a>
|
|
2045
|
+
<a class="btn" target="_blank" rel="noopener" href="https://github.com/dwgx/WindsurfAPI/pulls">提 PR</a>
|
|
2046
|
+
</div>
|
|
2047
|
+
</div>
|
|
2048
|
+
</div>
|
|
2049
|
+
</section>
|
|
2050
|
+
</main>
|
|
2051
|
+
|
|
2052
|
+
<!-- Login overlay -->
|
|
2053
|
+
<div class="login-overlay hidden" id="login-overlay">
|
|
2054
|
+
<div class="login-box">
|
|
2055
|
+
<div class="login-mark">W<span class="em">&</span>API</div>
|
|
2056
|
+
<div class="login-tag">a console for the curious</div>
|
|
2057
|
+
<input type="password" id="login-password" class="input" placeholder="dashboard password" onkeydown="if(event.key==='Enter')App.login()">
|
|
2058
|
+
<button class="btn btn-primary" id="login-btn" onclick="App.login()"><svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/></svg> <span id="login-btn-label">enter</span></button>
|
|
2059
|
+
<div id="login-error" style="margin-top:12px;color:var(--error);font-family:'Caveat',cursive;font-size:18px;min-height:22px"></div>
|
|
2060
|
+
</div>
|
|
2061
|
+
</div>
|
|
2062
|
+
|
|
2063
|
+
<div id="modal-container"></div>
|
|
2064
|
+
<div class="toast-stack" id="toast-stack"></div>
|
|
2065
|
+
|
|
2066
|
+
<script>
|
|
2067
|
+
/* ──────────────────────────────────────────────
|
|
2068
|
+
Tiny i18n — element-attribute-driven
|
|
2069
|
+
────────────────────────────────────────────── */
|
|
2070
|
+
const I18n = {
|
|
2071
|
+
locale: localStorage.getItem('lang') || 'zh',
|
|
2072
|
+
apply() {
|
|
2073
|
+
document.querySelectorAll('[data-zh]').forEach(el => {
|
|
2074
|
+
const v = this.locale === 'en' ? el.dataset.en : el.dataset.zh;
|
|
2075
|
+
if (v != null) el.textContent = v;
|
|
2076
|
+
});
|
|
2077
|
+
document.documentElement.lang = this.locale === 'en' ? 'en' : 'zh-CN';
|
|
2078
|
+
const ind = document.getElementById('lang-ind');
|
|
2079
|
+
if (ind) ind.textContent = this.locale === 'en' ? 'EN' : '中';
|
|
2080
|
+
},
|
|
2081
|
+
setLocale(l) { this.locale = l; localStorage.setItem('lang', l); this.apply(); },
|
|
2082
|
+
};
|
|
2083
|
+
|
|
2084
|
+
/* ──────────────────────────────────────────────
|
|
2085
|
+
App
|
|
2086
|
+
────────────────────────────────────────────── */
|
|
2087
|
+
const App = {
|
|
2088
|
+
password: localStorage.getItem('dp') || '',
|
|
2089
|
+
sseConn: null,
|
|
2090
|
+
logEntries: [],
|
|
2091
|
+
pollers: {},
|
|
2092
|
+
allModels: [],
|
|
2093
|
+
modelAccessConfig: { mode: 'all', list: [] },
|
|
2094
|
+
loginHistory: JSON.parse(localStorage.getItem('wl_history') || '[]'),
|
|
2095
|
+
_expandedAccounts: new Set(),
|
|
2096
|
+
_modelsCatalog: null,
|
|
2097
|
+
|
|
2098
|
+
/* ── theme ── */
|
|
2099
|
+
toggleTheme() {
|
|
2100
|
+
const cur = document.documentElement.dataset.theme;
|
|
2101
|
+
const next = cur === 'light' ? 'dark' : 'light';
|
|
2102
|
+
document.documentElement.dataset.theme = next;
|
|
2103
|
+
localStorage.setItem('theme', next);
|
|
2104
|
+
document.getElementById('theme-ind').innerHTML = next === 'light' ? `<svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>` : `<svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>`;
|
|
2105
|
+
},
|
|
2106
|
+
toggleLang() { I18n.setLocale(I18n.locale === 'zh' ? 'en' : 'zh'); },
|
|
2107
|
+
/* ── skin (sketch ↔ default modern UI) ── */
|
|
2108
|
+
switchSkin(skin) {
|
|
2109
|
+
const next = skin === 'sketch' ? 'sketch' : 'modern';
|
|
2110
|
+
const oneYear = 365 * 24 * 3600;
|
|
2111
|
+
document.cookie = `dashboard_skin=${next}; path=/; max-age=${oneYear}; samesite=lax`;
|
|
2112
|
+
location.reload();
|
|
2113
|
+
},
|
|
2114
|
+
|
|
2115
|
+
/* ── boot ── */
|
|
2116
|
+
async init() {
|
|
2117
|
+
const t = localStorage.getItem('theme');
|
|
2118
|
+
if (t) {
|
|
2119
|
+
document.documentElement.dataset.theme = t;
|
|
2120
|
+
const ind = document.getElementById('theme-ind');
|
|
2121
|
+
if (ind) ind.innerHTML = t === 'light' ? `<svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>` : `<svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>`;
|
|
2122
|
+
}
|
|
2123
|
+
I18n.apply();
|
|
2124
|
+
setInterval(() => {
|
|
2125
|
+
const el = document.getElementById('hdr-now');
|
|
2126
|
+
if (el) el.textContent = new Date().toLocaleTimeString();
|
|
2127
|
+
}, 1000);
|
|
2128
|
+
document.getElementById('hdr-now').textContent = new Date().toLocaleTimeString();
|
|
2129
|
+
|
|
2130
|
+
const auth = await this.api('GET', '/auth');
|
|
2131
|
+
if (auth.required && !auth.valid) {
|
|
2132
|
+
document.getElementById('login-overlay').classList.remove('hidden');
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
document.getElementById('login-overlay').classList.add('hidden');
|
|
2136
|
+
document.querySelectorAll('.sidebar nav a').forEach(a => {
|
|
2137
|
+
a.onclick = (e) => { e.preventDefault(); this.navigate(a.dataset.panel); };
|
|
2138
|
+
});
|
|
2139
|
+
const hash = location.hash.slice(1);
|
|
2140
|
+
if (hash) this.navigate(hash); else this.loadOverview();
|
|
2141
|
+
},
|
|
2142
|
+
|
|
2143
|
+
async login() {
|
|
2144
|
+
const pw = document.getElementById('login-password').value;
|
|
2145
|
+
const btn = document.getElementById('login-btn');
|
|
2146
|
+
const lbl = document.getElementById('login-btn-label');
|
|
2147
|
+
const errEl = document.getElementById('login-error');
|
|
2148
|
+
const en = I18n.locale === 'en';
|
|
2149
|
+
if (errEl) errEl.textContent = '';
|
|
2150
|
+
if (!pw) {
|
|
2151
|
+
if (errEl) errEl.textContent = en ? 'password required' : '请输入密码';
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
if (btn) btn.disabled = true;
|
|
2155
|
+
if (lbl) { lbl.dataset.orig ||= lbl.textContent; lbl.textContent = en ? 'checking…' : '验证中…'; }
|
|
2156
|
+
try {
|
|
2157
|
+
// Pre-flight: check the password against /auth before storing it so a
|
|
2158
|
+
// wrong attempt actually shows feedback instead of silently re-rendering
|
|
2159
|
+
// the same overlay (which looks like "no response" to users).
|
|
2160
|
+
const r = await fetch('/dashboard/api/auth', { headers: { 'X-Dashboard-Password': pw } });
|
|
2161
|
+
const data = await r.json().catch(() => ({}));
|
|
2162
|
+
if (data.locked) {
|
|
2163
|
+
if (errEl) errEl.textContent = en
|
|
2164
|
+
? 'backend has no password configured — set DASHBOARD_PASSWORD or API_KEY env and restart'
|
|
2165
|
+
: '后端未配置密码:请在服务端设置 DASHBOARD_PASSWORD 或 API_KEY 环境变量并重启';
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
if (data.required && !data.valid) {
|
|
2169
|
+
if (errEl) errEl.textContent = en ? 'wrong password' : '密码不正确';
|
|
2170
|
+
document.getElementById('login-password')?.focus();
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
this.password = pw;
|
|
2174
|
+
localStorage.setItem('dp', pw);
|
|
2175
|
+
await this.init();
|
|
2176
|
+
} catch (e) {
|
|
2177
|
+
if (errEl) errEl.textContent = (en ? 'cannot reach server: ' : '无法连接服务器: ') + e.message;
|
|
2178
|
+
} finally {
|
|
2179
|
+
if (btn) btn.disabled = false;
|
|
2180
|
+
if (lbl && lbl.dataset.orig) lbl.textContent = lbl.dataset.orig;
|
|
2181
|
+
}
|
|
2182
|
+
},
|
|
2183
|
+
|
|
2184
|
+
navigate(panel) {
|
|
2185
|
+
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
|
2186
|
+
document.querySelectorAll('.sidebar nav a').forEach(a => a.classList.remove('active'));
|
|
2187
|
+
const el = document.getElementById('p-' + panel);
|
|
2188
|
+
const nav = document.querySelector(`[data-panel="${panel}"]`);
|
|
2189
|
+
if (el) el.classList.add('active');
|
|
2190
|
+
if (nav) nav.classList.add('active');
|
|
2191
|
+
location.hash = panel;
|
|
2192
|
+
Object.values(this.pollers).forEach(clearInterval);
|
|
2193
|
+
this.pollers = {};
|
|
2194
|
+
if (this.sseConn) { this.sseConn.close(); this.sseConn = null; }
|
|
2195
|
+
const loaders = {
|
|
2196
|
+
overview: 'loadOverview', 'windsurf-login': 'loadWindsurfLogin',
|
|
2197
|
+
accounts: 'loadAccounts', models: 'loadModels', proxy: 'loadProxy',
|
|
2198
|
+
logs: 'loadLogs', stats: 'loadStats', bans: 'loadBans',
|
|
2199
|
+
experimental: 'loadExperimental', credits: 'loadCredits',
|
|
2200
|
+
};
|
|
2201
|
+
if (loaders[panel]) this[loaders[panel]]();
|
|
2202
|
+
},
|
|
2203
|
+
|
|
2204
|
+
async api(method, path, body) {
|
|
2205
|
+
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
|
2206
|
+
if (this.password) opts.headers['X-Dashboard-Password'] = this.password;
|
|
2207
|
+
if (body) opts.body = JSON.stringify(body);
|
|
2208
|
+
try {
|
|
2209
|
+
const r = await fetch('/dashboard/api' + path, opts);
|
|
2210
|
+
const data = await r.json();
|
|
2211
|
+
if (r.status === 401) {
|
|
2212
|
+
document.getElementById('login-overlay').classList.remove('hidden');
|
|
2213
|
+
return {};
|
|
2214
|
+
}
|
|
2215
|
+
return data;
|
|
2216
|
+
} catch (e) { this.toast(e.message, 'error'); return {}; }
|
|
2217
|
+
},
|
|
2218
|
+
|
|
2219
|
+
poll(key, fn, ms) {
|
|
2220
|
+
if (this.pollers[key]) clearInterval(this.pollers[key]);
|
|
2221
|
+
this.pollers[key] = setInterval(() => { if (!document.hidden) fn(); }, ms);
|
|
2222
|
+
},
|
|
2223
|
+
|
|
2224
|
+
esc(s) {
|
|
2225
|
+
if (s == null) return '';
|
|
2226
|
+
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
2227
|
+
},
|
|
2228
|
+
systemPromptDomId(key) {
|
|
2229
|
+
return `sp-${encodeURIComponent(String(key == null ? '' : key)).replace(/%/g, '_')}`;
|
|
2230
|
+
},
|
|
2231
|
+
escAttr(s) { return this.esc(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); },
|
|
2232
|
+
|
|
2233
|
+
/* ── toast ── */
|
|
2234
|
+
toast(msg, type = 'success') {
|
|
2235
|
+
const t = document.createElement('div');
|
|
2236
|
+
t.className = 'toast ' + type;
|
|
2237
|
+
const icon = type === 'success' ? `<svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="20 6 9 17 4 12"/></svg>` : type === 'error' ? `<svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>` : 'i';
|
|
2238
|
+
t.innerHTML = `<span class="toast-icon">${icon}</span><span>${this.esc(msg)}</span>`;
|
|
2239
|
+
document.getElementById('toast-stack').appendChild(t);
|
|
2240
|
+
setTimeout(() => {
|
|
2241
|
+
t.style.opacity = '0'; t.style.transform = 'translateX(20px)'; t.style.transition = 'all .2s';
|
|
2242
|
+
setTimeout(() => t.remove(), 200);
|
|
2243
|
+
}, 3000);
|
|
2244
|
+
},
|
|
2245
|
+
|
|
2246
|
+
/* ── modal helpers ── */
|
|
2247
|
+
confirm(title, desc, opts = {}) {
|
|
2248
|
+
return new Promise(resolve => {
|
|
2249
|
+
const wrap = document.createElement('div');
|
|
2250
|
+
wrap.className = 'modal-overlay';
|
|
2251
|
+
const descHtml = desc
|
|
2252
|
+
? (opts.html ? `<div class="modal-body">${desc}</div>` : `<div class="modal-desc">${this.esc(desc)}</div>`)
|
|
2253
|
+
: '';
|
|
2254
|
+
const widthStyle = opts.wide ? ' style="max-width:680px;width:92vw"' : '';
|
|
2255
|
+
wrap.innerHTML = `
|
|
2256
|
+
<div class="modal"${widthStyle}>
|
|
2257
|
+
<div class="modal-header">
|
|
2258
|
+
<div class="modal-title">${opts.titleHtml ? title : this.esc(title)}</div>
|
|
2259
|
+
${opts.html ? '' : (desc ? `<div class="modal-desc">${this.esc(desc)}</div>` : '')}
|
|
2260
|
+
</div>
|
|
2261
|
+
${opts.html ? descHtml : ''}
|
|
2262
|
+
<div class="modal-footer">
|
|
2263
|
+
<button class="btn btn-ghost" data-act="cancel">${this.esc(opts.cancelText || 'Cancel')}</button>
|
|
2264
|
+
<button class="btn ${opts.danger ? 'btn-danger' : 'btn-primary'}" data-act="ok">${this.esc(opts.okText || 'OK')}</button>
|
|
2265
|
+
</div>
|
|
2266
|
+
</div>`;
|
|
2267
|
+
document.getElementById('modal-container').appendChild(wrap);
|
|
2268
|
+
const close = (v) => { wrap.remove(); resolve(v); };
|
|
2269
|
+
wrap.querySelector('[data-act=cancel]').onclick = () => close(false);
|
|
2270
|
+
wrap.querySelector('[data-act=ok]').onclick = () => close(true);
|
|
2271
|
+
wrap.onclick = (e) => { if (e.target === wrap) close(false); };
|
|
2272
|
+
});
|
|
2273
|
+
},
|
|
2274
|
+
|
|
2275
|
+
prompt(title, desc, fields) {
|
|
2276
|
+
return new Promise(resolve => {
|
|
2277
|
+
const wrap = document.createElement('div');
|
|
2278
|
+
wrap.className = 'modal-overlay';
|
|
2279
|
+
const fieldHtml = fields.map(f => `
|
|
2280
|
+
<div class="field" style="margin-top:14px">
|
|
2281
|
+
<label class="field-label">${this.esc(f.label)}</label>
|
|
2282
|
+
${f.type === 'select'
|
|
2283
|
+
? `<select class="select" data-name="${f.name}">${f.options.map(o => `<option value="${this.esc(o.value)}" ${o.value === f.value ? 'selected' : ''}>${this.esc(o.label)}</option>`).join('')}</select>`
|
|
2284
|
+
: `<input class="input" data-name="${f.name}" type="${f.type || 'text'}" placeholder="${this.esc(f.placeholder || '')}" value="${this.esc(f.value || '')}">`}
|
|
2285
|
+
${f.hint ? `<div class="field-hint">${this.esc(f.hint)}</div>` : ''}
|
|
2286
|
+
</div>`).join('');
|
|
2287
|
+
wrap.innerHTML = `
|
|
2288
|
+
<div class="modal">
|
|
2289
|
+
<div class="modal-header">
|
|
2290
|
+
<div class="modal-title">${this.esc(title)}</div>
|
|
2291
|
+
${desc ? `<div class="modal-desc">${this.esc(desc)}</div>` : ''}
|
|
2292
|
+
</div>
|
|
2293
|
+
<div class="modal-body">${fieldHtml}</div>
|
|
2294
|
+
<div class="modal-footer">
|
|
2295
|
+
<button class="btn btn-ghost" data-act="cancel">Cancel</button>
|
|
2296
|
+
<button class="btn btn-primary" data-act="ok">OK</button>
|
|
2297
|
+
</div>
|
|
2298
|
+
</div>`;
|
|
2299
|
+
document.getElementById('modal-container').appendChild(wrap);
|
|
2300
|
+
const close = (v) => { wrap.remove(); resolve(v); };
|
|
2301
|
+
wrap.querySelector('[data-act=cancel]').onclick = () => close(null);
|
|
2302
|
+
wrap.querySelector('[data-act=ok]').onclick = () => {
|
|
2303
|
+
const values = {};
|
|
2304
|
+
wrap.querySelectorAll('[data-name]').forEach(el => values[el.dataset.name] = el.value);
|
|
2305
|
+
close(values);
|
|
2306
|
+
};
|
|
2307
|
+
wrap.onclick = (e) => { if (e.target === wrap) close(null); };
|
|
2308
|
+
setTimeout(() => wrap.querySelector('input, select')?.focus(), 100);
|
|
2309
|
+
});
|
|
2310
|
+
},
|
|
2311
|
+
|
|
2312
|
+
/* ── format ── */
|
|
2313
|
+
fmtDuration(s) {
|
|
2314
|
+
if (!s) return '-';
|
|
2315
|
+
const d = Math.floor(s / 86400);
|
|
2316
|
+
const h = Math.floor((s % 86400) / 3600);
|
|
2317
|
+
const m = Math.floor((s % 3600) / 60);
|
|
2318
|
+
if (d) return `${d}d ${h}h`;
|
|
2319
|
+
if (h) return `${h}h ${m}m`;
|
|
2320
|
+
return `${m}m ${s % 60}s`;
|
|
2321
|
+
},
|
|
2322
|
+
|
|
2323
|
+
/* ── update ── */
|
|
2324
|
+
async checkUpdate() {
|
|
2325
|
+
const btn = document.getElementById('btn-check-update');
|
|
2326
|
+
const status = document.getElementById('update-status');
|
|
2327
|
+
const apply = document.getElementById('btn-apply-update');
|
|
2328
|
+
btn.disabled = true;
|
|
2329
|
+
status.classList.remove('hidden');
|
|
2330
|
+
status.innerHTML = '<span class="text-mute italic">checking …</span>';
|
|
2331
|
+
try {
|
|
2332
|
+
const r = await this.api('GET', '/self-update/check');
|
|
2333
|
+
if (!r.ok) {
|
|
2334
|
+
if (/not a git repository/i.test(r.error || '')) {
|
|
2335
|
+
status.innerHTML = `<div style="color:var(--warn);font-weight:600;margin-bottom:4px">Not a git repository</div><div class="text-sm text-mute">Self-update only works for git checkouts.</div>`;
|
|
2336
|
+
apply.classList.add('hidden');
|
|
2337
|
+
return;
|
|
2338
|
+
}
|
|
2339
|
+
throw new Error(r.error || 'API error');
|
|
2340
|
+
}
|
|
2341
|
+
if (r.behind) {
|
|
2342
|
+
status.innerHTML = `<div style="color:var(--accent);font-weight:600;margin-bottom:6px">Update available</div><div class="text-sm" style="display:grid;gap:3px"><div>local <code>${this.esc(r.commit)}</code> <span class="text-mute">${this.esc(r.localMessage)}</span></div><div>remote <code>${this.esc(r.remoteCommit)}</code> <span class="text-mute">${this.esc(r.remoteMessage || '')}</span></div></div>`;
|
|
2343
|
+
apply.classList.remove('hidden');
|
|
2344
|
+
} else {
|
|
2345
|
+
status.innerHTML = `<span style="color:var(--good)"><svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="20 6 9 17 4 12"/></svg> up to date — ${this.esc(r.commit)} ${this.esc(r.localMessage)}</span>`;
|
|
2346
|
+
apply.classList.add('hidden');
|
|
2347
|
+
}
|
|
2348
|
+
} catch (err) {
|
|
2349
|
+
status.innerHTML = `<span style="color:var(--error)"><svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> ${this.esc(err.message)}</span>`;
|
|
2350
|
+
} finally { btn.disabled = false; }
|
|
2351
|
+
},
|
|
2352
|
+
|
|
2353
|
+
async applyUpdate() {
|
|
2354
|
+
const ok = await this.confirm('Update & restart?', 'Pull latest then exec a restart.', { okText: 'Update & restart', danger: true });
|
|
2355
|
+
if (!ok) return;
|
|
2356
|
+
const status = document.getElementById('update-status');
|
|
2357
|
+
const apply = document.getElementById('btn-apply-update');
|
|
2358
|
+
apply.disabled = true;
|
|
2359
|
+
status.innerHTML = '<span class="text-mute italic">pulling & restarting …</span>';
|
|
2360
|
+
try {
|
|
2361
|
+
let r = await this.api('POST', '/self-update');
|
|
2362
|
+
if (r.dirty) {
|
|
2363
|
+
const filesList = (r.dirtyFiles || []).slice(0, 10).map(f => this.esc(f)).join('<br>');
|
|
2364
|
+
const force = await this.confirm('Working dir dirty', `<div>${filesList}</div>`, { okText: 'Force', danger: true, html: true });
|
|
2365
|
+
if (!force) { status.innerHTML = '<span class="text-mute">cancelled</span>'; apply.disabled = false; return; }
|
|
2366
|
+
r = await this.api('POST', '/self-update', { forceReset: true });
|
|
2367
|
+
}
|
|
2368
|
+
if (!r.ok) throw new Error(r.error || 'update failed');
|
|
2369
|
+
if (r.changed) {
|
|
2370
|
+
status.innerHTML = `<div style="color:var(--good);font-weight:600;margin-bottom:6px"><svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="20 6 9 17 4 12"/></svg> ${this.esc(r.before)} <svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ${this.esc(r.after)}</div><div class="text-sm text-mute">restarting…</div>`;
|
|
2371
|
+
setTimeout(() => location.reload(), 8000);
|
|
2372
|
+
} else {
|
|
2373
|
+
status.innerHTML = '<span class="text-mute">no update needed</span>';
|
|
2374
|
+
apply.classList.add('hidden');
|
|
2375
|
+
}
|
|
2376
|
+
} catch (err) {
|
|
2377
|
+
status.innerHTML = `<span style="color:var(--error)"><svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> ${this.esc(err.message)}</span>`;
|
|
2378
|
+
} finally { apply.disabled = false; }
|
|
2379
|
+
},
|
|
2380
|
+
|
|
2381
|
+
/* ── overview ── */
|
|
2382
|
+
async loadOverview() {
|
|
2383
|
+
const d = await this.api('GET', '/overview') || {};
|
|
2384
|
+
try {
|
|
2385
|
+
const h = await fetch('/health').then(r => r.json()).catch(() => null);
|
|
2386
|
+
if (h) {
|
|
2387
|
+
const sv = document.getElementById('sidebar-ver');
|
|
2388
|
+
if (sv) sv.textContent = h.commit ? `v${h.version}·${h.commit.slice(0,7)}` : `v${h.version}`;
|
|
2389
|
+
}
|
|
2390
|
+
} catch {}
|
|
2391
|
+
const uptime = d.uptime ? this.fmtDuration(d.uptime) : '-';
|
|
2392
|
+
const ls = d.langServer || {};
|
|
2393
|
+
const poolCount = (ls.instances || []).length || (ls.running ? 1 : 0);
|
|
2394
|
+
const totalAccts = d.accounts?.total || 0;
|
|
2395
|
+
const navAcct = document.getElementById('nav-accounts-count');
|
|
2396
|
+
if (navAcct) navAcct.textContent = totalAccts;
|
|
2397
|
+
|
|
2398
|
+
document.getElementById('overview-cards').innerHTML = `
|
|
2399
|
+
${this.metric({ label: 'active accounts', value: d.accounts?.active || 0, sub: `${d.accounts?.total || 0} total · ${d.accounts?.error || 0} errored`, kind: 'good' })}
|
|
2400
|
+
${this.metric({ label: 'total requests', value: d.totalRequests || 0, sub: `${d.successRate || 0}% success`, kind: 'accent' })}
|
|
2401
|
+
${this.metric({ label: 'uptime', value: uptime, sub: d.startedAt ? new Date(d.startedAt).toLocaleString() : '—', valueSize: 22 })}
|
|
2402
|
+
${this.metric({ label: 'language server', value: ls.running ? 'running' : 'stopped', sub: `${poolCount} inst · :${ls.port || '-'}`, kind: ls.running ? 'good' : 'error', valueSize: 22 })}
|
|
2403
|
+
${this.metric({ label: 'response cache', value: (d.cache?.hitRate || '0.0') + '%', sub: `${d.cache?.hits || 0} / ${d.cache?.misses || 0} · ${d.cache?.size || 0}/${d.cache?.maxSize || 0}` })}
|
|
2404
|
+
`;
|
|
2405
|
+
const instances = ls.instances || [];
|
|
2406
|
+
if (instances.length > 0) {
|
|
2407
|
+
document.getElementById('ls-status').innerHTML = `<div class="ls-pool" style="border:none;margin:-18px -20px">${
|
|
2408
|
+
instances.map(i => `<div class="ls-inst">
|
|
2409
|
+
<div class="ls-key"><span class="ls-dot ${i.ready ? '' : 'pending'}"></span>${this.esc(i.key === 'default' ? 'default' : i.key.replace(/^px_/, ''))}</div>
|
|
2410
|
+
<div class="ls-meta">port ${i.port} · pid ${i.pid || '-'}</div>
|
|
2411
|
+
<div class="ls-meta">${this.esc(i.proxy || 'no proxy')}</div>
|
|
2412
|
+
</div>`).join('')
|
|
2413
|
+
}</div>`;
|
|
2414
|
+
} else {
|
|
2415
|
+
document.getElementById('ls-status').innerHTML = `<span class="badge ${ls.running ? 'running' : 'stopped'}">${ls.running ? 'running' : 'stopped'}</span> <span class="text-sm text-mute">pid ${ls.pid || '-'} · port ${ls.port || '-'} · restarts ${ls.restartCount || 0}</span>`;
|
|
2416
|
+
}
|
|
2417
|
+
this.poll('overview', () => this.loadOverview(), 15000);
|
|
2418
|
+
},
|
|
2419
|
+
|
|
2420
|
+
metric(o) {
|
|
2421
|
+
const sz = o.valueSize ? `font-size:${o.valueSize}px` : '';
|
|
2422
|
+
return `<div class="metric ${o.kind || ''}">
|
|
2423
|
+
<div class="m-label">${this.esc(o.label)}</div>
|
|
2424
|
+
<div class="m-value" style="${sz}">${this.esc(o.value)}</div>
|
|
2425
|
+
<div class="m-sub">${this.esc(o.sub || '')}</div>
|
|
2426
|
+
</div>`;
|
|
2427
|
+
},
|
|
2428
|
+
|
|
2429
|
+
async restartLs() {
|
|
2430
|
+
const ok = await this.confirm('Restart language server?', 'Active sessions will be reset.', { danger: true, okText: 'Restart' });
|
|
2431
|
+
if (!ok) return;
|
|
2432
|
+
await this.api('POST', '/langserver/restart', { confirm: true });
|
|
2433
|
+
this.toast('restarting…', 'info');
|
|
2434
|
+
},
|
|
2435
|
+
|
|
2436
|
+
/* ── windsurf-login ── */
|
|
2437
|
+
loadWindsurfLogin() { this.renderLoginHistory(); },
|
|
2438
|
+
|
|
2439
|
+
isFirebaseOAuthOriginBlocked(err) {
|
|
2440
|
+
const msg = String(err?.message || err || '');
|
|
2441
|
+
return /auth\/(?:requests-from-referer-.*are-blocked|unauthorized-domain)|requests-from-referer-.*are-blocked|unauthorized-domain/i.test(msg);
|
|
2442
|
+
},
|
|
2443
|
+
|
|
2444
|
+
async oauthLogin(provider) {
|
|
2445
|
+
const btn = document.getElementById(`oauth-${provider}-btn`);
|
|
2446
|
+
const status = document.getElementById('oauth-status');
|
|
2447
|
+
const label = provider === 'google' ? 'Google' : 'GitHub';
|
|
2448
|
+
if (btn) btn.disabled = true;
|
|
2449
|
+
status.textContent = `signing in via ${label} …`;
|
|
2450
|
+
status.style.color = 'var(--ink-mute)';
|
|
2451
|
+
try {
|
|
2452
|
+
if (!window._firebaseOAuth) throw new Error('firebase not loaded');
|
|
2453
|
+
const cred = await window._firebaseOAuth(provider);
|
|
2454
|
+
status.textContent = `registering with codeium … (${label})`;
|
|
2455
|
+
const r = await this.api('POST', '/oauth-login', { idToken: cred.idToken, refreshToken: cred.refreshToken, email: cred.email, provider: cred.provider, autoAdd: true });
|
|
2456
|
+
if (r.error) throw new Error(r.error);
|
|
2457
|
+
status.style.color = 'var(--good)';
|
|
2458
|
+
status.innerHTML = `<svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="20 6 9 17 4 12"/></svg> logged in as ${r.email || label}`;
|
|
2459
|
+
this.toast(`${label} login OK`, 'success');
|
|
2460
|
+
this.loadAccounts();
|
|
2461
|
+
this.pushLoginHistory({ time: new Date().toISOString(), email: r.email || label, proxy: 'direct', status: 'success', method: label });
|
|
2462
|
+
} catch (err) {
|
|
2463
|
+
const rawError = err?.message || String(err);
|
|
2464
|
+
const originBlocked = this.isFirebaseOAuthOriginBlocked(err);
|
|
2465
|
+
const fallback = I18n.locale === 'en'
|
|
2466
|
+
? `${label} OAuth is blocked for this dashboard origin by Firebase. Use Auth Token from windsurf.com/show-auth-token and add it in Account Management.`
|
|
2467
|
+
: `${label} OAuth 被 Firebase 拒绝了当前 Dashboard 来源。请从 windsurf.com/show-auth-token 复制 Auth Token 后在账号管理里添加。`;
|
|
2468
|
+
const message = originBlocked ? fallback : rawError;
|
|
2469
|
+
status.style.color = 'var(--error)';
|
|
2470
|
+
status.innerHTML = `<svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> ${this.esc(message)}`;
|
|
2471
|
+
this.toast(originBlocked ? message : `${label} login failed: ${rawError}`, 'error');
|
|
2472
|
+
} finally { if (btn) btn.disabled = false; }
|
|
2473
|
+
},
|
|
2474
|
+
|
|
2475
|
+
getWindsurfLoginProxy() {
|
|
2476
|
+
const proxyHost = document.getElementById('wl-proxy-host').value.trim();
|
|
2477
|
+
return proxyHost ? {
|
|
2478
|
+
type: document.getElementById('wl-proxy-type').value,
|
|
2479
|
+
host: proxyHost,
|
|
2480
|
+
port: parseInt(document.getElementById('wl-proxy-port').value) || 8080,
|
|
2481
|
+
username: document.getElementById('wl-proxy-user').value,
|
|
2482
|
+
password: document.getElementById('wl-proxy-pass').value,
|
|
2483
|
+
} : null;
|
|
2484
|
+
},
|
|
2485
|
+
proxyLabel(p) { return p ? `${p.type}://${p.host}:${p.port}` : 'direct'; },
|
|
2486
|
+
|
|
2487
|
+
pushLoginHistory(entry) { this.pushLoginHistoryEntries([entry]); },
|
|
2488
|
+
pushLoginHistoryEntries(entries) {
|
|
2489
|
+
for (let i = entries.length - 1; i >= 0; i--) this.loginHistory.unshift(entries[i]);
|
|
2490
|
+
if (this.loginHistory.length > 50) this.loginHistory.length = 50;
|
|
2491
|
+
localStorage.setItem('wl_history', JSON.stringify(this.loginHistory));
|
|
2492
|
+
this.renderLoginHistory();
|
|
2493
|
+
},
|
|
2494
|
+
removeLoginHistory(idx) { this.loginHistory.splice(idx, 1); localStorage.setItem('wl_history', JSON.stringify(this.loginHistory)); this.renderLoginHistory(); },
|
|
2495
|
+
|
|
2496
|
+
showWLResult(html) {
|
|
2497
|
+
const rd = document.getElementById('wl-result');
|
|
2498
|
+
rd.classList.remove('hidden');
|
|
2499
|
+
rd.innerHTML = html;
|
|
2500
|
+
},
|
|
2501
|
+
|
|
2502
|
+
getWindsurfLoginFailActions(r, email) {
|
|
2503
|
+
if (!r.isAuthFail) return '';
|
|
2504
|
+
const inlineId = 'inline-token-' + Math.random().toString(36).slice(2, 8);
|
|
2505
|
+
const emailAttr = this.esc(email || r.email || '');
|
|
2506
|
+
const en = I18n.locale === 'en';
|
|
2507
|
+
const t = {
|
|
2508
|
+
tryOther: en ? 'Try another way' : '换种方式登录',
|
|
2509
|
+
google: en ? 'Sign in with Google' : 'Google 登录',
|
|
2510
|
+
github: en ? 'Sign in with GitHub' : 'GitHub 登录',
|
|
2511
|
+
title: en ? 'Or paste Auth Token directly (recommended for OAuth-only accounts)' : '或者直接粘贴 Auth Token(推荐 OAuth-only 账号用这个)',
|
|
2512
|
+
desc: en ? 'Click the button to open windsurf.com signin page, copy the token shown after login, paste it back here.' : '点按钮打开 windsurf.com 登录页,复制登录完成后显示的 token,粘回这里。',
|
|
2513
|
+
open: en ? 'Open windsurf.com Token' : '打开 windsurf.com 拿 Token',
|
|
2514
|
+
placeholder: en ? 'Paste Auth Token here…' : '粘贴 Auth Token …',
|
|
2515
|
+
add: en ? 'Add via Token' : '用 Token 添加',
|
|
2516
|
+
};
|
|
2517
|
+
return `
|
|
2518
|
+
<div style="margin-top:14px;padding:14px;background:var(--paper-2,rgba(0,0,0,.03));border-radius:8px;border-left:3px solid var(--accent,#c89c5c)">
|
|
2519
|
+
<div style="font-weight:600;margin-bottom:8px">${t.tryOther}</div>
|
|
2520
|
+
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px">
|
|
2521
|
+
<button class="btn btn-sm" style="background:#4285F4;color:#fff" onclick="App.oauthLogin('google')">${t.google}</button>
|
|
2522
|
+
<button class="btn btn-sm" style="background:#24292e;color:#fff" onclick="App.oauthLogin('github')">${t.github}</button>
|
|
2523
|
+
</div>
|
|
2524
|
+
<div style="border-top:1px dashed var(--ink-mute,#999);padding-top:12px">
|
|
2525
|
+
<div style="font-weight:600;margin-bottom:6px">${t.title}</div>
|
|
2526
|
+
<div class="text-sm text-mute" style="margin-bottom:10px">${t.desc}</div>
|
|
2527
|
+
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
|
2528
|
+
<button class="btn btn-sm btn-ghost" onclick="App.openWindsurfTokenUrl('${inlineId}')">${t.open}</button>
|
|
2529
|
+
<input id="${inlineId}" type="text" class="input" style="flex:1;min-width:240px" placeholder="${t.placeholder}">
|
|
2530
|
+
<button class="btn btn-sm btn-primary" onclick="App.addAccountFromInlineToken('${inlineId}','${emailAttr}')">${t.add}</button>
|
|
2531
|
+
</div>
|
|
2532
|
+
</div>
|
|
2533
|
+
</div>`;
|
|
2534
|
+
},
|
|
2535
|
+
|
|
2536
|
+
openWindsurfTokenUrl(inputId) {
|
|
2537
|
+
// Editor backup-token signin URL extracted from real Windsurf editor 2.0.67
|
|
2538
|
+
// (extension.js getLoginUrl with forceShowAuthToken:true). The public client_id
|
|
2539
|
+
// is the canonical Windsurf editor identifier.
|
|
2540
|
+
const state = (crypto.getRandomValues
|
|
2541
|
+
? Array.from(crypto.getRandomValues(new Uint8Array(8))).map(b => b.toString(16).padStart(2, '0')).join('')
|
|
2542
|
+
: Math.random().toString(36).slice(2));
|
|
2543
|
+
const url = 'https://windsurf.com/windsurf/signin?response_type=token&client_id=3GUryQ7ldAeKEuD2obYnppsnmj58eP5u&redirect_uri=show-auth-token&state=' + state + '&prompt=login&redirect_parameters_type=query&workflow=';
|
|
2544
|
+
window.open(url, '_blank');
|
|
2545
|
+
setTimeout(() => document.getElementById(inputId)?.focus(), 100);
|
|
2546
|
+
},
|
|
2547
|
+
|
|
2548
|
+
async addAccountFromInlineToken(inputId, label) {
|
|
2549
|
+
const input = document.getElementById(inputId);
|
|
2550
|
+
if (!input) return;
|
|
2551
|
+
const token = input.value.trim();
|
|
2552
|
+
if (!token) return this.toast('paste token first', 'error');
|
|
2553
|
+
const btn = input.nextElementSibling;
|
|
2554
|
+
const originalDisabled = btn?.disabled;
|
|
2555
|
+
if (btn) btn.disabled = true;
|
|
2556
|
+
try {
|
|
2557
|
+
const r = await this.api('POST', '/accounts', { token, label });
|
|
2558
|
+
if (r.success) {
|
|
2559
|
+
this.toast('account added', 'success');
|
|
2560
|
+
input.value = '';
|
|
2561
|
+
this.loadAccounts();
|
|
2562
|
+
} else {
|
|
2563
|
+
this.toast(r.error || 'add failed', 'error');
|
|
2564
|
+
}
|
|
2565
|
+
} catch (err) {
|
|
2566
|
+
this.toast(err.message, 'error');
|
|
2567
|
+
} finally {
|
|
2568
|
+
if (btn) btn.disabled = originalDisabled || false;
|
|
2569
|
+
}
|
|
2570
|
+
},
|
|
2571
|
+
|
|
2572
|
+
async windsurfLogin() {
|
|
2573
|
+
const email = document.getElementById('wl-email').value.trim();
|
|
2574
|
+
const password = document.getElementById('wl-password').value.trim();
|
|
2575
|
+
if (!email || !password) return this.toast('email + password required', 'error');
|
|
2576
|
+
const proxy = this.getWindsurfLoginProxy();
|
|
2577
|
+
const autoAdd = document.getElementById('wl-auto-add').checked;
|
|
2578
|
+
const btn = document.getElementById('wl-btn');
|
|
2579
|
+
btn.disabled = true;
|
|
2580
|
+
btn.innerHTML = '<span class="spinner"></span> logging in …';
|
|
2581
|
+
const entry = { time: new Date().toISOString(), email, proxy: this.proxyLabel(proxy), status: 'pending' };
|
|
2582
|
+
try {
|
|
2583
|
+
const r = await this.api('POST', '/windsurf-login', { email, password, proxy, autoAdd });
|
|
2584
|
+
if (r.success) {
|
|
2585
|
+
entry.status = 'success';
|
|
2586
|
+
entry.apiKey = r.apiKey_masked || (r.apiKey?.slice(0,16) + '…');
|
|
2587
|
+
this.showWLResult(`<div class="anno" style="color:var(--good);font-size:20px"><svg class="ic" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="20 6 9 17 4 12"/></svg> login OK</div>
|
|
2588
|
+
<table style="margin-top:12px">
|
|
2589
|
+
<tr><td class="text-mute">email</td><td>${this.esc(r.email)}</td></tr>
|
|
2590
|
+
<tr><td class="text-mute">name</td><td>${this.esc(r.name || '-')}</td></tr>
|
|
2591
|
+
<tr><td class="text-mute">api key</td><td><code class="break-all">${this.esc(r.apiKey_masked || r.apiKey || '')}</code></td></tr>
|
|
2592
|
+
</table>`);
|
|
2593
|
+
this.toast(autoAdd ? 'login OK · added to pool' : 'login OK', 'success');
|
|
2594
|
+
this.loadAccounts();
|
|
2595
|
+
} else {
|
|
2596
|
+
entry.status = 'error: ' + (r.error || 'unknown');
|
|
2597
|
+
this.showWLResult(`<div class="anno" style="color:var(--error);font-size:20px"><svg class="ic" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> login failed</div><p class="text-sm">${this.esc(r.error || 'unknown')}</p>${this.getWindsurfLoginFailActions(r, email)}`);
|
|
2598
|
+
this.toast(r.error || 'login failed', 'error');
|
|
2599
|
+
}
|
|
2600
|
+
} catch (err) {
|
|
2601
|
+
entry.status = 'error: ' + err.message;
|
|
2602
|
+
this.toast(err.message, 'error');
|
|
2603
|
+
}
|
|
2604
|
+
this.pushLoginHistory(entry);
|
|
2605
|
+
btn.disabled = false;
|
|
2606
|
+
btn.innerHTML = `登录取号 <svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>`;
|
|
2607
|
+
},
|
|
2608
|
+
|
|
2609
|
+
async testLoginProxy() {
|
|
2610
|
+
const host = document.getElementById('wl-proxy-host').value.trim();
|
|
2611
|
+
const port = parseInt(document.getElementById('wl-proxy-port').value) || 0;
|
|
2612
|
+
const out = document.getElementById('wl-proxy-test-result');
|
|
2613
|
+
const btn = document.getElementById('wl-proxy-test-btn');
|
|
2614
|
+
if (!host || !port) { if (out) { out.textContent = '请填 host + port'; out.style.color = 'var(--error)'; } return; }
|
|
2615
|
+
const type = document.getElementById('wl-proxy-type').value;
|
|
2616
|
+
const username = document.getElementById('wl-proxy-user').value.trim();
|
|
2617
|
+
const password = document.getElementById('wl-proxy-pass').value;
|
|
2618
|
+
if (btn) btn.disabled = true;
|
|
2619
|
+
if (out) { out.textContent = 'testing…'; out.style.color = 'var(--ink-mute)'; }
|
|
2620
|
+
try {
|
|
2621
|
+
const r = await this.api('POST', '/test-proxy', { host, port, username, password, type });
|
|
2622
|
+
if (out) {
|
|
2623
|
+
if (r.ok) {
|
|
2624
|
+
out.textContent = `OK · egress ${r.egressIp || '?'} · ${r.latencyMs || 0}ms`;
|
|
2625
|
+
out.style.color = 'var(--good)';
|
|
2626
|
+
} else {
|
|
2627
|
+
out.textContent = `failed: ${r.error || 'unknown'}`;
|
|
2628
|
+
out.style.color = 'var(--error)';
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
} catch (e) {
|
|
2632
|
+
if (out) { out.innerHTML = `<svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> ` + this.esc(e.message); out.style.color = 'var(--error)'; }
|
|
2633
|
+
} finally {
|
|
2634
|
+
if (btn) btn.disabled = false;
|
|
2635
|
+
}
|
|
2636
|
+
},
|
|
2637
|
+
|
|
2638
|
+
async pasteWindsurfBatch() {
|
|
2639
|
+
const el = document.getElementById('wl-batch-input');
|
|
2640
|
+
if (!navigator.clipboard) { el?.focus(); return this.toast('clipboard unsupported', 'info'); }
|
|
2641
|
+
try {
|
|
2642
|
+
const text = await navigator.clipboard.readText();
|
|
2643
|
+
if (!text) return this.toast('clipboard empty', 'info');
|
|
2644
|
+
el.value = text;
|
|
2645
|
+
this.toast('pasted', 'success');
|
|
2646
|
+
} catch (err) { this.toast('clipboard error: ' + err.message, 'error'); }
|
|
2647
|
+
},
|
|
2648
|
+
|
|
2649
|
+
async windsurfLoginBatch() {
|
|
2650
|
+
const input = document.getElementById('wl-batch-input').value;
|
|
2651
|
+
if (!input.trim()) return this.toast('paste accounts first', 'error');
|
|
2652
|
+
const autoAdd = document.getElementById('wl-auto-add').checked;
|
|
2653
|
+
const btn = document.getElementById('wl-batch-btn');
|
|
2654
|
+
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> importing …';
|
|
2655
|
+
try {
|
|
2656
|
+
const r = await this.api('POST', '/batch-import', { text: input, autoAdd });
|
|
2657
|
+
if (!r.success || !Array.isArray(r.results)) {
|
|
2658
|
+
this.toast(r.error || 'batch failed', 'error'); return;
|
|
2659
|
+
}
|
|
2660
|
+
this.showWLResult(`<div class="anno" style="font-size:20px">batch · ${r.successCount || 0} ok / ${r.failCount || 0} fail</div>
|
|
2661
|
+
<div class="table-wrap" style="margin-top:12px"><table>
|
|
2662
|
+
<thead><tr><th>email</th><th>status</th><th>note</th></tr></thead>
|
|
2663
|
+
<tbody>${r.results.map(x => `<tr><td>${this.esc(x.email || '-')}</td>
|
|
2664
|
+
<td><span class="badge ${x.success ? 'active' : 'error'}">${x.success ? 'ok' : 'fail'}</span></td>
|
|
2665
|
+
<td class="text-sm">${x.success ? `<code class="break-all">${this.esc(x.apiKey_masked || x.apiKey?.slice(0,16) + '…' || '-')}</code>` : `<span style="color:var(--error)">${this.esc(x.error || 'unknown')}</span>`}</td></tr>`).join('')}</tbody>
|
|
2666
|
+
</table></div>`);
|
|
2667
|
+
this.pushLoginHistoryEntries(r.results.map(x => ({ time: new Date().toISOString(), email: x.email, proxy: 'batch', status: x.success ? 'success' : 'error: ' + (x.error || 'unknown'), apiKey: x.success ? x.apiKey_masked : undefined })));
|
|
2668
|
+
this.toast(`batch · ${r.successCount || 0} ok / ${r.failCount || 0} fail`);
|
|
2669
|
+
if ((r.successCount || 0) > 0) { this.loadAccounts(); document.getElementById('wl-batch-input').value = ''; }
|
|
2670
|
+
} catch (err) { this.toast(err.message, 'error'); }
|
|
2671
|
+
finally { btn.disabled = false; btn.textContent = '批量导入'; }
|
|
2672
|
+
},
|
|
2673
|
+
|
|
2674
|
+
renderLoginHistory() {
|
|
2675
|
+
const tbody = document.querySelector('#wl-history-table tbody');
|
|
2676
|
+
if (!tbody) return;
|
|
2677
|
+
tbody.innerHTML = this.loginHistory.map((h, i) => {
|
|
2678
|
+
const ok = h.status === 'success';
|
|
2679
|
+
return `<tr>
|
|
2680
|
+
<td class="text-sm nowrap">${new Date(h.time).toLocaleString()}</td>
|
|
2681
|
+
<td><span style="display:inline-flex;align-items:center;gap:8px"><span class="avatar-letter">${this.esc((h.email || '?')[0].toUpperCase())}</span>${this.esc(h.email)}</span></td>
|
|
2682
|
+
<td><span class="badge ${ok ? 'active' : 'error'}">${ok ? 'ok' : 'fail'}</span>${!ok ? `<div class="text-xs" style="color:var(--error);margin-top:3px">${this.esc((h.status||'').replace('error: ',''))}</div>` : ''}</td>
|
|
2683
|
+
<td class="text-sm">${this.esc(h.proxy || '-')}</td>
|
|
2684
|
+
<td><button class="btn btn-ghost btn-xs" onclick="App.removeLoginHistory(${i})">×</button></td>
|
|
2685
|
+
</tr>`;
|
|
2686
|
+
}).join('') || `<tr class="empty-row"><td colspan="5">no history yet — sign in above ↑</td></tr>`;
|
|
2687
|
+
},
|
|
2688
|
+
|
|
2689
|
+
/* ── accounts ── */
|
|
2690
|
+
async _ensureModelsCatalog() {
|
|
2691
|
+
if (this._modelsCatalog) return this._modelsCatalog;
|
|
2692
|
+
try { const r = await this.api('GET', '/models'); this._modelsCatalog = {}; for (const m of (r.models || [])) this._modelsCatalog[m.id] = m; }
|
|
2693
|
+
catch { this._modelsCatalog = {}; }
|
|
2694
|
+
return this._modelsCatalog;
|
|
2695
|
+
},
|
|
2696
|
+
|
|
2697
|
+
async loadDrought() {
|
|
2698
|
+
const banner = document.getElementById('drought-banner');
|
|
2699
|
+
const detail = document.getElementById('drought-detail');
|
|
2700
|
+
if (!banner) return;
|
|
2701
|
+
try {
|
|
2702
|
+
const d = await this.api('GET', '/drought');
|
|
2703
|
+
if (!d?.drought) { banner.style.display = 'none'; return; }
|
|
2704
|
+
banner.style.display = '';
|
|
2705
|
+
if (detail) {
|
|
2706
|
+
const lw = d.lowestWeeklyPercent;
|
|
2707
|
+
const ld = d.lowestDailyPercent;
|
|
2708
|
+
const parts = [];
|
|
2709
|
+
if (lw != null) parts.push(`min weekly=${lw.toFixed(0)}%`);
|
|
2710
|
+
if (ld != null) parts.push(`min daily=${ld.toFixed(0)}%`);
|
|
2711
|
+
parts.push(`${d.knownAccounts}/${d.activeAccounts} accounts known`);
|
|
2712
|
+
detail.textContent = parts.join(' · ');
|
|
2713
|
+
}
|
|
2714
|
+
} catch { banner.style.display = 'none'; }
|
|
2715
|
+
},
|
|
2716
|
+
|
|
2717
|
+
async loadAccounts() {
|
|
2718
|
+
// v2.0.57 Fix 5: refresh drought banner alongside accounts.
|
|
2719
|
+
this.loadDrought();
|
|
2720
|
+
const [d, ov] = await Promise.all([
|
|
2721
|
+
this.api('GET', '/accounts'),
|
|
2722
|
+
this.api('GET', '/overview').catch(() => null),
|
|
2723
|
+
]);
|
|
2724
|
+
const instances = ov?.langServer?.instances || [];
|
|
2725
|
+
const poolCard = document.getElementById('ls-pool-card');
|
|
2726
|
+
if (poolCard) {
|
|
2727
|
+
if (instances.length > 0) {
|
|
2728
|
+
poolCard.innerHTML = `<div class="section">
|
|
2729
|
+
<div class="section-head"><div><div class="section-title"><span class="num">L</span><span>language server pool</span></div>
|
|
2730
|
+
<div class="section-desc">${instances.length} instance(s)</div></div></div>
|
|
2731
|
+
<div class="ls-pool">${instances.map(i => `<div class="ls-inst">
|
|
2732
|
+
<div class="ls-key"><span class="ls-dot ${i.ready ? '' : 'pending'}"></span>${this.esc(i.key === 'default' ? 'default' : i.key.replace(/^px_/, ''))}</div>
|
|
2733
|
+
<div class="ls-meta">port ${i.port} · pid ${i.pid || '-'}</div>
|
|
2734
|
+
<div class="ls-meta">${this.esc(i.proxy || 'no proxy')}</div>
|
|
2735
|
+
</div>`).join('')}</div></div>`;
|
|
2736
|
+
} else { poolCard.innerHTML = ''; }
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
const tbody = document.querySelector('#accounts-table tbody');
|
|
2740
|
+
const tierLabel = { pro: 'PRO', free: 'FREE', expired: 'EXP', unknown: '?' };
|
|
2741
|
+
tbody.innerHTML = (d.accounts || []).map(a => {
|
|
2742
|
+
const tier = a.tier || 'unknown';
|
|
2743
|
+
const tierModels = a.tierModels || [];
|
|
2744
|
+
const blockedCount = (a.blockedModels || []).length;
|
|
2745
|
+
const availCount = tierModels.length - blockedCount;
|
|
2746
|
+
const capsHtml = tierModels.length
|
|
2747
|
+
? `<button class="btn btn-ghost btn-xs" onclick="App.openBlockedModal('${this.escAttr(a.id)}')" title="edit">
|
|
2748
|
+
<span style="color:var(--good);font-weight:600">${availCount}</span><span class="text-mute">/${tierModels.length}</span>
|
|
2749
|
+
${blockedCount > 0 ? `<span class="badge warn" style="margin-left:4px">−${blockedCount}</span>` : ''}
|
|
2750
|
+
</button>`
|
|
2751
|
+
: `<span class="text-xs text-dim">—</span>`;
|
|
2752
|
+
|
|
2753
|
+
const rateLimitBadge = a.rateLimited ? ` <span class="badge warn">RL</span>` : '';
|
|
2754
|
+
const rpmUsed = a.rpmUsed ?? 0, rpmLimit = a.rpmLimit ?? 0;
|
|
2755
|
+
const rpmPct = rpmLimit > 0 ? Math.min(100, Math.round((rpmUsed / rpmLimit) * 100)) : 0;
|
|
2756
|
+
const rpmKind = rpmPct >= 90 ? 'error' : rpmPct >= 60 ? 'warn' : 'good';
|
|
2757
|
+
const rpmCell = rpmLimit > 0
|
|
2758
|
+
? `<div style="min-width:80px"><div class="text-xs" style="display:flex;justify-content:space-between;margin-bottom:2px"><span>${rpmUsed}/${rpmLimit}</span><span>${rpmPct}%</span></div><div class="bar" style="height:5px"><div class="bar-fill ${rpmKind}" style="width:${rpmPct}%"></div></div></div>`
|
|
2759
|
+
: `<span class="text-xs text-dim">—</span>`;
|
|
2760
|
+
|
|
2761
|
+
const cr = a.credits || null;
|
|
2762
|
+
let creditCell;
|
|
2763
|
+
if (!cr) creditCell = `<span class="text-xs text-dim italic">unfetched</span>`;
|
|
2764
|
+
else if (cr.lastError && cr.percent == null && !cr.prompt?.limit) creditCell = `<span class="text-xs" style="color:var(--error)" title="${this.esc(cr.lastError)}">fetch failed</span>`;
|
|
2765
|
+
else {
|
|
2766
|
+
const planName = cr.planName || '-';
|
|
2767
|
+
const fetchedAgo = cr.fetchedAt ? Math.round((Date.now() - cr.fetchedAt) / 60000) + 'm' : '-';
|
|
2768
|
+
const renderBar = (lbl, pct) => {
|
|
2769
|
+
// v2.0.75 (#123): "N/A" reads more clearly than an em-dash
|
|
2770
|
+
// when upstream just doesn't return that bucket (Trial Daily
|
|
2771
|
+
// is null but Weekly has a value — easy to mistake for a
|
|
2772
|
+
// refresh failure). italic + dashed border separates it
|
|
2773
|
+
// visually from a 0% / failure state.
|
|
2774
|
+
if (pct == null) return `<div class="detail-bar" style="margin:0;grid-template-columns:18px 1fr 36px" title="upstream returned no data for this bucket"><span class="b-label">${lbl}</span><div class="bar" style="height:5px;border-width:1px;border-style:dashed"><div class="bar-fill"></div></div><span class="b-pct text-dim italic">N/A</span></div>`;
|
|
2775
|
+
const v = Math.max(0, Math.min(100, pct));
|
|
2776
|
+
const k = v <= 10 ? 'error' : v <= 30 ? 'warn' : 'good';
|
|
2777
|
+
return `<div class="detail-bar" style="margin:0;grid-template-columns:18px 1fr 36px"><span class="b-label">${lbl}</span><div class="bar" style="height:5px;border-width:1px"><div class="bar-fill ${k}" style="width:${v}%"></div></div><span class="b-pct">${v.toFixed(0)}%</span></div>`;
|
|
2778
|
+
};
|
|
2779
|
+
creditCell = `<div style="min-width:130px"><div class="text-xs" style="margin-bottom:3px"><b>${this.esc(planName.slice(0,12))}</b> <span class="text-mute">${fetchedAgo}</span></div>${renderBar('D', cr.dailyPercent)}${renderBar('W', cr.weeklyPercent)}</div>`;
|
|
2780
|
+
}
|
|
2781
|
+
const isExpanded = this._expandedAccounts.has(a.id);
|
|
2782
|
+
return `<tr${isExpanded ? ' class="expanded"' : ''}>
|
|
2783
|
+
<td><button class="expand-chev ${isExpanded ? 'open' : ''}" onclick="App.toggleAccountDetail('${this.escAttr(a.id)}')"><svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="9 18 15 12 9 6"/></svg></button><code>${this.esc((a.id||'').slice(0,12))}</code></td>
|
|
2784
|
+
<td>${this.esc(a.email)}</td>
|
|
2785
|
+
<td><span class="tier ${this.esc(tier)}" onclick="App.overrideTier('${this.escAttr(a.id)}','${this.escAttr(tier)}')">${tierLabel[tier] || this.esc(tier)}${a.tierManual ? ' ' + `<svg class="ic" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>` : ''}</span></td>
|
|
2786
|
+
<td>${rpmCell}</td>
|
|
2787
|
+
<td>${creditCell}</td>
|
|
2788
|
+
<td>${capsHtml}</td>
|
|
2789
|
+
<td><span class="badge ${a.status}">${a.status}</span>${rateLimitBadge}</td>
|
|
2790
|
+
<td style="color:${a.errorCount > 0 ? 'var(--error)' : 'inherit'};font-family:'JetBrains Mono',monospace">${a.errorCount}</td>
|
|
2791
|
+
<td class="text-sm nowrap">${a.lastUsed ? new Date(a.lastUsed).toLocaleString() : '—'}</td>
|
|
2792
|
+
<td class="nowrap"><code title="reveal & copy" style="cursor:pointer" onclick="App.revealAndCopyKey('${this.escAttr(a.id)}')">${this.esc(a.apiKey_masked || a.keyPrefix || '')}</code></td>
|
|
2793
|
+
<td class="nowrap"><div class="btn-group">
|
|
2794
|
+
<button class="btn btn-ghost btn-xs" onclick="App.probeAccount('${this.escAttr(a.id)}')">probe</button>
|
|
2795
|
+
<button class="btn btn-ghost btn-xs" onclick="App.refreshCredits('${this.escAttr(a.id)}')"><svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
|
|
2796
|
+
${a.status === 'active'
|
|
2797
|
+
? `<button class="btn btn-ghost btn-xs" onclick="App.toggleAccount('${this.escAttr(a.id)}','disabled')">disable</button>`
|
|
2798
|
+
: `<button class="btn btn-good btn-xs" onclick="App.toggleAccount('${this.escAttr(a.id)}','active')">enable</button>`}
|
|
2799
|
+
<button class="btn btn-ghost btn-xs" onclick="App.resetErrors('${this.escAttr(a.id)}')">reset</button>
|
|
2800
|
+
<button class="btn btn-ghost btn-xs" style="color:var(--error)" onclick="App.deleteAccount('${this.escAttr(a.id)}')">×</button>
|
|
2801
|
+
</div></td>
|
|
2802
|
+
</tr>${isExpanded ? `<tr class="detail-row"><td colspan="11">${this.renderAccountDetail(a)}</td></tr>` : ''}`;
|
|
2803
|
+
}).join('') || `<tr class="empty-row"><td colspan="11">no accounts yet · paste a key above ↑ or grab one from /windsurf-login</td></tr>`;
|
|
2804
|
+
},
|
|
2805
|
+
|
|
2806
|
+
toggleAccountDetail(id) {
|
|
2807
|
+
if (this._expandedAccounts.has(id)) { this._expandedAccounts.delete(id); this.loadAccounts(); }
|
|
2808
|
+
else { this._expandedAccounts.add(id); this._ensureModelsCatalog().then(() => this.loadAccounts()); }
|
|
2809
|
+
},
|
|
2810
|
+
|
|
2811
|
+
renderAccountDetail(a) {
|
|
2812
|
+
const cr = a.credits || {};
|
|
2813
|
+
const us = a.userStatus || {};
|
|
2814
|
+
const catalog = this._modelsCatalog || {};
|
|
2815
|
+
const fmtUnix = u => u ? new Date(u * 1000).toLocaleString() : '—';
|
|
2816
|
+
const fmtDate = v => v ? new Date(v).toLocaleDateString() : '—';
|
|
2817
|
+
const fmtAgo = ts => {
|
|
2818
|
+
if (!ts) return '—';
|
|
2819
|
+
const s = Math.floor((Date.now() - ts) / 1000);
|
|
2820
|
+
if (s < 60) return `${s}s ago`;
|
|
2821
|
+
if (s < 3600) return `${Math.floor(s/60)}m ago`;
|
|
2822
|
+
if (s < 86400) return `${Math.floor(s/3600)}h ago`;
|
|
2823
|
+
return `${Math.floor(s/86400)}d ago`;
|
|
2824
|
+
};
|
|
2825
|
+
const renderBar = (lbl, pct, resetAt, naText) => {
|
|
2826
|
+
const isN = pct == null;
|
|
2827
|
+
const v = isN ? 0 : Math.max(0, Math.min(100, pct));
|
|
2828
|
+
const k = isN ? '' : v <= 10 ? 'error' : v <= 30 ? 'warn' : 'good';
|
|
2829
|
+
return `<div class="detail-bar"><span class="b-label">${lbl}</span><div class="bar"><div class="bar-fill ${k}" style="width:${v}%"></div></div><span class="b-pct">${isN ? '—' : v.toFixed(0)+'%'}</span>${resetAt ? `<div class="b-reset"><svg class="ic" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 9A9 9 0 1 1 5.64 5.64L23 10"/></svg> reset ${fmtUnix(resetAt)}</div>` : (isN && naText ? `<div class="b-reset">${naText}</div>` : '')}</div>`;
|
|
2830
|
+
};
|
|
2831
|
+
|
|
2832
|
+
const tierModels = a.tierModels || [];
|
|
2833
|
+
const blocked = new Set(a.blockedModels || []);
|
|
2834
|
+
const provOf = (m) => {
|
|
2835
|
+
if (m.startsWith('claude')) return 'anthropic';
|
|
2836
|
+
if (m.startsWith('gpt') || m.startsWith('o3') || m.startsWith('o4')) return 'openai';
|
|
2837
|
+
if (m.startsWith('gemini')) return 'google';
|
|
2838
|
+
if (m.startsWith('grok')) return 'xai';
|
|
2839
|
+
if (m.startsWith('deepseek')) return 'deepseek';
|
|
2840
|
+
if (m.startsWith('qwen')) return 'alibaba';
|
|
2841
|
+
if (m.startsWith('kimi')) return 'moonshot';
|
|
2842
|
+
if (m.startsWith('swe') || m.startsWith('arena')) return 'windsurf';
|
|
2843
|
+
return 'other';
|
|
2844
|
+
};
|
|
2845
|
+
const provLabel = { anthropic: 'Anthropic', openai: 'OpenAI', google: 'Google', deepseek: 'DeepSeek', xai: 'xAI', alibaba: 'Alibaba', moonshot: 'Moonshot', zhipu: 'Zhipu', minimax: 'MiniMax', windsurf: 'Windsurf', other: 'Other' };
|
|
2846
|
+
const groups = {};
|
|
2847
|
+
for (const m of tierModels) {
|
|
2848
|
+
const info = catalog[m] || {};
|
|
2849
|
+
const p = info.provider || provOf(m);
|
|
2850
|
+
(groups[p] = groups[p] || []).push({ id: m, ...info });
|
|
2851
|
+
}
|
|
2852
|
+
const availCount = tierModels.length - blocked.size;
|
|
2853
|
+
let trialDaysLeft = null;
|
|
2854
|
+
if (us.trialEndMs) trialDaysLeft = Math.max(0, Math.ceil((us.trialEndMs - Date.now()) / 86400000));
|
|
2855
|
+
|
|
2856
|
+
return `<div class="detail-wrap">
|
|
2857
|
+
<div class="detail-grid">
|
|
2858
|
+
<div class="detail-card">
|
|
2859
|
+
<div class="detail-card-head"><span class="num-circle">P</span>plan</div>
|
|
2860
|
+
<div class="detail-kv">
|
|
2861
|
+
<span class="k">name</span><span class="v"><b>${this.esc(cr.planName || us.planName || '—')}</b></span>
|
|
2862
|
+
<span class="k">tier</span><span class="v"><span class="tier ${this.esc(a.tier || 'unknown')}">${this.esc(a.tier || 'unknown')}</span>${a.tierManual ? ' <span class="text-mute text-xs"><svg class="ic" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg> manual</span>' : ''}</span>
|
|
2863
|
+
<span class="k">start</span><span class="v">${fmtDate(cr.planStart || us.planStart)}</span>
|
|
2864
|
+
<span class="k">end</span><span class="v">${fmtDate(cr.planEnd || us.planEnd)}${trialDaysLeft != null ? ` <span style="color:${trialDaysLeft <= 3 ? 'var(--error)' : 'var(--ink-mute)'}">(${trialDaysLeft}d)</span>` : ''}</span>
|
|
2865
|
+
${cr.overageBalance != null ? `<span class="k">overage</span><span class="v">$${cr.overageBalance.toFixed(4)}</span>` : ''}
|
|
2866
|
+
</div>
|
|
2867
|
+
</div>
|
|
2868
|
+
<div class="detail-card">
|
|
2869
|
+
<div class="detail-card-head"><span class="num-circle">Q</span>quota</div>
|
|
2870
|
+
${renderBar('daily', cr.dailyPercent, cr.dailyResetAt, 'no daily quota data')}
|
|
2871
|
+
${renderBar('weekly', cr.weeklyPercent, cr.weeklyResetAt, 'no weekly quota data')}
|
|
2872
|
+
<div style="margin-top:12px;padding-top:10px;border-top:1px dashed var(--rule-soft)" class="detail-kv">
|
|
2873
|
+
${cr.prompt?.limit ? `<span class="k">prompt</span><span class="v"><b>${cr.prompt.remaining ?? 0}</b> / ${cr.prompt.limit} <span class="text-mute text-xs">used ${cr.prompt.used ?? 0}</span></span>` : `<span class="k">prompt</span><span class="v text-mute">N/A</span>`}
|
|
2874
|
+
${cr.flex?.limit ? `<span class="k">flex</span><span class="v"><b>${cr.flex.remaining ?? 0}</b> / ${cr.flex.limit}</span>` : `<span class="k">flex</span><span class="v text-mute">N/A</span>`}
|
|
2875
|
+
<span class="k">fetched</span><span class="v">${fmtAgo(cr.fetchedAt)}</span>
|
|
2876
|
+
</div>
|
|
2877
|
+
</div>
|
|
2878
|
+
|
|
2879
|
+
<div class="detail-card" style="grid-column:1/-1">
|
|
2880
|
+
<div class="detail-card-head"><span class="num-circle">M</span>models <span class="text-mute text-xs" style="font-style:normal;font-family:'JetBrains Mono',monospace">${availCount} / ${tierModels.length} available${blocked.size > 0 ? ` · ${blocked.size} blocked` : ''}</span><div style="margin-left:auto"><button class="btn btn-ghost btn-xs" onclick="App.openBlockedModal('${this.escAttr(a.id)}')">edit</button></div></div>
|
|
2881
|
+
${tierModels.length === 0 ? `<div class="text-mute italic" style="padding:6px 0">no allowed models — run probe</div>` : Object.entries(groups).map(([p, ms]) => {
|
|
2882
|
+
ms.sort((x,y) => (x.credit||0)-(y.credit||0));
|
|
2883
|
+
const blockedIn = ms.filter(m => blocked.has(m.id)).length;
|
|
2884
|
+
return `<div class="provider-group">
|
|
2885
|
+
<div class="provider-label"><span>${this.esc(provLabel[p] || p)}</span><span class="text-xs text-mute" style="font-family:'JetBrains Mono',monospace">${ms.length-blockedIn}/${ms.length}</span></div>
|
|
2886
|
+
<div class="model-chips">${ms.map(m => `<div class="model-chip ${blocked.has(m.id) ? 'blocked' : ''}" title="${m.id}"><span>${this.esc(m.id)}</span>${m.credit != null ? `<span style="color:var(--accent);font-weight:600">${m.credit}c</span>` : ''}</div>`).join('')}</div>
|
|
2887
|
+
</div>`;
|
|
2888
|
+
}).join('')}
|
|
2889
|
+
</div>
|
|
2890
|
+
|
|
2891
|
+
<div class="detail-card" style="grid-column:1/-1">
|
|
2892
|
+
<div class="detail-card-head"><span class="num-circle">R</span>runtime</div>
|
|
2893
|
+
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:24px">
|
|
2894
|
+
<div class="detail-kv">
|
|
2895
|
+
<span class="k">status</span><span class="v"><span class="badge ${a.status}">${a.status}</span></span>
|
|
2896
|
+
<span class="k">errors</span><span class="v" style="color:${a.errorCount > 0 ? 'var(--error)' : 'inherit'}">${a.errorCount || 0}</span>
|
|
2897
|
+
<span class="k">rpm</span><span class="v">${a.rpmUsed ?? 0} / ${a.rpmLimit ?? 0}</span>
|
|
2898
|
+
</div>
|
|
2899
|
+
<div class="detail-kv">
|
|
2900
|
+
<span class="k">last used</span><span class="v">${fmtAgo(a.lastUsed)}</span>
|
|
2901
|
+
<span class="k">last probe</span><span class="v">${fmtAgo(a.lastProbed || a.userStatusLastFetched)}</span>
|
|
2902
|
+
<span class="k">credits @</span><span class="v">${fmtAgo(cr.fetchedAt)}</span>
|
|
2903
|
+
</div>
|
|
2904
|
+
<div class="detail-kv">
|
|
2905
|
+
<span class="k">id</span><span class="v"><code>${this.esc(a.id)}</code></span>
|
|
2906
|
+
<span class="k">key</span><span class="v"><code>${this.esc(a.apiKey_masked || a.keyPrefix || '')}</code></span>
|
|
2907
|
+
${cr.lastError ? `<span class="k">last err</span><span class="v" style="color:var(--error);font-size:11px">${this.esc(cr.lastError).slice(0,80)}</span>` : ''}
|
|
2908
|
+
</div>
|
|
2909
|
+
</div>
|
|
2910
|
+
</div>
|
|
2911
|
+
</div>
|
|
2912
|
+
</div>`;
|
|
2913
|
+
},
|
|
2914
|
+
|
|
2915
|
+
copyKey(k) { navigator.clipboard?.writeText(k).then(() => this.toast('copied', 'success')); },
|
|
2916
|
+
async revealAndCopyKey(accountId) {
|
|
2917
|
+
try {
|
|
2918
|
+
const r = await this.api('POST', `/account/${encodeURIComponent(accountId)}/reveal-key`);
|
|
2919
|
+
if (r?.apiKey) this.copyKey(r.apiKey);
|
|
2920
|
+
else this.toast('copy failed', 'error');
|
|
2921
|
+
} catch (e) {
|
|
2922
|
+
this.toast(e?.message || 'copy failed', 'error');
|
|
2923
|
+
}
|
|
2924
|
+
},
|
|
2925
|
+
|
|
2926
|
+
async refreshCredits(id) { try { const r = await this.api('POST', `/accounts/${id}/refresh-credits`, {}); if (r.ok) { this.toast('credits refreshed'); this.loadAccounts(); } else this.toast(r.error || 'fail', 'error'); } catch (e) { this.toast(e.message, 'error'); } },
|
|
2927
|
+
async refreshAllCredits() { this.toast('refreshing all …', 'info'); try { const r = await this.api('POST', '/accounts/refresh-credits', {}); if (r.success) { this.toast(`${(r.results||[]).filter(x=>x.ok).length} refreshed`); this.loadAccounts(); } } catch (e) { this.toast(e.message, 'error'); } },
|
|
2928
|
+
async probeAll() { this.toast('probing all …', 'info'); try { const r = await this.api('POST', '/accounts/probe-all', {}); if (r.success) { this.toast('probe done'); this.loadAccounts(); } } catch (e) { this.toast(e.message, 'error'); } },
|
|
2929
|
+
async probeAccount(id) { this.toast('probing …', 'info'); try { const r = await this.api('POST', `/accounts/${id}/probe`, {}); if (r.success) { this.toast(`tier: ${r.tier}`); this.loadAccounts(); } } catch (e) { this.toast(e.message, 'error'); } },
|
|
2930
|
+
|
|
2931
|
+
async openBlockedModal(id) {
|
|
2932
|
+
try {
|
|
2933
|
+
const d = await this.api('GET', '/accounts');
|
|
2934
|
+
const acct = (d.accounts || []).find(a => a.id === id);
|
|
2935
|
+
if (!acct) return this.toast('account not found', 'error');
|
|
2936
|
+
const tierModels = acct.tierModels || [];
|
|
2937
|
+
const blocked = new Set(acct.blockedModels || []);
|
|
2938
|
+
if (!tierModels.length) return this.toast('no models', 'info');
|
|
2939
|
+
const provOf = (m) => {
|
|
2940
|
+
if (m.startsWith('claude')) return 'anthropic';
|
|
2941
|
+
if (m.startsWith('gpt') || m.startsWith('o3') || m.startsWith('o4')) return 'openai';
|
|
2942
|
+
if (m.startsWith('gemini')) return 'google';
|
|
2943
|
+
if (m.startsWith('grok')) return 'xai';
|
|
2944
|
+
if (m.startsWith('deepseek')) return 'deepseek';
|
|
2945
|
+
if (m.startsWith('qwen')) return 'alibaba';
|
|
2946
|
+
if (m.startsWith('kimi')) return 'moonshot';
|
|
2947
|
+
if (m.startsWith('swe') || m.startsWith('arena')) return 'windsurf';
|
|
2948
|
+
return 'other';
|
|
2949
|
+
};
|
|
2950
|
+
const groups = {};
|
|
2951
|
+
for (const m of tierModels) (groups[provOf(m)] = groups[provOf(m)] || []).push(m);
|
|
2952
|
+
const total = tierModels.length;
|
|
2953
|
+
const html = `
|
|
2954
|
+
<div style="display:flex;justify-content:space-between;gap:12px;margin-bottom:10px;padding:8px 12px;background:var(--paper-3);border:1px dashed var(--rule-soft)">
|
|
2955
|
+
<div class="anno">tick = enabled</div>
|
|
2956
|
+
<div>enabled <span id="bm-avail" style="color:var(--good);font-weight:600">${total - blocked.size}</span> <span class="text-mute">/ ${total}</span></div>
|
|
2957
|
+
</div>
|
|
2958
|
+
<div style="display:flex;gap:6px;margin-bottom:10px">
|
|
2959
|
+
<button class="btn btn-ghost btn-xs" onclick="App.bmAll(true)">all</button>
|
|
2960
|
+
<button class="btn btn-ghost btn-xs" onclick="App.bmAll(false)">none</button>
|
|
2961
|
+
<button class="btn btn-ghost btn-xs" onclick="App.bmInvert()">invert</button>
|
|
2962
|
+
</div>
|
|
2963
|
+
<div style="max-height:50vh;overflow:auto">${Object.entries(groups).map(([p, ms]) => `
|
|
2964
|
+
<div class="bm-group" data-provider="${p}" style="margin-bottom:14px;border:1px solid var(--rule-faint);padding:10px">
|
|
2965
|
+
<label class="checkbox" style="font-family:'Caveat',cursive;font-size:17px;margin-bottom:6px;padding-bottom:6px;border-bottom:1px dashed var(--rule-soft);display:flex;width:100%">
|
|
2966
|
+
<input type="checkbox" class="bm-group-cb" data-provider="${p}"><span class="box-mark"></span>
|
|
2967
|
+
<span style="font-weight:600">${this.esc(p)}</span>
|
|
2968
|
+
<span class="text-xs text-mute" data-role="count" style="margin-left:auto;font-family:'JetBrains Mono',monospace">${ms.filter(m => !blocked.has(m)).length}/${ms.length}</span>
|
|
2969
|
+
</label>
|
|
2970
|
+
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:4px 10px">
|
|
2971
|
+
${ms.map(m => `<label class="checkbox"><input type="checkbox" class="blocked-model-cb" data-model="${this.esc(m)}" data-provider="${p}" ${blocked.has(m) ? '' : 'checked'}><span class="box-mark"></span><span class="mono text-xs">${this.esc(m)}</span></label>`).join('')}
|
|
2972
|
+
</div>
|
|
2973
|
+
</div>`).join('')}</div>`;
|
|
2974
|
+
this.confirm(`models · ${acct.email}`, html, { okText: 'save', html: true, wide: true });
|
|
2975
|
+
setTimeout(() => this._bindBlockedModal(id), 0);
|
|
2976
|
+
} catch (e) { this.toast(e.message, 'error'); }
|
|
2977
|
+
},
|
|
2978
|
+
|
|
2979
|
+
_bindBlockedModal(accountId) {
|
|
2980
|
+
const root = document.querySelector('.modal-overlay:last-child');
|
|
2981
|
+
if (!root) return;
|
|
2982
|
+
const updateGroup = (p) => {
|
|
2983
|
+
const g = root.querySelector(`.bm-group[data-provider="${p}"]`);
|
|
2984
|
+
if (!g) return;
|
|
2985
|
+
const boxes = g.querySelectorAll('.blocked-model-cb');
|
|
2986
|
+
const checked = [...boxes].filter(b => b.checked).length;
|
|
2987
|
+
const gcb = g.querySelector('.bm-group-cb');
|
|
2988
|
+
gcb.checked = checked === boxes.length;
|
|
2989
|
+
gcb.indeterminate = checked > 0 && checked < boxes.length;
|
|
2990
|
+
g.querySelector('[data-role=count]').textContent = `${checked}/${boxes.length}`;
|
|
2991
|
+
};
|
|
2992
|
+
const updateTotal = () => {
|
|
2993
|
+
const all = root.querySelectorAll('.blocked-model-cb');
|
|
2994
|
+
const checked = [...all].filter(b => b.checked).length;
|
|
2995
|
+
const el = root.querySelector('#bm-avail'); if (el) el.textContent = checked;
|
|
2996
|
+
};
|
|
2997
|
+
root.querySelectorAll('.bm-group').forEach(g => updateGroup(g.dataset.provider));
|
|
2998
|
+
updateTotal();
|
|
2999
|
+
root.querySelectorAll('.blocked-model-cb').forEach(cb => cb.addEventListener('change', () => { updateGroup(cb.dataset.provider); updateTotal(); }));
|
|
3000
|
+
root.querySelectorAll('.bm-group-cb').forEach(gcb => gcb.addEventListener('change', () => {
|
|
3001
|
+
root.querySelectorAll(`.blocked-model-cb[data-provider="${gcb.dataset.provider}"]`).forEach(cb => cb.checked = gcb.checked);
|
|
3002
|
+
updateGroup(gcb.dataset.provider); updateTotal();
|
|
3003
|
+
}));
|
|
3004
|
+
const okBtn = root.querySelector('[data-act=ok]');
|
|
3005
|
+
if (!okBtn || okBtn.dataset.bound) return;
|
|
3006
|
+
okBtn.dataset.bound = '1';
|
|
3007
|
+
const self = this;
|
|
3008
|
+
okBtn.onclick = async () => {
|
|
3009
|
+
const newBlocked = [];
|
|
3010
|
+
root.querySelectorAll('.blocked-model-cb').forEach(cb => { if (!cb.checked) newBlocked.push(cb.dataset.model); });
|
|
3011
|
+
okBtn.disabled = true; okBtn.textContent = 'saving …';
|
|
3012
|
+
try {
|
|
3013
|
+
const r = await self.api('PATCH', `/accounts/${accountId}`, { blockedModels: newBlocked });
|
|
3014
|
+
if (r.success) { self.toast('saved'); root.remove(); self.loadAccounts(); }
|
|
3015
|
+
else { self.toast(r.error || 'save failed', 'error'); okBtn.disabled = false; okBtn.textContent = 'save'; }
|
|
3016
|
+
} catch (e) { self.toast(e.message, 'error'); okBtn.disabled = false; okBtn.textContent = 'save'; }
|
|
3017
|
+
};
|
|
3018
|
+
},
|
|
3019
|
+
bmAll(checked) { const root = document.querySelector('.modal-overlay:last-child'); if (!root) return; root.querySelectorAll('.blocked-model-cb').forEach(cb => { cb.checked = checked; cb.dispatchEvent(new Event('change')); }); },
|
|
3020
|
+
bmInvert() { const root = document.querySelector('.modal-overlay:last-child'); if (!root) return; root.querySelectorAll('.blocked-model-cb').forEach(cb => { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); }); },
|
|
3021
|
+
|
|
3022
|
+
async addAccount() {
|
|
3023
|
+
const type = document.getElementById('acc-type').value;
|
|
3024
|
+
const key = document.getElementById('acc-key').value.trim();
|
|
3025
|
+
const label = document.getElementById('acc-label').value.trim();
|
|
3026
|
+
if (!key) return this.toast('paste key or token', 'error');
|
|
3027
|
+
const body = type === 'api_key' ? { api_key: key, label } : { token: key, label };
|
|
3028
|
+
const r = await this.api('POST', '/accounts', body);
|
|
3029
|
+
if (r.success) { this.toast('account added'); document.getElementById('acc-key').value = ''; document.getElementById('acc-label').value = ''; this.loadAccounts(); }
|
|
3030
|
+
else this.toast(r.error || 'add failed', 'error');
|
|
3031
|
+
},
|
|
3032
|
+
async toggleAccount(id, status) { await this.api('PATCH', `/accounts/${id}`, { status }); this.loadAccounts(); },
|
|
3033
|
+
async overrideTier(id, current) {
|
|
3034
|
+
const v = await this.prompt('override tier', `current: ${current}`, [{ name: 'tier', label: 'tier', type: 'select', value: current, options: [{ value: 'pro', label: 'pro' }, { value: 'free', label: 'free' }, { value: 'unknown', label: 'unknown' }] }]);
|
|
3035
|
+
if (!v) return;
|
|
3036
|
+
await this.api('PATCH', `/accounts/${id}`, { tier: v.tier });
|
|
3037
|
+
this.toast('tier set'); this.loadAccounts();
|
|
3038
|
+
},
|
|
3039
|
+
async resetErrors(id) { await this.api('PATCH', `/accounts/${id}`, { resetErrors: true }); this.loadAccounts(); this.toast('errors reset'); },
|
|
3040
|
+
async deleteAccount(id) {
|
|
3041
|
+
const ok = await this.confirm('delete account?', 'this cannot be undone', { danger: true, okText: 'delete' });
|
|
3042
|
+
if (!ok) return;
|
|
3043
|
+
await this.api('DELETE', `/accounts/${id}`);
|
|
3044
|
+
this.loadAccounts(); this.toast('deleted');
|
|
3045
|
+
},
|
|
3046
|
+
|
|
3047
|
+
/* ── models ── */
|
|
3048
|
+
async loadModels() {
|
|
3049
|
+
const [m, a] = await Promise.all([this.api('GET', '/models'), this.api('GET', '/model-access')]);
|
|
3050
|
+
this.allModels = m.models || [];
|
|
3051
|
+
this.modelAccessConfig = a.mode ? a : { mode: 'all', list: [] };
|
|
3052
|
+
document.querySelector(`input[name="model-mode"][value="${this.modelAccessConfig.mode}"]`).checked = true;
|
|
3053
|
+
const sel = document.getElementById('model-provider-filter');
|
|
3054
|
+
const provs = [...new Set(this.allModels.map(x => x.provider))].sort();
|
|
3055
|
+
sel.innerHTML = '<option value="">all providers</option>' + provs.map(p => `<option>${this.esc(p)}</option>`).join('');
|
|
3056
|
+
this.updateModelListUI();
|
|
3057
|
+
},
|
|
3058
|
+
updateModelListUI() {
|
|
3059
|
+
const sec = document.getElementById('model-list-section');
|
|
3060
|
+
if (this.modelAccessConfig.mode === 'all') { sec.classList.add('hidden'); return; }
|
|
3061
|
+
sec.classList.remove('hidden');
|
|
3062
|
+
const isAllow = this.modelAccessConfig.mode === 'allowlist';
|
|
3063
|
+
document.getElementById('model-list-title').textContent = isAllow ? 'allow list — only these models' : 'block list — these models are denied';
|
|
3064
|
+
document.getElementById('model-list-hint').textContent = isAllow ? 'click chips below to allow them' : 'click chips below to block them';
|
|
3065
|
+
const current = this.modelAccessConfig.list;
|
|
3066
|
+
const cont = document.getElementById('model-list-current');
|
|
3067
|
+
cont.innerHTML = current.length
|
|
3068
|
+
? `<div class="model-chips">${current.map(m => `<span class="model-chip selected">${this.esc(m)}<span class="remove" onclick="App.removeModelFromList('${this.escAttr(m)}')">×</span></span>`).join('')}</div>`
|
|
3069
|
+
: `<div class="text-xs text-dim italic">empty</div>`;
|
|
3070
|
+
this.filterModels();
|
|
3071
|
+
},
|
|
3072
|
+
filterModels() {
|
|
3073
|
+
const search = (document.getElementById('model-search')?.value || '').toLowerCase();
|
|
3074
|
+
const provider = document.getElementById('model-provider-filter')?.value || '';
|
|
3075
|
+
const list = this.modelAccessConfig.list;
|
|
3076
|
+
const grouped = {};
|
|
3077
|
+
for (const m of this.allModels) {
|
|
3078
|
+
if (search && !m.name.toLowerCase().includes(search) && !m.provider.toLowerCase().includes(search)) continue;
|
|
3079
|
+
if (provider && m.provider !== provider) continue;
|
|
3080
|
+
(grouped[m.provider] = grouped[m.provider] || []).push(m);
|
|
3081
|
+
}
|
|
3082
|
+
document.getElementById('model-chips-container').innerHTML = Object.entries(grouped).map(([p, ms]) => `
|
|
3083
|
+
<div class="provider-group">
|
|
3084
|
+
<div class="provider-label">${this.esc(p)}</div>
|
|
3085
|
+
<div class="model-chips">${ms.map(m => `<span class="model-chip ${list.includes(m.id) ? 'selected' : ''}" onclick="App.toggleModelInList('${this.escAttr(m.id)}')">${this.esc(m.name)}</span>`).join('')}</div>
|
|
3086
|
+
</div>`).join('') || '<div class="text-mute italic">no matches</div>';
|
|
3087
|
+
},
|
|
3088
|
+
async setModelMode(mode) { await this.api('PUT', '/model-access', { mode }); this.modelAccessConfig.mode = mode; this.updateModelListUI(); this.toast('mode updated'); },
|
|
3089
|
+
async toggleModelInList(id) {
|
|
3090
|
+
const idx = this.modelAccessConfig.list.indexOf(id);
|
|
3091
|
+
if (idx > -1) { await this.api('POST', '/model-access/remove', { model: id }); this.modelAccessConfig.list.splice(idx, 1); }
|
|
3092
|
+
else { await this.api('POST', '/model-access/add', { model: id }); this.modelAccessConfig.list.push(id); }
|
|
3093
|
+
this.updateModelListUI();
|
|
3094
|
+
},
|
|
3095
|
+
async removeModelFromList(id) { await this.api('POST', '/model-access/remove', { model: id }); this.modelAccessConfig.list = this.modelAccessConfig.list.filter(x => x !== id); this.updateModelListUI(); },
|
|
3096
|
+
|
|
3097
|
+
/* ── proxy ── */
|
|
3098
|
+
async loadProxy() {
|
|
3099
|
+
const d = await this.api('GET', '/proxy');
|
|
3100
|
+
if (d.global) {
|
|
3101
|
+
document.getElementById('proxy-type').value = d.global.type || 'http';
|
|
3102
|
+
document.getElementById('proxy-host').value = (d.global.host || '').replace(/:\d+$/, '');
|
|
3103
|
+
document.getElementById('proxy-port').value = d.global.port || '';
|
|
3104
|
+
document.getElementById('proxy-user').value = d.global.username || '';
|
|
3105
|
+
document.getElementById('proxy-pass').value = '';
|
|
3106
|
+
document.getElementById('proxy-pass').placeholder = d.global.hasPassword ? '(saved)' : 'optional';
|
|
3107
|
+
this._globalProxyHasPassword = !!d.global.hasPassword;
|
|
3108
|
+
const h = (d.global.host || '').replace(/:\d+$/, '');
|
|
3109
|
+
document.getElementById('proxy-current').textContent = `current: ${d.global.type}://${h}:${d.global.port}${d.global.username ? ' (auth: ' + d.global.username + ')' : ''}`;
|
|
3110
|
+
} else {
|
|
3111
|
+
document.getElementById('proxy-current').textContent = 'not configured';
|
|
3112
|
+
}
|
|
3113
|
+
const accts = await this.api('GET', '/accounts?view=summary&pageSize=200');
|
|
3114
|
+
const tbody = document.querySelector('#proxy-accounts-table tbody');
|
|
3115
|
+
const pa = d.perAccount || {};
|
|
3116
|
+
tbody.innerHTML = (accts.accounts || []).map(a => {
|
|
3117
|
+
const p = pa[a.id];
|
|
3118
|
+
return `<tr>
|
|
3119
|
+
<td>${this.esc(a.email)} <code class="text-xs">${this.esc(a.id)}</code></td>
|
|
3120
|
+
<td>${p ? `<code>${this.esc(p.type)}://${p.username ? this.esc(p.username) + '@' : ''}${this.esc(p.host)}:${this.esc(String(p.port))}</code>` : `<span class="text-sm text-dim italic">none</span>`}</td>
|
|
3121
|
+
<td class="nowrap"><div class="btn-group"><button class="btn btn-ghost btn-xs" onclick="App.editAccountProxy('${this.escAttr(a.id)}','${this.escAttr(a.email)}')">configure</button>${p ? `<button class="btn btn-ghost btn-xs" style="color:var(--error)" onclick="App.clearAccountProxy('${this.escAttr(a.id)}')">clear</button>` : ''}</div></td>
|
|
3122
|
+
</tr>`;
|
|
3123
|
+
}).join('') || `<tr class="empty-row"><td colspan="3">no accounts yet</td></tr>`;
|
|
3124
|
+
},
|
|
3125
|
+
async saveGlobalProxy() {
|
|
3126
|
+
const cfg = { type: document.getElementById('proxy-type').value, host: document.getElementById('proxy-host').value.trim(), port: document.getElementById('proxy-port').value, username: document.getElementById('proxy-user').value };
|
|
3127
|
+
const pw = document.getElementById('proxy-pass').value;
|
|
3128
|
+
if (pw || !this._globalProxyHasPassword) cfg.password = pw;
|
|
3129
|
+
if (!cfg.host) return this.toast('enter host', 'error');
|
|
3130
|
+
const r = await this.api('PUT', '/proxy/global', cfg);
|
|
3131
|
+
if (r && r.error) return this.toast(r.error, 'error');
|
|
3132
|
+
this.toast('saved'); this.loadProxy();
|
|
3133
|
+
},
|
|
3134
|
+
async clearGlobalProxy() { await this.api('DELETE', '/proxy/global'); ['proxy-host','proxy-port','proxy-user','proxy-pass'].forEach(id => document.getElementById(id).value = ''); this.toast('cleared'); this.loadProxy(); },
|
|
3135
|
+
async editAccountProxy(id, label) {
|
|
3136
|
+
const v = await this.prompt('configure proxy', `for ${label}`, [
|
|
3137
|
+
{ name: 'type', label: 'type', type: 'select', value: 'http', options: [{value:'http',label:'HTTP'},{value:'https',label:'HTTPS'},{value:'socks5',label:'SOCKS5'}] },
|
|
3138
|
+
{ name: 'host', label: 'host', placeholder: 'proxy.host' },
|
|
3139
|
+
{ name: 'port', label: 'port', type: 'number', placeholder: '8080' },
|
|
3140
|
+
{ name: 'username', label: 'username', placeholder: 'optional' },
|
|
3141
|
+
{ name: 'password', label: 'password', type: 'password', placeholder: 'optional' },
|
|
3142
|
+
]);
|
|
3143
|
+
if (!v || !v.host) return;
|
|
3144
|
+
const r = await this.api('PUT', `/proxy/accounts/${id}`, { type: v.type || 'http', host: v.host, port: parseInt(v.port) || 8080, username: v.username || '', password: v.password || '' });
|
|
3145
|
+
if (r && r.error) return this.toast(r.error, 'error');
|
|
3146
|
+
this.toast('saved'); this.loadProxy();
|
|
3147
|
+
},
|
|
3148
|
+
async clearAccountProxy(id) { await this.api('DELETE', `/proxy/accounts/${id}`); this.toast('cleared'); this.loadProxy(); },
|
|
3149
|
+
|
|
3150
|
+
/* ── logs ──
|
|
3151
|
+
Uses fetch + X-Dashboard-Password header to align with the production
|
|
3152
|
+
SSE contract (server expects header, payload shape `{ts, level, msg}`).
|
|
3153
|
+
EventSource cannot set custom headers, so the original sketch wiring
|
|
3154
|
+
(?token= query) silently failed against the real backend. */
|
|
3155
|
+
loadLogs() {
|
|
3156
|
+
if (this.sseConn) { try { this.sseConn.close(); } catch {} this.sseConn = null; }
|
|
3157
|
+
this.logEntries = [];
|
|
3158
|
+
const c = document.getElementById('log-container');
|
|
3159
|
+
if (c) c.innerHTML = '';
|
|
3160
|
+
|
|
3161
|
+
const headers = { 'Accept': 'text/event-stream' };
|
|
3162
|
+
if (this.password) headers['X-Dashboard-Password'] = this.password;
|
|
3163
|
+
const controller = new AbortController();
|
|
3164
|
+
this.sseConn = { close: () => controller.abort() };
|
|
3165
|
+
|
|
3166
|
+
fetch('/dashboard/api/logs/stream', { headers, signal: controller.signal })
|
|
3167
|
+
.then(r => {
|
|
3168
|
+
if (!r.ok || !r.body) throw new Error('SSE connect failed');
|
|
3169
|
+
const reader = r.body.getReader();
|
|
3170
|
+
const dec = new TextDecoder();
|
|
3171
|
+
let buf = '';
|
|
3172
|
+
const pump = () => reader.read().then(({ done, value }) => {
|
|
3173
|
+
if (done) return;
|
|
3174
|
+
buf += dec.decode(value, { stream: true });
|
|
3175
|
+
let idx;
|
|
3176
|
+
while ((idx = buf.indexOf('\n\n')) !== -1) {
|
|
3177
|
+
const frame = buf.slice(0, idx);
|
|
3178
|
+
buf = buf.slice(idx + 2);
|
|
3179
|
+
const payload = frame.split('\n').filter(l => l.startsWith('data: ')).map(l => l.slice(6)).join('\n');
|
|
3180
|
+
if (!payload) continue;
|
|
3181
|
+
try { this.appendLog(JSON.parse(payload)); } catch {}
|
|
3182
|
+
}
|
|
3183
|
+
return pump();
|
|
3184
|
+
});
|
|
3185
|
+
return pump();
|
|
3186
|
+
})
|
|
3187
|
+
.catch(() => { /* silent — abort or transient */ });
|
|
3188
|
+
},
|
|
3189
|
+
appendLog(e) {
|
|
3190
|
+
this.logEntries.push(e);
|
|
3191
|
+
if (this.logEntries.length > 5000) this.logEntries.splice(0, this.logEntries.length - 5000);
|
|
3192
|
+
this.renderLogEntry(e);
|
|
3193
|
+
},
|
|
3194
|
+
renderLogEntry(e) {
|
|
3195
|
+
const lvl = (document.getElementById('log-level')?.value || '').toLowerCase();
|
|
3196
|
+
const search = (document.getElementById('log-search')?.value || '').toLowerCase();
|
|
3197
|
+
if (lvl && (e.level || '').toLowerCase() !== lvl) return;
|
|
3198
|
+
// Production payload uses `msg`; sketch UI used to read `message`.
|
|
3199
|
+
const text = e.msg || e.message || '';
|
|
3200
|
+
if (search && !text.toLowerCase().includes(search)) return;
|
|
3201
|
+
const c = document.getElementById('log-container');
|
|
3202
|
+
if (!c) return;
|
|
3203
|
+
const div = document.createElement('div');
|
|
3204
|
+
div.className = 'log-entry ' + (e.level || 'info').toLowerCase();
|
|
3205
|
+
const ts = e.ts || e.time || Date.now();
|
|
3206
|
+
div.innerHTML = `<span class="ts">${new Date(ts).toLocaleTimeString()}</span><span class="lvl">${this.esc((e.level || 'info').toUpperCase())}</span><span>${this.esc(text)}</span>`;
|
|
3207
|
+
c.appendChild(div);
|
|
3208
|
+
if (document.getElementById('log-tail')?.checked) c.scrollTop = c.scrollHeight;
|
|
3209
|
+
},
|
|
3210
|
+
applyLogFilter() {
|
|
3211
|
+
const c = document.getElementById('log-container'); c.innerHTML = '';
|
|
3212
|
+
this.logEntries.forEach(e => this.renderLogEntry(e));
|
|
3213
|
+
},
|
|
3214
|
+
clearLogs() { this.logEntries = []; document.getElementById('log-container').innerHTML = ''; },
|
|
3215
|
+
|
|
3216
|
+
/* ── stats ── */
|
|
3217
|
+
async loadStats() {
|
|
3218
|
+
/* Backend response: { totalRequests, successCount, errorCount, startedAt,
|
|
3219
|
+
modelCounts: { [model]: { requests, success, errors, totalMs, avgMs, p50Ms, p95Ms } },
|
|
3220
|
+
accountCounts: { [aid]: { requests, success, errors } },
|
|
3221
|
+
hourlyBuckets: [ { hour, requests, errors } ] }.
|
|
3222
|
+
Original sketch read r.hourly / r.models / r.accounts which were never sent. */
|
|
3223
|
+
const r = await this.api('GET', '/stats');
|
|
3224
|
+
const total = r.totalRequests || 0;
|
|
3225
|
+
const success = r.successCount || 0;
|
|
3226
|
+
const errors = r.errorCount || 0;
|
|
3227
|
+
const successRate = total > 0 ? Math.round((success / total) * 1000) / 10 : 0;
|
|
3228
|
+
|
|
3229
|
+
// Aggregate latency across models for the top card.
|
|
3230
|
+
const modelEntries = Object.entries(r.modelCounts || {}).map(([model, s]) => ({
|
|
3231
|
+
model,
|
|
3232
|
+
requests: s.requests || 0,
|
|
3233
|
+
success: s.success || 0,
|
|
3234
|
+
errors: s.errors || 0,
|
|
3235
|
+
successRate: s.requests > 0 ? Math.round((s.success / s.requests) * 1000) / 10 : 0,
|
|
3236
|
+
avg: s.avgMs || 0,
|
|
3237
|
+
p50: s.p50Ms || 0,
|
|
3238
|
+
p95: s.p95Ms || 0,
|
|
3239
|
+
})).sort((a, b) => b.requests - a.requests);
|
|
3240
|
+
const grandTotalMs = Object.values(r.modelCounts || {}).reduce((n, s) => n + (s.totalMs || 0), 0);
|
|
3241
|
+
const avgLatency = total > 0 ? Math.round(grandTotalMs / total) : 0;
|
|
3242
|
+
const sortedP50 = modelEntries.map(m => m.p50).filter(Boolean).sort((a, b) => a - b);
|
|
3243
|
+
const sortedP95 = modelEntries.map(m => m.p95).filter(Boolean).sort((a, b) => a - b);
|
|
3244
|
+
const overallP50 = sortedP50.length ? sortedP50[Math.floor(sortedP50.length / 2)] : 0;
|
|
3245
|
+
const overallP95 = sortedP95.length ? sortedP95[Math.floor(sortedP95.length * 0.95)] || sortedP95[sortedP95.length - 1] : 0;
|
|
3246
|
+
|
|
3247
|
+
document.getElementById('stats-cards').innerHTML = `
|
|
3248
|
+
${this.metric({ label: 'total requests', value: total, sub: '', kind: 'accent' })}
|
|
3249
|
+
${this.metric({ label: 'success', value: success, sub: `${successRate}%`, kind: 'good' })}
|
|
3250
|
+
${this.metric({ label: 'errors', value: errors, sub: '', kind: 'error' })}
|
|
3251
|
+
${this.metric({ label: 'avg latency', value: avgLatency + ' ms', sub: `p50 ${overallP50} · p95 ${overallP95}` })}
|
|
3252
|
+
`;
|
|
3253
|
+
|
|
3254
|
+
// Hourly bucket bar strip — backend ships `hourlyBuckets[*].requests` not `count`.
|
|
3255
|
+
// Slice window follows the segmented range radio: 24h / 7d / 30d.
|
|
3256
|
+
const range = document.querySelector('#stats-range input[name=range]:checked')?.value || '24h';
|
|
3257
|
+
const sliceN = range === '7d' ? 168 : range === '30d' ? 720 : 24;
|
|
3258
|
+
const buckets = (r.hourlyBuckets || []).slice(-sliceN);
|
|
3259
|
+
const maxV = Math.max(1, ...buckets.map(b => b.requests || 0));
|
|
3260
|
+
document.getElementById('stats-bars').innerHTML = buckets.map(b => {
|
|
3261
|
+
const h = ((b.requests || 0) / maxV) * 96;
|
|
3262
|
+
const t = b.hour ? new Date(b.hour) : null;
|
|
3263
|
+
const label = t
|
|
3264
|
+
? (range === '24h'
|
|
3265
|
+
? t.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
3266
|
+
: t.toLocaleString([], { month: '2-digit', day: '2-digit', hour: '2-digit' }))
|
|
3267
|
+
: '';
|
|
3268
|
+
return `<div class="bar-wrap"><div class="b-bar ${b.errors > 0 ? 'has-errors' : ''}" style="height:${h}%" title="${label} · ${b.requests || 0} req · ${b.errors || 0} err"></div></div>`;
|
|
3269
|
+
}).join('') || '<div class="text-mute italic" style="margin:auto">no data yet</div>';
|
|
3270
|
+
|
|
3271
|
+
const tbody = document.querySelector('#model-stats-table tbody');
|
|
3272
|
+
tbody.innerHTML = modelEntries.map(m => `<tr>
|
|
3273
|
+
<td><code>${this.esc(m.model)}</code></td>
|
|
3274
|
+
<td>${m.requests}</td><td>${m.success}</td><td>${m.errors}</td>
|
|
3275
|
+
<td>${m.successRate}%</td>
|
|
3276
|
+
<td>${m.avg} ms</td><td>${m.p50}</td><td>${m.p95}</td>
|
|
3277
|
+
</tr>`).join('') || `<tr class="empty-row"><td colspan="8">no model data yet</td></tr>`;
|
|
3278
|
+
|
|
3279
|
+
const accountEntries = Object.entries(r.accountCounts || {}).map(([id, s]) => ({
|
|
3280
|
+
id,
|
|
3281
|
+
requests: s.requests || 0,
|
|
3282
|
+
success: s.success || 0,
|
|
3283
|
+
errors: s.errors || 0,
|
|
3284
|
+
successRate: s.requests > 0 ? Math.round((s.success / s.requests) * 1000) / 10 : 0,
|
|
3285
|
+
})).sort((a, b) => b.requests - a.requests);
|
|
3286
|
+
const tbody2 = document.querySelector('#account-stats-table tbody');
|
|
3287
|
+
tbody2.innerHTML = accountEntries.map(a => `<tr>
|
|
3288
|
+
<td><code>${this.esc(a.id.slice(0,8))}</code></td>
|
|
3289
|
+
<td>${a.requests}</td><td>${a.success}</td><td>${a.errors}</td>
|
|
3290
|
+
<td>${a.successRate}%</td>
|
|
3291
|
+
</tr>`).join('') || `<tr class="empty-row"><td colspan="5">no account data yet</td></tr>`;
|
|
3292
|
+
|
|
3293
|
+
// 30s auto-refresh — production UI keeps this running so the panel
|
|
3294
|
+
// stays live without manual reload.
|
|
3295
|
+
this.poll('stats', () => this.loadStats(), 30000);
|
|
3296
|
+
},
|
|
3297
|
+
|
|
3298
|
+
async resetStats() {
|
|
3299
|
+
const ok = await this.confirm('reset stats', 'This wipes all accumulated counters. Continue?', { okText: 'reset', danger: true });
|
|
3300
|
+
if (!ok) return;
|
|
3301
|
+
try {
|
|
3302
|
+
await this.api('DELETE', '/stats');
|
|
3303
|
+
this.toast('stats reset', 'success');
|
|
3304
|
+
this.loadStats();
|
|
3305
|
+
} catch (e) {
|
|
3306
|
+
this.toast(e.message, 'error');
|
|
3307
|
+
}
|
|
3308
|
+
},
|
|
3309
|
+
|
|
3310
|
+
/* ── bans ── derive from /accounts; the sketch's original `/bans` 404'd. */
|
|
3311
|
+
async loadBans() {
|
|
3312
|
+
const d = await this.api('GET', '/accounts?view=summary&filter=flagged&pageSize=200');
|
|
3313
|
+
const accounts = d.accounts || [];
|
|
3314
|
+
const errored = accounts.filter(a => a.status === 'error' || (a.errorCount || 0) > 0);
|
|
3315
|
+
const rateLimited = accounts.filter(a => a.rateLimited);
|
|
3316
|
+
const disabled = accounts.filter(a => a.status === 'error');
|
|
3317
|
+
const total = d.stats?.total ?? d.total ?? accounts.length;
|
|
3318
|
+
const healthy = Math.max(0, total - (d.stats?.flagged ?? errored.length));
|
|
3319
|
+
document.getElementById('ban-cards').innerHTML = `
|
|
3320
|
+
${this.metric({ label: 'abnormal', value: errored.length, kind: 'error' })}
|
|
3321
|
+
${this.metric({ label: 'disabled', value: disabled.length, kind: 'warn' })}
|
|
3322
|
+
${this.metric({ label: 'rate-limited', value: rateLimited.length, kind: 'warn' })}
|
|
3323
|
+
${this.metric({ label: 'healthy', value: healthy, kind: 'good' })}
|
|
3324
|
+
`;
|
|
3325
|
+
const tbody = document.querySelector('#ban-table tbody');
|
|
3326
|
+
tbody.innerHTML = accounts.map(a => {
|
|
3327
|
+
const flagged = a.status === 'error' || (a.errorCount || 0) > 0;
|
|
3328
|
+
return `<tr style="${flagged ? 'background:rgba(181,50,36,.04)' : ''}">
|
|
3329
|
+
<td>${this.esc(a.email)} <code class="text-xs">${this.esc((a.id||'').slice(0,8))}</code></td>
|
|
3330
|
+
<td><span class="badge ${a.status}">${a.status}</span></td>
|
|
3331
|
+
<td style="color:${(a.errorCount||0) > 0 ? 'var(--error)' : 'inherit'};font-family:'JetBrains Mono',monospace">${a.errorCount || 0}</td>
|
|
3332
|
+
<td class="text-sm">${a.lastUsed ? new Date(a.lastUsed).toLocaleString() : '—'}</td>
|
|
3333
|
+
<td><div class="btn-group">
|
|
3334
|
+
${(a.errorCount||0) > 0 ? `<button class="btn btn-ghost btn-xs" onclick="App.resetErrors('${this.escAttr(a.id)}')">reset</button>` : ''}
|
|
3335
|
+
${a.status === 'error' ? `<button class="btn btn-good btn-xs" onclick="App.toggleAccount('${this.escAttr(a.id)}','active')">enable</button>` : ''}
|
|
3336
|
+
</div></td>
|
|
3337
|
+
</tr>`;
|
|
3338
|
+
}).join('') || `<tr class="empty-row"><td colspan="5">all healthy</td></tr>`;
|
|
3339
|
+
this.poll('bans', () => this.loadBans(), 30000);
|
|
3340
|
+
},
|
|
3341
|
+
|
|
3342
|
+
/* ── experimental ──
|
|
3343
|
+
Backend serves cascadeConversationReuse under `flags.*` and accepts
|
|
3344
|
+
`PUT /experimental` for writes + `DELETE /experimental/conversation-pool`
|
|
3345
|
+
for clearing. The original sketch wired PATCH and POST /clear-pool which
|
|
3346
|
+
returned 404, so toggles silently reverted on next load. */
|
|
3347
|
+
async loadExperimental() {
|
|
3348
|
+
// v2.0.56: refresh credentials snapshot when the panel opens.
|
|
3349
|
+
this.loadCredentials();
|
|
3350
|
+
const r = await this.api('GET', '/experimental');
|
|
3351
|
+
const flag = r.flags?.cascadeConversationReuse ?? r.cascadeConversationReuse;
|
|
3352
|
+
const cb = document.getElementById('exp-cascade-reuse');
|
|
3353
|
+
if (cb) cb.checked = !!flag;
|
|
3354
|
+
const pool = r.conversationPool || {};
|
|
3355
|
+
document.getElementById('exp-pool-cards').innerHTML = `
|
|
3356
|
+
${this.metric({ label: 'pool size', value: pool.size || 0 })}
|
|
3357
|
+
${this.metric({ label: 'hits', value: pool.hits || 0, kind: 'good' })}
|
|
3358
|
+
${this.metric({ label: 'misses', value: pool.misses || 0, kind: 'warn' })}
|
|
3359
|
+
${this.metric({ label: 'hit rate', value: (pool.hitRate || '0.0') + '%' })}
|
|
3360
|
+
`;
|
|
3361
|
+
// Reflect current skin cookie in the dropdown so reload state is visible.
|
|
3362
|
+
const skinSel = document.getElementById('skin-select');
|
|
3363
|
+
if (skinSel) {
|
|
3364
|
+
const m = document.cookie.match(/(?:^|;\s*)dashboard_skin=([^;]+)/);
|
|
3365
|
+
const cur = m ? decodeURIComponent(m[1]) : 'modern';
|
|
3366
|
+
skinSel.value = (cur === 'sketch') ? 'sketch' : 'modern';
|
|
3367
|
+
}
|
|
3368
|
+
// System prompts editor — moved here so the experimental panel matches
|
|
3369
|
+
// the production UI. Render is best-effort: if /system-prompts is gone
|
|
3370
|
+
// (older backend) just hide the section silently.
|
|
3371
|
+
await this.loadSystemPrompts();
|
|
3372
|
+
},
|
|
3373
|
+
|
|
3374
|
+
/* ── credentials (v2.0.56) — runtime-rotatable API_KEY + dashboard pw */
|
|
3375
|
+
async loadCredentials() {
|
|
3376
|
+
try {
|
|
3377
|
+
const d = await this.api('GET', '/settings/credentials');
|
|
3378
|
+
const ks = document.getElementById('credentials-apikey-source');
|
|
3379
|
+
const km = document.getElementById('credentials-apikey-masked');
|
|
3380
|
+
const ds = document.getElementById('credentials-dashboardpw-source');
|
|
3381
|
+
const dst = document.getElementById('credentials-dashboardpw-status');
|
|
3382
|
+
if (ks) ks.textContent = '[' + (d.apiKeySource || 'unset') + ']';
|
|
3383
|
+
if (km) km.textContent = d.apiKey_masked || '(unset)';
|
|
3384
|
+
if (ds) ds.textContent = '[' + (d.dashboardPasswordSource || 'unset') + ']';
|
|
3385
|
+
if (dst) dst.textContent = d.dashboardPasswordSet ? '已设置' : '未设置';
|
|
3386
|
+
} catch (e) { /* panel not mounted yet */ }
|
|
3387
|
+
},
|
|
3388
|
+
toggleApiKeyVisibility() {
|
|
3389
|
+
const el = document.getElementById('credentials-apikey-input');
|
|
3390
|
+
if (el) el.type = el.type === 'password' ? 'text' : 'password';
|
|
3391
|
+
},
|
|
3392
|
+
toggleDashboardPwVisibility() {
|
|
3393
|
+
const el = document.getElementById('credentials-dashboardpw-input');
|
|
3394
|
+
if (el) el.type = el.type === 'password' ? 'text' : 'password';
|
|
3395
|
+
},
|
|
3396
|
+
async saveCredential(field) {
|
|
3397
|
+
const inputId = field === 'apiKey' ? 'credentials-apikey-input' : 'credentials-dashboardpw-input';
|
|
3398
|
+
const el = document.getElementById(inputId);
|
|
3399
|
+
if (!el) return;
|
|
3400
|
+
const value = el.value;
|
|
3401
|
+
const cleared = value === '';
|
|
3402
|
+
const label = field === 'apiKey' ? 'API_KEY' : 'Dashboard 密码';
|
|
3403
|
+
const okConfirm = confirm(`${label}: ${cleared ? '清除运行时覆盖,回退 .env 值?' : '改完旧值立即失效,要继续吗?'}`);
|
|
3404
|
+
if (!okConfirm) return;
|
|
3405
|
+
try {
|
|
3406
|
+
const r = await this.api('PUT', '/settings/credentials', { [field]: value });
|
|
3407
|
+
el.value = '';
|
|
3408
|
+
this.toast(r.success ? 'saved' : '保存失败');
|
|
3409
|
+
if (field === 'dashboardPassword' && !cleared) {
|
|
3410
|
+
sessionStorage.removeItem('dashboard_password');
|
|
3411
|
+
setTimeout(() => location.reload(), 800);
|
|
3412
|
+
return;
|
|
3413
|
+
}
|
|
3414
|
+
this.loadCredentials();
|
|
3415
|
+
} catch (e) {
|
|
3416
|
+
this.toast('保存失败: ' + (e.message || ''));
|
|
3417
|
+
}
|
|
3418
|
+
},
|
|
3419
|
+
async toggleExperimental(key, val) {
|
|
3420
|
+
await this.api('PUT', '/experimental', { [key]: val });
|
|
3421
|
+
this.toast(`${key}: ${val}`);
|
|
3422
|
+
this.loadExperimental();
|
|
3423
|
+
},
|
|
3424
|
+
async clearConversationPool() {
|
|
3425
|
+
await this.api('DELETE', '/experimental/conversation-pool');
|
|
3426
|
+
this.toast('pool cleared');
|
|
3427
|
+
this.loadExperimental();
|
|
3428
|
+
},
|
|
3429
|
+
|
|
3430
|
+
/* ── system prompts editor — moved over from the production UI so the
|
|
3431
|
+
experimental panel exposes the same prompt template controls. */
|
|
3432
|
+
async loadSystemPrompts() {
|
|
3433
|
+
const holder = document.getElementById('system-prompts-editor');
|
|
3434
|
+
if (!holder) return;
|
|
3435
|
+
const sp = await this.api('GET', '/system-prompts');
|
|
3436
|
+
const prompts = sp?.prompts || {};
|
|
3437
|
+
const keys = Object.keys(prompts);
|
|
3438
|
+
if (!keys.length) {
|
|
3439
|
+
holder.innerHTML = '<div class="text-mute italic">no editable prompts on this backend</div>';
|
|
3440
|
+
return;
|
|
3441
|
+
}
|
|
3442
|
+
holder.innerHTML = keys.map(k => {
|
|
3443
|
+
const keyArg = this.escAttr(k);
|
|
3444
|
+
const promptId = this.systemPromptDomId(k);
|
|
3445
|
+
return `
|
|
3446
|
+
<div style="border:1px dashed var(--rule-soft);padding:12px">
|
|
3447
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
|
|
3448
|
+
<div><code style="color:var(--accent);font-weight:600">${this.esc(k)}</code></div>
|
|
3449
|
+
<div class="field-row-actions" style="margin:0">
|
|
3450
|
+
<button class="btn btn-ghost btn-xs" onclick="App.resetSystemPrompt('${keyArg}')">reset</button>
|
|
3451
|
+
<button class="btn btn-primary btn-xs" onclick="App.saveSystemPrompt('${keyArg}')">save</button>
|
|
3452
|
+
</div>
|
|
3453
|
+
</div>
|
|
3454
|
+
<textarea id="${promptId}" class="input mono" rows="3" style="width:100%;resize:vertical">${this.esc(prompts[k] || '')}</textarea>
|
|
3455
|
+
</div>
|
|
3456
|
+
`;
|
|
3457
|
+
}).join('');
|
|
3458
|
+
},
|
|
3459
|
+
async saveSystemPrompt(key) {
|
|
3460
|
+
const ta = document.getElementById(this.systemPromptDomId(key));
|
|
3461
|
+
if (!ta) return;
|
|
3462
|
+
await this.api('PUT', '/system-prompts', { [key]: ta.value.trim() });
|
|
3463
|
+
this.toast(`${key} saved`);
|
|
3464
|
+
},
|
|
3465
|
+
async resetSystemPrompt(key) {
|
|
3466
|
+
const ok = await this.confirm('reset prompt', `restore default for ${key}?`, { okText: 'reset' });
|
|
3467
|
+
if (!ok) return;
|
|
3468
|
+
await this.api('DELETE', `/system-prompts/${encodeURIComponent(key)}`);
|
|
3469
|
+
this.toast(`${key} reset`);
|
|
3470
|
+
this.loadExperimental();
|
|
3471
|
+
},
|
|
3472
|
+
|
|
3473
|
+
/* ── credits page ── */
|
|
3474
|
+
CONTRIBUTORS_CACHE: null,
|
|
3475
|
+
async fetchContributors() {
|
|
3476
|
+
if (this.CONTRIBUTORS_CACHE) return this.CONTRIBUTORS_CACHE;
|
|
3477
|
+
try {
|
|
3478
|
+
const r = await fetch('/dashboard/data/contributors.json');
|
|
3479
|
+
if (!r.ok) throw new Error(`status ${r.status}`);
|
|
3480
|
+
const data = await r.json();
|
|
3481
|
+
this.CONTRIBUTORS_CACHE = Array.isArray(data?.contributors) ? data.contributors : [];
|
|
3482
|
+
} catch (e) {
|
|
3483
|
+
console.error('fetchContributors failed:', e);
|
|
3484
|
+
this.CONTRIBUTORS_CACHE = [];
|
|
3485
|
+
}
|
|
3486
|
+
return this.CONTRIBUTORS_CACHE;
|
|
3487
|
+
},
|
|
3488
|
+
weightRank(w) { return ({ 'S+': 6, 'S': 5, 'A+': 4, 'A': 3, 'B+': 2, 'B': 1 })[w] || 0; },
|
|
3489
|
+
contributorRarity(weight) {
|
|
3490
|
+
if (weight === 'S+') return 'UR';
|
|
3491
|
+
if (weight === 'S' || weight === 'A+') return 'SSR';
|
|
3492
|
+
return 'SR';
|
|
3493
|
+
},
|
|
3494
|
+
groupContributors(list) {
|
|
3495
|
+
const map = new Map();
|
|
3496
|
+
for (const c of list) {
|
|
3497
|
+
if (!map.has(c.login)) map.set(c.login, { login: c.login, prs: [] });
|
|
3498
|
+
map.get(c.login).prs.push(c);
|
|
3499
|
+
}
|
|
3500
|
+
const groups = [];
|
|
3501
|
+
for (const g of map.values()) {
|
|
3502
|
+
g.prs.sort((a, b) => (b.mergedAt || '').localeCompare(a.mergedAt || ''));
|
|
3503
|
+
g.latest = g.prs[0];
|
|
3504
|
+
g.topWeight = g.prs.reduce((best, p) => this.weightRank(p.weight) > this.weightRank(best) ? p.weight : best, '');
|
|
3505
|
+
groups.push(g);
|
|
3506
|
+
}
|
|
3507
|
+
groups.sort((a, b) =>
|
|
3508
|
+
this.weightRank(b.topWeight) - this.weightRank(a.topWeight)
|
|
3509
|
+
|| (b.latest.mergedAt || '').localeCompare(a.latest.mergedAt || '')
|
|
3510
|
+
);
|
|
3511
|
+
return groups;
|
|
3512
|
+
},
|
|
3513
|
+
async loadCredits() {
|
|
3514
|
+
const body = document.getElementById('credits-body');
|
|
3515
|
+
if (!body) return;
|
|
3516
|
+
const list = await this.fetchContributors();
|
|
3517
|
+
if (!list.length) {
|
|
3518
|
+
body.innerHTML = `<div class="text-mute italic">no contributors</div>`;
|
|
3519
|
+
return;
|
|
3520
|
+
}
|
|
3521
|
+
const groups = this.groupContributors(list);
|
|
3522
|
+
body.innerHTML = `<div style="display:flex;flex-direction:column;gap:14px">${groups.map(g => {
|
|
3523
|
+
const latest = g.latest;
|
|
3524
|
+
const count = g.prs.length;
|
|
3525
|
+
const rarity = this.contributorRarity(g.topWeight);
|
|
3526
|
+
const histItems = g.prs.map(p => {
|
|
3527
|
+
const r = this.contributorRarity(p.weight);
|
|
3528
|
+
return `<li style="margin:8px 0;padding-left:10px;border-left:1.5px solid var(--rule-soft)">
|
|
3529
|
+
<div style="display:flex;gap:8px;align-items:baseline;flex-wrap:wrap">
|
|
3530
|
+
<a class="serif" style="color:var(--ink);text-decoration:underline dotted" target="_blank" rel="noopener" href="https://github.com/dwgx/WindsurfAPI/pull/${p.pr}">PR #${p.pr}</a>
|
|
3531
|
+
<span class="text-mute text-xs">${this.esc(p.mergedAt || '')}</span>
|
|
3532
|
+
${p.weight ? `<span class="stamp" style="font-size:11px;padding:1px 8px;margin-top:0">${this.esc(r)}</span>` : ''}
|
|
3533
|
+
${p.weightLabel ? `<span class="text-mute text-xs">${this.esc(p.weightLabel)}</span>` : ''}
|
|
3534
|
+
</div>
|
|
3535
|
+
<div style="margin-top:3px">${this.esc(p.title)}</div>
|
|
3536
|
+
<div class="text-mute text-sm" style="margin-top:3px">${this.esc(p.summary)}</div>
|
|
3537
|
+
</li>`;
|
|
3538
|
+
}).join('');
|
|
3539
|
+
return `<div style="display:flex;gap:14px;padding:14px;border:1px dashed var(--rule-soft);background:var(--paper)">
|
|
3540
|
+
<img src="https://github.com/${this.esc(g.login)}.png?size=128" alt="${this.esc(g.login)}" loading="lazy" onerror="this.style.visibility='hidden'" style="width:56px;height:56px;border-radius:50%;border:1.5px solid var(--ink);flex-shrink:0">
|
|
3541
|
+
<div style="flex:1;min-width:0">
|
|
3542
|
+
<div style="display:flex;gap:10px;align-items:baseline;flex-wrap:wrap">
|
|
3543
|
+
<a href="https://github.com/${this.esc(g.login)}" target="_blank" rel="noopener" class="serif" style="font-size:20px;color:var(--ink);text-decoration:none">@${this.esc(g.login)}</a>
|
|
3544
|
+
${g.topWeight ? `<span class="stamp" style="font-size:13px;padding:1px 10px;margin-top:0">${this.esc(rarity)}</span>` : ''}
|
|
3545
|
+
<span class="text-mute text-sm">×${count}</span>
|
|
3546
|
+
<span style="flex:1"></span>
|
|
3547
|
+
<a class="text-sm" style="color:var(--ink);text-decoration:underline dotted" target="_blank" rel="noopener" href="https://github.com/dwgx/WindsurfAPI/pull/${latest.pr}">PR #${latest.pr}</a>
|
|
3548
|
+
<span class="text-mute text-xs">${this.esc(latest.mergedAt || '')}</span>
|
|
3549
|
+
</div>
|
|
3550
|
+
<div class="serif" style="margin-top:6px;font-size:16px">${this.esc(latest.title)}</div>
|
|
3551
|
+
<details style="margin-top:6px">
|
|
3552
|
+
<summary class="text-mute text-sm" style="cursor:pointer">${count > 1 ? `${count} 次贡献 · 展开历史` : '查看详情'}</summary>
|
|
3553
|
+
<ol style="list-style:none;padding:0;margin:8px 0 0">${histItems}</ol>
|
|
3554
|
+
</details>
|
|
3555
|
+
</div>
|
|
3556
|
+
</div>`;
|
|
3557
|
+
}).join('')}</div>`;
|
|
3558
|
+
},
|
|
3559
|
+
};
|
|
3560
|
+
|
|
3561
|
+
document.addEventListener('DOMContentLoaded', () => App.init());
|
|
3562
|
+
</script>
|
|
3563
|
+
</body>
|
|
3564
|
+
</html>
|