@goodandready/dsh-key-rotation 0.8.66 → 0.8.68
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 +33 -0
- package/README.md +4 -0
- package/README.ru.md +4 -0
- package/README.zh.md +4 -0
- package/lib/client.js +366 -20
- package/lib/ops-analytics.js +162 -0
- package/lib/ops-config-io.js +325 -0
- package/lib/ops-paths.js +3 -0
- package/lib/ops-quick-action.js +167 -0
- package/lib/ops-status.js +6 -0
- package/lib/ops-telemetry.js +8 -1
- package/lib/prompt-cache.js +252 -0
- package/lib/rotate.js +12 -0
- package/lib/routes-ops.js +7 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.68 - 2026-10-19
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- **24h Error Breakdown & Load Distribution Analytics (#505)**:
|
|
7
|
+
- Rolling 24-hour analytics engine (`lib/ops-analytics.js`): tracks sliding 24-hour request outcomes, key load distribution percentages, failovers, and cascade occurrences without memory leaks via bounded ring buffers.
|
|
8
|
+
- Multi-category failure classification: categorizes upstream errors into `429 Rate Limit`, `5xx Server`, `Timeout`, `Quota`, `Auth`, and `Other`.
|
|
9
|
+
- Health Index indicator: calculates the percentage of requests served clean on first attempt without failovers or retries (`cleanRequests / totalRequests * 100%`).
|
|
10
|
+
- Cooldown recovery telemetry: computes the average recovery time (`avgCooldownRecoveryMs`) from recent failure and recovery events.
|
|
11
|
+
- Settings Card UI: renders interactive Health Index badges (`🛡️ Health Index: 98% (clean)`), 24h error breakdown pills (`429: 2`, `5xx: 1`, `Timeout: 0`), cascade counters (`🔀 Cascades: X`), and average recovery time pills.
|
|
12
|
+
- **Prompt Cache Savings Dashboard & Calculator (#497)**:
|
|
13
|
+
- Multi-provider prompt cache parser (`lib/prompt-cache.js`): extracts prefix cache hit and miss tokens across streaming and non-streaming responses from DeepSeek (`prompt_cache_hit_tokens`), Anthropic (`cache_read_input_tokens`), and OpenAI (`prompt_tokens_details.cached_tokens`).
|
|
14
|
+
- Financial savings engine: computes real-time USD cost savings based on provider-specific token discount tariffs (~90% discount on cache hits).
|
|
15
|
+
- Multi-window rolling aggregation: aggregates cached tokens, total input tokens, hit ratio (%), and dollar savings across 24h (`d1`), 7d (`d7`), and 30d (`d30`) sliding windows, plus per-model and per-key breakdowns.
|
|
16
|
+
- Settings Card Widget: compact «⚡ Prompt Cache Savings» card displaying 24h, 7d, and 30d cache hit tokens, effective cache hit ratio (%), and cumulative dollar savings.
|
|
17
|
+
- Telemetry & reporting: exposes prompt cache summaries and 24h analytics in `/dsh-key-rotation/status` and `/dsh-key-rotation/usage`.
|
|
18
|
+
|
|
19
|
+
## 0.8.67 - 2026-10-19
|
|
20
|
+
|
|
21
|
+
### Features
|
|
22
|
+
- **Full Pool Configuration Export & Import (#504)**:
|
|
23
|
+
- Masked export: exports topology and pools with masked credentials (`sk-...cdef`) and hidden webhook secrets, safe for bug reports and sharing.
|
|
24
|
+
- Full backup export with AES-256-GCM encryption: optional passphrase encrypts configuration with PBKDF2 (100,000 iterations) using standard `node:crypto`.
|
|
25
|
+
- Config import route `POST /dsh-key-rotation/config-import`: supports file upload, raw JSON, or encrypted payload with passphrase decryption.
|
|
26
|
+
- Diff preview mode: `dryRun: true` returns summary of created/updated/removed providers and keys count before applying.
|
|
27
|
+
- Interactive UI in Settings card: modal drawers for Export (masked vs full, optional passphrase) and Import (file upload, merge vs replace modes, diff preview, apply).
|
|
28
|
+
- **Quick Key Actions in Chat Header Popover (#499)**:
|
|
29
|
+
- Extended `KeyRotationHeaderChip` popover: displays active provider and key tail (`...abcd`) for each configured pool.
|
|
30
|
+
- Interactive inline action buttons:
|
|
31
|
+
- ⏭️ **Skip**: advances active key cursor to the next healthy candidate in the pool without leaving chat.
|
|
32
|
+
- 🔄 **Reset**: immediately resets failure counts, cooldowns, and circuit breaker for the active provider.
|
|
33
|
+
- ⏸️ **Pause**: toggles pause state on the currently active key for immediate fault isolation.
|
|
34
|
+
- Endpoint `POST /dsh-key-rotation/quick-action`: secure loopback-validated operational route executing skip, reset, and pause commands with audit telemetry events.
|
|
35
|
+
|
|
3
36
|
## 0.8.66 - 2026-10-19
|
|
4
37
|
|
|
5
38
|
### Features
|
package/README.md
CHANGED
|
@@ -67,6 +67,10 @@
|
|
|
67
67
|
- **📡 Status extras**: per-provider `circuit` plus `meta.expectedClones` / `meta.notifyQueue`.
|
|
68
68
|
- **🧪 Smoke harness**: scripted 429 → next-key → success path in `test/smoke-rotation-080.test.mjs`.
|
|
69
69
|
|
|
70
|
+
### 🚀 What's New in v0.8.67
|
|
71
|
+
- **📥 Full Pool Config Export & Import (#504)**: Masked export for safe sharing, full backup export with AES-256-GCM encryption (PBKDF2), diff preview mode, and import with merge/replace strategies.
|
|
72
|
+
- **⚡ Quick Actions in Chat Header Popover (#499)**: Instant key management directly from the conversation toolbar — skip to next key, reset cooldowns, or pause problematic keys via `/dsh-key-rotation/quick-action`.
|
|
73
|
+
|
|
70
74
|
### 🚀 What's New in v0.8.66
|
|
71
75
|
- **⏳ Dynamic Reset Scheduler (#498)**: Reads upstream `retry-after` and `x-ratelimit-reset-*` headers to set exact cooldowns (+250ms safety jitter) and schedules immediate unref wake-up timers when the upstream reset window rolls over.
|
|
72
76
|
- **💰 Upstream Balance Inspector & Credit Tracking (#495)**: Live balance checks for DeepSeek, SiliconFlow, OpenRouter, and OpenAI via `/dsh-key-rotation/balance`, pre-emptive empty balance protection (`EMPTY_BALANCE_PAUSED`), persistent balance cache, and WebUI `💰 Balance` pills.
|
package/README.ru.md
CHANGED
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
|
|
37
37
|
---
|
|
38
38
|
|
|
39
|
+
### 🚀 Что нового в v0.8.67
|
|
40
|
+
- **📥 Экспорт и импорт полной конфигурации пулов (#504)**: Безопасный маскированный экспорт для баг-репортов, полный бэкап с AES-256-GCM шифрованием (PBKDF2), интерактивный diff-предпросмотр и режимы объединения/замены.
|
|
41
|
+
- **⚡ Быстрые действия в поповере шапки чата (#499)**: Управление ключами прямо из тулбара диалога — принудительный пропуск (Skip) на следующий ключ, мгновенный сброс кулдаунов (Reset) и изоляция сбойного ключа (Pause).
|
|
42
|
+
|
|
39
43
|
### 🚀 Что нового в v0.8.66
|
|
40
44
|
- **⏳ Динамический планировщик сброса кулдауна (#498)**: Точное вычисление времени восстановления ключей по заголовкам `retry-after` и `x-ratelimit-reset-*` (+250мс джиттер) с запуском точного unref-таймера восстановления сразу по истечении окна, не дожидаясь 30-секундного периодического свипа.
|
|
41
45
|
- **💰 Инспектор баланса и отслеживание кредитов (#495)**: Запрос реального баланса провайдеров (DeepSeek, SiliconFlow, OpenRouter, OpenAI) через маршрут `/dsh-key-rotation/balance`, автоматическая мягкая пауза при нулевом балансе (`EMPTY_BALANCE_PAUSED`), персистентное сохранение и плашки баланса в WebUI карточках ключей.
|
package/README.zh.md
CHANGED
|
@@ -38,6 +38,10 @@
|
|
|
38
38
|
|
|
39
39
|
## ⚡ 概述与核心痛点
|
|
40
40
|
|
|
41
|
+
### 🚀 v0.8.67 新特性
|
|
42
|
+
- **📥 密钥池完整配置导入与导出 (#504)**: 支持脱敏导出安全分享、AES-256-GCM 密码加密完整备份、导入差异预览 (Diff Preview) 以及合并/全量替换模式。
|
|
43
|
+
- **⚡ 聊天头部快捷键操作 (#499)**: 直接在对话工具栏气泡中管理密钥 — 快速跳过当前密钥 (Skip)、一键重置冷却与断路器 (Reset)、暂停故障密钥 (Pause)。
|
|
44
|
+
|
|
41
45
|
### 🚀 v0.8.66 新特性
|
|
42
46
|
- **⏳ 基于限流响应头的动态重置调度器 (#498)**: 解析上游 `retry-after` 与 `x-ratelimit-reset-*` 响应头,设定精确冷却时间(+250ms抖动保护),并通过独立 unref 定时器在窗口重置瞬间立刻唤醒可用密钥,无需等待30秒周期性轮询。
|
|
43
47
|
- **💰 上游余额与信用额度巡检 (#495)**: 支持 DeepSeek、SiliconFlow、OpenRouter、OpenAI 实时余额查询 (`/dsh-key-rotation/balance`),零余额前置软暂停 (`EMPTY_BALANCE_PAUSED`),持久化余额状态以及 WebUI 密钥卡片实时余额标签 (`💰 15.50 CNY`)。
|
package/lib/client.js
CHANGED
|
@@ -261,6 +261,37 @@ window.__ModuleLoader__.load({
|
|
|
261
261
|
autoImportBtn: '1-Click Auto-Import',
|
|
262
262
|
autoImportSuccess: 'Imported {n} keys into rotation pools',
|
|
263
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).',
|
|
286
|
+
promptCacheTitle: 'Prompt Cache Savings',
|
|
287
|
+
promptCacheDesc: 'Tokens served from prefix cache across sessions and estimated cost savings.',
|
|
288
|
+
promptCacheHitRatio: 'Hit Ratio',
|
|
289
|
+
promptCacheSaved: 'saved',
|
|
290
|
+
analytics24hTitle: '24h Pool Health & Load Balance',
|
|
291
|
+
healthIndex: 'Health Index',
|
|
292
|
+
cleanRequests: 'clean',
|
|
293
|
+
errorBreakdown: '24h Errors',
|
|
294
|
+
avgRecovery: 'Avg Recovery',
|
|
264
295
|
};
|
|
265
296
|
|
|
266
297
|
const zh = {
|
|
@@ -472,6 +503,37 @@ window.__ModuleLoader__.load({
|
|
|
472
503
|
autoImportBtn: '一键自动导入',
|
|
473
504
|
autoImportSuccess: '成功导入 {n} 个密钥到轮换池',
|
|
474
505
|
autoImporting: '正在导入…',
|
|
506
|
+
quickSkip: '跳过',
|
|
507
|
+
quickSkipTitle: '跳过并轮换到池中下一个密钥',
|
|
508
|
+
quickReset: '重置',
|
|
509
|
+
quickResetTitle: '重置当前提供商的冷却时间与断路器',
|
|
510
|
+
quickPause: '暂停',
|
|
511
|
+
quickPauseTitle: '暂停或恢复当前正在使用的密钥',
|
|
512
|
+
exportConfig: '导出密钥池',
|
|
513
|
+
importConfig: '导入密钥池',
|
|
514
|
+
exportSafe: '脱敏导出 (安全分享)',
|
|
515
|
+
exportFull: '完整备份 (包含密钥)',
|
|
516
|
+
exportWithPassphrase: '使用密码加密导出',
|
|
517
|
+
importMode: '导入模式',
|
|
518
|
+
importMerge: '与现有密钥池合并',
|
|
519
|
+
importReplace: '覆盖现有全部密钥池配置',
|
|
520
|
+
importPreview: '预览差异',
|
|
521
|
+
importApply: '应用配置',
|
|
522
|
+
importSuccess: '配置已成功导入并生效',
|
|
523
|
+
exportSuccess: '配置导出成功',
|
|
524
|
+
passphraseLabel: '加密密码 (可选)',
|
|
525
|
+
passphrasePlaceholder: '输入用于加密/解密的主密码',
|
|
526
|
+
jsonConfigPlaceholder: '在此粘贴 JSON 配置或选择文件...',
|
|
527
|
+
previewSummary: '差异: 新增 {created} 个提供商, 更新 {updated} 个提供商, 包含 {keys} 个密钥 (新增 {added} 个)。',
|
|
528
|
+
promptCacheTitle: 'Prompt 缓存节省',
|
|
529
|
+
promptCacheDesc: '会话间前缀缓存命中 Token 及预估节省成本。',
|
|
530
|
+
promptCacheHitRatio: '缓存命中率',
|
|
531
|
+
promptCacheSaved: '已节省',
|
|
532
|
+
analytics24hTitle: '24小时池健康度与负载分布',
|
|
533
|
+
healthIndex: '健康指数',
|
|
534
|
+
cleanRequests: '无重试',
|
|
535
|
+
errorBreakdown: '24小时错误',
|
|
536
|
+
avgRecovery: '平均恢复',
|
|
475
537
|
};
|
|
476
538
|
|
|
477
539
|
// Codes worth switching on. Host list is DEFAULT_SWITCH_CODES; config may
|
|
@@ -1186,6 +1248,111 @@ window.__ModuleLoader__.load({
|
|
|
1186
1248
|
|
|
1187
1249
|
// Test Matrix State (#490)
|
|
1188
1250
|
const [testMatrixRunning, setTestMatrixRunning] = React.useState(false);
|
|
1251
|
+
const [exportModal, setExportModal] = React.useState(false);
|
|
1252
|
+
const [exportMasked, setExportMasked] = React.useState(true);
|
|
1253
|
+
const [exportPassphrase, setExportPassphrase] = React.useState('');
|
|
1254
|
+
const [exportError, setExportError] = React.useState('');
|
|
1255
|
+
const [importModal, setImportModal] = React.useState(false);
|
|
1256
|
+
const [importText, setImportText] = React.useState('');
|
|
1257
|
+
const [importPassphrase, setImportPassphrase] = React.useState('');
|
|
1258
|
+
const [importMode, setImportMode] = React.useState('merge');
|
|
1259
|
+
const [importPreview, setImportPreview] = React.useState(null);
|
|
1260
|
+
const [importError, setImportError] = React.useState('');
|
|
1261
|
+
const [importRunning, setImportRunning] = React.useState(false);
|
|
1262
|
+
|
|
1263
|
+
const runExport = async () => {
|
|
1264
|
+
setExportError('');
|
|
1265
|
+
try {
|
|
1266
|
+
const q = new URLSearchParams();
|
|
1267
|
+
q.set('masked', exportMasked ? 'true' : 'false');
|
|
1268
|
+
if (exportPassphrase.trim()) q.set('passphrase', exportPassphrase.trim());
|
|
1269
|
+
const res = await fetch(`/dsh-key-rotation/export?${q.toString()}`, {
|
|
1270
|
+
headers: { accept: 'application/json' },
|
|
1271
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
|
|
1272
|
+
});
|
|
1273
|
+
const data = await res.json();
|
|
1274
|
+
if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Export failed');
|
|
1275
|
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
1276
|
+
const url = URL.createObjectURL(blob);
|
|
1277
|
+
const a = document.createElement('a');
|
|
1278
|
+
a.href = url;
|
|
1279
|
+
a.download = `dsh-key-rotation-${exportMasked ? 'masked' : 'full'}-${Date.now()}.json`;
|
|
1280
|
+
a.click();
|
|
1281
|
+
URL.revokeObjectURL(url);
|
|
1282
|
+
setExportModal(false);
|
|
1283
|
+
} catch (err) {
|
|
1284
|
+
setExportError(String(err?.message ?? err));
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
|
|
1288
|
+
const runImportPreview = async () => {
|
|
1289
|
+
setImportError('');
|
|
1290
|
+
setImportPreview(null);
|
|
1291
|
+
setImportRunning(true);
|
|
1292
|
+
try {
|
|
1293
|
+
let parsed;
|
|
1294
|
+
try { parsed = JSON.parse(importText); } catch (_) { throw new Error('Invalid JSON format'); }
|
|
1295
|
+
const res = await fetch('/dsh-key-rotation/config-import', {
|
|
1296
|
+
method: 'POST',
|
|
1297
|
+
headers: { 'Content-Type': 'application/json', accept: 'application/json' },
|
|
1298
|
+
body: JSON.stringify({
|
|
1299
|
+
config: parsed.config || (parsed.providers ? parsed : undefined),
|
|
1300
|
+
ciphertext: parsed.ciphertext,
|
|
1301
|
+
salt: parsed.salt,
|
|
1302
|
+
iv: parsed.iv,
|
|
1303
|
+
tag: parsed.tag,
|
|
1304
|
+
encrypted: parsed.encrypted,
|
|
1305
|
+
passphrase: importPassphrase.trim() || undefined,
|
|
1306
|
+
mode: importMode,
|
|
1307
|
+
dryRun: true,
|
|
1308
|
+
}),
|
|
1309
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
|
|
1310
|
+
});
|
|
1311
|
+
const data = await res.json();
|
|
1312
|
+
if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Import preview failed');
|
|
1313
|
+
setImportPreview(data.preview);
|
|
1314
|
+
} catch (err) {
|
|
1315
|
+
setImportError(String(err?.message ?? err));
|
|
1316
|
+
} finally {
|
|
1317
|
+
setImportRunning(false);
|
|
1318
|
+
}
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
const runImportApply = async () => {
|
|
1322
|
+
setImportError('');
|
|
1323
|
+
setImportRunning(true);
|
|
1324
|
+
try {
|
|
1325
|
+
let parsed;
|
|
1326
|
+
try { parsed = JSON.parse(importText); } catch (_) { throw new Error('Invalid JSON format'); }
|
|
1327
|
+
const res = await fetch('/dsh-key-rotation/config-import', {
|
|
1328
|
+
method: 'POST',
|
|
1329
|
+
headers: { 'Content-Type': 'application/json', accept: 'application/json' },
|
|
1330
|
+
body: JSON.stringify({
|
|
1331
|
+
config: parsed.config || (parsed.providers ? parsed : undefined),
|
|
1332
|
+
ciphertext: parsed.ciphertext,
|
|
1333
|
+
salt: parsed.salt,
|
|
1334
|
+
iv: parsed.iv,
|
|
1335
|
+
tag: parsed.tag,
|
|
1336
|
+
encrypted: parsed.encrypted,
|
|
1337
|
+
passphrase: importPassphrase.trim() || undefined,
|
|
1338
|
+
mode: importMode,
|
|
1339
|
+
dryRun: false,
|
|
1340
|
+
}),
|
|
1341
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
|
|
1342
|
+
});
|
|
1343
|
+
const data = await res.json();
|
|
1344
|
+
if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Import apply failed');
|
|
1345
|
+
setImportModal(false);
|
|
1346
|
+
setImportPreview(null);
|
|
1347
|
+
setImportText('');
|
|
1348
|
+
void load();
|
|
1349
|
+
} catch (err) {
|
|
1350
|
+
setImportError(String(err?.message ?? err));
|
|
1351
|
+
} finally {
|
|
1352
|
+
setImportRunning(false);
|
|
1353
|
+
}
|
|
1354
|
+
};
|
|
1355
|
+
|
|
1189
1356
|
const [testMatrixResults, setTestMatrixResults] = React.useState(null);
|
|
1190
1357
|
const [testMatrixModal, setTestMatrixModal] = React.useState(false);
|
|
1191
1358
|
|
|
@@ -2333,7 +2500,55 @@ window.__ModuleLoader__.load({
|
|
|
2333
2500
|
total > 0 && keyUsages.length > 1 ? h('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center', gap: '8px' } },
|
|
2334
2501
|
h('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, '📈 ' + t('sparklineTraffic') + ':'),
|
|
2335
2502
|
h(Sparkline, { points: keyUsages.map(x => x.usage), width: 140, height: 20 })
|
|
2336
|
-
) : null
|
|
2503
|
+
) : null,
|
|
2504
|
+
(() => {
|
|
2505
|
+
const an = ps && ps.analytics24h ? ps.analytics24h : null;
|
|
2506
|
+
if (!an || an.totalRequests === 0) return null;
|
|
2507
|
+
const hColor = an.healthIndexPct >= 80 ? 'var(--dsw-alias-state-success-primary)' : an.healthIndexPct >= 50 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-error-primary)';
|
|
2508
|
+
return h('div', { style: { marginTop: '8px', display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '6px' } },
|
|
2509
|
+
h('span', { className: 'krot-badge', style: { color: hColor, fontWeight: 700 } }, '🛡️ ' + t('healthIndex') + ': ' + an.healthIndexPct + '% (' + an.firstAttemptSuccesses + '/' + an.totalRequests + ' ' + t('cleanRequests') + ')'),
|
|
2510
|
+
an.errorBreakdown.rateLimit > 0 ? h('span', { className: 'krot-badge krot-badge-warn' }, '429: ' + an.errorBreakdown.rateLimit) : null,
|
|
2511
|
+
an.errorBreakdown.server > 0 ? h('span', { className: 'krot-badge krot-badge-warn' }, '5xx: ' + an.errorBreakdown.server) : null,
|
|
2512
|
+
an.errorBreakdown.timeout > 0 ? h('span', { className: 'krot-badge krot-badge-warn' }, 'Timeout: ' + an.errorBreakdown.timeout) : null,
|
|
2513
|
+
an.errorBreakdown.quota > 0 ? h('span', { className: 'krot-badge krot-badge-warn' }, 'Quota: ' + an.errorBreakdown.quota) : null,
|
|
2514
|
+
an.cascades > 0 ? h('span', { className: 'krot-badge' }, '🔀 Cascades: ' + an.cascades) : null,
|
|
2515
|
+
an.avgCooldownRecoveryMs > 0 ? h('span', { className: 'krot-badge' }, '⏱️ ' + t('avgRecovery') + ': ' + Math.round(an.avgCooldownRecoveryMs / 1000) + 's') : null
|
|
2516
|
+
);
|
|
2517
|
+
})()
|
|
2518
|
+
);
|
|
2519
|
+
})();
|
|
2520
|
+
|
|
2521
|
+
const promptCacheWidget = (() => {
|
|
2522
|
+
const ps = status[entry.provider];
|
|
2523
|
+
const pc = ps && ps.promptCache ? ps.promptCache : null;
|
|
2524
|
+
if (!pc || (!pc.d30?.cachedTokens && !pc.allTime?.cachedTokens)) return null;
|
|
2525
|
+
const d1 = pc.d1 || { cachedTokens: 0, savingsUsd: 0, hitRatioPct: 0 };
|
|
2526
|
+
const d7 = pc.d7 || { cachedTokens: 0, savingsUsd: 0, hitRatioPct: 0 };
|
|
2527
|
+
const d30 = pc.d30 || { cachedTokens: 0, savingsUsd: 0, hitRatioPct: 0 };
|
|
2528
|
+
const savedStr = d30.savingsUsd > 0 ? '$' + d30.savingsUsd.toFixed(2) : '$0.00';
|
|
2529
|
+
return h('div', {
|
|
2530
|
+
className: 'krot-prompt-cache-card',
|
|
2531
|
+
style: {
|
|
2532
|
+
marginTop: '6px',
|
|
2533
|
+
marginBottom: '6px',
|
|
2534
|
+
padding: '8px 10px',
|
|
2535
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
2536
|
+
borderRadius: '6px',
|
|
2537
|
+
border: '1px solid var(--dsw-alias-border-subtle)',
|
|
2538
|
+
display: 'flex',
|
|
2539
|
+
flexDirection: 'column',
|
|
2540
|
+
gap: '4px'
|
|
2541
|
+
}
|
|
2542
|
+
},
|
|
2543
|
+
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
|
|
2544
|
+
h('span', { style: { fontWeight: 600, fontSize: '12px' } }, '⚡ ' + t('promptCacheTitle')),
|
|
2545
|
+
h('span', { className: 'krot-badge krot-badge-ok', style: { fontSize: '11px', fontWeight: 600 } }, savedStr + ' ' + t('promptCacheSaved'))
|
|
2546
|
+
),
|
|
2547
|
+
h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '10px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } },
|
|
2548
|
+
h('span', null, h('strong', { style: { color: 'var(--dsw-alias-label-primary)' } }, '24h: '), (d1.cachedTokens || 0).toLocaleString() + ' tok (' + d1.hitRatioPct + '%)'),
|
|
2549
|
+
h('span', null, h('strong', { style: { color: 'var(--dsw-alias-label-primary)' } }, '7d: '), (d7.cachedTokens || 0).toLocaleString() + ' tok (' + d7.hitRatioPct + '%)'),
|
|
2550
|
+
h('span', null, h('strong', { style: { color: 'var(--dsw-alias-label-primary)' } }, '30d: '), (d30.cachedTokens || 0).toLocaleString() + ' tok (' + d30.hitRatioPct + '%)')
|
|
2551
|
+
)
|
|
2337
2552
|
);
|
|
2338
2553
|
})();
|
|
2339
2554
|
|
|
@@ -2497,6 +2712,7 @@ window.__ModuleLoader__.load({
|
|
|
2497
2712
|
),
|
|
2498
2713
|
filterBar,
|
|
2499
2714
|
loadChart,
|
|
2715
|
+
promptCacheWidget,
|
|
2500
2716
|
h('div', { className: 'krot-keys' }, keyRows),
|
|
2501
2717
|
modelPools,
|
|
2502
2718
|
h('div', { className: 'krot-foot' },
|
|
@@ -2782,7 +2998,91 @@ window.__ModuleLoader__.load({
|
|
|
2782
2998
|
h('span', null, '💾 ' + t('sectionBackup')),
|
|
2783
2999
|
),
|
|
2784
3000
|
h('div', { className: 'krot-section-desc' }, t('sectionBackupDesc')),
|
|
2785
|
-
h('div', { className: 'krot-
|
|
3001
|
+
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)' } },
|
|
3002
|
+
h('div', { style: { fontWeight: 600, marginBottom: '8px' } }, '📥 ' + (t('exportConfig') || 'Export Pools')),
|
|
3003
|
+
h('label', { style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px', cursor: 'pointer', marginBottom: '8px' } },
|
|
3004
|
+
h('input', { type: 'checkbox', checked: exportMasked, onChange: (e) => setExportMasked(e.target.checked) }),
|
|
3005
|
+
t('exportSafe') || 'Masked (Safe for sharing)'
|
|
3006
|
+
),
|
|
3007
|
+
h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '10px' } },
|
|
3008
|
+
h('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, t('passphraseLabel') || 'Passphrase (optional)'),
|
|
3009
|
+
h('input', {
|
|
3010
|
+
type: 'password',
|
|
3011
|
+
className: 'krot-input',
|
|
3012
|
+
value: exportPassphrase,
|
|
3013
|
+
onChange: (e) => setExportPassphrase(e.target.value),
|
|
3014
|
+
placeholder: t('passphrasePlaceholder') || 'Enter passphrase for AES-256-GCM encryption',
|
|
3015
|
+
style: { maxWidth: '360px' }
|
|
3016
|
+
})
|
|
3017
|
+
),
|
|
3018
|
+
exportError ? h('div', { className: 'krot-alert krot-alert-bad', style: { marginBottom: '8px' } }, exportError) : null,
|
|
3019
|
+
h('div', { style: { display: 'flex', gap: '8px' } },
|
|
3020
|
+
btn('⬇️ ' + (t('exportConfig') || 'Download JSON'), runExport, { primary: true }),
|
|
3021
|
+
btn(t('cancel'), () => setExportModal(false))
|
|
3022
|
+
)
|
|
3023
|
+
) : null,
|
|
3024
|
+
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)' } },
|
|
3025
|
+
h('div', { style: { fontWeight: 600, marginBottom: '8px' } }, '📤 ' + (t('importConfig') || 'Import Pools')),
|
|
3026
|
+
h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '8px' } },
|
|
3027
|
+
h('label', { className: 'krot-btn', style: { cursor: 'pointer', fontSize: '12px' } },
|
|
3028
|
+
'📁 Choose File',
|
|
3029
|
+
h('input', {
|
|
3030
|
+
type: 'file',
|
|
3031
|
+
accept: '.json',
|
|
3032
|
+
style: { display: 'none' },
|
|
3033
|
+
onChange: (e) => {
|
|
3034
|
+
const file = e.target.files?.[0];
|
|
3035
|
+
if (!file) return;
|
|
3036
|
+
const reader = new FileReader();
|
|
3037
|
+
reader.onload = (ev) => { setImportText(String(ev.target?.result || '')); };
|
|
3038
|
+
reader.readAsText(file);
|
|
3039
|
+
}
|
|
3040
|
+
})
|
|
3041
|
+
),
|
|
3042
|
+
h('span', { style: { fontSize: '12px', opacity: 0.7 } }, 'or paste JSON below')
|
|
3043
|
+
),
|
|
3044
|
+
h('textarea', {
|
|
3045
|
+
className: 'krot-input',
|
|
3046
|
+
rows: 6,
|
|
3047
|
+
value: importText,
|
|
3048
|
+
onChange: (e) => { setImportText(e.target.value); setImportPreview(null); },
|
|
3049
|
+
placeholder: t('jsonConfigPlaceholder') || 'Paste JSON configuration here...',
|
|
3050
|
+
style: { width: '100%', fontFamily: 'monospace', fontSize: '11px', marginBottom: '8px' }
|
|
3051
|
+
}),
|
|
3052
|
+
h('div', { style: { display: 'flex', gap: '16px', alignItems: 'center', marginBottom: '8px' } },
|
|
3053
|
+
h('label', { style: { display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px' } },
|
|
3054
|
+
h('input', { type: 'radio', name: 'importMode', checked: importMode === 'merge', onChange: () => setImportMode('merge') }),
|
|
3055
|
+
t('importMerge') || 'Merge'
|
|
3056
|
+
),
|
|
3057
|
+
h('label', { style: { display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px' } },
|
|
3058
|
+
h('input', { type: 'radio', name: 'importMode', checked: importMode === 'replace', onChange: () => setImportMode('replace') }),
|
|
3059
|
+
t('importReplace') || 'Replace'
|
|
3060
|
+
)
|
|
3061
|
+
),
|
|
3062
|
+
h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '10px' } },
|
|
3063
|
+
h('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, t('passphraseLabel') || 'Passphrase (if encrypted)'),
|
|
3064
|
+
h('input', {
|
|
3065
|
+
type: 'password',
|
|
3066
|
+
className: 'krot-input',
|
|
3067
|
+
value: importPassphrase,
|
|
3068
|
+
onChange: (e) => setImportPassphrase(e.target.value),
|
|
3069
|
+
placeholder: t('passphrasePlaceholder') || 'Enter passphrase for decryption',
|
|
3070
|
+
style: { maxWidth: '360px' }
|
|
3071
|
+
})
|
|
3072
|
+
),
|
|
3073
|
+
importError ? h('div', { className: 'krot-alert krot-alert-bad', style: { marginBottom: '8px' } }, importError) : null,
|
|
3074
|
+
importPreview ? h('div', { className: 'krot-alert krot-alert-ok', style: { marginBottom: '8px', fontSize: '12px' } },
|
|
3075
|
+
`Preview: ${importPreview.createdProviders?.length || 0} created, ${importPreview.updatedProviders?.length || 0} updated, ${importPreview.keysAdded || 0} keys added, ${importPreview.totalKeys || 0} total keys.`
|
|
3076
|
+
) : null,
|
|
3077
|
+
h('div', { style: { display: 'flex', gap: '8px' } },
|
|
3078
|
+
btn('🔍 ' + (t('importPreview') || 'Preview Diff'), runImportPreview, { disabled: !importText.trim() || importRunning }),
|
|
3079
|
+
btn('✅ ' + (t('importApply') || 'Apply Configuration'), runImportApply, { primary: true, disabled: !importText.trim() || importRunning }),
|
|
3080
|
+
btn(t('cancel'), () => { setImportModal(false); setImportPreview(null); })
|
|
3081
|
+
)
|
|
3082
|
+
) : null,
|
|
3083
|
+
h('div', { className: 'krot-row', style: { display: 'flex', gap: '8px', flexWrap: 'wrap' } },
|
|
3084
|
+
btn('📥 ' + (t('exportConfig') || 'Export Pools'), () => { setExportModal((v) => !v); setImportModal(false); }, { primary: exportModal }),
|
|
3085
|
+
btn('📤 ' + (t('importConfig') || 'Import Pools'), () => { setImportModal((v) => !v); setExportModal(false); }, { primary: importModal }),
|
|
2786
3086
|
btn(t('exportPools'), () => {
|
|
2787
3087
|
const data = JSON.stringify(val.providers ?? [], null, 2);
|
|
2788
3088
|
const blob = new Blob([data], { type: 'application/json' });
|
|
@@ -3001,31 +3301,34 @@ window.__ModuleLoader__.load({
|
|
|
3001
3301
|
|
|
3002
3302
|
|
|
3003
3303
|
|
|
3004
|
-
// (issue 201) header chip: one dot + counts for all pools. Green = all healthy,
|
|
3005
|
-
// amber = some keys cooling, red = a pool fully exhausted. Click opens
|
|
3006
|
-
// same summary the floating dashboard shows.
|
|
3304
|
+
// (issue 201, #499) header chip: one dot + counts for all pools. Green = all healthy,
|
|
3305
|
+
// amber = some keys cooling, red = a pool fully exhausted. Click opens quick actions popover.
|
|
3007
3306
|
function KeyRotationHeaderChip(props) {
|
|
3008
3307
|
const t = resolveT(props);
|
|
3009
3308
|
const [snap, setSnap] = React.useState(null);
|
|
3010
3309
|
const [open, setOpen] = React.useState(false);
|
|
3011
3310
|
const [, setTick] = React.useState(0);
|
|
3311
|
+
const [busy, setBusy] = React.useState(false);
|
|
3012
3312
|
const ref = React.useRef(null);
|
|
3013
3313
|
|
|
3314
|
+
const load = () => {
|
|
3315
|
+
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
|
|
3316
|
+
fetch('/dsh-key-rotation/health', {
|
|
3317
|
+
headers: { accept: 'application/json' },
|
|
3318
|
+
credentials: 'same-origin',
|
|
3319
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined
|
|
3320
|
+
})
|
|
3321
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
3322
|
+
.then((d) => { if (d) setSnap(d); })
|
|
3323
|
+
.catch((e) => { void e; });
|
|
3324
|
+
};
|
|
3325
|
+
|
|
3014
3326
|
React.useEffect(() => {
|
|
3015
|
-
let alive = true;
|
|
3016
|
-
const load = () => {
|
|
3017
|
-
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
|
|
3018
|
-
fetch('/dsh-key-rotation/health', { headers: { accept: 'application/json' }, credentials: 'same-origin', signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined })
|
|
3019
|
-
.then((r) => (r.ok ? r.json() : null))
|
|
3020
|
-
.then((d) => { if (alive) setSnap(d); })
|
|
3021
|
-
.catch((e) => { void e; });
|
|
3022
|
-
};
|
|
3023
3327
|
load();
|
|
3024
3328
|
const id = setInterval(load, 5000);
|
|
3025
3329
|
const onVis = () => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') load(); };
|
|
3026
3330
|
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVis);
|
|
3027
3331
|
return () => {
|
|
3028
|
-
alive = false;
|
|
3029
3332
|
clearInterval(id);
|
|
3030
3333
|
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVis);
|
|
3031
3334
|
};
|
|
@@ -3047,6 +3350,21 @@ window.__ModuleLoader__.load({
|
|
|
3047
3350
|
return () => document.removeEventListener('click', onDocClick);
|
|
3048
3351
|
}, [open]);
|
|
3049
3352
|
|
|
3353
|
+
const runAction = (action, provider, keyRef) => {
|
|
3354
|
+
setBusy(true);
|
|
3355
|
+
fetch('/dsh-key-rotation/quick-action', {
|
|
3356
|
+
method: 'POST',
|
|
3357
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
3358
|
+
body: JSON.stringify({ action, provider, ref: keyRef }),
|
|
3359
|
+
credentials: 'same-origin',
|
|
3360
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(5000) : undefined,
|
|
3361
|
+
})
|
|
3362
|
+
.then((r) => r.json())
|
|
3363
|
+
.then(() => { load(); })
|
|
3364
|
+
.catch((err) => { void err; })
|
|
3365
|
+
.finally(() => { setBusy(false); });
|
|
3366
|
+
};
|
|
3367
|
+
|
|
3050
3368
|
let settingsVal = null;
|
|
3051
3369
|
try {
|
|
3052
3370
|
const sSnap = props?.settingsSource?.getSnapshot?.()?.service?.getSnapshot?.();
|
|
@@ -3076,17 +3394,45 @@ window.__ModuleLoader__.load({
|
|
|
3076
3394
|
label,
|
|
3077
3395
|
h('span', { style: { fontSize: '9px', opacity: 0.6 } }, open ? '▲' : '▼')
|
|
3078
3396
|
),
|
|
3079
|
-
open ? h('div', { className: 'krot-popover' },
|
|
3397
|
+
open ? h('div', { className: 'krot-popover', style: { minWidth: '220px' } },
|
|
3080
3398
|
h('div', { className: 'krot-pop-title' }, t('headerPoolsTitle')),
|
|
3081
3399
|
!poolEntries.length ? h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, t('noActivePools')) : null,
|
|
3082
3400
|
poolEntries.map(([name, p]) => {
|
|
3083
3401
|
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)');
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3402
|
+
const provName = p.providerName || name;
|
|
3403
|
+
const tail = p.lastUsedTail;
|
|
3404
|
+
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)' } },
|
|
3405
|
+
h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
|
|
3406
|
+
h('div', { className: 'krot-pop-name' },
|
|
3407
|
+
h('span', { style: { width: '7px', height: '7px', borderRadius: '50%', background: c, flex: 'none' } }),
|
|
3408
|
+
name,
|
|
3409
|
+
tail ? h('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)', fontFeatureSettings: '"tnum"', fontFamily: 'monospace' } }, `...${tail}`) : null
|
|
3410
|
+
),
|
|
3411
|
+
h('div', { className: 'krot-pop-count', style: { color: c, fontWeight: 600 } }, `${p.healthy}/${p.total}`)
|
|
3088
3412
|
),
|
|
3089
|
-
h('div', {
|
|
3413
|
+
h('div', { style: { display: 'flex', gap: '4px', marginTop: '2px' } },
|
|
3414
|
+
h('button', {
|
|
3415
|
+
type: 'button',
|
|
3416
|
+
disabled: busy,
|
|
3417
|
+
onClick: (e) => { e.stopPropagation(); runAction('skip', provName, p.lastUsed); },
|
|
3418
|
+
title: t('quickSkipTitle'),
|
|
3419
|
+
style: { padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }
|
|
3420
|
+
}, `⏭️ ${t('quickSkip')}`),
|
|
3421
|
+
h('button', {
|
|
3422
|
+
type: 'button',
|
|
3423
|
+
disabled: busy,
|
|
3424
|
+
onClick: (e) => { e.stopPropagation(); runAction('reset', provName, p.lastUsed); },
|
|
3425
|
+
title: t('quickResetTitle'),
|
|
3426
|
+
style: { padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }
|
|
3427
|
+
}, `🔄 ${t('quickReset')}`),
|
|
3428
|
+
h('button', {
|
|
3429
|
+
type: 'button',
|
|
3430
|
+
disabled: busy || !p.lastUsed,
|
|
3431
|
+
onClick: (e) => { e.stopPropagation(); runAction('pause', provName, p.lastUsed); },
|
|
3432
|
+
title: t('quickPauseTitle'),
|
|
3433
|
+
style: { padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }
|
|
3434
|
+
}, `⏸️ ${t('quickPause')}`)
|
|
3435
|
+
)
|
|
3090
3436
|
);
|
|
3091
3437
|
})
|
|
3092
3438
|
) : null
|