@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/CHANGELOG.md +31 -0
- package/README.md +8 -0
- package/README.ru.md +8 -0
- package/README.zh.md +8 -0
- package/lib/balance-inspector.js +176 -0
- package/lib/client.js +344 -19
- package/lib/ops-balance.js +147 -0
- package/lib/ops-config-io.js +325 -0
- package/lib/ops-paths.js +4 -0
- package/lib/ops-quick-action.js +167 -0
- package/lib/ops-status.js +5 -0
- package/lib/persistence.js +13 -0
- package/lib/pool-state.js +1 -1
- package/lib/rotate.js +46 -11
- package/lib/routes-ops.js +8 -2
- package/package.json +1 -1
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-
|
|
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
|
|
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
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
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', {
|
|
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
|
+
}
|