@goodandready/dsh-key-rotation 0.8.65 → 0.8.67

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
@@ -104,6 +104,11 @@ window.__ModuleLoader__.load({
104
104
  presetNone: 'No canonical preset for this provider',
105
105
  detectedLimitsLabel: 'Upstream: {rpm} RPM · {tpm} TPM',
106
106
  trafficSmoothingLabel: 'Traffic Smoothing (Burst)',
107
+ balanceLabel: 'Balance: {amt} {curr}',
108
+ balanceEmpty: 'Balance Empty (Paused)',
109
+ balanceCheckBtn: '💰 Check Balance',
110
+ checkingBalance: 'Checking…',
111
+ upstreamResetIn: '⚡ Reset in {time} (header)',
107
112
  pauseKey: 'Pause key from rotation',
108
113
  resumeKey: 'Resume key rotation',
109
114
  sparklineTraffic: 'Traffic Trend',
@@ -256,6 +261,28 @@ window.__ModuleLoader__.load({
256
261
  autoImportBtn: '1-Click Auto-Import',
257
262
  autoImportSuccess: 'Imported {n} keys into rotation pools',
258
263
  autoImporting: 'Importing…',
264
+ quickSkip: 'Skip',
265
+ quickSkipTitle: 'Skip to next key in pool',
266
+ quickReset: 'Reset',
267
+ quickResetTitle: 'Reset cooldowns and circuit breaker for active provider',
268
+ quickPause: 'Pause',
269
+ quickPauseTitle: 'Pause or resume active key',
270
+ exportConfig: 'Export Pools',
271
+ importConfig: 'Import Pools',
272
+ exportSafe: 'Masked (Safe for sharing)',
273
+ exportFull: 'Full Backup (Includes secrets)',
274
+ exportWithPassphrase: 'Encrypted with passphrase',
275
+ importMode: 'Import Mode',
276
+ importMerge: 'Merge with existing pools',
277
+ importReplace: 'Replace entire pool configuration',
278
+ importPreview: 'Preview Diff',
279
+ importApply: 'Apply Configuration',
280
+ importSuccess: 'Configuration successfully imported and applied',
281
+ exportSuccess: 'Configuration exported successfully',
282
+ passphraseLabel: 'Passphrase (optional)',
283
+ passphrasePlaceholder: 'Enter master passphrase for encryption/decryption',
284
+ jsonConfigPlaceholder: 'Paste JSON configuration here or choose a file...',
285
+ previewSummary: 'Diff: {created} new providers, {updated} updated providers, {keys} keys ({added} new).',
259
286
  };
260
287
 
261
288
  const zh = {
@@ -311,6 +338,11 @@ window.__ModuleLoader__.load({
311
338
  presetNone: '未找到该提供商预设规则',
312
339
  detectedLimitsLabel: '上游限制:{rpm} RPM · {tpm} TPM',
313
340
  trafficSmoothingLabel: '流量平滑 (突发并发)',
341
+ balanceLabel: '余额:{amt} {curr}',
342
+ balanceEmpty: '余额不足已暂停',
343
+ balanceCheckBtn: '💰 检查余额',
344
+ checkingBalance: '查询中…',
345
+ upstreamResetIn: '⚡ {time}后重置 (上游头信息)',
314
346
  pauseKey: '暂停此密钥轮换',
315
347
  resumeKey: '恢复此密钥轮换',
316
348
  sparklineTraffic: '流量趋势',
@@ -462,6 +494,28 @@ window.__ModuleLoader__.load({
462
494
  autoImportBtn: '一键自动导入',
463
495
  autoImportSuccess: '成功导入 {n} 个密钥到轮换池',
464
496
  autoImporting: '正在导入…',
497
+ quickSkip: '跳过',
498
+ quickSkipTitle: '跳过并轮换到池中下一个密钥',
499
+ quickReset: '重置',
500
+ quickResetTitle: '重置当前提供商的冷却时间与断路器',
501
+ quickPause: '暂停',
502
+ quickPauseTitle: '暂停或恢复当前正在使用的密钥',
503
+ exportConfig: '导出密钥池',
504
+ importConfig: '导入密钥池',
505
+ exportSafe: '脱敏导出 (安全分享)',
506
+ exportFull: '完整备份 (包含密钥)',
507
+ exportWithPassphrase: '使用密码加密导出',
508
+ importMode: '导入模式',
509
+ importMerge: '与现有密钥池合并',
510
+ importReplace: '覆盖现有全部密钥池配置',
511
+ importPreview: '预览差异',
512
+ importApply: '应用配置',
513
+ importSuccess: '配置已成功导入并生效',
514
+ exportSuccess: '配置导出成功',
515
+ passphraseLabel: '加密密码 (可选)',
516
+ passphrasePlaceholder: '输入用于加密/解密的主密码',
517
+ jsonConfigPlaceholder: '在此粘贴 JSON 配置或选择文件...',
518
+ previewSummary: '差异: 新增 {created} 个提供商, 更新 {updated} 个提供商, 包含 {keys} 个密钥 (新增 {added} 个)。',
465
519
  };
466
520
 
467
521
  // Codes worth switching on. Host list is DEFAULT_SWITCH_CODES; config may
@@ -1176,6 +1230,111 @@ window.__ModuleLoader__.load({
1176
1230
 
1177
1231
  // Test Matrix State (#490)
1178
1232
  const [testMatrixRunning, setTestMatrixRunning] = React.useState(false);
1233
+ const [exportModal, setExportModal] = React.useState(false);
1234
+ const [exportMasked, setExportMasked] = React.useState(true);
1235
+ const [exportPassphrase, setExportPassphrase] = React.useState('');
1236
+ const [exportError, setExportError] = React.useState('');
1237
+ const [importModal, setImportModal] = React.useState(false);
1238
+ const [importText, setImportText] = React.useState('');
1239
+ const [importPassphrase, setImportPassphrase] = React.useState('');
1240
+ const [importMode, setImportMode] = React.useState('merge');
1241
+ const [importPreview, setImportPreview] = React.useState(null);
1242
+ const [importError, setImportError] = React.useState('');
1243
+ const [importRunning, setImportRunning] = React.useState(false);
1244
+
1245
+ const runExport = async () => {
1246
+ setExportError('');
1247
+ try {
1248
+ const q = new URLSearchParams();
1249
+ q.set('masked', exportMasked ? 'true' : 'false');
1250
+ if (exportPassphrase.trim()) q.set('passphrase', exportPassphrase.trim());
1251
+ const res = await fetch(`/dsh-key-rotation/export?${q.toString()}`, {
1252
+ headers: { accept: 'application/json' },
1253
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
1254
+ });
1255
+ const data = await res.json();
1256
+ if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Export failed');
1257
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
1258
+ const url = URL.createObjectURL(blob);
1259
+ const a = document.createElement('a');
1260
+ a.href = url;
1261
+ a.download = `dsh-key-rotation-${exportMasked ? 'masked' : 'full'}-${Date.now()}.json`;
1262
+ a.click();
1263
+ URL.revokeObjectURL(url);
1264
+ setExportModal(false);
1265
+ } catch (err) {
1266
+ setExportError(String(err?.message ?? err));
1267
+ }
1268
+ };
1269
+
1270
+ const runImportPreview = async () => {
1271
+ setImportError('');
1272
+ setImportPreview(null);
1273
+ setImportRunning(true);
1274
+ try {
1275
+ let parsed;
1276
+ try { parsed = JSON.parse(importText); } catch (_) { throw new Error('Invalid JSON format'); }
1277
+ const res = await fetch('/dsh-key-rotation/config-import', {
1278
+ method: 'POST',
1279
+ headers: { 'Content-Type': 'application/json', accept: 'application/json' },
1280
+ body: JSON.stringify({
1281
+ config: parsed.config || (parsed.providers ? parsed : undefined),
1282
+ ciphertext: parsed.ciphertext,
1283
+ salt: parsed.salt,
1284
+ iv: parsed.iv,
1285
+ tag: parsed.tag,
1286
+ encrypted: parsed.encrypted,
1287
+ passphrase: importPassphrase.trim() || undefined,
1288
+ mode: importMode,
1289
+ dryRun: true,
1290
+ }),
1291
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
1292
+ });
1293
+ const data = await res.json();
1294
+ if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Import preview failed');
1295
+ setImportPreview(data.preview);
1296
+ } catch (err) {
1297
+ setImportError(String(err?.message ?? err));
1298
+ } finally {
1299
+ setImportRunning(false);
1300
+ }
1301
+ };
1302
+
1303
+ const runImportApply = async () => {
1304
+ setImportError('');
1305
+ setImportRunning(true);
1306
+ try {
1307
+ let parsed;
1308
+ try { parsed = JSON.parse(importText); } catch (_) { throw new Error('Invalid JSON format'); }
1309
+ const res = await fetch('/dsh-key-rotation/config-import', {
1310
+ method: 'POST',
1311
+ headers: { 'Content-Type': 'application/json', accept: 'application/json' },
1312
+ body: JSON.stringify({
1313
+ config: parsed.config || (parsed.providers ? parsed : undefined),
1314
+ ciphertext: parsed.ciphertext,
1315
+ salt: parsed.salt,
1316
+ iv: parsed.iv,
1317
+ tag: parsed.tag,
1318
+ encrypted: parsed.encrypted,
1319
+ passphrase: importPassphrase.trim() || undefined,
1320
+ mode: importMode,
1321
+ dryRun: false,
1322
+ }),
1323
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
1324
+ });
1325
+ const data = await res.json();
1326
+ if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Import apply failed');
1327
+ setImportModal(false);
1328
+ setImportPreview(null);
1329
+ setImportText('');
1330
+ void load();
1331
+ } catch (err) {
1332
+ setImportError(String(err?.message ?? err));
1333
+ } finally {
1334
+ setImportRunning(false);
1335
+ }
1336
+ };
1337
+
1179
1338
  const [testMatrixResults, setTestMatrixResults] = React.useState(null);
1180
1339
  const [testMatrixModal, setTestMatrixModal] = React.useState(false);
1181
1340
 
@@ -1567,6 +1726,26 @@ window.__ModuleLoader__.load({
1567
1726
 
1568
1727
  const [validating, setValidating] = React.useState('');
1569
1728
  const [presetNotice, setPresetNotice] = React.useState('');
1729
+ const [checkingBalance, setCheckingBalance] = React.useState('');
1730
+ const checkBalances = async (pIndex, provName) => {
1731
+ setCheckingBalance(provName);
1732
+ try {
1733
+ const r = await fetch('/dsh-key-rotation/balance', {
1734
+ method: 'POST',
1735
+ headers: { 'Content-Type': 'application/json' },
1736
+ body: JSON.stringify({ provider: provName }),
1737
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(15000) : undefined,
1738
+ });
1739
+ const data = await r.json();
1740
+ if (data && data.ok) {
1741
+ load();
1742
+ }
1743
+ } catch (err) {
1744
+ setSecretError(String(err?.message || err));
1745
+ } finally {
1746
+ setCheckingBalance('');
1747
+ }
1748
+ };
1570
1749
  const applyPreset = async (pIndex, provName) => {
1571
1750
  try {
1572
1751
  const r = await fetch('/dsh-key-rotation/preset-limits?provider=' + encodeURIComponent(provName));
@@ -2162,6 +2341,18 @@ window.__ModuleLoader__.load({
2162
2341
  if (info && info.broken) {
2163
2342
  meta.push(h('button', { key: 'rt', className: 'krot-btn', onClick: () => retestBroken(key), title: t('retestBroken') }, t('retestBroken')));
2164
2343
  }
2344
+ if (info && info.balance) {
2345
+ const b = info.balance;
2346
+ const isZero = b.isAvailable === false || (typeof b.total === 'number' && b.total <= 0);
2347
+ const cur = b.currency || '';
2348
+ const amtStr = typeof b.total === 'number' ? b.total.toFixed(2) : String(b.total || 0);
2349
+ meta.push(h('span', {
2350
+ key: 'bal',
2351
+ className: 'krot-tail' + (isZero ? ' krot-badge-bad' : ''),
2352
+ title: isZero ? t('balanceEmpty') : ('Upstream balance: ' + amtStr + ' ' + cur),
2353
+ style: isZero ? { color: 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } : undefined,
2354
+ }, '💰 ' + amtStr + (cur ? (' ' + cur) : '')));
2355
+ }
2165
2356
  if (info && info.detectedLimits) {
2166
2357
  const dl = info.detectedLimits;
2167
2358
  const parts = [];
@@ -2509,6 +2700,10 @@ window.__ModuleLoader__.load({
2509
2700
  danger: false,
2510
2701
  onConfirm: () => doReset(entry.provider),
2511
2702
  }), { disabled: !(providerStatus && providerStatus.switches > 0) || resetting === entry.provider, title: t('resetCooldown') }),
2703
+ btn(checkingBalance === entry.provider ? t('checkingBalance') : ('💰 ' + t('balanceCheckBtn')), () => checkBalances(pIndex, entry.provider), {
2704
+ disabled: Boolean(checkingBalance) || !entry.provider.trim(),
2705
+ title: t('balanceCheckBtn'),
2706
+ }),
2512
2707
  btn(testAllProvider === entry.provider ? (testingProgress[entry.provider] ? t('testingAllProgress').replace('{n}', testingProgress[entry.provider].split('/')[0]).replace('{total}', testingProgress[entry.provider].split('/')[1]) : t('testing')) : t('testAll'), () => doTestAll(entry.provider), { disabled: Boolean(testing || testAllProvider), title: t('testAll') }),
2513
2708
  exportCsv,
2514
2709
  ),
@@ -2736,7 +2931,91 @@ window.__ModuleLoader__.load({
2736
2931
  h('span', null, '💾 ' + t('sectionBackup')),
2737
2932
  ),
2738
2933
  h('div', { className: 'krot-section-desc' }, t('sectionBackupDesc')),
2739
- h('div', { className: 'krot-row' },
2934
+ exportModal ? h('div', { className: 'krot-box', style: { marginBottom: '12px', padding: '12px', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '6px', background: 'var(--dsw-alias-bg-hover)' } },
2935
+ h('div', { style: { fontWeight: 600, marginBottom: '8px' } }, '📥 ' + (t('exportConfig') || 'Export Pools')),
2936
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px', cursor: 'pointer', marginBottom: '8px' } },
2937
+ h('input', { type: 'checkbox', checked: exportMasked, onChange: (e) => setExportMasked(e.target.checked) }),
2938
+ t('exportSafe') || 'Masked (Safe for sharing)'
2939
+ ),
2940
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '10px' } },
2941
+ h('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, t('passphraseLabel') || 'Passphrase (optional)'),
2942
+ h('input', {
2943
+ type: 'password',
2944
+ className: 'krot-input',
2945
+ value: exportPassphrase,
2946
+ onChange: (e) => setExportPassphrase(e.target.value),
2947
+ placeholder: t('passphrasePlaceholder') || 'Enter passphrase for AES-256-GCM encryption',
2948
+ style: { maxWidth: '360px' }
2949
+ })
2950
+ ),
2951
+ exportError ? h('div', { className: 'krot-alert krot-alert-bad', style: { marginBottom: '8px' } }, exportError) : null,
2952
+ h('div', { style: { display: 'flex', gap: '8px' } },
2953
+ btn('⬇️ ' + (t('exportConfig') || 'Download JSON'), runExport, { primary: true }),
2954
+ btn(t('cancel'), () => setExportModal(false))
2955
+ )
2956
+ ) : null,
2957
+ importModal ? h('div', { className: 'krot-box', style: { marginBottom: '12px', padding: '12px', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '6px', background: 'var(--dsw-alias-bg-hover)' } },
2958
+ h('div', { style: { fontWeight: 600, marginBottom: '8px' } }, '📤 ' + (t('importConfig') || 'Import Pools')),
2959
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '8px' } },
2960
+ h('label', { className: 'krot-btn', style: { cursor: 'pointer', fontSize: '12px' } },
2961
+ '📁 Choose File',
2962
+ h('input', {
2963
+ type: 'file',
2964
+ accept: '.json',
2965
+ style: { display: 'none' },
2966
+ onChange: (e) => {
2967
+ const file = e.target.files?.[0];
2968
+ if (!file) return;
2969
+ const reader = new FileReader();
2970
+ reader.onload = (ev) => { setImportText(String(ev.target?.result || '')); };
2971
+ reader.readAsText(file);
2972
+ }
2973
+ })
2974
+ ),
2975
+ h('span', { style: { fontSize: '12px', opacity: 0.7 } }, 'or paste JSON below')
2976
+ ),
2977
+ h('textarea', {
2978
+ className: 'krot-input',
2979
+ rows: 6,
2980
+ value: importText,
2981
+ onChange: (e) => { setImportText(e.target.value); setImportPreview(null); },
2982
+ placeholder: t('jsonConfigPlaceholder') || 'Paste JSON configuration here...',
2983
+ style: { width: '100%', fontFamily: 'monospace', fontSize: '11px', marginBottom: '8px' }
2984
+ }),
2985
+ h('div', { style: { display: 'flex', gap: '16px', alignItems: 'center', marginBottom: '8px' } },
2986
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px' } },
2987
+ h('input', { type: 'radio', name: 'importMode', checked: importMode === 'merge', onChange: () => setImportMode('merge') }),
2988
+ t('importMerge') || 'Merge'
2989
+ ),
2990
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px' } },
2991
+ h('input', { type: 'radio', name: 'importMode', checked: importMode === 'replace', onChange: () => setImportMode('replace') }),
2992
+ t('importReplace') || 'Replace'
2993
+ )
2994
+ ),
2995
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '10px' } },
2996
+ h('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, t('passphraseLabel') || 'Passphrase (if encrypted)'),
2997
+ h('input', {
2998
+ type: 'password',
2999
+ className: 'krot-input',
3000
+ value: importPassphrase,
3001
+ onChange: (e) => setImportPassphrase(e.target.value),
3002
+ placeholder: t('passphrasePlaceholder') || 'Enter passphrase for decryption',
3003
+ style: { maxWidth: '360px' }
3004
+ })
3005
+ ),
3006
+ importError ? h('div', { className: 'krot-alert krot-alert-bad', style: { marginBottom: '8px' } }, importError) : null,
3007
+ importPreview ? h('div', { className: 'krot-alert krot-alert-ok', style: { marginBottom: '8px', fontSize: '12px' } },
3008
+ `Preview: ${importPreview.createdProviders?.length || 0} created, ${importPreview.updatedProviders?.length || 0} updated, ${importPreview.keysAdded || 0} keys added, ${importPreview.totalKeys || 0} total keys.`
3009
+ ) : null,
3010
+ h('div', { style: { display: 'flex', gap: '8px' } },
3011
+ btn('🔍 ' + (t('importPreview') || 'Preview Diff'), runImportPreview, { disabled: !importText.trim() || importRunning }),
3012
+ btn('✅ ' + (t('importApply') || 'Apply Configuration'), runImportApply, { primary: true, disabled: !importText.trim() || importRunning }),
3013
+ btn(t('cancel'), () => { setImportModal(false); setImportPreview(null); })
3014
+ )
3015
+ ) : null,
3016
+ h('div', { className: 'krot-row', style: { display: 'flex', gap: '8px', flexWrap: 'wrap' } },
3017
+ btn('📥 ' + (t('exportConfig') || 'Export Pools'), () => { setExportModal((v) => !v); setImportModal(false); }, { primary: exportModal }),
3018
+ btn('📤 ' + (t('importConfig') || 'Import Pools'), () => { setImportModal((v) => !v); setExportModal(false); }, { primary: importModal }),
2740
3019
  btn(t('exportPools'), () => {
2741
3020
  const data = JSON.stringify(val.providers ?? [], null, 2);
2742
3021
  const blob = new Blob([data], { type: 'application/json' });
@@ -2955,31 +3234,34 @@ window.__ModuleLoader__.load({
2955
3234
 
2956
3235
 
2957
3236
 
2958
- // (issue 201) header chip: one dot + counts for all pools. Green = all healthy,
2959
- // amber = some keys cooling, red = a pool fully exhausted. Click opens the
2960
- // same summary the floating dashboard shows.
3237
+ // (issue 201, #499) header chip: one dot + counts for all pools. Green = all healthy,
3238
+ // amber = some keys cooling, red = a pool fully exhausted. Click opens quick actions popover.
2961
3239
  function KeyRotationHeaderChip(props) {
2962
3240
  const t = resolveT(props);
2963
3241
  const [snap, setSnap] = React.useState(null);
2964
3242
  const [open, setOpen] = React.useState(false);
2965
3243
  const [, setTick] = React.useState(0);
3244
+ const [busy, setBusy] = React.useState(false);
2966
3245
  const ref = React.useRef(null);
2967
3246
 
3247
+ const load = () => {
3248
+ if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
3249
+ fetch('/dsh-key-rotation/health', {
3250
+ headers: { accept: 'application/json' },
3251
+ credentials: 'same-origin',
3252
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined
3253
+ })
3254
+ .then((r) => (r.ok ? r.json() : null))
3255
+ .then((d) => { if (d) setSnap(d); })
3256
+ .catch((e) => { void e; });
3257
+ };
3258
+
2968
3259
  React.useEffect(() => {
2969
- let alive = true;
2970
- const load = () => {
2971
- if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
2972
- fetch('/dsh-key-rotation/health', { headers: { accept: 'application/json' }, credentials: 'same-origin', signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined })
2973
- .then((r) => (r.ok ? r.json() : null))
2974
- .then((d) => { if (alive) setSnap(d); })
2975
- .catch((e) => { void e; });
2976
- };
2977
3260
  load();
2978
3261
  const id = setInterval(load, 5000);
2979
3262
  const onVis = () => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') load(); };
2980
3263
  if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVis);
2981
3264
  return () => {
2982
- alive = false;
2983
3265
  clearInterval(id);
2984
3266
  if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVis);
2985
3267
  };
@@ -3001,6 +3283,21 @@ window.__ModuleLoader__.load({
3001
3283
  return () => document.removeEventListener('click', onDocClick);
3002
3284
  }, [open]);
3003
3285
 
3286
+ const runAction = (action, provider, keyRef) => {
3287
+ setBusy(true);
3288
+ fetch('/dsh-key-rotation/quick-action', {
3289
+ method: 'POST',
3290
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
3291
+ body: JSON.stringify({ action, provider, ref: keyRef }),
3292
+ credentials: 'same-origin',
3293
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(5000) : undefined,
3294
+ })
3295
+ .then((r) => r.json())
3296
+ .then(() => { load(); })
3297
+ .catch((err) => { void err; })
3298
+ .finally(() => { setBusy(false); });
3299
+ };
3300
+
3004
3301
  let settingsVal = null;
3005
3302
  try {
3006
3303
  const sSnap = props?.settingsSource?.getSnapshot?.()?.service?.getSnapshot?.();
@@ -3030,17 +3327,45 @@ window.__ModuleLoader__.load({
3030
3327
  label,
3031
3328
  h('span', { style: { fontSize: '9px', opacity: 0.6 } }, open ? '▲' : '▼')
3032
3329
  ),
3033
- open ? h('div', { className: 'krot-popover' },
3330
+ open ? h('div', { className: 'krot-popover', style: { minWidth: '220px' } },
3034
3331
  h('div', { className: 'krot-pop-title' }, t('headerPoolsTitle')),
3035
3332
  !poolEntries.length ? h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, t('noActivePools')) : null,
3036
3333
  poolEntries.map(([name, p]) => {
3037
3334
  const c = p.exhausted ? 'var(--dsw-alias-state-error-primary)' : (p.healthy < p.total ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)');
3038
- return h('div', { key: name, className: 'krot-pop-row' },
3039
- h('div', { className: 'krot-pop-name' },
3040
- h('span', { style: { width: '7px', height: '7px', borderRadius: '50%', background: c, flex: 'none' } }),
3041
- name
3335
+ const provName = p.providerName || name;
3336
+ const tail = p.lastUsedTail;
3337
+ return h('div', { key: name, className: 'krot-pop-row', style: { display: 'flex', flexDirection: 'column', gap: '4px', padding: '6px 0', borderBottom: '1px solid var(--dsw-alias-border-subtle)' } },
3338
+ h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
3339
+ h('div', { className: 'krot-pop-name' },
3340
+ h('span', { style: { width: '7px', height: '7px', borderRadius: '50%', background: c, flex: 'none' } }),
3341
+ name,
3342
+ tail ? h('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)', fontFeatureSettings: '"tnum"', fontFamily: 'monospace' } }, `...${tail}`) : null
3343
+ ),
3344
+ h('div', { className: 'krot-pop-count', style: { color: c, fontWeight: 600 } }, `${p.healthy}/${p.total}`)
3042
3345
  ),
3043
- h('div', { className: 'krot-pop-count', style: { color: c } }, `${p.healthy}/${p.total}`)
3346
+ h('div', { style: { display: 'flex', gap: '4px', marginTop: '2px' } },
3347
+ h('button', {
3348
+ type: 'button',
3349
+ disabled: busy,
3350
+ onClick: (e) => { e.stopPropagation(); runAction('skip', provName, p.lastUsed); },
3351
+ title: t('quickSkipTitle'),
3352
+ style: { padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }
3353
+ }, `⏭️ ${t('quickSkip')}`),
3354
+ h('button', {
3355
+ type: 'button',
3356
+ disabled: busy,
3357
+ onClick: (e) => { e.stopPropagation(); runAction('reset', provName, p.lastUsed); },
3358
+ title: t('quickResetTitle'),
3359
+ style: { padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }
3360
+ }, `🔄 ${t('quickReset')}`),
3361
+ h('button', {
3362
+ type: 'button',
3363
+ disabled: busy || !p.lastUsed,
3364
+ onClick: (e) => { e.stopPropagation(); runAction('pause', provName, p.lastUsed); },
3365
+ title: t('quickPauseTitle'),
3366
+ style: { padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }
3367
+ }, `⏸️ ${t('quickPause')}`)
3368
+ )
3044
3369
  );
3045
3370
  })
3046
3371
  ) : null
@@ -0,0 +1,147 @@
1
+ // lib/ops-balance.js — operational HTTP routes for balance inspection (#495).
2
+
3
+ import {
4
+ json,
5
+ readJson,
6
+ } from './http-bridge.js';
7
+ import {
8
+ isTrustedBridgeRequest,
9
+ isValidRef,
10
+ } from './pool.js';
11
+ import { BALANCE_PATH } from './ops-paths.js';
12
+ import { inspectBalanceForRef } from './balance-inspector.js';
13
+ import { pushEvent } from './notify-events.js';
14
+
15
+ export function registerBalanceRoutes(ctx, deps) {
16
+ const {
17
+ poolState,
18
+ buildRuntime,
19
+ } = deps;
20
+
21
+ ctx.effect(() => ctx.webServer.register({
22
+ kind: 'exact',
23
+ path: BALANCE_PATH,
24
+ handler: async (req, res) => {
25
+ if (!isTrustedBridgeRequest(req)) {
26
+ json(res, 403, { ok: false, error: 'Forbidden' });
27
+ return;
28
+ }
29
+
30
+ const urlObj = new URL(req.url, 'http://127.0.0.1');
31
+
32
+ // GET: Query single key or provider cached balances
33
+ if (req.method === 'GET') {
34
+ const ref = urlObj.searchParams.get('ref');
35
+ const provider = urlObj.searchParams.get('provider');
36
+
37
+ if (ref) {
38
+ const runtime = buildRuntime();
39
+ const targetPool = provider ? runtime.pools?.find((p) => p.provider === provider) : null;
40
+ const pool = targetPool ?? runtime.poolByRef?.get(ref);
41
+ const cached = pool?.state?.balances?.get(ref) ?? null;
42
+
43
+ if (urlObj.searchParams.get('refresh') === 'true' && provider) {
44
+ const inspectRes = await inspectBalanceForRef(ctx, ref, provider);
45
+ if (inspectRes.ok) {
46
+ if (pool && pool.state) {
47
+ if (!pool.state.balances) pool.state.balances = new Map();
48
+ pool.state.balances.set(ref, { ...inspectRes, at: Date.now() });
49
+
50
+ // Check soft pause
51
+ applyBalanceState(pool, ref, inspectRes);
52
+ }
53
+ json(res, 200, { ok: true, ref, balance: inspectRes });
54
+ return;
55
+ }
56
+ json(res, 200, { ok: false, ref, error: inspectRes.error, cached });
57
+ return;
58
+ }
59
+
60
+ json(res, 200, { ok: true, ref, balance: cached });
61
+ return;
62
+ }
63
+
64
+ // Return all cached balances
65
+ const out = {};
66
+ if (poolState && typeof poolState.forEach === 'function') {
67
+ poolState.forEach((st, provName) => {
68
+ if (st && st.balances instanceof Map) {
69
+ for (const [k, v] of st.balances) {
70
+ out[k] = v;
71
+ }
72
+ }
73
+ });
74
+ }
75
+ json(res, 200, { ok: true, balances: out });
76
+ return;
77
+ }
78
+
79
+ // POST: Trigger refresh of balances for a provider or key list
80
+ if (req.method === 'POST') {
81
+ const body = await readJson(req);
82
+ const provider = body?.provider;
83
+ const ref = body?.ref;
84
+ const testValue = body?.value;
85
+
86
+ if (ref && provider) {
87
+ const inspectRes = await inspectBalanceForRef(ctx, ref, provider, { testValue });
88
+ const runtime = buildRuntime();
89
+ const pool = runtime.pools?.find((p) => p.provider === provider) ?? runtime.poolByRef?.get(ref);
90
+
91
+ if (inspectRes.ok && pool && pool.state) {
92
+ if (!pool.state.balances) pool.state.balances = new Map();
93
+ pool.state.balances.set(ref, { ...inspectRes, at: Date.now() });
94
+ applyBalanceState(pool, ref, inspectRes);
95
+ }
96
+
97
+ json(res, inspectRes.ok ? 200 : 400, { ok: inspectRes.ok, ref, balance: inspectRes });
98
+ return;
99
+ }
100
+
101
+ if (provider) {
102
+ const runtime = buildRuntime();
103
+ const pool = runtime.pools?.find((p) => p.provider === provider);
104
+ if (!pool || !Array.isArray(pool.refs) || pool.refs.length === 0) {
105
+ json(res, 404, { ok: false, error: `Provider '${provider}' not found or has no keys` });
106
+ return;
107
+ }
108
+
109
+ const results = {};
110
+ for (const kRef of pool.refs) {
111
+ const r = await inspectBalanceForRef(ctx, kRef, provider);
112
+ results[kRef] = r;
113
+ if (r.ok && pool.state) {
114
+ if (!pool.state.balances) pool.state.balances = new Map();
115
+ pool.state.balances.set(kRef, { ...r, at: Date.now() });
116
+ applyBalanceState(pool, kRef, r);
117
+ }
118
+ }
119
+ json(res, 200, { ok: true, provider, balances: results });
120
+ return;
121
+ }
122
+
123
+ json(res, 400, { ok: false, error: 'ref or provider required' });
124
+ return;
125
+ }
126
+
127
+ json(res, 405, { ok: false, error: 'Method Not Allowed' });
128
+ },
129
+ }), 'dsh-key-rotation: balance routes');
130
+ }
131
+
132
+ function applyBalanceState(pool, ref, balanceRes) {
133
+ if (!pool || !pool.state) return;
134
+ const isZero = balanceRes.isAvailable === false || (typeof balanceRes.total === 'number' && balanceRes.total <= 0);
135
+
136
+ if (isZero) {
137
+ if (!pool.state.pausedRefs) pool.state.pausedRefs = new Set();
138
+ if (pool.state.pausedRefs instanceof Set) pool.state.pausedRefs.add(ref);
139
+ if (!pool.state.pauseReasons) pool.state.pauseReasons = new Map();
140
+ pool.state.pauseReasons.set(ref, 'EMPTY_BALANCE_PAUSED');
141
+ pushEvent(pool, ref, 'BALANCE_EMPTY', 0);
142
+ } else if (pool.state.pauseReasons?.get?.(ref) === 'EMPTY_BALANCE_PAUSED') {
143
+ if (pool.state.pausedRefs instanceof Set) pool.state.pausedRefs.delete(ref);
144
+ pool.state.pauseReasons.delete(ref);
145
+ pushEvent(pool, ref, 'BALANCE_RESTORED', 0);
146
+ }
147
+ }