@goodandready/dsh-key-rotation 0.7.30 → 0.7.32
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/LICENSE +21 -21
- package/README.md +254 -172
- package/README.ru.md +254 -0
- package/README.zh.md +215 -0
- package/cordis.patch.yml +6 -6
- package/lib/agent-budget.js +68 -68
- package/lib/bucket.js +129 -52
- package/lib/canary.js +63 -56
- package/lib/client-helpers.js +21 -21
- package/lib/client.js +1156 -1063
- package/lib/concurrency.js +73 -72
- package/lib/heal.js +35 -35
- package/lib/histogram.js +66 -66
- package/lib/incident.js +76 -76
- package/lib/index.js +1850 -1836
- package/lib/pool.js +264 -237
- package/lib/quota-window.js +45 -45
- package/lib/quota.js +39 -39
- package/lib/region.js +50 -50
- package/lib/sandbox.js +117 -117
- package/lib/shadow.js +81 -81
- package/lib/usage-report.js +79 -49
- package/lib/webhook.js +193 -133
- package/package.json +58 -58
package/lib/client.js
CHANGED
|
@@ -1,1064 +1,1157 @@
|
|
|
1
|
-
// dsh-key-rotation — Settings section ("Key Rotation" / "Ротация ключей").
|
|
2
|
-
// Renders in Settings → Plugins → Plugin settings via the settings.plugin.item slot and
|
|
3
|
-
// edits the plugin's `dsh-key-rotation` settings namespace through the
|
|
4
|
-
// loopback-fenced config bridge at /dsh-key-rotation/config.
|
|
5
|
-
//
|
|
6
|
-
// The config is a KEY POOL PER PROVIDER: a list of providers, each with a list
|
|
7
|
-
// of API-key env names. The provider is picked from the catalog of providers
|
|
8
|
-
// actually registered with ctx.llm (served by the host as data.providers), so no
|
|
9
|
-
// manual route typing is ever needed. The plugin derives the fallback chain and
|
|
10
|
-
// auto-creates clone routes from the key count.
|
|
11
|
-
//
|
|
12
|
-
// Localization: the plugin registers its own en/ru dictionaries with the DSH
|
|
13
|
-
// locale service (ctx.locale.register) and resolves the "active" locale
|
|
14
|
-
// through ctx.locale.getSnapshot().active + ctx.locale.subscribe() via
|
|
15
|
-
// React.useSyncExternalStore, so the UI switches language live whenever the
|
|
16
|
-
// DSH UI locale changes (Settings → Language).
|
|
17
|
-
window.__ModuleLoader__.load({
|
|
18
|
-
id: '@goodandready/dsh-key-rotation',
|
|
19
|
-
factory: (require) => {
|
|
20
|
-
var module = { exports: {} };
|
|
21
|
-
var exports = module.exports;
|
|
22
|
-
const React = require('react');
|
|
23
|
-
const h = React.createElement;
|
|
24
|
-
|
|
25
|
-
const CONFIG_PATH = '/dsh-key-rotation/config';
|
|
26
|
-
const NS = 'dsh-key-rotation';
|
|
27
|
-
|
|
28
|
-
// -------------------------------------------------------------- i18n
|
|
29
|
-
const en = {
|
|
30
|
-
title: 'Key Rotation',
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
'
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
'
|
|
224
|
-
|
|
225
|
-
'.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
'
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
return
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
1
|
+
// dsh-key-rotation — Settings section ("Key Rotation" / "Ротация ключей").
|
|
2
|
+
// Renders in Settings → Plugins → Plugin settings via the settings.plugin.item slot and
|
|
3
|
+
// edits the plugin's `dsh-key-rotation` settings namespace through the
|
|
4
|
+
// loopback-fenced config bridge at /dsh-key-rotation/config.
|
|
5
|
+
//
|
|
6
|
+
// The config is a KEY POOL PER PROVIDER: a list of providers, each with a list
|
|
7
|
+
// of API-key env names. The provider is picked from the catalog of providers
|
|
8
|
+
// actually registered with ctx.llm (served by the host as data.providers), so no
|
|
9
|
+
// manual route typing is ever needed. The plugin derives the fallback chain and
|
|
10
|
+
// auto-creates clone routes from the key count.
|
|
11
|
+
//
|
|
12
|
+
// Localization: the plugin registers its own en/ru dictionaries with the DSH
|
|
13
|
+
// locale service (ctx.locale.register) and resolves the "active" locale
|
|
14
|
+
// through ctx.locale.getSnapshot().active + ctx.locale.subscribe() via
|
|
15
|
+
// React.useSyncExternalStore, so the UI switches language live whenever the
|
|
16
|
+
// DSH UI locale changes (Settings → Language).
|
|
17
|
+
window.__ModuleLoader__.load({
|
|
18
|
+
id: '@goodandready/dsh-key-rotation',
|
|
19
|
+
factory: (require) => {
|
|
20
|
+
var module = { exports: {} };
|
|
21
|
+
var exports = module.exports;
|
|
22
|
+
const React = require('react');
|
|
23
|
+
const h = React.createElement;
|
|
24
|
+
|
|
25
|
+
const CONFIG_PATH = '/dsh-key-rotation/config';
|
|
26
|
+
const NS = 'dsh-key-rotation';
|
|
27
|
+
|
|
28
|
+
// -------------------------------------------------------------- i18n
|
|
29
|
+
const en = {
|
|
30
|
+
title: 'Key Rotation',
|
|
31
|
+
filterAll: 'All',
|
|
32
|
+
filterReady: 'Ready',
|
|
33
|
+
filterCooldown: 'In Cooldown',
|
|
34
|
+
filterErrors: 'With Errors',
|
|
35
|
+
subtitle: 'Per-provider API key rotation: pools of keys, automatic failover on quota/rate-limit errors, cooldown and recovery.',
|
|
36
|
+
cardDesc: 'Per-provider API key rotation: pools of keys, automatic failover on quota/rate-limit errors, cooldown and recovery.',
|
|
37
|
+
loading: 'Loading…',
|
|
38
|
+
notRegistered: 'not registered',
|
|
39
|
+
removeKey: 'Remove key',
|
|
40
|
+
removeProvider: 'Remove provider',
|
|
41
|
+
addKey: '+ Add key',
|
|
42
|
+
addKeyTitle: 'Add API key',
|
|
43
|
+
noProviders: 'No providers registered with DSH — nothing to pick from yet.',
|
|
44
|
+
addProvider: '+ Add provider',
|
|
45
|
+
desc: 'Per-provider API key rotation. For each provider, list its API keys (env names, stored in DSH credentials). The plugin routes a model through that provider\u2019s keys in order and switches to the next on a quota/rate-limit failure.',
|
|
46
|
+
cooldown: 'Cooldown after failure (ms)',
|
|
47
|
+
scheduleDays: 'Rotation schedule (days, 0=off)',
|
|
48
|
+
switchCodes: 'Switch codes (comma-separated)',
|
|
49
|
+
providersTitle: 'Providers and their keys',
|
|
50
|
+
save: 'Save',
|
|
51
|
+
discard: 'Discard',
|
|
52
|
+
saving: 'Saving…',
|
|
53
|
+
moveUp: 'Move up',
|
|
54
|
+
moveDown: 'Move down',
|
|
55
|
+
keyActive: 'in use',
|
|
56
|
+
keyReady: 'ready',
|
|
57
|
+
keyCooling: 'cooling down, {s}s',
|
|
58
|
+
keyMissing: 'no such credential',
|
|
59
|
+
switchesNone: 'no switches yet',
|
|
60
|
+
switchesSome: 'switches: {n} · last: {reason}, {ago}',
|
|
61
|
+
justNow: 'just now',
|
|
62
|
+
minutesAgo: '{n} min ago',
|
|
63
|
+
hoursAgo: '{n} h ago',
|
|
64
|
+
codesTitle: 'Switch on these failures',
|
|
65
|
+
keyValuePlaceholder: 'paste the key, then Save',
|
|
66
|
+
keySave: 'Save key',
|
|
67
|
+
keySaved: 'saved',
|
|
68
|
+
keyFromEnv: 'from the environment, read-only here',
|
|
69
|
+
keyWriteFailed: 'could not store the key: {msg}',
|
|
70
|
+
notSecretShape: 'saved, but the value does not look like an API key - check for a typo',
|
|
71
|
+
rpmTitle: 'requests/min: {u} used, {r} remaining',
|
|
72
|
+
budgetLabel: 'budget:',
|
|
73
|
+
exportCsv: 'Export CSV',
|
|
74
|
+
weightHint: 'round-robin weight: how many times this key joins the cycle (1 = equal share)',
|
|
75
|
+
retestBroken: 'Re-test',
|
|
76
|
+
retestFail: 're-test failed - key still down',
|
|
77
|
+
snapshotExport: 'Snapshot ⬇',
|
|
78
|
+
snapshotImport: 'Restore',
|
|
79
|
+
keyHint: 'The value is stored in DSH credentials and never sent back to the browser — only its last 5 characters are shown. Names are generated for you; hover a key to see the one it uses.',
|
|
80
|
+
brokenKey: 'broken (3× AUTH)',
|
|
81
|
+
keyExpired: 'expired',
|
|
82
|
+
keyExpiringSoon: 'expires in {n} d',
|
|
83
|
+
exportPools: 'Export',
|
|
84
|
+
exportOne: '⬇',
|
|
85
|
+
usedAgo: '{ago} ago',
|
|
86
|
+
importPools: 'Import',
|
|
87
|
+
importEnv: 'Import .env',
|
|
88
|
+
resetCooldown: 'Reset cooldown',
|
|
89
|
+
testAll: 'Test all keys',
|
|
90
|
+
testing: 'Testing…',
|
|
91
|
+
testKey: 'Test',
|
|
92
|
+
testOk: 'OK',
|
|
93
|
+
testFail: 'FAIL',
|
|
94
|
+
poolExhausted: 'pool exhausted — all keys cooling',
|
|
95
|
+
resetting: 'Resetting…',
|
|
96
|
+
keyLabel: 'Key {n}',
|
|
97
|
+
};
|
|
98
|
+
const ru = {
|
|
99
|
+
title: 'Ротация ключей',
|
|
100
|
+
filterAll: 'Все',
|
|
101
|
+
filterReady: 'Готовы',
|
|
102
|
+
filterCooldown: 'В кулдауне',
|
|
103
|
+
filterErrors: 'С ошибками',
|
|
104
|
+
subtitle: 'Ротация API-ключей по провайдерам: пулы ключей, автоматическое переключение при исчерпании квоты или лимита, кулдаун и восстановление.',
|
|
105
|
+
cardDesc: 'Ротация API-ключей по провайдерам: пулы ключей, автоматическое переключение при исчерпании квоты или лимита, кулдаун и восстановление.',
|
|
106
|
+
loading: 'Загрузка…',
|
|
107
|
+
notRegistered: 'не зарегистрирован',
|
|
108
|
+
removeKey: 'Удалить ключ',
|
|
109
|
+
removeProvider: 'Удалить провайдера',
|
|
110
|
+
addKey: '+ Добавить ключ',
|
|
111
|
+
addKeyTitle: 'Добавить API-ключ',
|
|
112
|
+
noProviders: 'Провайдеры ещё не зарегистрированы в DSH — выбирать не из чего.',
|
|
113
|
+
addProvider: '+ Добавить провайдера',
|
|
114
|
+
desc: 'Ротация API-ключей по провайдерам. Для каждого провайдера укажите его API-ключи (имена env, хранятся в учётных данных DSH). Плагин ведёт модель по ключам провайдера по порядку и переключается на следующий при исчерпании квоты/превышении лимита.',
|
|
115
|
+
cooldown: 'Задержка после сбоя (мс)',
|
|
116
|
+
scheduleDays: 'Расписание ротации (дней, 0=выкл)',
|
|
117
|
+
switchCodes: 'Коды переключения (через запятую)',
|
|
118
|
+
providersTitle: 'Провайдеры и их ключи',
|
|
119
|
+
save: 'Сохранить',
|
|
120
|
+
discard: 'Отменить',
|
|
121
|
+
saving: 'Сохранение…',
|
|
122
|
+
moveUp: 'Выше',
|
|
123
|
+
moveDown: 'Ниже',
|
|
124
|
+
keyActive: 'используется',
|
|
125
|
+
keyReady: 'готов',
|
|
126
|
+
keyCooling: 'остывает, {s}с',
|
|
127
|
+
keyMissing: 'ключ не найден',
|
|
128
|
+
switchesNone: 'переключений не было',
|
|
129
|
+
switchesSome: 'переключений: {n} · последнее: {reason}, {ago}',
|
|
130
|
+
justNow: 'только что',
|
|
131
|
+
minutesAgo: '{n} мин назад',
|
|
132
|
+
hoursAgo: '{n} ч назад',
|
|
133
|
+
codesTitle: 'Переключаться при этих сбоях',
|
|
134
|
+
keyValuePlaceholder: 'вставьте ключ и нажмите «Сохранить»',
|
|
135
|
+
keySave: 'Сохранить ключ',
|
|
136
|
+
keySaved: 'сохранён',
|
|
137
|
+
keyFromEnv: 'задан в окружении, отсюда не меняется',
|
|
138
|
+
keyWriteFailed: 'не удалось сохранить ключ: {msg}',
|
|
139
|
+
notSecretShape: 'сохранено, но значение не похоже на API-ключ — проверьте опечатки',
|
|
140
|
+
rpmTitle: 'запросов/мин: {u} использовано, {r} осталось',
|
|
141
|
+
budgetLabel: 'бюджет:',
|
|
142
|
+
exportCsv: 'CSV',
|
|
143
|
+
weightHint: 'вес в круге: сколько раз ключ участвует в ротации (1 = поровну)',
|
|
144
|
+
retestBroken: 'Re-test',
|
|
145
|
+
retestFail: 'перепроверка не прошла — ключ всё ещё недоступен',
|
|
146
|
+
snapshotExport: 'Снапшот ⬇',
|
|
147
|
+
snapshotImport: 'Восстановить',
|
|
148
|
+
keyHint: 'Значение хранится в учётных данных DSH и обратно в браузер не отдаётся — показываются только последние 5 символов. Имена переменных создаются автоматически; наведите на ключ, чтобы увидеть используемое имя.',
|
|
149
|
+
brokenKey: 'сломан (3× AUTH)',
|
|
150
|
+
keyExpired: 'истёк',
|
|
151
|
+
keyExpiringSoon: 'истекает через {n} д',
|
|
152
|
+
exportPools: 'Экспорт',
|
|
153
|
+
exportOne: '⬇',
|
|
154
|
+
usedAgo: '{ago} назад',
|
|
155
|
+
importPools: 'Импорт',
|
|
156
|
+
importEnv: 'Импорт .env',
|
|
157
|
+
resetCooldown: 'Сбросить кулдаун',
|
|
158
|
+
testAll: 'Тест всех ключей',
|
|
159
|
+
testing: 'Тестирование…',
|
|
160
|
+
testKey: 'Тест',
|
|
161
|
+
testOk: 'OK',
|
|
162
|
+
testFail: 'FAIL',
|
|
163
|
+
poolExhausted: 'пул исчерпан — все ключи остывают',
|
|
164
|
+
resetting: 'Сброс…',
|
|
165
|
+
keyLabel: 'Ключ {n}',
|
|
166
|
+
};
|
|
167
|
+
const zh = {
|
|
168
|
+
title: '密钥轮换',
|
|
169
|
+
filterAll: '全部',
|
|
170
|
+
filterReady: '就绪',
|
|
171
|
+
filterCooldown: '冷却中',
|
|
172
|
+
filterErrors: '故障',
|
|
173
|
+
subtitle: '按提供商轮换 API 密钥:密钥池管理、配额/限流自动故障转移、平滑冷却与自愈。',
|
|
174
|
+
cardDesc: '按提供商轮换 API 密钥:密钥池管理、配额/限流自动故障转移、平滑冷却与自愈。',
|
|
175
|
+
loading: '加载中…',
|
|
176
|
+
notRegistered: '未注册',
|
|
177
|
+
removeKey: '删除密钥',
|
|
178
|
+
removeProvider: '删除提供商',
|
|
179
|
+
addKey: '+ 添加密钥',
|
|
180
|
+
addKeyTitle: '添加 API 密钥',
|
|
181
|
+
noProviders: 'DSH 尚未注册任何提供商 — 暂无可选项目。',
|
|
182
|
+
addProvider: '+ 添加提供商',
|
|
183
|
+
desc: '按提供商轮换 API 密钥。为每个提供商配置其 API 密钥(环境变量名,存储于 DSH 凭据中)。插件按序调度请求,遇配额或速率超限自动切换至下一密钥。',
|
|
184
|
+
cooldown: '故障后冷却时间 (毫秒)',
|
|
185
|
+
scheduleDays: '轮换排期 (天数,0=关闭)',
|
|
186
|
+
switchCodes: '切换触发错误码 (逗号分隔)',
|
|
187
|
+
providersTitle: '提供商与密钥池',
|
|
188
|
+
save: '保存',
|
|
189
|
+
discard: '放弃',
|
|
190
|
+
saving: '保存中…',
|
|
191
|
+
moveUp: '上移',
|
|
192
|
+
moveDown: '下移',
|
|
193
|
+
keyActive: '正在使用',
|
|
194
|
+
keyReady: '就绪',
|
|
195
|
+
keyCooling: '冷却中,{s}秒',
|
|
196
|
+
keyMissing: '凭据未找到',
|
|
197
|
+
switchesNone: '暂无切换',
|
|
198
|
+
switchesSome: '已切换:{n} 次 · 最近:{reason},{ago}',
|
|
199
|
+
justNow: '刚刚',
|
|
200
|
+
minutesAgo: '{n} 分钟前',
|
|
201
|
+
hoursAgo: '{n} 小时前',
|
|
202
|
+
codesTitle: '遇以下故障自动切换',
|
|
203
|
+
keyValuePlaceholder: '粘贴密钥,然后点击保存',
|
|
204
|
+
keySave: '保存密钥',
|
|
205
|
+
keySaved: '已保存',
|
|
206
|
+
keyFromEnv: '来自环境变量,此处只读',
|
|
207
|
+
keyWriteFailed: '无法保存密钥:{msg}',
|
|
208
|
+
notSecretShape: '已保存,但格式不像常见 API 密钥 — 请检查是否有拼写错误',
|
|
209
|
+
rpmTitle: '请求数/分钟:已用 {u},剩余 {r}',
|
|
210
|
+
budgetLabel: '预算:',
|
|
211
|
+
exportCsv: '导出 CSV',
|
|
212
|
+
weightHint: '轮询权重:该密钥参与循环的轮次(1 = 均等分担)',
|
|
213
|
+
retestBroken: '重新测试',
|
|
214
|
+
retestFail: '重试失败 — 密钥仍不可用',
|
|
215
|
+
snapshotExport: '快照 ⬇',
|
|
216
|
+
snapshotImport: '恢复快照',
|
|
217
|
+
keyHint: '密钥安全保存在 DSH 凭据中,绝不返回浏览器 — 界面仅展示末尾 5 位字符。变量名自动生成,鼠标悬停可查看。',
|
|
218
|
+
brokenKey: '已损坏 (连续 3 次鉴权失败)',
|
|
219
|
+
keyExpired: '已过期',
|
|
220
|
+
keyExpiringSoon: '{n} 天后过期',
|
|
221
|
+
exportPools: '导出',
|
|
222
|
+
exportOne: '⬇',
|
|
223
|
+
usedAgo: '{ago}',
|
|
224
|
+
importPools: '导入',
|
|
225
|
+
importEnv: '导入 .env',
|
|
226
|
+
resetCooldown: '重置冷却',
|
|
227
|
+
testAll: '测试全部密钥',
|
|
228
|
+
testing: '测试中…',
|
|
229
|
+
testKey: '测试',
|
|
230
|
+
testOk: '正常',
|
|
231
|
+
testFail: '失败',
|
|
232
|
+
poolExhausted: '密钥池耗尽 — 所有密钥均处于冷却状态',
|
|
233
|
+
resetting: '重置中…',
|
|
234
|
+
keyLabel: '密钥 {n}',
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
// Коды, на которых имеет смысл переключать ключ. Список из хоста
|
|
239
|
+
// (DEFAULT_SWITCH_CODES); конфиг может содержать и свои — они показываются
|
|
240
|
+
// отдельными отмеченными галочками, чтобы правило нельзя было потерять.
|
|
241
|
+
const KNOWN_CODES = ['QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'UNKNOWN_MODEL', 'AUTH'];
|
|
242
|
+
|
|
243
|
+
/** Опрос статуса ротации, пока раздел настроек открыт. */
|
|
244
|
+
function useRotationStatus() {
|
|
245
|
+
const [byProvider, setByProvider] = React.useState({});
|
|
246
|
+
React.useEffect(() => {
|
|
247
|
+
let alive = true;
|
|
248
|
+
const pull = () => {
|
|
249
|
+
fetch('/dsh-key-rotation/status', { headers: { accept: 'application/json' } })
|
|
250
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
251
|
+
.then((data) => {
|
|
252
|
+
if (!alive || !data || !Array.isArray(data.providers)) return;
|
|
253
|
+
const map = {};
|
|
254
|
+
for (const entry of data.providers) map[entry.provider] = entry;
|
|
255
|
+
setByProvider(map);
|
|
256
|
+
})
|
|
257
|
+
.catch(() => { /* статус необязателен: карточка остаётся редактором */ });
|
|
258
|
+
};
|
|
259
|
+
pull();
|
|
260
|
+
const id = setInterval(pull, 4000);
|
|
261
|
+
return () => { alive = false; clearInterval(id); };
|
|
262
|
+
}, []);
|
|
263
|
+
return byProvider;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Последний probe-результат по каждому ключу (#219): /sandbox-cache. */
|
|
267
|
+
function useProbeCache() {
|
|
268
|
+
const [cache, setCache] = React.useState({});
|
|
269
|
+
React.useEffect(() => {
|
|
270
|
+
let alive = true;
|
|
271
|
+
const pull = () => {
|
|
272
|
+
fetch('/dsh-key-rotation/sandbox-cache', { headers: { accept: 'application/json' } })
|
|
273
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
274
|
+
.then((data) => { if (alive && data) setCache(data); })
|
|
275
|
+
.catch(() => { /* кэш не критичен: карточка работает и без него */ });
|
|
276
|
+
};
|
|
277
|
+
pull();
|
|
278
|
+
const id = setInterval(pull, 4000);
|
|
279
|
+
return () => { alive = false; clearInterval(id); };
|
|
280
|
+
}, []);
|
|
281
|
+
return cache;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// formatAgo moved to lib/client-helpers.js for testability — keep local alias for bundle self-containment
|
|
285
|
+
function formatAgo(t, at) {
|
|
286
|
+
if (!at) return '';
|
|
287
|
+
const sec = Math.max(0, Math.round((Date.now() - at) / 1000));
|
|
288
|
+
if (sec < 60) return t('justNow');
|
|
289
|
+
if (sec < 3600) return t('minutesAgo').replace('{n}', String(Math.round(sec / 60)));
|
|
290
|
+
return t('hoursAgo').replace('{n}', String(Math.round(sec / 3600)));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Разметка карточки: сетка, а не набор inline-стилей. Фиксированные ширины
|
|
294
|
+
// здесь уже приводили к тому, что имя ключа обрезалось, а кнопки наезжали
|
|
295
|
+
// на поле значения, поэтому имя занимает свою строку, а служебная строка
|
|
296
|
+
// под ним ужимается сама.
|
|
297
|
+
const CARD_CSS = [
|
|
298
|
+
'.krot{display:flex;flex-direction:column;gap:14px;max-width:640px}',
|
|
299
|
+
'.krot p{margin:0}',
|
|
300
|
+
'.krot-hint{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
301
|
+
'.krot-err{font-size:12px;color:var(--dsw-alias-state-error-primary)}',
|
|
302
|
+
'.krot-label{font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
303
|
+
'.krot-field{display:flex;flex-direction:column;gap:5px}',
|
|
304
|
+
'.krot-in{background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);border-radius:6px;padding:5px 8px;font-size:13px;font-family:inherit;min-width:0;width:100%;box-sizing:border-box}',
|
|
305
|
+
'.krot-in:focus{outline:none;border-color:var(--dsw-alias-border-l3,var(--dsw-alias-border-l2))}',
|
|
306
|
+
'.krot-codes{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:4px 12px}',
|
|
307
|
+
'.krot-code{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
308
|
+
'.krot-prov{display:flex;flex-direction:column;gap:10px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:10px 12px}',
|
|
309
|
+
'.krot-prov-head{display:flex;gap:8px;align-items:center}',
|
|
310
|
+
'.krot-prov-head select{flex:1;min-width:0}',
|
|
311
|
+
'.krot-keys{display:flex;flex-direction:column;gap:8px}',
|
|
312
|
+
'.krot-key{display:grid;grid-template-columns:18px minmax(0,1fr);gap:4px 8px;align-items:center}',
|
|
313
|
+
'.krot-num{font-size:12px;color:var(--dsw-alias-label-tertiary);text-align:right}',
|
|
314
|
+
'.krot-name{font-size:13px;color:var(--dsw-alias-label-primary);cursor:default}',
|
|
315
|
+
'.krot-meta{grid-column:2;display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
316
|
+
'.krot-dot{width:8px;height:8px;border-radius:50%;flex:none}',
|
|
317
|
+
'.krot-state{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
318
|
+
'.krot-tail{font-size:11px;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,Menlo,Consolas,monospace}',
|
|
319
|
+
'.krot-secret{flex:1;min-width:120px;max-width:220px}',
|
|
320
|
+
'.krot-acts{display:flex;gap:4px;margin-left:auto;flex:none}',
|
|
321
|
+
'.krot-btn{cursor:pointer;border-radius:6px;padding:2px 8px;font-size:12px;font-family:inherit;background:transparent;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);line-height:1.6}',
|
|
322
|
+
'.krot-btn:disabled{opacity:.35;cursor:default}',
|
|
323
|
+
'.krot-btn:not(:disabled):hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3,var(--dsw-alias-border-l2))}',
|
|
324
|
+
'.krot-foot{display:flex;gap:8px;align-items:center}',
|
|
325
|
+
'.krot-save{background:var(--dsw-alias-button-info-fill);border-color:var(--dsw-alias-button-info-fill);color:var(--dsw-alias-label-primary-foreground);padding:5px 14px;font-size:13px}',
|
|
326
|
+
'.krot-filter-bar{display:flex;gap:6px;margin:8px 0;flex-wrap:wrap}',
|
|
327
|
+
'.krot-pill{appearance:none;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:20px;padding:3px 10px;font-size:11px;font-weight:500;color:var(--dsw-alias-label-secondary);cursor:pointer;transition:all .15s ease}',
|
|
328
|
+
'.krot-pill:hover{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}',
|
|
329
|
+
'.krot-pill-active{background:var(--dsw-alias-brand-primary,#007aff);border-color:var(--dsw-alias-brand-primary,#007aff);color:#fff!important;font-weight:600}',
|
|
330
|
+
'.krot-pill-warn{border-color:rgba(245,158,11,0.3);color:#f59e0b}',
|
|
331
|
+
'.krot-pill-err{border-color:rgba(239,68,68,0.3);color:#ef4444}',
|
|
332
|
+
'.krot-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}',
|
|
333
|
+
'.krot-card-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}',
|
|
334
|
+
'.krot-card-head-text{display:flex;flex-direction:column;flex:1;gap:4px;min-width:0}',
|
|
335
|
+
'.krot-card-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}',
|
|
336
|
+
'.krot-card-description{color:var(--dsw-alias-label-secondary);font-size:13px}',
|
|
337
|
+
'.krot-card-chevron{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s ease;display:flex;align-items:center}.krot-card-chevron-open{transform:rotate(180deg)}',
|
|
338
|
+
'.krot-card-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.krot-header-chip{display:inline-flex;align-items:center;gap:6px;height:26px;padding:0 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12px;font-weight:600;cursor:pointer;position:relative;user-select:none;transition:all .15s ease}.krot-header-chip:hover{background:var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-3));border-color:var(--dsw-alias-border-l1);transform:translateY(-0.5px)}.krot-popover{position:absolute;top:calc(100% + 6px);right:0;z-index:10000;min-width:210px;background:color-mix(in srgb,var(--dsw-alias-bg-base,#121318) 95%,#000);border:1px solid rgba(255,255,255,.12);border-radius:14px;padding:12px 14px;box-shadow:0 16px 40px rgba(0,0,0,.55),0 2px 8px rgba(0,0,0,.2);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);display:flex;flex-direction:column;gap:8px;text-align:left}.krot-pop-title{font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--dsw-alias-label-tertiary)}.krot-pop-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:3px 0}.krot-pop-name{font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:7px}.krot-pop-count{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;opacity:.85}',
|
|
339
|
+
].join('');
|
|
340
|
+
const CARD_CSS_ID = 'dsh-key-rotation/section.module.css';
|
|
341
|
+
if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="' + CARD_CSS_ID + '"]')) {
|
|
342
|
+
const tag = document.createElement('style');
|
|
343
|
+
tag.textContent = CARD_CSS;
|
|
344
|
+
tag.setAttribute('data-plugin', 'dsh-key-rotation');
|
|
345
|
+
tag.dataset.pluginCss = CARD_CSS_ID;
|
|
346
|
+
document.head.appendChild(tag);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Имя переменной под новый ключ.
|
|
351
|
+
*
|
|
352
|
+
* Пользователь его больше не печатает: первый ключ провайдера получает имя
|
|
353
|
+
* вида <PROVIDER>_API_KEY, следующие — тот же корень с суффиксом _2, _3…
|
|
354
|
+
* Корень берётся у уже существующих ключей, чтобы вручную заведённые имена
|
|
355
|
+
* не ломались, и проверяется на занятость по ВСЕМ провайдерам — иначе два
|
|
356
|
+
* провайдера незаметно делили бы одну учётную запись.
|
|
357
|
+
*/
|
|
358
|
+
// nextKeyRef also in lib/client-helpers.js
|
|
359
|
+
function nextKeyRef(providerId, existingKeys, allRefs) {
|
|
360
|
+
const fromExisting = (existingKeys || []).find((k) => typeof k === 'string' && k.length > 0);
|
|
361
|
+
const base = fromExisting
|
|
362
|
+
? fromExisting.replace(/_\d+$/, '')
|
|
363
|
+
: String(providerId || 'provider').toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') + '_API_KEY';
|
|
364
|
+
const taken = new Set(allRefs);
|
|
365
|
+
if (!taken.has(base)) return base;
|
|
366
|
+
for (let n = 2; n < 1000; n++) {
|
|
367
|
+
const candidate = base + '_' + n;
|
|
368
|
+
if (!taken.has(candidate)) return candidate;
|
|
369
|
+
}
|
|
370
|
+
return base + '_' + Date.now();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function useActiveLocale(ctx) {
|
|
374
|
+
return React.useSyncExternalStore(
|
|
375
|
+
React.useMemo(() => (cb) => (ctx && ctx.locale ? ctx.locale.subscribe(cb) : () => {}), [ctx]),
|
|
376
|
+
React.useCallback(() => {
|
|
377
|
+
if (ctx && ctx.locale) {
|
|
378
|
+
const active = ctx.locale.getSnapshot().active;
|
|
379
|
+
if (typeof active === 'string' && active) return active;
|
|
380
|
+
}
|
|
381
|
+
return typeof navigator !== 'undefined' ? String(navigator.language || '').slice(0, 2) : '';
|
|
382
|
+
}, [ctx])
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function makeT(DICT, fallbackKeys) {
|
|
387
|
+
return (key) => (DICT && DICT[key]) || (fallbackKeys && fallbackKeys[key]) || key;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function KeyRotationSection(props) {
|
|
391
|
+
const DICT = props.locale === 'ru' ? ru : (props.locale === 'zh' ? zh : en);
|
|
392
|
+
const t = makeT(DICT, en);
|
|
393
|
+
const [state, setState] = React.useState({ status: 'loading', value: null, revision: 0, error: '', providers: [] });
|
|
394
|
+
const [draft, setDraft] = React.useState(null);
|
|
395
|
+
// ── all hooks live ABOVE any early return (React error 310 otherwise) ──
|
|
396
|
+
const [search, setSearch] = React.useState('');
|
|
397
|
+
const [statusFilter, setStatusFilter] = React.useState('all');
|
|
398
|
+
const [optimisticReset, setOptimisticReset] = React.useState({});
|
|
399
|
+
const [selected, setSelected] = React.useState(new Set());
|
|
400
|
+
const [bulkCooldown, setBulkCooldown] = React.useState('');
|
|
401
|
+
const [undo, setUndo] = React.useState(null);
|
|
402
|
+
const undoTimer = React.useRef(null);
|
|
403
|
+
const [testing, setTesting] = React.useState('');
|
|
404
|
+
const [testResult, setTestResult] = React.useState({});
|
|
405
|
+
const [testAllProvider, setTestAllProvider] = React.useState('');
|
|
406
|
+
const [secretDraft, setSecretDraft] = React.useState({});
|
|
407
|
+
const [secretError, setSecretError] = React.useState('');
|
|
408
|
+
const stashUndo = (u) => { setUndo(u); if (undoTimer.current) clearTimeout(undoTimer.current); undoTimer.current = setTimeout(() => setUndo(null), 5000); };
|
|
409
|
+
const doUndo = () => { if (!undo) return; const u = undo; setUndo(null); setField((cur) => {
|
|
410
|
+
const providers = [...(cur.providers ?? [])];
|
|
411
|
+
if (u.type === 'provider') providers.splice(Math.min(u.index, providers.length), 0, u.entry);
|
|
412
|
+
else if (providers[u.index]) { const keys=[...providers[u.index].keys]; keys.splice(Math.min(u.kIndex, keys.length), 0, u.key); providers[u.index] = { ...providers[u.index], keys }; }
|
|
413
|
+
return { ...cur, providers };
|
|
414
|
+
}); };
|
|
415
|
+
const doTest = (ref) => {
|
|
416
|
+
setTesting(ref); setTestResult((m) => ({ ...m, [ref]: null }));
|
|
417
|
+
// #212: real API probe (models is free on most providers), not just presence
|
|
418
|
+
fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref, probe: 'models' }) })
|
|
419
|
+
.then((r) => r.json())
|
|
420
|
+
.then((data) => setTestResult((m) => ({ ...m, [ref]: data })))
|
|
421
|
+
.catch((e) => setTestResult((m) => ({ ...m, [ref]: { ok: false, message: String(e?.message ?? e) } })))
|
|
422
|
+
.finally(() => setTesting(''));
|
|
423
|
+
};
|
|
424
|
+
const doTestAll = (providerId) => {
|
|
425
|
+
if (!val || !Array.isArray(val.providers)) return;
|
|
426
|
+
const entry = val.providers.find((p) => p.provider === providerId);
|
|
427
|
+
if (!entry || !Array.isArray(entry.keys)) return;
|
|
428
|
+
const refs = entry.keys.filter((k) => k && typeof k === 'string' && k.length > 0);
|
|
429
|
+
setTestAllProvider(providerId);
|
|
430
|
+
Promise.all(refs.map((ref) =>
|
|
431
|
+
fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref }) })
|
|
432
|
+
.then((r) => r.json())
|
|
433
|
+
.then((data) => ({ ref, data }))
|
|
434
|
+
.catch((e) => ({ ref, data: { ok: false, message: String(e?.message ?? e) } }))
|
|
435
|
+
)).then((results) => {
|
|
436
|
+
setTestResult((m) => { const nm = { ...m }; for (const { ref, data } of results) nm[ref] = data; return nm; });
|
|
437
|
+
setTestAllProvider('');
|
|
438
|
+
});
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const load = React.useCallback(() => {
|
|
442
|
+
setState((s) => ({ ...s, status: 'loading', error: '' }));
|
|
443
|
+
fetch(CONFIG_PATH, { headers: { accept: 'application/json' } })
|
|
444
|
+
.then((r) => r.json())
|
|
445
|
+
.then((data) => {
|
|
446
|
+
setState({
|
|
447
|
+
status: 'ready',
|
|
448
|
+
value: data.value ?? null,
|
|
449
|
+
revision: data.revision ?? 0,
|
|
450
|
+
providers: Array.isArray(data.providers) ? data.providers : [],
|
|
451
|
+
error: data.error ? data.error.message : '',
|
|
452
|
+
});
|
|
453
|
+
setDraft(null);
|
|
454
|
+
})
|
|
455
|
+
.catch((e) => setState((s) => ({ ...s, status: 'error', error: String(e) })));
|
|
456
|
+
}, []);
|
|
457
|
+
|
|
458
|
+
React.useEffect(() => { load(); }, [load]);
|
|
459
|
+
|
|
460
|
+
const val = draft ?? state.value;
|
|
461
|
+
const status = useRotationStatus();
|
|
462
|
+
const probeCache = useProbeCache();
|
|
463
|
+
const [resetting, setResetting] = React.useState('');
|
|
464
|
+
const doReset = (providerId) => {
|
|
465
|
+
setResetting(providerId);
|
|
466
|
+
setSecretError('');
|
|
467
|
+
const provEntry = val?.providers?.find((p) => p.provider === providerId);
|
|
468
|
+
if (provEntry && Array.isArray(provEntry.keys)) {
|
|
469
|
+
setOptimisticReset((cur) => {
|
|
470
|
+
const next = { ...cur };
|
|
471
|
+
provEntry.keys.forEach((k) => { next[k] = true; });
|
|
472
|
+
return next;
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
fetch('/dsh-key-rotation/reset', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: providerId }) })
|
|
476
|
+
.then((r) => r.json().then((data) => ({ ok: r.ok, data })))
|
|
477
|
+
.then(({ ok, data }) => { if (!ok) throw new Error(data?.error?.message ?? 'unknown error'); })
|
|
478
|
+
.catch((e) => {
|
|
479
|
+
setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e)));
|
|
480
|
+
if (provEntry && Array.isArray(provEntry.keys)) {
|
|
481
|
+
setOptimisticReset((cur) => {
|
|
482
|
+
const next = { ...cur };
|
|
483
|
+
provEntry.keys.forEach((k) => { delete next[k]; });
|
|
484
|
+
return next;
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
})
|
|
488
|
+
.finally(() => setResetting(''));
|
|
489
|
+
};
|
|
490
|
+
// #223: re-test a broken key; a successful live probe lifts the 30-day broken quarantine
|
|
491
|
+
const retestBroken = (ref) => {
|
|
492
|
+
setSecretError('');
|
|
493
|
+
fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref, probe: 'models' }) })
|
|
494
|
+
.then((r) => r.json())
|
|
495
|
+
.then((data) => {
|
|
496
|
+
if (data && data.ok) {
|
|
497
|
+
return fetch('/dsh-key-rotation/reset', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref }) });
|
|
498
|
+
}
|
|
499
|
+
setSecretError(t('retestFail'));
|
|
500
|
+
return null;
|
|
501
|
+
})
|
|
502
|
+
.catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))));
|
|
503
|
+
};
|
|
401
504
|
const keyInfo = (providerId, ref) => {
|
|
402
|
-
const entryStatus = status[providerId];
|
|
403
|
-
if (!entryStatus || !ref) return null;
|
|
404
|
-
return (entryStatus.keys ?? []).find((k) => k.ref === ref) ?? null;
|
|
405
|
-
};
|
|
406
|
-
|
|
407
|
-
const [validating, setValidating] = React.useState('');
|
|
408
|
-
const [validationResult, setValidationResult] = React.useState({});
|
|
409
|
-
const validateBeforeSave = (ref, value) => {
|
|
410
|
-
setValidating(ref);
|
|
411
|
-
return fetch('/dsh-key-rotation/test', {
|
|
412
|
-
method: 'POST',
|
|
413
|
-
headers: { 'content-type': 'application/json' },
|
|
414
|
-
body: JSON.stringify({ ref, value }),
|
|
415
|
-
})
|
|
416
|
-
.then((r) => r.json())
|
|
417
|
-
.then((data) => { setValidationResult((m) => ({ ...m, [ref]: data })); return data; })
|
|
418
|
-
.catch(() => null)
|
|
419
|
-
.finally(() => setValidating(''));
|
|
420
|
-
};
|
|
421
|
-
const saveSecret = async (ref, rowKey) => {
|
|
422
|
-
const value = secretDraft[rowKey];
|
|
423
|
-
if (!value) return;
|
|
424
|
-
setSecretError('');
|
|
425
|
-
// Pre-save validation (issue #118)
|
|
426
|
-
setValidating(ref);
|
|
427
|
-
const vres = await validateBeforeSave(ref, value);
|
|
428
|
-
setValidating('');
|
|
429
|
-
if (vres && vres.ok === false && vres.code === 'no-credential') {
|
|
430
|
-
// No credential yet is fine for a new key being saved
|
|
431
|
-
} else if (vres && !vres.ok) {
|
|
432
|
-
setSecretError(t('keyWriteFailed').replace('{msg}', vres.message || 'validation failed'));
|
|
433
|
-
return;
|
|
434
|
-
}
|
|
435
|
-
fetch('/dsh-key-rotation/key', {
|
|
436
|
-
method: 'PUT',
|
|
437
|
-
headers: { 'content-type': 'application/json' },
|
|
438
|
-
body: JSON.stringify({ ref, value }),
|
|
439
|
-
})
|
|
440
|
-
.then((r) => r.json().then((data) => ({ ok: r.ok, data })))
|
|
441
|
-
.then(({ ok, data }) => {
|
|
442
|
-
if (!ok) throw new Error(data?.error?.message ?? 'unknown error');
|
|
443
|
-
// #200 leak-detector hint: stored value does not match any known
|
|
444
|
-
// API-key shape - probably a placeholder or a typo.
|
|
445
|
-
if (data?.looksLikeSecret === false) {
|
|
446
|
-
setSecretError(t('notSecretShape'));
|
|
447
|
-
}
|
|
448
|
-
setSecretDraft((cur) => {
|
|
449
|
-
const next = { ...cur };
|
|
450
|
-
delete next[rowKey];
|
|
451
|
-
return next;
|
|
452
|
-
});
|
|
453
|
-
})
|
|
454
|
-
.catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))));
|
|
455
|
-
};
|
|
456
|
-
if (state.status === 'loading' || !val) {
|
|
457
|
-
return React.createElement('p', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 13 } }, t('loading'));
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
const providers = state.providers;
|
|
461
|
-
const providerById = new Map(providers.map((p) => [p.id, p.name]));
|
|
462
|
-
|
|
463
|
-
const setField = (fn) => setDraft(fn(val));
|
|
464
|
-
const providerList = Array.isArray(val.providers) ? val.providers.filter((p) => Array.isArray(p.keys) && p.keys.length > 0).filter((p) => !search || p.provider.toLowerCase().includes(search.toLowerCase())) : [];
|
|
465
|
-
|
|
466
|
-
const setProvider = (index, id) => setField((cur) => {
|
|
467
|
-
const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
|
|
468
|
-
next[index] = { ...next[index], provider: id };
|
|
469
|
-
return { ...cur, providers: next };
|
|
470
|
-
});
|
|
471
|
-
const addKey = (pIndex) => setField((cur) => {
|
|
472
|
-
const providers = [...(cur.providers ?? [])];
|
|
473
|
-
const entry = { ...(providers[pIndex] ?? {}) };
|
|
474
|
-
const allRefs = providers.flatMap((prov) => prov?.keys ?? []);
|
|
475
|
-
entry.keys = [...(entry.keys ?? []), nextKeyRef(entry.provider, entry.keys, allRefs)];
|
|
476
|
-
// keep weights aligned with keys (#215): new key gets default weight 1
|
|
477
|
-
if (Array.isArray(entry.weights) && entry.weights.length > 0) entry.weights = [...entry.weights, 1];
|
|
478
|
-
providers[pIndex] = entry;
|
|
479
|
-
return { ...cur, providers };
|
|
480
|
-
});
|
|
481
|
-
const removeKey = (pIndex, kIndex) => { setField((cur) => {
|
|
482
|
-
const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
|
|
483
|
-
stashUndo({ type: 'key', index: pIndex, kIndex, key: next[pIndex]?.keys?.[kIndex] });
|
|
484
|
-
next[pIndex] = { ...next[pIndex], keys: (next[pIndex].keys ?? []).filter((_, i) => i !== kIndex) };
|
|
485
|
-
// weights are positional - drop along with the key (#215)
|
|
486
|
-
if (Array.isArray(next[pIndex].weights) && next[pIndex].weights.length > 0) {
|
|
487
|
-
next[pIndex] = { ...next[pIndex], weights: next[pIndex].weights.filter((_, i) => i !== kIndex) };
|
|
488
|
-
}
|
|
489
|
-
return { ...cur, providers: next };
|
|
490
|
-
}); };
|
|
491
|
-
const removeProvider = (pIndex) => setField((cur) => {
|
|
492
|
-
const arr = Array.isArray(cur.providers) ? cur.providers : [];
|
|
493
|
-
stashUndo({ type: 'provider', index: pIndex, entry: arr[pIndex] });
|
|
494
|
-
return { ...cur, providers: arr.filter((_, i) => i !== pIndex) };
|
|
495
|
-
});
|
|
496
|
-
// Порядок ключей = порядок попыток, поэтому его надо менять кнопками,
|
|
497
|
-
// а не перепечатыванием имён.
|
|
498
|
-
const moveKey = (pIndex, kIndex, delta) => setField((cur) => {
|
|
499
|
-
const providers = [...(cur.providers ?? [])];
|
|
500
|
-
const entry = { ...(providers[pIndex] ?? {}) };
|
|
501
|
-
const keys = [...(entry.keys ?? [])];
|
|
502
|
-
const target = kIndex + delta;
|
|
503
|
-
if (target < 0 || target >= keys.length) return cur;
|
|
504
|
-
const moved = keys[kIndex];
|
|
505
|
-
keys[kIndex] = keys[target];
|
|
506
|
-
keys[target] = moved;
|
|
507
|
-
entry.keys = keys;
|
|
508
|
-
// weights are positional - swap along with the keys (#215)
|
|
509
|
-
const weights = [...(entry.weights ?? [])];
|
|
510
|
-
if (weights.length > 0) {
|
|
511
|
-
const w = weights[kIndex];
|
|
512
|
-
weights[kIndex] = weights[target];
|
|
513
|
-
weights[target] = w;
|
|
514
|
-
entry.weights = weights;
|
|
515
|
-
}
|
|
516
|
-
providers[pIndex] = entry;
|
|
517
|
-
return { ...cur, providers };
|
|
518
|
-
});
|
|
519
|
-
// #215: set a single key's round-robin weight (integer >= 1)
|
|
520
|
-
const setKeyWeight = (pIndex, kIndex, weight) => setField((cur) => {
|
|
521
|
-
const n = Math.max(1, Math.min(1000, Math.floor(Number(weight) || 1)));
|
|
522
|
-
const providers = [...(cur.providers ?? [])];
|
|
523
|
-
const entry = { ...(providers[pIndex] ?? {}) };
|
|
524
|
-
const weights = [...(entry.weights ?? [])];
|
|
525
|
-
while (weights.length < (entry.keys ?? []).length) weights.push(1);
|
|
526
|
-
weights[kIndex] = n;
|
|
527
|
-
entry.weights = weights;
|
|
528
|
-
providers[pIndex] = entry;
|
|
529
|
-
return { ...cur, providers };
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
// Коды из конфига, которых нет в известном списке, показываем тоже:
|
|
533
|
-
// иначе галочки молча выбросили бы чужое правило при первом сохранении.
|
|
534
|
-
const selectedCodes = new Set(Array.isArray(val.switchCodes) ? val.switchCodes : []);
|
|
535
|
-
const codeList = [...KNOWN_CODES, ...[...selectedCodes].filter((c) => !KNOWN_CODES.includes(c))];
|
|
536
|
-
const toggleCode = (code, on) => setField((cur) => {
|
|
537
|
-
const current = new Set(Array.isArray(cur.switchCodes) ? cur.switchCodes : []);
|
|
538
|
-
if (on) current.add(code); else current.delete(code);
|
|
539
|
-
return { ...cur, switchCodes: codeList.filter((c) => current.has(c)) };
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
const addProvider = () => setField((cur) => ({
|
|
543
|
-
...cur,
|
|
544
|
-
providers: [...(Array.isArray(cur.providers) ? cur.providers : []), { provider: '', keys: [''] }],
|
|
545
|
-
}));
|
|
546
|
-
|
|
547
|
-
const save = () => {
|
|
548
|
-
if (!draft) return;
|
|
549
|
-
setState((s) => ({ ...s, status: 'saving', error: '' }));
|
|
550
|
-
fetch(CONFIG_PATH, {
|
|
551
|
-
method: 'PUT',
|
|
552
|
-
headers: { 'content-type': 'application/json' },
|
|
553
|
-
body: JSON.stringify({ section: draft, expectedRevision: state.revision }),
|
|
554
|
-
})
|
|
555
|
-
.then((r) => r.json())
|
|
556
|
-
.then((data) => {
|
|
557
|
-
if (data.error) {
|
|
558
|
-
setState((s) => ({ ...s, status: 'error', error: data.error.message }));
|
|
559
|
-
return;
|
|
560
|
-
}
|
|
561
|
-
setState((s) => ({ status: 'ready', value: data.value ?? draft, revision: data.revision ?? s.revision, error: '', providers: s.providers }));
|
|
562
|
-
setDraft(null);
|
|
563
|
-
})
|
|
564
|
-
.catch((e) => setState((s) => ({ ...s, status: 'error', error: String(e) })));
|
|
565
|
-
};
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
const field = (labelText, node) => h('label', { className: 'krot-field' },
|
|
570
|
-
h('span', { className: 'krot-label' }, labelText), node);
|
|
571
|
-
|
|
572
|
-
const textInput = (value, onChange, placeholder) => h('input', {
|
|
573
|
-
className: 'krot-in',
|
|
574
|
-
value: value ?? '',
|
|
575
|
-
onChange: (e) => onChange(e.target.value),
|
|
576
|
-
placeholder,
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
const btn = (labelText, onClick, opts) => h('button', {
|
|
580
|
-
className: 'krot-btn' + (opts && opts.primary ? ' krot-save' : ''),
|
|
581
|
-
onClick,
|
|
582
|
-
disabled: Boolean(opts && opts.disabled),
|
|
583
|
-
title: (opts && opts.title) || undefined,
|
|
584
|
-
}, labelText);
|
|
585
|
-
|
|
586
|
-
// Точка состояния ключа: цвет и подпись читаются с одного взгляда,
|
|
587
|
-
// а «ключ не найден» ловит опечатку в имени env, которая иначе молчит.
|
|
588
|
-
const keyStatus = (providerId, ref) => {
|
|
589
|
-
const hit = keyInfo(providerId, ref);
|
|
590
|
-
if (!hit) return null;
|
|
591
|
-
if (hit.expired) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyExpired') };
|
|
592
|
-
if (hit.expiresAt && !hit.expired) {
|
|
593
|
-
const days = Math.ceil((hit.expiresAt - Date.now()) / 86400000);
|
|
594
|
-
const warnDays = Number(val?.expiryWarnDays) || 7; // #207: configurable horizon
|
|
595
|
-
if (days <= warnDays) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyExpiringSoon').replace('{n}', String(days)) };
|
|
596
|
-
}
|
|
597
|
-
if (hit.broken) return { color: 'var(--dsw-alias-state-error-primary)', text: t('brokenKey') };
|
|
598
|
-
if (!hit.present) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyMissing') };
|
|
599
|
-
if (hit.cooldownMsLeft > 0) {
|
|
600
|
-
return {
|
|
601
|
-
color: 'var(--dsw-alias-state-warning-primary)',
|
|
602
|
-
text: t('keyCooling').replace('{s}', String(Math.ceil(hit.cooldownMsLeft / 1000))),
|
|
603
|
-
};
|
|
604
|
-
}
|
|
605
|
-
if (hit.active) return { color: 'var(--dsw-alias-state-success-primary)', text: t('keyActive') };
|
|
606
|
-
return { color: 'var(--dsw-alias-label-tertiary)', text: t('keyReady') };
|
|
607
|
-
};
|
|
608
|
-
|
|
609
|
-
const searchInput = h('input', { className: 'krot-in', placeholder: 'Search providers…', value: search, onChange: (e) => setSearch(e.target.value), style: { marginBottom: '8px' } });
|
|
610
|
-
const providerRows = providerList.map((entry, pIndex) => {
|
|
611
|
-
const options = [];
|
|
612
|
-
if (entry.provider && !providerById.has(entry.provider)) {
|
|
613
|
-
options.push(h('option', { key: entry.provider, value: entry.provider }, entry.provider + ' (' + t('notRegistered') + ')'));
|
|
614
|
-
}
|
|
615
|
-
options.push(...providers.map((prov) =>
|
|
616
|
-
h('option', { key: prov.id, value: prov.id }, prov.name + (prov.id !== prov.name ? ' — ' + prov.id : ''))));
|
|
617
|
-
|
|
618
|
-
const keys = entry.keys ?? [];
|
|
619
|
-
const entryWeights = entry.weights ?? [];
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
const
|
|
624
|
-
const
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
}
|
|
700
|
-
meta.push(h('
|
|
701
|
-
// #
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
)
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
:
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
const
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
})
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
const
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
})
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
{ name: 'settings.section', id: 'dsh-key-rotation', order: 20, label: () => 'Key Rotation' },
|
|
1056
|
-
(props) => h(KeyRotationSection, { ...props, locale: useLocale() }),
|
|
1057
|
-
));
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
module.exports = { apply, inject: ['slots', 'locale'] };
|
|
1062
|
-
return module.exports;
|
|
1063
|
-
},
|
|
1064
|
-
});
|
|
505
|
+
const entryStatus = status[providerId];
|
|
506
|
+
if (!entryStatus || !ref) return null;
|
|
507
|
+
return (entryStatus.keys ?? []).find((k) => k.ref === ref) ?? null;
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const [validating, setValidating] = React.useState('');
|
|
511
|
+
const [validationResult, setValidationResult] = React.useState({});
|
|
512
|
+
const validateBeforeSave = (ref, value) => {
|
|
513
|
+
setValidating(ref);
|
|
514
|
+
return fetch('/dsh-key-rotation/test', {
|
|
515
|
+
method: 'POST',
|
|
516
|
+
headers: { 'content-type': 'application/json' },
|
|
517
|
+
body: JSON.stringify({ ref, value }),
|
|
518
|
+
})
|
|
519
|
+
.then((r) => r.json())
|
|
520
|
+
.then((data) => { setValidationResult((m) => ({ ...m, [ref]: data })); return data; })
|
|
521
|
+
.catch(() => null)
|
|
522
|
+
.finally(() => setValidating(''));
|
|
523
|
+
};
|
|
524
|
+
const saveSecret = async (ref, rowKey) => {
|
|
525
|
+
const value = secretDraft[rowKey];
|
|
526
|
+
if (!value) return;
|
|
527
|
+
setSecretError('');
|
|
528
|
+
// Pre-save validation (issue #118)
|
|
529
|
+
setValidating(ref);
|
|
530
|
+
const vres = await validateBeforeSave(ref, value);
|
|
531
|
+
setValidating('');
|
|
532
|
+
if (vres && vres.ok === false && vres.code === 'no-credential') {
|
|
533
|
+
// No credential yet is fine for a new key being saved
|
|
534
|
+
} else if (vres && !vres.ok) {
|
|
535
|
+
setSecretError(t('keyWriteFailed').replace('{msg}', vres.message || 'validation failed'));
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
fetch('/dsh-key-rotation/key', {
|
|
539
|
+
method: 'PUT',
|
|
540
|
+
headers: { 'content-type': 'application/json' },
|
|
541
|
+
body: JSON.stringify({ ref, value }),
|
|
542
|
+
})
|
|
543
|
+
.then((r) => r.json().then((data) => ({ ok: r.ok, data })))
|
|
544
|
+
.then(({ ok, data }) => {
|
|
545
|
+
if (!ok) throw new Error(data?.error?.message ?? 'unknown error');
|
|
546
|
+
// #200 leak-detector hint: stored value does not match any known
|
|
547
|
+
// API-key shape - probably a placeholder or a typo.
|
|
548
|
+
if (data?.looksLikeSecret === false) {
|
|
549
|
+
setSecretError(t('notSecretShape'));
|
|
550
|
+
}
|
|
551
|
+
setSecretDraft((cur) => {
|
|
552
|
+
const next = { ...cur };
|
|
553
|
+
delete next[rowKey];
|
|
554
|
+
return next;
|
|
555
|
+
});
|
|
556
|
+
})
|
|
557
|
+
.catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))));
|
|
558
|
+
};
|
|
559
|
+
if (state.status === 'loading' || !val) {
|
|
560
|
+
return React.createElement('p', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 13 } }, t('loading'));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const providers = state.providers;
|
|
564
|
+
const providerById = new Map(providers.map((p) => [p.id, p.name]));
|
|
565
|
+
|
|
566
|
+
const setField = (fn) => setDraft(fn(val));
|
|
567
|
+
const providerList = Array.isArray(val.providers) ? val.providers.filter((p) => Array.isArray(p.keys) && p.keys.length > 0).filter((p) => !search || p.provider.toLowerCase().includes(search.toLowerCase())) : [];
|
|
568
|
+
|
|
569
|
+
const setProvider = (index, id) => setField((cur) => {
|
|
570
|
+
const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
|
|
571
|
+
next[index] = { ...next[index], provider: id };
|
|
572
|
+
return { ...cur, providers: next };
|
|
573
|
+
});
|
|
574
|
+
const addKey = (pIndex) => setField((cur) => {
|
|
575
|
+
const providers = [...(cur.providers ?? [])];
|
|
576
|
+
const entry = { ...(providers[pIndex] ?? {}) };
|
|
577
|
+
const allRefs = providers.flatMap((prov) => prov?.keys ?? []);
|
|
578
|
+
entry.keys = [...(entry.keys ?? []), nextKeyRef(entry.provider, entry.keys, allRefs)];
|
|
579
|
+
// keep weights aligned with keys (#215): new key gets default weight 1
|
|
580
|
+
if (Array.isArray(entry.weights) && entry.weights.length > 0) entry.weights = [...entry.weights, 1];
|
|
581
|
+
providers[pIndex] = entry;
|
|
582
|
+
return { ...cur, providers };
|
|
583
|
+
});
|
|
584
|
+
const removeKey = (pIndex, kIndex) => { setField((cur) => {
|
|
585
|
+
const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
|
|
586
|
+
stashUndo({ type: 'key', index: pIndex, kIndex, key: next[pIndex]?.keys?.[kIndex] });
|
|
587
|
+
next[pIndex] = { ...next[pIndex], keys: (next[pIndex].keys ?? []).filter((_, i) => i !== kIndex) };
|
|
588
|
+
// weights are positional - drop along with the key (#215)
|
|
589
|
+
if (Array.isArray(next[pIndex].weights) && next[pIndex].weights.length > 0) {
|
|
590
|
+
next[pIndex] = { ...next[pIndex], weights: next[pIndex].weights.filter((_, i) => i !== kIndex) };
|
|
591
|
+
}
|
|
592
|
+
return { ...cur, providers: next };
|
|
593
|
+
}); };
|
|
594
|
+
const removeProvider = (pIndex) => setField((cur) => {
|
|
595
|
+
const arr = Array.isArray(cur.providers) ? cur.providers : [];
|
|
596
|
+
stashUndo({ type: 'provider', index: pIndex, entry: arr[pIndex] });
|
|
597
|
+
return { ...cur, providers: arr.filter((_, i) => i !== pIndex) };
|
|
598
|
+
});
|
|
599
|
+
// Порядок ключей = порядок попыток, поэтому его надо менять кнопками,
|
|
600
|
+
// а не перепечатыванием имён.
|
|
601
|
+
const moveKey = (pIndex, kIndex, delta) => setField((cur) => {
|
|
602
|
+
const providers = [...(cur.providers ?? [])];
|
|
603
|
+
const entry = { ...(providers[pIndex] ?? {}) };
|
|
604
|
+
const keys = [...(entry.keys ?? [])];
|
|
605
|
+
const target = kIndex + delta;
|
|
606
|
+
if (target < 0 || target >= keys.length) return cur;
|
|
607
|
+
const moved = keys[kIndex];
|
|
608
|
+
keys[kIndex] = keys[target];
|
|
609
|
+
keys[target] = moved;
|
|
610
|
+
entry.keys = keys;
|
|
611
|
+
// weights are positional - swap along with the keys (#215)
|
|
612
|
+
const weights = [...(entry.weights ?? [])];
|
|
613
|
+
if (weights.length > 0) {
|
|
614
|
+
const w = weights[kIndex];
|
|
615
|
+
weights[kIndex] = weights[target];
|
|
616
|
+
weights[target] = w;
|
|
617
|
+
entry.weights = weights;
|
|
618
|
+
}
|
|
619
|
+
providers[pIndex] = entry;
|
|
620
|
+
return { ...cur, providers };
|
|
621
|
+
});
|
|
622
|
+
// #215: set a single key's round-robin weight (integer >= 1)
|
|
623
|
+
const setKeyWeight = (pIndex, kIndex, weight) => setField((cur) => {
|
|
624
|
+
const n = Math.max(1, Math.min(1000, Math.floor(Number(weight) || 1)));
|
|
625
|
+
const providers = [...(cur.providers ?? [])];
|
|
626
|
+
const entry = { ...(providers[pIndex] ?? {}) };
|
|
627
|
+
const weights = [...(entry.weights ?? [])];
|
|
628
|
+
while (weights.length < (entry.keys ?? []).length) weights.push(1);
|
|
629
|
+
weights[kIndex] = n;
|
|
630
|
+
entry.weights = weights;
|
|
631
|
+
providers[pIndex] = entry;
|
|
632
|
+
return { ...cur, providers };
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// Коды из конфига, которых нет в известном списке, показываем тоже:
|
|
636
|
+
// иначе галочки молча выбросили бы чужое правило при первом сохранении.
|
|
637
|
+
const selectedCodes = new Set(Array.isArray(val.switchCodes) ? val.switchCodes : []);
|
|
638
|
+
const codeList = [...KNOWN_CODES, ...[...selectedCodes].filter((c) => !KNOWN_CODES.includes(c))];
|
|
639
|
+
const toggleCode = (code, on) => setField((cur) => {
|
|
640
|
+
const current = new Set(Array.isArray(cur.switchCodes) ? cur.switchCodes : []);
|
|
641
|
+
if (on) current.add(code); else current.delete(code);
|
|
642
|
+
return { ...cur, switchCodes: codeList.filter((c) => current.has(c)) };
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
const addProvider = () => setField((cur) => ({
|
|
646
|
+
...cur,
|
|
647
|
+
providers: [...(Array.isArray(cur.providers) ? cur.providers : []), { provider: '', keys: [''] }],
|
|
648
|
+
}));
|
|
649
|
+
|
|
650
|
+
const save = () => {
|
|
651
|
+
if (!draft) return;
|
|
652
|
+
setState((s) => ({ ...s, status: 'saving', error: '' }));
|
|
653
|
+
fetch(CONFIG_PATH, {
|
|
654
|
+
method: 'PUT',
|
|
655
|
+
headers: { 'content-type': 'application/json' },
|
|
656
|
+
body: JSON.stringify({ section: draft, expectedRevision: state.revision }),
|
|
657
|
+
})
|
|
658
|
+
.then((r) => r.json())
|
|
659
|
+
.then((data) => {
|
|
660
|
+
if (data.error) {
|
|
661
|
+
setState((s) => ({ ...s, status: 'error', error: data.error.message }));
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
setState((s) => ({ status: 'ready', value: data.value ?? draft, revision: data.revision ?? s.revision, error: '', providers: s.providers }));
|
|
665
|
+
setDraft(null);
|
|
666
|
+
})
|
|
667
|
+
.catch((e) => setState((s) => ({ ...s, status: 'error', error: String(e) })));
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
const field = (labelText, node) => h('label', { className: 'krot-field' },
|
|
673
|
+
h('span', { className: 'krot-label' }, labelText), node);
|
|
674
|
+
|
|
675
|
+
const textInput = (value, onChange, placeholder) => h('input', {
|
|
676
|
+
className: 'krot-in',
|
|
677
|
+
value: value ?? '',
|
|
678
|
+
onChange: (e) => onChange(e.target.value),
|
|
679
|
+
placeholder,
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
const btn = (labelText, onClick, opts) => h('button', {
|
|
683
|
+
className: 'krot-btn' + (opts && opts.primary ? ' krot-save' : ''),
|
|
684
|
+
onClick,
|
|
685
|
+
disabled: Boolean(opts && opts.disabled),
|
|
686
|
+
title: (opts && opts.title) || undefined,
|
|
687
|
+
}, labelText);
|
|
688
|
+
|
|
689
|
+
// Точка состояния ключа: цвет и подпись читаются с одного взгляда,
|
|
690
|
+
// а «ключ не найден» ловит опечатку в имени env, которая иначе молчит.
|
|
691
|
+
const keyStatus = (providerId, ref) => {
|
|
692
|
+
const hit = keyInfo(providerId, ref);
|
|
693
|
+
if (!hit) return null;
|
|
694
|
+
if (hit.expired) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyExpired') };
|
|
695
|
+
if (hit.expiresAt && !hit.expired) {
|
|
696
|
+
const days = Math.ceil((hit.expiresAt - Date.now()) / 86400000);
|
|
697
|
+
const warnDays = Number(val?.expiryWarnDays) || 7; // #207: configurable horizon
|
|
698
|
+
if (days <= warnDays) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyExpiringSoon').replace('{n}', String(days)) };
|
|
699
|
+
}
|
|
700
|
+
if (hit.broken) return { color: 'var(--dsw-alias-state-error-primary)', text: t('brokenKey') };
|
|
701
|
+
if (!hit.present) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyMissing') };
|
|
702
|
+
if (hit.cooldownMsLeft > 0 && !optimisticReset[ref]) {
|
|
703
|
+
return {
|
|
704
|
+
color: 'var(--dsw-alias-state-warning-primary)',
|
|
705
|
+
text: t('keyCooling').replace('{s}', String(Math.ceil(hit.cooldownMsLeft / 1000))),
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
if (hit.active) return { color: 'var(--dsw-alias-state-success-primary)', text: t('keyActive') };
|
|
709
|
+
return { color: 'var(--dsw-alias-label-tertiary)', text: t('keyReady') };
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
const searchInput = h('input', { className: 'krot-in', placeholder: 'Search providers…', value: search, onChange: (e) => setSearch(e.target.value), style: { marginBottom: '8px' } });
|
|
713
|
+
const providerRows = providerList.map((entry, pIndex) => {
|
|
714
|
+
const options = [];
|
|
715
|
+
if (entry.provider && !providerById.has(entry.provider)) {
|
|
716
|
+
options.push(h('option', { key: entry.provider, value: entry.provider }, entry.provider + ' (' + t('notRegistered') + ')'));
|
|
717
|
+
}
|
|
718
|
+
options.push(...providers.map((prov) =>
|
|
719
|
+
h('option', { key: prov.id, value: prov.id }, prov.name + (prov.id !== prov.name ? ' — ' + prov.id : ''))));
|
|
720
|
+
|
|
721
|
+
const keys = entry.keys ?? [];
|
|
722
|
+
const entryWeights = entry.weights ?? [];
|
|
723
|
+
|
|
724
|
+
let readyCount = 0, cooldownCount = 0, errorCount = 0;
|
|
725
|
+
for (const k of keys) {
|
|
726
|
+
const st = keyStatus(entry.provider, k);
|
|
727
|
+
const isCool = (st && (st.text === t('brokenKey') || (st.text && st.text.includes(t('keyCooling').slice(0, 4))))) && !optimisticReset[k];
|
|
728
|
+
const tr = testResult[k];
|
|
729
|
+
if (tr && !tr.ok) errorCount++;
|
|
730
|
+
if (isCool) cooldownCount++;
|
|
731
|
+
else readyCount++;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const filteredIndices = keys.map((k, idx) => ({ key: k, kIndex: idx })).filter(({ key: k }) => {
|
|
735
|
+
if (statusFilter === 'all') return true;
|
|
736
|
+
const st = keyStatus(entry.provider, k);
|
|
737
|
+
const isCool = (st && (st.text === t('brokenKey') || (st.text && st.text.includes(t('keyCooling').slice(0, 4))))) && !optimisticReset[k];
|
|
738
|
+
const tr = testResult[k];
|
|
739
|
+
if (statusFilter === 'ready') return !isCool;
|
|
740
|
+
if (statusFilter === 'cooldown') return isCool;
|
|
741
|
+
if (statusFilter === 'error') return tr && !tr.ok;
|
|
742
|
+
return true;
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
const filterBar = keys.length > 1 ? h('div', { className: 'krot-filter-bar' },
|
|
746
|
+
h('button', { type: 'button', className: 'krot-pill' + (statusFilter === 'all' ? ' krot-pill-active' : ''), onClick: () => setStatusFilter('all') }, (t('filterAll') || 'Все') + ' (' + keys.length + ')'),
|
|
747
|
+
h('button', { type: 'button', className: 'krot-pill' + (statusFilter === 'ready' ? ' krot-pill-active' : ''), onClick: () => setStatusFilter('ready') }, (t('filterReady') || 'Готовы') + ' (' + readyCount + ')'),
|
|
748
|
+
cooldownCount > 0 ? h('button', { type: 'button', className: 'krot-pill krot-pill-warn' + (statusFilter === 'cooldown' ? ' krot-pill-active' : ''), onClick: () => setStatusFilter('cooldown') }, (t('filterCooldown') || 'В кулдауне') + ' (' + cooldownCount + ')') : null,
|
|
749
|
+
errorCount > 0 ? h('button', { type: 'button', className: 'krot-pill krot-pill-err' + (statusFilter === 'error' ? ' krot-pill-active' : ''), onClick: () => setStatusFilter('error') }, (t('filterErrors') || 'С ошибками') + ' (' + errorCount + ')') : null,
|
|
750
|
+
) : null;
|
|
751
|
+
|
|
752
|
+
const keyRows = filteredIndices.map(({ key, kIndex }) => {
|
|
753
|
+
const st = keyStatus(entry.provider, key);
|
|
754
|
+
const info = keyInfo(entry.provider, key);
|
|
755
|
+
const rowKey = entry.provider + '/' + kIndex;
|
|
756
|
+
const typed = secretDraft[rowKey];
|
|
757
|
+
const fromEnv = Boolean(info && info.source === 'env');
|
|
758
|
+
|
|
759
|
+
// Имя ключа занимает свою строку целиком: раньше оно обрезалось и
|
|
760
|
+
// соседние ключи выглядели одинаково.
|
|
761
|
+
const nameRow = [
|
|
762
|
+
h('span', { className: 'krot-num', key: 'n' }, String(kIndex + 1)),
|
|
763
|
+
h('span', { key: 'i', className: 'krot-name', title: key + ' (click to copy)', style: { cursor: 'copy' }, onClick: () => {
|
|
764
|
+
if (navigator.clipboard) navigator.clipboard.writeText(key).then(() => setSecretDraft((cur) => ({ ...cur, ['copied:' + key]: true }))).catch(() => {});
|
|
765
|
+
setTimeout(() => setSecretDraft((cur) => ({ ...cur, ['copied:' + key]: false })), 1500);
|
|
766
|
+
} },
|
|
767
|
+
t('keyLabel').replace('{n}', String(kIndex + 1)),
|
|
768
|
+
h('span', null, secretDraft['copied:' + key] ? ' ✓' : '')),
|
|
769
|
+
];
|
|
770
|
+
|
|
771
|
+
const meta = [
|
|
772
|
+
h('span', { key: 'd', className: 'krot-dot', style: { background: st ? st.color : 'var(--dsw-alias-border-l2)' } }),
|
|
773
|
+
h('span', { key: 's', className: 'krot-state' }, st ? st.text : ''),
|
|
774
|
+
];
|
|
775
|
+
if (fromEnv) {
|
|
776
|
+
meta.push(h('span', { key: 'v', className: 'krot-tail', title: t('keyFromEnv') },
|
|
777
|
+
info.tail ? '••••' + info.tail : t('keyFromEnv')));
|
|
778
|
+
} else {
|
|
779
|
+
meta.push(h('input', {
|
|
780
|
+
key: 'v',
|
|
781
|
+
type: 'password',
|
|
782
|
+
className: 'krot-in krot-secret',
|
|
783
|
+
value: typed ?? '',
|
|
784
|
+
placeholder: info && info.tail ? '••••' + info.tail : t('keyValuePlaceholder'),
|
|
785
|
+
onChange: (e) => setSecretDraft((cur) => ({ ...cur, [rowKey]: e.target.value })),
|
|
786
|
+
}));
|
|
787
|
+
if (typed) meta.push(btn('✓', () => saveSecret(key, rowKey), { title: t('keySave'), key: 'w' }));
|
|
788
|
+
}
|
|
789
|
+
if (info && typeof info.usage === 'number' && info.usage > 0) {
|
|
790
|
+
let tip = 'requests through this key';
|
|
791
|
+
if (info.byModel && Object.keys(info.byModel).length > 0) {
|
|
792
|
+
tip = Object.entries(info.byModel).map(([m, c]) => m + ': ' + c).join('\n');
|
|
793
|
+
}
|
|
794
|
+
meta.push(h('span', { key: 'u', className: 'krot-tail', title: tip }, String(info.usage)));
|
|
795
|
+
if (info.usageDays && Object.keys(info.usageDays).length > 0) {
|
|
796
|
+
const days = Object.entries(info.usageDays);
|
|
797
|
+
const max = Math.max(1, ...days.map(([, c]) => c));
|
|
798
|
+
meta.push(h('span', { key: 'g', className: 'krot-graph', title: days.map(([d, c]) => d + ': ' + c).join('\n'), style: { display: 'inline-flex', gap: '1px', alignItems: 'flex-end', height: '12px' } },
|
|
799
|
+
days.slice(-14).map(([d, c]) => h('span', { key: d, style: { width: '3px', height: Math.max(2, (c / max) * 12) + 'px', background: 'var(--dsw-alias-state-success-primary)', borderRadius: '1px' } }))
|
|
800
|
+
));
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
if (info && info.lastUsedAt) meta.push(h('span', { key: 'lu', className: 'krot-tail', title: 'last used' }, formatAgo((k)=>t(k), info.lastUsedAt)));
|
|
804
|
+
// #215: per-key weight input (default 1)
|
|
805
|
+
meta.push(h('input', { key: 'w', type: 'number', min: 1, max: 1000, className: 'krot-in krot-weight',
|
|
806
|
+
value: (entryWeights[kIndex] ?? info?.weight ?? 1),
|
|
807
|
+
title: t('weightHint'),
|
|
808
|
+
onChange: (e) => setKeyWeight(pIndex, kIndex, e.target.value),
|
|
809
|
+
style: { width: '52px', padding: '2px 6px', fontSize: '12px' } }));
|
|
810
|
+
// #210: RPM capacity indicator (only when rpmLimit is active)
|
|
811
|
+
if (info && info.rpm) meta.push(h('span', { key: 'rpm', className: 'krot-tail',
|
|
812
|
+
title: t('rpmTitle').replace('{u}', String(info.rpm.used)).replace('{r}', String(info.rpm.remaining)),
|
|
813
|
+
style: info.rpm.remaining === 0 ? { color: 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } : undefined },
|
|
814
|
+
'⏱' + info.rpm.remaining));
|
|
815
|
+
if (info && typeof info.cost === 'number' && info.cost > 0) meta.push(h('span', { key: 'c', className: 'krot-tail', title: 'cost' }, '$' + info.cost.toFixed(2)));
|
|
816
|
+
const tr = testResult[key];
|
|
817
|
+
if (tr) meta.push(h('span', { key: 'tr', className: 'krot-tail',
|
|
818
|
+
title: (tr.message || (tr.ok ? t('testOk') : t('testFail')))
|
|
819
|
+
+ (tr.ok && tr.modelsCount ? ' · ' + tr.modelsCount + ' models' : '')
|
|
820
|
+
+ (tr.ok && tr.latencyMs ? ' · ' + tr.latencyMs + 'ms' : ''),
|
|
821
|
+
style: { color: tr.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } },
|
|
822
|
+
tr.ok ? (tr.modelsCount ? tr.modelsCount + 'm' : '✓') : '✕'));
|
|
823
|
+
// #219: last probe from the sandbox cache, greyed when older than 24h
|
|
824
|
+
else if (probeCache && probeCache[key]) {
|
|
825
|
+
const pc = probeCache[key];
|
|
826
|
+
const stale = Date.now() - (pc.at ?? 0) > 86400000;
|
|
827
|
+
meta.push(h('span', { key: 'pc', className: 'krot-tail',
|
|
828
|
+
title: 'last probe ' + (pc.at ? new Date(pc.at).toLocaleTimeString() : '') + (pc.ok ? ' ok' : ' ' + (pc.code ?? 'fail')),
|
|
829
|
+
style: { opacity: stale ? 0.4 : 0.7, color: pc.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)' } },
|
|
830
|
+
(pc.ok ? '✓' : '✕') + (pc.latencyMs ? ' ' + pc.latencyMs + 'ms' : '')));
|
|
831
|
+
}
|
|
832
|
+
meta.push(h('button', { key: 't', className: 'krot-btn', onClick: () => doTest(key), disabled: testing === key, title: t('testKey') }, testing === key ? '…' : t('testKey')));
|
|
833
|
+
// #223: broken keys get a one-click live re-test + auto-unbreak
|
|
834
|
+
if (info && info.broken) {
|
|
835
|
+
meta.push(h('button', { key: 'rt', className: 'krot-btn', onClick: () => retestBroken(key), title: t('retestBroken') }, t('retestBroken')));
|
|
836
|
+
}
|
|
837
|
+
meta.push(h('span', { key: 'a', className: 'krot-acts' },
|
|
838
|
+
btn('↑', () => moveKey(pIndex, kIndex, -1), { disabled: kIndex === 0, title: t('moveUp') }),
|
|
839
|
+
btn('↓', () => moveKey(pIndex, kIndex, 1), { disabled: kIndex === keys.length - 1, title: t('moveDown') }),
|
|
840
|
+
btn('✕', () => removeKey(pIndex, kIndex), { title: t('removeKey') }),
|
|
841
|
+
));
|
|
842
|
+
|
|
843
|
+
return h('div', { key: kIndex, className: 'krot-key' },
|
|
844
|
+
nameRow,
|
|
845
|
+
h('div', { className: 'krot-meta' }, meta),
|
|
846
|
+
);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
const providerStatus = status[entry.provider];
|
|
850
|
+
const switchesLine = h('p', { className: 'krot-hint' },
|
|
851
|
+
providerStatus && providerStatus.switches > 0
|
|
852
|
+
? t('switchesSome')
|
|
853
|
+
.replace('{n}', String(providerStatus.switches))
|
|
854
|
+
.replace('{reason}', String(providerStatus.lastReason || '—'))
|
|
855
|
+
.replace('{ago}', formatAgo(t, providerStatus.lastSwitchAt))
|
|
856
|
+
: t('switchesNone'));
|
|
857
|
+
const exhaustionWarning = providerStatus && providerStatus.lastExhaustionAt && (Date.now() - providerStatus.lastExhaustionAt) < 3600000
|
|
858
|
+
? h('p', { className: 'krot-err' }, t('poolExhausted') + ' (' + formatAgo(t, providerStatus.lastExhaustionAt) + ')')
|
|
859
|
+
: null;
|
|
860
|
+
// #208: budget line (warn color at >=80%, red at 100%)
|
|
861
|
+
const budgetLine = providerStatus && (providerStatus.budgetDaily > 0 || providerStatus.budgetWeekly > 0)
|
|
862
|
+
? (() => {
|
|
863
|
+
const dayRatio = providerStatus.budgetDaily > 0 ? providerStatus.todayCost / providerStatus.budgetDaily : 0;
|
|
864
|
+
const weekRatio = providerStatus.budgetWeekly > 0 ? providerStatus.weeklyCost / providerStatus.budgetWeekly : 0;
|
|
865
|
+
const worst = Math.max(dayRatio, weekRatio);
|
|
866
|
+
const color = worst >= 1 ? 'var(--dsw-alias-state-error-primary)' : worst >= 0.8 ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-label-tertiary)';
|
|
867
|
+
const parts = [];
|
|
868
|
+
if (providerStatus.budgetDaily > 0) parts.push('$' + (providerStatus.todayCost ?? 0).toFixed(2) + '/' + '$' + providerStatus.budgetDaily);
|
|
869
|
+
if (providerStatus.budgetWeekly > 0) parts.push('week $' + (providerStatus.weeklyCost ?? 0).toFixed(2) + '/' + '$' + providerStatus.budgetWeekly);
|
|
870
|
+
if (worst >= 1 && providerStatus.pauseOnBudget) parts.push('· paused');
|
|
871
|
+
return h('p', { className: 'krot-hint', style: { color } }, t('budgetLabel') + ' ' + parts.join(' · '));
|
|
872
|
+
})()
|
|
873
|
+
: null;
|
|
874
|
+
// #225: provider p95 latency + SLO marker
|
|
875
|
+
const sloLine = providerStatus && providerStatus.p95 != null
|
|
876
|
+
? (() => {
|
|
877
|
+
const over = providerStatus.latencySloMs && providerStatus.p95 > providerStatus.latencySloMs;
|
|
878
|
+
return h('p', { className: 'krot-hint', style: over ? { color: 'var(--dsw-alias-state-warning-primary)' } : undefined },
|
|
879
|
+
'p95 ' + providerStatus.p95 + 'ms' + (providerStatus.latencySloMs ? ' / ' + providerStatus.latencySloMs + 'ms SLO' : ''));
|
|
880
|
+
})()
|
|
881
|
+
: null;
|
|
882
|
+
// #209: CSV export for this provider's usage (last 7 days)
|
|
883
|
+
const exportCsv = h('button', { className: 'krot-btn', title: t('exportCsv'),
|
|
884
|
+
onClick: () => {
|
|
885
|
+
const url = '/dsh-key-rotation/usage?format=csv&days=7&provider=' + encodeURIComponent(entry.provider);
|
|
886
|
+
const a = document.createElement('a');
|
|
887
|
+
a.href = url; a.download = 'usage-' + entry.provider + '.csv';
|
|
888
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
889
|
+
} }, t('exportCsv'));
|
|
890
|
+
|
|
891
|
+
return h('div', { key: pIndex, className: 'krot-prov' },
|
|
892
|
+
h('div', { className: 'krot-prov-head' },
|
|
893
|
+
h('input', { type: 'checkbox', checked: selected.has(entry.provider), onChange: (e) => { const ns = new Set(selected); if (e.target.checked) ns.add(entry.provider); else ns.delete(entry.provider); setSelected(ns); } }),
|
|
894
|
+
btn(t('exportOne'), () => {
|
|
895
|
+
const data = JSON.stringify([entry], null, 2);
|
|
896
|
+
const blob = new Blob([data], { type: 'application/json' });
|
|
897
|
+
const url = URL.createObjectURL(blob);
|
|
898
|
+
const a = document.createElement('a'); a.href = url; a.download = entry.provider + '.json'; a.click(); URL.revokeObjectURL(url);
|
|
899
|
+
}, { title: 'Export this provider' }),
|
|
900
|
+
btn('⇅', () => {
|
|
901
|
+
const ps = status[entry.provider];
|
|
902
|
+
if (!ps || !Array.isArray(ps.keys)) return;
|
|
903
|
+
const usageOf = (ref) => { const hit = ps.keys.find((k) => k.ref === ref); return hit && typeof hit.usage === 'number' ? hit.usage : 0; };
|
|
904
|
+
setField((cur) => {
|
|
905
|
+
const next = [...(cur.providers ?? [])];
|
|
906
|
+
if (!next[pIndex]) return cur;
|
|
907
|
+
const sorted = [...(next[pIndex].keys ?? [])].sort((a, b) => usageOf(b) - usageOf(a));
|
|
908
|
+
next[pIndex] = { ...next[pIndex], keys: sorted };
|
|
909
|
+
return { ...cur, providers: next };
|
|
910
|
+
});
|
|
911
|
+
}, { title: 'Sort by usage' }),
|
|
912
|
+
h('select', { className: 'krot-in', value: entry.provider, onChange: (e) => setProvider(pIndex, e.target.value) }, options),
|
|
913
|
+
(() => {
|
|
914
|
+
const ps = status[entry.provider];
|
|
915
|
+
const score = ps && typeof ps.healthScore === 'number' ? ps.healthScore : null;
|
|
916
|
+
if (score === null) return null;
|
|
917
|
+
const color = score > 80 ? 'var(--dsw-alias-state-success-primary)' : score >= 50 ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-state-error-primary)';
|
|
918
|
+
return h('span', { className: 'krot-tail', title: 'health score', style: { flex: 'none', color, fontWeight: 700 } }, String(score));
|
|
919
|
+
})(),
|
|
920
|
+
(() => { const ps = status[entry.provider]; const tot = ps && typeof ps.totalUsage === 'number' ? ps.totalUsage : null; return tot !== null ? h('span', { className: 'krot-tail', title: 'total requests', style: { flex: 'none' } }, String(tot)) : null; })(),
|
|
921
|
+
btn('✕', () => removeProvider(pIndex), { title: t('removeProvider') }),
|
|
922
|
+
),
|
|
923
|
+
filterBar, h('div', { className: 'krot-keys' }, keyRows),
|
|
924
|
+
h('div', { className: 'krot-foot' },
|
|
925
|
+
btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle') }),
|
|
926
|
+
switchesLine,
|
|
927
|
+
budgetLine,
|
|
928
|
+
sloLine,
|
|
929
|
+
exhaustionWarning,
|
|
930
|
+
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('div', { style: { display: 'flex', gap: '2px', alignItems: 'end', height: '24px', marginTop: '4px' } }, (() => { const now = Date.now(); const buckets = Array(24).fill(0); for (const ev of providerStatus.events) { const h = Math.floor((now - ev.at) / 3600000); if (h >= 0 && h < 24) buckets[23 - h]++; } const max = Math.max(1, ...buckets); return buckets.map((c, i) => h('div', { key: i, title: c + ' switches', style: { flex: 1, background: c ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-border-l2)', height: (c / max * 24) + 'px', minHeight: '2px', borderRadius: '2px' } })); })()) : null),
|
|
931
|
+
// #224: 7-day switches per day (client-side, from the same events)
|
|
932
|
+
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('div', { style: { display: 'flex', gap: '2px', alignItems: 'end', height: '16px', marginTop: '2px' } }, (() => { const now = Date.now(); const days = Array(7).fill(0); for (const ev of providerStatus.events) { const d = Math.floor((now - ev.at) / 86400000); if (d >= 0 && d < 7) days[6 - d]++; } const max = Math.max(1, ...days); return days.map((c, i) => h('div', { key: i, title: c + ' switches · day -' + (6 - i), style: { flex: 1, background: c ? 'var(--dsw-alias-state-info-primary, var(--dsw-alias-state-warning-primary))' : 'var(--dsw-alias-border-l2)', height: (c / max * 16) + 'px', minHeight: '2px', borderRadius: '2px' } })); })()) : null),
|
|
933
|
+
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('details', { style: { fontSize: '11px', marginTop: '6px' } }, h('summary', null, 'Recent failures ('+providerStatus.events.length+')'), h('ul', { style: { margin: '4px 0 0', paddingLeft: '16px' } }, providerStatus.events.slice().reverse().map((ev, i) => h('li', { key: i, style: ev.type === 'probe' ? { opacity: .5 } : null }, new Date(ev.at).toLocaleTimeString() + ' ' + (ev.type === 'probe' ? '[probe] ' : '') + ev.ref + ' ' + ev.reason + ' cd=' + ev.cooldownMs)) )) : null),
|
|
934
|
+
btn(t('resetCooldown'), () => doReset(entry.provider), { disabled: !(providerStatus && providerStatus.switches > 0) || resetting === entry.provider, title: t('resetCooldown') }),
|
|
935
|
+
btn(testAllProvider === entry.provider ? t('testing') : t('testAll'), () => doTestAll(entry.provider), { disabled: testAllProvider === entry.provider, title: t('testAll') }),
|
|
936
|
+
exportCsv,
|
|
937
|
+
),
|
|
938
|
+
);
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
const noProviders = providers.length === 0
|
|
942
|
+
? h('p', { className: 'krot-err' }, t('noProviders'))
|
|
943
|
+
: null;
|
|
944
|
+
|
|
945
|
+
return h('div', { className: 'krot' },
|
|
946
|
+
h('p', { className: 'krot-hint' }, t('desc')),
|
|
947
|
+
field(t('cooldown'), textInput(String(val.cooldownMs ?? 60000), (v) => setField((cur) => ({ ...cur, cooldownMs: Number(v) || 0 })))),
|
|
948
|
+
field(t('scheduleDays'), textInput(String(val.rotationScheduleDays ?? 0), (v) => setField((cur) => ({ ...cur, rotationScheduleDays: Number(v) || 0 })))),
|
|
949
|
+
field(t('codesTitle'), h('div', { className: 'krot-codes' }, codeList.map((code) => h('label', { key: code, className: 'krot-code' },
|
|
950
|
+
h('input', {
|
|
951
|
+
type: 'checkbox',
|
|
952
|
+
checked: selectedCodes.has(code),
|
|
953
|
+
onChange: (e) => toggleCode(code, e.target.checked),
|
|
954
|
+
}),
|
|
955
|
+
code,
|
|
956
|
+
)))),
|
|
957
|
+
field(t('providersTitle'), h('div', { className: 'krot-keys' },
|
|
958
|
+
searchInput,
|
|
959
|
+
h('div', { className: 'krot-foot' }, h('input', { className: 'krot-in', placeholder: 'Bulk cooldown ms', value: bulkCooldown, onChange: (e) => setBulkCooldown(e.target.value), style: { maxWidth: '140px' } }), btn('Apply to selected', () => {
|
|
960
|
+
const v = Number(bulkCooldown); if (!v) return;
|
|
961
|
+
setField((cur) => {
|
|
962
|
+
const next = [...(cur.providers ?? [])];
|
|
963
|
+
for (let i=0;i<next.length;i++) if (selected.has(next[i].provider)) next[i] = { ...next[i], cooldownMs: v };
|
|
964
|
+
return { ...cur, providers: next };
|
|
965
|
+
});
|
|
966
|
+
}, { disabled: selected.size === 0 || !bulkCooldown })),
|
|
967
|
+
providerRows,
|
|
968
|
+
h('div', { className: 'krot-foot' }, btn(t('addProvider'), addProvider, {}), noProviders),
|
|
969
|
+
)),
|
|
970
|
+
h('div', { className: 'krot-foot' }, btn(t('exportPools'), () => {
|
|
971
|
+
const data = JSON.stringify(val.providers ?? [], null, 2);
|
|
972
|
+
const blob = new Blob([data], { type: 'application/json' });
|
|
973
|
+
const url = URL.createObjectURL(blob);
|
|
974
|
+
const a = document.createElement('a'); a.href = url; a.download = 'pools.json'; a.click(); URL.revokeObjectURL(url);
|
|
975
|
+
}, {}), h('label', { className: 'krot-btn', style: { cursor: 'pointer' } }, t('importPools'), h('input', { type: 'file', accept: '.json', style: { display: 'none' }, onChange: (e) => {
|
|
976
|
+
const f = e.target.files[0]; if (!f) return;
|
|
977
|
+
const reader = new FileReader();
|
|
978
|
+
reader.onload = () => { try { const imp = JSON.parse(String(reader.result)); if (!Array.isArray(imp)) throw new Error('expected array'); setField((cur) => {
|
|
979
|
+
const curProviders = Array.isArray(cur.providers) ? [...cur.providers] : [];
|
|
980
|
+
const map = new Map(curProviders.map((p) => [p.provider, p]));
|
|
981
|
+
for (const p of imp) { if (p && typeof p.provider === 'string') map.set(p.provider, p); }
|
|
982
|
+
return { ...cur, providers: [...map.values()] };
|
|
983
|
+
}); } catch (err) { setSecretError(String(err.message || err)); } };
|
|
984
|
+
reader.readAsText(f);
|
|
985
|
+
} }))),
|
|
986
|
+
// #218: full snapshot export/import - one file moves the whole config
|
|
987
|
+
btn(t('snapshotExport'), () => {
|
|
988
|
+
fetch('/dsh-key-rotation/snapshot', { headers: { accept: 'application/json' } })
|
|
989
|
+
.then((r) => r.json())
|
|
990
|
+
.then((data) => {
|
|
991
|
+
const blob = new Blob([JSON.stringify(data.snapshot ?? {}, null, 2)], { type: 'application/json' });
|
|
992
|
+
const url = URL.createObjectURL(blob);
|
|
993
|
+
const a = document.createElement('a'); a.href = url; a.download = 'dsh-key-rotation-snapshot.json'; a.click(); URL.revokeObjectURL(url);
|
|
994
|
+
})
|
|
995
|
+
.catch((e) => setSecretError(String(e?.message ?? e)));
|
|
996
|
+
}, {}),
|
|
997
|
+
h('label', { className: 'krot-btn', style: { cursor: 'pointer' } }, t('snapshotImport'), h('input', { type: 'file', accept: '.json', style: { display: 'none' }, onChange: (e) => {
|
|
998
|
+
const f = e.target.files[0]; if (!f) return;
|
|
999
|
+
const reader2 = new FileReader();
|
|
1000
|
+
reader2.onload = () => {
|
|
1001
|
+
try {
|
|
1002
|
+
const snap = JSON.parse(String(reader2.result));
|
|
1003
|
+
if (!snap || typeof snap !== 'object' || Array.isArray(snap)) throw new Error('expected snapshot object');
|
|
1004
|
+
fetch('/dsh-key-rotation/snapshot', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ snapshot: snap }) })
|
|
1005
|
+
.then((r) => r.json().then((data) => ({ ok: r.ok, data })))
|
|
1006
|
+
.then(({ ok, data }) => { if (!ok) throw new Error(data?.error?.message ?? 'import failed'); load(); })
|
|
1007
|
+
.catch((err) => setSecretError(t('keyWriteFailed').replace('{msg}', String(err?.message ?? err))));
|
|
1008
|
+
} catch (err) { setSecretError(String(err.message || err)); }
|
|
1009
|
+
};
|
|
1010
|
+
reader2.readAsText(f);
|
|
1011
|
+
} })),
|
|
1012
|
+
h('p', { className: 'krot-hint' }, t('keyHint')),
|
|
1013
|
+
secretError ? h('p', { className: 'krot-err' }, secretError) : null,
|
|
1014
|
+
state.error ? h('p', { className: 'krot-err' }, state.error) : null,
|
|
1015
|
+
undo ? h('div', { className: 'krot-foot' }, h('span', { className: 'krot-hint' }, undo.type === 'provider' ? 'Удалён провайдер' : 'Удалён ключ'), btn('Undo', doUndo, {})) : null,
|
|
1016
|
+
h('div', { className: 'krot-foot' },
|
|
1017
|
+
btn(t('save'), save, { primary: true }),
|
|
1018
|
+
btn(t('discard'), load, {}),
|
|
1019
|
+
state.status === 'saving' ? h('span', { className: 'krot-hint' }, t('saving')) : null,
|
|
1020
|
+
),
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function mountDashboard() {
|
|
1025
|
+
// Floating dashboard disabled in favor of pinned header chip popover
|
|
1026
|
+
if (typeof document !== 'undefined') {
|
|
1027
|
+
var el = document.getElementById('krot-dash');
|
|
1028
|
+
if (el) el.remove();
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// #201 header chip: one dot + counts for all pools. Green = all healthy,
|
|
1033
|
+
// amber = some keys cooling, red = a pool fully exhausted. Click opens the
|
|
1034
|
+
// same summary the floating dashboard shows.
|
|
1035
|
+
function KeyRotationHeaderChip() {
|
|
1036
|
+
const [snap, setSnap] = React.useState(null);
|
|
1037
|
+
const [open, setOpen] = React.useState(false);
|
|
1038
|
+
const ref = React.useRef(null);
|
|
1039
|
+
|
|
1040
|
+
React.useEffect(() => {
|
|
1041
|
+
let alive = true;
|
|
1042
|
+
const load = () => {
|
|
1043
|
+
fetch('/dsh-key-rotation/health', { headers: { accept: 'application/json' }, credentials: 'same-origin' })
|
|
1044
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
1045
|
+
.then((d) => { if (alive) setSnap(d); })
|
|
1046
|
+
.catch(() => {});
|
|
1047
|
+
};
|
|
1048
|
+
load();
|
|
1049
|
+
const id = setInterval(load, 4000);
|
|
1050
|
+
return () => { alive = false; clearInterval(id); };
|
|
1051
|
+
}, []);
|
|
1052
|
+
|
|
1053
|
+
React.useEffect(() => {
|
|
1054
|
+
if (!open) return;
|
|
1055
|
+
const onDocClick = (e) => {
|
|
1056
|
+
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
|
|
1057
|
+
};
|
|
1058
|
+
document.addEventListener('click', onDocClick);
|
|
1059
|
+
return () => document.removeEventListener('click', onDocClick);
|
|
1060
|
+
}, [open]);
|
|
1061
|
+
|
|
1062
|
+
const poolsObj = (snap && snap.pools) || {};
|
|
1063
|
+
const poolEntries = Object.entries(poolsObj);
|
|
1064
|
+
const total = poolEntries.reduce((a, [, p]) => a + (p.total || 0), 0);
|
|
1065
|
+
const healthy = poolEntries.reduce((a, [, p]) => a + (p.healthy || 0), 0);
|
|
1066
|
+
const anyExhausted = poolEntries.some(([, p]) => p.exhausted);
|
|
1067
|
+
const color = !poolEntries.length ? 'var(--dsw-alias-label-tertiary)' : anyExhausted ? '#e5484d' : healthy < total ? '#f5a623' : '#30a46c';
|
|
1068
|
+
const label = poolEntries.length ? `${healthy}/${total} rot` : 'rot';
|
|
1069
|
+
|
|
1070
|
+
return h('div', { ref, style: { position: 'relative', display: 'inline-flex' } },
|
|
1071
|
+
h('button', {
|
|
1072
|
+
type: 'button',
|
|
1073
|
+
className: 'krot-header-chip',
|
|
1074
|
+
title: 'Ротация ключей / Key Rotation Pools',
|
|
1075
|
+
onClick: () => setOpen((v) => !v),
|
|
1076
|
+
},
|
|
1077
|
+
h('span', { style: { width: '8px', height: '8px', borderRadius: '50%', background: color, flex: 'none', boxShadow: `0 0 6px ${color}` } }),
|
|
1078
|
+
label,
|
|
1079
|
+
h('span', { style: { fontSize: '9px', opacity: 0.6 } }, open ? '▲' : '▼')
|
|
1080
|
+
),
|
|
1081
|
+
open ? h('div', { className: 'krot-popover' },
|
|
1082
|
+
h('div', { className: 'krot-pop-title' }, 'Пулы ротации ключей'),
|
|
1083
|
+
!poolEntries.length ? h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, 'Нет активных пулов') : null,
|
|
1084
|
+
poolEntries.map(([name, p]) => {
|
|
1085
|
+
const c = p.exhausted ? '#e5484d' : (p.healthy < p.total ? '#f5a623' : '#30a46c');
|
|
1086
|
+
return h('div', { key: name, className: 'krot-pop-row' },
|
|
1087
|
+
h('div', { className: 'krot-pop-name' },
|
|
1088
|
+
h('span', { style: { width: '7px', height: '7px', borderRadius: '50%', background: c, flex: 'none' } }),
|
|
1089
|
+
name
|
|
1090
|
+
),
|
|
1091
|
+
h('div', { className: 'krot-pop-count', style: { color: c } }, `${p.healthy}/${p.total}`)
|
|
1092
|
+
);
|
|
1093
|
+
})
|
|
1094
|
+
) : null
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function apply(ctx) {
|
|
1099
|
+
ctx.effect(() => ctx.locale.register(NS, { en, ru }), 'dsh-key-rotation: dictionaries');
|
|
1100
|
+
// Dashboard widget: lives on every page, polls /health (#152).
|
|
1101
|
+
ctx.effect(() => mountDashboard(), 'dsh-key-rotation: dashboard widget');
|
|
1102
|
+
// Header chip (#201): status dot in the session header utilities slot,
|
|
1103
|
+
// same slot dsh-gitea / dsh-subscriptions use for their header widgets.
|
|
1104
|
+
ctx.effect(() => {
|
|
1105
|
+
if (!ctx.slots) return;
|
|
1106
|
+
try {
|
|
1107
|
+
ctx.slots.inject('conversation.session.header.utilities', () =>
|
|
1108
|
+
ctx.slots.register(
|
|
1109
|
+
{ name: 'conversation.session.header.utilities', id: 'dsh-key-rotation-header-chip', order: 6 },
|
|
1110
|
+
KeyRotationHeaderChip,
|
|
1111
|
+
));
|
|
1112
|
+
} catch { /* slot not available in this build */ }
|
|
1113
|
+
}, 'dsh-key-rotation: header chip');
|
|
1114
|
+
|
|
1115
|
+
function useLocale() {
|
|
1116
|
+
return useActiveLocale(ctx);
|
|
1117
|
+
}
|
|
1118
|
+
// Collapsible card in Settings -> Plugins -> Plugin settings
|
|
1119
|
+
// (settings.plugin.item), matching Model Sync / Spendmeter / Vision Bridge.
|
|
1120
|
+
// key MUST equal the settings namespace (NS), else the tab silently skips it.
|
|
1121
|
+
function KeyRotationCard(props) {
|
|
1122
|
+
const locale = useLocale();
|
|
1123
|
+
const t = makeT(locale === 'ru' ? ru : (locale === 'zh' ? zh : en), en);
|
|
1124
|
+
const [open, setOpen] = React.useState(false);
|
|
1125
|
+
return h('div', { className: 'krot-card' + (open ? ' krot-card-open' : '') },
|
|
1126
|
+
h('button', { type: 'button', className: 'krot-card-header', 'aria-expanded': open, onClick: () => setOpen((v) => !v) },
|
|
1127
|
+
h('span', { className: 'krot-card-head-text' },
|
|
1128
|
+
h('span', { className: 'krot-card-name' }, t('title')),
|
|
1129
|
+
h('span', { className: 'krot-card-description' }, t('subtitle'))),
|
|
1130
|
+
h('span', { className: 'krot-card-chevron' + (open ? ' krot-card-chevron-open' : ''), 'aria-hidden': 'true' },
|
|
1131
|
+
h('svg', { width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, style: { display: 'block' } },
|
|
1132
|
+
h('path', { d: 'M3.5 5.25L7 8.75L10.5 5.25' })))),
|
|
1133
|
+
open ? h('div', { className: 'krot-card-body' }, h(KeyRotationSection, { ...props, locale })) : null);
|
|
1134
|
+
}
|
|
1135
|
+
const tryPluginItem = () => {
|
|
1136
|
+
try {
|
|
1137
|
+
ctx.slots.inject('settings.plugin.item', () =>
|
|
1138
|
+
ctx.slots.register(
|
|
1139
|
+
{ name: 'settings.plugin.item', key: NS, locale: NS, inject: () => ({ ctx }) },
|
|
1140
|
+
KeyRotationCard,
|
|
1141
|
+
));
|
|
1142
|
+
return true;
|
|
1143
|
+
} catch { return false; }
|
|
1144
|
+
};
|
|
1145
|
+
if (!tryPluginItem()) {
|
|
1146
|
+
// Fallback for builds without the Plugins tab slot: keep the sidebar section.
|
|
1147
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register(
|
|
1148
|
+
{ name: 'settings.section', id: 'dsh-key-rotation', order: 20, label: () => 'Key Rotation' },
|
|
1149
|
+
(props) => h(KeyRotationSection, { ...props, locale: useLocale() }),
|
|
1150
|
+
));
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
module.exports = { apply, inject: ['slots', 'locale'] };
|
|
1155
|
+
return module.exports;
|
|
1156
|
+
},
|
|
1157
|
+
});
|