@goodandready/dsh-key-rotation 0.7.26 → 0.7.28
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/LICENSE +21 -21
- package/README.md +157 -118
- package/cordis.patch.yml +6 -6
- package/lib/agent-budget.js +68 -68
- package/lib/bucket.js +52 -0
- package/lib/canary.js +56 -56
- package/lib/cascade.js +39 -39
- package/lib/client-helpers.js +21 -21
- package/lib/client.js +95 -5
- package/lib/concurrency.js +72 -72
- package/lib/heal.js +35 -35
- package/lib/histogram.js +66 -66
- package/lib/incident.js +76 -76
- package/lib/index.js +1658 -1402
- package/lib/keycheck.js +31 -0
- package/lib/maintenance.js +64 -0
- package/lib/pool.js +237 -227
- package/lib/quota-window.js +45 -45
- package/lib/quota.js +39 -39
- package/lib/region.js +50 -50
- package/lib/sandbox.js +117 -117
- package/lib/shadow.js +81 -81
- package/lib/usage-report.js +49 -0
- package/lib/webhook.js +133 -64
- package/package.json +58 -58
package/lib/canary.js
CHANGED
|
@@ -1,56 +1,56 @@
|
|
|
1
|
-
// canary.js — canary probing before releasing a key from cooldown (issue #196).
|
|
2
|
-
|
|
3
|
-
export const CANARY_PROBE_TIMEOUT_MS = 5000;
|
|
4
|
-
export const CANARY_DEFAULT_INTERVAL_MS = 30 * 1000;
|
|
5
|
-
|
|
6
|
-
export class CanaryProber {
|
|
7
|
-
constructor(opts) {
|
|
8
|
-
opts = opts || {};
|
|
9
|
-
if (!opts.sandboxRunner) throw new Error('canary: sandboxRunner required');
|
|
10
|
-
this._runner = opts.sandboxRunner;
|
|
11
|
-
this._intervalMs = opts.intervalMs || CANARY_DEFAULT_INTERVAL_MS;
|
|
12
|
-
this._results = new Map();
|
|
13
|
-
this._inProgress = new Set();
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
get intervalMs() { return this._intervalMs; }
|
|
17
|
-
|
|
18
|
-
async probe(ref, key) {
|
|
19
|
-
if (!ref || this._inProgress.has(ref)) return null;
|
|
20
|
-
this._inProgress.add(ref);
|
|
21
|
-
try {
|
|
22
|
-
const result = await this._runner.probeModels(ref, key);
|
|
23
|
-
this._results.set(ref, Object.assign({}, result, { at: Date.now() }));
|
|
24
|
-
return result;
|
|
25
|
-
} catch (e) {
|
|
26
|
-
return { ok: false, code: 'error', at: Date.now() };
|
|
27
|
-
} finally {
|
|
28
|
-
this._inProgress.delete(ref);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
lastResult(ref) {
|
|
33
|
-
return this._results.get(ref) || null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
isHealthy(ref) {
|
|
37
|
-
const r = this._results.get(ref);
|
|
38
|
-
return Boolean(r && r.ok);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
clear(ref) {
|
|
42
|
-
if (ref) {
|
|
43
|
-
this._results.delete(ref);
|
|
44
|
-
this._inProgress.delete(ref);
|
|
45
|
-
} else {
|
|
46
|
-
this._results.clear();
|
|
47
|
-
this._inProgress.clear();
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
snapshot() {
|
|
52
|
-
const out = {};
|
|
53
|
-
for (const [k, v] of this._results) out[k] = v;
|
|
54
|
-
return out;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
1
|
+
// canary.js — canary probing before releasing a key from cooldown (issue #196).
|
|
2
|
+
|
|
3
|
+
export const CANARY_PROBE_TIMEOUT_MS = 5000;
|
|
4
|
+
export const CANARY_DEFAULT_INTERVAL_MS = 30 * 1000;
|
|
5
|
+
|
|
6
|
+
export class CanaryProber {
|
|
7
|
+
constructor(opts) {
|
|
8
|
+
opts = opts || {};
|
|
9
|
+
if (!opts.sandboxRunner) throw new Error('canary: sandboxRunner required');
|
|
10
|
+
this._runner = opts.sandboxRunner;
|
|
11
|
+
this._intervalMs = opts.intervalMs || CANARY_DEFAULT_INTERVAL_MS;
|
|
12
|
+
this._results = new Map();
|
|
13
|
+
this._inProgress = new Set();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get intervalMs() { return this._intervalMs; }
|
|
17
|
+
|
|
18
|
+
async probe(ref, key) {
|
|
19
|
+
if (!ref || this._inProgress.has(ref)) return null;
|
|
20
|
+
this._inProgress.add(ref);
|
|
21
|
+
try {
|
|
22
|
+
const result = await this._runner.probeModels(ref, key);
|
|
23
|
+
this._results.set(ref, Object.assign({}, result, { at: Date.now() }));
|
|
24
|
+
return result;
|
|
25
|
+
} catch (e) {
|
|
26
|
+
return { ok: false, code: 'error', at: Date.now() };
|
|
27
|
+
} finally {
|
|
28
|
+
this._inProgress.delete(ref);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
lastResult(ref) {
|
|
33
|
+
return this._results.get(ref) || null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
isHealthy(ref) {
|
|
37
|
+
const r = this._results.get(ref);
|
|
38
|
+
return Boolean(r && r.ok);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
clear(ref) {
|
|
42
|
+
if (ref) {
|
|
43
|
+
this._results.delete(ref);
|
|
44
|
+
this._inProgress.delete(ref);
|
|
45
|
+
} else {
|
|
46
|
+
this._results.clear();
|
|
47
|
+
this._inProgress.clear();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
snapshot() {
|
|
52
|
+
const out = {};
|
|
53
|
+
for (const [k, v] of this._results) out[k] = v;
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
}
|
package/lib/cascade.js
CHANGED
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
// cascade.js — cross-provider failover cascade (issue #194).
|
|
2
|
-
// ponytail: minimal — pick fallback provider from a RegionMap-like config.
|
|
3
|
-
|
|
4
|
-
export const CASCADE_MAX_DEPTH = 1;
|
|
5
|
-
|
|
6
|
-
export function pickCascadeFallback(provider, cfg, pools) {
|
|
7
|
-
const list = Array.isArray(cfg && cfg.cascade) ? cfg.cascade : [];
|
|
8
|
-
for (const entry of list) {
|
|
9
|
-
const fb = typeof entry === 'string' ? { provider: entry } : entry;
|
|
10
|
-
if (!fb || !fb.provider || fb.provider === provider) continue;
|
|
11
|
-
const pool = pools instanceof Map ? pools.get(fb.provider) : (pools ? pools[fb.provider] : null);
|
|
12
|
-
if (!pool) continue;
|
|
13
|
-
const now = Date.now();
|
|
14
|
-
let healthy = 0;
|
|
15
|
-
for (const ref of pool.refs) {
|
|
16
|
-
const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
|
|
17
|
-
if (failedUntil > now) continue;
|
|
18
|
-
const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
|
|
19
|
-
if (exp !== undefined && now >= exp) continue;
|
|
20
|
-
healthy += 1;
|
|
21
|
-
}
|
|
22
|
-
if (healthy === 0) continue;
|
|
23
|
-
return { provider: fb.provider, pool, model: fb.model || null };
|
|
24
|
-
}
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function hasHealthyKey(pool, now) {
|
|
29
|
-
now = now || Date.now();
|
|
30
|
-
if (!pool || !Array.isArray(pool.refs)) return false;
|
|
31
|
-
for (const ref of pool.refs) {
|
|
32
|
-
const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
|
|
33
|
-
if (failedUntil > now) continue;
|
|
34
|
-
const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
|
|
35
|
-
if (exp !== undefined && now >= exp) continue;
|
|
36
|
-
return true;
|
|
37
|
-
}
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
1
|
+
// cascade.js — cross-provider failover cascade (issue #194).
|
|
2
|
+
// ponytail: minimal — pick fallback provider from a RegionMap-like config.
|
|
3
|
+
|
|
4
|
+
export const CASCADE_MAX_DEPTH = 1;
|
|
5
|
+
|
|
6
|
+
export function pickCascadeFallback(provider, cfg, pools) {
|
|
7
|
+
const list = Array.isArray(cfg && cfg.cascade) ? cfg.cascade : [];
|
|
8
|
+
for (const entry of list) {
|
|
9
|
+
const fb = typeof entry === 'string' ? { provider: entry } : entry;
|
|
10
|
+
if (!fb || !fb.provider || fb.provider === provider) continue;
|
|
11
|
+
const pool = pools instanceof Map ? pools.get(fb.provider) : (pools ? pools[fb.provider] : null);
|
|
12
|
+
if (!pool) continue;
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
let healthy = 0;
|
|
15
|
+
for (const ref of pool.refs) {
|
|
16
|
+
const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
|
|
17
|
+
if (failedUntil > now) continue;
|
|
18
|
+
const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
|
|
19
|
+
if (exp !== undefined && now >= exp) continue;
|
|
20
|
+
healthy += 1;
|
|
21
|
+
}
|
|
22
|
+
if (healthy === 0) continue;
|
|
23
|
+
return { provider: fb.provider, pool, model: fb.model || null };
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function hasHealthyKey(pool, now) {
|
|
29
|
+
now = now || Date.now();
|
|
30
|
+
if (!pool || !Array.isArray(pool.refs)) return false;
|
|
31
|
+
for (const ref of pool.refs) {
|
|
32
|
+
const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
|
|
33
|
+
if (failedUntil > now) continue;
|
|
34
|
+
const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
|
|
35
|
+
if (exp !== undefined && now >= exp) continue;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
package/lib/client-helpers.js
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
export function formatAgo(t, at) {
|
|
2
|
-
if (!at) return '';
|
|
3
|
-
const sec = Math.max(0, Math.round((Date.now() - at) / 1000));
|
|
4
|
-
if (sec < 60) return t('justNow');
|
|
5
|
-
if (sec < 3600) return t('minutesAgo').replace('{n}', String(Math.round(sec / 60)));
|
|
6
|
-
return t('hoursAgo').replace('{n}', String(Math.round(sec / 3600)));
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function nextKeyRef(providerId, existingKeys, allRefs) {
|
|
10
|
-
const fromExisting = (existingKeys || []).find((k) => typeof k === 'string' && k.length > 0);
|
|
11
|
-
const base = fromExisting
|
|
12
|
-
? fromExisting.replace(/_\d+$/, '')
|
|
13
|
-
: String(providerId || 'provider').toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') + '_API_KEY';
|
|
14
|
-
const taken = new Set(allRefs);
|
|
15
|
-
if (!taken.has(base)) return base;
|
|
16
|
-
for (let n = 2; n < 1000; n++) {
|
|
17
|
-
const candidate = base + '_' + n;
|
|
18
|
-
if (!taken.has(candidate)) return candidate;
|
|
19
|
-
}
|
|
20
|
-
return base + '_' + Date.now();
|
|
21
|
-
}
|
|
1
|
+
export function formatAgo(t, at) {
|
|
2
|
+
if (!at) return '';
|
|
3
|
+
const sec = Math.max(0, Math.round((Date.now() - at) / 1000));
|
|
4
|
+
if (sec < 60) return t('justNow');
|
|
5
|
+
if (sec < 3600) return t('minutesAgo').replace('{n}', String(Math.round(sec / 60)));
|
|
6
|
+
return t('hoursAgo').replace('{n}', String(Math.round(sec / 3600)));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function nextKeyRef(providerId, existingKeys, allRefs) {
|
|
10
|
+
const fromExisting = (existingKeys || []).find((k) => typeof k === 'string' && k.length > 0);
|
|
11
|
+
const base = fromExisting
|
|
12
|
+
? fromExisting.replace(/_\d+$/, '')
|
|
13
|
+
: String(providerId || 'provider').toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') + '_API_KEY';
|
|
14
|
+
const taken = new Set(allRefs);
|
|
15
|
+
if (!taken.has(base)) return base;
|
|
16
|
+
for (let n = 2; n < 1000; n++) {
|
|
17
|
+
const candidate = base + '_' + n;
|
|
18
|
+
if (!taken.has(candidate)) return candidate;
|
|
19
|
+
}
|
|
20
|
+
return base + '_' + Date.now();
|
|
21
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -63,6 +63,10 @@ window.__ModuleLoader__.load({
|
|
|
63
63
|
keySaved: 'saved',
|
|
64
64
|
keyFromEnv: 'from the environment, read-only here',
|
|
65
65
|
keyWriteFailed: 'could not store the key: {msg}',
|
|
66
|
+
notSecretShape: 'saved, but the value does not look like an API key - check for a typo',
|
|
67
|
+
rpmTitle: 'requests/min: {u} used, {r} remaining',
|
|
68
|
+
budgetLabel: 'budget:',
|
|
69
|
+
exportCsv: 'Export CSV',
|
|
66
70
|
keyHint: 'The value is stored in DSH credentials and never sent back to the browser — only its last 5 characters are shown. Names are generated for you; hover a key to see the one it uses.',
|
|
67
71
|
brokenKey: 'broken (3× AUTH)',
|
|
68
72
|
keyExpired: 'expired',
|
|
@@ -119,6 +123,10 @@ window.__ModuleLoader__.load({
|
|
|
119
123
|
keySaved: 'сохранён',
|
|
120
124
|
keyFromEnv: 'задан в окружении, отсюда не меняется',
|
|
121
125
|
keyWriteFailed: 'не удалось сохранить ключ: {msg}',
|
|
126
|
+
notSecretShape: 'сохранено, но значение не похоже на API-ключ — проверьте опечатки',
|
|
127
|
+
rpmTitle: 'запросов/мин: {u} использовано, {r} осталось',
|
|
128
|
+
budgetLabel: 'бюджет:',
|
|
129
|
+
exportCsv: 'CSV',
|
|
122
130
|
keyHint: 'Значение хранится в учётных данных DSH и обратно в браузер не отдаётся — показываются только последние 5 символов. Имена переменных создаются автоматически; наведите на ключ, чтобы увидеть используемое имя.',
|
|
123
131
|
brokenKey: 'сломан (3× AUTH)',
|
|
124
132
|
keyExpired: 'истёк',
|
|
@@ -292,7 +300,8 @@ window.__ModuleLoader__.load({
|
|
|
292
300
|
}); };
|
|
293
301
|
const doTest = (ref) => {
|
|
294
302
|
setTesting(ref); setTestResult((m) => ({ ...m, [ref]: null }));
|
|
295
|
-
|
|
303
|
+
// #212: real API probe (models is free on most providers), not just presence
|
|
304
|
+
fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref, probe: 'models' }) })
|
|
296
305
|
.then((r) => r.json())
|
|
297
306
|
.then((data) => setTestResult((m) => ({ ...m, [ref]: data })))
|
|
298
307
|
.catch((e) => setTestResult((m) => ({ ...m, [ref]: { ok: false, message: String(e?.message ?? e) } })))
|
|
@@ -390,6 +399,11 @@ window.__ModuleLoader__.load({
|
|
|
390
399
|
.then((r) => r.json().then((data) => ({ ok: r.ok, data })))
|
|
391
400
|
.then(({ ok, data }) => {
|
|
392
401
|
if (!ok) throw new Error(data?.error?.message ?? 'unknown error');
|
|
402
|
+
// #200 leak-detector hint: stored value does not match any known
|
|
403
|
+
// API-key shape - probably a placeholder or a typo.
|
|
404
|
+
if (data?.looksLikeSecret === false) {
|
|
405
|
+
setSecretError(t('notSecretShape'));
|
|
406
|
+
}
|
|
393
407
|
setSecretDraft((cur) => {
|
|
394
408
|
const next = { ...cur };
|
|
395
409
|
delete next[rowKey];
|
|
@@ -510,7 +524,8 @@ window.__ModuleLoader__.load({
|
|
|
510
524
|
if (hit.expired) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyExpired') };
|
|
511
525
|
if (hit.expiresAt && !hit.expired) {
|
|
512
526
|
const days = Math.ceil((hit.expiresAt - Date.now()) / 86400000);
|
|
513
|
-
|
|
527
|
+
const warnDays = Number(val?.expiryWarnDays) || 7; // #207: configurable horizon
|
|
528
|
+
if (days <= warnDays) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyExpiringSoon').replace('{n}', String(days)) };
|
|
514
529
|
}
|
|
515
530
|
if (hit.broken) return { color: 'var(--dsw-alias-state-error-primary)', text: t('brokenKey') };
|
|
516
531
|
if (!hit.present) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyMissing') };
|
|
@@ -586,12 +601,19 @@ window.__ModuleLoader__.load({
|
|
|
586
601
|
}
|
|
587
602
|
}
|
|
588
603
|
if (info && info.lastUsedAt) meta.push(h('span', { key: 'lu', className: 'krot-tail', title: 'last used' }, formatAgo((k)=>t(k), info.lastUsedAt)));
|
|
604
|
+
// #210: RPM capacity indicator (only when rpmLimit is active)
|
|
605
|
+
if (info && info.rpm) meta.push(h('span', { key: 'rpm', className: 'krot-tail',
|
|
606
|
+
title: t('rpmTitle').replace('{u}', String(info.rpm.used)).replace('{r}', String(info.rpm.remaining)),
|
|
607
|
+
style: info.rpm.remaining === 0 ? { color: 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } : undefined },
|
|
608
|
+
'⏱' + info.rpm.remaining));
|
|
589
609
|
if (info && typeof info.cost === 'number' && info.cost > 0) meta.push(h('span', { key: 'c', className: 'krot-tail', title: 'cost' }, '$' + info.cost.toFixed(2)));
|
|
590
610
|
const tr = testResult[key];
|
|
591
611
|
if (tr) meta.push(h('span', { key: 'tr', className: 'krot-tail',
|
|
592
|
-
title: tr.message || (tr.ok ? t('testOk') : t('testFail'))
|
|
612
|
+
title: (tr.message || (tr.ok ? t('testOk') : t('testFail')))
|
|
613
|
+
+ (tr.ok && tr.modelsCount ? ' · ' + tr.modelsCount + ' models' : '')
|
|
614
|
+
+ (tr.ok && tr.latencyMs ? ' · ' + tr.latencyMs + 'ms' : ''),
|
|
593
615
|
style: { color: tr.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } },
|
|
594
|
-
tr.ok ? '✓' : '✕'));
|
|
616
|
+
tr.ok ? (tr.modelsCount ? tr.modelsCount + 'm' : '✓') : '✕'));
|
|
595
617
|
meta.push(h('button', { key: 't', className: 'krot-btn', onClick: () => doTest(key), disabled: testing === key, title: t('testKey') }, testing === key ? '…' : t('testKey')));
|
|
596
618
|
meta.push(h('span', { key: 'a', className: 'krot-acts' },
|
|
597
619
|
btn('↑', () => moveKey(pIndex, kIndex, -1), { disabled: kIndex === 0, title: t('moveUp') }),
|
|
@@ -616,6 +638,28 @@ window.__ModuleLoader__.load({
|
|
|
616
638
|
const exhaustionWarning = providerStatus && providerStatus.lastExhaustionAt && (Date.now() - providerStatus.lastExhaustionAt) < 3600000
|
|
617
639
|
? h('p', { className: 'krot-err' }, t('poolExhausted') + ' (' + formatAgo(t, providerStatus.lastExhaustionAt) + ')')
|
|
618
640
|
: null;
|
|
641
|
+
// #208: budget line (warn color at >=80%, red at 100%)
|
|
642
|
+
const budgetLine = providerStatus && (providerStatus.budgetDaily > 0 || providerStatus.budgetWeekly > 0)
|
|
643
|
+
? (() => {
|
|
644
|
+
const dayRatio = providerStatus.budgetDaily > 0 ? providerStatus.todayCost / providerStatus.budgetDaily : 0;
|
|
645
|
+
const weekRatio = providerStatus.budgetWeekly > 0 ? providerStatus.weeklyCost / providerStatus.budgetWeekly : 0;
|
|
646
|
+
const worst = Math.max(dayRatio, weekRatio);
|
|
647
|
+
const color = worst >= 1 ? 'var(--dsw-alias-state-error-primary)' : worst >= 0.8 ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-label-tertiary)';
|
|
648
|
+
const parts = [];
|
|
649
|
+
if (providerStatus.budgetDaily > 0) parts.push('$' + (providerStatus.todayCost ?? 0).toFixed(2) + '/' + '$' + providerStatus.budgetDaily);
|
|
650
|
+
if (providerStatus.budgetWeekly > 0) parts.push('week $' + (providerStatus.weeklyCost ?? 0).toFixed(2) + '/' + '$' + providerStatus.budgetWeekly);
|
|
651
|
+
if (worst >= 1 && providerStatus.pauseOnBudget) parts.push('· paused');
|
|
652
|
+
return h('p', { className: 'krot-hint', style: { color } }, t('budgetLabel') + ' ' + parts.join(' · '));
|
|
653
|
+
})()
|
|
654
|
+
: null;
|
|
655
|
+
// #209: CSV export for this provider's usage (last 7 days)
|
|
656
|
+
const exportCsv = h('button', { className: 'krot-btn', title: t('exportCsv'),
|
|
657
|
+
onClick: () => {
|
|
658
|
+
const url = '/dsh-key-rotation/usage?format=csv&days=7&provider=' + encodeURIComponent(entry.provider);
|
|
659
|
+
const a = document.createElement('a');
|
|
660
|
+
a.href = url; a.download = 'usage-' + entry.provider + '.csv';
|
|
661
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
662
|
+
} }, t('exportCsv'));
|
|
619
663
|
|
|
620
664
|
return h('div', { key: pIndex, className: 'krot-prov' },
|
|
621
665
|
h('div', { className: 'krot-prov-head' },
|
|
@@ -653,11 +697,13 @@ window.__ModuleLoader__.load({
|
|
|
653
697
|
h('div', { className: 'krot-foot' },
|
|
654
698
|
btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle') }),
|
|
655
699
|
switchesLine,
|
|
700
|
+
budgetLine,
|
|
656
701
|
exhaustionWarning,
|
|
657
702
|
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('div', { style: { display: 'flex', gap: '2px', alignItems: 'end', height: '24px', marginTop: '4px' } }, (() => { const now = Date.now(); const buckets = Array(24).fill(0); for (const ev of providerStatus.events) { const h = Math.floor((now - ev.at) / 3600000); if (h >= 0 && h < 24) buckets[23 - h]++; } const max = Math.max(1, ...buckets); return buckets.map((c, i) => h('div', { key: i, title: c + ' switches', style: { flex: 1, background: c ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-border-l2)', height: (c / max * 24) + 'px', minHeight: '2px', borderRadius: '2px' } })); })()) : null),
|
|
658
703
|
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('details', { style: { fontSize: '11px', marginTop: '6px' } }, h('summary', null, 'Recent failures ('+providerStatus.events.length+')'), h('ul', { style: { margin: '4px 0 0', paddingLeft: '16px' } }, providerStatus.events.slice().reverse().map((ev, i) => h('li', { key: i, style: ev.type === 'probe' ? { opacity: .5 } : null }, new Date(ev.at).toLocaleTimeString() + ' ' + (ev.type === 'probe' ? '[probe] ' : '') + ev.ref + ' ' + ev.reason + ' cd=' + ev.cooldownMs)) )) : null),
|
|
659
704
|
btn(t('resetCooldown'), () => doReset(entry.provider), { disabled: !(providerStatus && providerStatus.switches > 0) || resetting === entry.provider, title: t('resetCooldown') }),
|
|
660
705
|
btn(testAllProvider === entry.provider ? t('testing') : t('testAll'), () => doTestAll(entry.provider), { disabled: testAllProvider === entry.provider, title: t('testAll') }),
|
|
706
|
+
exportCsv,
|
|
661
707
|
),
|
|
662
708
|
);
|
|
663
709
|
});
|
|
@@ -803,10 +849,54 @@ window.__ModuleLoader__.load({
|
|
|
803
849
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
|
|
804
850
|
}
|
|
805
851
|
|
|
806
|
-
|
|
852
|
+
// #201 header chip: one dot + counts for all pools. Green = all healthy,
|
|
853
|
+
// amber = some keys cooling, red = a pool fully exhausted. Click opens the
|
|
854
|
+
// same summary the floating dashboard shows.
|
|
855
|
+
function KeyRotationHeaderChip() {
|
|
856
|
+
const [snap, setSnap] = React.useState(null);
|
|
857
|
+
React.useEffect(() => {
|
|
858
|
+
let alive = true;
|
|
859
|
+
const load = () => {
|
|
860
|
+
fetch('/dsh-key-rotation/health', { headers: { accept: 'application/json' }, credentials: 'same-origin' })
|
|
861
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
862
|
+
.then((d) => { if (alive) setSnap(d); })
|
|
863
|
+
.catch(() => {});
|
|
864
|
+
};
|
|
865
|
+
load();
|
|
866
|
+
const id = setInterval(load, 4000);
|
|
867
|
+
return () => { alive = false; clearInterval(id); };
|
|
868
|
+
}, []);
|
|
869
|
+
const pools = Object.values((snap && snap.pools) || {});
|
|
870
|
+
const total = pools.reduce((a, p) => a + (p.total || 0), 0);
|
|
871
|
+
const healthy = pools.reduce((a, p) => a + (p.healthy || 0), 0);
|
|
872
|
+
const anyExhausted = pools.some((p) => p.exhausted);
|
|
873
|
+
const color = !pools.length ? 'var(--dsw-alias-label-tertiary)' : anyExhausted ? '#e5484d' : healthy < total ? '#f5a623' : '#30a46c';
|
|
874
|
+
const label = pools.length ? `${healthy}/${total}` : 'rot';
|
|
875
|
+
return h('span', {
|
|
876
|
+
className: 'krot-chip',
|
|
877
|
+
title: 'Key Rotation',
|
|
878
|
+
style: { display: 'inline-flex', alignItems: 'center', gap: '5px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', cursor: 'default' },
|
|
879
|
+
},
|
|
880
|
+
h('span', { style: { width: '8px', height: '8px', borderRadius: '50%', background: color, flex: 'none' } }),
|
|
881
|
+
label);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function apply(ctx) {
|
|
807
885
|
ctx.effect(() => ctx.locale.register(NS, { en, ru }), 'dsh-key-rotation: dictionaries');
|
|
808
886
|
// Dashboard widget: lives on every page, polls /health (#152).
|
|
809
887
|
ctx.effect(() => mountDashboard(), 'dsh-key-rotation: dashboard widget');
|
|
888
|
+
// Header chip (#201): status dot in the session header utilities slot,
|
|
889
|
+
// same slot dsh-gitea / dsh-subscriptions use for their header widgets.
|
|
890
|
+
ctx.effect(() => {
|
|
891
|
+
if (!ctx.slots) return;
|
|
892
|
+
try {
|
|
893
|
+
ctx.slots.inject('conversation.session.header.utilities', () =>
|
|
894
|
+
ctx.slots.register(
|
|
895
|
+
{ name: 'conversation.session.header.utilities', id: 'dsh-key-rotation-header-chip', order: 6 },
|
|
896
|
+
KeyRotationHeaderChip,
|
|
897
|
+
));
|
|
898
|
+
} catch { /* slot not available in this build */ }
|
|
899
|
+
}, 'dsh-key-rotation: header chip');
|
|
810
900
|
function useLocale() {
|
|
811
901
|
return useActiveLocale(ctx);
|
|
812
902
|
}
|
package/lib/concurrency.js
CHANGED
|
@@ -1,72 +1,72 @@
|
|
|
1
|
-
// concurrency.js — per-key in-flight counter + least-connections picking (issue #193).
|
|
2
|
-
|
|
3
|
-
export const CONCURRENCY_DEFAULT_LIMIT = 0;
|
|
4
|
-
export const CONCURRENCY_STALE_LOCK_MS = 5 * 60 * 1000;
|
|
5
|
-
|
|
6
|
-
export class ConcurrencyTracker {
|
|
7
|
-
constructor(opts) {
|
|
8
|
-
opts = opts || {};
|
|
9
|
-
const limit = opts.limit !== undefined ? opts.limit : CONCURRENCY_DEFAULT_LIMIT;
|
|
10
|
-
this._limit = (Number.isFinite(limit) && limit >= 0) ? Math.floor(limit) : 0;
|
|
11
|
-
this._staleMs = opts.staleMs || CONCURRENCY_STALE_LOCK_MS;
|
|
12
|
-
this._inFlight = new Map();
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
isEnabled() { return this._limit > 0; }
|
|
16
|
-
get limit() { return this._limit; }
|
|
17
|
-
|
|
18
|
-
acquire(ref, now) {
|
|
19
|
-
now = now || Date.now();
|
|
20
|
-
if (!this.isEnabled()) return true;
|
|
21
|
-
let e = this._inFlight.get(ref);
|
|
22
|
-
if (!e) {
|
|
23
|
-
e = { count: 0, lastAcquired: now };
|
|
24
|
-
this._inFlight.set(ref, e);
|
|
25
|
-
}
|
|
26
|
-
if (now - e.lastAcquired > this._staleMs) {
|
|
27
|
-
e.count = 0;
|
|
28
|
-
}
|
|
29
|
-
if (e.count >= this._limit) return false;
|
|
30
|
-
e.count += 1;
|
|
31
|
-
e.lastAcquired = now;
|
|
32
|
-
return true;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
release(ref, now) {
|
|
36
|
-
now = now || Date.now();
|
|
37
|
-
const e = this._inFlight.get(ref);
|
|
38
|
-
if (!e) return;
|
|
39
|
-
e.count = Math.max(0, e.count - 1);
|
|
40
|
-
e.lastAcquired = now;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
snapshot() {
|
|
44
|
-
const out = {};
|
|
45
|
-
for (const [k, v] of this._inFlight) out[k] = { count: v.count };
|
|
46
|
-
return out;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
pickLeastLoaded(candidates, now) {
|
|
50
|
-
now = now || Date.now();
|
|
51
|
-
if (!Array.isArray(candidates) || candidates.length === 0) return null;
|
|
52
|
-
let best = null;
|
|
53
|
-
let bestCount = Infinity;
|
|
54
|
-
for (const ref of candidates) {
|
|
55
|
-
const e = this._inFlight.get(ref);
|
|
56
|
-
const count = e ? e.count : 0;
|
|
57
|
-
if (this.isEnabled() && count >= this._limit) continue;
|
|
58
|
-
if (count < bestCount) {
|
|
59
|
-
best = ref;
|
|
60
|
-
bestCount = count;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return best;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
clear(ref) {
|
|
67
|
-
if (ref) this._inFlight.delete(ref);
|
|
68
|
-
else this._inFlight.clear();
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
get size() { return this._inFlight.size; }
|
|
72
|
-
}
|
|
1
|
+
// concurrency.js — per-key in-flight counter + least-connections picking (issue #193).
|
|
2
|
+
|
|
3
|
+
export const CONCURRENCY_DEFAULT_LIMIT = 0;
|
|
4
|
+
export const CONCURRENCY_STALE_LOCK_MS = 5 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
export class ConcurrencyTracker {
|
|
7
|
+
constructor(opts) {
|
|
8
|
+
opts = opts || {};
|
|
9
|
+
const limit = opts.limit !== undefined ? opts.limit : CONCURRENCY_DEFAULT_LIMIT;
|
|
10
|
+
this._limit = (Number.isFinite(limit) && limit >= 0) ? Math.floor(limit) : 0;
|
|
11
|
+
this._staleMs = opts.staleMs || CONCURRENCY_STALE_LOCK_MS;
|
|
12
|
+
this._inFlight = new Map();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
isEnabled() { return this._limit > 0; }
|
|
16
|
+
get limit() { return this._limit; }
|
|
17
|
+
|
|
18
|
+
acquire(ref, now) {
|
|
19
|
+
now = now || Date.now();
|
|
20
|
+
if (!this.isEnabled()) return true;
|
|
21
|
+
let e = this._inFlight.get(ref);
|
|
22
|
+
if (!e) {
|
|
23
|
+
e = { count: 0, lastAcquired: now };
|
|
24
|
+
this._inFlight.set(ref, e);
|
|
25
|
+
}
|
|
26
|
+
if (now - e.lastAcquired > this._staleMs) {
|
|
27
|
+
e.count = 0;
|
|
28
|
+
}
|
|
29
|
+
if (e.count >= this._limit) return false;
|
|
30
|
+
e.count += 1;
|
|
31
|
+
e.lastAcquired = now;
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
release(ref, now) {
|
|
36
|
+
now = now || Date.now();
|
|
37
|
+
const e = this._inFlight.get(ref);
|
|
38
|
+
if (!e) return;
|
|
39
|
+
e.count = Math.max(0, e.count - 1);
|
|
40
|
+
e.lastAcquired = now;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
snapshot() {
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const [k, v] of this._inFlight) out[k] = { count: v.count };
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
pickLeastLoaded(candidates, now) {
|
|
50
|
+
now = now || Date.now();
|
|
51
|
+
if (!Array.isArray(candidates) || candidates.length === 0) return null;
|
|
52
|
+
let best = null;
|
|
53
|
+
let bestCount = Infinity;
|
|
54
|
+
for (const ref of candidates) {
|
|
55
|
+
const e = this._inFlight.get(ref);
|
|
56
|
+
const count = e ? e.count : 0;
|
|
57
|
+
if (this.isEnabled() && count >= this._limit) continue;
|
|
58
|
+
if (count < bestCount) {
|
|
59
|
+
best = ref;
|
|
60
|
+
bestCount = count;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return best;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
clear(ref) {
|
|
67
|
+
if (ref) this._inFlight.delete(ref);
|
|
68
|
+
else this._inFlight.clear();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get size() { return this._inFlight.size; }
|
|
72
|
+
}
|
package/lib/heal.js
CHANGED
|
@@ -1,35 +1,35 @@
|
|
|
1
|
-
// heal.js — self-healing idle cooldowns.
|
|
2
|
-
// ponytail: pure function, easy to test, no side effects beyond mutation of passed-in state.
|
|
3
|
-
|
|
4
|
-
// Returns array of { ref, poolBase } entries that were healed in this tick.
|
|
5
|
-
// Mutates `pools` (removes from failedUntil, pushes heal event into events).
|
|
6
|
-
// `now` parameter is injectable for tests.
|
|
7
|
-
export function healIdleCooldowns(pools, idleMs, now = Date.now()) {
|
|
8
|
-
if (!Array.isArray(pools) || pools.length === 0) return [];
|
|
9
|
-
if (!Number.isFinite(idleMs) || idleMs <= 0) return [];
|
|
10
|
-
const healed = [];
|
|
11
|
-
for (const pool of pools) {
|
|
12
|
-
if (!pool || !pool.state || !pool.base) continue;
|
|
13
|
-
const fu = pool.state.failedUntil;
|
|
14
|
-
const lu = pool.state.lastUsed;
|
|
15
|
-
if (!fu || fu.size === 0) continue;
|
|
16
|
-
const expiredRefs = [];
|
|
17
|
-
for (const [ref, until] of fu.entries()) {
|
|
18
|
-
if (!Number.isFinite(until)) continue;
|
|
19
|
-
if (until > now) continue; // cooldown still active
|
|
20
|
-
const last = lu ? lu.get(ref) : undefined;
|
|
21
|
-
if (!Number.isFinite(last)) continue; // never used → no signal, skip
|
|
22
|
-
if (now - last < idleMs) continue; // used recently → don't heal
|
|
23
|
-
expiredRefs.push(ref);
|
|
24
|
-
}
|
|
25
|
-
for (const ref of expiredRefs) {
|
|
26
|
-
fu.delete(ref);
|
|
27
|
-
if (Array.isArray(pool.state.events)) {
|
|
28
|
-
pool.state.events.push({ at: now, ref, reason: 'self-heal', cooldownMs: 0, type: 'heal' });
|
|
29
|
-
if (pool.state.events.length > 50) pool.state.events.shift();
|
|
30
|
-
}
|
|
31
|
-
healed.push({ ref, poolBase: pool.base });
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return healed;
|
|
35
|
-
}
|
|
1
|
+
// heal.js — self-healing idle cooldowns.
|
|
2
|
+
// ponytail: pure function, easy to test, no side effects beyond mutation of passed-in state.
|
|
3
|
+
|
|
4
|
+
// Returns array of { ref, poolBase } entries that were healed in this tick.
|
|
5
|
+
// Mutates `pools` (removes from failedUntil, pushes heal event into events).
|
|
6
|
+
// `now` parameter is injectable for tests.
|
|
7
|
+
export function healIdleCooldowns(pools, idleMs, now = Date.now()) {
|
|
8
|
+
if (!Array.isArray(pools) || pools.length === 0) return [];
|
|
9
|
+
if (!Number.isFinite(idleMs) || idleMs <= 0) return [];
|
|
10
|
+
const healed = [];
|
|
11
|
+
for (const pool of pools) {
|
|
12
|
+
if (!pool || !pool.state || !pool.base) continue;
|
|
13
|
+
const fu = pool.state.failedUntil;
|
|
14
|
+
const lu = pool.state.lastUsed;
|
|
15
|
+
if (!fu || fu.size === 0) continue;
|
|
16
|
+
const expiredRefs = [];
|
|
17
|
+
for (const [ref, until] of fu.entries()) {
|
|
18
|
+
if (!Number.isFinite(until)) continue;
|
|
19
|
+
if (until > now) continue; // cooldown still active
|
|
20
|
+
const last = lu ? lu.get(ref) : undefined;
|
|
21
|
+
if (!Number.isFinite(last)) continue; // never used → no signal, skip
|
|
22
|
+
if (now - last < idleMs) continue; // used recently → don't heal
|
|
23
|
+
expiredRefs.push(ref);
|
|
24
|
+
}
|
|
25
|
+
for (const ref of expiredRefs) {
|
|
26
|
+
fu.delete(ref);
|
|
27
|
+
if (Array.isArray(pool.state.events)) {
|
|
28
|
+
pool.state.events.push({ at: now, ref, reason: 'self-heal', cooldownMs: 0, type: 'heal' });
|
|
29
|
+
if (pool.state.events.length > 50) pool.state.events.shift();
|
|
30
|
+
}
|
|
31
|
+
healed.push({ ref, poolBase: pool.base });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return healed;
|
|
35
|
+
}
|