@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/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 only (ctx.locale.register(NS, { en })).
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
- // (DEFAULT_SWITCH_CODES); конфиг может содержать и свои — они показываются
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
- try { console.error('[dsh-key-rotation] settings card crashed', error, info); } catch (_) {}
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
- /** Опрос статуса ротации, пока раздел настроек открыт (smart polling). */
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
- /** Последний probe-результат по каждому ключу (#219): /sandbox-cache (smart polling). */
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
- // Разметка карточки: сетка, а не набор inline-стилей. Фиксированные ширины
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:rgba(239,68,68,0.1);display:flex;align-items:center;gap:8px}',
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:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(2px)}',
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 rgba(0,0,0,0.28);padding:20px;display:flex;flex-direction:column;gap:12px}',
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:rgba(16,185,129,0.08)}',
441
- '.krot-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:rgba(245,158,11,0.08)}',
442
- '.krot-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:rgba(239,68,68,0.08)}',
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:rgba(239,68,68,0.3)}',
461
- '.krot-btn-danger:hover:not(:disabled){background:rgba(239,68,68,0.12)!important;border-color:rgba(239,68,68,0.5)}',
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:rgba(245,158,11,0.3);color:var(--dsw-alias-state-warning-primary)}',
469
- '.krot-pill-err{border-color:rgba(239,68,68,0.3);color:var(--dsw-alias-state-error-primary)}',
470
- '.krot-alert-ok{padding:10px 14px;border-radius:8px;background:rgba(16,185,129,0.1);color:var(--dsw-alias-state-success-primary);font-size:13px;display:flex;align-items:center;gap:10px}',
471
- '.krot-alert-bad{padding:10px 14px;border-radius:8px;background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary);font-size:13px;display:flex;align-items:center;gap:10px}',
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 rgba(0,0,0,.45),0 2px 8px rgba(0,0,0,.15);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);display:flex;flex-direction:column;gap:8px;text-align:left}',
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
- * вида <PROVIDER>_API_KEY, следующие — тот же корень с суффиксом _2, _3…
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
- try { prev.focus(); } catch (_) {}
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
- // а «ключ не найден» ловит опечатку в имени env, которая иначе молчит.
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, #6366f1)',
1208
- 'var(--dsw-alias-state-success-primary, #10b981)',
1209
- 'var(--dsw-alias-state-warning-primary, #f59e0b)',
1210
- 'var(--dsw-alias-state-info-primary, #3b82f6)',
1211
- '#8b5cf6',
1212
- '#ec4899',
1213
- '#14b8a6',
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('div', { className: 'krot-foot' },
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
- function KeyRotationCard(props) {
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
- try { console.error('[dsh-key-rotation] settings.plugin.item register failed', e); } catch (_) {}
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
@@ -20,5 +20,3 @@ export function nowMono() {
20
20
  return performance.timeOrigin + performance.now();
21
21
  }
22
22
 
23
- /** Default clock injectable into pure helpers. */
24
- export const defaultClock = { nowWall, nowMono };
@@ -61,6 +61,3 @@ export function classifyFailure(failureOrPayload) {
61
61
  }
62
62
 
63
63
  /** True when classification says switch to next key. */
64
- export function shouldSwitch(failureOrPayload) {
65
- return classifyFailure(failureOrPayload).action === 'switch';
66
- }
@@ -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(() => { try { schedulePersist(); } catch (_) {} }, 15000);
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
+