@goodandready/dsh-key-rotation 0.7.18 → 0.7.20
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/lib/client.js +850 -763
- package/lib/index.js +2 -38
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -1,763 +1,850 @@
|
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
React.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
.then((
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (sec <
|
|
175
|
-
return t('
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
'.krot
|
|
185
|
-
'.krot
|
|
186
|
-
'.krot-
|
|
187
|
-
'.krot-
|
|
188
|
-
'.krot-
|
|
189
|
-
'.krot-
|
|
190
|
-
'.krot-in
|
|
191
|
-
'.krot-
|
|
192
|
-
'.krot-
|
|
193
|
-
'.krot-
|
|
194
|
-
'.krot-prov
|
|
195
|
-
'.krot-prov-head
|
|
196
|
-
'.krot-
|
|
197
|
-
'.krot-
|
|
198
|
-
'.krot-
|
|
199
|
-
'.krot-
|
|
200
|
-
'.krot-
|
|
201
|
-
'.krot-
|
|
202
|
-
'.krot-
|
|
203
|
-
'.krot-
|
|
204
|
-
'.krot-
|
|
205
|
-
'.krot-
|
|
206
|
-
'.krot-
|
|
207
|
-
'.krot-btn
|
|
208
|
-
'.krot-btn:
|
|
209
|
-
'.krot-
|
|
210
|
-
'.krot-
|
|
211
|
-
'.krot-
|
|
212
|
-
'.krot-card
|
|
213
|
-
'.krot-card-
|
|
214
|
-
'.krot-card-
|
|
215
|
-
'.krot-card-
|
|
216
|
-
'.krot-card-
|
|
217
|
-
'.krot-card-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
tag
|
|
223
|
-
tag.
|
|
224
|
-
tag.
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
React.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
const
|
|
273
|
-
const [
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const [
|
|
277
|
-
const [
|
|
278
|
-
const [
|
|
279
|
-
const
|
|
280
|
-
const
|
|
281
|
-
const [
|
|
282
|
-
const [
|
|
283
|
-
const [
|
|
284
|
-
const [
|
|
285
|
-
const
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
.then((
|
|
297
|
-
.
|
|
298
|
-
.
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
.then((
|
|
310
|
-
.
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
.then((
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const
|
|
338
|
-
const
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
.then((
|
|
345
|
-
.
|
|
346
|
-
.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const [
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
.then((
|
|
367
|
-
.
|
|
368
|
-
.
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
.then(({ ok, data })
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
next
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
const
|
|
439
|
-
const
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
keys[
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
const
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
.then((
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
if (hit
|
|
510
|
-
if (hit.
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
if (
|
|
516
|
-
if (hit.
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
return { color: 'var(--dsw-alias-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
const
|
|
539
|
-
const
|
|
540
|
-
const
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
//
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
h('span', {
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
h('span', { key: '
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
if (info &&
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
tr.ok ? '
|
|
594
|
-
|
|
595
|
-
meta.push(h('
|
|
596
|
-
|
|
597
|
-
btn('
|
|
598
|
-
btn('
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
.replace('{
|
|
613
|
-
.replace('{
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
:
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
const
|
|
625
|
-
const
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
(() =>
|
|
642
|
-
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
h('div', { className: 'krot-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('
|
|
658
|
-
|
|
659
|
-
btn(
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
:
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
field(t('
|
|
672
|
-
field(t('
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
const
|
|
696
|
-
const
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
h('div', { className: 'krot-foot' },
|
|
714
|
-
|
|
715
|
-
btn(t('
|
|
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
|
-
}
|
|
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
|
+
subtitle: 'Per-provider API key rotation: pools of keys, automatic failover on quota/rate-limit errors, cooldown and recovery.',
|
|
32
|
+
cardDesc: 'Per-provider API key rotation: pools of keys, automatic failover on quota/rate-limit errors, cooldown and recovery.',
|
|
33
|
+
loading: 'Loading…',
|
|
34
|
+
notRegistered: 'not registered',
|
|
35
|
+
removeKey: 'Remove key',
|
|
36
|
+
removeProvider: 'Remove provider',
|
|
37
|
+
addKey: '+ Add key',
|
|
38
|
+
addKeyTitle: 'Add API key',
|
|
39
|
+
noProviders: 'No providers registered with DSH — nothing to pick from yet.',
|
|
40
|
+
addProvider: '+ Add provider',
|
|
41
|
+
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.',
|
|
42
|
+
cooldown: 'Cooldown after failure (ms)',
|
|
43
|
+
scheduleDays: 'Rotation schedule (days, 0=off)',
|
|
44
|
+
switchCodes: 'Switch codes (comma-separated)',
|
|
45
|
+
providersTitle: 'Providers and their keys',
|
|
46
|
+
save: 'Save',
|
|
47
|
+
discard: 'Discard',
|
|
48
|
+
saving: 'Saving…',
|
|
49
|
+
moveUp: 'Move up',
|
|
50
|
+
moveDown: 'Move down',
|
|
51
|
+
keyActive: 'in use',
|
|
52
|
+
keyReady: 'ready',
|
|
53
|
+
keyCooling: 'cooling down, {s}s',
|
|
54
|
+
keyMissing: 'no such credential',
|
|
55
|
+
switchesNone: 'no switches yet',
|
|
56
|
+
switchesSome: 'switches: {n} · last: {reason}, {ago}',
|
|
57
|
+
justNow: 'just now',
|
|
58
|
+
minutesAgo: '{n} min ago',
|
|
59
|
+
hoursAgo: '{n} h ago',
|
|
60
|
+
codesTitle: 'Switch on these failures',
|
|
61
|
+
keyValuePlaceholder: 'paste the key, then Save',
|
|
62
|
+
keySave: 'Save key',
|
|
63
|
+
keySaved: 'saved',
|
|
64
|
+
keyFromEnv: 'from the environment, read-only here',
|
|
65
|
+
keyWriteFailed: 'could not store the key: {msg}',
|
|
66
|
+
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.',
|
|
67
|
+
brokenKey: 'broken (3× AUTH)',
|
|
68
|
+
keyExpired: 'expired',
|
|
69
|
+
keyExpiringSoon: 'expires in {n} d',
|
|
70
|
+
exportPools: 'Export',
|
|
71
|
+
exportOne: '⬇',
|
|
72
|
+
usedAgo: '{ago} ago',
|
|
73
|
+
importPools: 'Import',
|
|
74
|
+
importEnv: 'Import .env',
|
|
75
|
+
resetCooldown: 'Reset cooldown',
|
|
76
|
+
testAll: 'Test all keys',
|
|
77
|
+
testing: 'Testing…',
|
|
78
|
+
testKey: 'Test',
|
|
79
|
+
testOk: 'OK',
|
|
80
|
+
testFail: 'FAIL',
|
|
81
|
+
poolExhausted: 'pool exhausted — all keys cooling',
|
|
82
|
+
resetting: 'Resetting…',
|
|
83
|
+
keyLabel: 'Key {n}',
|
|
84
|
+
};
|
|
85
|
+
const ru = {
|
|
86
|
+
title: 'Ротация ключей',
|
|
87
|
+
subtitle: 'Ротация API-ключей по провайдерам: пулы ключей, автоматическое переключение при исчерпании квоты или лимита, кулдаун и восстановление.',
|
|
88
|
+
cardDesc: 'Ротация API-ключей по провайдерам: пулы ключей, автоматическое переключение при исчерпании квоты или лимита, кулдаун и восстановление.',
|
|
89
|
+
loading: 'Загрузка…',
|
|
90
|
+
notRegistered: 'не зарегистрирован',
|
|
91
|
+
removeKey: 'Удалить ключ',
|
|
92
|
+
removeProvider: 'Удалить провайдера',
|
|
93
|
+
addKey: '+ Добавить ключ',
|
|
94
|
+
addKeyTitle: 'Добавить API-ключ',
|
|
95
|
+
noProviders: 'Провайдеры ещё не зарегистрированы в DSH — выбирать не из чего.',
|
|
96
|
+
addProvider: '+ Добавить провайдера',
|
|
97
|
+
desc: 'Ротация API-ключей по провайдерам. Для каждого провайдера укажите его API-ключи (имена env, хранятся в учётных данных DSH). Плагин ведёт модель по ключам провайдера по порядку и переключается на следующий при исчерпании квоты/превышении лимита.',
|
|
98
|
+
cooldown: 'Задержка после сбоя (мс)',
|
|
99
|
+
scheduleDays: 'Расписание ротации (дней, 0=выкл)',
|
|
100
|
+
switchCodes: 'Коды переключения (через запятую)',
|
|
101
|
+
providersTitle: 'Провайдеры и их ключи',
|
|
102
|
+
save: 'Сохранить',
|
|
103
|
+
discard: 'Отменить',
|
|
104
|
+
saving: 'Сохранение…',
|
|
105
|
+
moveUp: 'Выше',
|
|
106
|
+
moveDown: 'Ниже',
|
|
107
|
+
keyActive: 'используется',
|
|
108
|
+
keyReady: 'готов',
|
|
109
|
+
keyCooling: 'остывает, {s}с',
|
|
110
|
+
keyMissing: 'ключ не найден',
|
|
111
|
+
switchesNone: 'переключений не было',
|
|
112
|
+
switchesSome: 'переключений: {n} · последнее: {reason}, {ago}',
|
|
113
|
+
justNow: 'только что',
|
|
114
|
+
minutesAgo: '{n} мин назад',
|
|
115
|
+
hoursAgo: '{n} ч назад',
|
|
116
|
+
codesTitle: 'Переключаться при этих сбоях',
|
|
117
|
+
keyValuePlaceholder: 'вставьте ключ и нажмите «Сохранить»',
|
|
118
|
+
keySave: 'Сохранить ключ',
|
|
119
|
+
keySaved: 'сохранён',
|
|
120
|
+
keyFromEnv: 'задан в окружении, отсюда не меняется',
|
|
121
|
+
keyWriteFailed: 'не удалось сохранить ключ: {msg}',
|
|
122
|
+
keyHint: 'Значение хранится в учётных данных DSH и обратно в браузер не отдаётся — показываются только последние 5 символов. Имена переменных создаются автоматически; наведите на ключ, чтобы увидеть используемое имя.',
|
|
123
|
+
brokenKey: 'сломан (3× AUTH)',
|
|
124
|
+
keyExpired: 'истёк',
|
|
125
|
+
keyExpiringSoon: 'истекает через {n} д',
|
|
126
|
+
exportPools: 'Экспорт',
|
|
127
|
+
exportOne: '⬇',
|
|
128
|
+
usedAgo: '{ago} назад',
|
|
129
|
+
importPools: 'Импорт',
|
|
130
|
+
importEnv: 'Импорт .env',
|
|
131
|
+
resetCooldown: 'Сбросить кулдаун',
|
|
132
|
+
testAll: 'Тест всех ключей',
|
|
133
|
+
testing: 'Тестирование…',
|
|
134
|
+
testKey: 'Тест',
|
|
135
|
+
testOk: 'OK',
|
|
136
|
+
testFail: 'FAIL',
|
|
137
|
+
poolExhausted: 'пул исчерпан — все ключи остывают',
|
|
138
|
+
resetting: 'Сброс…',
|
|
139
|
+
keyLabel: 'Ключ {n}',
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Коды, на которых имеет смысл переключать ключ. Список из хоста
|
|
143
|
+
// (DEFAULT_SWITCH_CODES); конфиг может содержать и свои — они показываются
|
|
144
|
+
// отдельными отмеченными галочками, чтобы правило нельзя было потерять.
|
|
145
|
+
const KNOWN_CODES = ['QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'UNKNOWN_MODEL', 'AUTH'];
|
|
146
|
+
|
|
147
|
+
/** Опрос статуса ротации, пока раздел настроек открыт. */
|
|
148
|
+
function useRotationStatus() {
|
|
149
|
+
const [byProvider, setByProvider] = React.useState({});
|
|
150
|
+
React.useEffect(() => {
|
|
151
|
+
let alive = true;
|
|
152
|
+
const pull = () => {
|
|
153
|
+
fetch('/dsh-key-rotation/status', { headers: { accept: 'application/json' } })
|
|
154
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
155
|
+
.then((data) => {
|
|
156
|
+
if (!alive || !data || !Array.isArray(data.providers)) return;
|
|
157
|
+
const map = {};
|
|
158
|
+
for (const entry of data.providers) map[entry.provider] = entry;
|
|
159
|
+
setByProvider(map);
|
|
160
|
+
})
|
|
161
|
+
.catch(() => { /* статус необязателен: карточка остаётся редактором */ });
|
|
162
|
+
};
|
|
163
|
+
pull();
|
|
164
|
+
const id = setInterval(pull, 4000);
|
|
165
|
+
return () => { alive = false; clearInterval(id); };
|
|
166
|
+
}, []);
|
|
167
|
+
return byProvider;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// formatAgo moved to lib/client-helpers.js for testability — keep local alias for bundle self-containment
|
|
171
|
+
function formatAgo(t, at) {
|
|
172
|
+
if (!at) return '';
|
|
173
|
+
const sec = Math.max(0, Math.round((Date.now() - at) / 1000));
|
|
174
|
+
if (sec < 60) return t('justNow');
|
|
175
|
+
if (sec < 3600) return t('minutesAgo').replace('{n}', String(Math.round(sec / 60)));
|
|
176
|
+
return t('hoursAgo').replace('{n}', String(Math.round(sec / 3600)));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Разметка карточки: сетка, а не набор inline-стилей. Фиксированные ширины
|
|
180
|
+
// здесь уже приводили к тому, что имя ключа обрезалось, а кнопки наезжали
|
|
181
|
+
// на поле значения, поэтому имя занимает свою строку, а служебная строка
|
|
182
|
+
// под ним ужимается сама.
|
|
183
|
+
const CARD_CSS = [
|
|
184
|
+
'.krot{display:flex;flex-direction:column;gap:14px;max-width:640px}',
|
|
185
|
+
'.krot p{margin:0}',
|
|
186
|
+
'.krot-hint{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
187
|
+
'.krot-err{font-size:12px;color:var(--dsw-alias-state-error-primary)}',
|
|
188
|
+
'.krot-label{font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
189
|
+
'.krot-field{display:flex;flex-direction:column;gap:5px}',
|
|
190
|
+
'.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}',
|
|
191
|
+
'.krot-in:focus{outline:none;border-color:var(--dsw-alias-border-l3,var(--dsw-alias-border-l2))}',
|
|
192
|
+
'.krot-codes{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:4px 12px}',
|
|
193
|
+
'.krot-code{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
194
|
+
'.krot-prov{display:flex;flex-direction:column;gap:10px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:10px 12px}',
|
|
195
|
+
'.krot-prov-head{display:flex;gap:8px;align-items:center}',
|
|
196
|
+
'.krot-prov-head select{flex:1;min-width:0}',
|
|
197
|
+
'.krot-keys{display:flex;flex-direction:column;gap:8px}',
|
|
198
|
+
'.krot-key{display:grid;grid-template-columns:18px minmax(0,1fr);gap:4px 8px;align-items:center}',
|
|
199
|
+
'.krot-num{font-size:12px;color:var(--dsw-alias-label-tertiary);text-align:right}',
|
|
200
|
+
'.krot-name{font-size:13px;color:var(--dsw-alias-label-primary);cursor:default}',
|
|
201
|
+
'.krot-meta{grid-column:2;display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
202
|
+
'.krot-dot{width:8px;height:8px;border-radius:50%;flex:none}',
|
|
203
|
+
'.krot-state{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
204
|
+
'.krot-tail{font-size:11px;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,Menlo,Consolas,monospace}',
|
|
205
|
+
'.krot-secret{flex:1;min-width:120px;max-width:220px}',
|
|
206
|
+
'.krot-acts{display:flex;gap:4px;margin-left:auto;flex:none}',
|
|
207
|
+
'.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}',
|
|
208
|
+
'.krot-btn:disabled{opacity:.35;cursor:default}',
|
|
209
|
+
'.krot-btn:not(:disabled):hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3,var(--dsw-alias-border-l2))}',
|
|
210
|
+
'.krot-foot{display:flex;gap:8px;align-items:center}',
|
|
211
|
+
'.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}',
|
|
212
|
+
'.krot-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}',
|
|
213
|
+
'.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}',
|
|
214
|
+
'.krot-card-head-text{display:flex;flex-direction:column;gap:2px;min-width:0}',
|
|
215
|
+
'.krot-card-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}',
|
|
216
|
+
'.krot-card-description{color:var(--dsw-alias-label-secondary);font-size:13px}',
|
|
217
|
+
'.krot-card-chevron{flex:none;opacity:.6}',
|
|
218
|
+
'.krot-card-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}',
|
|
219
|
+
].join('');
|
|
220
|
+
const CARD_CSS_ID = 'dsh-key-rotation/section.module.css';
|
|
221
|
+
if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="' + CARD_CSS_ID + '"]')) {
|
|
222
|
+
const tag = document.createElement('style');
|
|
223
|
+
tag.textContent = CARD_CSS;
|
|
224
|
+
tag.setAttribute('data-plugin', 'dsh-key-rotation');
|
|
225
|
+
tag.dataset.pluginCss = CARD_CSS_ID;
|
|
226
|
+
document.head.appendChild(tag);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Имя переменной под новый ключ.
|
|
231
|
+
*
|
|
232
|
+
* Пользователь его больше не печатает: первый ключ провайдера получает имя
|
|
233
|
+
* вида <PROVIDER>_API_KEY, следующие — тот же корень с суффиксом _2, _3…
|
|
234
|
+
* Корень берётся у уже существующих ключей, чтобы вручную заведённые имена
|
|
235
|
+
* не ломались, и проверяется на занятость по ВСЕМ провайдерам — иначе два
|
|
236
|
+
* провайдера незаметно делили бы одну учётную запись.
|
|
237
|
+
*/
|
|
238
|
+
// nextKeyRef also in lib/client-helpers.js
|
|
239
|
+
function nextKeyRef(providerId, existingKeys, allRefs) {
|
|
240
|
+
const fromExisting = (existingKeys || []).find((k) => typeof k === 'string' && k.length > 0);
|
|
241
|
+
const base = fromExisting
|
|
242
|
+
? fromExisting.replace(/_\d+$/, '')
|
|
243
|
+
: String(providerId || 'provider').toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') + '_API_KEY';
|
|
244
|
+
const taken = new Set(allRefs);
|
|
245
|
+
if (!taken.has(base)) return base;
|
|
246
|
+
for (let n = 2; n < 1000; n++) {
|
|
247
|
+
const candidate = base + '_' + n;
|
|
248
|
+
if (!taken.has(candidate)) return candidate;
|
|
249
|
+
}
|
|
250
|
+
return base + '_' + Date.now();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function useActiveLocale(ctx) {
|
|
254
|
+
return React.useSyncExternalStore(
|
|
255
|
+
React.useMemo(() => (cb) => (ctx && ctx.locale ? ctx.locale.subscribe(cb) : () => {}), [ctx]),
|
|
256
|
+
React.useCallback(() => {
|
|
257
|
+
if (ctx && ctx.locale) {
|
|
258
|
+
const active = ctx.locale.getSnapshot().active;
|
|
259
|
+
if (typeof active === 'string' && active) return active;
|
|
260
|
+
}
|
|
261
|
+
return typeof navigator !== 'undefined' ? String(navigator.language || '').slice(0, 2) : '';
|
|
262
|
+
}, [ctx])
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function makeT(DICT, fallbackKeys) {
|
|
267
|
+
return (key) => (DICT && DICT[key]) || (fallbackKeys && fallbackKeys[key]) || key;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function KeyRotationSection(props) {
|
|
271
|
+
const DICT = props.locale === 'ru' ? ru : en;
|
|
272
|
+
const t = makeT(DICT, en);
|
|
273
|
+
const [state, setState] = React.useState({ status: 'loading', value: null, revision: 0, error: '', providers: [] });
|
|
274
|
+
const [draft, setDraft] = React.useState(null);
|
|
275
|
+
// ── all hooks live ABOVE any early return (React error 310 otherwise) ──
|
|
276
|
+
const [search, setSearch] = React.useState('');
|
|
277
|
+
const [selected, setSelected] = React.useState(new Set());
|
|
278
|
+
const [bulkCooldown, setBulkCooldown] = React.useState('');
|
|
279
|
+
const [undo, setUndo] = React.useState(null);
|
|
280
|
+
const undoTimer = React.useRef(null);
|
|
281
|
+
const [testing, setTesting] = React.useState('');
|
|
282
|
+
const [testResult, setTestResult] = React.useState({});
|
|
283
|
+
const [testAllProvider, setTestAllProvider] = React.useState('');
|
|
284
|
+
const [secretDraft, setSecretDraft] = React.useState({});
|
|
285
|
+
const [secretError, setSecretError] = React.useState('');
|
|
286
|
+
const stashUndo = (u) => { setUndo(u); if (undoTimer.current) clearTimeout(undoTimer.current); undoTimer.current = setTimeout(() => setUndo(null), 5000); };
|
|
287
|
+
const doUndo = () => { if (!undo) return; const u = undo; setUndo(null); setField((cur) => {
|
|
288
|
+
const providers = [...(cur.providers ?? [])];
|
|
289
|
+
if (u.type === 'provider') providers.splice(Math.min(u.index, providers.length), 0, u.entry);
|
|
290
|
+
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 }; }
|
|
291
|
+
return { ...cur, providers };
|
|
292
|
+
}); };
|
|
293
|
+
const doTest = (ref) => {
|
|
294
|
+
setTesting(ref); setTestResult((m) => ({ ...m, [ref]: null }));
|
|
295
|
+
fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref }) })
|
|
296
|
+
.then((r) => r.json())
|
|
297
|
+
.then((data) => setTestResult((m) => ({ ...m, [ref]: data })))
|
|
298
|
+
.catch((e) => setTestResult((m) => ({ ...m, [ref]: { ok: false, message: String(e?.message ?? e) } })))
|
|
299
|
+
.finally(() => setTesting(''));
|
|
300
|
+
};
|
|
301
|
+
const doTestAll = (providerId) => {
|
|
302
|
+
if (!val || !Array.isArray(val.providers)) return;
|
|
303
|
+
const entry = val.providers.find((p) => p.provider === providerId);
|
|
304
|
+
if (!entry || !Array.isArray(entry.keys)) return;
|
|
305
|
+
const refs = entry.keys.filter((k) => k && typeof k === 'string' && k.length > 0);
|
|
306
|
+
setTestAllProvider(providerId);
|
|
307
|
+
Promise.all(refs.map((ref) =>
|
|
308
|
+
fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref }) })
|
|
309
|
+
.then((r) => r.json())
|
|
310
|
+
.then((data) => ({ ref, data }))
|
|
311
|
+
.catch((e) => ({ ref, data: { ok: false, message: String(e?.message ?? e) } }))
|
|
312
|
+
)).then((results) => {
|
|
313
|
+
setTestResult((m) => { const nm = { ...m }; for (const { ref, data } of results) nm[ref] = data; return nm; });
|
|
314
|
+
setTestAllProvider('');
|
|
315
|
+
});
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const load = React.useCallback(() => {
|
|
319
|
+
setState((s) => ({ ...s, status: 'loading', error: '' }));
|
|
320
|
+
fetch(CONFIG_PATH, { headers: { accept: 'application/json' } })
|
|
321
|
+
.then((r) => r.json())
|
|
322
|
+
.then((data) => {
|
|
323
|
+
setState({
|
|
324
|
+
status: 'ready',
|
|
325
|
+
value: data.value ?? null,
|
|
326
|
+
revision: data.revision ?? 0,
|
|
327
|
+
providers: Array.isArray(data.providers) ? data.providers : [],
|
|
328
|
+
error: data.error ? data.error.message : '',
|
|
329
|
+
});
|
|
330
|
+
setDraft(null);
|
|
331
|
+
})
|
|
332
|
+
.catch((e) => setState((s) => ({ ...s, status: 'error', error: String(e) })));
|
|
333
|
+
}, []);
|
|
334
|
+
|
|
335
|
+
React.useEffect(() => { load(); }, [load]);
|
|
336
|
+
|
|
337
|
+
const val = draft ?? state.value;
|
|
338
|
+
const status = useRotationStatus();
|
|
339
|
+
const [resetting, setResetting] = React.useState('');
|
|
340
|
+
const doReset = (providerId) => {
|
|
341
|
+
setResetting(providerId);
|
|
342
|
+
setSecretError('');
|
|
343
|
+
fetch('/dsh-key-rotation/reset', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: providerId }) })
|
|
344
|
+
.then((r) => r.json().then((data) => ({ ok: r.ok, data })))
|
|
345
|
+
.then(({ ok, data }) => { if (!ok) throw new Error(data?.error?.message ?? 'unknown error'); })
|
|
346
|
+
.catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))))
|
|
347
|
+
.finally(() => setResetting(''));
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
const keyInfo = (providerId, ref) => {
|
|
352
|
+
const entryStatus = status[providerId];
|
|
353
|
+
if (!entryStatus || !ref) return null;
|
|
354
|
+
return (entryStatus.keys ?? []).find((k) => k.ref === ref) ?? null;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const [validating, setValidating] = React.useState('');
|
|
358
|
+
const [validationResult, setValidationResult] = React.useState({});
|
|
359
|
+
const validateBeforeSave = (ref, value) => {
|
|
360
|
+
setValidating(ref);
|
|
361
|
+
return fetch('/dsh-key-rotation/test', {
|
|
362
|
+
method: 'POST',
|
|
363
|
+
headers: { 'content-type': 'application/json' },
|
|
364
|
+
body: JSON.stringify({ ref, value }),
|
|
365
|
+
})
|
|
366
|
+
.then((r) => r.json())
|
|
367
|
+
.then((data) => { setValidationResult((m) => ({ ...m, [ref]: data })); return data; })
|
|
368
|
+
.catch(() => null)
|
|
369
|
+
.finally(() => setValidating(''));
|
|
370
|
+
};
|
|
371
|
+
const saveSecret = async (ref, rowKey) => {
|
|
372
|
+
const value = secretDraft[rowKey];
|
|
373
|
+
if (!value) return;
|
|
374
|
+
setSecretError('');
|
|
375
|
+
// Pre-save validation (issue #118)
|
|
376
|
+
setValidating(ref);
|
|
377
|
+
const vres = await validateBeforeSave(ref, value);
|
|
378
|
+
setValidating('');
|
|
379
|
+
if (vres && vres.ok === false && vres.code === 'no-credential') {
|
|
380
|
+
// No credential yet is fine for a new key being saved
|
|
381
|
+
} else if (vres && !vres.ok) {
|
|
382
|
+
setSecretError(t('keyWriteFailed').replace('{msg}', vres.message || 'validation failed'));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
fetch('/dsh-key-rotation/key', {
|
|
386
|
+
method: 'PUT',
|
|
387
|
+
headers: { 'content-type': 'application/json' },
|
|
388
|
+
body: JSON.stringify({ ref, value }),
|
|
389
|
+
})
|
|
390
|
+
.then((r) => r.json().then((data) => ({ ok: r.ok, data })))
|
|
391
|
+
.then(({ ok, data }) => {
|
|
392
|
+
if (!ok) throw new Error(data?.error?.message ?? 'unknown error');
|
|
393
|
+
setSecretDraft((cur) => {
|
|
394
|
+
const next = { ...cur };
|
|
395
|
+
delete next[rowKey];
|
|
396
|
+
return next;
|
|
397
|
+
});
|
|
398
|
+
})
|
|
399
|
+
.catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))));
|
|
400
|
+
};
|
|
401
|
+
if (state.status === 'loading' || !val) {
|
|
402
|
+
return React.createElement('p', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 13 } }, t('loading'));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const providers = state.providers;
|
|
406
|
+
const providerById = new Map(providers.map((p) => [p.id, p.name]));
|
|
407
|
+
|
|
408
|
+
const setField = (fn) => setDraft(fn(val));
|
|
409
|
+
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())) : [];
|
|
410
|
+
|
|
411
|
+
const setProvider = (index, id) => setField((cur) => {
|
|
412
|
+
const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
|
|
413
|
+
next[index] = { ...next[index], provider: id };
|
|
414
|
+
return { ...cur, providers: next };
|
|
415
|
+
});
|
|
416
|
+
const addKey = (pIndex) => setField((cur) => {
|
|
417
|
+
const providers = [...(cur.providers ?? [])];
|
|
418
|
+
const entry = { ...(providers[pIndex] ?? {}) };
|
|
419
|
+
const allRefs = providers.flatMap((prov) => prov?.keys ?? []);
|
|
420
|
+
entry.keys = [...(entry.keys ?? []), nextKeyRef(entry.provider, entry.keys, allRefs)];
|
|
421
|
+
providers[pIndex] = entry;
|
|
422
|
+
return { ...cur, providers };
|
|
423
|
+
});
|
|
424
|
+
const removeKey = (pIndex, kIndex) => { setField((cur) => {
|
|
425
|
+
const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
|
|
426
|
+
stashUndo({ type: 'key', index: pIndex, kIndex, key: next[pIndex]?.keys?.[kIndex] });
|
|
427
|
+
next[pIndex] = { ...next[pIndex], keys: (next[pIndex].keys ?? []).filter((_, i) => i !== kIndex) };
|
|
428
|
+
return { ...cur, providers: next };
|
|
429
|
+
}); };
|
|
430
|
+
const removeProvider = (pIndex) => setField((cur) => {
|
|
431
|
+
const arr = Array.isArray(cur.providers) ? cur.providers : [];
|
|
432
|
+
stashUndo({ type: 'provider', index: pIndex, entry: arr[pIndex] });
|
|
433
|
+
return { ...cur, providers: arr.filter((_, i) => i !== pIndex) };
|
|
434
|
+
});
|
|
435
|
+
// Порядок ключей = порядок попыток, поэтому его надо менять кнопками,
|
|
436
|
+
// а не перепечатыванием имён.
|
|
437
|
+
const moveKey = (pIndex, kIndex, delta) => setField((cur) => {
|
|
438
|
+
const providers = [...(cur.providers ?? [])];
|
|
439
|
+
const entry = { ...(providers[pIndex] ?? {}) };
|
|
440
|
+
const keys = [...(entry.keys ?? [])];
|
|
441
|
+
const target = kIndex + delta;
|
|
442
|
+
if (target < 0 || target >= keys.length) return cur;
|
|
443
|
+
const moved = keys[kIndex];
|
|
444
|
+
keys[kIndex] = keys[target];
|
|
445
|
+
keys[target] = moved;
|
|
446
|
+
entry.keys = keys;
|
|
447
|
+
providers[pIndex] = entry;
|
|
448
|
+
return { ...cur, providers };
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// Коды из конфига, которых нет в известном списке, показываем тоже:
|
|
452
|
+
// иначе галочки молча выбросили бы чужое правило при первом сохранении.
|
|
453
|
+
const selectedCodes = new Set(Array.isArray(val.switchCodes) ? val.switchCodes : []);
|
|
454
|
+
const codeList = [...KNOWN_CODES, ...[...selectedCodes].filter((c) => !KNOWN_CODES.includes(c))];
|
|
455
|
+
const toggleCode = (code, on) => setField((cur) => {
|
|
456
|
+
const current = new Set(Array.isArray(cur.switchCodes) ? cur.switchCodes : []);
|
|
457
|
+
if (on) current.add(code); else current.delete(code);
|
|
458
|
+
return { ...cur, switchCodes: codeList.filter((c) => current.has(c)) };
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
const addProvider = () => setField((cur) => ({
|
|
462
|
+
...cur,
|
|
463
|
+
providers: [...(Array.isArray(cur.providers) ? cur.providers : []), { provider: '', keys: [''] }],
|
|
464
|
+
}));
|
|
465
|
+
|
|
466
|
+
const save = () => {
|
|
467
|
+
if (!draft) return;
|
|
468
|
+
setState((s) => ({ ...s, status: 'saving', error: '' }));
|
|
469
|
+
fetch(CONFIG_PATH, {
|
|
470
|
+
method: 'PUT',
|
|
471
|
+
headers: { 'content-type': 'application/json' },
|
|
472
|
+
body: JSON.stringify({ section: draft, expectedRevision: state.revision }),
|
|
473
|
+
})
|
|
474
|
+
.then((r) => r.json())
|
|
475
|
+
.then((data) => {
|
|
476
|
+
if (data.error) {
|
|
477
|
+
setState((s) => ({ ...s, status: 'error', error: data.error.message }));
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
setState((s) => ({ status: 'ready', value: data.value ?? draft, revision: data.revision ?? s.revision, error: '', providers: s.providers }));
|
|
481
|
+
setDraft(null);
|
|
482
|
+
})
|
|
483
|
+
.catch((e) => setState((s) => ({ ...s, status: 'error', error: String(e) })));
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
const field = (labelText, node) => h('label', { className: 'krot-field' },
|
|
489
|
+
h('span', { className: 'krot-label' }, labelText), node);
|
|
490
|
+
|
|
491
|
+
const textInput = (value, onChange, placeholder) => h('input', {
|
|
492
|
+
className: 'krot-in',
|
|
493
|
+
value: value ?? '',
|
|
494
|
+
onChange: (e) => onChange(e.target.value),
|
|
495
|
+
placeholder,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
const btn = (labelText, onClick, opts) => h('button', {
|
|
499
|
+
className: 'krot-btn' + (opts && opts.primary ? ' krot-save' : ''),
|
|
500
|
+
onClick,
|
|
501
|
+
disabled: Boolean(opts && opts.disabled),
|
|
502
|
+
title: (opts && opts.title) || undefined,
|
|
503
|
+
}, labelText);
|
|
504
|
+
|
|
505
|
+
// Точка состояния ключа: цвет и подпись читаются с одного взгляда,
|
|
506
|
+
// а «ключ не найден» ловит опечатку в имени env, которая иначе молчит.
|
|
507
|
+
const keyStatus = (providerId, ref) => {
|
|
508
|
+
const hit = keyInfo(providerId, ref);
|
|
509
|
+
if (!hit) return null;
|
|
510
|
+
if (hit.expired) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyExpired') };
|
|
511
|
+
if (hit.expiresAt && !hit.expired) {
|
|
512
|
+
const days = Math.ceil((hit.expiresAt - Date.now()) / 86400000);
|
|
513
|
+
if (days <= 7) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyExpiringSoon').replace('{n}', String(days)) };
|
|
514
|
+
}
|
|
515
|
+
if (hit.broken) return { color: 'var(--dsw-alias-state-error-primary)', text: t('brokenKey') };
|
|
516
|
+
if (!hit.present) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyMissing') };
|
|
517
|
+
if (hit.cooldownMsLeft > 0) {
|
|
518
|
+
return {
|
|
519
|
+
color: 'var(--dsw-alias-state-warning-primary)',
|
|
520
|
+
text: t('keyCooling').replace('{s}', String(Math.ceil(hit.cooldownMsLeft / 1000))),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
if (hit.active) return { color: 'var(--dsw-alias-state-success-primary)', text: t('keyActive') };
|
|
524
|
+
return { color: 'var(--dsw-alias-label-tertiary)', text: t('keyReady') };
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const searchInput = h('input', { className: 'krot-in', placeholder: 'Search providers…', value: search, onChange: (e) => setSearch(e.target.value), style: { marginBottom: '8px' } });
|
|
528
|
+
const providerRows = providerList.map((entry, pIndex) => {
|
|
529
|
+
const options = [];
|
|
530
|
+
if (entry.provider && !providerById.has(entry.provider)) {
|
|
531
|
+
options.push(h('option', { key: entry.provider, value: entry.provider }, entry.provider + ' (' + t('notRegistered') + ')'));
|
|
532
|
+
}
|
|
533
|
+
options.push(...providers.map((prov) =>
|
|
534
|
+
h('option', { key: prov.id, value: prov.id }, prov.name + (prov.id !== prov.name ? ' — ' + prov.id : ''))));
|
|
535
|
+
|
|
536
|
+
const keys = entry.keys ?? [];
|
|
537
|
+
const keyRows = keys.map((key, kIndex) => {
|
|
538
|
+
const st = keyStatus(entry.provider, key);
|
|
539
|
+
const info = keyInfo(entry.provider, key);
|
|
540
|
+
const rowKey = entry.provider + '/' + kIndex;
|
|
541
|
+
const typed = secretDraft[rowKey];
|
|
542
|
+
const fromEnv = Boolean(info && info.source === 'env');
|
|
543
|
+
|
|
544
|
+
// Имя ключа занимает свою строку целиком: раньше оно обрезалось и
|
|
545
|
+
// соседние ключи выглядели одинаково.
|
|
546
|
+
const nameRow = [
|
|
547
|
+
h('span', { className: 'krot-num', key: 'n' }, String(kIndex + 1)),
|
|
548
|
+
h('span', { key: 'i', className: 'krot-name', title: key + ' (click to copy)', style: { cursor: 'copy' }, onClick: () => {
|
|
549
|
+
if (navigator.clipboard) navigator.clipboard.writeText(key).then(() => setSecretDraft((cur) => ({ ...cur, ['copied:' + key]: true }))).catch(() => {});
|
|
550
|
+
setTimeout(() => setSecretDraft((cur) => ({ ...cur, ['copied:' + key]: false })), 1500);
|
|
551
|
+
} },
|
|
552
|
+
t('keyLabel').replace('{n}', String(kIndex + 1)),
|
|
553
|
+
h('span', null, secretDraft['copied:' + key] ? ' ✓' : '')),
|
|
554
|
+
];
|
|
555
|
+
|
|
556
|
+
const meta = [
|
|
557
|
+
h('span', { key: 'd', className: 'krot-dot', style: { background: st ? st.color : 'var(--dsw-alias-border-l2)' } }),
|
|
558
|
+
h('span', { key: 's', className: 'krot-state' }, st ? st.text : ''),
|
|
559
|
+
];
|
|
560
|
+
if (fromEnv) {
|
|
561
|
+
meta.push(h('span', { key: 'v', className: 'krot-tail', title: t('keyFromEnv') },
|
|
562
|
+
info.tail ? '••••' + info.tail : t('keyFromEnv')));
|
|
563
|
+
} else {
|
|
564
|
+
meta.push(h('input', {
|
|
565
|
+
key: 'v',
|
|
566
|
+
type: 'password',
|
|
567
|
+
className: 'krot-in krot-secret',
|
|
568
|
+
value: typed ?? '',
|
|
569
|
+
placeholder: info && info.tail ? '••••' + info.tail : t('keyValuePlaceholder'),
|
|
570
|
+
onChange: (e) => setSecretDraft((cur) => ({ ...cur, [rowKey]: e.target.value })),
|
|
571
|
+
}));
|
|
572
|
+
if (typed) meta.push(btn('✓', () => saveSecret(key, rowKey), { title: t('keySave'), key: 'w' }));
|
|
573
|
+
}
|
|
574
|
+
if (info && typeof info.usage === 'number' && info.usage > 0) {
|
|
575
|
+
let tip = 'requests through this key';
|
|
576
|
+
if (info.byModel && Object.keys(info.byModel).length > 0) {
|
|
577
|
+
tip = Object.entries(info.byModel).map(([m, c]) => m + ': ' + c).join('\n');
|
|
578
|
+
}
|
|
579
|
+
meta.push(h('span', { key: 'u', className: 'krot-tail', title: tip }, String(info.usage)));
|
|
580
|
+
if (info.usageDays && Object.keys(info.usageDays).length > 0) {
|
|
581
|
+
const days = Object.entries(info.usageDays);
|
|
582
|
+
const max = Math.max(1, ...days.map(([, c]) => c));
|
|
583
|
+
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' } },
|
|
584
|
+
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' } }))
|
|
585
|
+
));
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (info && info.lastUsedAt) meta.push(h('span', { key: 'lu', className: 'krot-tail', title: 'last used' }, formatAgo((k)=>t(k), info.lastUsedAt)));
|
|
589
|
+
if (info && typeof info.cost === 'number' && info.cost > 0) meta.push(h('span', { key: 'c', className: 'krot-tail', title: 'cost' }, '$' + info.cost.toFixed(2)));
|
|
590
|
+
const tr = testResult[key];
|
|
591
|
+
if (tr) meta.push(h('span', { key: 'tr', className: 'krot-tail',
|
|
592
|
+
title: tr.message || (tr.ok ? t('testOk') : t('testFail')),
|
|
593
|
+
style: { color: tr.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } },
|
|
594
|
+
tr.ok ? '✓' : '✕'));
|
|
595
|
+
meta.push(h('button', { key: 't', className: 'krot-btn', onClick: () => doTest(key), disabled: testing === key, title: t('testKey') }, testing === key ? '…' : t('testKey')));
|
|
596
|
+
meta.push(h('span', { key: 'a', className: 'krot-acts' },
|
|
597
|
+
btn('↑', () => moveKey(pIndex, kIndex, -1), { disabled: kIndex === 0, title: t('moveUp') }),
|
|
598
|
+
btn('↓', () => moveKey(pIndex, kIndex, 1), { disabled: kIndex === keys.length - 1, title: t('moveDown') }),
|
|
599
|
+
btn('✕', () => removeKey(pIndex, kIndex), { title: t('removeKey') }),
|
|
600
|
+
));
|
|
601
|
+
|
|
602
|
+
return h('div', { key: kIndex, className: 'krot-key' },
|
|
603
|
+
nameRow,
|
|
604
|
+
h('div', { className: 'krot-meta' }, meta),
|
|
605
|
+
);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const providerStatus = status[entry.provider];
|
|
609
|
+
const switchesLine = h('p', { className: 'krot-hint' },
|
|
610
|
+
providerStatus && providerStatus.switches > 0
|
|
611
|
+
? t('switchesSome')
|
|
612
|
+
.replace('{n}', String(providerStatus.switches))
|
|
613
|
+
.replace('{reason}', String(providerStatus.lastReason || '—'))
|
|
614
|
+
.replace('{ago}', formatAgo(t, providerStatus.lastSwitchAt))
|
|
615
|
+
: t('switchesNone'));
|
|
616
|
+
const exhaustionWarning = providerStatus && providerStatus.lastExhaustionAt && (Date.now() - providerStatus.lastExhaustionAt) < 3600000
|
|
617
|
+
? h('p', { className: 'krot-err' }, t('poolExhausted') + ' (' + formatAgo(t, providerStatus.lastExhaustionAt) + ')')
|
|
618
|
+
: null;
|
|
619
|
+
|
|
620
|
+
return h('div', { key: pIndex, className: 'krot-prov' },
|
|
621
|
+
h('div', { className: 'krot-prov-head' },
|
|
622
|
+
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); } }),
|
|
623
|
+
btn(t('exportOne'), () => {
|
|
624
|
+
const data = JSON.stringify([entry], null, 2);
|
|
625
|
+
const blob = new Blob([data], { type: 'application/json' });
|
|
626
|
+
const url = URL.createObjectURL(blob);
|
|
627
|
+
const a = document.createElement('a'); a.href = url; a.download = entry.provider + '.json'; a.click(); URL.revokeObjectURL(url);
|
|
628
|
+
}, { title: 'Export this provider' }),
|
|
629
|
+
btn('⇅', () => {
|
|
630
|
+
const ps = status[entry.provider];
|
|
631
|
+
if (!ps || !Array.isArray(ps.keys)) return;
|
|
632
|
+
const usageOf = (ref) => { const hit = ps.keys.find((k) => k.ref === ref); return hit && typeof hit.usage === 'number' ? hit.usage : 0; };
|
|
633
|
+
setField((cur) => {
|
|
634
|
+
const next = [...(cur.providers ?? [])];
|
|
635
|
+
if (!next[pIndex]) return cur;
|
|
636
|
+
const sorted = [...(next[pIndex].keys ?? [])].sort((a, b) => usageOf(b) - usageOf(a));
|
|
637
|
+
next[pIndex] = { ...next[pIndex], keys: sorted };
|
|
638
|
+
return { ...cur, providers: next };
|
|
639
|
+
});
|
|
640
|
+
}, { title: 'Sort by usage' }),
|
|
641
|
+
h('select', { className: 'krot-in', value: entry.provider, onChange: (e) => setProvider(pIndex, e.target.value) }, options),
|
|
642
|
+
(() => {
|
|
643
|
+
const ps = status[entry.provider];
|
|
644
|
+
const score = ps && typeof ps.healthScore === 'number' ? ps.healthScore : null;
|
|
645
|
+
if (score === null) return null;
|
|
646
|
+
const color = score > 80 ? 'var(--dsw-alias-state-success-primary)' : score >= 50 ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-state-error-primary)';
|
|
647
|
+
return h('span', { className: 'krot-tail', title: 'health score', style: { flex: 'none', color, fontWeight: 700 } }, String(score));
|
|
648
|
+
})(),
|
|
649
|
+
(() => { 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; })(),
|
|
650
|
+
btn('✕', () => removeProvider(pIndex), { title: t('removeProvider') }),
|
|
651
|
+
),
|
|
652
|
+
h('div', { className: 'krot-keys' }, keyRows),
|
|
653
|
+
h('div', { className: 'krot-foot' },
|
|
654
|
+
btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle') }),
|
|
655
|
+
switchesLine,
|
|
656
|
+
exhaustionWarning,
|
|
657
|
+
(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),
|
|
658
|
+
(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),
|
|
659
|
+
btn(t('resetCooldown'), () => doReset(entry.provider), { disabled: !(providerStatus && providerStatus.switches > 0) || resetting === entry.provider, title: t('resetCooldown') }),
|
|
660
|
+
btn(testAllProvider === entry.provider ? t('testing') : t('testAll'), () => doTestAll(entry.provider), { disabled: testAllProvider === entry.provider, title: t('testAll') }),
|
|
661
|
+
),
|
|
662
|
+
);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
const noProviders = providers.length === 0
|
|
666
|
+
? h('p', { className: 'krot-err' }, t('noProviders'))
|
|
667
|
+
: null;
|
|
668
|
+
|
|
669
|
+
return h('div', { className: 'krot' },
|
|
670
|
+
h('p', { className: 'krot-hint' }, t('desc')),
|
|
671
|
+
field(t('cooldown'), textInput(String(val.cooldownMs ?? 60000), (v) => setField((cur) => ({ ...cur, cooldownMs: Number(v) || 0 })))),
|
|
672
|
+
field(t('scheduleDays'), textInput(String(val.rotationScheduleDays ?? 0), (v) => setField((cur) => ({ ...cur, rotationScheduleDays: Number(v) || 0 })))),
|
|
673
|
+
field(t('codesTitle'), h('div', { className: 'krot-codes' }, codeList.map((code) => h('label', { key: code, className: 'krot-code' },
|
|
674
|
+
h('input', {
|
|
675
|
+
type: 'checkbox',
|
|
676
|
+
checked: selectedCodes.has(code),
|
|
677
|
+
onChange: (e) => toggleCode(code, e.target.checked),
|
|
678
|
+
}),
|
|
679
|
+
code,
|
|
680
|
+
)))),
|
|
681
|
+
field(t('providersTitle'), h('div', { className: 'krot-keys' },
|
|
682
|
+
searchInput,
|
|
683
|
+
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', () => {
|
|
684
|
+
const v = Number(bulkCooldown); if (!v) return;
|
|
685
|
+
setField((cur) => {
|
|
686
|
+
const next = [...(cur.providers ?? [])];
|
|
687
|
+
for (let i=0;i<next.length;i++) if (selected.has(next[i].provider)) next[i] = { ...next[i], cooldownMs: v };
|
|
688
|
+
return { ...cur, providers: next };
|
|
689
|
+
});
|
|
690
|
+
}, { disabled: selected.size === 0 || !bulkCooldown })),
|
|
691
|
+
providerRows,
|
|
692
|
+
h('div', { className: 'krot-foot' }, btn(t('addProvider'), addProvider, {}), noProviders),
|
|
693
|
+
)),
|
|
694
|
+
h('div', { className: 'krot-foot' }, btn(t('exportPools'), () => {
|
|
695
|
+
const data = JSON.stringify(val.providers ?? [], null, 2);
|
|
696
|
+
const blob = new Blob([data], { type: 'application/json' });
|
|
697
|
+
const url = URL.createObjectURL(blob);
|
|
698
|
+
const a = document.createElement('a'); a.href = url; a.download = 'pools.json'; a.click(); URL.revokeObjectURL(url);
|
|
699
|
+
}, {}), h('label', { className: 'krot-btn', style: { cursor: 'pointer' } }, t('importPools'), h('input', { type: 'file', accept: '.json', style: { display: 'none' }, onChange: (e) => {
|
|
700
|
+
const f = e.target.files[0]; if (!f) return;
|
|
701
|
+
const reader = new FileReader();
|
|
702
|
+
reader.onload = () => { try { const imp = JSON.parse(String(reader.result)); if (!Array.isArray(imp)) throw new Error('expected array'); setField((cur) => {
|
|
703
|
+
const curProviders = Array.isArray(cur.providers) ? [...cur.providers] : [];
|
|
704
|
+
const map = new Map(curProviders.map((p) => [p.provider, p]));
|
|
705
|
+
for (const p of imp) { if (p && typeof p.provider === 'string') map.set(p.provider, p); }
|
|
706
|
+
return { ...cur, providers: [...map.values()] };
|
|
707
|
+
}); } catch (err) { setSecretError(String(err.message || err)); } };
|
|
708
|
+
reader.readAsText(f);
|
|
709
|
+
} }))),
|
|
710
|
+
h('p', { className: 'krot-hint' }, t('keyHint')),
|
|
711
|
+
secretError ? h('p', { className: 'krot-err' }, secretError) : null,
|
|
712
|
+
state.error ? h('p', { className: 'krot-err' }, state.error) : null,
|
|
713
|
+
undo ? h('div', { className: 'krot-foot' }, h('span', { className: 'krot-hint' }, undo.type === 'provider' ? 'Удалён провайдер' : 'Удалён ключ'), btn('Undo', doUndo, {})) : null,
|
|
714
|
+
h('div', { className: 'krot-foot' },
|
|
715
|
+
btn(t('save'), save, { primary: true }),
|
|
716
|
+
btn(t('discard'), load, {}),
|
|
717
|
+
state.status === 'saving' ? h('span', { className: 'krot-hint' }, t('saving')) : null,
|
|
718
|
+
),
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function mountDashboard() {
|
|
723
|
+
if (typeof document === 'undefined') return;
|
|
724
|
+
var STYLE_ID = 'krot-dash-style';
|
|
725
|
+
var HOST_ID = 'krot-dash';
|
|
726
|
+
function ensure() {
|
|
727
|
+
var el = document.getElementById(HOST_ID);
|
|
728
|
+
if (!el) {
|
|
729
|
+
el = document.createElement('div');
|
|
730
|
+
el.id = HOST_ID;
|
|
731
|
+
el.innerHTML = '<div class="khint">rotating pools</div>';
|
|
732
|
+
(document.body || document.documentElement).appendChild(el);
|
|
733
|
+
} else if (el.parentNode !== document.body && document.body) {
|
|
734
|
+
document.body.appendChild(el);
|
|
735
|
+
}
|
|
736
|
+
return el;
|
|
737
|
+
}
|
|
738
|
+
function paint(d) {
|
|
739
|
+
var el = ensure();
|
|
740
|
+
var pools = (d && d.pools) || {};
|
|
741
|
+
var names = Object.keys(pools);
|
|
742
|
+
var lines = [];
|
|
743
|
+
if (!names.length) lines.push('<div class="khint">no pools</div>');
|
|
744
|
+
for (var i = 0; i < names.length; i++) {
|
|
745
|
+
var n = names[i]; var p = pools[n];
|
|
746
|
+
var c = p.exhausted ? '#e5484d' : (p.healthy < p.total ? '#f5a623' : '#30a46c');
|
|
747
|
+
lines.push('<div class="krow"><span class="kdot" style="background:' + c + '"></span><span class="kname">' + n + '</span><span class="kcount">' + p.healthy + '/' + p.total + '</span></div>');
|
|
748
|
+
}
|
|
749
|
+
el.innerHTML = lines.join('');
|
|
750
|
+
}
|
|
751
|
+
function poll() {
|
|
752
|
+
fetch('/dsh-key-rotation/health', { headers: { accept: 'application/json' }, credentials: 'same-origin' })
|
|
753
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
754
|
+
.then(paint)
|
|
755
|
+
.catch(function () {});
|
|
756
|
+
}
|
|
757
|
+
function init() {
|
|
758
|
+
if (document.getElementById(STYLE_ID)) { ensure(); poll(); return; }
|
|
759
|
+
var s = document.createElement('style');
|
|
760
|
+
s.id = STYLE_ID;
|
|
761
|
+
s.textContent = [
|
|
762
|
+
'#krot-dash{position:fixed;top:16px;right:16px;z-index:99999;font:12px/1.4 system-ui,sans-serif;background:rgba(28,28,30,.92);color:#eaeaea;border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:8px 10px 6px;box-shadow:0 2px 12px rgba(0,0,0,.4);min-width:160px;max-width:280px;cursor:grab;user-select:none;touch-action:none;backdrop-filter:blur(6px)}',
|
|
763
|
+
'#krot-dash.dragging{cursor:grabbing}',
|
|
764
|
+
'#krot-dash .krow{display:flex;align-items:center;gap:6px;margin:2px 0}',
|
|
765
|
+
'#krot-dash .kdot{width:8px;height:8px;border-radius:50%;flex:none}',
|
|
766
|
+
'#krot-dash .kname{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}',
|
|
767
|
+
'#krot-dash .kcount{opacity:.65;font-variant-numeric:tabular-nums;font-size:11px}',
|
|
768
|
+
'#krot-dash .khint{opacity:.55;font-size:11px;margin:2px 0 4px}'
|
|
769
|
+
].join('');
|
|
770
|
+
(document.head || document.documentElement).appendChild(s);
|
|
771
|
+
var el = ensure();
|
|
772
|
+
try {
|
|
773
|
+
var saved = JSON.parse(localStorage.getItem('krot-dash-pos') || 'null');
|
|
774
|
+
if (saved && saved.left != null && saved.top != null) {
|
|
775
|
+
el.style.left = saved.left + 'px';
|
|
776
|
+
el.style.top = saved.top + 'px';
|
|
777
|
+
el.style.right = 'auto';
|
|
778
|
+
}
|
|
779
|
+
} catch (e) {}
|
|
780
|
+
poll();
|
|
781
|
+
setInterval(poll, 4000);
|
|
782
|
+
var dragging = false, ox = 0, oy = 0, sx = 0, sy = 0;
|
|
783
|
+
el.addEventListener('mousedown', function (e) {
|
|
784
|
+
if (e.button !== 0) return;
|
|
785
|
+
dragging = true; ox = e.clientX; oy = e.clientY;
|
|
786
|
+
var r = el.getBoundingClientRect(); sx = r.left; sy = r.top;
|
|
787
|
+
el.classList.add('dragging');
|
|
788
|
+
e.preventDefault();
|
|
789
|
+
});
|
|
790
|
+
document.addEventListener('mousemove', function (e) {
|
|
791
|
+
if (!dragging) return;
|
|
792
|
+
var x = sx + (e.clientX - ox), y = sy + (e.clientY - oy);
|
|
793
|
+
x = Math.max(0, Math.min(x, window.innerWidth - el.offsetWidth));
|
|
794
|
+
y = Math.max(0, Math.min(y, window.innerHeight - el.offsetHeight));
|
|
795
|
+
el.style.left = x + 'px'; el.style.top = y + 'px'; el.style.right = 'auto';
|
|
796
|
+
});
|
|
797
|
+
document.addEventListener('mouseup', function () {
|
|
798
|
+
if (!dragging) return;
|
|
799
|
+
dragging = false; el.classList.remove('dragging');
|
|
800
|
+
try { localStorage.setItem('krot-dash-pos', JSON.stringify({ left: el.offsetLeft, top: el.offsetTop })); } catch (e) {}
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function apply(ctx) {
|
|
807
|
+
ctx.effect(() => ctx.locale.register(NS, { en, ru }), 'dsh-key-rotation: dictionaries');
|
|
808
|
+
// Dashboard widget: lives on every page, polls /health (#152).
|
|
809
|
+
ctx.effect(() => mountDashboard(), 'dsh-key-rotation: dashboard widget');
|
|
810
|
+
function useLocale() {
|
|
811
|
+
return useActiveLocale(ctx);
|
|
812
|
+
}
|
|
813
|
+
// Collapsible card in Settings -> Plugins -> Plugin settings
|
|
814
|
+
// (settings.plugin.item), matching Model Sync / Spendmeter / Vision Bridge.
|
|
815
|
+
// key MUST equal the settings namespace (NS), else the tab silently skips it.
|
|
816
|
+
function KeyRotationCard(props) {
|
|
817
|
+
const locale = useLocale();
|
|
818
|
+
const t = makeT(locale === 'ru' ? ru : en, en);
|
|
819
|
+
const [open, setOpen] = React.useState(false);
|
|
820
|
+
return h('div', { className: 'krot-card' + (open ? ' krot-card-open' : '') },
|
|
821
|
+
h('button', { type: 'button', className: 'krot-card-header', 'aria-expanded': open, onClick: () => setOpen((v) => !v) },
|
|
822
|
+
h('span', { className: 'krot-card-head-text' },
|
|
823
|
+
h('span', { className: 'krot-card-name' }, t('title')),
|
|
824
|
+
h('span', { className: 'krot-card-description' }, t('subtitle'))),
|
|
825
|
+
h('span', { className: 'krot-card-chevron', 'aria-hidden': 'true' }, open ? '▴' : '▾')),
|
|
826
|
+
open ? h('div', { className: 'krot-card-body' }, h(KeyRotationSection, { ...props, locale })) : null);
|
|
827
|
+
}
|
|
828
|
+
const tryPluginItem = () => {
|
|
829
|
+
try {
|
|
830
|
+
ctx.slots.inject('settings.plugin.item', () =>
|
|
831
|
+
ctx.slots.register(
|
|
832
|
+
{ name: 'settings.plugin.item', key: NS, locale: NS, inject: () => ({ ctx }) },
|
|
833
|
+
KeyRotationCard,
|
|
834
|
+
));
|
|
835
|
+
return true;
|
|
836
|
+
} catch { return false; }
|
|
837
|
+
};
|
|
838
|
+
if (!tryPluginItem()) {
|
|
839
|
+
// Fallback for builds without the Plugins tab slot: keep the sidebar section.
|
|
840
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register(
|
|
841
|
+
{ name: 'settings.section', id: 'dsh-key-rotation', order: 20, label: () => 'Key Rotation' },
|
|
842
|
+
(props) => h(KeyRotationSection, { ...props, locale: useLocale() }),
|
|
843
|
+
));
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
module.exports = { apply, inject: ['slots', 'locale'] };
|
|
848
|
+
return module.exports;
|
|
849
|
+
},
|
|
850
|
+
});
|