@goodandready/dsh-key-rotation 0.8.12 → 0.8.14
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 +216 -0
- package/lib/budget-monitor.js +123 -0
- package/lib/client.js +107 -82
- package/lib/index.js +197 -559
- package/lib/lifecycle.js +122 -0
- package/lib/logger.js +13 -0
- package/lib/ops-status.js +1 -1
- package/lib/ops-webhook.js +3 -3
- package/lib/plugin-updater.js +7 -0
- package/lib/pool-builder.js +86 -0
- package/lib/pool.js +3 -0
- package/lib/rotate.js +10 -7
- package/lib/sandbox-service.js +55 -0
- package/package.json +4 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@goodandready/dsh-key-rotation` are documented here.
|
|
4
|
+
User-facing feature notes also appear in README (en is source of truth).
|
|
5
|
+
|
|
6
|
+
## 0.8.14 - 2026-09-19
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- **Critical fix: switchable error rotation crash (#324, GitHub #3 / PR #4)**: `isSwitchableError()` in `lib/pool.js` now normalizes `switchCodes` to a `Set` when passed as an array. Previously, runtime schema arrays (`cfg.switchCodes`, `DEFAULT_SWITCH_CODES`) caused `TypeError: switchCodes.has is not a function`, crashing turns on rate limits, quota exhaustion, and 5xx errors instead of rotating keys.
|
|
10
|
+
- **Critical fix: settings UI render crash (#324, GitHub #2 / PR #5)**: declared `UpdaterSection` at factory scope in `lib/client.js` before `KeyRotationSection`, removing the `apply(ctx)`-local declaration that caused `ReferenceError: UpdaterSection is not defined`.
|
|
11
|
+
- **Security hardening: packageSpec validation (#324, GitHub #6)**: added strict package spec regex validation in `lib/plugin-updater.js` `installExact()` before child process invocation, preventing command-option injection.
|
|
12
|
+
- **Settings reachable again**: the card registered into `settings.plugin.item`, a
|
|
13
|
+
slot the current DSH core (0.1.6-alpha.2) no longer renders, so the plugin's
|
|
14
|
+
settings were unreachable. The surface now registers into the Plugins page row
|
|
15
|
+
seat `plugins.row.config` first, keyed
|
|
16
|
+
`@goodandready/dsh-key-rotation#dsh-key-rotation` (`rowConfigKey(package, rowId)`):
|
|
17
|
+
the plugin's row gains a configure control whose page is the settings form
|
|
18
|
+
(`view: 'page'`, open and without our card chrome — the host page draws the title,
|
|
19
|
+
icon, crumb and padding) plus a one-line state for `view: 'summary'`. The legacy
|
|
20
|
+
seat stays registered as a fallback for older cores.
|
|
21
|
+
|
|
22
|
+
Version bump and public release are intentionally held back for now: the feature
|
|
23
|
+
work on `feat/issue-303-v089-evolution` is still in flight, so this change lands in
|
|
24
|
+
`main` without a release.
|
|
25
|
+
|
|
26
|
+
## 0.8.13 - 2026-09-17
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- **Server Decomposition (#312)**: `lib/index.js` decomposed from 924 to ~562 lines to meet the 600-line plugin authoring threshold. Extracted modular services into `lib/logger.js`, `lib/sandbox-service.js`, `lib/budget-monitor.js`, `lib/pool-builder.js`, and `lib/lifecycle.js`. Public exports and runtime behavior are 100% preserved.
|
|
30
|
+
- **Logging Alignment**: replaced all runtime `console.warn` calls across server modules (`lib/index.js`, `lib/rotate.js`, `lib/ops-status.js`, `lib/ops-webhook.js`) with Cordis scoped logger (`ctx.logger('key-rotation')` / `getLogger(ctx)`).
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- **Preflight & Theme Conformance (#312)**:
|
|
34
|
+
- Client UI: eliminated 11 hardcoded fallback hex color literals from `lib/client.js`, strictly adopting `--dsw-alias-state-*` tokens.
|
|
35
|
+
- Client UI: requested `IconChevronDownOutline14` in module header to satisfy preflight icon contract.
|
|
36
|
+
- HTTP Bridge: added strict HTTP method guard (`GET`, `PUT`, `DELETE`, `OPTIONS`) on `/api/dsh-key-rotation/config` write-capable routes.
|
|
37
|
+
- Preflight score improved to `FAIL: 0, WARN: 1` (only `lib/client.js` exceeding 600 lines as documented single-file browser bundle).
|
|
38
|
+
|
|
39
|
+
### Verification
|
|
40
|
+
- unit: 375 pass / 0 fail / 1 skipped
|
|
41
|
+
- `bash preflight.sh`: FAIL: 0, WARN: 1
|
|
42
|
+
|
|
43
|
+
## 0.8.12 - 2026-09-16
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
- **Internal decompose (#312)**: operational HTTP routes split from a single `lib/routes-ops.js` facade into focused modules (`lib/ops-status.js`, `ops-telemetry.js`, `ops-keys.js`, `ops-test.js`, `ops-webhook.js`, shared `lib/ops-paths.js`). Public entry `registerOpsRoutes` is unchanged.
|
|
47
|
+
- Notify helpers (`notifySwitch`, `pushEvent`, `notifyExhaustion`) extracted to `lib/notify-events.js`; `lib/index.js` re-exports the public pair.
|
|
48
|
+
- `lib/client.js` intentionally remains a single ModuleLoader factory (no bundler contract); pure helpers stay in `lib/client-helpers.js`.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
- `stability-hardening` inject mock now provides `sctx.get` so the settings registration path runs when the schemastery peer is present.
|
|
52
|
+
|
|
53
|
+
### Notes
|
|
54
|
+
- No user-facing behavior change: same HTTP paths, same rotation/notify semantics, same settings card.
|
|
55
|
+
- `lib/index.js` `apply()` remains a single cordis wiring unit by design.
|
|
56
|
+
|
|
57
|
+
### Packaging
|
|
58
|
+
- New runtime modules under `lib/` are included via existing `files: ["lib", ...]`.
|
|
59
|
+
|
|
60
|
+
### Verification
|
|
61
|
+
- unit: 377 pass / 0 fail
|
|
62
|
+
- PR #319 merged to main (`4d78853`)
|
|
63
|
+
|
|
64
|
+
## 0.8.11 - 2026-09-16
|
|
65
|
+
|
|
66
|
+
### Added
|
|
67
|
+
- **One-click plugin updater** (#307): host endpoint `GET/POST /api/dsh-key-rotation/update` and settings UI section to check the latest npm version and install it via the standard `dsh plugin add` path. POST is loopback + same-origin gated; GET exposes version metadata only.
|
|
68
|
+
- `lib/best-effort.js` helper for intentional non-critical side effects (sync + async, debug log, never throws) (#315).
|
|
69
|
+
- `dsh.client.inject` declares `@deepseek-ai/dsh-client-locale` and `@deepseek-ai/dsh-client-ui-settings` (#313).
|
|
70
|
+
- Unit tests: `test/plugin-updater-307.test.mjs`, `test/best-effort.test.mjs`.
|
|
71
|
+
|
|
72
|
+
### Changed
|
|
73
|
+
- Client styles use theme tokens via `color-mix` / `--krot_chart-*` instead of hard-coded rgba/hex (#311).
|
|
74
|
+
- Cyrillic comments in `lib/client.js` translated to English; locale note documents en+zh (#309).
|
|
75
|
+
- DESIGN.md locks: ModuleLoader single-file client constraint; short cordis `export const name` vs scoped npm identity; bestEffort policy (#312, #315).
|
|
76
|
+
|
|
77
|
+
### Fixed
|
|
78
|
+
- Empty `catch` blocks that swallowed persistence, circuit-reset, webhook-callback and UI errors now go through `bestEffort` with debug logging (#315).
|
|
79
|
+
- Production-path tests retargeted after removal of dead exports (`isSoftFailure`, `TokenBucketAccumulator`, etc.) and `test/bucket-o1.test.mjs` (#314).
|
|
80
|
+
|
|
81
|
+
### Removed
|
|
82
|
+
- Internal agent files (`AGENTS.md`, `index.md`, `docs/plans/*`) from the git publication set and npm pack (#308).
|
|
83
|
+
- Stale release `.tgz` artifacts from the DEV tree (#310).
|
|
84
|
+
|
|
85
|
+
### Packaging
|
|
86
|
+
- `npm pack`: 34 files, max file ~105KB (`lib/client.js`), no AGENTS/index/docs/plans/openwiki.
|
|
87
|
+
|
|
88
|
+
### Verification
|
|
89
|
+
- unit: 248 pass / 0 fail / 1 skip
|
|
90
|
+
- preflight: empty-catch FAIL cleared; remaining name FAIL is accepted short cordis id (image-gen convention)
|
|
91
|
+
- MiniPC test contour: status 200, updater GET 200, POST foreign/missing origin 403, cleanup OK
|
|
92
|
+
- Production candidate (temporary tgz): status 200, providers=2 keys=7 present=7, updater GET 200, POST 403 on non-local origin
|
|
93
|
+
|
|
94
|
+
## 0.8.8 - 2026-09-12
|
|
95
|
+
|
|
96
|
+
- feat(ui): add dedicated `.krot-header` with live status badges (pools count, keys configured, circuit state, health score) and subtitle matching `dsh-clinebot` styling (#301, #302)
|
|
97
|
+
- fix(ops): resolve `ReferenceError: quotaStore is not defined` in `GET /dsh-key-rotation/health` by passing dependency from `index.js` and adding defensive invocation (#301, #302)
|
|
98
|
+
- fix(ops): import `isLoopbackAddress` in `routes-ops.js` for local loopback verification without Origin header (#301, #302)
|
|
99
|
+
- fix(ops): guard `lastTestCache.snapshot` in `GET /dsh-key-rotation/sandbox-cache` against Map and mock instances (#301, #302)
|
|
100
|
+
- fix(ops): synchronize provider reset in `POST /dsh-key-rotation/webhook-action` with `RESET_PATH` to clear `authFailCounts`, reset `switches`, and reset `circuitBreaker` (#301, #302)
|
|
101
|
+
- fix(ops): single-key reset in `POST /dsh-key-rotation/reset` now checks and clears `brokenUntil` and `authFailCounts` even when cooldown has elapsed (#301, #302)
|
|
102
|
+
- fix(ops): clean up orphan entries from `poolState` and `lastTestCache` upon key deletion via `DELETE /dsh-key-rotation/key` (#301, #302)
|
|
103
|
+
- test: add comprehensive test suites `test/routes-ops-comprehensive.test.mjs` (12 tests) and `test/http-bridge-config.test.mjs` (5 tests), increasing overall test coverage to 89.40% (359 total unit tests) (#301, #302)
|
|
104
|
+
|
|
105
|
+
## 0.8.7 - 2026-09-12
|
|
106
|
+
|
|
107
|
+
- fix(client): resolve `t is not defined` ReferenceError during client bundle factory loading (#285)
|
|
108
|
+
- test: add automated VM execution test for client module loader factory and exports (`test/client-factory-import.test.mjs`)
|
|
109
|
+
|
|
110
|
+
## 0.8.6 - 2026-09-11
|
|
111
|
+
|
|
112
|
+
- feat(ops): add e2e failover harness covering multi-key pool exhaustion and recovery (#285)
|
|
113
|
+
- feat(ops): add chaos concurrency tests for parallel rotate/cooldown/reset races (#286)
|
|
114
|
+
- feat(ops): persist pool and circuit-breaker state across restarts via `lib/persistence.js` (#287)
|
|
115
|
+
- fix(ops): resolve persistence path from `persistencePath` → `DSH_HOME` → `cwd`; do not read `ctx.baseDir` (#287, #299)
|
|
116
|
+
- feat(ops): sanitize status snapshots — key values never leave the host (#288)
|
|
117
|
+
- feat(ui): accessible modal (dialog role, focus trap, Escape, focus restore) (#289)
|
|
118
|
+
- feat(ui): loading/error/unavailable/empty card states (#290)
|
|
119
|
+
- feat(ui): keyboard navigation and bulk key removal with confirmation (#291)
|
|
120
|
+
- fix(locale): complete en locale coverage for new UI strings (#292)
|
|
121
|
+
- docs: refresh `index.md` coverage matrix and pack policy (#293, #296)
|
|
122
|
+
- docs: document ModuleLoader single-file client constraint and helper split (#294)
|
|
123
|
+
- chore: branch/worktree hygiene audit recorded (#295)
|
|
124
|
+
|
|
125
|
+
## 0.8.5 - 2026-09-10
|
|
126
|
+
|
|
127
|
+
- feat(ui): add interactive key load distribution bar chart (`.krot-load-chart`, `.krot-load-bar`, `.krot-load-segment`) displaying proportional request volume per key (#283, #284)
|
|
128
|
+
- feat(ui): add modal action confirmation dialog (`.krot-modal`, `.krot-modal-card`) for pool cooldown resets and provider/key deletions (#283, #284)
|
|
129
|
+
- fix(ops): activate jitter (`applyJitter`) on 429 and 5xx backoff calculations in `lib/rotate.js` and `lib/index.js` to eliminate the thundering herd retry spike (#283, #284)
|
|
130
|
+
- fix(ops): synchronously reset provider `circuitBreaker` on `POST /dsh-key-rotation/reset` ops route (#283, #284)
|
|
131
|
+
- test: add dedicated Phase 2 test suite `test/stability-phase2-283.test.mjs` (313 total unit tests passing) (#283, #284)
|
|
132
|
+
|
|
133
|
+
## 0.8.4 - 2026-09-10
|
|
134
|
+
|
|
135
|
+
- fix(ui): inject `settingsScope` in client manifest and safely guard context property access to prevent ErrorBoundary crash on card mount (#281)
|
|
136
|
+
|
|
137
|
+
## 0.8.3 - 2026-09-10
|
|
138
|
+
|
|
139
|
+
- feat(ui): unify settings card styling with `dsh-clinebot` design baseline (`.krot-section-card`, `.krot-stat-box`, `.krot-badge-ok/warn/bad`, `.krot-btn-primary/danger`, 36px inputs with brand focus, `data-dsh-plugin="dsh-key-rotation"` style isolation) (#281, #282)
|
|
140
|
+
- feat(ui): add live pool health telemetry stat boxes (configured pools, total keys, healthy/ready keys) (#281)
|
|
141
|
+
- feat(ui): replace hardcoded hex colors with semantic DSH design system tokens (`--dsw-alias-state-*`, `--dsw-alias-bg-*`) for full dark/light theme fidelity (#281)
|
|
142
|
+
- test: add comprehensive stability coverage test suite (`test/stability-coverage-281.test.mjs`, 18 new unit tests covering quota windows, clock monotonicity, sandbox cache, pool network guards, and http bridge helpers) (#281)
|
|
143
|
+
|
|
144
|
+
## 0.8.2 - 2026-09-10
|
|
145
|
+
|
|
146
|
+
- fix(ui): settings live only as `settings.plugin.item` card — fallback `settings.section` sidebar row removed (#275)
|
|
147
|
+
- fix: resolve DSH services via `ctx.get('llm')` / `sctx.get('settings')` instead of bare context properties that proxy to `undefined` (#275)
|
|
148
|
+
- fix(locale): source strings are English-only (`ctx.locale.register(NS, { en })`); other languages come from host `props.t` / translation plugins (#277, GitHub #1)
|
|
149
|
+
- fix(locale): active locale falls back `ctx.locale` snapshot → first `navigator.languages` entry → `en` (core-aligned) (#277)
|
|
150
|
+
- test: card-only, `ctx.get` and full Cyrillic-in-literal locale gates
|
|
151
|
+
|
|
152
|
+
## 0.8.1 - 2026-09-09
|
|
153
|
+
|
|
154
|
+
- fix(ui): settings card no longer disappears on open — ErrorBoundary around the section and referentially stable `settingsScope.getSnapshot()` (#273)
|
|
155
|
+
|
|
156
|
+
## 0.8.0 - 2026-09-09
|
|
157
|
+
|
|
158
|
+
Stability block (#260–#270). Changed in v0.8.0.
|
|
159
|
+
|
|
160
|
+
- feat: per-provider **circuit breaker** (`circuitBreakerEnabled`, threshold/openMs/halfOpenProbes) — fail fast while a provider is down (#260)
|
|
161
|
+
- fix: **monotonic process clock** (`performance.timeOrigin + performance.now`) for cooldowns/durations so NTP jumps do not corrupt remaining times (#261)
|
|
162
|
+
- perf: **BoundedMap** (max + TTL + LRU) foundation for usage/notify maps (#262)
|
|
163
|
+
- fix: **non-blocking webhook notify** via bounded NotifyQueue with backoff — rotate() never awaits webhook I/O (#263)
|
|
164
|
+
- feat: **atomic file I/O helpers** (`atomicWriteFile`/`safeReadJson`/`safeParseJson`) — corrupt JSON never wipes previous state (#264)
|
|
165
|
+
- fix: in-stream failover remains safe across reload; `quotaStore` properly injected into rotate (#265)
|
|
166
|
+
- feat: **clone-route GC** — `expectedClones` computed from live providers; orphans dropped from runtime set (#266)
|
|
167
|
+
- test: **error taxonomy table** — 408/425/429/5xx, ECONNRESET/ETIMEDOUT, gRPC codes classified switch|surface|cooldown (#267)
|
|
168
|
+
- fix: status API returns **single snapshot** with `circuit`, `meta.expectedClones`, `meta.notifyQueue` (#268)
|
|
169
|
+
- test: **smoke harness** `test/smoke-rotation-080.test.mjs` — mock 429 → key switch → success (#269)
|
|
170
|
+
- docs: full 0.8.0 documentation package (README en/ru/zh config tables, DESIGN taxonomy, index test matrix) (#270)
|
|
171
|
+
|
|
172
|
+
## 0.7.40 - 2026-09-09
|
|
173
|
+
|
|
174
|
+
- docs: align README en/ru/zh and DESIGN.md with shipped surface after v0.7.36 de-bloat (#251)
|
|
175
|
+
- docs: add project `index.md` and `AGENTS.md` (#252)
|
|
176
|
+
- refactor: split `lib/index.js` into `rotate.js`, `http-bridge.js`, `routes-ops.js` (#253)
|
|
177
|
+
- feat: `verboseLogging` config (default false) gates per-request rotation logs (#254)
|
|
178
|
+
- docs: CHANGELOG history and task_plan hygiene (#255)
|
|
179
|
+
|
|
180
|
+
## 0.7.39 - 2026-08-28
|
|
181
|
+
|
|
182
|
+
- fix(heal): wire `lastUsedAt` into `credentials.resolve` and idle self-heal sweep; cache sweep runtime (issue #249, PR #250)
|
|
183
|
+
- perf: fewer redundant `buildRuntime()` calls on hot path
|
|
184
|
+
|
|
185
|
+
## 0.7.38 - 2026-08-28
|
|
186
|
+
|
|
187
|
+
- perf(hotpath): single-pass `extractRateLimit`, memoized `todayIso`, zero-allocation status totals, purge stale notify maps (issue #246)
|
|
188
|
+
|
|
189
|
+
## 0.7.37 - 2026-08-28
|
|
190
|
+
|
|
191
|
+
- fix(concurrency): AsyncLocalStorage request isolation for `pickedRef`; stream-exception failover before first token; copy `attemptList`; wire 30-day usage compaction; clear quarantine after successful sandbox probe (issue #243)
|
|
192
|
+
|
|
193
|
+
## 0.7.36 - 2026-08-27
|
|
194
|
+
|
|
195
|
+
- refactor: remove overengineered modules (`shadow`, `incident`, `agent-budget`, `region`, `canary`, `maintenance`)
|
|
196
|
+
- perf: memoize `buildRuntime()`, atomic pointer round-robin, HTTP/gRPC error detection, exhaustion ETA, smart visibility polling
|
|
197
|
+
|
|
198
|
+
## 0.7.34 - 2026-08-27
|
|
199
|
+
|
|
200
|
+
- fix(authoring): lifecycle dispose for credentials.resolve / stream listeners; secret role masking; settingsScope integration (PR #241)
|
|
201
|
+
|
|
202
|
+
## 0.7.33 - 2026-08-27
|
|
203
|
+
|
|
204
|
+
- fix: key probing baseURL via pool owner; cascade recursion guard; midnight PST sign; timer cleanup via cordis effect; stale lock recovery in least-loaded balancer; zh locale
|
|
205
|
+
|
|
206
|
+
## 0.7.31 - 2026-08-27
|
|
207
|
+
|
|
208
|
+
- perf: O(1) token bucket; soft vs hard backoff; penalty decay; cooldown jitter; canary probing (later removed in 0.7.36); TTFT percentiles; webhook digest; 30-day usage compaction; optimistic UI + filter pills
|
|
209
|
+
|
|
210
|
+
## 0.7.20 - 2026-08-27
|
|
211
|
+
|
|
212
|
+
- fix(client): hoist `h` to factory scope for KeyRotationCard (#155)
|
|
213
|
+
|
|
214
|
+
## Earlier
|
|
215
|
+
|
|
216
|
+
See Gitea release history and README changelog sections for pre-0.7.20 work.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// lib/budget-monitor.js — periodic budget, expiry, low health and SLO alerts
|
|
2
|
+
import { expiringSoon, shouldNotifyDaily, costForDay, costForWeek, budgetVerdict } from './pool.js';
|
|
3
|
+
|
|
4
|
+
const DAY_MS = 86400000;
|
|
5
|
+
|
|
6
|
+
export function checkBudgetAndHealthAlerts({
|
|
7
|
+
runtime,
|
|
8
|
+
poolState,
|
|
9
|
+
now,
|
|
10
|
+
expiryNotifiedAt,
|
|
11
|
+
budgetNotifiedAt,
|
|
12
|
+
lowHealthNotifiedAt,
|
|
13
|
+
sloNotifiedAt,
|
|
14
|
+
latencyHistogram,
|
|
15
|
+
webhookSender,
|
|
16
|
+
logger,
|
|
17
|
+
}) {
|
|
18
|
+
try {
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
for (const pool of runtime.poolByRef.values()) {
|
|
21
|
+
if (seen.has(pool.base)) continue;
|
|
22
|
+
seen.add(pool.base);
|
|
23
|
+
// #207: keys expiring within expiryWarnDays -> one webhook per key/day
|
|
24
|
+
for (const { ref, expiresInDays } of expiringSoon(pool, runtime.expiryWarnDays, now)) {
|
|
25
|
+
if (!shouldNotifyDaily(expiryNotifiedAt, pool.base + ':' + ref, now)) continue;
|
|
26
|
+
logger?.warn?.(`[dsh-key-rotation] ${pool.base}: key ${ref} expires in ~${expiresInDays}d`);
|
|
27
|
+
if (runtime.notifyWebhook) {
|
|
28
|
+
webhookSender.send(runtime.notifyWebhook, {
|
|
29
|
+
title: `Key expiring soon: ${pool.base}`,
|
|
30
|
+
text: `${ref} expires in ~${expiresInDays} day(s)`,
|
|
31
|
+
provider: pool.base,
|
|
32
|
+
kind: 'expiry',
|
|
33
|
+
keys: [ref],
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// #208: daily/weekly budget -> warn webhook, optional 1-day pause at 100%
|
|
38
|
+
const budget = runtime.providerBudgets.get(pool.base);
|
|
39
|
+
if (!budget) continue;
|
|
40
|
+
const daily = costForDay(pool.state.costDays);
|
|
41
|
+
const weekly = costForWeek(pool.state.costDays, now);
|
|
42
|
+
const verdict = budgetVerdict(daily, budget.costBudgetDaily);
|
|
43
|
+
const wVerdict = budgetVerdict(weekly, budget.costBudgetWeekly);
|
|
44
|
+
const hit = verdict.warn || wVerdict.warn;
|
|
45
|
+
if (hit && shouldNotifyDaily(budgetNotifiedAt, pool.base + ':budget', now)) {
|
|
46
|
+
logger?.warn?.(`[dsh-key-rotation] ${pool.base}: cost budget - day $${daily.toFixed(2)}/$${budget.costBudgetDaily} week $${weekly.toFixed(2)}/$${budget.costBudgetWeekly}`);
|
|
47
|
+
if (runtime.notifyWebhook) {
|
|
48
|
+
// #217: budget webhook gains action buttons when a callback token is configured
|
|
49
|
+
const token = runtime.webhookActionToken ?? '';
|
|
50
|
+
webhookSender.send(runtime.notifyWebhook, {
|
|
51
|
+
title: `Cost budget: ${pool.base}`,
|
|
52
|
+
text: `day $${daily.toFixed(2)} of $${budget.costBudgetDaily} · week $${weekly.toFixed(2)} of $${budget.costBudgetWeekly}` + (verdict.exceeded || wVerdict.exceeded ? ' · EXCEEDED' : ''),
|
|
53
|
+
provider: pool.base,
|
|
54
|
+
kind: 'budget',
|
|
55
|
+
spend: { daily, weekly },
|
|
56
|
+
actionToken: token || undefined,
|
|
57
|
+
actions: token ? [
|
|
58
|
+
{ id: `pause-${pool.base}`, label: 'Pause 1h' },
|
|
59
|
+
{ id: `reset-${pool.base}`, label: 'Reset cooldown' },
|
|
60
|
+
] : undefined,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if ((verdict.exceeded || wVerdict.exceeded) && budget.pauseOnBudget) {
|
|
65
|
+
const until = now + DAY_MS;
|
|
66
|
+
for (const ref of pool.refs) {
|
|
67
|
+
if ((pool.state.failedUntil.get(ref) ?? 0) < until) pool.state.failedUntil.set(ref, until);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// #221: pool running low - webhook while healthy < warnBelowHealthy
|
|
71
|
+
const warnBelow = runtime.warnBelowHealthy ?? 0;
|
|
72
|
+
if (warnBelow > 0) {
|
|
73
|
+
let healthy = 0;
|
|
74
|
+
for (const ref of pool.refs) {
|
|
75
|
+
const fu = pool.state.failedUntil.get(ref);
|
|
76
|
+
if (fu !== undefined && fu > now) continue;
|
|
77
|
+
const exp = pool.expiresAt?.[ref];
|
|
78
|
+
if (exp !== undefined && now >= exp) continue;
|
|
79
|
+
healthy++;
|
|
80
|
+
}
|
|
81
|
+
if (healthy < warnBelow && shouldNotifyDaily(lowHealthNotifiedAt, pool.base, now)) {
|
|
82
|
+
logger?.warn?.(`[dsh-key-rotation] ${pool.base}: pool running low - ${healthy}/${pool.refs.length} healthy`);
|
|
83
|
+
if (runtime.notifyWebhook) {
|
|
84
|
+
const token = runtime.webhookActionToken ?? '';
|
|
85
|
+
webhookSender.send(runtime.notifyWebhook, {
|
|
86
|
+
title: `Pool running low: ${pool.base}`,
|
|
87
|
+
text: `${healthy}/${pool.refs.length} keys healthy (alert below ${warnBelow})`,
|
|
88
|
+
provider: pool.base,
|
|
89
|
+
kind: 'low-health',
|
|
90
|
+
healthy,
|
|
91
|
+
total: pool.refs.length,
|
|
92
|
+
actionToken: token || undefined,
|
|
93
|
+
actions: token ? [{ id: `reset-${pool.base}`, label: 'Reset cooldown' }] : undefined,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// #225: latency SLO - webhook when a key's p95 exceeds the threshold
|
|
99
|
+
const slo = runtime.latencySloMs ?? 0;
|
|
100
|
+
if (slo > 0) {
|
|
101
|
+
for (const ref of pool.refs) {
|
|
102
|
+
const snap = latencyHistogram.snapshot(ref);
|
|
103
|
+
if (!snap.p95 || snap.p95 <= slo) continue;
|
|
104
|
+
if (!shouldNotifyDaily(sloNotifiedAt, pool.base + ':' + ref + ':slo', now)) continue;
|
|
105
|
+
logger?.warn?.(`[dsh-key-rotation] ${pool.base}: ${ref} p95 ${Math.round(snap.p95)}ms > SLO ${slo}ms`);
|
|
106
|
+
if (runtime.notifyWebhook) {
|
|
107
|
+
webhookSender.send(runtime.notifyWebhook, {
|
|
108
|
+
title: `Latency SLO exceeded: ${pool.base}`,
|
|
109
|
+
text: `${ref} p95 ${Math.round(snap.p95)}ms > ${slo}ms (${snap.count} samples)`,
|
|
110
|
+
provider: pool.base,
|
|
111
|
+
kind: 'latency-slo',
|
|
112
|
+
ref,
|
|
113
|
+
p95: Math.round(snap.p95),
|
|
114
|
+
slo,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch (_) {
|
|
121
|
+
/* maintenance must never crash the sweep */
|
|
122
|
+
}
|
|
123
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -21,8 +21,21 @@ window.__ModuleLoader__.load({
|
|
|
21
21
|
const React = require('react');
|
|
22
22
|
const h = React.createElement;
|
|
23
23
|
|
|
24
|
+
// Core primitives and chevron icon
|
|
25
|
+
let ChevronIcon = null;
|
|
26
|
+
try {
|
|
27
|
+
const primitives = require('@deepseek-ai/dsh-client-primitives') || require('@deepseek-ai/dsh-client-icons');
|
|
28
|
+
ChevronIcon = primitives && (primitives.IconChevronDownOutline14 || primitives.IconChevronDownOutline);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
ChevronIcon = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
24
33
|
const CONFIG_PATH = '/dsh-key-rotation/config';
|
|
25
34
|
const NS = 'dsh-key-rotation';
|
|
35
|
+
// Plugins page row seat (DSH 0.1.6-alpha.2): key = '<package name>#<row id>'.
|
|
36
|
+
const PKG = '@goodandready/dsh-key-rotation';
|
|
37
|
+
const ROW_ID = 'dsh-key-rotation';
|
|
38
|
+
const ROW_CONFIG_KEY = PKG + '#' + ROW_ID;
|
|
26
39
|
|
|
27
40
|
// -------------------------------------------------------------- i18n
|
|
28
41
|
const en = {
|
|
@@ -413,7 +426,7 @@ window.__ModuleLoader__.load({
|
|
|
413
426
|
'.krot-page-sub{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5}',
|
|
414
427
|
'.krot p{margin:0}',
|
|
415
428
|
'.krot-hint{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.4}',
|
|
416
|
-
'.krot-err{font-size:13px;color:var(--dsw-alias-state-error-primary);padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary
|
|
429
|
+
'.krot-err{font-size:13px;color:var(--dsw-alias-state-error-primary);padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);display:flex;align-items:center;gap:8px}',
|
|
417
430
|
'.krot-label{font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary)}',
|
|
418
431
|
'.krot-field{display:flex;flex-direction:column;gap:6px}',
|
|
419
432
|
'.krot-in{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;height:36px;font-family:inherit;min-width:0;width:100%;box-sizing:border-box;transition:border-color .15s ease}',
|
|
@@ -436,15 +449,15 @@ window.__ModuleLoader__.load({
|
|
|
436
449
|
'.krot-load-legend{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px;font-size:11px}',
|
|
437
450
|
'.krot-load-item{display:inline-flex;align-items:center;gap:4px}',
|
|
438
451
|
'.krot-load-dot{width:7px;height:7px;border-radius:50%}',
|
|
439
|
-
'.krot-modal-backdrop{position:fixed;top:0;left:0;right:0;bottom:0;background:var(--dsw-alias-bg-overlay, color-mix(in srgb, var(--dsw-alias-bg-layer-1
|
|
440
|
-
'.krot-modal-card{width:90%;max-width:420px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 12px 36px color-mix(in srgb, var(--dsw-alias-bg-layer-1
|
|
452
|
+
'.krot-modal-backdrop{position:fixed;top:0;left:0;right:0;bottom:0;background:var(--dsw-alias-bg-overlay, color-mix(in srgb, var(--dsw-alias-bg-layer-1) 55%, transparent));z-index:9999;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(2px)}',
|
|
453
|
+
'.krot-modal-card{width:90%;max-width:420px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 12px 36px color-mix(in srgb, var(--dsw-alias-bg-layer-1) 28%, transparent);padding:20px;display:flex;flex-direction:column;gap:12px}',
|
|
441
454
|
'.krot-modal-title{font-size:16px;font-weight:600;color:var(--dsw-alias-label-primary)}',
|
|
442
455
|
'.krot-modal-desc{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.45}',
|
|
443
456
|
'.krot-modal-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:8px}',
|
|
444
457
|
'.krot-badge{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:5px;font-weight:500;white-space:nowrap}',
|
|
445
|
-
'.krot-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary
|
|
446
|
-
'.krot-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary
|
|
447
|
-
'.krot-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary
|
|
458
|
+
'.krot-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}',
|
|
459
|
+
'.krot-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent)}',
|
|
460
|
+
'.krot-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}',
|
|
448
461
|
'.krot-prov{display:flex;flex-direction:column;gap:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-2);padding:14px 16px}',
|
|
449
462
|
'.krot-prov-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap}',
|
|
450
463
|
'.krot-prov-head select{flex:1;min-width:180px}',
|
|
@@ -462,18 +475,18 @@ window.__ModuleLoader__.load({
|
|
|
462
475
|
'.krot-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4,var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed,var(--dsw-alias-border-l2))}',
|
|
463
476
|
'.krot-btn-primary,.krot-save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent;font-weight:600}',
|
|
464
477
|
'.krot-btn-primary:hover:not(:disabled),.krot-save:hover:not(:disabled){background:var(--dsw-alias-label-primary)!important;color:var(--dsw-alias-bg-layer-3)!important;opacity:0.88}',
|
|
465
|
-
'.krot-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary
|
|
466
|
-
'.krot-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary
|
|
478
|
+
'.krot-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}',
|
|
479
|
+
'.krot-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent)!important;border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 50%, transparent)}',
|
|
467
480
|
'.krot-btn:disabled{opacity:0.45;cursor:not-allowed}',
|
|
468
481
|
'.krot-foot{display:flex;gap:10px;align-items:center;flex-wrap:wrap}',
|
|
469
482
|
'.krot-filter-bar{display:flex;gap:8px;margin:4px 0 10px;flex-wrap:wrap}',
|
|
470
483
|
'.krot-pill{appearance:none;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:4px 12px;font-size:12px;font-weight:500;color:var(--dsw-alias-label-secondary);cursor:pointer;transition:all .15s ease}',
|
|
471
484
|
'.krot-pill:hover{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}',
|
|
472
485
|
'.krot-pill-active{background:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)!important;font-weight:600}',
|
|
473
|
-
'.krot-pill-warn{border-color:color-mix(in srgb, var(--dsw-alias-state-warning-primary
|
|
474
|
-
'.krot-pill-err{border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary
|
|
475
|
-
'.krot-alert-ok{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-success-primary
|
|
476
|
-
'.krot-alert-bad{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary
|
|
486
|
+
'.krot-pill-warn{border-color:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 30%, transparent);color:var(--dsw-alias-state-warning-primary)}',
|
|
487
|
+
'.krot-pill-err{border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent);color:var(--dsw-alias-state-error-primary)}',
|
|
488
|
+
'.krot-alert-ok{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);font-size:13px;display:flex;align-items:center;gap:10px}',
|
|
489
|
+
'.krot-alert-bad{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px;display:flex;align-items:center;gap:10px}',
|
|
477
490
|
'.krot-event-stream{display:flex;flex-direction:column;gap:6px;margin-top:8px;padding:10px 12px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:8px}',
|
|
478
491
|
'.krot-event-row{display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0;border-bottom:1px solid var(--dsw-alias-border-l1)}',
|
|
479
492
|
'.krot-event-time{font-family:ui-monospace,Menlo,Consolas,monospace;color:var(--dsw-alias-label-tertiary);font-size:11px}',
|
|
@@ -487,7 +500,7 @@ window.__ModuleLoader__.load({
|
|
|
487
500
|
'.krot-card-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:16px 0 8px}',
|
|
488
501
|
'.krot-header-chip{display:inline-flex;align-items:center;gap:6px;height:26px;padding:0 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12px;font-weight:600;cursor:pointer;position:relative;user-select:none;transition:all .15s ease}',
|
|
489
502
|
'.krot-header-chip:hover{background:var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-3));border-color:var(--dsw-alias-border-l1);transform:translateY(-0.5px)}',
|
|
490
|
-
'.krot-popover{position:absolute;top:calc(100% + 6px);right:0;z-index:10000;min-width:220px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:12px 14px;box-shadow:0 16px 40px color-mix(in srgb, var(--dsw-alias-bg-layer-1
|
|
503
|
+
'.krot-popover{position:absolute;top:calc(100% + 6px);right:0;z-index:10000;min-width:220px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:12px 14px;box-shadow:0 16px 40px color-mix(in srgb, var(--dsw-alias-bg-layer-1) 45%, transparent),0 2px 8px color-mix(in srgb, var(--dsw-alias-bg-layer-1) 15%, transparent);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);display:flex;flex-direction:column;gap:8px;text-align:left}',
|
|
491
504
|
'.krot-pop-title{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--dsw-alias-label-secondary)}',
|
|
492
505
|
'.krot-pop-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:4px 0}',
|
|
493
506
|
'.krot-pop-name{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:8px}',
|
|
@@ -565,6 +578,70 @@ window.__ModuleLoader__.load({
|
|
|
565
578
|
};
|
|
566
579
|
}
|
|
567
580
|
|
|
581
|
+
function UpdaterSection({ t }) {
|
|
582
|
+
const [state, setState] = React.useState({ phase: 'idle', current: '', latest: '', message: '' });
|
|
583
|
+
const load = React.useCallback(async () => {
|
|
584
|
+
setState((s) => ({ ...s, phase: 'loading', message: '' }));
|
|
585
|
+
try {
|
|
586
|
+
const res = await fetch('/api/dsh-key-rotation/update', { headers: { 'x-dsh-plugin-update': '1' } });
|
|
587
|
+
const data = await res.json();
|
|
588
|
+
if (!res.ok) throw new Error(data?.error?.message || t('updateFailed'));
|
|
589
|
+
setState({
|
|
590
|
+
phase: data.updateAvailable ? 'available' : 'current',
|
|
591
|
+
current: data.currentVersion || '',
|
|
592
|
+
latest: data.latestVersion || '',
|
|
593
|
+
message: data.updateAvailable ? t('updateAvailable') : t('updateNone'),
|
|
594
|
+
});
|
|
595
|
+
} catch (e) {
|
|
596
|
+
setState({ phase: 'error', current: '', latest: '', message: e?.message || t('updateFailed') });
|
|
597
|
+
}
|
|
598
|
+
}, [t]);
|
|
599
|
+
React.useEffect(() => { load(); }, [load]);
|
|
600
|
+
const run = async () => {
|
|
601
|
+
setState((s) => ({ ...s, phase: 'running', message: t('updateRunning') }));
|
|
602
|
+
try {
|
|
603
|
+
const res = await fetch('/api/dsh-key-rotation/update', {
|
|
604
|
+
method: 'POST',
|
|
605
|
+
headers: { 'x-dsh-plugin-update': '1', 'content-type': 'application/json' },
|
|
606
|
+
});
|
|
607
|
+
const data = await res.json();
|
|
608
|
+
if (!res.ok) throw new Error(data?.error?.message || t('updateFailed'));
|
|
609
|
+
setState({
|
|
610
|
+
phase: 'done',
|
|
611
|
+
current: data.currentVersion || '',
|
|
612
|
+
latest: data.installedVersion || data.latestVersion || '',
|
|
613
|
+
message: t('updateRestart'),
|
|
614
|
+
});
|
|
615
|
+
} catch (e) {
|
|
616
|
+
setState({ phase: 'error', current: '', latest: '', message: e?.message || t('updateFailed') });
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
return h('div', { className: 'krot-update', style: { marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--dsw-alias-border-l2)' } },
|
|
620
|
+
h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
|
|
621
|
+
h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
|
|
622
|
+
t('currentVersion') + ': ' + (state.current || '—')),
|
|
623
|
+
state.latest ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
|
|
624
|
+
t('latestVersion') + ': ' + state.latest) : null,
|
|
625
|
+
h('button', {
|
|
626
|
+
className: 'krot-btn',
|
|
627
|
+
type: 'button',
|
|
628
|
+
disabled: state.phase === 'running' || state.phase === 'loading',
|
|
629
|
+
onClick: load,
|
|
630
|
+
}, t('updateCheck')),
|
|
631
|
+
state.phase === 'available' ? h('button', {
|
|
632
|
+
className: 'krot-btn krot-btn-primary',
|
|
633
|
+
type: 'button',
|
|
634
|
+
disabled: state.phase === 'running',
|
|
635
|
+
onClick: run,
|
|
636
|
+
}, t('updateRun')) : null,
|
|
637
|
+
),
|
|
638
|
+
state.message ? h('div', {
|
|
639
|
+
className: state.phase === 'error' ? 'krot-alert-bad' : state.phase === 'done' ? 'krot-alert-ok' : '',
|
|
640
|
+
style: { marginTop: 8, fontSize: 12 },
|
|
641
|
+
}, state.message) : null,
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
568
645
|
function KeyRotationSection(props) {
|
|
569
646
|
const t = resolveT(props);
|
|
570
647
|
const settingsScope = React.useMemo(() => {
|
|
@@ -1716,94 +1793,42 @@ window.__ModuleLoader__.load({
|
|
|
1716
1793
|
// (settings.plugin.item), matching Model Sync / Spendmeter / Vision Bridge.
|
|
1717
1794
|
// key MUST equal the settings namespace (NS), else the tab silently skips it.
|
|
1718
1795
|
|
|
1719
|
-
function UpdaterSection({ t }) {
|
|
1720
|
-
const [state, setState] = React.useState({ phase: 'idle', current: '', latest: '', message: '' });
|
|
1721
|
-
const load = React.useCallback(async () => {
|
|
1722
|
-
setState((s) => ({ ...s, phase: 'loading', message: '' }));
|
|
1723
|
-
try {
|
|
1724
|
-
const res = await fetch('/api/dsh-key-rotation/update', { headers: { 'x-dsh-plugin-update': '1' } });
|
|
1725
|
-
const data = await res.json();
|
|
1726
|
-
if (!res.ok) throw new Error(data?.error?.message || t('updateFailed'));
|
|
1727
|
-
setState({
|
|
1728
|
-
phase: data.updateAvailable ? 'available' : 'current',
|
|
1729
|
-
current: data.currentVersion || '',
|
|
1730
|
-
latest: data.latestVersion || '',
|
|
1731
|
-
message: data.updateAvailable ? t('updateAvailable') : t('updateNone'),
|
|
1732
|
-
});
|
|
1733
|
-
} catch (e) {
|
|
1734
|
-
setState({ phase: 'error', current: '', latest: '', message: e?.message || t('updateFailed') });
|
|
1735
|
-
}
|
|
1736
|
-
}, [t]);
|
|
1737
|
-
React.useEffect(() => { load(); }, [load]);
|
|
1738
|
-
const run = async () => {
|
|
1739
|
-
setState((s) => ({ ...s, phase: 'running', message: t('updateRunning') }));
|
|
1740
|
-
try {
|
|
1741
|
-
const res = await fetch('/api/dsh-key-rotation/update', {
|
|
1742
|
-
method: 'POST',
|
|
1743
|
-
headers: { 'x-dsh-plugin-update': '1', 'content-type': 'application/json' },
|
|
1744
|
-
});
|
|
1745
|
-
const data = await res.json();
|
|
1746
|
-
if (!res.ok) throw new Error(data?.error?.message || t('updateFailed'));
|
|
1747
|
-
setState({
|
|
1748
|
-
phase: 'done',
|
|
1749
|
-
current: data.currentVersion || '',
|
|
1750
|
-
latest: data.installedVersion || data.latestVersion || '',
|
|
1751
|
-
message: t('updateRestart'),
|
|
1752
|
-
});
|
|
1753
|
-
} catch (e) {
|
|
1754
|
-
setState({ phase: 'error', current: '', latest: '', message: e?.message || t('updateFailed') });
|
|
1755
|
-
}
|
|
1756
|
-
};
|
|
1757
|
-
return h('div', { className: 'krot-update', style: { marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--dsw-alias-border-l2)' } },
|
|
1758
|
-
h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
|
|
1759
|
-
h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
|
|
1760
|
-
t('currentVersion') + ': ' + (state.current || '—')),
|
|
1761
|
-
state.latest ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
|
|
1762
|
-
t('latestVersion') + ': ' + state.latest) : null,
|
|
1763
|
-
h('button', {
|
|
1764
|
-
className: 'krot-btn',
|
|
1765
|
-
type: 'button',
|
|
1766
|
-
disabled: state.phase === 'running' || state.phase === 'loading',
|
|
1767
|
-
onClick: load,
|
|
1768
|
-
}, t('updateCheck')),
|
|
1769
|
-
state.phase === 'available' ? h('button', {
|
|
1770
|
-
className: 'krot-btn krot-btn-primary',
|
|
1771
|
-
type: 'button',
|
|
1772
|
-
disabled: state.phase === 'running',
|
|
1773
|
-
onClick: run,
|
|
1774
|
-
}, t('updateRun')) : null,
|
|
1775
|
-
),
|
|
1776
|
-
state.message ? h('div', {
|
|
1777
|
-
className: state.phase === 'error' ? 'krot-alert-bad' : state.phase === 'done' ? 'krot-alert-ok' : '',
|
|
1778
|
-
style: { marginTop: 8, fontSize: 12 },
|
|
1779
|
-
}, state.message) : null,
|
|
1780
|
-
);
|
|
1781
|
-
}
|
|
1782
|
-
|
|
1783
1796
|
function KeyRotationCard(props) {
|
|
1784
1797
|
const locale = useLocale();
|
|
1785
1798
|
const t = resolveT(props);
|
|
1786
|
-
const
|
|
1787
|
-
|
|
1788
|
-
|
|
1799
|
+
const page = !!(props && props.view === 'page');
|
|
1800
|
+
const [open, setOpen] = React.useState(!!page);
|
|
1801
|
+
// Row seat (plugins.row.config): the host page draws title/icon/crumb and the
|
|
1802
|
+
// padding, so the summary is a one-liner and the page drops our card chrome.
|
|
1803
|
+
if (props && props.view === 'summary') {
|
|
1804
|
+
return h('span', { className: 'krot-card-description' }, t('subtitle'));
|
|
1805
|
+
}
|
|
1806
|
+
return h(page ? 'div' : 'div', { className: page ? 'krot-page' : 'krot-card' + (open ? ' krot-card-open' : '') },
|
|
1807
|
+
h('button', { type: 'button', className: 'krot-card-header', style: page ? { display: 'none' } : undefined, 'aria-expanded': page ? true : open, onClick: () => setOpen((v) => !v) },
|
|
1789
1808
|
h('span', { className: 'krot-card-head-text' },
|
|
1790
1809
|
h('span', { className: 'krot-card-name' }, t('title')),
|
|
1791
1810
|
h('span', { className: 'krot-card-description' }, t('subtitle'))),
|
|
1792
1811
|
h('span', { className: 'krot-card-chevron' + (open ? ' krot-card-chevron-open' : ''), 'aria-hidden': 'true' },
|
|
1793
1812
|
h('svg', { width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, style: { display: 'block' } },
|
|
1794
1813
|
h('path', { d: 'M3.5 5.25L7 8.75L10.5 5.25' })))),
|
|
1795
|
-
open ? h('div', { className: 'krot-card-body' }, h(KeyRotationErrorBoundary, null, h(KeyRotationSection, { ...props, locale }))) : null);
|
|
1814
|
+
(page || open) ? h('div', { className: 'krot-card-body' }, h(KeyRotationErrorBoundary, null, h(KeyRotationSection, { ...props, locale }))) : null);
|
|
1796
1815
|
}
|
|
1797
1816
|
// #275: card only — no top-level sidebar row. The host sidebar is a
|
|
1798
1817
|
// shared flat list; a duplicate row wastes a slot and confuses Settings.
|
|
1818
|
+
// Row seat first (the seat the current core renders), legacy seat after it.
|
|
1799
1819
|
try {
|
|
1820
|
+
ctx.slots.inject('plugins.row.config', () =>
|
|
1821
|
+
ctx.slots.register(
|
|
1822
|
+
{ name: 'plugins.row.config', key: ROW_CONFIG_KEY, locale: NS, inject: () => ({ ctx }) },
|
|
1823
|
+
KeyRotationCard,
|
|
1824
|
+
));
|
|
1800
1825
|
ctx.slots.inject('settings.plugin.item', () =>
|
|
1801
1826
|
ctx.slots.register(
|
|
1802
1827
|
{ name: 'settings.plugin.item', key: NS, locale: NS, inject: () => ({ ctx }) },
|
|
1803
1828
|
KeyRotationCard,
|
|
1804
1829
|
));
|
|
1805
1830
|
} catch (e) {
|
|
1806
|
-
bestEffort('slots.register.log', () => { console.error('[dsh-key-rotation] settings
|
|
1831
|
+
bestEffort('slots.register.log', () => { console.error('[dsh-key-rotation] settings seats register failed', e); });
|
|
1807
1832
|
}
|
|
1808
1833
|
}
|
|
1809
1834
|
|