@goodandready/dsh-key-rotation 0.8.10 → 0.8.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/README.ru.md +18 -0
- package/README.zh.md +7 -0
- package/lib/best-effort.js +26 -0
- package/lib/bucket.js +1 -87
- package/lib/cascade.js +27 -39
- package/lib/client.js +141 -51
- package/lib/clock.js +0 -2
- package/lib/error-taxonomy.js +0 -3
- package/lib/http-bridge.js +0 -7
- package/lib/index.js +22 -56
- package/lib/notify-events.js +64 -0
- package/lib/ops-keys.js +189 -0
- package/lib/ops-paths.js +11 -0
- package/lib/ops-status.js +202 -0
- package/lib/ops-telemetry.js +113 -0
- package/lib/ops-test.js +106 -0
- package/lib/ops-webhook.js +115 -0
- package/lib/plugin-updater.js +277 -0
- package/lib/pool.js +5 -8
- package/lib/routes-ops.js +12 -630
- package/lib/webhook.js +2 -1
- package/package.json +5 -2
package/lib/client.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// manual route typing is ever needed. The plugin derives the fallback chain and
|
|
10
10
|
// auto-creates clone routes from the key count.
|
|
11
11
|
//
|
|
12
|
-
// Localization: source strings are English
|
|
12
|
+
// Localization: source strings are English; Chinese is a first-class locale (en + zh).
|
|
13
13
|
// Other languages come from the DSH core locale service / translation plugins
|
|
14
14
|
// via props.t (slot locale: NS). Active locale prefers ctx.locale.getSnapshot().active,
|
|
15
15
|
// else first navigator.languages entry, else 'en' — same fallback chain as DSH core.
|
|
@@ -301,12 +301,18 @@ window.__ModuleLoader__.load({
|
|
|
301
301
|
selfHealingHint: '定期轻量探测以自动恢复损坏密钥 (0 为禁用)',
|
|
302
302
|
};
|
|
303
303
|
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
304
|
+
// Codes worth switching on. Host list is DEFAULT_SWITCH_CODES; config may
|
|
305
|
+
// add extras — those render as separate checked boxes so a custom rule is
|
|
306
|
+
// not dropped on first save.
|
|
307
307
|
const KNOWN_CODES = ['QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'UNKNOWN_MODEL', 'AUTH'];
|
|
308
308
|
|
|
309
309
|
// #273: keep the settings list item mounted if the section throws.
|
|
310
|
+
const bestEffort = (label, fn) => {
|
|
311
|
+
try { return fn(); } catch (err) {
|
|
312
|
+
try { console.debug('[dsh-key-rotation] best-effort ' + label, err); } catch { /* console unavailable */ }
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
};
|
|
310
316
|
class KeyRotationErrorBoundary extends React.Component {
|
|
311
317
|
constructor(props) {
|
|
312
318
|
super(props);
|
|
@@ -316,7 +322,7 @@ window.__ModuleLoader__.load({
|
|
|
316
322
|
return { error };
|
|
317
323
|
}
|
|
318
324
|
componentDidCatch(error, info) {
|
|
319
|
-
|
|
325
|
+
bestEffort('error-boundary.log', () => { console.error('[dsh-key-rotation] settings card crashed', error, info); });
|
|
320
326
|
}
|
|
321
327
|
render() {
|
|
322
328
|
if (this.state.error) {
|
|
@@ -333,7 +339,7 @@ window.__ModuleLoader__.load({
|
|
|
333
339
|
}
|
|
334
340
|
}
|
|
335
341
|
|
|
336
|
-
/**
|
|
342
|
+
/** Poll rotation status while the settings section is open (smart polling). */
|
|
337
343
|
function useRotationStatus() {
|
|
338
344
|
const [byProvider, setByProvider] = React.useState({});
|
|
339
345
|
React.useEffect(() => {
|
|
@@ -348,7 +354,7 @@ window.__ModuleLoader__.load({
|
|
|
348
354
|
for (const entry of data.providers) map[entry.provider] = entry;
|
|
349
355
|
setByProvider(map);
|
|
350
356
|
})
|
|
351
|
-
.catch(() => { /*
|
|
357
|
+
.catch(() => { /* status is optional: card stays a working editor */ });
|
|
352
358
|
};
|
|
353
359
|
pull();
|
|
354
360
|
const id = setInterval(pull, 4000);
|
|
@@ -363,7 +369,7 @@ window.__ModuleLoader__.load({
|
|
|
363
369
|
return byProvider;
|
|
364
370
|
}
|
|
365
371
|
|
|
366
|
-
/**
|
|
372
|
+
/** Latest probe result per key (#219): /sandbox-cache (smart polling). */
|
|
367
373
|
function useProbeCache() {
|
|
368
374
|
const [cache, setCache] = React.useState({});
|
|
369
375
|
React.useEffect(() => {
|
|
@@ -373,7 +379,7 @@ window.__ModuleLoader__.load({
|
|
|
373
379
|
fetch('/dsh-key-rotation/sandbox-cache', { headers: { accept: 'application/json' } })
|
|
374
380
|
.then((r) => (r.ok ? r.json() : null))
|
|
375
381
|
.then((data) => { if (alive && data) setCache(data); })
|
|
376
|
-
.catch(() => { /*
|
|
382
|
+
.catch(() => { /* cache is non-critical: card works without it */ });
|
|
377
383
|
};
|
|
378
384
|
pull();
|
|
379
385
|
const id = setInterval(pull, 4000);
|
|
@@ -397,10 +403,9 @@ window.__ModuleLoader__.load({
|
|
|
397
403
|
return t('hoursAgo').replace('{n}', String(Math.round(sec / 3600)));
|
|
398
404
|
}
|
|
399
405
|
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
// под ним ужимается сама.
|
|
406
|
+
// Card layout uses a grid, not ad-hoc inline widths. Fixed widths used to
|
|
407
|
+
// clip key names and collide actions with the value field, so the name owns
|
|
408
|
+
// its own row and the meta row shrinks on its own.
|
|
404
409
|
const CARD_CSS = [
|
|
405
410
|
'.krot{display:flex;flex-direction:column;gap:20px;max-width:960px;padding:6px 0 24px;box-sizing:border-box}',
|
|
406
411
|
'.krot-header{display:flex;flex-direction:column;gap:8px;padding-bottom:16px;border-bottom:1px solid var(--dsw-alias-border-l2)}',
|
|
@@ -408,7 +413,7 @@ window.__ModuleLoader__.load({
|
|
|
408
413
|
'.krot-page-sub{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5}',
|
|
409
414
|
'.krot p{margin:0}',
|
|
410
415
|
'.krot-hint{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.4}',
|
|
411
|
-
'.krot-err{font-size:13px;color:var(--dsw-alias-state-error-primary);padding:10px 14px;border-radius:8px;background:
|
|
416
|
+
'.krot-err{font-size:13px;color:var(--dsw-alias-state-error-primary);padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 10%, transparent);display:flex;align-items:center;gap:8px}',
|
|
412
417
|
'.krot-label{font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary)}',
|
|
413
418
|
'.krot-field{display:flex;flex-direction:column;gap:6px}',
|
|
414
419
|
'.krot-in{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;height:36px;font-family:inherit;min-width:0;width:100%;box-sizing:border-box;transition:border-color .15s ease}',
|
|
@@ -431,15 +436,15 @@ window.__ModuleLoader__.load({
|
|
|
431
436
|
'.krot-load-legend{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px;font-size:11px}',
|
|
432
437
|
'.krot-load-item{display:inline-flex;align-items:center;gap:4px}',
|
|
433
438
|
'.krot-load-dot{width:7px;height:7px;border-radius:50%}',
|
|
434
|
-
'.krot-modal-backdrop{position:fixed;top:0;left:0;right:0;bottom:0;background:
|
|
435
|
-
'.krot-modal-card{width:90%;max-width:420px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 12px 36px
|
|
439
|
+
'.krot-modal-backdrop{position:fixed;top:0;left:0;right:0;bottom:0;background:var(--dsw-alias-bg-overlay, color-mix(in srgb, var(--dsw-alias-bg-layer-1, #000) 55%, transparent));z-index:9999;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(2px)}',
|
|
440
|
+
'.krot-modal-card{width:90%;max-width:420px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 12px 36px color-mix(in srgb, var(--dsw-alias-bg-layer-1, #000) 28%, transparent);padding:20px;display:flex;flex-direction:column;gap:12px}',
|
|
436
441
|
'.krot-modal-title{font-size:16px;font-weight:600;color:var(--dsw-alias-label-primary)}',
|
|
437
442
|
'.krot-modal-desc{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.45}',
|
|
438
443
|
'.krot-modal-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:8px}',
|
|
439
444
|
'.krot-badge{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:5px;font-weight:500;white-space:nowrap}',
|
|
440
|
-
'.krot-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:
|
|
441
|
-
'.krot-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:
|
|
442
|
-
'.krot-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:
|
|
445
|
+
'.krot-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary, #10b981) 8%, transparent)}',
|
|
446
|
+
'.krot-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary, #f59e0b) 8%, transparent)}',
|
|
447
|
+
'.krot-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 8%, transparent)}',
|
|
443
448
|
'.krot-prov{display:flex;flex-direction:column;gap:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-2);padding:14px 16px}',
|
|
444
449
|
'.krot-prov-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap}',
|
|
445
450
|
'.krot-prov-head select{flex:1;min-width:180px}',
|
|
@@ -457,21 +462,22 @@ window.__ModuleLoader__.load({
|
|
|
457
462
|
'.krot-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4,var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed,var(--dsw-alias-border-l2))}',
|
|
458
463
|
'.krot-btn-primary,.krot-save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent;font-weight:600}',
|
|
459
464
|
'.krot-btn-primary:hover:not(:disabled),.krot-save:hover:not(:disabled){background:var(--dsw-alias-label-primary)!important;color:var(--dsw-alias-bg-layer-3)!important;opacity:0.88}',
|
|
460
|
-
'.krot-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:
|
|
461
|
-
'.krot-btn-danger:hover:not(:disabled){background:
|
|
465
|
+
'.krot-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 30%, transparent)}',
|
|
466
|
+
'.krot-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 12%, transparent)!important;border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 50%, transparent)}',
|
|
462
467
|
'.krot-btn:disabled{opacity:0.45;cursor:not-allowed}',
|
|
463
468
|
'.krot-foot{display:flex;gap:10px;align-items:center;flex-wrap:wrap}',
|
|
464
469
|
'.krot-filter-bar{display:flex;gap:8px;margin:4px 0 10px;flex-wrap:wrap}',
|
|
465
470
|
'.krot-pill{appearance:none;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:4px 12px;font-size:12px;font-weight:500;color:var(--dsw-alias-label-secondary);cursor:pointer;transition:all .15s ease}',
|
|
466
471
|
'.krot-pill:hover{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}',
|
|
467
472
|
'.krot-pill-active{background:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)!important;font-weight:600}',
|
|
468
|
-
'.krot-pill-warn{border-color:
|
|
469
|
-
'.krot-pill-err{border-color:
|
|
470
|
-
'.krot-alert-ok{padding:10px 14px;border-radius:8px;background:
|
|
471
|
-
'.krot-alert-bad{padding:10px 14px;border-radius:8px;background:
|
|
473
|
+
'.krot-pill-warn{border-color:color-mix(in srgb, var(--dsw-alias-state-warning-primary, #f59e0b) 30%, transparent);color:var(--dsw-alias-state-warning-primary)}',
|
|
474
|
+
'.krot-pill-err{border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 30%, transparent);color:var(--dsw-alias-state-error-primary)}',
|
|
475
|
+
'.krot-alert-ok{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-success-primary, #10b981) 10%, transparent);color:var(--dsw-alias-state-success-primary);font-size:13px;display:flex;align-items:center;gap:10px}',
|
|
476
|
+
'.krot-alert-bad{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px;display:flex;align-items:center;gap:10px}',
|
|
472
477
|
'.krot-event-stream{display:flex;flex-direction:column;gap:6px;margin-top:8px;padding:10px 12px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:8px}',
|
|
473
478
|
'.krot-event-row{display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0;border-bottom:1px solid var(--dsw-alias-border-l1)}',
|
|
474
479
|
'.krot-event-time{font-family:ui-monospace,Menlo,Consolas,monospace;color:var(--dsw-alias-label-tertiary);font-size:11px}',
|
|
480
|
+
':root{--krot-chart-4:var(--dsw-alias-state-brand-primary);--krot-chart-5:var(--dsw-alias-state-error-primary);--krot-chart-6:var(--dsw-alias-state-success-primary)}',
|
|
475
481
|
'.krot-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}',
|
|
476
482
|
'.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}',
|
|
477
483
|
'.krot-card-head-text{display:flex;flex-direction:column;flex:1;gap:4px;min-width:0}',
|
|
@@ -481,7 +487,7 @@ window.__ModuleLoader__.load({
|
|
|
481
487
|
'.krot-card-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:16px 0 8px}',
|
|
482
488
|
'.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}',
|
|
483
489
|
'.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)}',
|
|
484
|
-
'.krot-popover{position:absolute;top:calc(100% + 6px);right:0;z-index:10000;min-width:220px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:12px 14px;box-shadow:0 16px 40px
|
|
490
|
+
'.krot-popover{position:absolute;top:calc(100% + 6px);right:0;z-index:10000;min-width:220px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:12px 14px;box-shadow:0 16px 40px color-mix(in srgb, var(--dsw-alias-bg-layer-1, #000) 45%, transparent),0 2px 8px color-mix(in srgb, var(--dsw-alias-bg-layer-1, #000) 15%, transparent);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);display:flex;flex-direction:column;gap:8px;text-align:left}',
|
|
485
491
|
'.krot-pop-title{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--dsw-alias-label-secondary)}',
|
|
486
492
|
'.krot-pop-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:4px 0}',
|
|
487
493
|
'.krot-pop-name{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:8px}',
|
|
@@ -499,13 +505,13 @@ window.__ModuleLoader__.load({
|
|
|
499
505
|
}
|
|
500
506
|
|
|
501
507
|
/**
|
|
502
|
-
*
|
|
508
|
+
* Env-var name for a newly added key.
|
|
503
509
|
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
510
|
+
* Users no longer type it: the first key of a provider becomes
|
|
511
|
+
* <PROVIDER>_API_KEY, later keys reuse that root with _2, _3… suffixes.
|
|
512
|
+
* The root is taken from existing keys so hand-made names keep working,
|
|
513
|
+
* and uniqueness is checked across ALL providers — otherwise two providers
|
|
514
|
+
* would silently share one credential.
|
|
509
515
|
*/
|
|
510
516
|
// nextKeyRef also in lib/client-helpers.js
|
|
511
517
|
function nextKeyRef(providerId, existingKeys, allRefs) {
|
|
@@ -650,7 +656,7 @@ window.__ModuleLoader__.load({
|
|
|
650
656
|
cancelAnimationFrame(id);
|
|
651
657
|
const prev = modalReturnFocusRef.current;
|
|
652
658
|
if (prev && typeof prev.focus === 'function') {
|
|
653
|
-
|
|
659
|
+
bestEffort('modal.returnFocus', () => { prev.focus(); });
|
|
654
660
|
}
|
|
655
661
|
};
|
|
656
662
|
}, [confirmModal]);
|
|
@@ -885,8 +891,7 @@ window.__ModuleLoader__.load({
|
|
|
885
891
|
stashUndo({ type: 'provider', index: pIndex, entry: arr[pIndex] });
|
|
886
892
|
return { ...cur, providers: arr.filter((_, i) => i !== pIndex) };
|
|
887
893
|
});
|
|
888
|
-
//
|
|
889
|
-
// а не перепечатыванием имён.
|
|
894
|
+
// Key order is attempt order — move it with buttons, do not retype names.
|
|
890
895
|
const moveKey = (pIndex, kIndex, delta) => setField((cur) => {
|
|
891
896
|
const providers = [...(cur.providers ?? [])];
|
|
892
897
|
const entry = { ...(providers[pIndex] ?? {}) };
|
|
@@ -921,8 +926,8 @@ window.__ModuleLoader__.load({
|
|
|
921
926
|
return { ...cur, providers };
|
|
922
927
|
});
|
|
923
928
|
|
|
924
|
-
//
|
|
925
|
-
//
|
|
929
|
+
// Config codes missing from the known list still render as checked items;
|
|
930
|
+
// otherwise the checkboxes would silently drop a custom rule on first save.
|
|
926
931
|
const selectedCodes = new Set(Array.isArray(val.switchCodes) ? val.switchCodes : []);
|
|
927
932
|
const codeList = [...KNOWN_CODES, ...[...selectedCodes].filter((c) => !KNOWN_CODES.includes(c))];
|
|
928
933
|
const toggleCode = (code, on) => setField((cur) => {
|
|
@@ -992,8 +997,8 @@ window.__ModuleLoader__.load({
|
|
|
992
997
|
title: (opts && opts.title) || undefined,
|
|
993
998
|
}, labelText);
|
|
994
999
|
|
|
995
|
-
//
|
|
996
|
-
//
|
|
1000
|
+
// Key state dot: color and label read at a glance; "key not found"
|
|
1001
|
+
// catches an env-name typo that would otherwise stay silent.
|
|
997
1002
|
const keyStatus = (providerId, ref) => {
|
|
998
1003
|
const hit = keyInfo(providerId, ref);
|
|
999
1004
|
if (!hit) return null;
|
|
@@ -1062,8 +1067,7 @@ window.__ModuleLoader__.load({
|
|
|
1062
1067
|
const typed = secretDraft[rowKey];
|
|
1063
1068
|
const fromEnv = Boolean(info && info.source === 'env');
|
|
1064
1069
|
|
|
1065
|
-
//
|
|
1066
|
-
// соседние ключи выглядели одинаково.
|
|
1070
|
+
// Key name owns a full row so neighbouring keys stay distinguishable.
|
|
1067
1071
|
const nameRow = [
|
|
1068
1072
|
h('span', { className: 'krot-num', key: 'n' }, String(kIndex + 1)),
|
|
1069
1073
|
h('span', { key: 'i', className: 'krot-name', title: key + ' (click to copy)', style: { cursor: 'copy' }, onClick: () => {
|
|
@@ -1204,13 +1208,13 @@ window.__ModuleLoader__.load({
|
|
|
1204
1208
|
});
|
|
1205
1209
|
const total = keyUsages.reduce((acc, x) => acc + x.usage, 0);
|
|
1206
1210
|
const palette = [
|
|
1207
|
-
'var(--dsw-alias-state-brand-primary
|
|
1208
|
-
'var(--dsw-alias-state-success-primary
|
|
1209
|
-
'var(--dsw-alias-state-warning-primary
|
|
1210
|
-
'var(--dsw-alias-state-info-primary
|
|
1211
|
-
'
|
|
1212
|
-
'
|
|
1213
|
-
'
|
|
1211
|
+
'var(--dsw-alias-state-brand-primary)',
|
|
1212
|
+
'var(--dsw-alias-state-success-primary)',
|
|
1213
|
+
'var(--dsw-alias-state-warning-primary)',
|
|
1214
|
+
'var(--dsw-alias-state-info-primary)',
|
|
1215
|
+
'var(--krot-chart-4)',
|
|
1216
|
+
'var(--krot-chart-5)',
|
|
1217
|
+
'var(--krot-chart-6)',
|
|
1214
1218
|
];
|
|
1215
1219
|
return h('div', { className: 'krot-load-chart' },
|
|
1216
1220
|
h('div', { className: 'krot-load-header' },
|
|
@@ -1311,7 +1315,8 @@ window.__ModuleLoader__.load({
|
|
|
1311
1315
|
filterBar,
|
|
1312
1316
|
loadChart,
|
|
1313
1317
|
h('div', { className: 'krot-keys' }, keyRows),
|
|
1314
|
-
h(
|
|
1318
|
+
h(UpdaterSection, { t }),
|
|
1319
|
+
h('div', { className: 'krot-foot' },
|
|
1315
1320
|
btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle') }),
|
|
1316
1321
|
switchesLine,
|
|
1317
1322
|
budgetLine,
|
|
@@ -1710,7 +1715,72 @@ window.__ModuleLoader__.load({
|
|
|
1710
1715
|
// Collapsible card in Settings -> Plugins -> Plugin settings
|
|
1711
1716
|
// (settings.plugin.item), matching Model Sync / Spendmeter / Vision Bridge.
|
|
1712
1717
|
// key MUST equal the settings namespace (NS), else the tab silently skips it.
|
|
1713
|
-
|
|
1718
|
+
|
|
1719
|
+
function UpdaterSection({ t }) {
|
|
1720
|
+
const [state, setState] = React.useState({ phase: 'idle', current: '', latest: '', message: '' });
|
|
1721
|
+
const load = React.useCallback(async () => {
|
|
1722
|
+
setState((s) => ({ ...s, phase: 'loading', message: '' }));
|
|
1723
|
+
try {
|
|
1724
|
+
const res = await fetch('/api/dsh-key-rotation/update', { headers: { 'x-dsh-plugin-update': '1' } });
|
|
1725
|
+
const data = await res.json();
|
|
1726
|
+
if (!res.ok) throw new Error(data?.error?.message || t('updateFailed'));
|
|
1727
|
+
setState({
|
|
1728
|
+
phase: data.updateAvailable ? 'available' : 'current',
|
|
1729
|
+
current: data.currentVersion || '',
|
|
1730
|
+
latest: data.latestVersion || '',
|
|
1731
|
+
message: data.updateAvailable ? t('updateAvailable') : t('updateNone'),
|
|
1732
|
+
});
|
|
1733
|
+
} catch (e) {
|
|
1734
|
+
setState({ phase: 'error', current: '', latest: '', message: e?.message || t('updateFailed') });
|
|
1735
|
+
}
|
|
1736
|
+
}, [t]);
|
|
1737
|
+
React.useEffect(() => { load(); }, [load]);
|
|
1738
|
+
const run = async () => {
|
|
1739
|
+
setState((s) => ({ ...s, phase: 'running', message: t('updateRunning') }));
|
|
1740
|
+
try {
|
|
1741
|
+
const res = await fetch('/api/dsh-key-rotation/update', {
|
|
1742
|
+
method: 'POST',
|
|
1743
|
+
headers: { 'x-dsh-plugin-update': '1', 'content-type': 'application/json' },
|
|
1744
|
+
});
|
|
1745
|
+
const data = await res.json();
|
|
1746
|
+
if (!res.ok) throw new Error(data?.error?.message || t('updateFailed'));
|
|
1747
|
+
setState({
|
|
1748
|
+
phase: 'done',
|
|
1749
|
+
current: data.currentVersion || '',
|
|
1750
|
+
latest: data.installedVersion || data.latestVersion || '',
|
|
1751
|
+
message: t('updateRestart'),
|
|
1752
|
+
});
|
|
1753
|
+
} catch (e) {
|
|
1754
|
+
setState({ phase: 'error', current: '', latest: '', message: e?.message || t('updateFailed') });
|
|
1755
|
+
}
|
|
1756
|
+
};
|
|
1757
|
+
return h('div', { className: 'krot-update', style: { marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--dsw-alias-border-l2)' } },
|
|
1758
|
+
h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
|
|
1759
|
+
h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
|
|
1760
|
+
t('currentVersion') + ': ' + (state.current || '—')),
|
|
1761
|
+
state.latest ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
|
|
1762
|
+
t('latestVersion') + ': ' + state.latest) : null,
|
|
1763
|
+
h('button', {
|
|
1764
|
+
className: 'krot-btn',
|
|
1765
|
+
type: 'button',
|
|
1766
|
+
disabled: state.phase === 'running' || state.phase === 'loading',
|
|
1767
|
+
onClick: load,
|
|
1768
|
+
}, t('updateCheck')),
|
|
1769
|
+
state.phase === 'available' ? h('button', {
|
|
1770
|
+
className: 'krot-btn krot-btn-primary',
|
|
1771
|
+
type: 'button',
|
|
1772
|
+
disabled: state.phase === 'running',
|
|
1773
|
+
onClick: run,
|
|
1774
|
+
}, t('updateRun')) : null,
|
|
1775
|
+
),
|
|
1776
|
+
state.message ? h('div', {
|
|
1777
|
+
className: state.phase === 'error' ? 'krot-alert-bad' : state.phase === 'done' ? 'krot-alert-ok' : '',
|
|
1778
|
+
style: { marginTop: 8, fontSize: 12 },
|
|
1779
|
+
}, state.message) : null,
|
|
1780
|
+
);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
function KeyRotationCard(props) {
|
|
1714
1784
|
const locale = useLocale();
|
|
1715
1785
|
const t = resolveT(props);
|
|
1716
1786
|
const [open, setOpen] = React.useState(false);
|
|
@@ -1733,11 +1803,31 @@ window.__ModuleLoader__.load({
|
|
|
1733
1803
|
KeyRotationCard,
|
|
1734
1804
|
));
|
|
1735
1805
|
} catch (e) {
|
|
1736
|
-
|
|
1806
|
+
bestEffort('slots.register.log', () => { console.error('[dsh-key-rotation] settings.plugin.item register failed', e); });
|
|
1737
1807
|
}
|
|
1738
1808
|
}
|
|
1739
1809
|
|
|
1740
1810
|
module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'] };
|
|
1741
1811
|
return module.exports;
|
|
1742
1812
|
},
|
|
1813
|
+
updateCheck: 'Check for updates',
|
|
1814
|
+
updateAvailable: 'Update available',
|
|
1815
|
+
updateNone: 'Plugin is up to date',
|
|
1816
|
+
updateRun: 'Update now',
|
|
1817
|
+
updateRestart: 'Update installed. Restart DSH to load the new version.',
|
|
1818
|
+
updateRunning: 'Updating…',
|
|
1819
|
+
updateFailed: 'Update failed',
|
|
1820
|
+
currentVersion: 'Current version',
|
|
1821
|
+
latestVersion: 'Latest version',
|
|
1822
|
+
|
|
1823
|
+
updateCheck: '检查更新',
|
|
1824
|
+
updateAvailable: '有可用更新',
|
|
1825
|
+
updateNone: '插件已是最新版本',
|
|
1826
|
+
updateRun: '立即更新',
|
|
1827
|
+
updateRestart: '更新已安装。请重启 DSH 以加载新版本。',
|
|
1828
|
+
updateRunning: '更新中…',
|
|
1829
|
+
updateFailed: '更新失败',
|
|
1830
|
+
currentVersion: '当前版本',
|
|
1831
|
+
latestVersion: '最新版本',
|
|
1832
|
+
|
|
1743
1833
|
});
|
package/lib/clock.js
CHANGED
package/lib/error-taxonomy.js
CHANGED
package/lib/http-bridge.js
CHANGED
|
@@ -78,13 +78,6 @@ export function providerCatalog(ctx, cloneIds) {
|
|
|
78
78
|
return out;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
export function guardLocal(req, res, label) {
|
|
82
|
-
if (!isTrustedBridgeRequest(req)) {
|
|
83
|
-
json(res, 403, { error: { code: 'forbidden', message: `dsh-key-rotation: ${label} is local-only` } });
|
|
84
|
-
return false;
|
|
85
|
-
}
|
|
86
|
-
return true;
|
|
87
|
-
}
|
|
88
81
|
|
|
89
82
|
export function scanForLiveSecrets(section) {
|
|
90
83
|
const masked = structuredClone(section);
|
package/lib/index.js
CHANGED
|
@@ -52,6 +52,9 @@ import { safeParseJson } from './atomic-io.js';
|
|
|
52
52
|
import { registerOpsRoutes } from './routes-ops.js';
|
|
53
53
|
import { StatePersistence, resolveStatePath } from './persistence.js';
|
|
54
54
|
import path from 'node:path';
|
|
55
|
+
import { registerPluginUpdater } from './plugin-updater.js'
|
|
56
|
+
import { notifySwitch, notifyExhaustion, pushEvent } from './notify-events.js';
|
|
57
|
+
export { notifySwitch, notifyExhaustion };
|
|
55
58
|
|
|
56
59
|
/** The llm-pi-ai namespace whose provider profiles map providers to pools. */
|
|
57
60
|
const PIAI_NS = 'llm-pi-ai';
|
|
@@ -62,40 +65,12 @@ let rotationDisabled = false;
|
|
|
62
65
|
// #207/#208 dedupe maps: one notification per key/window per day.
|
|
63
66
|
const expiryNotifiedAt = new Map();
|
|
64
67
|
const budgetNotifiedAt = new Map();
|
|
65
|
-
const switchNotifiedAt = new Map();
|
|
66
68
|
const lowHealthNotifiedAt = new Map();
|
|
67
69
|
const sloNotifiedAt = new Map();
|
|
68
70
|
const DAY_MS = 86400000;
|
|
69
71
|
|
|
70
72
|
// #216: one webhook per switch, deduped to at most one message per provider
|
|
71
73
|
// per switchNotifyThrottleMs. Extracted for testability.
|
|
72
|
-
export function notifySwitch(runtime, pool, info, hooks = { webhookSender, now: () => Date.now() }) {
|
|
73
|
-
if (!runtime?.notifyWebhook) return;
|
|
74
|
-
const throttle = Math.max(0, runtime.switchNotifyThrottleMs ?? 60000);
|
|
75
|
-
const last = switchNotifiedAt.get(info.provider) ?? 0;
|
|
76
|
-
const now = hooks.now();
|
|
77
|
-
if (now - last < throttle) return;
|
|
78
|
-
switchNotifiedAt.set(info.provider, now);
|
|
79
|
-
// #263: non-blocking — enqueue never awaits webhook I/O
|
|
80
|
-
const send = hooks.notifyQueue
|
|
81
|
-
? (url, payload) => { hooks.notifyQueue.enqueue(url, payload); return { sent: true, queued: true }; }
|
|
82
|
-
: (url, payload) => hooks.webhookSender.send(url, payload);
|
|
83
|
-
send(runtime.notifyWebhook, {
|
|
84
|
-
title: `Key switched: ${info.provider}`,
|
|
85
|
-
text: `${info.from} failed (${info.code}) - next key in pool`,
|
|
86
|
-
provider: info.provider,
|
|
87
|
-
kind: 'switch',
|
|
88
|
-
from: info.from,
|
|
89
|
-
code: info.code,
|
|
90
|
-
at: info.at,
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
const MAX_EVENTS = 50;
|
|
94
|
-
function pushEvent(pool, ref, reason, cooldownMs, type) {
|
|
95
|
-
const ev = { at: Date.now(), ref, reason: String(reason ?? 'UNKNOWN'), cooldownMs, type: type ?? 'fail' };
|
|
96
|
-
pool.state.events.push(ev);
|
|
97
|
-
if (pool.state.events.length > MAX_EVENTS) pool.state.events.shift();
|
|
98
|
-
}
|
|
99
74
|
|
|
100
75
|
|
|
101
76
|
// Fallback classification by failure message. pi-ai surfaces many real quota /
|
|
@@ -362,14 +337,17 @@ export function apply(ctx, config = {}) {
|
|
|
362
337
|
if (!val) return { ok: false };
|
|
363
338
|
return runner.probeModels(ref, val);
|
|
364
339
|
});
|
|
365
|
-
} catch (
|
|
340
|
+
} catch (e) { ctx.logger?.warn?.('[dsh-key-rotation] auto-unbreak failed', e); }
|
|
366
341
|
}, intervalMs);
|
|
367
342
|
if (typeof timer.unref === 'function') timer.unref();
|
|
368
343
|
return () => clearInterval(timer);
|
|
369
344
|
}, 'dsh-key-rotation: auto-unbreak');
|
|
370
345
|
|
|
371
346
|
ctx.effect(() => {
|
|
372
|
-
const timer = setInterval(() => {
|
|
347
|
+
const timer = setInterval(() => {
|
|
348
|
+
try { schedulePersist(); }
|
|
349
|
+
catch (e) { ctx.logger?.warn?.('[dsh-key-rotation] periodic persist failed', e); }
|
|
350
|
+
}, 15000);
|
|
373
351
|
if (typeof timer.unref === 'function') timer.unref();
|
|
374
352
|
return () => {
|
|
375
353
|
clearInterval(timer);
|
|
@@ -383,7 +361,9 @@ export function apply(ctx, config = {}) {
|
|
|
383
361
|
}
|
|
384
362
|
statePersistence.dispose();
|
|
385
363
|
}
|
|
386
|
-
} catch (
|
|
364
|
+
} catch (e) {
|
|
365
|
+
ctx.logger?.warn?.('[dsh-key-rotation] dispose persist failed', e);
|
|
366
|
+
}
|
|
387
367
|
};
|
|
388
368
|
}, 'dsh-key-rotation: state persistence');
|
|
389
369
|
// Periodic sweep of expired cooldowns — keeps health probe cheap and avoids waiting for next user request
|
|
@@ -857,6 +837,7 @@ export function apply(ctx, config = {}) {
|
|
|
857
837
|
// the resolve patch hands out the next key on each dispatch.
|
|
858
838
|
// Operational routes (status/usage/snapshot/key/import/test/health/webhook) (#253)
|
|
859
839
|
registerOpsRoutes(ctx, {
|
|
840
|
+
|
|
860
841
|
buildRuntime,
|
|
861
842
|
latencyHistogram,
|
|
862
843
|
lastTestCache,
|
|
@@ -868,6 +849,16 @@ export function apply(ctx, config = {}) {
|
|
|
868
849
|
quotaStore,
|
|
869
850
|
});
|
|
870
851
|
|
|
852
|
+
// One-click plugin updater (#307). GET status / POST install exact registry version.
|
|
853
|
+
ctx.effect(() => {
|
|
854
|
+
if (typeof ctx.webServer?.register !== 'function') return;
|
|
855
|
+
registerPluginUpdater(ctx, {
|
|
856
|
+
endpoint: '/api/dsh-key-rotation/update',
|
|
857
|
+
packageName: '@goodandready/dsh-key-rotation',
|
|
858
|
+
manifestUrl: new URL('../package.json', import.meta.url),
|
|
859
|
+
});
|
|
860
|
+
}, 'dsh-key-rotation: plugin updater');
|
|
861
|
+
|
|
871
862
|
ctx.effect(() => ctx.on('llm/stream', (options, next) => {
|
|
872
863
|
if (options[MARKER]) return next();
|
|
873
864
|
if (rotationDisabled) return next(); // #199: disabled via webhook action
|
|
@@ -930,29 +921,4 @@ export function apply(ctx, config = {}) {
|
|
|
930
921
|
// Notify on exhaustion: webhook notification.
|
|
931
922
|
// Extracted at module scope for testability. No I/O outside the injected hooks.
|
|
932
923
|
// ponytail: thresholds and URLs are runtime-resolved per call, so changing Config is reflected immediately.
|
|
933
|
-
export function notifyExhaustion(runtime, pool, options, hooks = { webhookSender }) {
|
|
934
|
-
if (!runtime || !pool) return;
|
|
935
|
-
const count = pool.state ? (pool.state.exhaustionCount ?? 0) : 0;
|
|
936
|
-
if (count <= 0) return;
|
|
937
|
-
try {
|
|
938
|
-
if (runtime.notifyWebhook && count >= (runtime.notifyThreshold ?? 0)) {
|
|
939
|
-
const token = runtime.webhookActionToken ?? '';
|
|
940
|
-
const payload = {
|
|
941
|
-
title: `Key pool exhausted: ${options.provider}`,
|
|
942
|
-
text: `${count} exhaustion(s); keys: ${(pool.refs ?? []).join(', ')}`,
|
|
943
|
-
provider: options.provider,
|
|
944
|
-
exhaustionCount: count,
|
|
945
|
-
at: pool.state.lastExhaustionAt,
|
|
946
|
-
keys: pool.refs,
|
|
947
|
-
actionToken: token || undefined,
|
|
948
|
-
actions: token ? [
|
|
949
|
-
{ id: `reset-${options.provider}`, label: 'Reset cooldown' },
|
|
950
|
-
{ id: `pause-${options.provider}`, label: 'Pause 1h' },
|
|
951
|
-
] : undefined,
|
|
952
|
-
};
|
|
953
|
-
if (hooks.notifyQueue) hooks.notifyQueue.enqueue(runtime.notifyWebhook, payload);
|
|
954
|
-
else hooks.webhookSender.send(runtime.notifyWebhook, payload);
|
|
955
|
-
}
|
|
956
|
-
} catch (_) { /* ponytail: never crash rotate() */ }
|
|
957
|
-
}
|
|
958
924
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// lib/notify-events.js — switch/exhaustion notify helpers and pool event ring (#312).
|
|
2
|
+
// Extracted from index.js for size and testability; behavior unchanged.
|
|
3
|
+
|
|
4
|
+
import { WebhookSender } from './webhook.js';
|
|
5
|
+
|
|
6
|
+
const switchNotifiedAt = new Map();
|
|
7
|
+
const MAX_EVENTS = 50;
|
|
8
|
+
// Default sender for callers that omit hooks (matches former index.js module scope).
|
|
9
|
+
const defaultWebhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
|
|
10
|
+
|
|
11
|
+
export function notifySwitch(runtime, pool, info, hooks = { webhookSender: defaultWebhookSender, now: () => Date.now() }) {
|
|
12
|
+
if (!runtime?.notifyWebhook) return;
|
|
13
|
+
const throttle = Math.max(0, runtime.switchNotifyThrottleMs ?? 60000);
|
|
14
|
+
const last = switchNotifiedAt.get(info.provider) ?? 0;
|
|
15
|
+
const now = hooks.now();
|
|
16
|
+
if (now - last < throttle) return;
|
|
17
|
+
switchNotifiedAt.set(info.provider, now);
|
|
18
|
+
// #263: non-blocking — enqueue never awaits webhook I/O
|
|
19
|
+
const send = hooks.notifyQueue
|
|
20
|
+
? (url, payload) => { hooks.notifyQueue.enqueue(url, payload); return { sent: true, queued: true }; }
|
|
21
|
+
: (url, payload) => hooks.webhookSender.send(url, payload);
|
|
22
|
+
send(runtime.notifyWebhook, {
|
|
23
|
+
title: `Key switched: ${info.provider}`,
|
|
24
|
+
text: `${info.from} failed (${info.code}) - next key in pool`,
|
|
25
|
+
provider: info.provider,
|
|
26
|
+
kind: 'switch',
|
|
27
|
+
from: info.from,
|
|
28
|
+
code: info.code,
|
|
29
|
+
at: info.at,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function pushEvent(pool, ref, reason, cooldownMs, type) {
|
|
34
|
+
const ev = { at: Date.now(), ref, reason: String(reason ?? 'UNKNOWN'), cooldownMs, type: type ?? 'fail' };
|
|
35
|
+
pool.state.events.push(ev);
|
|
36
|
+
if (pool.state.events.length > MAX_EVENTS) pool.state.events.shift();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function notifyExhaustion(runtime, pool, options, hooks = { webhookSender: defaultWebhookSender }) {
|
|
40
|
+
if (!runtime || !pool) return;
|
|
41
|
+
const count = pool.state ? (pool.state.exhaustionCount ?? 0) : 0;
|
|
42
|
+
if (count <= 0) return;
|
|
43
|
+
try {
|
|
44
|
+
if (runtime.notifyWebhook && count >= (runtime.notifyThreshold ?? 0)) {
|
|
45
|
+
const token = runtime.webhookActionToken ?? '';
|
|
46
|
+
const payload = {
|
|
47
|
+
title: `Key pool exhausted: ${options.provider}`,
|
|
48
|
+
text: `${count} exhaustion(s); keys: ${(pool.refs ?? []).join(', ')}`,
|
|
49
|
+
provider: options.provider,
|
|
50
|
+
exhaustionCount: count,
|
|
51
|
+
at: pool.state.lastExhaustionAt,
|
|
52
|
+
keys: pool.refs,
|
|
53
|
+
actionToken: token || undefined,
|
|
54
|
+
actions: token ? [
|
|
55
|
+
{ id: `reset-${options.provider}`, label: 'Reset cooldown' },
|
|
56
|
+
{ id: `pause-${options.provider}`, label: 'Pause 1h' },
|
|
57
|
+
] : undefined,
|
|
58
|
+
};
|
|
59
|
+
if (hooks.notifyQueue) hooks.notifyQueue.enqueue(runtime.notifyWebhook, payload);
|
|
60
|
+
else hooks.webhookSender.send(runtime.notifyWebhook, payload);
|
|
61
|
+
}
|
|
62
|
+
} catch (_) { /* ponytail: never crash rotate() */ }
|
|
63
|
+
}
|
|
64
|
+
|